diff --git a/docs/AEO_GEO_RUBRIC.md b/docs/AEO_GEO_RUBRIC.md new file mode 100644 index 0000000..7460de6 --- /dev/null +++ b/docs/AEO_GEO_RUBRIC.md @@ -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 / LocalBusiness | 5 | 학술 | 직접 | 자동 | + openingHours·medicalSpecialty·geo | +| B3 | Physician 스키마 | 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) diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..f46d49e --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,37 @@ +/** + * Badge — 가격표 등에서 쓰는 강조 배지. + * PricingPage가 import하던 컴포넌트가 저장소에 누락되어 빌드가 깨져 있어 복원 (2026-08-28). + */ +type BadgeVariant = 'popular' | 'new' | 'recommended'; +type BadgeSize = 'sm' | 'md'; + +const LABEL: Record = { + popular: 'Most Popular', + new: 'New', + recommended: 'Recommended', +}; + +const SIZE: Record = { + 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 ( + + {label ?? LABEL[variant]} + + ); +} diff --git a/src/components/report/ui/SeverityBadge.tsx b/src/components/report/ui/SeverityBadge.tsx index bdf4e43..efaaadf 100644 --- a/src/components/report/ui/SeverityBadge.tsx +++ b/src/components/report/ui/SeverityBadge.tsx @@ -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 = { @@ -28,12 +30,12 @@ const config: Record }, }; -export function SeverityBadge({ severity, label }: SeverityBadgeProps) { +export function SeverityBadge({ severity, label, className: extra = '' }: SeverityBadgeProps) { const { className, defaultLabel } = config[severity]; return ( {label ?? defaultLabel} diff --git a/src/data/aeoGeoRubric.ts b/src/data/aeoGeoRubric.ts new file mode 100644 index 0000000..0920015 --- /dev/null +++ b/src/data/aeoGeoRubric.ts @@ -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 / LocalBusiness 스키마', + rationale: '엔티티 식별을 돕는 위생 조치. 인과 증거는 없으나 name·address·telephone·openingHours·medicalSpecialty를 기계가 읽게 한다.', + weight: 5, + evidenceGrade: 'academic', + control: 'direct', + method: 'auto', + howToCheck: '@type MedicalClinic|MedicalBusiness|LocalBusiness 존재 + 필수 속성 4개', + levels: L('없음', 'Organization만', 'MedicalClinic + 주소·전화', '+ openingHours·medicalSpecialty·geo'), + }, + { + id: 'B3', + category: 'entity', + name: 'Physician 스키마 (의료진)', + rationale: '"누가 수술하나"는 병의원 질의의 핵심. 의사 이름·전문의 자격을 구조화하면 E-E-A-T 엔티티가 된다.', + weight: 3, + evidenceGrade: 'academic', + control: 'direct', + method: 'auto', + howToCheck: '@type Physician + name·medicalSpecialty·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: ' 또는 정의 리스트 존재 (이미지 표 제외)', + 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('의료진 정보 없음', '이름·사진만', '전문의·경력 텍스트', '+ 논문·학회·수상 + Physician 스키마'), + }, + { + 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 인용을 100% 장악. 여기 없으면 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); diff --git a/src/data/discovery_viewclinic.ts b/src/data/discovery_viewclinic.ts new file mode 100644 index 0000000..486f24a --- /dev/null +++ b/src/data/discovery_viewclinic.ts @@ -0,0 +1,159 @@ +/** + * 뷰성형외과 (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 요청', + '외부 표면(강남언니·GBP·NAP 대조)과 측정 인프라(GA4·GSC)는 오너 접근 없이 확인 불가 → 미검증 처리, 분모 제외', + '답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)', + ], + 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: 1, evidence: 'hreflang 없음, lang=ko-KR 단일 언어. 외국인 환자 대상 언어 페이지 미확인' }, + + // 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
없음. 비교 정보는 이미지로 추정' }, + { 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: 'unverified', evidence: '강남언니 프로필 존재·시술 수·후기 수는 수동 확인 필요 (P0 확인 항목)' }, + { criterionId: 'D2', level: 'unverified', evidence: 'Google 비즈니스 프로필 소유 여부 수동 확인 필요' }, + { criterionId: 'D3', level: 2, evidence: '자체 YouTube 채널 활발(VIEW SHORTS/FILM, 최신 영상 제목 다수). 티스토리·언론 언급은 미집계' }, + { criterionId: 'D4', level: 'unverified', evidence: '외부 표면 NAP 대조표 미작성' }, + { 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: '병의원 AI 인용의 핵심 표면(강남언니·GBP)은 아직 미확인', + detail: '2026-08 실측에서 병의원 AI 인용은 강남언니·닥터나우 등 버티컬이 100% 장악했다. 이 표면의 상태가 실제 노출을 좌우하므로 수동 확인이 P0다. 확인 전까지 분모에서 제외해 점수를 부풀리지 않았다.', + 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: '강남언니·Google 비즈니스 프로필 실사', + detail: '프로필 존재·시술 등록 수·후기·가격 갱신일 확인. GBP 소유권 미확인이면 즉시 클레임. 홈페이지·강남언니·GBP·플레이스 4개 표면 NAP 대조표 작성 후 "서울특별시 강남구 봉은사로 107 뷰성형외과빌딩 / 02-539-1177"로 통일.', + criterionIds: ['D1', 'D2', 'D4', 'B4'], + 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
로. 의료진 페이지에 전문의 자격·경력·학회를 텍스트 + 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 브리핑은 별도 트랙.' }, + ], +}; + +export const DISCOVERY_RESULTS: Record = { + viewclinic: DISCOVERY_VIEWCLINIC, +}; diff --git a/src/lib/discoveryScore.ts b/src/lib/discoveryScore.ts new file mode 100644 index 0000000..b4ee99f --- /dev/null +++ b/src/lib/discoveryScore.ts @@ -0,0 +1,68 @@ +/** + * 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'; +}; + +export function scoreDiscovery(rubric: Rubric, result: DiscoveryResult): OverallScore { + const byId = new Map(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; diff --git a/src/main.tsx b/src/main.tsx index 3e04bb9..1c46b8c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,6 +15,7 @@ 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 DiscoveryReportPage from './pages/DiscoveryReportPage.tsx'; import './index.css'; createRoot(document.getElementById('root')!).render( @@ -36,6 +37,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx new file mode 100644 index 0000000..8962bc9 --- /dev/null +++ b/src/pages/DiscoveryReportPage.tsx @@ -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/discovery_viewclinic'; +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> = { + access: ServerFilled, + entity: DatabaseFilled, + content: FileTextFilled, + surface: ShareFilled, + measure: EyeFilled, + hygiene: ShieldFilled, +}; + +const EVIDENCE_LABEL: Record = { + measured: '실측', + academic: '학술', + vendor: '벤더', + estimate: '추정', +}; + +const CONTROL_LABEL: Record = { + direct: '직접 통제', + influence: '영향 가능', + none: '통제 불가', +}; + +const METHOD_LABEL: Record = { + auto: '자동', + semi: '반자동', + manual: '수동', +}; + +const PRIORITY_STYLE: Record = { + 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 ( +
+

진단 결과를 찾을 수 없습니다.

+ 홈으로 +
+ ); + } + + const resultById = new Map(result.results.map((r) => [r.criterionId, r])); + + return ( +
+ {/* ── 1. 헤더 (dark) ── */} +
+
+
+
+
+ + + INFINITH AI Discovery · AEO / GEO Audit v{result.rubricVersion} + +
+

+ + AI Answer Readiness + +

+

{result.clinicName}

+

{result.industry}

+ + {result.url} + +

+ 실측일 {result.auditedAt} · 검증 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} / + {' '}{AEO_GEO_RUBRIC.criteria.length} · 미검증 항목은 분모 제외 +

+
+ +
+ +
+ {overall.grade} + + {overall.earned} / {overall.possible}점 + +
+
+
+ + {/* 카테고리 바 */} +
+ {overall.categories.map((cs, i) => ( + + ))} +
+
+ + {/* ── 2. 핵심 발견 (light) ── */} + +
+ {result.keyFindings.map((f, i) => ( + +
+

{f.title}

+ +
+

{f.detail}

+
+ ))} +
+
+ + {/* ── 3. 카테고리별 상세 (dark) ── */} + +
+ {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 ( +
+
+
+
+ +
+
+

+ {cs.category.code} · {cs.category.nameEn} +

+

{cs.category.name}

+
+
+
+
+

배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}

+

{cs.earned} / {cs.possible}점

+
+ +
+
+

{cs.category.description}

+ +
+ {criteria.map((c) => { + const r = resultById.get(c.id); + const level = r?.level ?? 'unverified'; + const levelLabel = + level === 'unverified' ? '미검증' : c.levels[level].label; + return ( +
+
+ {c.id} +

{c.name}

+

{c.weight}점

+
+
+

{r?.evidence}

+ {r?.source && ( +

{r.source}

+ )} +
+ {EVIDENCE_LABEL[c.evidenceGrade]} + {CONTROL_LABEL[c.control]} + {METHOD_LABEL[c.method]} +
+
+
+ + {level !== 'unverified' && ( + + {Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight} + + )} +
+
+ ); + })} +
+
+ ); + })} +
+
+ + {/* ── 4. 실행 계획 (light) ── */} + +
+ {(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => ( +
+
+ + {p} + + + {p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'} + +
+
+ {result.actions.filter((a) => a.priority === p).map((a, i) => ( + +
+

{a.title}

+ {a.effort} +
+

{a.detail}

+
+ {a.criterionIds.map((cid) => ( + {cid} + ))} +
+
+ ))} +
+
+ ))} +
+
+ + {/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */} + +
+
+

하지 않을 것

+
    + {result.notDoing.map((n) => ( +
  • + +
    +

    {n.item}

    +

    {n.reason}

    +
    +
  • + ))} +
+
+
+

측정 조건

+
    + {result.conditions.map((c) => ( +
  • + +

    {c}

    +
  • + ))} +
+
+

+ 점수는 검증된 항목만으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을 + 확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며, + 크롤러 접근·엔티티·콘텐츠 구조라는 전제조건의 충족 여부를 진단합니다. +

+
+
+
+
+ + {/* ── 6. 채점 기준표 (light) ── */} + +
+
+ + + + + + + + + + + + + {AEO_GEO_RUBRIC.categories.map((cat) => ( + c.category === cat.id)} + /> + ))} + +
ID항목배점근거통제채점3점 기준
+ + + + ); +} + +/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */ + +function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) { + const Icon = CATEGORY_ICON[cs.category.id]; + return ( + +
+
+ + {cs.category.code}. {cs.category.name} +
+ {cs.weight}점 +
+
+ +
+
+ {cs.pct}% + + {cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'} + +
+
+ ); +} + +function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) { + if (severity === 'good') return ; + if (severity === 'warning') return ; + return ; +} + +function Tag({ children }: { key?: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) { + return ( + <> + + + {categoryName} + + + {criteria.map((c) => ( + + {c.id} + +

{c.name}

+

{c.rationale}

+

{c.howToCheck}

+ + {c.weight} + {EVIDENCE_LABEL[c.evidenceGrade]} + {CONTROL_LABEL[c.control]} + {METHOD_LABEL[c.method]} + {c.levels[3].label} + + ))} + + ); +} diff --git a/src/types/discovery.ts b/src/types/discovery.ts new file mode 100644 index 0000000..7bb1f7d --- /dev/null +++ b/src/types/discovery.ts @@ -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'; +}