Compare commits
18 Commits
7251c6d153
...
18740ca800
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18740ca800 | ||
|
|
85d72e4cfb | ||
|
|
58fb4a317d | ||
|
|
9ca5c593b8 | ||
|
|
6a38b19ddd | ||
|
|
65e6a19ec1 | ||
|
|
d04d5ae638 | ||
|
|
99d770b75f | ||
|
|
978073dd0e | ||
|
|
d14a1ed51f | ||
|
|
a3a1f68297 | ||
|
|
3888e0b60b | ||
|
|
f86cf26cc0 | ||
|
|
f6168fe63a | ||
|
|
d73538acad | ||
|
|
0cbfb9fe42 | ||
|
|
62c86af128 | ||
|
|
ef4d8218e9 |
91
docs/AEO_GEO_RUBRIC.md
Normal file
91
docs/AEO_GEO_RUBRIC.md
Normal file
@ -0,0 +1,91 @@
|
||||
# INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0
|
||||
|
||||
> 코드 원본: `src/data/aeoGeoRubric.ts` · 타입: `src/types/discovery.ts` · 집계: `src/lib/discoveryScore.ts`
|
||||
> 리포트 페이지: `/discovery/:id` (`src/pages/DiscoveryReportPage.tsx`)
|
||||
> 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO KDD 2024 · C-SEO Bench NeurIPS 2025)
|
||||
|
||||
## 채점 방식
|
||||
|
||||
- 항목마다 **level 0~3** (없음/치명 · 미흡 · 양호 · 우수) → `weight × level/3`
|
||||
- **미검증(unverified)** 항목은 0점이 아니라 **분모에서 제외**한다. 확인 못 한 것을 감점하면 점수가 거짓이 된다.
|
||||
- 전체 점수 = 검증 항목 획득 / 검증 항목 배점 × 100
|
||||
- 등급: A(81+) · B(61–80) · C(41–60) · D(≤40). ScoreRing 색 임계값과 동일
|
||||
- 항목마다 **근거 등급**(실측/학술/벤더/추정) · **통제 가능성**(직접/영향/불가) · **채점 방식**(자동/반자동/수동)을 명시한다.
|
||||
|
||||
## 배점 철학
|
||||
|
||||
| 원칙 | 반영 |
|
||||
|---|---|
|
||||
| "차단되면 절대 인용될 수 없다"는 논쟁의 여지가 없다 | A 접근성 20 · B 엔티티 20. 크롤러 접근에 무게 |
|
||||
| GEO 콘텐츠 전술의 효과는 학술적으로 논쟁 중 | C 콘텐츠 25. 배점은 크지만 "좋은 콘텐츠 원칙"으로만 설명. "X% 올려드립니다" 금지 |
|
||||
| 병의원 AI 인용은 강남언니(`ai-input=yes`) 등 버티컬이 100% 장악 (실측) | D1 강남언니 6점. 단일 항목 최고 배점 |
|
||||
| 네이버 자산은 글로벌 AI에 기여도 0 (robots.txt 전면 차단) | D5 네이버 플레이스는 2점, "AI 브리핑 전용"으로 한정 |
|
||||
| llms.txt는 위약 (Google 효과 0, 채택률 0.13%) | 항목에서 **제외** |
|
||||
| 개선을 증명 못 하면 상품이 아니다 | E 측정 인프라 10 |
|
||||
|
||||
## 카테고리 (100점)
|
||||
|
||||
| 코드 | 카테고리 | 배점 | 무엇을 보나 |
|
||||
|---|---|---|---|
|
||||
| A | AI 크롤러 접근성 | 20 | 검색봇 허용(학습봇과 구분) · CDN 차단 · SSR · HTTPS · sitemap · Bing 색인 · 속도 |
|
||||
| B | 엔티티 식별·구조화 | 20 | JSON-LD 유효성 · MedicalClinic · Physician · NAP 일관성 · sameAs · canonical · hreflang |
|
||||
| C | 답변 인용 가능 콘텐츠 | 25 | 정의문 · FAQ · 제목 계층 · 구체 수치 · 표 · 신선도 · 의료진 E-E-A-T · 패시지 자기완결성 |
|
||||
| D | AI 인용 표면 확보 | 20 | 강남언니 · GBP · 오픈웹 언급 · 표면 간 NAP · 네이버 플레이스 · Wikidata |
|
||||
| E | AI 가시성 측정 인프라 | 10 | 크롤러 로그(IP 검증) · GA4 AI 채널 · GSC AIO · 기준선 스팟체크 |
|
||||
| F | 기술 위생 | 5 | 이미지 alt · CMS 최신성 · 페이지 무게 · 모바일 |
|
||||
|
||||
## 항목 목록
|
||||
|
||||
| ID | 항목 | 배점 | 근거 | 통제 | 채점 | 3점 기준 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A1 | 검색용 AI 봇 허용 | 5 | 실측 | 직접 | 자동 | 검색봇 전부 허용 + 200 응답 |
|
||||
| A2 | CDN/WAF AI 차단 없음 | 3 | 실측 | 직접 | 자동 | CDN 차단 없음 확인 |
|
||||
| A3 | 서버 렌더링 본문 | 3 | 실측 | 직접 | 자동 | JS 미실행 텍스트 80% 이상 |
|
||||
| A4 | HTTPS·리다이렉트 정합 | 2 | 실측 | 직접 | 자동 | 전부 301로 정본 수렴 |
|
||||
| A5 | sitemap.xml 제공 | 3 | 실측 | 직접 | 자동 | 선언·200·lastmod 최신 |
|
||||
| A6 | Bing 색인 (ChatGPT 경로) | 3 | 벤더 | 직접 | 수동 | 색인 + IndexNow |
|
||||
| A7 | 응답 속도·크롤 예산 | 1 | 추정 | 직접 | 자동 | TTFB < 0.5s, HTML < 300KB |
|
||||
| B1 | JSON-LD 문법 유효성 | 3 | 실측 | 직접 | 자동 | 전부 유효 + @graph 연결 |
|
||||
| B2 | 업종 엔티티 스키마 (MedicalClinic / LegalService) | 5 | 학술 | 직접 | 자동 | + openingHours·medicalSpecialty·geo |
|
||||
| B3 | 전문가 스키마 (Physician / Attorney) | 3 | 학술 | 직접 | 자동 | 자격·소속·sameAs 포함 |
|
||||
| B4 | NAP 사이트 내 일관성 | 3 | 실측 | 직접 | 자동 | 단일 표기 |
|
||||
| B5 | sameAs 연결 | 2 | 학술 | 직접 | 자동 | 5개 이상 (SNS+버티컬+지도) |
|
||||
| B6 | canonical·중복 제거 | 2 | 실측 | 직접 | 자동 | 있음 + 파라미터 정리 |
|
||||
| B7 | 다국어 hreflang | 2 | 벤더 | 직접 | 자동 | 3개 이상 연결 |
|
||||
| C1 | 정의문형 첫 문단 | 4 | 학술 | 직접 | 반자동 | 전 핵심 페이지 정의문 |
|
||||
| C2 | FAQ 섹션 | 4 | 학술 | 직접 | 자동 | FAQ + FAQPage 스키마 + 시술별 |
|
||||
| C3 | 제목 계층 의미성 | 3 | 실측 | 직접 | 자동 | H1 1개 서술형, H2 논리 계층 |
|
||||
| C4 | 구체 수치 (가격·시간·회복) | 4 | 학술 | 직접 | 반자동 | 시술별 가격대·시간·회복 표기 |
|
||||
| C5 | 표·비교 구조 | 2 | 벤더 | 직접 | 자동 | HTML 표 다수 |
|
||||
| C6 | 신선도 신호 | 3 | 학술 | 직접 | 자동 | 3개월 이내 + dateModified |
|
||||
| C7 | 전문가 E-E-A-T (의료진 / 변호사) | 3 | 학술 | 직접 | 반자동 | + 논문·학회·수상 + Physician 스키마 |
|
||||
| C8 | 패시지 자기완결성 | 2 | 학술 | 직접 | 수동 | 80% 이상 |
|
||||
| D1 | 업종 버티컬 프로필 (강남언니 / 로톡) | 6 | 실측 | 직접 | 수동 | + 의료진·가격 갱신 3개월 이내 |
|
||||
| D2 | Google 비즈니스 프로필 | 4 | 실측 | 직접 | 수동 | 소유·리뷰 응답·게시물 활성 |
|
||||
| D3 | 오픈웹 언급 | 4 | 학술 | 영향 | 반자동 | 다수 오픈 도메인 일관 언급 |
|
||||
| D4 | 표면 간 NAP 완전 일치 | 3 | 실측 | 직접 | 수동 | 완전 일치 |
|
||||
| D5 | 네이버 플레이스 (AI 브리핑) | 2 | 실측 | 직접 | 반자동 | 연결·리뷰·소식 활성 |
|
||||
| D6 | Wikidata/위키백과 | 1 | 추정 | 영향 | 자동 | 항목 + 공식 사이트 연결 |
|
||||
| E1 | AI 크롤러 로그 (IP 검증) | 3 | 실측 | 직접 | 수동 | IP 검증 히트 집계 |
|
||||
| E2 | GA4 AI 유입 채널 | 3 | 실측 | 직접 | 수동 | 커스텀 채널 (Perplexity 포함) |
|
||||
| E3 | GSC AI Overviews | 2 | 벤더 | 직접 | 수동 | 월간 추적 |
|
||||
| E4 | 기준선 스팟체크 | 2 | 학술 | 직접 | 수동 | 분기 반복 측정 |
|
||||
| F1 | 이미지 대체텍스트 | 2 | 실측 | 직접 | 자동 | 5% 미만 누락 |
|
||||
| F2 | CMS 보안·최신성 | 1 | 추정 | 직접 | 자동 | 최신 |
|
||||
| F3 | 페이지 무게 | 1 | 추정 | 직접 | 자동 | < 300KB |
|
||||
| F4 | 모바일 대응 | 1 | 실측 | 직접 | 자동 | 반응형 + CWV 양호 |
|
||||
|
||||
각 항목의 level 0~2 라벨과 `howToCheck`(자동화 구현 스펙)는 `aeoGeoRubric.ts`에 있다.
|
||||
|
||||
## 자동화 로드맵 (Edge Function `discover-aeo-geo`)
|
||||
|
||||
| 단계 | 자동 채점 가능 항목 | 도구 |
|
||||
|---|---|---|
|
||||
| 1 | A1–A5, A7, B1, B4, B6, B7, C2(탐지), C3, C5, C6, F1–F4 | robots 파싱 + curl UA 테스트 + HTML 파서 (crawler_audit.py 로직 이식) |
|
||||
| 2 | B2, B3, C1, C4, C7, C8 | Perplexity/Claude에 HTML 텍스트 투입 → 구조화 판정 |
|
||||
| 3 | D3, D6 | 검색 그라운딩 도메인 집계, Wikidata API |
|
||||
| 수동 | A6, D1, D2, D4, E1–E4 | 오너 계정 접근 또는 담당자 확인 → 입력 슬롯 |
|
||||
|
||||
## 하지 않을 것 (리포트에 항상 명시)
|
||||
|
||||
- llms.txt 납품 · "스키마 넣으면 인용됩니다" · 단발 조회 순위 보고 · 답변엔진 UI 자동 조회(약관 위반) · 네이버 블로그 증량(글로벌 AI 기여도 0)
|
||||
94
docs/AI_DISCOVERY_FRONTEND_HANDOVER.md
Normal file
94
docs/AI_DISCOVERY_FRONTEND_HANDOVER.md
Normal file
@ -0,0 +1,94 @@
|
||||
# AI Discovery · Frontend Handover
|
||||
|
||||
2026-08-31 세션 산출물을 INFINITH 프로덕트(React)에 반영하기 위한 인수인계 문서.
|
||||
시안은 HTML로 확정됐고, 이 문서는 그것을 실제 라우트로 옮기는 데 필요한 전부를 담는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 다음 세션 실행 프롬프트 (복붙용)
|
||||
|
||||
```
|
||||
docs/AI_DISCOVERY_FRONTEND_HANDOVER.md 를 읽고 AI Discovery 랜딩 페이지를 프로덕트에 구현해줘.
|
||||
|
||||
- 시안: docs/reports/ai_discovery_landing.html (픽셀 기준, 이 파일이 정답)
|
||||
- 라우트: /discovery (index) 신규, 기존 /discovery/:id 리포트 페이지와 공존
|
||||
- 재사용: Navbar/Footer(BI 워드마크), ScoreRing, Solution.tsx의 루프 다이어그램 구조(AGDP → ABMR 워딩)
|
||||
- 데이터: src/data/discovery_viewclinic.ts + src/lib/discoveryScore.ts 실측값만 사용, 수치 하드코딩 금지
|
||||
- 카피 규칙: 존댓말 문장형 타이틀, 엠대시 금지, break-keep, AI 답변 목업에 "구축 후 목표 상태 예시" 라벨 유지, 연락처는 o2oteam@o2o.kr만
|
||||
- 완료 기준: npm run lint 통과, /discovery가 시안과 시각적으로 일치, 모바일 1열 폴백
|
||||
끝나면 git push + vercel --prod 까지.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 이번 세션에서 만든 것
|
||||
|
||||
| 산출물 | 경로 | 상태 |
|
||||
|---|---|---|
|
||||
| 랜딩 시안 (단일 HTML, 픽셀 정답) | `docs/reports/ai_discovery_landing.html` | 확정 |
|
||||
| 랜딩 스크린샷 (덱 삽입용) | `docs/reports/shots/land_hero.png`, `land_report.png` | 확정 |
|
||||
| POC 제안서 덱 (10장) | `docs/reports/Viewclinic_POC_Proposal.pptx` / `.pdf` | 확정 |
|
||||
| 덱 빌드 스크립트 | `docs/reports/build_viewclinic_poc_deck.py` | 재실행 가능 |
|
||||
|
||||
시안 HTML은 외부 의존성이 Google Fonts(Playfair Display, Noto Sans KR) 하나뿐인
|
||||
단일 파일이다. 랜딩을 수정하면 스크린샷 재캡처 → 덱 리빌드 순서로 갱신한다
|
||||
(스크린샷 크롭 좌표는 배경색 감지로 잡았다: 히어로 0–1384px, 리포트 밴드는 #F4F6FB 행 범위).
|
||||
|
||||
## 2. 구현 대상: 라우트와 페이지
|
||||
|
||||
- 신규 라우트: `/discovery` → `DiscoveryLandingPage.tsx` (src/main.tsx에 추가)
|
||||
- 기존 라우트: `/discovery/:id` → `DiscoveryReportPage.tsx` (수정 불필요, 랜딩의 "샘플 리포트 보기"가 `/discovery/viewclinic`으로 연결)
|
||||
- Navbar/Footer는 전역 레이아웃이 이미 감싼다. 랜딩 시안의 자체 네비·푸터 마크업은 옮기지 말 것.
|
||||
|
||||
## 3. 섹션 → 컴포넌트 매핑 (시안 위에서 아래 순서)
|
||||
|
||||
| 시안 섹션 | 컴포넌트 제안 | 재사용/참조 |
|
||||
|---|---|---|
|
||||
| 히어로 (72px 헤드라인 + 배지 + CTA 2개) | `discovery/DiscoveryHero.tsx` | `Hero.tsx` 구조, `.text-gradient` |
|
||||
| ABMR 루프 카드 (다크) | `discovery/AbmrLoop.tsx` | **`Solution.tsx` 46–130행 다이어그램을 복제 후 워딩만 교체** |
|
||||
| AI 답변 목업 카드 | `discovery/AiAnswerMock.tsx` | glass-card |
|
||||
| Why Now 다크 밴드 (12종 / 0건 / 35~70%) | `discovery/WhyNow.tsx` | |
|
||||
| How It Works (Audit·Build·Prove) | `discovery/HowItWorks.tsx` | glass-card + EFE6FF 아이콘 스퀘어 |
|
||||
| 샘플 리포트 (48점 링 + A~F 바 + 칩 3장) | `discovery/SampleReport.tsx` | **`ScoreRing.tsx` 그대로**, 바 색상은 `getScoreColor()` 로직 재사용 |
|
||||
| 8주 로드맵 타임라인 | `discovery/Roadmap.tsx` | |
|
||||
| 정직성 인용문 + 3필 | `discovery/HonestyStrip.tsx` | |
|
||||
| 최종 CTA (다크) | `discovery/DiscoveryCta.tsx` | |
|
||||
|
||||
## 4. 디자인 실측값 (시안에 이미 적용된 프로덕션 토큰)
|
||||
|
||||
- **BI 워드마크**: Playfair Display Black(900) 30px, tracking 0.05em, `linear-gradient(to right, #4F1DA1, #021341)` 텍스트 클립. Navbar/Footer 컴포넌트에 이미 있으므로 재사용.
|
||||
- **DS Primary pill 버튼**: `px-10 py-4 text-lg font-medium rounded-full shadow-xl`, hero 변형은 `from-[#4F1DA1] to-[#021341]` + ArrowRight. `MultiChannelInput.tsx` 195행 참조.
|
||||
- **text-gradient**: `#0A1128 → #3b5998` 클립 (index.css `.text-gradient`).
|
||||
- **히어로 배경**: radial `#e0e7ff → #faf5ff → #fdf2f8` + 블롭 (Hero.tsx와 동일 계열).
|
||||
- **ScoreRing**: strokeWidth 8, 트랙 `#f1f5f9`, 점수 구간색 ≤40 `#C084CF` / ≤60 `#8B9CF7` / ≤80 `#7C6DD8` / 80+ `#6C5CE7`. 48점은 `#8B9CF7`.
|
||||
- **상태 칩**: index.css의 status-critical/warning/good/info 파스텔 토큰 (치명/보완/강점 칩).
|
||||
- **다크 카드**: `#0F1B45` bg + `#2A3A6B` border (Why Now 통계 카드).
|
||||
- 루프 회전 애니메이션: `animate-[spin_20s_linear_infinite]` (Solution.tsx와 동일).
|
||||
|
||||
## 5. 데이터 연결
|
||||
|
||||
- 수치는 전부 `src/data/discovery_viewclinic.ts`(`DISCOVERY_VIEWCLINIC`)와 `src/lib/discoveryScore.ts` 계산 결과에서 가져온다. **48, 92%, 20% 등을 JSX에 하드코딩하지 말 것** (재진단 시 점수가 바뀐다).
|
||||
- 카테고리 바 6개(A~F)는 discoveryScore의 카테고리별 pct를 순회 렌더.
|
||||
- 시안의 고정 수치는 2026-08-28 실측 스냅샷이다. 값이 다르게 나오면 데이터 쪽이 정답.
|
||||
|
||||
## 6. 카피·정직성 규칙 (이번 세션에서 확정된 것)
|
||||
|
||||
1. 타이틀은 존댓말 문장형으로 통일 ("~없습니다", "~올립니다"). 반말 종결·명사구 타이틀 금지.
|
||||
2. 엠대시 금지, 은유 금지, `break-keep` 적용 (기존 텍스트 스타일 규칙과 동일).
|
||||
3. AI 답변 목업에는 **"AEO / GEO 구축 후 목표 상태 예시" 라벨을 반드시 유지**한다. 현재 상태로 오인되게 만들지 않는다.
|
||||
4. 허위 도메인·이메일 금지. 연락처는 `o2oteam@o2o.kr` (Navbar 문의 mailto는 기존 `contact@o2o.kr` 유지).
|
||||
5. 시장 통계를 새로 만들어 넣지 않는다. 시안의 3개 수치(12종 크롤러 / 네이버 기여 0건 / Direct 유실 35~70%)는 근거 있는 실측·문헌 값이며, 이 밖의 수치 추가는 근거 확인 후에만.
|
||||
6. 순위·노출 보장형 문구 금지. "언급률과 신뢰구간으로 보고" 프레임 유지.
|
||||
|
||||
## 7. 완료 기준·배포
|
||||
|
||||
- `npm run lint` (tsc --noEmit) 통과.
|
||||
- 1440px에서 `/discovery`가 `docs/reports/ai_discovery_landing.html`과 시각적으로 일치.
|
||||
- 390px에서 2열 그리드가 1열로 접히고 헤드라인이 줄바꿈 깨짐 없이 표시.
|
||||
- 배포: `git push` 후 `vercel --prod` 수동 실행 (Gitea 자동 트리거 없음).
|
||||
|
||||
## 8. 이후 백로그 (이번 구현 범위 아님)
|
||||
|
||||
- 랜딩 CTA를 실제 진단 신청 폼/파이프라인(`discover-channels` Edge Function)에 연결.
|
||||
- 태하(법무법인, 68점 B) 케이스를 두 번째 샘플로 노출할지 결정.
|
||||
- 덱 스크린샷 자동 갱신 스크립트화 (랜딩 변경 → 캡처 → 크롭 → 덱 리빌드).
|
||||
58
docs/reports/Taeha_AI_Answer_Readiness_Report.html
Normal file
58
docs/reports/Taeha_AI_Answer_Readiness_Report.html
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/reports/Taeha_AI_Answer_Readiness_Report.pdf
Normal file
BIN
docs/reports/Taeha_AI_Answer_Readiness_Report.pdf
Normal file
Binary file not shown.
BIN
docs/reports/Taeha_AI_Answer_Readiness_Report.pptx
Normal file
BIN
docs/reports/Taeha_AI_Answer_Readiness_Report.pptx
Normal file
Binary file not shown.
BIN
docs/reports/Viewclinic AI Discovery_POC_Proposal.pdf
Normal file
BIN
docs/reports/Viewclinic AI Discovery_POC_Proposal.pdf
Normal file
Binary file not shown.
BIN
docs/reports/Viewclinic_AI Discovery_POC_Proposal.pptx
Normal file
BIN
docs/reports/Viewclinic_AI Discovery_POC_Proposal.pptx
Normal file
Binary file not shown.
BIN
docs/reports/Viewclinic_AI_Answer_Readiness_Report.pdf
Normal file
BIN
docs/reports/Viewclinic_AI_Answer_Readiness_Report.pdf
Normal file
Binary file not shown.
BIN
docs/reports/Viewclinic_AI_Answer_Readiness_Report.pptx
Normal file
BIN
docs/reports/Viewclinic_AI_Answer_Readiness_Report.pptx
Normal file
Binary file not shown.
401
docs/reports/ai_discovery_landing.html
Normal file
401
docs/reports/ai_discovery_landing.html
Normal file
@ -0,0 +1,401 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>INFINITH · AI Discovery AEO/GEO</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;0,700;0,800;0,900;1,600&family=Noto+Sans+KR:wght@400;500;600;700&display=swap">
|
||||
<style>
|
||||
body { margin: 0; background: #f8fafc; font-family: "Pretendard Variable", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; color: #1e293b; -webkit-font-smoothing: antialiased; word-break: keep-all; }
|
||||
a { color: #6C5CE7; text-decoration: none; }
|
||||
a:hover { color: #4F1DA1; }
|
||||
@keyframes if-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div style="width: 100%; background: #f8fafc;">
|
||||
|
||||
<!-- ── Nav ─────────────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: rgba(255,255,255,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid #f1f5f9;">
|
||||
<div style="width: 100%; max-width: 1120px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; height: 80px; box-sizing: border-box;">
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 900; font-size: 30px; letter-spacing: 0.05em; background: linear-gradient(to right, #4F1DA1, #021341); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent;">INFINITH</span>
|
||||
<span style="font-size: 13px; font-weight: 600; letter-spacing: 0.08em; color: #6C5CE7;">AI DISCOVERY</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 28px;">
|
||||
<a href="#" style="font-size: 15px; font-weight: 500; color: #475569;">왜 지금인가</a>
|
||||
<a href="#" style="font-size: 15px; font-weight: 500; color: #475569;">프로세스</a>
|
||||
<a href="#" style="font-size: 15px; font-weight: 500; color: #475569;">샘플 리포트</a>
|
||||
<div style="display: flex; align-items: center; height: 44px; padding: 0 22px; background: #0A1128; border-radius: 9999px;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #ffffff;">무료 진단 받기</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Hero ────────────────────────────────────────────────────────── -->
|
||||
<div style="position: relative; overflow: hidden; background: radial-gradient(ellipse at top, #e0e7ff 0%, #faf5ff 45%, #fdf2f8 100%);">
|
||||
<div style="position: absolute; top: 120px; left: 60px; width: 256px; height: 256px; background: #d8b4fe; border-radius: 9999px; filter: blur(64px); opacity: 0.3;"></div>
|
||||
<div style="position: absolute; top: 180px; right: 60px; width: 256px; height: 256px; background: #f9a8d4; border-radius: 9999px; filter: blur(64px); opacity: 0.3;"></div>
|
||||
<div style="position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; padding: 104px 24px 88px;">
|
||||
|
||||
<div style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 9999px; background: rgba(255,255,255,0.6); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 1px 2px rgba(0,0,0,0.05); margin-bottom: 32px;">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 8c0-1.4 1-2.5 2.2-2.5 1.6 0 2.6 5 4.2 5 1.2 0 2.2-1.1 2.2-2.5s-1-2.5-2.2-2.5c-1.6 0-2.6 5-4.2 5C5.5 10.5 4.5 9.4 4.5 8Z" stroke="#6C5CE7" stroke-width="1.4"/></svg>
|
||||
<span style="font-size: 14px; font-weight: 500; color: #334155;">AI DISCOVERY · AEO / GEO for Clinics</span>
|
||||
</div>
|
||||
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 72px; line-height: 1.1; letter-spacing: -0.02em; color: #0A1128; margin-bottom: 24px;">AI가 추천하는 병원은<br><span style="background: linear-gradient(to right, #0A1128, #3b5998); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent;">따로 있습니다.</span></div>
|
||||
|
||||
<div style="font-size: 20px; line-height: 1.625; color: #475569; max-width: 768px; margin-bottom: 40px;">고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.<br>답변에 인용될 근거를 36개 항목으로 실측 진단하고, 8주 안에 구축합니다.</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 72px;">
|
||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 16px 40px; background: linear-gradient(to right, #4F1DA1, #021341); border-radius: 9999px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
|
||||
<span style="font-size: 18px; font-weight: 500; color: #ffffff;">무료 AEO / GEO 진단 받기</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div style="display: inline-flex; align-items: center; justify-content: center; padding: 16px 40px; background: rgba(255,255,255,0.8); border: 1px solid #e2e8f0; border-radius: 9999px;">
|
||||
<span style="font-size: 18px; font-weight: 500; color: #0A1128;">샘플 리포트 보기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABMR loop + AI answer output -->
|
||||
<div style="width: 100%; max-width: 1120px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; text-align: left;">
|
||||
|
||||
<!-- Loop card (dark) -->
|
||||
<div style="position: relative; overflow: hidden; background: #0A1128; border-radius: 16px; padding: 28px 32px; display: flex; flex-direction: column; align-items: center; box-sizing: border-box;">
|
||||
<div style="position: absolute; top: 45%; left: 50%; transform: translate(-50%,-50%); width: 420px; height: 420px; background: rgba(108,92,231,0.2); border-radius: 9999px; filter: blur(80px);"></div>
|
||||
|
||||
<div style="position: relative; align-self: flex-start; font-size: 13px; font-weight: 700; letter-spacing: 0.2em; color: #AF90FF; margin-bottom: 8px;">THE ENGINE · ABMR CYCLE</div>
|
||||
|
||||
<div style="position: relative; width: 300px; height: 300px; margin: 44px 0 60px;">
|
||||
<svg viewBox="0 0 100 100" style="position: absolute; inset: 0; width: 100%; height: 100%; animation: if-spin 20s linear infinite;">
|
||||
<defs>
|
||||
<linearGradient id="loopGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a78bfa" stop-opacity="0.15"></stop>
|
||||
<stop offset="50%" stop-color="#c084fc" stop-opacity="0.6"></stop>
|
||||
<stop offset="100%" stop-color="#e879f9" stop-opacity="0.15"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="url(#loopGrad)" stroke-width="0.5" stroke-dasharray="2 2"></circle>
|
||||
</svg>
|
||||
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 148px; height: 148px; border-radius: 9999px; background: rgba(10,17,40,0.9); border: 1px solid rgba(255,255,255,0.05); box-shadow: 0 0 60px rgba(167,139,250,0.12); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; z-index: 2;">
|
||||
<span style="font-size: 30px; font-weight: 700; background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent;">ABMR</span>
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 16px; color: #ffffff; text-align: center; line-height: 1.25;">Discovery<br>Loop</span>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; top: 0; left: 50%; transform: translate(-50%,-50%); z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 7px;">
|
||||
<div style="width: 58px; height: 58px; border-radius: 9999px; border: 1px solid rgba(168,85,247,0.3); background: rgba(10,17,40,0.8); box-shadow: 0 0 20px rgba(168,85,247,0.15); display: flex; align-items: center; justify-content: center;"><span style="font-size: 25px; font-weight: 700; color: #d8b4fe;">A</span></div>
|
||||
<span style="font-size: 14px; font-weight: 500; color: #e9d5ff;">Audit</span>
|
||||
</div>
|
||||
<div style="position: absolute; top: 50%; right: 0; transform: translate(50%,-50%); z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 7px;">
|
||||
<div style="width: 58px; height: 58px; border-radius: 9999px; border: 1px solid rgba(168,85,247,0.3); background: rgba(10,17,40,0.8); box-shadow: 0 0 20px rgba(168,85,247,0.15); display: flex; align-items: center; justify-content: center;"><span style="font-size: 25px; font-weight: 700; color: #d8b4fe;">B</span></div>
|
||||
<span style="font-size: 14px; font-weight: 500; color: #e9d5ff;">Build</span>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 0; left: 50%; transform: translate(-50%,50%); z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 7px;">
|
||||
<div style="width: 58px; height: 58px; border-radius: 9999px; border: 1px solid rgba(168,85,247,0.3); background: rgba(10,17,40,0.8); box-shadow: 0 0 20px rgba(168,85,247,0.15); display: flex; align-items: center; justify-content: center;"><span style="font-size: 25px; font-weight: 700; color: #d8b4fe;">M</span></div>
|
||||
<span style="font-size: 14px; font-weight: 500; color: #e9d5ff;">Measure</span>
|
||||
</div>
|
||||
<div style="position: absolute; top: 50%; left: 0; transform: translate(-50%,-50%); z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 7px;">
|
||||
<div style="width: 58px; height: 58px; border-radius: 9999px; border: 1px solid rgba(168,85,247,0.3); background: rgba(10,17,40,0.8); box-shadow: 0 0 20px rgba(168,85,247,0.15); display: flex; align-items: center; justify-content: center;"><span style="font-size: 25px; font-weight: 700; color: #d8b4fe;">R</span></div>
|
||||
<span style="font-size: 14px; font-weight: 500; color: #e9d5ff;">Refine</span>
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 100 100" style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2;">
|
||||
<defs><path id="citePath" d="M 10.6 56.9 A 40 40 0 0 0 43.1 89.4" fill="none"></path></defs>
|
||||
<text font-size="3.5" font-weight="500" letter-spacing="0.6" fill="#d8b4fe" opacity="0.8"><textPath href="#citePath" startOffset="50%" text-anchor="middle">← CITATION FEEDBACK</textPath></text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style="position: relative; width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 14px 18px; box-sizing: border-box;">
|
||||
<div style="font-size: 13.5px; line-height: 1.75; color: #cbd5e1; text-align: center;"><span style="font-weight: 700; color: #d8b4fe;">ABMR Cycle:</span> 실측 진단(Audit) → 스키마·콘텐츠 구축(Build) → 언급률·AI 유입 측정(Measure) → 우선순위 재조정(Refine)의 무한 루프. 사이클이 돌 때마다 <span style="color: #ffffff; font-weight: 500;">측정된 언급률이 다음 구축의 입력</span>이 됩니다.</div>
|
||||
</div>
|
||||
<div style="position: relative; display: flex; justify-content: center; gap: 10px; margin-top: 12px; width: 100%;">
|
||||
<div style="padding: 6px 14px; background: rgba(175,144,255,0.12); border: 1px solid rgba(175,144,255,0.3); border-radius: 9999px;"><span style="font-size: 13px; font-weight: 600; color: #d8b4fe;">점수 48 → 61+</span></div>
|
||||
<div style="padding: 6px 14px; background: rgba(175,144,255,0.12); border: 1px solid rgba(175,144,255,0.3); border-radius: 9999px;"><span style="font-size: 13px; font-weight: 600; color: #d8b4fe;">등급 C → B</span></div>
|
||||
<div style="padding: 6px 14px; background: rgba(175,144,255,0.12); border: 1px solid rgba(175,144,255,0.3); border-radius: 9999px;"><span style="font-size: 13px; font-weight: 600; color: #d8b4fe;">언급률 상승 추적</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI answer mock (loop output) -->
|
||||
<div style="background: rgba(255,255,255,0.7); backdrop-filter: blur(24px); border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 8px 32px rgba(31,38,135,0.07); border-radius: 16px; padding: 28px 32px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center;">
|
||||
<div style="font-size: 13px; font-weight: 700; letter-spacing: 0.2em; color: #6C5CE7; margin-bottom: 20px;">THE OUTPUT · AI 답변 인용</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid #eef0f7; margin-bottom: 16px;">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="9" cy="9" r="5.5" stroke="#64748b" stroke-width="1.5"></circle><path d="M13.5 13.5 17 17" stroke="#64748b" stroke-width="1.5" stroke-linecap="round"></path></svg>
|
||||
<span style="font-size: 16px; font-weight: 500; color: #334155;">강남에서 안전하게 가슴성형 상담받을 수 있는 병원 추천해줘</span>
|
||||
</div>
|
||||
<div style="font-size: 15px; line-height: 1.8; color: #475569; margin-bottom: 16px;">모티바 공식 KOL 의료진이 있는 <span style="background: #F3F0FF; color: #4A3A7C; font-weight: 600; padding: 2px 6px; border-radius: 6px;">뷰성형외과</span>는 강남언니 후기 19,316건과 의료진 26명 정보가 공개되어 있으며, 시술별 FAQ와 비교표를 제공합니다.</div>
|
||||
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 24px;">
|
||||
<div style="display: flex; align-items: center; gap: 6px; padding: 5px 12px; background: #EFF0FF; border: 1px solid #C5CBF5; border-radius: 9999px;"><span style="font-size: 12.5px; font-weight: 500; color: #3A3F7C;">viewclinic.com</span></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; padding: 5px 12px; background: #EFF0FF; border: 1px solid #C5CBF5; border-radius: 9999px;"><span style="font-size: 12.5px; font-weight: 500; color: #3A3F7C;">gangnamunni.com</span></div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; padding: 5px 12px; background: #EFF0FF; border: 1px solid #C5CBF5; border-radius: 9999px;"><span style="font-size: 12.5px; font-weight: 500; color: #3A3F7C;">youtube.com</span></div>
|
||||
<span style="font-size: 12.5px; color: #94a3b8; margin-left: 6px;">AEO / GEO 구축 후 목표 상태 예시</span>
|
||||
</div>
|
||||
<div style="border-top: 1px solid #eef0f7; padding-top: 18px; display: flex; flex-direction: column; gap: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="width: 7px; height: 7px; background: #9B8AD4; border-radius: 9999px;"></span>
|
||||
<span style="font-size: 14px; color: #475569;">루프 1회전(8주)마다 36개 항목을 재측정해 점수를 갱신합니다.</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="width: 7px; height: 7px; background: #9B8AD4; border-radius: 9999px;"></span>
|
||||
<span style="font-size: 14px; color: #475569;">질의 10개 × 5회 스팟체크로 인용 여부를 신뢰구간과 함께 보고합니다.</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="width: 7px; height: 7px; background: #9B8AD4; border-radius: 9999px;"></span>
|
||||
<span style="font-size: 14px; color: #475569;">측정 결과가 다음 사이클의 구축 우선순위를 자동으로 정합니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Why now (dark) ──────────────────────────────────────────────── -->
|
||||
<div style="background: #0A1128; display: flex; justify-content: center;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 88px 24px; box-sizing: border-box;">
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.24em; color: #AF90FF; margin-bottom: 16px;">WHY NOW</div>
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 40px; line-height: 1.25; color: #ffffff; margin-bottom: 12px;">검색은 끝났고, 답변이 시작됐습니다.</div>
|
||||
<div style="font-size: 17px; line-height: 1.7; color: #94a3b8; margin-bottom: 56px;">답변엔진은 광고 입찰이 아니라 크롤링 가능한 오픈웹 근거로 병원을 고릅니다. 인용되지 못하면 비교 대상에도 오르지 못합니다.</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; padding: 28px; background: #0F1B45; border: 1px solid #2A3A6B; border-radius: 16px;">
|
||||
<div style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 46px; color: #AF90FF;">12종</div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #ffffff;">AI 크롤러가 이미 읽는 중</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #94a3b8;">GPTBot·OAI-SearchBot·PerplexityBot 등이 오늘도 병원 사이트를 수집합니다. 문제는 접근이 아니라 인용할 내용입니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; padding: 28px; background: #0F1B45; border: 1px solid #2A3A6B; border-radius: 16px;">
|
||||
<div style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 46px; color: #AF90FF;">0건</div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #ffffff;">네이버 자산의 AI 기여</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #94a3b8;">네이버 블로그·플레이스는 robots.txt로 AI 크롤러를 전면 차단합니다(2026-08 실측). 지금까지의 네이버 마케팅은 AI 답변에 닿지 않습니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; padding: 28px; background: #0F1B45; border: 1px solid #2A3A6B; border-radius: 16px;">
|
||||
<div style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 46px; color: #AF90FF;">35~70%</div>
|
||||
<div style="font-size: 16px; font-weight: 600; color: #ffffff;">Direct로 숨는 AI 유입</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #94a3b8;">AI가 보낸 방문의 상당수는 유입 경로 없이 도착합니다. 측정 인프라 없이는 이미 오는 환자도 세지 못합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── How it works ────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #f8fafc;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 96px 24px; box-sizing: border-box;">
|
||||
<div style="text-align: center; margin-bottom: 56px;">
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.24em; color: #6C5CE7; margin-bottom: 16px;">HOW IT WORKS</div>
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 40px; line-height: 1.25; color: #0A1128;">진단하고, 구축하고, 숫자로 증명합니다.</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px;">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 14px; background: rgba(255,255,255,0.7); backdrop-filter: blur(24px); border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 8px 32px rgba(31,38,135,0.07); border-radius: 16px; padding: 32px;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: #F3F0FF; border-radius: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="6.5" stroke="#6C5CE7" stroke-width="1.6"/><path d="M16 16l4 4" stroke="#6C5CE7" stroke-width="1.6" stroke-linecap="round"/><path d="M8.5 11h5M11 8.5v5" stroke="#6C5CE7" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<span style="font-size: 13px; font-weight: 700; letter-spacing: 0.12em; color: #6C5CE7;">STEP 01</span>
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 24px; color: #0A1128;">Audit</span>
|
||||
</div>
|
||||
<div style="font-size: 15.5px; line-height: 1.75; color: #475569;">AEO/GEO 기준표 v1.0으로 36개 항목을 실측합니다. 크롤러 접근성부터 인용 가능 콘텐츠, 측정 인프라까지 6개 카테고리 100점 척도. 항목마다 근거 등급을 명시합니다.</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 14px; background: rgba(255,255,255,0.7); backdrop-filter: blur(24px); border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 8px 32px rgba(31,38,135,0.07); border-radius: 16px; padding: 32px;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: #F3F0FF; border-radius: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M8 4H6a2 2 0 0 0-2 2v3c0 1-.6 2-1.6 2.4v1.2C3.4 13 4 14 4 15v3a2 2 0 0 0 2 2h2M16 4h2a2 2 0 0 1 2 2v3c0 1 .6 2 1.6 2.4v1.2C20.6 13 20 14 20 15v3a2 2 0 0 1-2 2h-2" stroke="#6C5CE7" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<span style="font-size: 13px; font-weight: 700; letter-spacing: 0.12em; color: #6C5CE7;">STEP 02</span>
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 24px; color: #0A1128;">Build</span>
|
||||
</div>
|
||||
<div style="font-size: 15.5px; line-height: 1.75; color: #475569;">MedicalClinic·Physician 스키마를 심고, 흩어진 표면의 NAP을 한 글자까지 통일합니다. 핵심 페이지에 정의문과 시술별 FAQ를 넣어 AI가 잘라 갈 단락을 만듭니다.</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 14px; background: rgba(255,255,255,0.7); backdrop-filter: blur(24px); border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 8px 32px rgba(31,38,135,0.07); border-radius: 16px; padding: 32px;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: #F3F0FF; border-radius: 12px;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2" stroke="#6C5CE7" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 10px;">
|
||||
<span style="font-size: 13px; font-weight: 700; letter-spacing: 0.12em; color: #6C5CE7;">STEP 03</span>
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: 24px; color: #0A1128;">Prove</span>
|
||||
</div>
|
||||
<div style="font-size: 15.5px; line-height: 1.75; color: #475569;">GA4 AI 채널그룹과 서버로그 크롤러 검증을 설치하고, 질의 10개 × 5회 반복 스팟체크로 변화를 측정합니다. 단발 조회가 아니라 언급률과 신뢰구간으로만 보고합니다.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Sample report ───────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #F4F6FB;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 96px 24px; box-sizing: border-box;">
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.24em; color: #6C5CE7; margin-bottom: 16px;">SAMPLE REPORT · 뷰성형외과 실측</div>
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 40px; line-height: 1.25; color: #0A1128; margin-bottom: 12px;">읽을 수는 있지만, 인용할 근거가 없습니다.</div>
|
||||
<div style="font-size: 17px; line-height: 1.7; color: #64748b; margin-bottom: 48px;">실제 1차 진단 결과입니다. 어디가 강하고 어디가 비어 있는지, 점수는 카테고리 단위로 드러납니다.</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 24px;">
|
||||
|
||||
<!-- Score ring card -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 40px 32px; box-shadow: 0 8px 32px rgba(31,38,135,0.07);">
|
||||
<div style="position: relative; width: 160px; height: 160px;">
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" style="transform: rotate(-90deg);">
|
||||
<circle cx="80" cy="80" r="76" fill="none" stroke="#f1f5f9" stroke-width="8"></circle>
|
||||
<circle cx="80" cy="80" r="76" fill="none" stroke="#8B9CF7" stroke-width="8" stroke-linecap="round" stroke-dasharray="477.5" stroke-dashoffset="248.3"></circle>
|
||||
</svg>
|
||||
<div style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;">
|
||||
<span style="font-size: 40px; font-weight: 700; color: #0A1128;">48</span>
|
||||
<span style="font-size: 13px; color: #94a3b8;">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="display: flex; align-items: center; padding: 5px 14px; background: #FFF6ED; border: 1px solid #F5E0C5; border-radius: 9999px;"><span style="font-size: 14px; font-weight: 700; color: #7C5C3A;">등급 C</span></div>
|
||||
<span style="font-size: 14px; color: #64748b;">AI Answer Readiness</span>
|
||||
</div>
|
||||
<div style="font-size: 13.5px; line-height: 1.6; color: #94a3b8; text-align: center;">36개 항목 중 31개 실측.<br>미검증 5개는 분모에서 제외했습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- Category bars -->
|
||||
<div style="display: flex; flex-direction: column; justify-content: center; gap: 18px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 36px 40px; box-shadow: 0 8px 32px rgba(31,38,135,0.07);">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">A · AI 크롤러 접근성</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #6C5CE7;">92%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 92%; height: 8px; background: #6C5CE7; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">B · 엔티티 식별·구조화</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #C084CF;">30%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 30%; height: 8px; background: #C084CF; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">C · 답변 인용 가능 콘텐츠</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #C084CF;">20%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 20%; height: 8px; background: #C084CF; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">D · AI 인용 표면 확보</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #7C6DD8;">72%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 72%; height: 8px; background: #7C6DD8; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">E · AI 가시성 측정 인프라</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #C084CF;">0%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 2%; height: 8px; background: #C084CF; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 7px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #0A1128;">F · 기술 위생</span>
|
||||
<span style="font-size: 15px; font-weight: 700; color: #C084CF;">33%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #f1f5f9; border-radius: 9999px;"><div style="width: 33%; height: 8px; background: #C084CF; border-radius: 9999px;"></div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings chips row -->
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-top: 24px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 24px 28px;">
|
||||
<div style="display: inline-flex; align-self: flex-start; align-items: center; gap: 7px; padding: 4px 12px; background: #FFF0F0; border: 1px solid #F5D5DC; border-radius: 9999px;"><span style="width: 7px; height: 7px; background: #D4889A; border-radius: 9999px;"></span><span style="font-size: 13px; font-weight: 700; color: #7C3A4B;">치명</span></div>
|
||||
<div style="font-size: 15.5px; font-weight: 600; color: #0A1128;">AI가 누구인지 확정 못 함</div>
|
||||
<div style="font-size: 14px; line-height: 1.7; color: #64748b;">MedicalClinic 스키마 없음. 시술 목록 JSON-LD는 문법 오류로 통째로 버려집니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 24px 28px;">
|
||||
<div style="display: inline-flex; align-self: flex-start; align-items: center; gap: 7px; padding: 4px 12px; background: #FFF6ED; border: 1px solid #F5E0C5; border-radius: 9999px;"><span style="width: 7px; height: 7px; background: #D4A872; border-radius: 9999px;"></span><span style="font-size: 13px; font-weight: 700; color: #7C5C3A;">보완</span></div>
|
||||
<div style="font-size: 15.5px; font-weight: 600; color: #0A1128;">인용할 문장이 없음</div>
|
||||
<div style="font-size: 14px; line-height: 1.7; color: #64748b;">정의문 0개, FAQ 0개, HTML 표 0개. 답변엔진이 잘라 갈 단락이 없습니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 24px 28px;">
|
||||
<div style="display: inline-flex; align-self: flex-start; align-items: center; gap: 7px; padding: 4px 12px; background: #F3F0FF; border: 1px solid #D5CDF5; border-radius: 9999px;"><span style="width: 7px; height: 7px; background: #9B8AD4; border-radius: 9999px;"></span><span style="font-size: 13px; font-weight: 700; color: #4A3A7C;">강점</span></div>
|
||||
<div style="font-size: 15.5px; font-weight: 600; color: #0A1128;">표면 자산은 이미 강함</div>
|
||||
<div style="font-size: 14px; line-height: 1.7; color: #64748b;">강남언니 후기 19,316건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Roadmap ─────────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #f8fafc;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 96px 24px; box-sizing: border-box;">
|
||||
<div style="text-align: center; margin-bottom: 64px;">
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.24em; color: #6C5CE7; margin-bottom: 16px;">8-WEEK ROADMAP</div>
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 40px; line-height: 1.25; color: #0A1128;">8주 안에, C에서 B로 올립니다.</div>
|
||||
</div>
|
||||
<div style="position: relative;">
|
||||
<div style="position: absolute; top: 11px; left: 8%; right: 8%; height: 2px; background: #D5CDF5;"></div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; text-align: center; gap: 14px;">
|
||||
<div style="position: relative; width: 24px; height: 24px; background: #6C5CE7; border: 5px solid #f8fafc; border-radius: 9999px; box-sizing: border-box;"></div>
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.1em; color: #6C5CE7;">W1–2</div>
|
||||
<div style="font-size: 17px; font-weight: 700; color: #0A1128;">기반 설치</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #64748b; max-width: 280px;">스키마·NAP·측정 인프라를 설치하고 기준선 스팟체크를 1회 측정합니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; text-align: center; gap: 14px;">
|
||||
<div style="position: relative; width: 24px; height: 24px; background: #6C5CE7; border: 5px solid #f8fafc; border-radius: 9999px; box-sizing: border-box;"></div>
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.1em; color: #6C5CE7;">W3–6</div>
|
||||
<div style="font-size: 17px; font-weight: 700; color: #0A1128;">콘텐츠 구조</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #64748b; max-width: 280px;">정의문·FAQ·H1 재설계와 HTML 비교표를 구축하고 WordPress를 업그레이드합니다.</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; text-align: center; gap: 14px;">
|
||||
<div style="position: relative; width: 24px; height: 24px; background: #6C5CE7; border: 5px solid #f8fafc; border-radius: 9999px; box-sizing: border-box;"></div>
|
||||
<div style="font-size: 14px; font-weight: 700; letter-spacing: 0.1em; color: #6C5CE7;">W7–8</div>
|
||||
<div style="font-size: 17px; font-weight: 700; color: #0A1128;">재진단·보고</div>
|
||||
<div style="font-size: 14.5px; line-height: 1.7; color: #64748b; max-width: 280px;">36개 항목을 재측정하고 미검증 5개를 반영해 총점을 다시 산정합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Honesty ─────────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #ffffff; border-top: 1px solid #eef0f7; border-bottom: 1px solid #eef0f7;">
|
||||
<div style="width: 100%; max-width: 880px; padding: 72px 24px; box-sizing: border-box; text-align: center;">
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-style: italic; font-weight: 600; font-size: 27px; line-height: 1.5; color: #0A1128; margin-bottom: 28px;">"스키마를 넣으면 인용된다고 약속하지 않습니다.<br>인용될 수 없는 이유를 전부 제거하고, 결과를 반복 측정으로 증명합니다."</div>
|
||||
<div style="display: flex; justify-content: center; gap: 12px;">
|
||||
<div style="padding: 8px 18px; background: #F4F6FB; border: 1px solid #e2e8f0; border-radius: 9999px;"><span style="font-size: 14px; font-weight: 500; color: #475569;">효과 없는 llms.txt는 팔지 않습니다</span></div>
|
||||
<div style="padding: 8px 18px; background: #F4F6FB; border: 1px solid #e2e8f0; border-radius: 9999px;"><span style="font-size: 14px; font-weight: 500; color: #475569;">순위·노출을 보장하지 않습니다</span></div>
|
||||
<div style="padding: 8px 18px; background: #F4F6FB; border: 1px solid #e2e8f0; border-radius: 9999px;"><span style="font-size: 14px; font-weight: 500; color: #475569;">단발 측정으로 보고하지 않습니다</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Final CTA ───────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #0A1128;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 96px 24px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; text-align: center;">
|
||||
<div style="font-family: 'Playfair Display', 'Noto Sans KR', Georgia, serif; font-weight: 700; font-size: 44px; line-height: 1.25; color: #ffffff; margin-bottom: 18px;">AI가 당신의 병원을 인용하게 하세요.</div>
|
||||
<div style="font-size: 18px; line-height: 1.7; color: #94a3b8; margin-bottom: 40px;">진단은 무료입니다. 36개 항목 실측 리포트를 받아보고 결정하세요.</div>
|
||||
<div style="display: flex; align-items: center; gap: 14px;">
|
||||
<div style="display: flex; align-items: center; height: 52px; padding: 0 30px; background: #ffffff; border-radius: 9999px;">
|
||||
<span style="font-size: 16px; font-weight: 700; color: #0A1128;">무료 AEO / GEO 진단 받기</span>
|
||||
</div>
|
||||
<span style="font-size: 15px; color: #64748b;">o2oteam@o2o.kr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ──────────────────────────────────────────────────────── -->
|
||||
<div style="display: flex; justify-content: center; background: #ffffff; border-top: 1px solid #f1f5f9;">
|
||||
<div style="width: 100%; max-width: 1120px; padding: 48px 24px; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="font-family: 'Playfair Display', Georgia, serif; font-weight: 900; font-size: 30px; letter-spacing: 0.05em; background: linear-gradient(to right, #4F1DA1, #021341); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent;">INFINITH</span>
|
||||
<span style="font-size: 14px; line-height: 1.7; color: #64748b; text-align: right;">© 2026 INFINITH. All rights reserved.<br>Infinite Marketing for Premium Medical Business & Marketing Agency</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
213
docs/reports/build_taeha_deck.py
Normal file
213
docs/reports/build_taeha_deck.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""
|
||||
법무법인 태하 AI Answer Readiness Report 덱 빌드 (INFINITH Deck 규격)
|
||||
|
||||
python3 docs/reports/build_taeha_deck.py docs/reports/Taeha_AI_Answer_Readiness_Report.pptx
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/Users/haewonkam/.claude/skills/infinith-deck/scripts")
|
||||
from ifd import Deck
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else "Taeha_AI_Answer_Readiness_Report.pptx"
|
||||
d = Deck()
|
||||
|
||||
# 01 Cover
|
||||
d.cover(
|
||||
eyebrow="AI DISCOVERY · AEO / GEO AUDIT v1.0 · LEGAL",
|
||||
title_lines=["AI Answer", "Readiness Report."],
|
||||
subtitle="법무법인 태하 · taehadrug.com · 네이버 마약 변호사 파워링크 1위 · 2026. 08. 28",
|
||||
meta_right="AI Discovery Report · 2026",
|
||||
)
|
||||
|
||||
# 02 Executive summary
|
||||
s = d.slide(eyebrow="Executive Summary",
|
||||
title="잘 만든 사이트를 ", title_accent="ChatGPT가 못 읽는다",
|
||||
lede=["36개 항목 중 31개를 실측했고, 오너 계정이 필요한 5개는 점수 분모에서 제외했습니다.",
|
||||
"구조화·콘텐츠·위생은 상위권. 메인 도메인의 WAF 차단과 도메인 6개 분산이 총점을 끌어내립니다."],
|
||||
section="Summary")
|
||||
k1, k2, k3, k4 = s.cards4(y=s.band_y, h=3.4)
|
||||
k1.kpi("68 / 100", "AI ANSWER READINESS", "등급 B · 60 / 88점")
|
||||
k2.kpi("87%", "F · 기술 위생", "alt 누락 0 · HTML 274KB")
|
||||
k3.kpi("65%", "C · 인용 가능 콘텐츠", "FAQ 14문항 · 전관 경력 텍스트")
|
||||
k4.kpi("403", "메인 도메인 → GPTBot", "taehalaw.com WAF가 AI 검색봇 차단")
|
||||
|
||||
# 03 What AI sees (dark)
|
||||
s = d.slide(eyebrow="What AI Sees · 진단의 결론",
|
||||
title="엔티티가 6개로 ", title_accent="쪼개져 있다", dark=True, section="Findings")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("정상", "good").rows([
|
||||
("마약 사이트 구조화 우수", "JSON-LD 3블록 전부 유효. LegalService·Attorney·FAQPage·Breadcrumb가 @graph로 연결."),
|
||||
("전관 경력이 텍스트로 있다", "지청장·판사·부장검사 역임이 H3로 노출. AI가 읽을 수 있는 권위 신호."),
|
||||
("FAQ 14문항, 실제 질문형", "\"초범인데도 실형이 나올 수 있나요?\" 같은 의뢰인 언어 그대로. FAQPage 스키마 병행."),
|
||||
])
|
||||
R.chip("치명", "critical").rows([
|
||||
("메인 도메인이 ChatGPT를 차단", "taehalaw.com 가비아 WAF가 GPTBot·OAI-SearchBot을 403으로. 브라우저·Perplexity는 200."),
|
||||
("도메인 6개, 전화번호 4종", "마약·형사·이혼·군형법·학폭 사이트가 따로. 콜트래킹 번호와 안심번호가 전부 다름."),
|
||||
("canonical이 http://, 경로 불일치", "메인 canonical http://taehadrug.com/. 성공사례 canonical이 실제 URL과 다름."),
|
||||
])
|
||||
|
||||
# 04 Scorecard
|
||||
s = d.slide(eyebrow="Scorecard · 6 Categories",
|
||||
title="카테고리별 점수와 ", title_accent="검증 범위",
|
||||
lede="점수는 검증된 항목만으로 정규화했습니다. 미검증 항목을 확인하면 총점이 바뀔 수 있습니다.",
|
||||
section="Scorecard")
|
||||
W, Rail = s.cards2(y=s.band_y, split=0.66)
|
||||
W.grid(
|
||||
headers=["카테고리", "배점", "획득", "검증", "판정"],
|
||||
col_w=[4.6, 1.3, 2.0, 1.4, 1.6],
|
||||
rows=[
|
||||
["A · AI 크롤러 접근성", "20", "15.0 / 17", "6 / 7", "양호"],
|
||||
["B · 엔티티 식별·구조화", "20", "13.7 / 20", "7 / 7", "양호"],
|
||||
["C · 답변 인용 가능 콘텐츠", "25", "16.3 / 25", "8 / 8", "양호"],
|
||||
["D · AI 인용 표면 확보", "20", "10.7 / 19", "5 / 6", "보완"],
|
||||
["E · AI 가시성 측정 인프라", "10", "0 / 2", "1 / 4", "미설치"],
|
||||
["F · 기술 위생", "5", "4.3 / 5", "4 / 4", "우수"],
|
||||
],
|
||||
tones=[[None, None, None, None, "good"], [None, None, None, None, "good"],
|
||||
[None, None, None, None, "good"], [None, None, None, None, "warning"],
|
||||
[None, None, None, None, "critical"], [None, None, None, None, "info"]],
|
||||
row_h=0.62,
|
||||
)
|
||||
Rail.title("읽는 법").bullets([
|
||||
"획득 분모는 검증 항목의 배점 합. E는 미검증 3개라 분모가 2점뿐.",
|
||||
"총점 60 / 88 = 68점, 등급 B. 뷰성형외과 48점과 20점 차이.",
|
||||
"A가 88%인 이유: 마약 사이트는 열려 있지만 메인 도메인 WAF 차단으로 A2 감점.",
|
||||
])
|
||||
|
||||
# 05 A 접근성 (dark)
|
||||
s = d.slide(eyebrow="A · Access & Crawlability · 88%",
|
||||
title="마약 사이트는 열려 있고, ", title_accent="메인은 막혀 있다", dark=True, section="A · Access")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("taehadrug.com · 정상", "good").rows([
|
||||
("A1 검색용 AI 봇 허용", "robots.txt User-agent * Allow /. 12종 UA로 실제 GET, 전부 200."),
|
||||
("A4 · A5 정합·sitemap", "http·www 변형 전부 301 수렴. sitemap 326개 URL, lastmod 2026-08-06."),
|
||||
("A7 속도·무게", "TTFB 0.31s, 초기 HTML 274KB. 기준 300KB 이내."),
|
||||
])
|
||||
R.chip("taehalaw.com · 차단", "critical").rows([
|
||||
("A2 WAF가 AI 검색봇 차단", "GPTBot → errdoc.gabia.io/403.html, OAI-SearchBot → modsecurity.html로 302."),
|
||||
("결과", "ChatGPT 검색은 \"법무법인 태하\" 메인 사이트를 읽지 못한다. Perplexity는 읽는다."),
|
||||
("A6 Bing 색인 (미검증)", "ChatGPT 검색은 Bing 인덱스에 의존. Webmaster 등록 여부는 계정 확인 필요."),
|
||||
])
|
||||
|
||||
# 06 B 엔티티
|
||||
s = d.slide(eyebrow="B · Entity & Structured Data · 68%",
|
||||
title="스키마는 좋은데 ", title_accent="도메인끼리 서로 모른다", section="B · Entity")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("우수", "info").rows([
|
||||
("B1 JSON-LD 전부 유효", "Organization·WebSite·LegalService×4·WebPage·Breadcrumb·FAQPage가 @graph로 연결."),
|
||||
("B5 sameAs 6개", "네이버 블로그·Instagram·YouTube·카카오채널·네이버 지도·Google 지도."),
|
||||
("B2 · B3 LegalService·Attorney", "address·geo·areaServed 있음. Attorney 2명 name·jobTitle·knowsAbout."),
|
||||
])
|
||||
R.chip("보완", "warning").rows([
|
||||
("B6 canonical 오류", "http:// 지정. 성공사례 canonical /success-story/ vs 실제 /success/success_story/."),
|
||||
("B3 변호사 6명 중 2명만 구조화", "전관 경력(지청장·판사·부장검사)이 스키마에 없음. openingHours 없음."),
|
||||
("B4 전화 2종, B7 단일 언어", "taehadrug 1533-2403 / taehalaw 1533-1403. hreflang 없음."),
|
||||
])
|
||||
|
||||
# 07 C 콘텐츠 (dark)
|
||||
s = d.slide(eyebrow="C · Answer-Ready Content · 65%",
|
||||
title="FAQ와 사례는 있고, ", title_accent="정의문과 표가 없다", dark=True, section="C · Content")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("우수", "good").rows([
|
||||
("C2 FAQ 14문항 + FAQPage", "\"마약 양이 적어도 구속될 수 있어요?\" 등 의뢰인 질문 그대로 H3."),
|
||||
("C7 전관 경력 텍스트", "지청장·판사·부장검사 역임이 H3로 노출. Attorney 스키마 병행."),
|
||||
("C8 · C6 사례 자기완결·갱신", "성공사례 H2가 [결과] 사건 유형 형태. sitemap 3주 전 갱신."),
|
||||
])
|
||||
R.chip("보완", "warning").rows([
|
||||
("C1 정의문 없음", "H1 \"마약 사건엔 태하가 답입니다\"는 슬로건. 누구·어디·무엇 정의문 없음."),
|
||||
("C5 HTML 표 없음", "\"마약사건 처벌현황\"이 표가 아닌 이미지·블록. 형량 비교표가 있어야 인용된다."),
|
||||
("C4 · C3 수치·제목", "수임료 범위·처리 기간 수치 없음. 첫 H2가 \"#필로폰 #MDMA\" 해시태그."),
|
||||
])
|
||||
|
||||
# 08 D 표면 / E 측정
|
||||
s = d.slide(eyebrow="D · Surfaces / E · Measurement",
|
||||
title="광고는 1위, ", title_accent="브랜드 검색은 경쟁사 차지", section="D · Surfaces")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("D · 표면 56%", "warning").rows([
|
||||
("D4 전화번호 4종", "1533-2403 / 1533-1403 / 0507-1495-4404 / 050-71496-4404. NAP가 안 맞는다."),
|
||||
("D2 GBP 본점 평점 미표시", "수원분사무소는 5.0. 브랜드 검색에 법무법인YK·로엘 스폰서가 상단 노출."),
|
||||
("D1 · D5 로톡·플레이스", "로톡은 변호사 개인 프로필만 확인. 플레이스 리뷰 220건, 분사무소 5곳."),
|
||||
])
|
||||
R.chip("E · 측정 0%", "critical").rows([
|
||||
("E1 크롤러 로그", "Apache 직접 호스팅. AI 봇 히트 추출과 IP 검증 파이프라인 없음."),
|
||||
("E2 GA4 AI 유입 채널", "광고에 utm_source=naver는 쓰고 있음. AI 채널그룹은 미확인, 계정 접근 필요."),
|
||||
("E4 기준선 스팟체크", "미실시. \"마약 변호사\", \"필로폰 초범 집행유예\" 질의 10개 × 5회가 전제."),
|
||||
])
|
||||
|
||||
# 09 Action plan
|
||||
s = d.slide(eyebrow="Action Plan",
|
||||
title="1일 안에 고칠 것과 ", title_accent="4주 안에 만들 것", section="Action Plan")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.chip("P0 · 1일~1주", "critical").title("접근·엔티티·NAP").bullets([
|
||||
"가비아 WAF에서 GPTBot·OAI-SearchBot·Claude-SearchBot 화이트리스트.",
|
||||
"canonical https 교정, 6개 도메인 sameAs 상호 연결.",
|
||||
"대표번호 1개로 홈페이지 6개·플레이스·Google 지도 통일.",
|
||||
"메인 taehalaw.com에 LegalService + Attorney 전원 스키마.",
|
||||
])
|
||||
b.chip("P1 · 4주 내", "warning").title("콘텐츠 구조·측정").bullets([
|
||||
"각 도메인 첫 단락에 정의문. 메인 H1을 서술형으로.",
|
||||
"성공사례 결과 분포·처리 기간을 HTML 표로.",
|
||||
"GA4 AI 채널그룹, 서버로그 IP 검증, GSC·Bing 등록, 기준선 측정.",
|
||||
])
|
||||
c.chip("P2 · 지속", "good").title("신선도·시딩").bullets([
|
||||
"성공사례에 datePublished·dateModified 마크업.",
|
||||
"YouTube @with_lawyer 영상을 사이트 글로 전사.",
|
||||
"티스토리·언론 기고로 naver.com 외 언급 도메인 확보.",
|
||||
"분기 1회 반복 측정. 언급률과 신뢰구간으로만 보고.",
|
||||
])
|
||||
|
||||
# 10 Roadmap (dark)
|
||||
s = d.slide(eyebrow="Roadmap · 8 Weeks",
|
||||
title="8주 안에 ", title_accent="B에서 A로", dark=True, section="Roadmap")
|
||||
s.stats3([
|
||||
("W1–2", "WAF 해제·canonical·sameAs·NAP 통일.\n기준선 스팟체크 1회."),
|
||||
("W3–6", "정의문·HTML 표·메인 H1 재설계.\n측정 인프라 설치."),
|
||||
("W7–8", "재진단. 미검증 5개 반영해\n총점 재산정."),
|
||||
], y=s.band_y + 0.6)
|
||||
|
||||
# 11 What we don't sell
|
||||
s = d.slide(eyebrow="What We Don't Sell · 정직성 규약",
|
||||
title="근거 없는 것은 ", title_accent="팔지 않는다", section="Integrity")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.icon_square().title("만들지 않는 것").bullets([
|
||||
"llms.txt. Google 공식 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음.",
|
||||
"도메인 통합 강요. 분야별 랜딩은 광고 전환에 유리하다. sameAs·NAP로 묶는 쪽이 싸다.",
|
||||
])
|
||||
b.icon_square().title("약속하지 않는 것").bullets([
|
||||
"\"스키마 넣으면 인용됩니다\". 태하는 스키마가 좋은데도 ChatGPT가 메인을 못 읽는다.",
|
||||
"순위·노출 보장. LLM 답변은 동일 질문에도 10~34% 흔들림.",
|
||||
])
|
||||
c.icon_square().title("측정 규율").bullets([
|
||||
"단발 조회로 성과 보고 금지. 반복 측정과 신뢰구간만.",
|
||||
"답변엔진 UI 자동 조회 금지. OpenAI·Perplexity 약관 위반.",
|
||||
"AI 유입은 항상 하한값. 35~70%가 Direct로 유실.",
|
||||
])
|
||||
|
||||
# 12 Method
|
||||
s = d.slide(eyebrow="Method · AEO / GEO Rubric v1.0",
|
||||
title="36개 항목, 100점, ", title_accent="근거 등급을 명시", section="Method")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.icon_square().title("채점 방식").bullets([
|
||||
"항목별 0~3 레벨 × 배점. 미검증은 0점이 아니라 분모 제외.",
|
||||
"A 20 · B 20 · C 25 · D 20 · E 10 · F 5.",
|
||||
"등급 A 81+ · B 61~80 · C 41~60 · D 40 이하.",
|
||||
])
|
||||
b.icon_square().title("법률 업종 적용").bullets([
|
||||
"업종 스키마: LegalService · 전문가 스키마: Attorney.",
|
||||
"버티컬 표면: 로톡·로앤굿 (병의원의 강남언니 자리).",
|
||||
"E-E-A-T: 전관 경력·전문분야 등록을 텍스트로.",
|
||||
])
|
||||
c.icon_square().title("항목마다 붙는 태그").bullets([
|
||||
"근거 등급: 실측 / 학술 / 벤더 / 추정.",
|
||||
"통제 가능성: 직접 / 영향 / 불가.",
|
||||
"채점 방식: 자동 / 반자동 / 수동. 자동 항목은 Edge Function으로 이식.",
|
||||
])
|
||||
|
||||
# 13 Closing
|
||||
d.closing(eyebrow="Next Step",
|
||||
title_lines=["WAF 한 줄이 ", "총점을 가르고 있습니다."],
|
||||
lede="메인 도메인에서 AI 검색봇을 허용하는 데 1일. 그 다음 canonical·sameAs·전화번호 통일까지 1주.",
|
||||
cta_primary="P0 착수하기 →", cta_secondary="리포트 페이지 보기",
|
||||
contact="infinith.kr · hello@infinith.kr")
|
||||
|
||||
d.save(OUT)
|
||||
print("saved", OUT)
|
||||
213
docs/reports/build_viewclinic_deck.py
Normal file
213
docs/reports/build_viewclinic_deck.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""
|
||||
Viewclinic AI Answer Readiness Report 덱 빌드 (INFINITH Deck 규격)
|
||||
|
||||
python3 docs/reports/build_viewclinic_deck.py docs/reports/Viewclinic_AI_Answer_Readiness_Report.pptx
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/Users/haewonkam/.claude/skills/infinith-deck/scripts")
|
||||
from ifd import Deck
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else "Viewclinic_AI_Answer_Readiness_Report.pptx"
|
||||
d = Deck()
|
||||
|
||||
# 01 Cover
|
||||
d.cover(
|
||||
eyebrow="AI DISCOVERY · AEO / GEO AUDIT v1.0",
|
||||
title_lines=["AI Answer", "Readiness Report."],
|
||||
subtitle="뷰성형외과 · viewclinic.com · 36개 항목 실측 · 2026. 08. 28",
|
||||
meta_right="AI Discovery Report · 2026",
|
||||
)
|
||||
|
||||
# 02 Executive summary
|
||||
s = d.slide(eyebrow="Executive Summary",
|
||||
title="읽을 수는 있지만, ", title_accent="인용할 근거가 없다",
|
||||
lede=["36개 항목 중 31개를 실측했고, 오너 계정이 필요한 5개는 점수 분모에서 제외했습니다.",
|
||||
"접근성(A) 92%와 표면(D) 72%는 양호. 엔티티(B) 30%와 콘텐츠(C) 20%가 총점을 끌어내립니다."],
|
||||
section="Summary")
|
||||
k1, k2, k3, k4 = s.cards4(y=s.band_y, h=3.4)
|
||||
k1.kpi("48 / 100", "AI ANSWER READINESS", "등급 C · 42.1 / 88점")
|
||||
k2.kpi("92%", "A · 크롤러 접근성", "AI 검색봇 12종 전부 허용")
|
||||
k3.kpi("20%", "C · 인용 가능 콘텐츠", "정의문 0 · FAQ 0 · HTML 표 0")
|
||||
k4.kpi("5 / 36", "미검증 항목", "GA4 · 서버로그 · GSC · Bing")
|
||||
|
||||
# 03 What AI sees (dark)
|
||||
s = d.slide(eyebrow="What AI Sees · 진단의 결론",
|
||||
title="문제는 차단이 아니라 ", title_accent="내용이다", dark=True, section="Findings")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("정상", "good").rows([
|
||||
("크롤러 접근 정상", "robots.txt가 OAI-SearchBot·PerplexityBot 등 12종을 허용. Cloudflare 차단 없음."),
|
||||
("서버 렌더링 본문", "WordPress가 본문을 서버에서 렌더. JS 없이 10,757자 추출."),
|
||||
("강남언니·YouTube 활발", "강남언니 후기 19,316건, 의료진 26명. YouTube 채널이 최신 영상을 꾸준히 발행."),
|
||||
])
|
||||
R.chip("치명", "critical").rows([
|
||||
("누구인지 확정 불가", "MedicalClinic 스키마 없음, sameAs 비어 있음. 시술 목록 JSON-LD는 문법 오류로 무효."),
|
||||
("인용할 문장 없음", "H1 5개가 전부 영문 라벨. FAQ 0개, HTML 표 0개, 정의문 0개."),
|
||||
("표면 간 NAP 불일치", "GBP에 전화번호 없음. 웹사이트가 viewplasticsurgery.com. 주소 표기 3종."),
|
||||
])
|
||||
|
||||
# 04 Scorecard
|
||||
s = d.slide(eyebrow="Scorecard · 6 Categories",
|
||||
title="카테고리별 점수와 ", title_accent="검증 범위",
|
||||
lede="점수는 검증된 항목만으로 정규화했습니다. 미검증 항목을 확인하면 총점이 바뀔 수 있습니다.",
|
||||
section="Scorecard")
|
||||
W, Rail = s.cards2(y=s.band_y, split=0.66)
|
||||
W.grid(
|
||||
headers=["카테고리", "배점", "획득", "검증", "판정"],
|
||||
col_w=[4.6, 1.3, 2.0, 1.4, 1.6],
|
||||
rows=[
|
||||
["A · AI 크롤러 접근성", "20", "15.7 / 17", "6 / 7", "우수"],
|
||||
["B · 엔티티 식별·구조화", "20", "6.0 / 20", "7 / 7", "미흡"],
|
||||
["C · 답변 인용 가능 콘텐츠", "25", "5.0 / 25", "8 / 8", "치명"],
|
||||
["D · AI 인용 표면 확보", "20", "13.7 / 19", "5 / 6", "양호"],
|
||||
["E · AI 가시성 측정 인프라", "10", "0 / 2", "1 / 4", "미설치"],
|
||||
["F · 기술 위생", "5", "1.7 / 5", "4 / 4", "미흡"],
|
||||
],
|
||||
tones=[[None, None, None, None, "info"], [None, None, None, None, "warning"],
|
||||
[None, None, None, None, "critical"], [None, None, None, None, "good"],
|
||||
[None, None, None, None, "critical"], [None, None, None, None, "warning"]],
|
||||
row_h=0.62,
|
||||
)
|
||||
Rail.title("읽는 법").bullets([
|
||||
"획득 분모는 검증 항목의 배점 합. E는 미검증 3개라 분모가 2점뿐.",
|
||||
"총점 42.1 / 88 = 48점, 등급 C.",
|
||||
"미검증 5개는 GA4·서버로그·GSC·Bing 계정 접근으로 1일 안에 채운다.",
|
||||
])
|
||||
|
||||
# 05 A 접근성
|
||||
s = d.slide(eyebrow="A · Access & Crawlability · 92%",
|
||||
title="크롤러 접근은 ", title_accent="열려 있다", section="A · Access")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("우수 · 3/3", "info").rows([
|
||||
("A1 검색용 AI 봇 허용", "robots.txt Disallow는 /wp-admin/ 뿐. 12종 UA로 실제 GET, 전부 200."),
|
||||
("A2 CDN 차단 없음", "nginx 직접 응답. Cloudflare 헤더와 AI UA 챌린지 없음."),
|
||||
("A4 HTTPS 정합", "http://viewclinic.com → 301 → https://www.viewclinic.com/ 정본 수렴."),
|
||||
])
|
||||
R.chip("보완 · 2/3", "warning").rows([
|
||||
("A5 sitemap.xml", "선언·200 응답, 페이지 249개. 단 post lastmod 최신이 2024-05-31."),
|
||||
("A7 응답 속도·무게", "TTFB 0.135s는 양호. 초기 HTML 838KB로 기준 300KB의 2.8배."),
|
||||
("A6 Bing 색인 (미검증)", "ChatGPT 검색은 Bing 인덱스에 의존. Webmaster 등록 여부는 계정 확인 필요."),
|
||||
])
|
||||
|
||||
# 06 B 엔티티 (dark)
|
||||
s = d.slide(eyebrow="B · Entity & Structured Data · 30%",
|
||||
title="AI가 이 병원을 ", title_accent="하나의 엔티티로 못 묶는다", dark=True, section="B · Entity")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("치명", "critical").rows([
|
||||
("B1 JSON-LD 문법 무효", "ItemList 블록 쉼표 누락(line 11). 시술 목록 구조화 데이터 전체가 파서에서 버려짐."),
|
||||
("B2 MedicalClinic 없음", "Organization·WebSite만 존재. 주소·전화·진료시간·전문분야가 구조화되지 않음."),
|
||||
("B3 Physician 없음", "의료진 정보는 텍스트와 이미지로만 존재. 누가 수술하는지 기계가 읽지 못함."),
|
||||
])
|
||||
R.chip("미흡", "warning").rows([
|
||||
("B5 sameAs 비어 있음", "푸터에 YouTube·Instagram·Facebook 링크가 있으나 스키마에 연결되지 않음."),
|
||||
("B4 NAP 표기 2종", "\"서울 강남구 봉은사로 107\" / \"서울특별시 강남구 봉은사로 107,\" 혼재."),
|
||||
("B7 영문 사이트 미연결", "viewplasticsurgery.com이 별도로 있으나 hreflang 없음. 두 도메인이 서로 모름."),
|
||||
])
|
||||
|
||||
# 07 C 콘텐츠
|
||||
s = d.slide(eyebrow="C · Answer-Ready Content · 20%",
|
||||
title="답변엔진이 잘라 갈 ", title_accent="단락이 없다", section="C · Content")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("0점 항목", "critical").rows([
|
||||
("C1 정의문 없음", "메인 첫 화면은 슬라이더와 \"VIEW SELFIE\" 라벨. 시술 페이지 첫 텍스트는 상담 폼."),
|
||||
("C2 FAQ 없음", "메인·가슴성형 페이지에서 FAQ·Q&A 섹션이 탐지되지 않음."),
|
||||
("C3 H1 5개, 전부 라벨", "VIEW SELFIE / EVENT / SHORTS / FILM / SPECIALITY. 시술 페이지는 H1 없음."),
|
||||
])
|
||||
R.chip("부분 점수", "warning").rows([
|
||||
("C7 의료진 권위 신호 있음", "모티바 KOL·복지부 표창·갈더마 방문 등 17회 언급. 단 구조화되지 않음."),
|
||||
("C4 수치 표기 미흡", "\"보형물 사용량 1위\" 같은 주장형. 가격대·수술시간·회복기간 표 없음."),
|
||||
("C6 신선도 신호 약함", "dateModified 없음. 유튜브 활동이 사이트 갱신으로 전달되지 않음."),
|
||||
])
|
||||
|
||||
# 08 D 표면 / E 측정 (dark)
|
||||
s = d.slide(eyebrow="D · Surfaces / E · Measurement",
|
||||
title="강남언니는 강하고, ", title_accent="GBP는 전화번호가 없다", dark=True, section="D · Surfaces")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("D · 표면 72%", "good").rows([
|
||||
("D1 강남언니 우수", "후기 19,316건, 의료진 26명, 이벤트 52건. 병의원 AI 인용의 핵심 표면 확보."),
|
||||
("D2 GBP 4.9점 (901)", "소유자 게시물 활성. 단 전화번호 미등록, 웹사이트가 viewplasticsurgery.com."),
|
||||
("D4 NAP 표기 3종", "홈페이지·강남언니·GBP 주소 표기가 전부 다름. 한 병원으로 묶을 근거 약함."),
|
||||
])
|
||||
R.chip("E · 측정 0%", "critical").rows([
|
||||
("E1 크롤러 로그", "nginx 직접 호스팅. AI 봇 히트 추출과 IP 검증 파이프라인 없음."),
|
||||
("E2 GA4 AI 유입 채널", "chatgpt.com·perplexity.ai 커스텀 채널그룹 없음. 계정 접근 필요."),
|
||||
("E4 기준선 스팟체크", "미실시. 질의 10개 × 5회 반복 측정이 개선 증명의 전제."),
|
||||
])
|
||||
|
||||
# 09 Action plan
|
||||
s = d.slide(eyebrow="Action Plan",
|
||||
title="즉시 고칠 것과 ", title_accent="4주 안에 만들 것", section="Action Plan")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.chip("P0 · 1일~1주", "critical").title("엔티티·표면·측정").bullets([
|
||||
"깨진 JSON-LD 수정, MedicalClinic 스키마 삽입, sameAs 연결.",
|
||||
"GBP 전화번호 등록, 웹사이트를 viewclinic.com으로 교정.",
|
||||
"4개 표면 NAP를 한 글자까지 통일. 두 도메인 hreflang 연결.",
|
||||
"GA4 AI 채널그룹, 서버로그 IP 검증, GSC·Bing 등록.",
|
||||
])
|
||||
b.chip("P1 · 4주 내", "warning").title("콘텐츠 구조").bullets([
|
||||
"핵심 페이지 첫 단락에 정의문. 시술별 FAQ 5~8개와 FAQPage 스키마.",
|
||||
"페이지당 H1 1개 서술형. 비교표를 HTML table로. Physician 스키마.",
|
||||
"WordPress 5.5.3 업그레이드, alt 누락 111개 보완, HTML 무게 절감.",
|
||||
])
|
||||
c.chip("P2 · 지속", "good").title("신선도·시딩").bullets([
|
||||
"dateModified 마크업 활성화, 월 1회 사이트 갱신.",
|
||||
"유튜브 영상을 사이트 글로 전사해 신선도를 전달.",
|
||||
"티스토리·언론 기고로 naver.com 외 언급 도메인 확보.",
|
||||
"분기 1회 반복 측정. 언급률과 신뢰구간으로만 보고.",
|
||||
])
|
||||
|
||||
# 10 Roadmap (stats style, dark)
|
||||
s = d.slide(eyebrow="Roadmap · 8 Weeks",
|
||||
title="8주 안에 ", title_accent="C에서 B로", dark=True, section="Roadmap")
|
||||
s.stats3([
|
||||
("W1–2", "스키마·NAP·측정 인프라 설치.\n기준선 스팟체크 1회."),
|
||||
("W3–6", "정의문·FAQ·H1 재설계·HTML 표.\nWordPress 업그레이드."),
|
||||
("W7–8", "재진단. 미검증 5개 반영해\n총점 재산정."),
|
||||
], y=s.band_y + 0.6)
|
||||
|
||||
# 11 What we don't sell
|
||||
s = d.slide(eyebrow="What We Don't Sell · 정직성 규약",
|
||||
title="근거 없는 것은 ", title_accent="팔지 않는다", section="Integrity")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.icon_square().title("만들지 않는 것").bullets([
|
||||
"llms.txt. Google 공식 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음.",
|
||||
"네이버 블로그 증량. robots.txt가 AI 크롤링을 전면 금지, 글로벌 AI 기여도 0.",
|
||||
])
|
||||
b.icon_square().title("약속하지 않는 것").bullets([
|
||||
"\"스키마 넣으면 인용됩니다\". 인과 증거 없음, 위생 조치로만 설명.",
|
||||
"순위·노출 보장. LLM 답변은 동일 질문에도 10~34% 흔들림.",
|
||||
])
|
||||
c.icon_square().title("측정 규율").bullets([
|
||||
"단발 조회로 성과 보고 금지. 반복 측정과 신뢰구간만.",
|
||||
"답변엔진 UI 자동 조회 금지. OpenAI·Perplexity 약관 위반.",
|
||||
"AI 유입은 항상 하한값. 35~70%가 Direct로 유실.",
|
||||
])
|
||||
|
||||
# 12 Method
|
||||
s = d.slide(eyebrow="Method · AEO / GEO Rubric v1.0",
|
||||
title="36개 항목, 100점, ", title_accent="근거 등급을 명시", section="Method")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.icon_square().title("채점 방식").bullets([
|
||||
"항목별 0~3 레벨 × 배점. 미검증은 0점이 아니라 분모 제외.",
|
||||
"A 20 · B 20 · C 25 · D 20 · E 10 · F 5.",
|
||||
"등급 A 81+ · B 61~80 · C 41~60 · D 40 이하.",
|
||||
])
|
||||
b.icon_square().title("배점 근거").bullets([
|
||||
"차단되면 절대 인용될 수 없다는 사실은 논쟁이 없다. A·B에 무게.",
|
||||
"GEO 콘텐츠 전술의 효과는 학술적으로 논쟁 중. C는 좋은 콘텐츠 원칙으로만.",
|
||||
"병의원은 강남언니가 인용을 장악. D1 단일 항목 최고 배점 6.",
|
||||
])
|
||||
c.icon_square().title("항목마다 붙는 태그").bullets([
|
||||
"근거 등급: 실측 / 학술 / 벤더 / 추정.",
|
||||
"통제 가능성: 직접 / 영향 / 불가.",
|
||||
"채점 방식: 자동 / 반자동 / 수동. 자동 항목은 Edge Function으로 이식.",
|
||||
])
|
||||
|
||||
# 13 Closing
|
||||
d.closing(eyebrow="Next Step",
|
||||
title_lines=["표면은 확보됐습니다.", "홈페이지가 따라가면 됩니다."],
|
||||
lede="P0 세 가지: 스키마, NAP 통일, 측정 인프라. 미검증 5개는 계정 접근 후 1일 안에 채웁니다.",
|
||||
cta_primary="P0 착수하기 →", cta_secondary="리포트 페이지 보기",
|
||||
contact="infinith.kr · hello@infinith.kr")
|
||||
|
||||
d.save(OUT)
|
||||
print("saved", OUT)
|
||||
210
docs/reports/build_viewclinic_poc_deck.py
Normal file
210
docs/reports/build_viewclinic_poc_deck.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""
|
||||
Viewclinic AI Discovery POC Proposal 덱 빌드 (INFINITH Deck 규격, 10페이지)
|
||||
|
||||
제안 4장 + 진단 리포트 요약 5장 + 클로징 1장.
|
||||
본문 텍스트는 기본값 대비 +2pt (rows/bullets 18, grid 16.25, quote 19.25).
|
||||
|
||||
python3 docs/reports/build_viewclinic_poc_deck.py docs/reports/Viewclinic_POC_Proposal.pptx
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/Users/haewonkam/.claude/skills/infinith-deck/scripts")
|
||||
import ifd
|
||||
from ifd import Deck
|
||||
|
||||
BODY = 18.0 # rows 본문·불릿 (기본 16 + 2)
|
||||
GRID = 16.25 # 스코어카드 격자 셀 (기본 14.25 + 2)
|
||||
QUOTE = 19.25 # 인용 본문 (기본 17.25 + 2)
|
||||
|
||||
|
||||
def quote_up(s, text, who, x=1.25, y=6.63, w=17.5, h=1.83):
|
||||
"""ifd.Slide.quote 와 동일 레이아웃, 본문만 +2pt."""
|
||||
ifd._rect(s._raw, x, y, w, h, fill=ifd.LIGHT_BG if not s.dark else ifd.DARK_CARD, line=None, radius=0)
|
||||
ifd._rect(s._raw, x, y, 0.04, h, fill=ifd.VIOLET, line=None, radius=0)
|
||||
ifd._check(text, w - 0.5, QUOTE, 2, "quote")
|
||||
ifd._write(ifd._tb(s._raw, x + 0.28, y + 0.25, w - 0.5, h - 0.9), text, QUOTE, ifd.SANS,
|
||||
color=ifd.WHITE if s.dark else ifd.INK, line_spacing=1.2)
|
||||
ifd._write(ifd._tb(s._raw, x + 0.28, y + h - 0.6, w - 0.5, 0.44), who, 18.0, ifd.SANS,
|
||||
color=ifd.MUTED_DK if s.dark else ifd.MUTED)
|
||||
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else "Viewclinic_POC_Proposal.pptx"
|
||||
d = Deck()
|
||||
|
||||
# 01 Cover
|
||||
d.cover(
|
||||
eyebrow="PROOF OF CONCEPT · POC PROPOSAL",
|
||||
title_lines=["AI Discovery,", "AEO / GEO."],
|
||||
subtitle="뷰성형외과 × INFINITH · AI 답변엔진 노출 구축 검증 제안 · 2026. 08. 31",
|
||||
meta_right="AI Discovery Proposal · 2026",
|
||||
)
|
||||
|
||||
# 02 Why now (dark)
|
||||
s = d.slide(eyebrow="Why Now · AEO / GEO",
|
||||
title="고객은 이미 AI에게 ", title_accent="병원을 묻습니다",
|
||||
lede=["검색은 링크 목록에서 하나의 답변으로 바뀌었습니다.",
|
||||
"답변에 인용되지 못한 병원은 비교 대상에도 오르지 못합니다."],
|
||||
dark=True, section="Why Now")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("시장의 변화", "info").rows([
|
||||
("검색이 답변으로 바뀌었다", "ChatGPT·Perplexity·네이버 AI 브리핑이 병원을 직접 추천. 클릭 없이 답변만 소비되는 비중이 커지는 중."),
|
||||
("AI는 인용으로만 노출한다", "답변엔진은 크롤링 가능한 오픈웹 근거를 골라 인용. 광고 입찰이 없어 근거를 먼저 갖춘 병원이 선점."),
|
||||
("네이버 자산은 기여 0", "네이버 블로그·플레이스는 robots.txt로 AI 크롤러를 전면 차단(2026-08 실측). 기존 네이버 마케팅이 AI 답변에 닿지 않음."),
|
||||
], b_size=BODY)
|
||||
R.chip("뷰성형외과 실측 · 48 / 100 · C", "critical").rows([
|
||||
("읽을 수는 있다", "robots.txt가 AI 검색봇 12종을 전부 허용. 크롤러 접근성 92%로 기술적 전제는 이미 확보."),
|
||||
("인용할 근거가 없다", "MedicalClinic 스키마 없음, FAQ 0개, 정의문 0개. 답변 인용 가능 콘텐츠 점수 20%."),
|
||||
("성과를 잴 수도 없다", "AI 유입 측정 인프라 0%. AI가 보내는 환자를 세는 장치 자체가 아직 없음."),
|
||||
], b_size=BODY)
|
||||
|
||||
# 03 Offering — 랜딩 히어로 스크린샷 + 3스텝
|
||||
from pptx.util import Inches
|
||||
|
||||
SHOT = "/Users/haewonkam/orca/workspaces/INFINITH/AEO-GEO-Audit/docs/reports/shots"
|
||||
s = d.slide(eyebrow="Our Offering · AI Discovery",
|
||||
title="진단하고, 구축하고, ", title_accent="숫자로 증명합니다",
|
||||
lede="AI Discovery는 36개 항목 실측 진단, 우선순위 구축, 반복 측정 보고까지 한 파이프라인으로 제공합니다.",
|
||||
section="Offering")
|
||||
img_h = 9.9 - s.band_y
|
||||
img_w = img_h * (1440 / 1384)
|
||||
ifd._rect(s._raw, 1.25 - 0.05, s.band_y - 0.05, img_w + 0.10, img_h + 0.10,
|
||||
fill="FFFFFF", line=ifd.CARD_LINE, radius=0.06)
|
||||
s._raw.shapes.add_picture(f"{SHOT}/land_hero.png", Inches(1.25), Inches(s.band_y), height=Inches(img_h))
|
||||
rx = 1.25 + img_w + 0.40
|
||||
R = ifd.Card(s, rx, s.band_y, 18.75 - rx, img_h, s.dark)
|
||||
R.rows([
|
||||
("STEP 1 · Audit", "AEO/GEO 기준표 v1.0으로 36개 항목 실측. 뷰성형외과 1차 진단 완료, 48점 등급 C."),
|
||||
("STEP 2 · Build", "스키마 삽입, NAP 통일, 정의문·FAQ 구축. AI가 인용할 근거를 홈페이지에 만듭니다."),
|
||||
("STEP 3 · Prove", "GA4·서버로그·반복 스팟체크 설치. 언급률과 신뢰구간으로만 보고합니다."),
|
||||
], b_size=BODY)
|
||||
|
||||
# 04 What you get (KPI + quote)
|
||||
s = d.slide(eyebrow="What You Get · 8 Weeks",
|
||||
title="8주 후, 이렇게 ", title_accent="달라집니다",
|
||||
lede="목표는 두 가지입니다. 인용될 기술적 전제를 완성하고, AI가 보내는 환자를 처음으로 계측합니다.",
|
||||
section="Outcome")
|
||||
k1, k2, k3, k4 = s.cards4(y=s.band_y, h=2.55)
|
||||
k1.kpi("48 → 61+", "AI ANSWER READINESS", "등급 C에서 B로")
|
||||
k2.kpi("100%", "AI 유입 측정 인프라", "현재 0%. GA4·서버로그 설치")
|
||||
k3.kpi("36 / 36", "검증 항목 커버리지", "미검증 5개를 계정 연동으로 해소")
|
||||
k4.kpi("19,316", "강남언니 후기", "기존 자산을 AI 인용 표면으로 연결")
|
||||
quote_up(s, "스키마를 넣으면 인용된다고 약속하지 않습니다. 인용될 수 없는 이유를 전부 제거하고, 결과를 반복 측정으로 증명합니다.",
|
||||
"INFINITH 정직성 규약")
|
||||
|
||||
# ── 진단 리포트 요약 5장 ──────────────────────────────────────────────────────
|
||||
|
||||
# 05 Report 1 · Executive summary
|
||||
s = d.slide(eyebrow="Report 1 / 5 · Executive Summary",
|
||||
title="읽을 수는 있지만, ", title_accent="인용할 근거가 없습니다",
|
||||
lede=["36개 항목 중 31개를 실측했고, 오너 계정이 필요한 5개는 점수 분모에서 제외했습니다.",
|
||||
"접근성(A) 92%와 표면(D) 72%는 양호. 엔티티(B) 30%와 콘텐츠(C) 20%가 총점을 끌어내립니다."],
|
||||
section="Report · Summary")
|
||||
img_h = 9.9 - s.band_y
|
||||
img_w = img_h * (1440 / 912)
|
||||
ix = 1.25 + (17.5 - img_w) / 2
|
||||
ifd._rect(s._raw, ix - 0.05, s.band_y - 0.05, img_w + 0.10, img_h + 0.10,
|
||||
fill="FFFFFF", line=ifd.CARD_LINE, radius=0.06)
|
||||
s._raw.shapes.add_picture(f"{SHOT}/land_report.png", Inches(ix), Inches(s.band_y), height=Inches(img_h))
|
||||
|
||||
# 06 Report 2 · What AI sees (dark)
|
||||
s = d.slide(eyebrow="Report 2 / 5 · What AI Sees",
|
||||
title="문제는 차단이 아니라 ", title_accent="내용입니다", dark=True, section="Report · Findings")
|
||||
L, R = s.cards2(y=s.band_y)
|
||||
L.chip("정상", "good").rows([
|
||||
("크롤러 접근 정상", "robots.txt가 OAI-SearchBot·PerplexityBot 등 12종을 허용. Cloudflare 차단 없음."),
|
||||
("서버 렌더링 본문", "WordPress가 본문을 서버에서 렌더. JS 없이 10,757자 추출."),
|
||||
("강남언니·YouTube 활발", "강남언니 후기 19,316건, 의료진 26명. YouTube 채널이 영상을 꾸준히 발행."),
|
||||
], b_size=BODY)
|
||||
R.chip("치명", "critical").rows([
|
||||
("누구인지 확정 불가", "MedicalClinic 스키마 없음, sameAs 비어 있음. 시술 목록 JSON-LD는 문법 오류로 무효."),
|
||||
("인용할 문장 없음", "H1 5개가 전부 영문 라벨. FAQ 0개, HTML 표 0개, 정의문 0개."),
|
||||
("표면 간 NAP 불일치", "GBP에 전화번호 없음. 웹사이트가 viewplasticsurgery.com. 주소 표기 3종."),
|
||||
], b_size=BODY)
|
||||
|
||||
# 07 Report 3 · Scorecard
|
||||
s = d.slide(eyebrow="Report 3 / 5 · Scorecard · 6 Categories",
|
||||
title="카테고리별 점수와 ", title_accent="검증 범위를 정리했습니다",
|
||||
lede="점수는 검증된 항목만으로 정규화했습니다. 미검증 항목을 확인하면 총점이 바뀔 수 있습니다.",
|
||||
section="Report · Scorecard")
|
||||
W, Rail = s.cards2(y=s.band_y, split=0.66)
|
||||
W.grid(
|
||||
headers=["카테고리", "배점", "획득", "검증", "판정"],
|
||||
col_w=[4.6, 1.3, 2.0, 1.4, 1.6],
|
||||
rows=[
|
||||
["A · AI 크롤러 접근성", "20", "15.7 / 17", "6 / 7", "우수"],
|
||||
["B · 엔티티 식별·구조화", "20", "6.0 / 20", "7 / 7", "미흡"],
|
||||
["C · 답변 인용 가능 콘텐츠", "25", "5.0 / 25", "8 / 8", "치명"],
|
||||
["D · AI 인용 표면 확보", "20", "13.7 / 19", "5 / 6", "양호"],
|
||||
["E · AI 가시성 측정 인프라", "10", "0 / 2", "1 / 4", "미설치"],
|
||||
["F · 기술 위생", "5", "1.7 / 5", "4 / 4", "미흡"],
|
||||
],
|
||||
tones=[[None, None, None, None, "info"], [None, None, None, None, "warning"],
|
||||
[None, None, None, None, "critical"], [None, None, None, None, "good"],
|
||||
[None, None, None, None, "critical"], [None, None, None, None, "warning"]],
|
||||
row_h=0.62, size=GRID,
|
||||
)
|
||||
Rail.title("읽는 법").bullets([
|
||||
"획득 분모는 검증 항목의 배점 합. E는 미검증 3개라 분모가 2점뿐.",
|
||||
"총점 42.1 / 88 = 48점, 등급 C.",
|
||||
"미검증 5개는 계정 접근으로 1일 안에 채웁니다.",
|
||||
], size=BODY)
|
||||
|
||||
# 08 Report 4 · Action plan
|
||||
s = d.slide(eyebrow="Report 4 / 5 · Action Plan",
|
||||
title="즉시 고칠 것과 ", title_accent="4주 안에 만들 것을 나눴습니다", section="Report · Action Plan")
|
||||
a, b, c = s.cards3(y=s.band_y)
|
||||
a.chip("P0 · 1일~1주", "critical").title("엔티티·표면·측정").bullets([
|
||||
"깨진 JSON-LD 수정, MedicalClinic 스키마, sameAs 연결.",
|
||||
"GBP 전화번호 등록, 웹사이트를 viewclinic.com으로 교정.",
|
||||
"4개 표면 NAP를 한 글자까지 통일. 두 도메인 hreflang 연결.",
|
||||
"GA4 AI 채널그룹, 서버로그 IP 검증, GSC·Bing 등록.",
|
||||
], size=BODY)
|
||||
b.chip("P1 · 4주 내", "warning").title("콘텐츠 구조").bullets([
|
||||
"핵심 페이지 첫 단락에 정의문. 시술별 FAQ 5~8개.",
|
||||
"페이지당 H1 1개 서술형. 비교표를 HTML table로.",
|
||||
"WordPress 업그레이드, alt 누락 111개 보완, HTML 무게 절감.",
|
||||
], size=BODY)
|
||||
c.chip("P2 · 지속", "good").title("신선도·시딩").bullets([
|
||||
"dateModified 마크업 활성화, 월 1회 사이트 갱신.",
|
||||
"유튜브 영상을 사이트 글로 전사해 신선도를 전달.",
|
||||
"티스토리·언론 기고로 naver.com 외 언급 도메인 확보.",
|
||||
"분기 1회 반복 측정. 언급률과 신뢰구간으로만 보고.",
|
||||
], size=BODY)
|
||||
|
||||
# 09 Report 5 · Roadmap (dark)
|
||||
s = d.slide(eyebrow="Report 5 / 5 · Roadmap · 8 Weeks",
|
||||
title="8주 안에 ", title_accent="C에서 B로 올립니다", dark=True, section="Report · Roadmap")
|
||||
s.stats3([
|
||||
("W1–2", "스키마·NAP·측정 인프라 설치.\n기준선 스팟체크 1회."),
|
||||
("W3–6", "정의문·FAQ·H1 재설계·HTML 표.\nWordPress 업그레이드."),
|
||||
("W7–8", "재진단. 미검증 5개 반영해\n총점 재산정."),
|
||||
], y=s.band_y + 0.6)
|
||||
|
||||
# 10 Closing — 제안서 포맷 (웹 CTA 버튼 없음, 다음 단계 3줄)
|
||||
raw = d._new(False)
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN, 2.12, ifd.CONTENT_W, 0.47), "NEXT STEP · POC PROPOSAL",
|
||||
ifd.T_EYEBROW, ifd.SANS, bold=True, color=ifd.VIOLET, spc=300)
|
||||
_title = ["진단은 끝났습니다.", "8주로 증명하겠습니다."]
|
||||
_th = 96 / 72 * 1.2 * 2
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN, 2.97, ifd.CONTENT_W, _th), _title, 96.0, ifd.SANS,
|
||||
bold=True, color=ifd.INK, line_spacing=0.98)
|
||||
_y = 2.97 + _th + 0.35
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN, _y, ifd.CONTENT_W, 0.62),
|
||||
"8주 구축과 재진단으로 C를 B로 올리고, 전 과정을 숫자로 보고합니다.",
|
||||
27.75, ifd.SANS, color=ifd.BODY)
|
||||
_y += 1.10
|
||||
for num, label, desc in [
|
||||
("01", "검토", "본 제안서와 36개 항목 진단 리포트 전문을 검토합니다."),
|
||||
("02", "계정 연동", "GA4·서버로그·GSC·Bing 권한 공유 시 미검증 5개 항목을 1일 안에 확정합니다."),
|
||||
("03", "킥오프", "W1 스키마·NAP·측정 인프라 설치부터 착수합니다."),
|
||||
]:
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN, _y, 0.75, 0.45), num, 19.5, ifd.SANS, bold=True, color=ifd.VIOLET)
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN + 0.85, _y, 2.1, 0.45), label, 19.5, ifd.SANS, bold=True, color=ifd.INK)
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN + 3.05, _y, 14.4, 0.45), desc, 19.5, ifd.SANS, color=ifd.BODY)
|
||||
_y += 0.60
|
||||
ifd._write(ifd._tb(raw, ifd.MARGIN, ifd.Y_FOOTER - 0.1, 6.0, 0.6), "INFINITH", 30.0, ifd.SERIF,
|
||||
bold=True, color=ifd.INK, spc=300)
|
||||
ifd._write(ifd._tb(raw, 11.0, ifd.Y_FOOTER, 7.75, 0.49), "o2oteam@o2o.kr", 20.25, ifd.SANS,
|
||||
color=ifd.MUTED, align=ifd.PP_ALIGN.RIGHT)
|
||||
|
||||
d.save(OUT)
|
||||
print("saved", OUT)
|
||||
69
docs/reports/export_html.py
Normal file
69
docs/reports/export_html.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""
|
||||
/discovery/:id 리포트 페이지를 단일 HTML 파일로 내보낸다.
|
||||
|
||||
npm run build && npx vite preview --port 3012
|
||||
python3 docs/reports/export_html.py taeha docs/reports/Taeha_AI_Answer_Readiness_Report.html
|
||||
|
||||
- 헤드리스 Chrome으로 렌더된 DOM을 덤프하고 빌드 CSS를 인라인한다
|
||||
- Navbar·Footer·스크립트 제거, motion 애니메이션 고정
|
||||
- ScoreRing 오프셋과 카테고리 진행 바 폭을 점수 텍스트에서 다시 계산한다
|
||||
"""
|
||||
import glob, re, subprocess, sys
|
||||
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
BASE = "http://localhost:3012"
|
||||
|
||||
slug, out = sys.argv[1], sys.argv[2]
|
||||
title = sys.argv[3] if len(sys.argv) > 3 else f"{slug} AI Answer Readiness Report"
|
||||
|
||||
dom = subprocess.run(
|
||||
[CHROME, "--headless=new", "--disable-gpu", "--window-size=1440,900", "--virtual-time-budget=20000",
|
||||
"--dump-dom", f"{BASE}/discovery/{slug}"],
|
||||
capture_output=True, text=True).stdout
|
||||
css = open(glob.glob("dist/assets/*.css")[0], encoding="utf-8").read()
|
||||
|
||||
m = re.search(r'<div id="root">(.*)</div>\s*</body>', dom, re.S)
|
||||
body = m.group(1)
|
||||
body = re.sub(r"<script.*?</script>", "", body, flags=re.S)
|
||||
body = re.sub(r"<nav\b.*?</nav>", "", body, flags=re.S, count=1)
|
||||
body = re.sub(r"<footer\b.*?</footer>", "", body, flags=re.S)
|
||||
|
||||
# ScoreRing: dashoffset = circumference * (1 - score/100)
|
||||
def fix_ring(mm):
|
||||
circ = float(mm.group(2)); score = int(mm.group(5))
|
||||
return f"{mm.group(1)}{circ * (1 - min(score, 100) / 100):.4f}{mm.group(4)}{score}{mm.group(6)}"
|
||||
body, n_ring = re.subn(
|
||||
r'(stroke-dasharray="([\d.]+)"\s+stroke-dashoffset=")([\d.]+)("></circle></svg><div class="absolute inset-0 flex items-center justify-center"><span[^>]*>)(\d+)(</span>)',
|
||||
fix_ring, body)
|
||||
|
||||
# 카테고리 바: <div class="h-full rounded-full" style="background: X; width: 0%;"> … <span class="font-semibold">NN%</span>
|
||||
def fix_bar(mm):
|
||||
return f'{mm.group(1)}width: {mm.group(3)}%;{mm.group(4)}{mm.group(3)}%{mm.group(5)}'
|
||||
body, n_bar = re.subn(
|
||||
r'(<div class="h-full rounded-full" style="[^"]*?)width:\s*[\d.]+%;?([^"]*"[^>]*></div></div><div class="flex justify-between mt-2 text-sm"><span class="font-semibold">)(\d+)(%</span>)',
|
||||
lambda mm: f'{mm.group(1)}width: {mm.group(3)}%;{mm.group(2)}{mm.group(3)}{mm.group(4)}', body)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
|
||||
<style>{css}</style>
|
||||
<style>
|
||||
[style*="opacity"], [style*="transform"] {{ opacity: 1 !important; transform: none !important; }}
|
||||
html, body {{ background: #fff; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen bg-white">
|
||||
{body}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
open(out, "w", encoding="utf-8").write(html)
|
||||
print(f"saved {out} ({len(html):,} bytes) rings={n_ring} bars={n_bar}")
|
||||
BIN
docs/reports/shots/land_hero.png
Normal file
BIN
docs/reports/shots/land_hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 701 KiB |
BIN
docs/reports/shots/land_report.png
Normal file
BIN
docs/reports/shots/land_report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
37
src/components/Badge.tsx
Normal file
37
src/components/Badge.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Badge — 가격표 등에서 쓰는 강조 배지.
|
||||
* PricingPage가 import하던 컴포넌트가 저장소에 누락되어 빌드가 깨져 있어 복원 (2026-08-28).
|
||||
*/
|
||||
type BadgeVariant = 'popular' | 'new' | 'recommended';
|
||||
type BadgeSize = 'sm' | 'md';
|
||||
|
||||
const LABEL: Record<BadgeVariant, string> = {
|
||||
popular: 'Most Popular',
|
||||
new: 'New',
|
||||
recommended: 'Recommended',
|
||||
};
|
||||
|
||||
const SIZE: Record<BadgeSize, string> = {
|
||||
sm: 'text-[10px] px-2.5 py-0.5',
|
||||
md: 'text-xs px-3 py-1',
|
||||
};
|
||||
|
||||
export default function Badge({
|
||||
variant = 'popular',
|
||||
size = 'md',
|
||||
label,
|
||||
className = '',
|
||||
}: {
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-semibold tracking-wide text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] ${SIZE[size]} ${className}`}
|
||||
>
|
||||
{label ?? LABEL[variant]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -132,7 +132,7 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
||||
gangnamUnni: aggregated.gangnamUnni.length ? aggregated.gangnamUnni : undefined,
|
||||
},
|
||||
rawInput: Object.entries(urls)
|
||||
.filter(([, v]) => v.trim())
|
||||
.filter(([, v]) => typeof v === 'string' && v.trim() !== '')
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n'),
|
||||
};
|
||||
|
||||
112
src/components/discovery/AbmrLoop.tsx
Normal file
112
src/components/discovery/AbmrLoop.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* ABMR 루프 카드 (다크) — 히어로 좌측.
|
||||
* Solution.tsx의 원형 루프 다이어그램 구조를 복제하고 워딩만 AGDP → ABMR로 교체.
|
||||
* 점수·등급 칩은 discoveryScore 계산값에서만 받는다 (하드코딩 금지).
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import type { OverallScore } from '../../types/discovery';
|
||||
|
||||
const NODES = [
|
||||
{ letter: 'A', label: 'Audit', pos: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2' },
|
||||
{ letter: 'B', label: 'Build', pos: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2' },
|
||||
{ letter: 'M', label: 'Measure', pos: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2' },
|
||||
{ letter: 'R', label: 'Refine', pos: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2' },
|
||||
];
|
||||
|
||||
interface AbmrLoopProps {
|
||||
overall: OverallScore;
|
||||
/** 다음 등급 목표 (A등급이면 null) */
|
||||
target: { score: number; grade: OverallScore['grade'] } | null;
|
||||
}
|
||||
|
||||
export default function AbmrLoop({ overall, target }: AbmrLoopProps) {
|
||||
const chips = [
|
||||
...(target
|
||||
? [`점수 ${overall.score} → ${target.score}+`, `등급 ${overall.grade} → ${target.grade}`]
|
||||
: [`점수 ${overall.score} 유지 검증`, `등급 ${overall.grade} 유지 검증`]),
|
||||
'언급률 상승 추적',
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="relative overflow-hidden bg-[#0A1128] rounded-2xl px-8 py-7 flex flex-col items-center text-left"
|
||||
>
|
||||
{/* Background glow */}
|
||||
<div className="absolute top-[45%] left-1/2 -translate-x-1/2 -translate-y-1/2 w-[420px] h-[420px] bg-accent/20 rounded-full blur-[80px] pointer-events-none" />
|
||||
|
||||
<div className="relative self-start text-[13px] font-bold tracking-[0.2em] text-[#AF90FF] mb-2">
|
||||
THE ENGINE · ABMR CYCLE
|
||||
</div>
|
||||
|
||||
{/* Circular loop diagram (Solution.tsx 구조, ABMR 워딩) */}
|
||||
<div className="relative w-[240px] h-[240px] md:w-[300px] md:h-[300px] my-11 mb-15">
|
||||
{/* Animated glowing ring */}
|
||||
<svg className="absolute inset-0 w-full h-full animate-[spin_20s_linear_infinite]" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="abmrRingGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a78bfa" stopOpacity="0.15" />
|
||||
<stop offset="50%" stopColor="#c084fc" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="#e879f9" stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="url(#abmrRingGrad)" strokeWidth="0.5" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
|
||||
{/* Center orb */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2] w-[120px] h-[120px] md:w-[148px] md:h-[148px] rounded-full bg-[#0A1128]/90 border border-white/5 shadow-[0_0_60px_rgba(167,139,250,0.12)] flex flex-col items-center justify-center gap-0.5">
|
||||
<span className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] bg-clip-text text-transparent">
|
||||
ABMR
|
||||
</span>
|
||||
<span className="font-serif font-bold text-sm md:text-base text-white text-center leading-tight">
|
||||
Discovery<br />Loop
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nodes */}
|
||||
{NODES.map((node) => (
|
||||
<div key={node.letter} className={`absolute ${node.pos} z-[3] flex flex-col items-center gap-[7px]`}>
|
||||
<div className="w-12 h-12 md:w-[58px] md:h-[58px] rounded-full border border-purple-500/30 bg-[#0A1128]/80 shadow-[0_0_20px_rgba(168,85,247,0.15)] flex items-center justify-center">
|
||||
<span className="text-xl md:text-[25px] font-bold text-purple-300">{node.letter}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-purple-100">{node.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Citation feedback curved text (M → A) */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-[2]" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<path id="citePath" d="M 10.6 56.9 A 40 40 0 0 0 43.1 89.4" fill="none" />
|
||||
</defs>
|
||||
<text fontSize="3.5" className="font-medium uppercase tracking-widest" fill="#d8b4fe" opacity="0.8">
|
||||
<textPath href="#citePath" startOffset="50%" textAnchor="middle">
|
||||
← CITATION FEEDBACK
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Cycle description */}
|
||||
<div className="relative w-full bg-white/5 border border-white/10 rounded-xl px-[18px] py-3.5">
|
||||
<p className="text-[13.5px] leading-[1.75] text-slate-300 text-center break-keep">
|
||||
<span className="font-bold text-purple-300">ABMR Cycle:</span> 실측 진단(Audit) → 스키마·콘텐츠
|
||||
구축(Build) → 언급률·AI 유입 측정(Measure) → 우선순위 재조정(Refine)의 무한 루프.
|
||||
사이클이 돌 때마다 <span className="text-white font-medium">측정된 언급률이 다음 구축의 입력</span>이
|
||||
됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Outcome chips — 계산값 파생 */}
|
||||
<div className="relative flex flex-wrap justify-center gap-2.5 mt-3 w-full">
|
||||
{chips.map((chip) => (
|
||||
<div key={chip} className="px-3.5 py-1.5 bg-[#AF90FF]/12 border border-[#AF90FF]/30 rounded-full">
|
||||
<span className="text-[13px] font-semibold text-purple-300">{chip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
77
src/components/discovery/AiAnswerMock.tsx
Normal file
77
src/components/discovery/AiAnswerMock.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AI 답변 목업 카드 — 히어로 우측.
|
||||
* ⚠️ 정직성 규약: 이 카드는 실제 AI 답변이 아니라 "구축 후 목표 상태 예시"다.
|
||||
* 라벨을 절대 제거하지 않는다 (현재 상태로 오인 금지).
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const CITED_DOMAINS = ['viewclinic.com', 'gangnamunni.com', 'youtube.com'];
|
||||
|
||||
interface AiAnswerMockProps {
|
||||
clinicName: string;
|
||||
/** 기준표 항목 수 (재측정 문구에 사용) */
|
||||
criteriaCount: number;
|
||||
}
|
||||
|
||||
export default function AiAnswerMock({ clinicName, criteriaCount }: AiAnswerMockProps) {
|
||||
const protocolPoints = [
|
||||
`루프 1회전(8주)마다 ${criteriaCount}개 항목을 재측정해 점수를 갱신합니다.`,
|
||||
'질의 10개 × 5회 스팟체크로 인용 여부를 신뢰구간과 함께 보고합니다.',
|
||||
'측정 결과가 다음 사이클의 구축 우선순위를 자동으로 정합니다.',
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="glass-card px-8 py-7 flex flex-col justify-center text-left"
|
||||
>
|
||||
<div className="text-[13px] font-bold tracking-[0.2em] text-accent mb-5">
|
||||
THE OUTPUT · AI 답변 인용
|
||||
</div>
|
||||
|
||||
{/* 질의 */}
|
||||
<div className="flex items-center gap-2.5 pb-4 border-b border-[#eef0f7] mb-4">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="5.5" stroke="#64748b" strokeWidth="1.5" />
|
||||
<path d="M13.5 13.5 17 17" stroke="#64748b" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-base font-medium text-slate-700 break-keep">
|
||||
강남에서 안전하게 가슴성형 상담받을 수 있는 병원 추천해줘
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 목표 상태의 답변 예시 */}
|
||||
<p className="text-[15px] leading-[1.8] text-slate-600 mb-4 break-keep">
|
||||
모티바 공식 KOL 의료진이 있는{' '}
|
||||
<span className="bg-[#F3F0FF] text-[#4A3A7C] font-semibold px-1.5 py-0.5 rounded-md">
|
||||
{clinicName}
|
||||
</span>
|
||||
는 강남언니 후기 19,316건과 의료진 26명 정보가 공개되어 있으며, 시술별 FAQ와 비교표를
|
||||
제공합니다.
|
||||
</p>
|
||||
|
||||
{/* 인용 도메인 칩 + 목표 상태 라벨 (제거 금지) */}
|
||||
<div className="flex items-center flex-wrap gap-2 mb-6">
|
||||
{CITED_DOMAINS.map((domain) => (
|
||||
<div key={domain} className="px-3 py-[5px] bg-[#EFF0FF] border border-[#C5CBF5] rounded-full">
|
||||
<span className="text-[12.5px] font-medium text-[#3A3F7C]">{domain}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[12.5px] text-slate-400 ml-1.5">AEO / GEO 구축 후 목표 상태 예시</span>
|
||||
</div>
|
||||
|
||||
{/* 측정 프로토콜 */}
|
||||
<div className="border-t border-[#eef0f7] pt-[18px] flex flex-col gap-2.5">
|
||||
{protocolPoints.map((point) => (
|
||||
<div key={point} className="flex items-center gap-2.5">
|
||||
<span className="w-[7px] h-[7px] shrink-0 bg-[#9B8AD4] rounded-full" />
|
||||
<span className="text-sm text-slate-600 break-keep">{point}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
44
src/components/discovery/DiscoveryCta.tsx
Normal file
44
src/components/discovery/DiscoveryCta.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 최종 CTA (다크). 연락처는 o2oteam@o2o.kr 단일 채널 (허위 연락처 금지).
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import { buildDiscoveryMailto, DISCOVERY_CONTACT_EMAIL } from './discoveryContact';
|
||||
|
||||
interface DiscoveryCtaProps {
|
||||
criteriaCount: number;
|
||||
}
|
||||
|
||||
export default function DiscoveryCta({ criteriaCount }: DiscoveryCtaProps) {
|
||||
return (
|
||||
<section className="bg-[#0A1128] px-6 py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-7xl mx-auto flex flex-col items-center text-center"
|
||||
>
|
||||
<h2 className="font-serif text-3xl md:text-[44px] font-bold leading-tight text-white mb-[18px] break-keep">
|
||||
AI가 당신의 병원을 인용하게 하세요.
|
||||
</h2>
|
||||
<p className="text-lg leading-[1.7] text-slate-400 mb-10 break-keep">
|
||||
진단은 무료입니다. {criteriaCount}개 항목 실측 리포트를 받아보고 결정하세요.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3.5">
|
||||
<a
|
||||
href={buildDiscoveryMailto('무료 진단 신청')}
|
||||
className="inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
무료 AEO / GEO 진단 받기
|
||||
</a>
|
||||
<a
|
||||
href={buildDiscoveryMailto('무료 진단 신청')}
|
||||
className="text-[15px] text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{DISCOVERY_CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
107
src/components/discovery/DiscoveryHero.tsx
Normal file
107
src/components/discovery/DiscoveryHero.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* AI Discovery 랜딩 히어로.
|
||||
* Hero.tsx의 배경·배지·헤드라인 구조를 따르고, 하단에 ABMR 루프 + AI 답변 목업 2열 그리드.
|
||||
* 시안: docs/reports/ai_discovery_landing.html (픽셀 기준)
|
||||
*/
|
||||
import { Link } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import type { OverallScore } from '../../types/discovery';
|
||||
import AbmrLoop from './AbmrLoop';
|
||||
import AiAnswerMock from './AiAnswerMock';
|
||||
import { buildDiscoveryMailto } from './discoveryContact';
|
||||
|
||||
interface DiscoveryHeroProps {
|
||||
overall: OverallScore;
|
||||
target: { score: number; grade: OverallScore['grade'] } | null;
|
||||
clinicName: string;
|
||||
sampleReportPath: string;
|
||||
criteriaCount: number;
|
||||
}
|
||||
|
||||
export default function DiscoveryHero({
|
||||
overall,
|
||||
target,
|
||||
clinicName,
|
||||
sampleReportPath,
|
||||
criteriaCount,
|
||||
}: DiscoveryHeroProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top,#e0e7ff_0%,#faf5ff_45%,#fdf2f8_100%)]">
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute top-[120px] left-[60px] w-64 h-64 bg-purple-300 rounded-full blur-3xl opacity-30 pointer-events-none" />
|
||||
<div className="absolute top-[180px] right-[60px] w-64 h-64 bg-pink-300 rounded-full blur-3xl opacity-30 pointer-events-none" />
|
||||
|
||||
<div className="relative flex flex-col items-center text-center px-6 pt-32 md:pt-46 pb-16 md:pb-22">
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 shadow-sm mb-8"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4.5 8c0-1.4 1-2.5 2.2-2.5 1.6 0 2.6 5 4.2 5 1.2 0 2.2-1.1 2.2-2.5s-1-2.5-2.2-2.5c-1.6 0-2.6 5-4.2 5C5.5 10.5 4.5 9.4 4.5 8Z"
|
||||
stroke="#6C5CE7"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-slate-700">AI DISCOVERY · AEO / GEO for Clinics</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="font-serif text-5xl md:text-7xl font-bold text-primary-900 leading-[1.1] tracking-[-0.02em] mb-6 break-keep"
|
||||
>
|
||||
AI가 추천하는 병원은
|
||||
<br />
|
||||
<span className="text-gradient">따로 있습니다.</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Sub copy */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-slate-600 leading-relaxed max-w-3xl mb-10 break-keep"
|
||||
>
|
||||
고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.
|
||||
<br className="hidden md:block" /> 답변에 인용될 근거를 {criteriaCount}개 항목으로 실측
|
||||
진단하고, 8주 안에 구축합니다.
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="flex flex-col sm:flex-row items-center gap-4 mb-18"
|
||||
>
|
||||
<a
|
||||
href={buildDiscoveryMailto('무료 진단 신청')}
|
||||
className="inline-flex items-center justify-center gap-2 px-10 py-4 text-lg font-medium text-white rounded-full shadow-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
무료 AEO / GEO 진단 받기
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</a>
|
||||
<Link
|
||||
to={sampleReportPath}
|
||||
className="inline-flex items-center justify-center px-10 py-4 text-lg font-medium text-primary-900 bg-white/80 border border-slate-200 rounded-full hover:bg-white transition-colors"
|
||||
>
|
||||
샘플 리포트 보기
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* ABMR loop + AI answer mock */}
|
||||
<div className="w-full max-w-7xl grid md:grid-cols-2 gap-6">
|
||||
<AbmrLoop overall={overall} target={target} />
|
||||
<AiAnswerMock clinicName={clinicName} criteriaCount={criteriaCount} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
src/components/discovery/HonestyStrip.tsx
Normal file
38
src/components/discovery/HonestyStrip.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 정직성 인용문 + 3필 (하지 않을 것).
|
||||
* 순위·노출 보장형 문구 금지, "언급률과 신뢰구간으로 보고" 프레임 유지.
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const PILLS = [
|
||||
'효과 없는 llms.txt는 팔지 않습니다',
|
||||
'순위·노출을 보장하지 않습니다',
|
||||
'단발 측정으로 보고하지 않습니다',
|
||||
];
|
||||
|
||||
export default function HonestyStrip() {
|
||||
return (
|
||||
<section className="bg-white border-y border-[#eef0f7] px-6 py-18">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-[880px] mx-auto text-center"
|
||||
>
|
||||
<blockquote className="font-serif italic font-semibold text-xl md:text-[27px] leading-normal text-primary-900 mb-7 break-keep">
|
||||
"스키마를 넣으면 인용된다고 약속하지 않습니다.
|
||||
<br className="hidden md:block" /> 인용될 수 없는 이유를 전부 제거하고, 결과를 반복
|
||||
측정으로 증명합니다."
|
||||
</blockquote>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{PILLS.map((pill) => (
|
||||
<div key={pill} className="px-[18px] py-2 bg-[#F4F6FB] border border-slate-200 rounded-full">
|
||||
<span className="text-sm font-medium text-slate-600 break-keep">{pill}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
103
src/components/discovery/HowItWorks.tsx
Normal file
103
src/components/discovery/HowItWorks.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* How It Works — Audit · Build · Prove 3단계 glass-card.
|
||||
* 아이콘은 시안의 커스텀 SVG를 그대로 사용 (F3F0FF 스퀘어 + accent 스트로크).
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface Step {
|
||||
step: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface HowItWorksProps {
|
||||
criteriaCount: number;
|
||||
categoryCount: number;
|
||||
}
|
||||
|
||||
export default function HowItWorks({ criteriaCount, categoryCount }: HowItWorksProps) {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
step: 'STEP 01',
|
||||
title: 'Audit',
|
||||
detail: `AEO/GEO 기준표 v1.0으로 ${criteriaCount}개 항목을 실측합니다. 크롤러 접근성부터 인용 가능 콘텐츠, 측정 인프라까지 ${categoryCount}개 카테고리 100점 척도. 항목마다 근거 등급을 명시합니다.`,
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" stroke="#6C5CE7" strokeWidth="1.6" />
|
||||
<path d="M16 16l4 4" stroke="#6C5CE7" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M8.5 11h5M11 8.5v5" stroke="#6C5CE7" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
step: 'STEP 02',
|
||||
title: 'Build',
|
||||
detail:
|
||||
'MedicalClinic·Physician 스키마를 심고, 흩어진 표면의 NAP을 한 글자까지 통일합니다. 핵심 페이지에 정의문과 시술별 FAQ를 넣어 AI가 잘라 갈 단락을 만듭니다.',
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 4H6a2 2 0 0 0-2 2v3c0 1-.6 2-1.6 2.4v1.2C3.4 13 4 14 4 15v3a2 2 0 0 0 2 2h2M16 4h2a2 2 0 0 1 2 2v3c0 1 .6 2 1.6 2.4v1.2C20.6 13 20 14 20 15v3a2 2 0 0 1-2 2h-2"
|
||||
stroke="#6C5CE7"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
step: 'STEP 03',
|
||||
title: 'Prove',
|
||||
detail:
|
||||
'GA4 AI 채널그룹과 서버로그 크롤러 검증을 설치하고, 질의 10개 × 5회 반복 스팟체크로 변화를 측정합니다. 단발 조회가 아니라 언급률과 신뢰구간으로만 보고합니다.',
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 20V10M10 20V4M16 20v-8M22 20H2" stroke="#6C5CE7" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="how-it-works" className="bg-slate-50 px-6 py-24">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-14"
|
||||
>
|
||||
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">HOW IT WORKS</div>
|
||||
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 break-keep">
|
||||
진단하고, 구축하고, 숫자로 증명합니다.
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{steps.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="glass-card flex flex-col gap-3.5 p-8"
|
||||
>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-[#F3F0FF] rounded-xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<span className="text-[13px] font-bold tracking-[0.12em] text-accent">{item.step}</span>
|
||||
<span className="font-serif text-2xl font-bold text-primary-900">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-[15.5px] leading-[1.75] text-slate-600 break-keep">{item.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
77
src/components/discovery/Roadmap.tsx
Normal file
77
src/components/discovery/Roadmap.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 8주 로드맵 타임라인. 타이틀의 등급 전환(C → B)은 계산값에서 파생.
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import type { OverallScore } from '../../types/discovery';
|
||||
|
||||
interface RoadmapProps {
|
||||
grade: OverallScore['grade'];
|
||||
targetGrade: OverallScore['grade'] | null;
|
||||
criteriaCount: number;
|
||||
unverifiedCount: number;
|
||||
}
|
||||
|
||||
export default function Roadmap({ grade, targetGrade, criteriaCount, unverifiedCount }: RoadmapProps) {
|
||||
const phases = [
|
||||
{
|
||||
week: 'W1–2',
|
||||
title: '기반 설치',
|
||||
detail: '스키마·NAP·측정 인프라를 설치하고 기준선 스팟체크를 1회 측정합니다.',
|
||||
},
|
||||
{
|
||||
week: 'W3–6',
|
||||
title: '콘텐츠 구조',
|
||||
detail: '정의문·FAQ·H1 재설계와 HTML 비교표를 구축하고 WordPress를 업그레이드합니다.',
|
||||
},
|
||||
{
|
||||
week: 'W7–8',
|
||||
title: '재진단·보고',
|
||||
detail: `${criteriaCount}개 항목을 재측정하고 미검증 ${unverifiedCount}개를 반영해 총점을 다시 산정합니다.`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-slate-50 px-6 py-24">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">8-WEEK ROADMAP</div>
|
||||
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 break-keep">
|
||||
{targetGrade
|
||||
? `8주 안에, ${grade}에서 ${targetGrade}로 올립니다.`
|
||||
: `8주마다, ${grade} 등급을 재검증합니다.`}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Connecting line (desktop only) */}
|
||||
<div className="hidden md:block absolute top-[11px] left-[8%] right-[8%] h-0.5 bg-[#D5CDF5]" />
|
||||
<div className="grid md:grid-cols-3 gap-10 md:gap-8">
|
||||
{phases.map((phase, index) => (
|
||||
<motion.div
|
||||
key={phase.week}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="flex flex-col items-center text-center gap-3.5"
|
||||
>
|
||||
<div className="relative w-6 h-6 bg-accent border-[5px] border-slate-50 rounded-full box-border" />
|
||||
<div className="text-sm font-bold tracking-[0.1em] text-accent">{phase.week}</div>
|
||||
<div className="text-[17px] font-bold text-primary-900">{phase.title}</div>
|
||||
<p className="text-[14.5px] leading-[1.7] text-slate-500 max-w-[280px] break-keep">
|
||||
{phase.detail}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
158
src/components/discovery/SampleReport.tsx
Normal file
158
src/components/discovery/SampleReport.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷.
|
||||
* 점수·등급·카테고리 %·항목 수는 전부 discoveryScore 계산값에서 렌더 (하드코딩 금지).
|
||||
* 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다.
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import type { DiscoveryResult, OverallScore } from '../../types/discovery';
|
||||
import { scorePctColor } from '../../lib/discoveryScore';
|
||||
import { ScoreRing } from '../report/ui/ScoreRing';
|
||||
|
||||
const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = {
|
||||
A: 'bg-[#EFF0FF] border-[#C5CBF5] text-[#3A3F7C]',
|
||||
B: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
|
||||
C: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
|
||||
D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
|
||||
};
|
||||
|
||||
/** keyFindings 압축본 (시안 확정 카피). 수치는 2026-08-28 실측 스냅샷과 일치한다. */
|
||||
const FINDING_CHIPS = [
|
||||
{
|
||||
label: '치명',
|
||||
chipClass: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
|
||||
dotColor: '#D4889A',
|
||||
title: 'AI가 누구인지 확정 못 함',
|
||||
detail: 'MedicalClinic 스키마 없음. 시술 목록 JSON-LD는 문법 오류로 통째로 버려집니다.',
|
||||
},
|
||||
{
|
||||
label: '보완',
|
||||
chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
|
||||
dotColor: '#D4A872',
|
||||
title: '인용할 문장이 없음',
|
||||
detail: '정의문 0개, FAQ 0개, HTML 표 0개. 답변엔진이 잘라 갈 단락이 없습니다.',
|
||||
},
|
||||
{
|
||||
label: '강점',
|
||||
chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
|
||||
dotColor: '#9B8AD4',
|
||||
title: '표면 자산은 이미 강함',
|
||||
detail: '강남언니 후기 19,316건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.',
|
||||
},
|
||||
];
|
||||
|
||||
interface SampleReportProps {
|
||||
overall: OverallScore;
|
||||
result: DiscoveryResult;
|
||||
}
|
||||
|
||||
export default function SampleReport({ overall, result }: SampleReportProps) {
|
||||
const totalCriteria = overall.categories.reduce((sum, c) => sum + c.totalCount, 0);
|
||||
const verifiedCriteria = overall.categories.reduce((sum, c) => sum + c.verifiedCount, 0);
|
||||
const unverifiedCriteria = totalCriteria - verifiedCriteria;
|
||||
|
||||
return (
|
||||
<section id="sample-report" className="bg-[#F4F6FB] px-6 py-24">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">
|
||||
SAMPLE REPORT · {result.clinicName} 실측
|
||||
</div>
|
||||
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">
|
||||
읽을 수는 있지만, 인용할 근거가 없습니다.
|
||||
</h2>
|
||||
<p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep">
|
||||
실제 1차 진단 결과입니다. 어디가 강하고 어디가 비어 있는지, 점수는 카테고리 단위로
|
||||
드러납니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid lg:grid-cols-[380px_minmax(0,1fr)] gap-6">
|
||||
{/* Score ring card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="flex flex-col items-center justify-center gap-4 bg-white border border-slate-200 rounded-2xl px-8 py-10 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
|
||||
>
|
||||
<ScoreRing score={overall.score} size={160} valueClassName="text-[40px]" subLabel="/ 100" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[overall.grade]}`}>
|
||||
<span className="text-sm font-bold">등급 {overall.grade}</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">AI Answer Readiness</span>
|
||||
</div>
|
||||
<p className="text-[13.5px] leading-relaxed text-slate-400 text-center break-keep">
|
||||
{totalCriteria}개 항목 중 {verifiedCriteria}개 실측.
|
||||
<br />
|
||||
미검증 {unverifiedCriteria}개는 분모에서 제외했습니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Category bars */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="flex flex-col justify-center gap-[18px] bg-white border border-slate-200 rounded-2xl px-6 md:px-10 py-9 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
|
||||
>
|
||||
{overall.categories.map((cat) => {
|
||||
const color = scorePctColor(cat.pct);
|
||||
return (
|
||||
<div key={cat.category.id} className="flex flex-col gap-[7px]">
|
||||
<div className="flex justify-between items-baseline gap-3">
|
||||
<span className="text-[15px] font-semibold text-primary-900 break-keep">
|
||||
{cat.category.code} · {cat.category.name}
|
||||
</span>
|
||||
<span className="text-[15px] font-bold" style={{ color }}>
|
||||
{cat.pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${Math.max(cat.pct, 2)}%` }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Finding chips */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-6">
|
||||
{FINDING_CHIPS.map((finding, index) => (
|
||||
<motion.div
|
||||
key={finding.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="flex flex-col gap-2.5 bg-white border border-slate-200 rounded-2xl px-7 py-6"
|
||||
>
|
||||
<div
|
||||
className={`inline-flex self-start items-center gap-[7px] px-3 py-1 border rounded-full ${finding.chipClass}`}
|
||||
>
|
||||
<span className="w-[7px] h-[7px] rounded-full" style={{ backgroundColor: finding.dotColor }} />
|
||||
<span className="text-[13px] font-bold">{finding.label}</span>
|
||||
</div>
|
||||
<div className="text-[15.5px] font-semibold text-primary-900 break-keep">{finding.title}</div>
|
||||
<p className="text-sm leading-[1.7] text-slate-500 break-keep">{finding.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
70
src/components/discovery/WhyNow.tsx
Normal file
70
src/components/discovery/WhyNow.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Why Now 다크 밴드.
|
||||
* ⚠️ 3개 수치(12종 크롤러 / 네이버 기여 0건 / Direct 유실 35~70%)는 근거 있는 실측·문헌 값.
|
||||
* 새 시장 통계를 임의로 추가하지 않는다 (근거 확인 후에만).
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const STATS = [
|
||||
{
|
||||
figure: '12종',
|
||||
title: 'AI 크롤러가 이미 읽는 중',
|
||||
detail:
|
||||
'GPTBot·OAI-SearchBot·PerplexityBot 등이 오늘도 병원 사이트를 수집합니다. 문제는 접근이 아니라 인용할 내용입니다.',
|
||||
},
|
||||
{
|
||||
figure: '0건',
|
||||
title: '네이버 자산의 AI 기여',
|
||||
detail:
|
||||
'네이버 블로그·플레이스는 robots.txt로 AI 크롤러를 전면 차단합니다(2026-08 실측). 지금까지의 네이버 마케팅은 AI 답변에 닿지 않습니다.',
|
||||
},
|
||||
{
|
||||
figure: '35~70%',
|
||||
title: 'Direct로 숨는 AI 유입',
|
||||
detail:
|
||||
'AI가 보낸 방문의 상당수는 유입 경로 없이 도착합니다. 측정 인프라 없이는 이미 오는 환자도 세지 못합니다.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhyNow() {
|
||||
return (
|
||||
<section id="why-now" className="bg-[#0A1128] px-6 py-22">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="text-sm font-bold tracking-[0.24em] text-[#AF90FF] mb-4">WHY NOW</div>
|
||||
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-white mb-3 break-keep">
|
||||
검색은 끝났고, 답변이 시작됐습니다.
|
||||
</h2>
|
||||
<p className="text-[17px] leading-[1.7] text-slate-400 mb-14 break-keep">
|
||||
답변엔진은 광고 입찰이 아니라 크롤링 가능한 오픈웹 근거로 병원을 고릅니다. 인용되지
|
||||
못하면 비교 대상에도 오르지 못합니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{STATS.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.figure}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="flex flex-col gap-2.5 p-7 bg-[#0F1B45] border border-[#2A3A6B] rounded-2xl"
|
||||
>
|
||||
<div className="font-serif text-[46px] font-bold text-[#AF90FF] leading-tight">
|
||||
{stat.figure}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-white break-keep">{stat.title}</div>
|
||||
<p className="text-[14.5px] leading-[1.7] text-slate-400 break-keep">{stat.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
8
src/components/discovery/discoveryContact.ts
Normal file
8
src/components/discovery/discoveryContact.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* AI Discovery 랜딩 전용 연락처.
|
||||
* 랜딩 CTA는 o2oteam@o2o.kr 단일 채널 (전역 Navbar의 contact@o2o.kr과 별도 트랙).
|
||||
*/
|
||||
export const DISCOVERY_CONTACT_EMAIL = 'o2oteam@o2o.kr';
|
||||
|
||||
export const buildDiscoveryMailto = (subject: string) =>
|
||||
`mailto:${DISCOVERY_CONTACT_EMAIL}?subject=${encodeURIComponent(`[INFINITH AI Discovery] ${subject}`)}`;
|
||||
@ -55,7 +55,7 @@ const statusLabel: Record<string, string> = {
|
||||
};
|
||||
|
||||
/* ─── Color Swatch Editor Popover ─── */
|
||||
function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: (newHex: string) => void }) {
|
||||
function ColorSwatchCard({ swatch, onUpdate }: { key?: string; swatch: ColorSwatch; onUpdate: (newHex: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hexInput, setHexInput] = useState(swatch.hex);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -79,6 +79,7 @@ function StageBar({ currentStage }: { currentStage: WorkflowStage }) {
|
||||
}
|
||||
|
||||
function WorkflowCard({ item, onStageChange, onNotesChange }: {
|
||||
key?: string;
|
||||
item: WorkflowItem;
|
||||
onStageChange: (id: string, stage: WorkflowStage) => void;
|
||||
onNotesChange: (id: string, notes: string) => void;
|
||||
|
||||
@ -6,6 +6,10 @@ interface ScoreRingProps {
|
||||
size?: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
/** 링 중앙 숫자 타이포 오버라이드 (기본 text-2xl) */
|
||||
valueClassName?: string;
|
||||
/** 링 중앙 숫자 아래 보조 표기 (예: "/ 100") */
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, maxScore: number): string {
|
||||
@ -22,6 +26,8 @@ export function ScoreRing({
|
||||
size = 120,
|
||||
label,
|
||||
color,
|
||||
valueClassName = 'text-2xl',
|
||||
subLabel,
|
||||
}: ScoreRingProps) {
|
||||
const strokeWidth = 8;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
@ -61,8 +67,9 @@ export function ScoreRing({
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-[#0A1128]">{score}</span>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`${valueClassName} font-bold text-[#0A1128]`}>{score}</span>
|
||||
{subLabel && <span className="text-[13px] text-slate-400">{subLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
|
||||
@ -3,6 +3,8 @@ type SeverityLevel = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown';
|
||||
interface SeverityBadgeProps {
|
||||
severity: SeverityLevel;
|
||||
label?: string;
|
||||
/** 크기·굵기 오버라이드 (기본 text-xs font-medium) */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const config: Record<SeverityLevel, { className: string; defaultLabel: string }> = {
|
||||
@ -28,12 +30,12 @@ const config: Record<SeverityLevel, { className: string; defaultLabel: string }>
|
||||
},
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
|
||||
export function SeverityBadge({ severity, label, className: extra = '' }: SeverityBadgeProps) {
|
||||
const { className, defaultLabel } = config[severity];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${className}`}
|
||||
className={`inline-flex items-center rounded-full border ${extra || 'text-xs font-medium px-3 py-1'} ${className}`}
|
||||
>
|
||||
{label ?? defaultLabel}
|
||||
</span>
|
||||
|
||||
531
src/data/aeoGeoRubric.ts
Normal file
531
src/data/aeoGeoRubric.ts
Normal file
@ -0,0 +1,531 @@
|
||||
/**
|
||||
* INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0
|
||||
*
|
||||
* 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO · C-SEO Bench)
|
||||
*
|
||||
* 배점 철학
|
||||
* - "차단되면 절대 인용될 수 없다"는 논쟁의 여지가 없다 → A(접근성)·B(엔티티) 배점이 높다
|
||||
* - GEO 콘텐츠 전술의 효과는 학술적으로 논쟁 중 → C는 배점이 크지만 "좋은 콘텐츠 원칙"으로만 설명한다
|
||||
* - llms.txt는 항목에서 제외한다 (Google 공식 효과 0, 채택률 0.13%)
|
||||
* - 병의원 업종은 강남언니(ai-input=yes)가 AI 인용을 장악 → D(표면)에서 최고 배점
|
||||
*/
|
||||
import type { Rubric } from '../types/discovery';
|
||||
|
||||
const L = (l0: string, l1: string, l2: string, l3: string) => [
|
||||
{ level: 0 as const, label: l0 },
|
||||
{ level: 1 as const, label: l1 },
|
||||
{ level: 2 as const, label: l2 },
|
||||
{ level: 3 as const, label: l3 },
|
||||
];
|
||||
|
||||
export const AEO_GEO_RUBRIC: Rubric = {
|
||||
version: '1.0',
|
||||
updatedAt: '2026-08-28',
|
||||
categories: [
|
||||
{
|
||||
id: 'access',
|
||||
code: 'A',
|
||||
name: 'AI 크롤러 접근성',
|
||||
nameEn: 'Access & Crawlability',
|
||||
description:
|
||||
'AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.',
|
||||
weight: 20,
|
||||
},
|
||||
{
|
||||
id: 'entity',
|
||||
code: 'B',
|
||||
name: '엔티티 식별·구조화',
|
||||
nameEn: 'Entity & Structured Data',
|
||||
description:
|
||||
'AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.',
|
||||
weight: 20,
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
code: 'C',
|
||||
name: '답변 인용 가능 콘텐츠',
|
||||
nameEn: 'Answer-Ready Content',
|
||||
description:
|
||||
'패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.',
|
||||
weight: 25,
|
||||
},
|
||||
{
|
||||
id: 'surface',
|
||||
code: 'D',
|
||||
name: 'AI 인용 표면 확보',
|
||||
nameEn: 'Off-site Surfaces',
|
||||
description:
|
||||
'실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.',
|
||||
weight: 20,
|
||||
},
|
||||
{
|
||||
id: 'measure',
|
||||
code: 'E',
|
||||
name: 'AI 가시성 측정 인프라',
|
||||
nameEn: 'Measurement',
|
||||
description:
|
||||
'개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'hygiene',
|
||||
code: 'F',
|
||||
name: '기술 위생',
|
||||
nameEn: 'Technical Hygiene',
|
||||
description: '이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.',
|
||||
weight: 5,
|
||||
},
|
||||
],
|
||||
criteria: [
|
||||
// ───────────── A. 접근성 (20) ─────────────
|
||||
{
|
||||
id: 'A1',
|
||||
category: 'access',
|
||||
name: '검색용 AI 봇 허용',
|
||||
rationale:
|
||||
'OAI-SearchBot·PerplexityBot·Claude-SearchBot·bingbot·Googlebot이 막히면 인용 자체가 불가능. 학습봇(GPTBot·ClaudeBot)과 구분해 평가.',
|
||||
weight: 5,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'robots.txt 파싱 + 각 UA로 실제 GET 200 확인 (crawler_audit.py)',
|
||||
levels: L('검색봇 2개 이상 차단', '검색봇 1개 차단', '모두 허용, 학습봇 일부 차단', '검색봇 전부 허용 + 200 응답'),
|
||||
},
|
||||
{
|
||||
id: 'A2',
|
||||
category: 'access',
|
||||
name: 'CDN/WAF AI 차단 없음',
|
||||
rationale: 'Cloudflare는 2025-07부터 신규 도메인에 AI 크롤러 기본 차단. 업체가 인지하지 못한 채 AI 검색에서 사라지는 주원인.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '응답 헤더(server/cf-ray) + AI UA 요청 시 403/challenge 여부',
|
||||
levels: L('AI UA 403/챌린지', '일부 UA 차단', 'CDN 사용, 차단 없음', 'CDN 차단 없음 확인'),
|
||||
},
|
||||
{
|
||||
id: 'A3',
|
||||
category: 'access',
|
||||
name: '서버 렌더링 본문',
|
||||
rationale: 'AI 크롤러는 JS를 실행하지 않거나 지연시킨다. 초기 HTML에 본문이 없으면 빈 페이지로 읽힌다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'JS 미실행 HTML의 텍스트 길이 / 렌더 후 텍스트 길이 비율',
|
||||
levels: L('본문 전무 (SPA 빈 셸)', '30% 미만', '30~80%', '80% 이상 (SSR/정적)'),
|
||||
},
|
||||
{
|
||||
id: 'A4',
|
||||
category: 'access',
|
||||
name: 'HTTPS·리다이렉트 정합',
|
||||
rationale: 'http/www 변형이 하나의 정본 URL로 301 수렴해야 엔티티가 분산되지 않는다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'http://, http://www, https:// 4변형 → 최종 URL 동일 여부',
|
||||
levels: L('HTTPS 없음', '변형 간 200 중복', '일부 302', '전부 301로 정본 수렴'),
|
||||
},
|
||||
{
|
||||
id: 'A5',
|
||||
category: 'access',
|
||||
name: 'sitemap.xml 제공',
|
||||
rationale: '크롤러 발견 경로. robots.txt에 선언되어야 한다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'robots.txt Sitemap: 선언 + 200 + lastmod 존재',
|
||||
levels: L('없음', '있으나 robots 미선언', '선언·200', '선언·200·lastmod 최신'),
|
||||
},
|
||||
{
|
||||
id: 'A6',
|
||||
category: 'access',
|
||||
name: 'Bing 색인 (ChatGPT 검색 경로)',
|
||||
rationale: 'ChatGPT 검색은 Bing 인덱스에 상당 부분 의존. Bing Webmaster + IndexNow 등록 여부.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'vendor',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: 'Bing Webmaster Tools 등록 확인 (오너 접근 필요) 또는 site: 검색 결과 수',
|
||||
levels: L('Bing 미색인', '일부 색인', '색인됨', '색인 + IndexNow 연동'),
|
||||
},
|
||||
{
|
||||
id: 'A7',
|
||||
category: 'access',
|
||||
name: '응답 속도·크롤 예산',
|
||||
rationale: 'TTFB가 느리거나 HTML이 비대하면 크롤러가 페이지를 덜 가져간다.',
|
||||
weight: 1,
|
||||
evidenceGrade: 'estimate',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'TTFB < 0.5s, HTML < 300KB',
|
||||
levels: L('TTFB > 2s', 'TTFB > 1s 또는 HTML > 1MB', 'TTFB < 0.5s, HTML > 300KB', 'TTFB < 0.5s, HTML < 300KB'),
|
||||
},
|
||||
|
||||
// ───────────── B. 엔티티 (20) ─────────────
|
||||
{
|
||||
id: 'B1',
|
||||
category: 'entity',
|
||||
name: 'JSON-LD 문법 유효성',
|
||||
rationale: '문법이 깨진 JSON-LD는 파서가 통째로 버린다. 있는데 무효인 것은 없는 것과 같다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '모든 ld+json 블록 JSON.parse 성공 여부',
|
||||
levels: L('전부 무효 또는 없음', '일부 무효', '전부 유효', '전부 유효 + @graph 연결'),
|
||||
},
|
||||
{
|
||||
id: 'B2',
|
||||
category: 'entity',
|
||||
name: '업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)',
|
||||
rationale: '엔티티 식별을 돕는 위생 조치. 인과 증거는 없으나 name·address·telephone·openingHours·전문분야(medicalSpecialty / areaServed)를 기계가 읽게 한다.',
|
||||
weight: 5,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '@type MedicalClinic|LegalService|LocalBusiness 존재 + 필수 속성 4개',
|
||||
levels: L('없음', 'Organization만', '업종 타입 + 주소·전화', '+ openingHours·전문분야·geo'),
|
||||
},
|
||||
{
|
||||
id: 'B3',
|
||||
category: 'entity',
|
||||
name: '전문가 스키마 (Physician / Attorney)',
|
||||
rationale: '"누가 담당하나"는 병의원·로펌 질의의 핵심. 의사·변호사 이름과 자격·경력을 구조화하면 E-E-A-T 엔티티가 된다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '@type Physician|Attorney + name·전문분야·worksFor',
|
||||
levels: L('없음', '이름만', '전문분야 포함', '자격·소속·sameAs 포함'),
|
||||
},
|
||||
{
|
||||
id: 'B4',
|
||||
category: 'entity',
|
||||
name: 'NAP 사이트 내 일관성',
|
||||
rationale: '같은 사이트 안에서 주소 표기가 다르면 외부 표면과 대조할 기준이 없다. 한 글자까지 동일해야 한다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '주소·전화 정규식 추출 → 고유 표기 수',
|
||||
levels: L('주소/전화 미표기', '3개 이상 표기 변형', '2개 변형', '단일 표기'),
|
||||
},
|
||||
{
|
||||
id: 'B5',
|
||||
category: 'entity',
|
||||
name: 'sameAs 연결',
|
||||
rationale: 'Organization.sameAs로 YouTube·Instagram·강남언니·GBP를 연결해야 AI가 흩어진 언급을 한 엔티티로 묶는다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'Organization.sameAs 배열 길이',
|
||||
levels: L('비어 있음', '1~2개', '3~4개', '5개 이상 (SNS + 버티컬 + 지도)'),
|
||||
},
|
||||
{
|
||||
id: 'B6',
|
||||
category: 'entity',
|
||||
name: 'canonical·중복 제거',
|
||||
rationale: '같은 내용이 여러 URL이면 인용 신호가 분산된다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'rel=canonical 존재 + self-referencing',
|
||||
levels: L('없음', '있으나 불일치', '있음', '있음 + 파라미터 정리'),
|
||||
},
|
||||
{
|
||||
id: 'B7',
|
||||
category: 'entity',
|
||||
name: '다국어 hreflang',
|
||||
rationale: '외국인 환자 질의(영·중·일)는 별도 엔진 표면. 언어 버전이 있다면 hreflang으로 연결해야 한다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'vendor',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'link hreflang 태그 수',
|
||||
levels: L('다국어 페이지 있으나 미연결', '단일 언어', '2개 언어 연결', '3개 이상 연결'),
|
||||
},
|
||||
|
||||
// ───────────── C. 콘텐츠 (25) ─────────────
|
||||
{
|
||||
id: 'C1',
|
||||
category: 'content',
|
||||
name: '정의문형 첫 문단',
|
||||
rationale: 'Google AI Mode의 query fan-out은 패시지 단위로 검색한다. "○○은 강남 신논현에 있는 가슴성형 전문 성형외과다"처럼 답을 먼저 써야 청크가 자기완결적이 된다.',
|
||||
weight: 4,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'semi',
|
||||
howToCheck: '메인·시술 페이지 첫 200자에 [상호+지역+전문분야] 정의문 존재',
|
||||
levels: L('없음 (슬로건·이미지만)', '슬로건형', '일부 페이지에 정의문', '전 핵심 페이지 정의문'),
|
||||
},
|
||||
{
|
||||
id: 'C2',
|
||||
category: 'content',
|
||||
name: 'FAQ 섹션',
|
||||
rationale: '실제 질문 형태 그대로의 H2/H3 + 답변은 답변엔진이 가장 잘 가져가는 구조. FAQPage 스키마와 결합.',
|
||||
weight: 4,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'FAQ/Q&A 섹션 탐지 + FAQPage 스키마',
|
||||
levels: L('없음', '게시판형 Q&A만', 'FAQ 섹션 존재', 'FAQ + FAQPage 스키마 + 시술별'),
|
||||
},
|
||||
{
|
||||
id: 'C3',
|
||||
category: 'content',
|
||||
name: '제목 계층 의미성',
|
||||
rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "VIEW SELFIE" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'H1 개수 = 1, H1/H2 텍스트가 명사구 서술형인지, 중복 H2 비율',
|
||||
levels: L('H1 없음 또는 5개 이상', 'H1 다수·라벨형', 'H1 1개, H2 일부 라벨형', 'H1 1개 서술형, H2 논리 계층'),
|
||||
},
|
||||
{
|
||||
id: 'C4',
|
||||
category: 'content',
|
||||
name: '구체 수치 (가격·시간·회복)',
|
||||
rationale: '가격대·수술시간·회복기간·재수술률 같은 수치는 인용되는 콘텐츠의 공통 특징. (Princeton GEO 정합, 단 C-SEO Bench 반박 있음 → 효과 보장 아님)',
|
||||
weight: 4,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'semi',
|
||||
howToCheck: '시술 페이지에서 원/분/일/% 단위 수치 밀도',
|
||||
levels: L('수치 없음', '마케팅 문구 속 수치만', '시술별 일부 수치', '시술별 가격대·시간·회복 표기'),
|
||||
},
|
||||
{
|
||||
id: 'C5',
|
||||
category: 'content',
|
||||
name: '표·비교 구조',
|
||||
rationale: 'AI가 인용하는 콘텐츠는 구조화·표 중심 (인블로그 조사). 보형물 비교표·시술 비교표가 대표 예.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'vendor',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '<table> 또는 정의 리스트 존재 (이미지 표 제외)',
|
||||
levels: L('없음', '이미지로만 된 표', 'HTML 표 1~2개', 'HTML 표 다수'),
|
||||
},
|
||||
{
|
||||
id: 'C6',
|
||||
category: 'content',
|
||||
name: '신선도 신호',
|
||||
rationale: 'dateModified·최종 수정일 명시. 1년 이상 갱신 없는 사이트는 "폐업했나?" 오정보 위험.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'sitemap lastmod 최신값, article:modified_time, 본문 갱신일',
|
||||
levels: L('2년 이상 갱신 없음', '1년 이상', '6개월 이내', '3개월 이내 + dateModified 표기'),
|
||||
},
|
||||
{
|
||||
id: 'C7',
|
||||
category: 'content',
|
||||
name: '전문가 E-E-A-T (의료진 / 변호사)',
|
||||
rationale: '전문의 자격·경력·학회·수상, 변호사의 전관 경력·전문분야 등록이 텍스트로 존재해야 AI가 "믿을 만한 출처"로 판단할 근거가 생긴다. 이미지 안의 글자는 안 읽힌다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'semi',
|
||||
howToCheck: '전문가 페이지 텍스트에 자격·경력 명시 여부',
|
||||
levels: L('전문가 정보 없음', '이름·사진만', '자격·경력 텍스트', '+ 논문·학회·수상 + 전문가 스키마'),
|
||||
},
|
||||
{
|
||||
id: 'C8',
|
||||
category: 'content',
|
||||
name: '패시지 자기완결성',
|
||||
rationale: '한 단락만 떼어내도 "누가·어디서·무엇을"이 들어 있어야 인용된다. 대명사·"저희"로 시작하는 단락은 맥락을 잃는다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: '핵심 페이지 단락 샘플 10개 중 상호·시술명이 포함된 비율',
|
||||
levels: L('0~20%', '20~50%', '50~80%', '80% 이상'),
|
||||
},
|
||||
|
||||
// ───────────── D. 표면 (20) ─────────────
|
||||
{
|
||||
id: 'D1',
|
||||
category: 'surface',
|
||||
name: '업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)',
|
||||
rationale: '2026-08 실측: 병의원 질의는 강남언니(ai-input=yes 명시)·닥터나우가 AI 인용을 장악. 법률은 로톡·로앤굿 등 변호사 디렉터리가 같은 역할. 여기 없으면 AI에겐 없는 업체.',
|
||||
weight: 6,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: '버티컬 프로필 존재·등록 항목 수·후기 수·최근 갱신 (병원 페이지 / 로펌·변호사 프로필)',
|
||||
levels: L('미등록', '등록, 항목 3개 미만', '항목 다수·후기 활성', '+ 전문가·가격 갱신 3개월 이내'),
|
||||
},
|
||||
{
|
||||
id: 'D2',
|
||||
category: 'surface',
|
||||
name: 'Google 비즈니스 프로필',
|
||||
rationale: 'Gemini·Google AI Mode의 구조화 데이터 원천. 국내 성형외과 등록률이 낮아 선점 여지가 크다.',
|
||||
weight: 4,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: 'Google Maps 검색 → 소유권 확인·카테고리·사진·리뷰·NAP 일치',
|
||||
levels: L('없음', '미소유 자동 생성', '소유·기본 정보', '소유·리뷰 응답·게시물 활성'),
|
||||
},
|
||||
{
|
||||
id: 'D3',
|
||||
category: 'surface',
|
||||
name: '오픈웹 언급 (티스토리·유튜브·언론)',
|
||||
rationale: 'Ahrefs 7.5만 브랜드: 웹 언급 상관 r=0.664 (백링크 0.218). 네이버 블로그·카페는 AI 차단이라 제외.',
|
||||
weight: 4,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'influence',
|
||||
method: 'semi',
|
||||
howToCheck: '검색 그라운딩으로 상호 언급 도메인 집계 (naver.com 제외)',
|
||||
levels: L('자체 사이트 외 없음', '유튜브만', '유튜브 + 언론/티스토리', '다수 오픈 도메인에서 일관된 언급'),
|
||||
},
|
||||
{
|
||||
id: 'D4',
|
||||
category: 'surface',
|
||||
name: '표면 간 NAP 완전 일치',
|
||||
rationale: '홈페이지·강남언니·GBP·플레이스의 주소·전화가 한 글자라도 다르면 답변엔진이 동일 업체로 묶지 못한다.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: '표면별 NAP 표를 만들어 대조',
|
||||
levels: L('전화번호 상이', '주소 표기 상이', '경미한 표기 차이', '완전 일치'),
|
||||
},
|
||||
{
|
||||
id: 'D5',
|
||||
category: 'surface',
|
||||
name: '네이버 플레이스 (AI 브리핑 전용)',
|
||||
rationale: '글로벌 AI에는 기여도 0. 단 네이버 AI 브리핑(검색의 20%→40%)은 별도 게임이며 플레이스↔홈페이지 연결이 중요.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'semi',
|
||||
howToCheck: '플레이스 존재·홈페이지 링크·리뷰 수',
|
||||
levels: L('없음', '있음, 홈페이지 미연결', '연결·리뷰 활성', '연결·리뷰·소식 활성'),
|
||||
},
|
||||
{
|
||||
id: 'D6',
|
||||
category: 'surface',
|
||||
name: '위키데이터/위키백과 엔티티',
|
||||
rationale: 'LLM의 엔티티 앵커. 대형 병원만 해당하나 있으면 확정적.',
|
||||
weight: 1,
|
||||
evidenceGrade: 'estimate',
|
||||
control: 'influence',
|
||||
method: 'auto',
|
||||
howToCheck: 'Wikidata API 상호 검색',
|
||||
levels: L('없음', '언급만', '항목 존재', '항목 + 공식 사이트 연결'),
|
||||
},
|
||||
|
||||
// ───────────── E. 측정 (10) ─────────────
|
||||
{
|
||||
id: 'E1',
|
||||
category: 'measure',
|
||||
name: 'AI 크롤러 로그 (IP 검증)',
|
||||
rationale: 'AI가 우리를 읽었는가(선행 지표). 크롤러 UA의 최대 81.8%가 위조 → IP 검증 히트만 인정.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: 'Cloudflare AI Crawl Control 또는 서버로그 + bot_ip_verify.py',
|
||||
levels: L('로그 접근 불가', '로그 있음, 미분석', 'UA 기준 집계', 'IP 검증 히트 집계'),
|
||||
},
|
||||
{
|
||||
id: 'E2',
|
||||
category: 'measure',
|
||||
name: 'GA4 AI 유입 채널',
|
||||
rationale: 'AI에서 사람이 왔는가(후행·매출 귀속). chatgpt.com·perplexity.ai 등 커스텀 채널그룹. 35~70%가 Direct로 유실되므로 하한값.',
|
||||
weight: 3,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: 'GA4 커스텀 채널그룹 존재 여부 (오너 접근 필요)',
|
||||
levels: L('GA4 없음', 'GA4 있음, AI 채널 없음', '기본 AI Assistant 채널', '커스텀 채널 (Perplexity 포함)'),
|
||||
},
|
||||
{
|
||||
id: 'E3',
|
||||
category: 'measure',
|
||||
name: 'GSC AI Overviews 노출',
|
||||
rationale: 'Google AI 표면 노출 (2026-06~ Search Console UI 전용).',
|
||||
weight: 2,
|
||||
evidenceGrade: 'vendor',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: 'GSC 등록 + AI Overviews 필터 확인',
|
||||
levels: L('GSC 미등록', '등록만', '데이터 확인', '월간 추적'),
|
||||
},
|
||||
{
|
||||
id: 'E4',
|
||||
category: 'measure',
|
||||
name: '기준선 스팟체크',
|
||||
rationale: '질의 10개 × 5~7회 반복 수동 측정 → 언급률+신뢰구간. 단발 조회는 무효(분산 10~34%).',
|
||||
weight: 2,
|
||||
evidenceGrade: 'academic',
|
||||
control: 'direct',
|
||||
method: 'manual',
|
||||
howToCheck: '04_baseline 진단 리포트 존재 여부',
|
||||
levels: L('없음', '단발 조회만', '반복 측정 1회', '분기 반복 측정'),
|
||||
},
|
||||
|
||||
// ───────────── F. 위생 (5) ─────────────
|
||||
{
|
||||
id: 'F1',
|
||||
category: 'hygiene',
|
||||
name: '이미지 대체텍스트',
|
||||
rationale: '성형외과 사이트는 정보의 대부분이 이미지 안 글자. alt가 없으면 AI에게 그 정보는 없다.',
|
||||
weight: 2,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'alt 누락 이미지 비율',
|
||||
levels: L('50% 이상 누락', '20~50% 누락', '5~20% 누락', '5% 미만 누락'),
|
||||
},
|
||||
{
|
||||
id: 'F2',
|
||||
category: 'hygiene',
|
||||
name: 'CMS 보안·최신성',
|
||||
rationale: '지원 종료 CMS는 해킹 시 스팸 페이지가 주입되어 AI가 엉뚱한 내용을 인용할 위험.',
|
||||
weight: 1,
|
||||
evidenceGrade: 'estimate',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'meta generator 버전 vs 최신 안정 버전',
|
||||
levels: L('3년 이상 구버전', '1~3년', '1년 이내', '최신'),
|
||||
},
|
||||
{
|
||||
id: 'F3',
|
||||
category: 'hygiene',
|
||||
name: '페이지 무게',
|
||||
rationale: 'HTML 크기가 크면 크롤 예산 소모·요약 품질 저하.',
|
||||
weight: 1,
|
||||
evidenceGrade: 'estimate',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: '초기 HTML 바이트',
|
||||
levels: L('> 2MB', '> 800KB', '300~800KB', '< 300KB'),
|
||||
},
|
||||
{
|
||||
id: 'F4',
|
||||
category: 'hygiene',
|
||||
name: '모바일 대응',
|
||||
rationale: '모바일 인앱 브라우저 유입(ChatGPT 앱)이 주 경로.',
|
||||
weight: 1,
|
||||
evidenceGrade: 'measured',
|
||||
control: 'direct',
|
||||
method: 'auto',
|
||||
howToCheck: 'viewport meta + 반응형',
|
||||
levels: L('viewport 없음', '별도 m. 도메인', '반응형', '반응형 + CWV 양호'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const getCriterion = (id: string) =>
|
||||
AEO_GEO_RUBRIC.criteria.find((c) => c.id === id);
|
||||
|
||||
export const getCategory = (id: string) =>
|
||||
AEO_GEO_RUBRIC.categories.find((c) => c.id === id);
|
||||
12
src/data/discoveryResults.ts
Normal file
12
src/data/discoveryResults.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* AI Discovery 결과 레지스트리. /discovery/:id 가 여기서 찾는다.
|
||||
* 새 업체는 결과 파일을 만들고 여기에 한 줄 추가한다.
|
||||
*/
|
||||
import type { DiscoveryResult } from '../types/discovery';
|
||||
import { DISCOVERY_VIEWCLINIC } from './discovery_viewclinic';
|
||||
import { DISCOVERY_TAEHA } from './discovery_taeha';
|
||||
|
||||
export const DISCOVERY_RESULTS: Record<string, DiscoveryResult> = {
|
||||
viewclinic: DISCOVERY_VIEWCLINIC,
|
||||
taeha: DISCOVERY_TAEHA,
|
||||
};
|
||||
150
src/data/discovery_taeha.ts
Normal file
150
src/data/discovery_taeha.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 법무법인 태하 (taehadrug.com) AEO/GEO 진단 결과
|
||||
* 선정 근거: 네이버 "마약 변호사" 검색 파워링크 1위 광고주 (2026-08-28)
|
||||
* 실측 대상: 광고 랜딩 taehadrug.com(마약 전용) + 메인 www.taehalaw.com
|
||||
* 미검증 항목은 'unverified'. 오너 계정(GA4·GSC·Bing·서버로그) 접근이 필요하다.
|
||||
*/
|
||||
import type { DiscoveryResult } from '../types/discovery';
|
||||
|
||||
export const DISCOVERY_TAEHA: DiscoveryResult = {
|
||||
id: 'taeha',
|
||||
clinicName: '법무법인 태하',
|
||||
url: 'https://taehadrug.com',
|
||||
industry: '로펌 · 마약 형사 (강남 테헤란로, 6개 분사무소)',
|
||||
auditedAt: '2026-08-28',
|
||||
rubricVersion: '1.0',
|
||||
conditions: [
|
||||
'선정: 네이버 "마약 변호사" 검색 파워링크 1위 광고주. 광고 랜딩은 마약 전용 도메인 taehadrug.com',
|
||||
'실측 대상: taehadrug.com 메인 + /success/success_story/, www.taehalaw.com 메인 + /member/page. JS 미실행 HTML 기준',
|
||||
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청 (두 도메인 모두)',
|
||||
'외부 표면: Google 지도·네이버 플레이스·로톡은 공개 페이지 실측. GBP 소유권·GA4·GSC·Bing·서버로그는 오너 접근 필요 → 미검증, 분모 제외',
|
||||
'태하는 도메인이 6개 이상(taehalaw · taehadrug · taehacri · taehadivorce · taehamil · taehaschool)으로 분산돼 있어 엔티티 판단은 메인 도메인과 교차 확인',
|
||||
],
|
||||
results: [
|
||||
// A. 접근성
|
||||
{ criterionId: 'A1', level: 3, evidence: 'taehadrug.com robots.txt: User-agent * Allow /. 12개 AI 봇 UA 전부 200', source: 'https://taehadrug.com/robots.txt' },
|
||||
{ criterionId: 'A2', level: 1, evidence: '메인 도메인 www.taehalaw.com(가비아 호스팅)의 ModSecurity WAF가 GPTBot·OAI-SearchBot을 403으로 차단(errdoc.gabia.io/modsecurity.html로 302). ChatGPT 검색은 메인 사이트를 읽지 못함. taehadrug.com(Apache 별도 호스팅)은 정상', source: 'GPTBot → 302 http://errdoc.gabia.io/403.html / OAI-SearchBot → 302 https://errdoc.gabia.io/modsecurity.html' },
|
||||
{ criterionId: 'A3', level: 3, evidence: '서버 렌더링. JS 미실행 HTML에서 본문 18,352자 추출 (메인), 성공사례 4,578자' },
|
||||
{ criterionId: 'A4', level: 3, evidence: 'http://, http://www, https://www 전부 301로 https://taehadrug.com/ 수렴' },
|
||||
{ criterionId: 'A5', level: 3, evidence: 'robots.txt에 sitemap 선언, 200 응답, URL 326개, lastmod 최신 2026-08-06' },
|
||||
{ criterionId: 'A6', level: 'unverified', evidence: 'Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요' },
|
||||
{ criterionId: 'A7', level: 3, evidence: 'TTFB 0.31s, 초기 HTML 274KB (기준 300KB 이내)' },
|
||||
|
||||
// B. 엔티티
|
||||
{ criterionId: 'B1', level: 3, evidence: 'JSON-LD 3블록 전부 유효. @graph로 Organization·WebSite·LegalService×4·WebPage·BreadcrumbList·FAQPage 연결' },
|
||||
{ criterionId: 'B2', level: 2, evidence: 'LegalService에 name·telephone·address·geo·areaServed(서울·수원·인천 등) 존재. openingHoursSpecification 없음' },
|
||||
{ criterionId: 'B3', level: 2, evidence: 'Organization.employee에 Attorney 2명(채의준 대표변호사, 최승현) name·jobTitle·knowsAbout. 페이지에 소개된 변호사 6명 중 2명만 구조화, 전관 경력(지청장·판사·부장검사 역임)은 스키마에 없음' },
|
||||
{ criterionId: 'B4', level: 2, evidence: '주소는 "테헤란로 522 홍우빌딩 14층" 단일 표기. 전화는 사이트별 상이: taehadrug 1533-2403 / taehalaw 1533-1403 (콜트래킹 번호 2종)' },
|
||||
{ criterionId: 'B5', level: 3, evidence: 'Organization.sameAs 6개: 네이버 블로그·Instagram·YouTube·카카오채널·네이버 지도·Google 지도' },
|
||||
{ criterionId: 'B6', level: 1, evidence: 'canonical이 http://taehadrug.com/ (https 아님). 성공사례 페이지 canonical은 /success-story/인데 실제 URL은 /success/success_story/ → 불일치. 메인 taehalaw.com은 모든 하위 페이지 canonical이 루트를 가리킴' },
|
||||
{ criterionId: 'B7', level: 1, evidence: 'hreflang 없음, lang=ko 단일 언어. 외국인 의뢰인 페이지 없음' },
|
||||
|
||||
// C. 콘텐츠
|
||||
{ criterionId: 'C1', level: 1, evidence: 'H1 "실제사례로 입증된 프리미엄 전략, 마약 사건엔 태하가 답입니다" 슬로건형. "법무법인 태하는 서울 강남 테헤란로에 있는 마약 형사 전문 로펌으로 …" 형태의 정의문 없음' },
|
||||
{ criterionId: 'C2', level: 3, evidence: 'FAQ 섹션 + FAQPage 스키마 14문항 ("초범인데도 실형이 나올 수 있나요?", "마약 양이 적어도 구속될 수 있어요?" 등 실제 질문형)' },
|
||||
{ criterionId: 'C3', level: 2, evidence: 'H1 1개. H2 8개가 서술형(3STEP 솔루션, 맞춤감형 전략). 단 첫 H2가 "#필로폰 #MDMA #대마 #밀수" 해시태그 라벨. 메인 taehalaw.com H1은 "변호사추천 변호사상담" 키워드형 + 영문 H1 2개 추가' },
|
||||
{ criterionId: 'C4', level: 2, evidence: '성공사례 페이지에 사건 유형별 결과(기소유예·집행유예·불송치·벌금형)가 H2로 구조화. 단 수임료 범위·사건 처리 기간·건수 같은 수치 표기 없음' },
|
||||
{ criterionId: 'C5', level: 0, evidence: 'HTML <table> 없음. "마약사건 처벌현황" 메뉴는 이미지·텍스트 블록' },
|
||||
{ criterionId: 'C6', level: 2, evidence: 'sitemap lastmod 2026-08-06(3주 전), 성공사례 지속 추가. 단 dateModified·datePublished 마크업 없음' },
|
||||
{ criterionId: 'C7', level: 3, evidence: '"지청장 역임 임양운", "판사 역임 최승현", "부장검사 역임 이선녀" 등 전관 경력이 H3 텍스트로 노출. Attorney 스키마 병행' },
|
||||
{ criterionId: 'C8', level: 2, evidence: '성공사례 각 H2가 "[집행유예] 필로폰 수수·투약 동종 전력 2회, 항소심 집행유예" 형태로 자기완결적. 메인 H2 "3STEP 솔루션"은 단독으로는 주체 불명' },
|
||||
|
||||
// D. 표면
|
||||
{ criterionId: 'D1', level: 2, evidence: '로톡에 소속 변호사 개인 프로필 확인(김진형 5956, 김유석 8918). 로펌 단위 프로필·전체 변호사 등록 여부는 미확인. 로톡 robots.txt는 GPTBot만 차단, 검색봇은 허용', source: 'https://www.lawtalk.co.kr/directory/profile/5956-김진형' },
|
||||
{ criterionId: 'D2', level: 2, evidence: 'Google 지도에 서울 본점(24시간 영업, 050-71496-4404, 웹사이트)과 수원분사무소(5.0) 등록. 서울 본점 평점 미표시. 브랜드명 검색에 경쟁사(법무법인YK·로엘) 스폰서 광고가 상단 노출' },
|
||||
{ criterionId: 'D3', level: 2, evidence: 'YouTube @with_lawyer, 네이버TV·클립(@taehalaw), Instagram 운영. 언론보도 페이지 존재. 티스토리·외부 매체 언급은 미집계' },
|
||||
{ criterionId: 'D4', level: 0, evidence: '전화번호가 표면마다 다름: taehadrug 1533-2403 / taehalaw 1533-1403 / 네이버 플레이스 0507-1495-4404 / Google 050-71496-4404. 콜트래킹 안심번호가 NAP 일치를 깨뜨림' },
|
||||
{ criterionId: 'D5', level: 2, evidence: '네이버 플레이스 서울주사무소 방문자리뷰 220건, 24시간 연중무휴, 분사무소 5곳 등록. 홈페이지 연결은 학폭 사이트(taehaschool.com)로 걸린 광고 확인, 마약 사이트 연결은 미확인' },
|
||||
{ criterionId: 'D6', level: 'unverified', evidence: 'Wikidata 조회 미실시' },
|
||||
|
||||
// E. 측정
|
||||
{ criterionId: 'E1', level: 'unverified', evidence: '서버 로그 접근 필요 (Apache 직접 호스팅, Cloudflare 미사용)' },
|
||||
{ criterionId: 'E2', level: 'unverified', evidence: 'GA4 접근 필요. 광고 링크에 utm_source=naver 파라미터 사용 중이라 채널그룹 관리 습관은 있음' },
|
||||
{ criterionId: 'E3', level: 'unverified', evidence: 'GSC 접근 필요' },
|
||||
{ criterionId: 'E4', level: 0, evidence: '기준선 스팟체크 미실시' },
|
||||
|
||||
// F. 위생
|
||||
{ criterionId: 'F1', level: 3, evidence: '이미지 136개 중 alt 누락 0 (메인), 성공사례 56개 중 0' },
|
||||
{ criterionId: 'F2', level: 2, evidence: 'generator 메타 없음, 자체 제작 사이트. CMS 버전 노출 없음 (보수적으로 2)' },
|
||||
{ criterionId: 'F3', level: 3, evidence: '초기 HTML 274KB' },
|
||||
{ criterionId: 'F4', level: 2, evidence: 'viewport meta 존재, 반응형. CWV 미측정' },
|
||||
],
|
||||
keyFindings: [
|
||||
{
|
||||
title: '마약 전용 사이트는 AI가 읽기 좋게 만들어져 있다',
|
||||
detail: 'taehadrug.com은 JSON-LD 3블록이 전부 유효하고(LegalService·Attorney·FAQPage·Breadcrumb), FAQ 14문항이 실제 질문형이며, 변호사의 전관 경력이 텍스트로 노출된다. alt 누락 0, HTML 274KB, sitemap 326개 URL이 3주 전 갱신. 뷰성형외과(C 20%)와 달리 콘텐츠 카테고리가 63%다.',
|
||||
severity: 'good',
|
||||
},
|
||||
{
|
||||
title: '메인 도메인이 ChatGPT를 차단하고 있다',
|
||||
detail: 'www.taehalaw.com의 가비아 ModSecurity WAF가 GPTBot과 OAI-SearchBot을 403 페이지로 보낸다. 브라우저와 PerplexityBot은 200. 즉 ChatGPT 검색은 "법무법인 태하"의 메인 사이트를 읽지 못하고, 마약 전용 사이트만 본다. 운영자가 모르는 채 걸려 있는 설정일 가능성이 높다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: '도메인 6개, 전화번호 4종. 엔티티가 쪼개져 있다',
|
||||
detail: '마약·형사·이혼·군형법·학폭이 각각 별도 도메인이고, 사이트별 콜트래킹 번호(1533-2403 / 1533-1403)와 지도의 안심번호(0507-1495-4404 / 050-71496-4404)가 전부 다르다. 사람에게는 분야별 랜딩이지만 AI에게는 같은 이름의 서로 다른 업체 6개로 읽힌다. sameAs가 도메인 간 상호 연결을 하지 않는다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: 'canonical이 http://이고 URL과 어긋난다',
|
||||
detail: 'taehadrug.com 메인 canonical은 http://taehadrug.com/ (https 아님). 성공사례 페이지 canonical은 /success-story/인데 실제 경로는 /success/success_story/. 메인 taehalaw.com은 모든 하위 페이지의 canonical이 루트다. 인용 신호가 잘못된 URL로 흘러간다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
title: '브랜드 검색에 경쟁사가 올라탄다',
|
||||
detail: 'Google 지도에서 "법무법인 태하"를 검색하면 법무법인YK·로엘의 스폰서 리스팅이 상단에 뜬다. 서울 본점 GBP는 평점이 표시되지 않는다. 로톡에는 변호사 개인 프로필만 확인되고 로펌 단위 존재감이 약하다. 네이버 파워링크 1위를 사는 만큼 AI 표면의 방어는 비어 있다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '메인 도메인 WAF에서 AI 검색봇 허용',
|
||||
detail: '가비아 호스팅 ModSecurity 규칙에서 GPTBot·OAI-SearchBot·Claude-SearchBot·PerplexityBot UA를 화이트리스트. 학습봇을 막고 싶으면 robots.txt로 GPTBot만 Disallow하고 OAI-SearchBot은 연다. 12개 UA로 200 응답 재확인.',
|
||||
criterionIds: ['A2', 'A1'],
|
||||
effort: '1일',
|
||||
},
|
||||
{
|
||||
priority: 'P0',
|
||||
title: 'canonical https 교정 + 도메인 간 sameAs 상호 연결',
|
||||
detail: 'taehadrug.com canonical을 https://로, 성공사례 canonical을 실제 경로로. taehalaw.com 하위 페이지 canonical을 self-referencing으로. 6개 도메인의 Organization 스키마에 서로의 URL을 sameAs로 넣어 한 엔티티로 묶는다. 메인 taehalaw.com에 LegalService + Attorney 전원 스키마 삽입.',
|
||||
criterionIds: ['B6', 'B5', 'B2', 'B3'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '전화번호 1개로 통일, 표면 4곳 NAP 정렬',
|
||||
detail: '대표번호 1개를 정하고 홈페이지 6개·네이버 플레이스·Google 지도 전화를 동일하게. 콜트래킹이 필요하면 스키마 telephone과 지도 등록은 대표번호로, 추적 번호는 화면 버튼에만. Google 본점 리뷰 수집 시작, 로톡 로펌 프로필 정비.',
|
||||
criterionIds: ['D4', 'D2', 'D1', 'B4'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: '정의문 + 수치 + 표 도입',
|
||||
detail: '각 도메인 첫 단락에 "법무법인 태하는 서울 강남 테헤란로에 본사를 둔 형사 전문 로펌으로, 마약·성범죄·이혼을 다루며 수원·인천·대전·천안·제주 분사무소를 운영한다" 정의문. 성공사례에 사건 처리 기간·결과 분포를 HTML 표로. 메인 H1을 "변호사추천 변호사상담"에서 서술형으로.',
|
||||
criterionIds: ['C1', 'C4', 'C5', 'C3'],
|
||||
effort: '2~4주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: '측정 인프라 설치',
|
||||
detail: 'GA4 커스텀 채널그룹(chatgpt.com·perplexity.ai·gemini.google.com·copilot·claude.ai). Apache access.log에서 AI 봇 히트 추출 + IP 검증. GSC·Bing Webmaster 등록 확인 + IndexNow. "마약 변호사", "필로폰 초범 집행유예" 등 질의 10개 × 5회 기준선 스팟체크.',
|
||||
criterionIds: ['E1', 'E2', 'E3', 'E4', 'A6'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: '신선도 마크업 + 오픈웹 시딩',
|
||||
detail: '성공사례에 datePublished·dateModified 마크업. YouTube @with_lawyer 영상을 사이트 글로 전사. 네이버 블로그 대신 티스토리·언론 기고로 naver.com 외 언급 도메인 확보. 외국인 의뢰인 대상 영문 페이지가 있다면 hreflang 연결.',
|
||||
criterionIds: ['C6', 'D3', 'B7'],
|
||||
effort: '지속',
|
||||
},
|
||||
],
|
||||
notDoing: [
|
||||
{ item: 'llms.txt 제작', reason: 'Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음.' },
|
||||
{ item: '도메인 통합 강요', reason: '분야별 도메인은 광고 랜딩 전환에 유리할 수 있다. 통합 대신 sameAs·canonical·NAP로 한 엔티티임을 기계에 알리는 쪽이 비용이 적다.' },
|
||||
{ item: '"스키마 넣으면 AI가 인용합니다" 약속', reason: '태하는 이미 스키마가 좋다. 그런데도 ChatGPT가 메인을 못 읽는다. 스키마는 위생이고, 접근이 먼저다.' },
|
||||
{ item: '단발 조회로 순위·노출 보고', reason: 'LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.' },
|
||||
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 네이버 AI 브리핑은 별도 트랙.' },
|
||||
],
|
||||
};
|
||||
156
src/data/discovery_viewclinic.ts
Normal file
156
src/data/discovery_viewclinic.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 뷰성형외과 (viewclinic.com) AEO/GEO 진단 결과
|
||||
* 실측일: 2026-08-28 · 도구: crawler_audit.py + curl + HTML 파서
|
||||
* 미검증 항목은 'unverified'. 오너 접근(GA4·GSC·Bing) 또는 수동 확인이 필요하다.
|
||||
*/
|
||||
import type { DiscoveryResult } from '../types/discovery';
|
||||
|
||||
export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
|
||||
id: 'viewclinic',
|
||||
clinicName: '뷰성형외과',
|
||||
url: 'https://www.viewclinic.com',
|
||||
industry: '성형외과 (강남 신논현)',
|
||||
auditedAt: '2026-08-28',
|
||||
rubricVersion: '1.0',
|
||||
conditions: [
|
||||
'실측 대상: 메인 페이지 + /breast/main/ (가슴성형) 2개 URL, JS 미실행 HTML 기준',
|
||||
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',
|
||||
'강남언니·Google 비즈니스 프로필은 공개 페이지를 실측(2026-08-28). GBP 소유권 클레임 여부·인사이트와 측정 인프라(GA4·GSC·Bing·서버로그)는 오너 접근 필요 → 미검증 처리, 분모 제외',
|
||||
'답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)',
|
||||
],
|
||||
results: [
|
||||
// A. 접근성
|
||||
{ criterionId: 'A1', level: 3, evidence: '12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200', source: 'robots.txt: User-agent: * / Disallow: /wp-admin/' },
|
||||
{ criterionId: 'A2', level: 3, evidence: 'Server: nginx 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음' },
|
||||
{ criterionId: 'A3', level: 3, evidence: 'WordPress 서버 렌더링. JS 미실행 HTML에서 본문 텍스트 10,757자 추출' },
|
||||
{ criterionId: 'A4', level: 3, evidence: 'http://viewclinic.com → 301 → https://www.viewclinic.com/ 정본 수렴' },
|
||||
{ criterionId: 'A5', level: 2, evidence: 'robots.txt에 sitemap 선언, 200 응답, AIOSEO 3.7.1 동적 생성. 페이지 249개 + kboard 11개. 단 post lastmod 최신이 2024-05-31' },
|
||||
{ criterionId: 'A6', level: 'unverified', evidence: 'Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요' },
|
||||
{ criterionId: 'A7', level: 2, evidence: 'TTFB 0.135s (양호) / 초기 HTML 838KB (비대). 시술 페이지 862KB' },
|
||||
|
||||
// B. 엔티티
|
||||
{ criterionId: 'B1', level: 1, evidence: 'JSON-LD 2블록 중 ItemList 블록이 문법 오류(쉼표 누락, line 11)로 파싱 실패 → 시술 목록 구조화 데이터 전체 무효', source: 'Expecting \',\' delimiter: line 11 column 7' },
|
||||
{ criterionId: 'B2', level: 1, evidence: 'Organization + WebSite + WebPage만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음' },
|
||||
{ criterionId: 'B3', level: 0, evidence: 'Physician 스키마 없음. 의료진 정보는 HTML 텍스트·이미지로만 존재' },
|
||||
{ criterionId: 'B4', level: 2, evidence: '주소 2가지 표기 혼재: "서울 강남구 봉은사로 107 뷰성형외과 빌딩" / "서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩". 전화 02-539-1177 + 휴대폰 2개 혼재' },
|
||||
{ criterionId: 'B5', level: 0, evidence: 'Organization.sameAs: [] (빈 배열). 푸터에 YouTube·Instagram·Facebook·네이버블로그 링크가 있으나 스키마에 미연결' },
|
||||
{ criterionId: 'B6', level: 2, evidence: 'rel=canonical self-referencing 존재' },
|
||||
{ criterionId: 'B7', level: 0, evidence: '영문 사이트가 별도 도메인(viewplasticsurgery.com, en-US)으로 존재하나 두 사이트 어디에도 hreflang 없음. 다국어 페이지가 있는데 미연결' },
|
||||
|
||||
// C. 콘텐츠
|
||||
{ criterionId: 'C1', level: 0, evidence: '메인 첫 화면은 슬라이더·"VIEW SELFIE" 등 라벨. "뷰성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 시술 페이지 첫 텍스트는 오시는길·상담폼' },
|
||||
{ criterionId: 'C2', level: 0, evidence: 'FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·가슴성형 페이지)' },
|
||||
{ criterionId: 'C3', level: 0, evidence: 'H1 5개: "VIEW SELFIE / VIEW EVENT / VIEW SHORTS / VIEW FILM / VIEW SPECIALITY". 전부 영문 라벨. H2는 같은 문구가 2회씩 중복(모바일/PC 분기). 시술 페이지 H1 없음' },
|
||||
{ criterionId: 'C4', level: 1, evidence: '메타 설명에 "가슴보형물사용량1위" 등 주장형 문구. 가슴성형 페이지에 가격/비용 단어 8회이나 표기된 가격대·수술시간·회복기간 표 없음' },
|
||||
{ criterionId: 'C5', level: 0, evidence: 'HTML <table> 없음. 비교 정보는 이미지로 추정' },
|
||||
{ criterionId: 'C6', level: 1, evidence: 'article:modified_time·dateModified 없음. post-sitemap 최신 lastmod 2024-05-31 (15개월 전). 단 H3 콘텐츠(유튜브 제목)는 최근 갱신 흔적' },
|
||||
{ criterionId: 'C7', level: 2, evidence: '원장·전문의·의료진 언급 17회, 모티바 글로벌 KOL·보건복지부장관표창·갈더마 방문 등 권위 신호 존재. 단 구조화 안 됨' },
|
||||
{ criterionId: 'C8', level: 1, evidence: 'H2 "철저한 정밀 검진 / ONE_STOP 시스템 / 1:1 전담 마취시스템". 단독으로 떼면 어느 병원의 무엇인지 알 수 없음' },
|
||||
|
||||
// D. 표면
|
||||
{ criterionId: 'D1', level: 3, evidence: '강남언니 hospitals/189 등록. 후기 19,316건, 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10건(CCTV·마취과 상주), 진료시간 표기', source: 'https://www.gangnamunni.com/hospitals/189' },
|
||||
{ criterionId: 'D2', level: 2, evidence: 'GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록("장소의 전화번호 추가" 상태), 웹사이트가 국문 홈페이지가 아닌 viewplasticsurgery.com' },
|
||||
{ criterionId: 'D3', level: 2, evidence: '자체 YouTube 채널 활발(VIEW SHORTS/FILM, 최신 영상 제목 다수). 티스토리·언론 언급은 미집계' },
|
||||
{ criterionId: 'D4', level: 1, evidence: '주소 표기 3종 혼재: 홈페이지 "서울 강남구 봉은사로 107 뷰성형외과 빌딩" / 강남언니 "서울 강남구 봉은사로 107 (논현동)" / GBP "서울특별시 강남구 봉은사로 107". GBP 전화 누락, 웹사이트 도메인 불일치' },
|
||||
{ criterionId: 'D5', level: 2, evidence: '네이버 플레이스 존재(리뷰 776건, 2026-04 INFINITH 리포트 기준). 홈페이지 링크 연결 여부 미확인 → 보수적으로 2' },
|
||||
{ criterionId: 'D6', level: 'unverified', evidence: 'Wikidata 조회 미실시' },
|
||||
|
||||
// E. 측정
|
||||
{ criterionId: 'E1', level: 'unverified', evidence: '서버 로그 접근 필요 (nginx 직접 호스팅, Cloudflare AI Crawl Control 미사용)' },
|
||||
{ criterionId: 'E2', level: 'unverified', evidence: 'GA4 접근 필요' },
|
||||
{ criterionId: 'E3', level: 'unverified', evidence: 'GSC 접근 필요' },
|
||||
{ criterionId: 'E4', level: 0, evidence: '기준선 스팟체크 미실시' },
|
||||
|
||||
// F. 위생
|
||||
{ criterionId: 'F1', level: 1, evidence: '이미지 302개 중 111개(37%) alt 누락 또는 빈 값' },
|
||||
{ criterionId: 'F2', level: 0, evidence: 'meta generator: WordPress 5.5.3 (2020-10 출시, 6년 전) + WPBakery. 보안 패치 미적용 위험' },
|
||||
{ criterionId: 'F3', level: 1, evidence: '초기 HTML 838KB (기준 300KB의 2.8배)' },
|
||||
{ criterionId: 'F4', level: 2, evidence: 'viewport meta 존재, 반응형. CWV 미측정' },
|
||||
],
|
||||
keyFindings: [
|
||||
{
|
||||
title: '크롤러 접근은 정상이다. AI가 사이트를 읽을 수 있다',
|
||||
detail: 'robots.txt가 AI 검색봇 12종을 모두 허용하고, Cloudflare 차단도 없으며, WordPress가 본문을 서버에서 렌더링한다. 접근성 카테고리는 상위권. "AI에 안 나오는" 원인은 차단이 아니라 내용에 있다.',
|
||||
severity: 'good',
|
||||
},
|
||||
{
|
||||
title: '읽어도 "누구인지" 확정할 수 없다',
|
||||
detail: 'MedicalClinic 스키마가 없고 Organization.sameAs가 비어 있으며, 시술 목록 JSON-LD는 쉼표 하나가 빠져 통째로 무효다. 주소 표기도 사이트 안에서 2가지. AI는 "뷰성형외과"라는 이름과 흩어진 사실을 하나의 엔티티로 묶을 근거를 얻지 못한다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: '인용할 문장이 없다',
|
||||
detail: 'H1이 "VIEW SELFIE / VIEW EVENT / VIEW SHORTS…" 5개, FAQ 0개, HTML 표 0개, 정의문 0개. 가슴성형 페이지의 첫 텍스트는 상담 폼이다. 답변엔진이 패시지 단위로 잘라 가져갈 자기완결적 단락이 존재하지 않는다. 권위 신호(KOL·표창)는 있으나 이미지와 슬로건 안에만 있어 텍스트로 읽히지 않는다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: '15개월간 갱신 신호 없음',
|
||||
detail: 'post-sitemap의 최신 lastmod가 2024-05-31이고 dateModified 마크업이 없다. 유튜브는 활발하지만 그 신선도가 사이트로 전달되지 않는다. 오래된 사이트는 brand 질의에서 "영업 중인가?" 오정보 위험을 만든다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
title: '강남언니는 강하다. GBP는 전화번호가 없고 웹사이트가 다르다',
|
||||
detail: '강남언니에 후기 19,316건·의료진 26명·이벤트 52건으로 병의원 AI 인용의 핵심 표면은 확보됐다. GBP도 4.9점(901 리뷰)에 소유자 게시물이 활성이다. 문제는 NAP다. GBP에 전화번호가 없고, 웹사이트는 viewclinic.com이 아닌 영문 사이트 viewplasticsurgery.com이며, 주소 표기가 표면마다 3종이다. AI가 세 표면을 한 병원으로 묶을 근거가 약하다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '깨진 JSON-LD 수정 + MedicalClinic 스키마 삽입',
|
||||
detail: 'ItemList 블록 쉼표 누락 수정(1시간). Organization → MedicalClinic으로 승격하고 address·telephone·openingHoursSpecification·medicalSpecialty·geo 추가. sameAs에 YouTube·Instagram·Facebook·강남언니·네이버플레이스·GBP URL 연결.',
|
||||
criterionIds: ['B1', 'B2', 'B5'],
|
||||
effort: '1일',
|
||||
},
|
||||
{
|
||||
priority: 'P0',
|
||||
title: 'GBP 전화번호 등록 + 4개 표면 NAP 통일',
|
||||
detail: 'GBP에 02-539-1177 등록, 웹사이트를 viewclinic.com으로 변경(영문 사이트는 hreflang으로 연결). 홈페이지·강남언니·GBP·플레이스 주소를 "서울특별시 강남구 봉은사로 107 뷰성형외과빌딩"으로 통일. 두 도메인 간 hreflang 상호 연결.',
|
||||
criterionIds: ['D2', 'D4', 'B4', 'B7'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '측정 인프라 설치 (개선 증명의 전제)',
|
||||
detail: 'GA4 커스텀 채널그룹(chatgpt.com·perplexity.ai·gemini.google.com·copilot·claude.ai) 생성. nginx access.log에서 AI 봇 히트 추출 + IP 검증 파이프라인. GSC·Bing Webmaster 등록 확인 + IndexNow. 질의 10개 × 5회 기준선 스팟체크.',
|
||||
criterionIds: ['E1', 'E2', 'E3', 'E4', 'A6'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: '메인·시술 페이지에 정의문 + FAQ 도입',
|
||||
detail: '각 핵심 페이지 첫 단락에 "뷰성형외과는 서울 강남 신논현역 1분 거리의 가슴성형·안면윤곽 전문 성형외과로, 마취과 전문의가 상주한다"형 정의문. 시술별 FAQ 5~8개(가격대·수술시간·회복기간·보형물 종류·재수술)를 H3 질문형으로 작성 + FAQPage 스키마.',
|
||||
criterionIds: ['C1', 'C2', 'C4', 'C8'],
|
||||
effort: '2~4주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'H1 재설계 + 이미지 속 정보의 텍스트화',
|
||||
detail: '페이지당 H1 1개 서술형("뷰성형외과 UIU 가슴성형: 보형물 선택과 안전 시스템"). 모바일/PC 중복 H2 제거. 보형물 비교·시술 비교를 HTML <table>로. 의료진 페이지에 전문의 자격·경력·학회를 텍스트 + Physician 스키마로.',
|
||||
criterionIds: ['C3', 'C5', 'C7', 'B3'],
|
||||
effort: '2~4주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'WordPress 5.5.3 → 최신 업그레이드',
|
||||
detail: '6년 된 코어는 보안 위험이자 스팸 주입 시 AI 오인용 위험. 업그레이드 후 alt 누락 111개 보완, HTML 무게 절감(WPBakery 인라인 CSS 정리).',
|
||||
criterionIds: ['F2', 'F1', 'F3'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: '신선도 신호 + 오픈웹 시딩',
|
||||
detail: 'dateModified 마크업 활성화, 유튜브 영상을 사이트 글로 전사(트랜스크립트 요약)해 매월 갱신. 티스토리·언론 기고로 naver.com 외 언급 도메인 확보. 네이버 블로그는 AI에 안 보이므로 글로벌 AEO 용도로는 쓰지 않는다.',
|
||||
criterionIds: ['C6', 'D3'],
|
||||
effort: '지속',
|
||||
},
|
||||
],
|
||||
notDoing: [
|
||||
{ item: 'llms.txt 제작', reason: 'Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.' },
|
||||
{ item: '"스키마 넣으면 AI가 인용합니다" 약속', reason: '인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.' },
|
||||
{ item: '단발 조회로 순위·노출 보고', reason: 'LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.' },
|
||||
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
|
||||
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' },
|
||||
],
|
||||
};
|
||||
|
||||
85
src/lib/discoveryScore.ts
Normal file
85
src/lib/discoveryScore.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* AEO/GEO 채점 집계
|
||||
*
|
||||
* - level(0~3) → weight × level/3
|
||||
* - 'unverified' 항목은 분모(possible)에서 제외 → 확인 못 한 것을 감점하지 않는다
|
||||
* - 전체 점수 = earned / possible × 100 (검증 항목 기준 정규화)
|
||||
*/
|
||||
import type {
|
||||
CategoryScore,
|
||||
CriterionLevel,
|
||||
CriterionResult,
|
||||
DiscoveryResult,
|
||||
OverallScore,
|
||||
Rubric,
|
||||
} from '../types/discovery';
|
||||
|
||||
export const levelToPct = (level: CriterionLevel) => (level / 3) * 100;
|
||||
|
||||
export const levelToSeverity = (
|
||||
level: CriterionLevel | 'unverified',
|
||||
): 'critical' | 'warning' | 'good' | 'excellent' | 'unknown' => {
|
||||
if (level === 'unverified') return 'unknown';
|
||||
return (['critical', 'warning', 'good', 'excellent'] as const)[level];
|
||||
};
|
||||
|
||||
export const gradeFromScore = (score: number): OverallScore['grade'] => {
|
||||
if (score > 80) return 'A';
|
||||
if (score > 60) return 'B';
|
||||
if (score > 40) return 'C';
|
||||
return 'D';
|
||||
};
|
||||
|
||||
/** 점수 구간별 색 (ScoreRing과 동일 기준) */
|
||||
export const scorePctColor = (pct: number) =>
|
||||
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
||||
|
||||
/**
|
||||
* 다음 등급까지의 목표 점수. gradeFromScore의 경계와 반드시 일치해야 한다.
|
||||
* 재진단 목표 표기에 사용 (예: 48/C → { score: 61, grade: 'B' }). A등급이면 null.
|
||||
*/
|
||||
export const nextGradeTarget = (
|
||||
score: number,
|
||||
): { score: number; grade: OverallScore['grade'] } | null => {
|
||||
if (score <= 40) return { score: 41, grade: 'C' };
|
||||
if (score <= 60) return { score: 61, grade: 'B' };
|
||||
if (score <= 80) return { score: 81, grade: 'A' };
|
||||
return null;
|
||||
};
|
||||
|
||||
export function scoreDiscovery(rubric: Rubric, result: DiscoveryResult): OverallScore {
|
||||
const byId = new Map<string, CriterionResult>(result.results.map((r) => [r.criterionId, r]));
|
||||
|
||||
const categories: CategoryScore[] = rubric.categories.map((category) => {
|
||||
const criteria = rubric.criteria.filter((c) => c.category === category.id);
|
||||
let earned = 0;
|
||||
let possible = 0;
|
||||
let verifiedCount = 0;
|
||||
|
||||
for (const c of criteria) {
|
||||
const r = byId.get(c.id);
|
||||
if (!r || r.level === 'unverified') continue;
|
||||
verifiedCount += 1;
|
||||
possible += c.weight;
|
||||
earned += (c.weight * r.level) / 3;
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
earned: round1(earned),
|
||||
possible,
|
||||
weight: category.weight,
|
||||
pct: possible === 0 ? 0 : Math.round((earned / possible) * 100),
|
||||
verifiedCount,
|
||||
totalCount: criteria.length,
|
||||
};
|
||||
});
|
||||
|
||||
const earned = categories.reduce((s, c) => s + c.earned, 0);
|
||||
const possible = categories.reduce((s, c) => s + c.possible, 0);
|
||||
const score = possible === 0 ? 0 : Math.round((earned / possible) * 100);
|
||||
|
||||
return { score, earned: round1(earned), possible, categories, grade: gradeFromScore(score) };
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
@ -194,8 +194,10 @@ function buildAssets(enrichment: EnrichmentData | undefined): { assets: AssetCar
|
||||
// Instagram posts → photo assets
|
||||
const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
|
||||
for (const ig of igAccounts) {
|
||||
if (ig.latestPosts) {
|
||||
for (const p of (ig.latestPosts as { type?: string; likes?: number; caption?: string }[]).slice(0, 3)) {
|
||||
const igPosts = (ig as { latestPosts?: { type?: string; likes?: number; caption?: string }[] })
|
||||
.latestPosts;
|
||||
if (igPosts) {
|
||||
for (const p of igPosts.slice(0, 3)) {
|
||||
assets.push({
|
||||
id: `ig-${assetIdx++}`,
|
||||
source: 'social',
|
||||
|
||||
@ -159,7 +159,7 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
||||
}
|
||||
if (ch.issues) {
|
||||
for (const issue of ch.issues) {
|
||||
const issueObj = issue as Record<string, unknown>;
|
||||
const issueObj = issue as unknown as Record<string, unknown>;
|
||||
const detail = typeof issue === 'string'
|
||||
? issue
|
||||
: typeof issueObj.issue === 'string'
|
||||
|
||||
@ -15,6 +15,8 @@ import DataValidationPage from './pages/DataValidationPage.tsx';
|
||||
import ClinicProfilePage from './pages/ClinicProfilePage.tsx';
|
||||
import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
|
||||
import LoginPage from './pages/LoginPage.tsx';
|
||||
import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
|
||||
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
@ -36,6 +38,8 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="data-validation" element={<DataValidationPage />} />
|
||||
<Route path="clinic/:id" element={<ClinicProfilePage />} />
|
||||
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
||||
<Route path="discovery" element={<DiscoveryLandingPage />} />
|
||||
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -262,7 +262,7 @@ function CategoryBadge({ category }: { category: ApiConfig['category'] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EnvKeyRow({ envKey, present, isServerSide }: { envKey: string; present: boolean; isServerSide: boolean }) {
|
||||
function EnvKeyRow({ envKey, present, isServerSide }: { key?: string; envKey: string; present: boolean; isServerSide: boolean }) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@ -303,7 +303,7 @@ function EnvKeyRow({ envKey, present, isServerSide }: { envKey: string; present:
|
||||
);
|
||||
}
|
||||
|
||||
function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) {
|
||||
function ApiCard({ api, index }: { key?: string; api: ApiStatusInfo; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
53
src/pages/DiscoveryLandingPage.tsx
Normal file
53
src/pages/DiscoveryLandingPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* /discovery — AI Discovery(AEO/GEO) 서비스 랜딩.
|
||||
* 시안: docs/reports/ai_discovery_landing.html (2026-08-31 확정)
|
||||
*
|
||||
* 데이터 원칙: 점수·등급·카테고리 %·항목 수는 전부
|
||||
* scoreDiscovery(AEO_GEO_RUBRIC, DISCOVERY_VIEWCLINIC) 계산값에서 파생한다.
|
||||
* 재진단으로 데이터가 바뀌면 랜딩 수치도 함께 바뀐다 (JSX 하드코딩 금지).
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||
import { DISCOVERY_VIEWCLINIC } from '../data/discovery_viewclinic';
|
||||
import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore';
|
||||
import DiscoveryHero from '../components/discovery/DiscoveryHero';
|
||||
import WhyNow from '../components/discovery/WhyNow';
|
||||
import HowItWorks from '../components/discovery/HowItWorks';
|
||||
import SampleReport from '../components/discovery/SampleReport';
|
||||
import Roadmap from '../components/discovery/Roadmap';
|
||||
import HonestyStrip from '../components/discovery/HonestyStrip';
|
||||
import DiscoveryCta from '../components/discovery/DiscoveryCta';
|
||||
|
||||
export default function DiscoveryLandingPage() {
|
||||
const result = DISCOVERY_VIEWCLINIC;
|
||||
const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]);
|
||||
const target = nextGradeTarget(overall.score);
|
||||
|
||||
const criteriaCount = AEO_GEO_RUBRIC.criteria.length;
|
||||
const categoryCount = AEO_GEO_RUBRIC.categories.length;
|
||||
const verifiedCount = overall.categories.reduce((sum, c) => sum + c.verifiedCount, 0);
|
||||
const unverifiedCount = criteriaCount - verifiedCount;
|
||||
|
||||
return (
|
||||
<div className="[word-break:keep-all]">
|
||||
<DiscoveryHero
|
||||
overall={overall}
|
||||
target={target}
|
||||
clinicName={result.clinicName}
|
||||
sampleReportPath={`/discovery/${result.id}`}
|
||||
criteriaCount={criteriaCount}
|
||||
/>
|
||||
<WhyNow />
|
||||
<HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} />
|
||||
<SampleReport overall={overall} result={result} />
|
||||
<Roadmap
|
||||
grade={overall.grade}
|
||||
targetGrade={target?.grade ?? null}
|
||||
criteriaCount={criteriaCount}
|
||||
unverifiedCount={unverifiedCount}
|
||||
/>
|
||||
<HonestyStrip />
|
||||
<DiscoveryCta criteriaCount={criteriaCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
src/pages/DiscoveryReportPage.tsx
Normal file
452
src/pages/DiscoveryReportPage.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
ActionPriority,
|
||||
CategoryId,
|
||||
CategoryScore,
|
||||
ControlLevel,
|
||||
EvidenceGrade,
|
||||
EvidenceMethod,
|
||||
RubricCriterion,
|
||||
} from '../types/discovery';
|
||||
import { SectionWrapper } from '../components/report/ui/SectionWrapper';
|
||||
import { ScoreRing } from '../components/report/ui/ScoreRing';
|
||||
import { SeverityBadge } from '../components/report/ui/SeverityBadge';
|
||||
import {
|
||||
ServerFilled,
|
||||
DatabaseFilled,
|
||||
FileTextFilled,
|
||||
ShareFilled,
|
||||
EyeFilled,
|
||||
ShieldFilled,
|
||||
PrismFilled,
|
||||
CheckFilled,
|
||||
CrossFilled,
|
||||
WarningFilled,
|
||||
} from '../components/icons/FilledIcons';
|
||||
|
||||
/* ────────────────────────── 라벨 맵 ────────────────────────── */
|
||||
|
||||
const CATEGORY_ICON: Record<CategoryId, ComponentType<{ size?: number; className?: string }>> = {
|
||||
access: ServerFilled,
|
||||
entity: DatabaseFilled,
|
||||
content: FileTextFilled,
|
||||
surface: ShareFilled,
|
||||
measure: EyeFilled,
|
||||
hygiene: ShieldFilled,
|
||||
};
|
||||
|
||||
const EVIDENCE_LABEL: Record<EvidenceGrade, string> = {
|
||||
measured: '실측',
|
||||
academic: '학술',
|
||||
vendor: '벤더',
|
||||
estimate: '추정',
|
||||
};
|
||||
|
||||
const CONTROL_LABEL: Record<ControlLevel, string> = {
|
||||
direct: '직접 통제',
|
||||
influence: '영향 가능',
|
||||
none: '통제 불가',
|
||||
};
|
||||
|
||||
const METHOD_LABEL: Record<EvidenceMethod, string> = {
|
||||
auto: '자동',
|
||||
semi: '반자동',
|
||||
manual: '수동',
|
||||
};
|
||||
|
||||
const PRIORITY_STYLE: Record<ActionPriority, string> = {
|
||||
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
|
||||
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
|
||||
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
||||
};
|
||||
|
||||
const scoreColor = (pct: number) =>
|
||||
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
||||
|
||||
/* ────────────────────────── 페이지 ────────────────────────── */
|
||||
|
||||
export default function DiscoveryReportPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const result = id ? DISCOVERY_RESULTS[id] : undefined;
|
||||
const overall = useMemo(
|
||||
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
|
||||
[result],
|
||||
);
|
||||
|
||||
if (!result || !overall) {
|
||||
return (
|
||||
<div className="pt-36 pb-24 px-6 text-center">
|
||||
<p className="text-slate-600">진단 결과를 찾을 수 없습니다.</p>
|
||||
<Link to="/" className="text-[#6C5CE7] text-base mt-4 inline-block">홈으로</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
|
||||
|
||||
return (
|
||||
<div data-report-content className="[word-break:keep-all]">
|
||||
{/* ── 1. 헤더 (dark) ── */}
|
||||
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
||||
<div className="relative max-w-7xl mx-auto grid lg:grid-cols-[1fr_auto] gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
|
||||
<PrismFilled size={16} className="text-purple-300" />
|
||||
<span className="text-sm font-semibold tracking-wide text-purple-200">
|
||||
INFINITH AI Discovery · AEO / GEO Audit v{result.rubricVersion}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
|
||||
AI Answer Readiness
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
|
||||
<p className="text-purple-200 text-base mb-1">{result.industry}</p>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-purple-300 text-base underline underline-offset-4"
|
||||
>
|
||||
{result.url}
|
||||
</a>
|
||||
<p className="text-white/60 text-sm mt-4">
|
||||
실측일 {result.auditedAt} · 검증 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
|
||||
{' '}{AEO_GEO_RUBRIC.criteria.length} · 미검증 항목은 분모 제외
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-3 min-w-[260px]">
|
||||
<ScoreRing score={overall.score} size={160} label="AI Answer Readiness" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-serif text-4xl font-black text-[#0A1128]">{overall.grade}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
{overall.earned} / {overall.possible}점
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 바 */}
|
||||
<div className="relative max-w-7xl mx-auto mt-14 grid md:grid-cols-3 gap-4">
|
||||
{overall.categories.map((cs, i) => (
|
||||
<CategoryBar key={cs.category.id} cs={cs} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2. 핵심 발견 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="findings"
|
||||
title="What AI Sees"
|
||||
subtitle="AI 답변엔진이 지금 이 업체를 어떻게 읽는지. 진단의 결론"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
{result.keyFindings.map((f, i) => (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
|
||||
<FindingIcon severity={f.severity} />
|
||||
</div>
|
||||
<p className="text-base text-slate-600 leading-relaxed">{f.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* ── 3. 카테고리별 상세 (dark) ── */}
|
||||
<SectionWrapper
|
||||
id="breakdown"
|
||||
title="Scorecard by Category"
|
||||
subtitle="36개 항목 · 각 항목의 실측 근거와 근거 등급"
|
||||
dark
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{overall.categories.map((cs) => {
|
||||
const Icon = CATEGORY_ICON[cs.category.id];
|
||||
const criteria = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cs.category.id);
|
||||
return (
|
||||
<div
|
||||
key={cs.category.id}
|
||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 shrink-0 rounded-xl bg-[#F3F0FF] flex items-center justify-center text-[#6C5CE7]">
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<p className="font-serif text-[28px] font-bold text-[#1D0024] tracking-[-0.01em] leading-10">
|
||||
{cs.category.code} · {cs.category.nameEn}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mt-1 pl-[52px] font-sans text-[#0A1128]!">{cs.category.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-400">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
|
||||
<p className="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
|
||||
</div>
|
||||
<ScoreRing score={cs.pct} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg text-[#4F1DA1] mb-4">{cs.category.description}</p>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{criteria.map((c) => {
|
||||
const r = resultById.get(c.id);
|
||||
const level = r?.level ?? 'unverified';
|
||||
const levelLabel =
|
||||
level === 'unverified' ? '미검증' : c.levels[level].label;
|
||||
return (
|
||||
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-400">{c.id}</span>
|
||||
<p className="text-base font-bold">{c.name}</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{c.weight}점</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base text-slate-700">{r?.evidence}</p>
|
||||
{r?.source && (
|
||||
<p className="text-sm text-slate-400 mt-1 font-mono break-all">{r.source}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
|
||||
<Tag>{CONTROL_LABEL[c.control]}</Tag>
|
||||
<Tag>{METHOD_LABEL[c.method]}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
|
||||
{level !== 'unverified' && (
|
||||
<span className="text-sm text-slate-400">
|
||||
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* ── 4. 실행 계획 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="actions"
|
||||
title="Action Plan"
|
||||
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
|
||||
<div key={p}>
|
||||
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
|
||||
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
|
||||
{p}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-[#0A1128]">
|
||||
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{result.actions.filter((a) => a.priority === p).map((a, i) => (
|
||||
<motion.div
|
||||
key={a.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
|
||||
<span className="text-sm text-slate-400 shrink-0">{a.effort}</span>
|
||||
</div>
|
||||
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{a.criterionIds.map((cid) => (
|
||||
<Tag key={cid}>{cid}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
|
||||
<SectionWrapper
|
||||
id="integrity"
|
||||
title="What We Don't Sell"
|
||||
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
|
||||
dark
|
||||
>
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
||||
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">하지 않을 것</h3>
|
||||
<ul className="space-y-4">
|
||||
{result.notDoing.map((n) => (
|
||||
<li key={n.item} className="flex gap-3">
|
||||
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
|
||||
<div>
|
||||
<p className="text-base font-semibold">{n.item}</p>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{n.reason}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
||||
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">측정 조건</h3>
|
||||
<ul className="space-y-3">
|
||||
{result.conditions.map((c) => (
|
||||
<li key={c} className="flex gap-3">
|
||||
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
|
||||
<p className="text-base text-slate-600">{c}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
|
||||
<p className="text-sm text-slate-600 leading-relaxed">
|
||||
점수는 <strong>검증된 항목만</strong>으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을
|
||||
확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며,
|
||||
크롤러 접근·엔티티·콘텐츠 구조라는 <strong>전제조건</strong>의 충족 여부를 진단합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* ── 6. 채점 기준표 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="rubric"
|
||||
title="Scoring Rubric"
|
||||
subtitle={`AEO/GEO 채점 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목 · 100점`}
|
||||
>
|
||||
<div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
|
||||
<table className="w-full text-base min-w-[860px]">
|
||||
<thead className="bg-[#0A1128] text-white">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">항목</th>
|
||||
<th className="text-left px-4 py-3 font-semibold w-14">배점</th>
|
||||
<th className="text-left px-4 py-3 font-semibold w-16">근거</th>
|
||||
<th className="text-left px-4 py-3 font-semibold w-20">통제</th>
|
||||
<th className="text-left px-4 py-3 font-semibold w-16">채점</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">3점 기준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{AEO_GEO_RUBRIC.categories.map((cat) => (
|
||||
<RubricRows
|
||||
key={cat.id}
|
||||
categoryName={`${cat.code}. ${cat.name} (${cat.weight})`}
|
||||
criteria={AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
||||
|
||||
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
|
||||
const Icon = CATEGORY_ICON[cs.category.id];
|
||||
return (
|
||||
<motion.a
|
||||
href="#breakdown"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 + index * 0.06 }}
|
||||
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 p-5 text-[#0A1128]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
|
||||
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">{cs.weight}점</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ background: scoreColor(cs.pct) }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${cs.pct}%` }}
|
||||
transition={{ duration: 0.9, ease: 'easeOut', delay: 0.3 + index * 0.06 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-sm">
|
||||
<span className="font-semibold">{cs.pct}%</span>
|
||||
<span className="text-slate-400">
|
||||
{cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) {
|
||||
if (severity === 'good') return <span className="text-[#9B8AD4] shrink-0"><CheckFilled size={22} /></span>;
|
||||
if (severity === 'warning') return <span className="text-[#D4A872] shrink-0"><WarningFilled size={22} /></span>;
|
||||
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
|
||||
}
|
||||
|
||||
function Tag({ children }: { key?: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
|
||||
return (
|
||||
<>
|
||||
<tr className="bg-[#F4F6FB]">
|
||||
<td colSpan={7} className="px-4 py-2 text-sm font-bold text-[#4A3A7C] tracking-wide">
|
||||
{categoryName}
|
||||
</td>
|
||||
</tr>
|
||||
{criteria.map((c) => (
|
||||
<tr key={c.id} className="border-t border-slate-100 align-top">
|
||||
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-semibold text-[#0A1128]">{c.name}</p>
|
||||
<p className="text-sm text-slate-500 mt-1 leading-relaxed">{c.rationale}</p>
|
||||
<p className="text-sm text-slate-400 mt-1 font-mono">{c.howToCheck}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold">{c.weight}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{CONTROL_LABEL[c.control]}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{METHOD_LABEL[c.method]}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{c.levels[3].label}</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
125
src/types/discovery.ts
Normal file
125
src/types/discovery.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* INFINITH AI Discovery AEO/GEO 채점 타입
|
||||
*
|
||||
* 설계 원칙
|
||||
* - 기준표(rubric)와 결과(result)를 분리한다. 결과는 criterionId → level 매핑만 가진다.
|
||||
* - 미검증(unverified) 항목은 0점이 아니라 분모에서 제외한다.
|
||||
* - 모든 항목에 근거 등급(evidenceGrade)과 통제 가능성(control)을 붙인다.
|
||||
*/
|
||||
|
||||
/** 0 = 없음/치명, 1 = 미흡, 2 = 양호, 3 = 우수 */
|
||||
export type CriterionLevel = 0 | 1 | 2 | 3;
|
||||
|
||||
/** 근거 등급 (ado2-aeo-geo 정직성 규약) */
|
||||
export type EvidenceGrade = 'measured' | 'academic' | 'vendor' | 'estimate';
|
||||
|
||||
/** 업체가 통제할 수 있는 정도 */
|
||||
export type ControlLevel = 'direct' | 'influence' | 'none';
|
||||
|
||||
/** 자동 채점 가능 여부 */
|
||||
export type EvidenceMethod = 'auto' | 'semi' | 'manual';
|
||||
|
||||
export type CategoryId =
|
||||
| 'access'
|
||||
| 'entity'
|
||||
| 'content'
|
||||
| 'surface'
|
||||
| 'measure'
|
||||
| 'hygiene';
|
||||
|
||||
export interface RubricLevelSpec {
|
||||
level: CriterionLevel;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RubricCriterion {
|
||||
id: string;
|
||||
category: CategoryId;
|
||||
name: string;
|
||||
/** 왜 AI 답변 노출에 영향을 주는가 */
|
||||
rationale: string;
|
||||
/** 배점 (카테고리 합 = 카테고리 weight) */
|
||||
weight: number;
|
||||
evidenceGrade: EvidenceGrade;
|
||||
control: ControlLevel;
|
||||
method: EvidenceMethod;
|
||||
/** 어떻게 확인하는가 (자동화 시 구현 스펙) */
|
||||
howToCheck: string;
|
||||
levels: RubricLevelSpec[];
|
||||
}
|
||||
|
||||
export interface RubricCategory {
|
||||
id: CategoryId;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
description: string;
|
||||
/** 100점 만점 중 배점 */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface Rubric {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
categories: RubricCategory[];
|
||||
criteria: RubricCriterion[];
|
||||
}
|
||||
|
||||
/** 개별 항목 채점 결과 */
|
||||
export interface CriterionResult {
|
||||
criterionId: string;
|
||||
/** 'unverified' → 분모 제외 */
|
||||
level: CriterionLevel | 'unverified';
|
||||
/** 실측 근거 (한 줄) */
|
||||
evidence: string;
|
||||
/** 실측 원문·URL 등 */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type ActionPriority = 'P0' | 'P1' | 'P2';
|
||||
|
||||
export interface ActionItem {
|
||||
priority: ActionPriority;
|
||||
title: string;
|
||||
detail: string;
|
||||
/** 연결된 기준 항목 */
|
||||
criterionIds: string[];
|
||||
/** 예상 소요 */
|
||||
effort: '1일' | '1주' | '2~4주' | '지속';
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
id: string;
|
||||
clinicName: string;
|
||||
url: string;
|
||||
industry: string;
|
||||
auditedAt: string;
|
||||
rubricVersion: string;
|
||||
/** 측정 조건 명시 (정직성 규약) */
|
||||
conditions: string[];
|
||||
results: CriterionResult[];
|
||||
/** AI가 지금 이 업체를 어떻게 읽는지. 핵심 발견 */
|
||||
keyFindings: { title: string; detail: string; severity: 'critical' | 'warning' | 'good' }[];
|
||||
actions: ActionItem[];
|
||||
/** 하지 않을 것 + 이유 */
|
||||
notDoing: { item: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** 집계 결과 */
|
||||
export interface CategoryScore {
|
||||
category: RubricCategory;
|
||||
earned: number;
|
||||
possible: number; // 검증된 항목의 배점 합
|
||||
weight: number; // 원래 배점
|
||||
pct: number; // 0~100
|
||||
verifiedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface OverallScore {
|
||||
score: number; // 0~100 (검증 항목 기준 정규화)
|
||||
earned: number;
|
||||
possible: number;
|
||||
categories: CategoryScore[];
|
||||
grade: 'A' | 'B' | 'C' | 'D';
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@ -22,5 +22,6 @@
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "supabase"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user