Compare commits
No commits in common. "main" and "feature/ai-sentiment-rubric" have entirely different histories.
main
...
feature/ai
10
.gitignore
vendored
@ -11,13 +11,3 @@ doc/
|
||||
|
||||
# Auto-generated: Supabase DB snapshots (vite-plugin-supabase-sync)
|
||||
src/data/db/
|
||||
|
||||
# Word 잠금 파일
|
||||
~$*
|
||||
|
||||
# 파이썬 캐시
|
||||
__pycache__/
|
||||
|
||||
# 개인 업무 문서 (주간보고 등). 저장소에 올리지 않는다
|
||||
docs/*주간보고*
|
||||
docs/*weekly_report*
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
"_comment": "AI 검색 유입 분류 기준(단일 원본). GA4 세션 출처(sessionSource)·리퍼러·utm_source 를 이 표로 AI 채널에 묶는다. measurable 은 리포트의 '측정 가능 범위' 표에 그대로 나간다. 수정하면 scripts/ga4_ai_traffic.py 와 리포트 빌더가 같이 바뀐다.",
|
||||
"version": "2026-09-08",
|
||||
"channels": [
|
||||
{ "id": "chatgpt", "label": "ChatGPT", "sources": ["chatgpt.com", "chat.openai.com", "openai.com"], "utm_sources": ["chatgpt.com", "chatgpt", "openai"],
|
||||
"measurable": "yes", "note": "링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다." },
|
||||
{ "id": "perplexity", "label": "Perplexity", "sources": ["perplexity.ai", "www.perplexity.ai"], "utm_sources": ["perplexity", "perplexity.ai"],
|
||||
"measurable": "partial", "note": "리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다." },
|
||||
{ "id": "gemini", "label": "Gemini", "sources": ["gemini.google.com", "bard.google.com"], "utm_sources": ["gemini"],
|
||||
"measurable": "yes", "note": "gemini.google.com 리퍼러로 분리된다." },
|
||||
{ "id": "copilot", "label": "Copilot", "sources": ["copilot.microsoft.com", "bing.com/chat", "edgeservices.bing.com"], "utm_sources": ["copilot"],
|
||||
"measurable": "partial", "note": "Bing 검색과 리퍼러가 섞이는 경우가 있다." },
|
||||
{ "id": "claude", "label": "Claude", "sources": ["claude.ai"], "utm_sources": ["claude"],
|
||||
"measurable": "yes", "note": "claude.ai 리퍼러." },
|
||||
{ "id": "google_aio", "label": "Google AI Overview · AI Mode", "sources": [], "utm_sources": [],
|
||||
"measurable": "no", "note": "구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다." },
|
||||
{ "id": "naver_ai", "label": "네이버 AI 브리핑", "sources": [], "utm_sources": [],
|
||||
"measurable": "no", "note": "네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능." },
|
||||
{ "id": "other_ai", "label": "기타 AI 검색", "sources": ["you.com", "search.brave.com", "phind.com", "deepseek.com", "chat.deepseek.com", "grok.com", "x.ai", "meta.ai", "kagi.com", "liner.com", "getliner.com", "wrtn.ai"], "utm_sources": ["deepseek", "grok", "you.com", "wrtn"],
|
||||
"measurable": "partial", "note": "리퍼러가 있을 때만 잡힌다." }
|
||||
],
|
||||
"not_measurable_by_ga4": [
|
||||
"AI 답변에 병원이 언급됐지만 사용자가 링크를 누르지 않은 경우(노출만 된 경우). 이건 답변엔진 실측(질문 뱅크)으로 잰다.",
|
||||
"AI 크롤러(GPTBot·PerplexityBot·ClaudeBot·Google-Extended)의 방문. 사람 방문이 아니라 GA4에 잡히지 않는다. 서버 로그로 잰다.",
|
||||
"리퍼러를 비운 채 넘어온 AI 유입. GA4에는 direct 로 들어간다.",
|
||||
"전화 통화·오프라인 방문으로 이어진 전환. 사이트의 전화 탭·예약 클릭까지만 잰다."
|
||||
],
|
||||
"conversion_events": [
|
||||
{ "name": "click_tel", "label": "전화 탭", "match": "a[href^='tel:']" },
|
||||
{ "name": "click_reservation", "label": "상담 예약 클릭", "match": "a.cta, a[data-ga='reservation']" },
|
||||
{ "name": "click_clinic_site", "label": "병원 홈페이지 이동", "match": "a[data-ga='clinic-site']" },
|
||||
{ "name": "click_map", "label": "지도·길찾기 클릭", "match": "a[data-ga='map']" }
|
||||
]
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
# 마이그레이션 파일과 운영 DB 대조표
|
||||
|
||||
작성 2026-09-14 · 읽기 전용 조회 결과입니다. 이 문서를 만들며 DB 를 바꾸지 않았습니다.
|
||||
|
||||
**정본은 마이그레이션 파일입니다**(haewon 결정 2026-09-14).
|
||||
|
||||
## 왜 필요한가
|
||||
|
||||
`supabase migration list` 기준으로 로컬 마이그레이션 10개 중 **원격 이력에 기록된 것은 3개**뿐입니다.
|
||||
나머지는 SQL 편집기로 직접 만들어 이력에 남지 않았습니다.
|
||||
이 상태에서 `migration repair` 로 "적용됨"을 기록하면 파일과 DB 가 실제로는 다른데 같다고 기록돼
|
||||
`db diff`·`db pull` 이 어긋난 결과를 냅니다. 그래서 repair 전에 무엇이 다른지 먼저 확인했습니다.
|
||||
|
||||
## 대조 결과
|
||||
|
||||
| 테이블 | 상태 | 파일에만 있음 (DB 에 추가 필요) | DB 에만 있음 (파일에 반영 필요) | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| `analysis_runs` | 일치 | - | - | - |
|
||||
| `channel_configs` | 일치 | - | - | - |
|
||||
| `channel_latest` | 뷰 | - | captured_at, channel, clinic_id, followers, handle, health_score, health_status, posts, rating, rating_scale, reviews, screenshot_url, total_views | VIEW 입니다. 마이그레이션 파일에 정의가 없습니다 |
|
||||
| `channel_snapshots` | 일치 | - | - | - |
|
||||
| `channel_weekly_delta` | 뷰 | - | captured_at, channel, clinic_id, current_followers, current_reviews, followers_change_pct, prev_followers, prev_reviews | VIEW 입니다. 마이그레이션 파일에 정의가 없습니다 |
|
||||
| `clinic_registry` | 다름 | is_active, verified_at, verified_by | notes | - |
|
||||
| `clinics` | 일치 | - | - | - |
|
||||
| `content_performance` | 일치 | - | - | - |
|
||||
| `content_plans` | 일치 | - | - | - |
|
||||
| `discovery_leads` | 일치 | - | - | - |
|
||||
| `marketing_reports` | 일치 | - | - | - |
|
||||
| `performance_metrics` | 일치 | - | - | - |
|
||||
| `scrape_results` | 일치 | - | - | - |
|
||||
| `screenshots` | 일치 | - | - | - |
|
||||
| `strategy_adjustments` | 일치 | - | - | - |
|
||||
| `supporter_builds` | 일치 | - | - | - |
|
||||
| `supporter_inputs` | 일치 | - | - | - |
|
||||
|
||||
표 17개 중 **15개가 정확히 일치**합니다. 손볼 곳은 `clinic_registry` 하나와 정의가 빠진 뷰 2개입니다.
|
||||
|
||||
## 확인한 것
|
||||
|
||||
- `channel_latest`·`channel_weekly_delta` 는 **뷰(VIEW)** 입니다. 표가 어긋난 것이 아니라 뷰 정의가 마이그레이션 파일에 없습니다.
|
||||
- `clinic_registry` 의 `notes` 는 **74곳 전부에 값이 있어 실제로 쓰이는 컬럼**입니다. 지우면 안 되고 파일에 반영해야 합니다.
|
||||
- `is_active`·`verified_at`·`verified_by` 는 파일에만 있습니다. 코드가 쓰는지 확인한 뒤 추가할지 파일에서 뺄지 정합니다.
|
||||
|
||||
## 다음에 할 일 (순서를 바꾸면 안 됩니다)
|
||||
|
||||
1. `clinic_registry` 의 `is_active`·`verified_at`·`verified_by` 를 운영 DB 에 추가하거나, 쓰지 않으면 마이그레이션 파일에서 뺍니다.
|
||||
2. `notes` 컬럼과 뷰 2개의 정의를 마이그레이션 파일에 추가합니다.
|
||||
3. 둘이 맞은 뒤에 `supabase migration repair --status applied <version>` 으로 이력을 맞춥니다.
|
||||
4. 그다음부터 `supabase db push` 가 새 마이그레이션만 적용합니다.
|
||||
|
||||
3번을 먼저 하면 어긋난 상태가 기록으로 굳습니다.
|
||||
|
||||
## 대조 방법
|
||||
|
||||
```
|
||||
supabase db query --linked -o csv \
|
||||
"select table_name, column_name from information_schema.columns where table_schema='public' order by table_name, ordinal_position;"
|
||||
```
|
||||
|
||||
파일 쪽은 `CREATE TABLE` 과 `ALTER TABLE ... ADD COLUMN` 을 파싱해 모았습니다.
|
||||
제약·인덱스·함수·트리거·RLS 는 이 대조에 넣지 않았습니다. 컬럼만 봅니다.
|
||||
@ -1,172 +0,0 @@
|
||||
# 설계: 신청 접수 알림과 완료 안내 메일 v0.1
|
||||
|
||||
작성 2026-09-18 · 대상 AI Discovery 랜딩(`/discovery`) 신청 흐름
|
||||
|
||||
---
|
||||
|
||||
## 1. 지금 무엇이 비어 있나
|
||||
|
||||
랜딩 신청 모달은 이렇게 말합니다.
|
||||
|
||||
> 입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다.
|
||||
|
||||
**보내는 코드가 없습니다.** `workers/`, `scripts/`, `supabase/functions/` 전체에 메일 발송이 없습니다. 유일한 `mailto:`는 저장 실패 시 사람이 직접 메일을 쓰라고 띄우는 폴백입니다(`DiscoveryLeadModal.tsx:82`).
|
||||
|
||||
그래서 지금은 이렇게 됩니다.
|
||||
|
||||
```
|
||||
신청 → discovery_leads (status: new)
|
||||
→ supporter_builds 큐 → 워커가 빌드 → preview
|
||||
→ 여기서 끝. 신청자에게도, 운영자에게도 아무 연락이 없음
|
||||
```
|
||||
|
||||
2026-09-18 JK성형외과 신청이 정상 접수되고 10분 만에 빌드까지 끝났지만, 신청자가 직접 확인하기 전까지 아무도 몰랐습니다.
|
||||
|
||||
**문제는 두 갈래입니다.**
|
||||
|
||||
| | 문제 |
|
||||
|---|---|
|
||||
| 운영자 | 신청이 들어온 사실을 알 방법이 없습니다. `discovery_leads`는 anon insert만 열려 있고 조회는 service_role 전용이며, 폴러는 `supporter_builds`만 봅니다 |
|
||||
| 신청자 | 화면이 약속한 안내를 받지 못합니다 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 원칙
|
||||
|
||||
**원칙 1. 자동 발송은 접수 알림까지만 한다.**
|
||||
|
||||
완료 안내를 자동으로 보내면 사람 검토를 건너뛴 결과물 링크가 나갑니다. 이번 JK 건에서 첫 화면 대표 사진이 사람 확인이 필요한 사진이었던 것을 생각하면, 자동 발송이 아니었던 것이 결과적으로 안전했습니다. 완료 안내는 **사람이 보내기를 누를 때** 나갑니다.
|
||||
|
||||
**원칙 2. 화면의 약속과 코드를 맞춘다.**
|
||||
|
||||
지금 모달 문구는 자동으로 갈 것처럼 읽힙니다. 실제 동작에 맞게 문구를 고치거나, 동작을 문구에 맞추거나 둘 중 하나를 해야 합니다. 이 설계는 후자에 가깝되 검토 단계를 명시하는 쪽입니다.
|
||||
|
||||
**원칙 3. "리포트"라는 말을 정확히 쓴다.**
|
||||
|
||||
현재 파이프라인이 만드는 것은 **서포터즈 사이트**입니다. 랜딩이 말하는 AEO/GEO 36항목 진단 리포트(`/discovery/viewclinic` 형태)는 이 빌드에서 생성되지 않습니다. 신청 한 건이 서로 다른 두 산출물로 갈라져 있어, 메일 문안에서 무엇을 보내는지 분명히 해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 보내는 메일 3종
|
||||
|
||||
### 3-1. 접수 확인 (신청자에게, 자동)
|
||||
|
||||
- **시점**: `discovery_leads` insert 직후
|
||||
- **받는 사람**: 신청자가 남긴 연락처
|
||||
- **내용**: 접수 사실, 어떤 URL로 접수됐는지, 언제쯤 연락이 가는지, 문의처(`o2oteam@o2o.kr`)
|
||||
- **보내지 않는 것**: 결과 링크, 진행률
|
||||
|
||||
### 3-2. 신규 신청 알림 (운영자에게, 자동)
|
||||
|
||||
- **시점**: 3-1과 동시
|
||||
- **받는 사람**: 운영 담당 주소 1개
|
||||
- **내용**: 병원명·URL·연락처, 빌드 큐 등록 여부, 확인 화면 링크(`/images/<clinic_id>`, `/supporters/<clinic_id>`)
|
||||
- **목적**: 신청을 놓치지 않는 최소 장치. 이것만 있어도 지금 상태보다 낫습니다
|
||||
|
||||
### 3-3. 완료 안내 (신청자에게, 사람이 보냄)
|
||||
|
||||
- **시점**: 운영자가 이미지 검토와 결과 확인을 마치고 **보내기를 눌렀을 때**
|
||||
- **받는 사람**: 신청자
|
||||
- **내용**: 무엇을 만들었는지, 미리보기 링크(noindex), 병원이 확인해야 할 것 두 가지(`/images/<id>` 사진 확인, `/supporters/<id>` 확인 항목), 색인 전환은 법무 확인 뒤라는 안내
|
||||
- **전제**: 발송 전에 사진 검토가 끝나 있어야 합니다
|
||||
|
||||
---
|
||||
|
||||
## 4. 무엇으로 보내나
|
||||
|
||||
### 4-1. 발송 수단
|
||||
|
||||
**현재 메일 발송 키가 하나도 없습니다.** `.env`에 Resend·SendGrid·SES·SMTP 어느 것도 없어 계정부터 필요합니다.
|
||||
|
||||
| 후보 | 장점 | 단점 |
|
||||
|---|---|---|
|
||||
| **Resend** (추천) | Edge Function에서 fetch 한 번. 도메인 인증이 단순. 무료 한도로 이 규모는 충분 | 계정·도메인 인증 필요 |
|
||||
| SendGrid | 전달률 기록이 길다 | 설정 항목이 많다 |
|
||||
| AWS SES | 단가가 가장 낮다 | 샌드박스 해제 심사가 필요 |
|
||||
|
||||
발신 도메인은 `o2o.kr` 계열을 쓰고, 신규 도메인은 초기 전달률이 낮으므로 SPF·DKIM·DMARC를 먼저 맞춥니다.
|
||||
|
||||
### 4-2. 발송 위치
|
||||
|
||||
**Supabase Edge Function `notify-lead` 를 새로 만듭니다.**
|
||||
|
||||
프런트에서 직접 부르지 않습니다. 프런트가 부르면 anon 키로 메일을 쏠 수 있게 되어 스팸 경로가 됩니다. DB 변경을 신호로 삼습니다.
|
||||
|
||||
```
|
||||
discovery_leads INSERT
|
||||
→ Postgres 트리거 (pg_net)
|
||||
→ Edge Function notify-lead (service_role 검증)
|
||||
→ Resend 호출 2건 (신청자 접수 확인 + 운영자 알림)
|
||||
→ email_log 기록
|
||||
```
|
||||
|
||||
완료 안내(3-3)는 같은 함수의 다른 진입점으로 두고, 운영자 화면의 버튼에서 호출합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 필요한 DB 변경
|
||||
|
||||
### 5-1. `email_log` 신설
|
||||
|
||||
무엇을 언제 누구에게 보냈는지 남깁니다. 중복 발송을 막고, 안 갔다는 문의에 답할 근거가 됩니다.
|
||||
|
||||
| 열 | 용도 |
|
||||
|---|---|
|
||||
| `id` | |
|
||||
| `kind` | `lead_ack` · `lead_notify` · `build_done` |
|
||||
| `lead_id` / `clinic_id` | 대상 |
|
||||
| `to_addr` | 받는 주소 |
|
||||
| `status` | `sent` · `failed` |
|
||||
| `provider_id` | 발송 서비스가 준 id. 전달 추적용 |
|
||||
| `error` | 실패 사유 |
|
||||
| `created_at` | |
|
||||
|
||||
중복 발송 방지는 `(kind, lead_id)` 유니크로 겁니다.
|
||||
|
||||
### 5-2. `discovery_leads.status` 를 실제로 쓴다
|
||||
|
||||
지금은 모든 행이 `new`로 고정입니다. 흐름을 담도록 값을 정합니다.
|
||||
|
||||
```
|
||||
new → notified(접수 메일 발송) → building → review(사람 검토 중)
|
||||
→ delivered(완료 안내 발송) → closed
|
||||
```
|
||||
|
||||
### 5-3. 연락처가 이메일이 아닐 수 있다
|
||||
|
||||
모달은 "이메일 또는 전화번호"를 받습니다. 전화번호면 메일을 보낼 수 없습니다.
|
||||
|
||||
- 저장 시 형식을 판별해 `contact_kind`(`email` · `phone`)를 같이 남깁니다
|
||||
- `phone`이면 접수 메일을 건너뛰고 운영자 알림에 **전화로 연락 필요**를 표시합니다
|
||||
|
||||
---
|
||||
|
||||
## 6. 화면에서 고칠 것
|
||||
|
||||
| 위치 | 지금 | 바꿀 안 |
|
||||
|---|---|---|
|
||||
| 신청 모달 완료 문구 | "실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다" | "접수됐습니다. 확인 메일을 보내 드렸습니다. 결과가 준비되면 남겨주신 연락처로 안내드립니다" |
|
||||
| 운영자 화면 | 없음 | 완료 안내 발송 버튼. 사진 검토가 끝나지 않았으면 버튼을 잠그고 이유를 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 만들 순서
|
||||
|
||||
| 순서 | 할 일 | 비고 |
|
||||
|---|---|---|
|
||||
| 1 | 발송 서비스 계정과 도메인 인증 | **haewon 결정 필요.** 이것 없이는 아무것도 못 보냅니다 |
|
||||
| 2 | `email_log` 테이블, `contact_kind` 열 추가 | |
|
||||
| 3 | Edge Function `notify-lead` + 트리거 | 3-1·3-2 자동 발송 |
|
||||
| 4 | 모달 문구 수정 | 1~3과 무관하게 지금 할 수 있습니다 |
|
||||
| 5 | 완료 안내 발송 버튼 | 사진 검토 완료 조건과 묶습니다 |
|
||||
|
||||
**1번이 없으면 2~3번이 무의미하므로, 4번을 먼저 넣어 화면과 코드의 어긋남부터 없애는 방법도 있습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. haewon 결정이 필요한 것
|
||||
|
||||
1. **발송 서비스**: Resend를 추천합니다. 계정과 발신 도메인(`o2o.kr` 하위 무엇으로 할지)이 필요합니다
|
||||
2. **운영자 알림 수신 주소**: 개인 주소인지 공용 주소인지
|
||||
3. **완료 안내를 사람이 보내는 방식으로 할지**: 자동 발송으로 하면 사진 검토를 건너뛴 링크가 나갈 수 있습니다. 이 설계는 사람이 보내는 쪽을 전제합니다
|
||||
4. **"리포트"의 정의**: 완료 안내로 보내는 것이 서포터즈 사이트인지, AEO/GEO 진단 리포트인지, 둘 다인지. 지금 파이프라인은 앞의 것만 만듭니다
|
||||
@ -1,171 +0,0 @@
|
||||
# 핸드오버 · 2026-09-18 (금)
|
||||
|
||||
다음 작업은 2026-09-19(토) 또는 다음 주. 이 문서 하나만 읽으면 이어서 할 수 있게 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 오늘 한 일 한 줄
|
||||
|
||||
랜딩에서 들어온 JK성형외과 신청을 자동 빌드로 완주시키고, 그 과정에서 드러난 파이프라인 결함 6건을 고쳤다. 그리고 진단 리포트에서 사이트 빌드까지 이어지는 제품 화면을 새로 만들었다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 지금 상태
|
||||
|
||||
### 저장소 두 곳, 커밋 완료
|
||||
|
||||
**Productize-2** (`main`) · 프런트엔드
|
||||
```
|
||||
f38c0f3 feat(front): 진단 요약과 보완 계획을 보여 준 뒤 빌드하게 한다
|
||||
14c4b82 refactor(design): 요청문과 설명문을 텍스트 역할 토큰으로 가른다
|
||||
be1ea72 feat(front): 진단 리포트에서 사이트 빌드로 이어지는 화면
|
||||
74c70d6 docs(design): 신청 접수 알림과 완료 안내 메일 설계 v0.1
|
||||
5cf28d9 feat(front): 병원이 사이트에 실을 사진을 한 장씩 고르는 화면
|
||||
```
|
||||
|
||||
**Productize** (`feature/supporters-autobuild`) · 파이프라인
|
||||
```
|
||||
1eb166e fix(worker): 병원 입력을 큐 빌드에서도 반영하고, 이미지 확인 결과를 사이트에 적용한다
|
||||
fa48d43 fix(supporters): 의료진 목록 페이지를 사람별로 나누고 지번 주소도 좌표로 바꾼다
|
||||
```
|
||||
|
||||
**푸시하지 않았다.** 두 저장소 모두 로컬에만 있다.
|
||||
|
||||
`f38c0f3` 은 haewon 육안 확인 전에 커밋했다. 퇴근 시점에 작업 트리를 비워 두려고 먼저 넣은 것이라, 마음에 안 들면 `git reset --soft HEAD~1` 로 되돌리면 된다.
|
||||
|
||||
### 추적하지 않는 파일 2개
|
||||
|
||||
`scripts/out/naver_briefing_summary.json`, `scripts/out/sentiment_summary.json` 는 09-15 측정 산출물이고 오늘 세션 것이 아니라 그대로 뒀다. 주간보고가 이 파일을 근거로 인용하므로 보고서를 공유할 계획이면 같이 커밋해야 한다.
|
||||
|
||||
### 배포
|
||||
|
||||
| 대상 | 주소 | 상태 |
|
||||
|---|---|---|
|
||||
| JK성형외과 서포터즈 | https://supporters-jkplastic.vercel.app | 배포됨 · noindex · 의료진 9명·주변 안내 123곳 반영 |
|
||||
| 프런트엔드 | 배포 안 함 | 오늘 만든 화면은 로컬에만 있다 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 오늘 고친 파이프라인 결함 6건
|
||||
|
||||
| # | 결함 | 상태 |
|
||||
|---|---|---|
|
||||
| 1 | 의료진 목록 페이지를 사람별로 못 나눔. 9명이 "과전문의"라는 없는 사람 1명으로 합쳐짐 | 고침 |
|
||||
| 2 | 지번 주소를 좌표로 못 바꿔 주변 안내 전체가 빔 | 고침 |
|
||||
| 3 | 글이 예전 의료진 id를 들고 있으면 astro build 중단 | 고침 |
|
||||
| 4 | `--deploy` 없는 부분 재실행이 `preview_url` 을 지움 | 고침 |
|
||||
| 5 | 큐 워커가 `--from-supabase` 를 안 넘겨 **병원 입력이 하나도 반영되지 않던 것** | 고침 |
|
||||
| 6 | 이미지 수집 개수 중복 계산 (36장을 51장으로 보고) | 고침 |
|
||||
|
||||
**수정 효과 (JK 재실행 실측)**
|
||||
|
||||
| 항목 | 전 | 후 |
|
||||
|---|---|---|
|
||||
| 의료진 | 1명 (가짜 이름) | 9명 (실명·직함·약력 분리) |
|
||||
| 수집 뉴스 | 958건 | 1,165건 (기고·인터뷰 217 → 387) |
|
||||
| 주변 안내 | 0곳 | 국문 123곳 · 영문 108곳 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 오늘 만든 화면
|
||||
|
||||
```
|
||||
/discovery/:id 진단 리포트 → [이대로 빌드하기]
|
||||
/build/:id 빌드 진행 (5단계, supporter_builds 폴링)
|
||||
/images/:id 사진 확인 (한 장씩 사용/빼기)
|
||||
/supporters/:id 확인 항목 입력 (기존)
|
||||
```
|
||||
|
||||
**새 파일**
|
||||
```
|
||||
src/pages/SiteBuildPage.tsx 빌드 진행 화면
|
||||
src/pages/ImageReviewPage.tsx 사진 확인 화면
|
||||
src/components/discovery/ImageReviewPanel.tsx 사진 확인 본체
|
||||
src/components/discovery/BuildPlanSection.tsx 진단 요약 → 보완 계획 → 빌드
|
||||
src/components/ClinicNav.tsx 4단계 절차 상단 메뉴 + 이전 단계 플로팅 버튼
|
||||
src/lib/buildPhases.ts 워커 17단계 → 고객용 5묶음
|
||||
src/lib/buildCoverage.ts 빌드가 보완하는 카테고리 선정
|
||||
```
|
||||
|
||||
**설계상 지켜야 할 것 두 가지**
|
||||
|
||||
1. **빌드는 병원 홈페이지를 고치지 않는다.** 진단의 실행 항목은 단 하나도 빌드로 해결되지 않는다. 화면에서 "실행 항목 N건 해결"로 쓰면 지키지 못할 약속이 된다. 오늘 한 번 그렇게 만들었다가 고쳤다.
|
||||
2. **"결과"라는 말을 쓰지 않는다.** 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니다.
|
||||
|
||||
**텍스트 역할 토큰** (`src/index.css`)
|
||||
```
|
||||
.ui-ask 요청문 (17px·600·인디고) · .ui-ask-block 은 왼쪽 규칙선
|
||||
.ui-body 설명 본문 (16px·slate-600)
|
||||
.ui-note 보조·출처 (14px·slate-400)
|
||||
.on-dark / .on-light 다크 섹션과 그 안의 흰 카드
|
||||
.btn-on-dark 다크 섹션 위 주 버튼 (기본 CTA 그라디언트는 흰 배경 전용)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 바로 이어서 할 수 있는 것
|
||||
|
||||
### 4-1. haewon 육안 확인 (최우선)
|
||||
|
||||
`f38c0f3` 의 화면을 아직 안 보셨다. 확인할 것:
|
||||
|
||||
- `/discovery/viewclinic` 하단 **What We Will Fix** 섹션의 문구와 버튼 대비
|
||||
- 네 화면 오가며 **이전 단계 플로팅 버튼** (왼쪽 아래)
|
||||
- `/images/jkplastic` 썸네일 줄
|
||||
|
||||
```bash
|
||||
cd ~/orca/workspaces/INFINITH/Productize-2 && npm run dev # 포트 3000 사용 중이면 자동으로 다음 포트
|
||||
```
|
||||
|
||||
### 4-2. 분석 리포트의 남은 요소 흡수 (오늘 하던 것)
|
||||
|
||||
haewon이 고른 방향은 **"분석 리포트의 좋은 부분을 진단으로 흡수"** 였다. 채널 점수 링·로드맵 타임라인·KPI 세 가지 중 아직 하나도 안 옮겼다. 중간에 "OK 버튼" 이야기로 방향이 틀어져 `BuildPlanSection` 을 먼저 만들었다.
|
||||
|
||||
| 요소 | 기존 컴포넌트 | 데이터 |
|
||||
|---|---|---|
|
||||
| 채널 건강도 링 | `src/components/report/ChannelOverview.tsx` | `supporter_builds.report.channels` 에 실제 발견 채널이 있다 |
|
||||
| 로드맵 타임라인 | `src/components/report/RoadmapTimeline.tsx` | `result.actions` 의 P0/P1/P2 + effort 로 시간축을 만들 수 있다 |
|
||||
| KPI 대시보드 | `src/components/report/KPIDashboard.tsx` | **데이터 없음.** 목표치를 지어내면 안 된다. 재측정 기준으로 바꾸는 편이 맞다 |
|
||||
|
||||
로드맵이 가장 쉽고 값이 크다. 데이터가 이미 있고 진단의 Action Plan에 시간축만 없다.
|
||||
|
||||
### 4-3. 분석 리포트 자체의 결함 (옮기든 안 옮기든 남는 문제)
|
||||
|
||||
`/report/:id` 를 바노바기 리포트로 열어 확인한 것:
|
||||
- 히어로가 비어 있다. 첫 화면에 그라디언트만 크게 깔리고 제목·병원명이 없다
|
||||
- `데이터 없음 (NaN년)`. 개원 연도 계산이 깨져 NaN이 화면에 나온다
|
||||
- 섹션 사이 여백이 화면 하나 가까이 된다
|
||||
|
||||
---
|
||||
|
||||
## 5. 결정 대기 (haewon)
|
||||
|
||||
### 5-1. 메일 (설계 끝, 결정 필요)
|
||||
|
||||
`docs/DESIGN_신청알림메일_v0.1.md` §8
|
||||
|
||||
1. **발송 서비스**: Resend 추천. 계정과 발신 도메인(`o2o.kr` 하위 무엇으로)
|
||||
2. **운영자 알림 수신 주소**: 개인인지 공용인지
|
||||
3. **완료 안내를 사람이 보내는 방식으로 확정할지**
|
||||
4. **"리포트"의 정의**: 가장 큰 건. 랜딩은 AEO/GEO 진단 리포트를 약속하는데 파이프라인은 서포터즈 사이트를 만든다. 메일 문안을 쓰려면 이걸 먼저 정해야 한다
|
||||
|
||||
### 5-2. 진단 데이터가 3곳뿐
|
||||
|
||||
`DISCOVERY_RESULTS` 에 viewclinic·taeha·wonjin 만 있다. JK처럼 빌드만 한 병원은 `/discovery/:id` 에서 **No Audit Yet** 화면을 본다(오늘 만들었다). 진단은 36항목 실측이라 자동으로 안 따라온다. 자동화 범위를 어디까지 잡을지 별도 과제.
|
||||
|
||||
### 5-3. 기타
|
||||
|
||||
- `clinicDoctors` 4명 고정. JK는 9명인데 소개 페이지에 4명만 나온다
|
||||
- planner가 주변 안내 좌표를 받고도 범용 기본표를 쓴다
|
||||
- 뷰 의료진 id 두 벌 (`dr-cho` 수작업 10개 vs `dr-chojinwoo` 자동 27개). 뷰 재빌드 때 한쪽으로 모아야 한다
|
||||
- `check.mjs --update-layout` 으로 레이아웃 기준선 생성
|
||||
|
||||
---
|
||||
|
||||
## 6. 알아 두면 시간 아끼는 것
|
||||
|
||||
- **빌드는 백그라운드로 돌리고 다른 작업을 같이 한다.** `nohup node workers/supporters-build/run.mjs ... &` 로 띄우고 로그를 감시한다. 오늘 내내 그렇게 했다.
|
||||
- **같은 병원 빌드를 동시에 두 개 돌리면 안 된다.** 작업 폴더(`~/supporters-builds/<clinic>-<id>/`)를 공유해 서로 덮어쓴다.
|
||||
- **부분 재실행은 `--from <단계>`.** 글은 `--regenerate` 없으면 보존되어 생성 비용이 0이다.
|
||||
- **워커는 Productize 워크트리에서 돈다.** Productize-2 가 아니다. launchd 폴러 `com.infinith.supporters-poll`, 로그 `~/supporters-builds/poll.log`.
|
||||
- **주간보고는 gitignore 처리했다** (`docs/*주간보고*`). 개인 문서라 저장소에 올리지 않는다.
|
||||
@ -1,57 +0,0 @@
|
||||
# GA4 AI 검색 유입 연동 안내 v0.1
|
||||
|
||||
작성 2026-09-08 · 스크립트 `scripts/ga4_ai_traffic.py` · 분류 기준 `data/ai_channels.json` · 태그 `supporters/src/layouts/Base.astro`
|
||||
|
||||
## 1. 구조
|
||||
|
||||
| 대상 | 누가 계측하는가 | 필요한 것 |
|
||||
|---|---|---|
|
||||
| 서포터즈 사이트 | INFINITH가 직접. `site.json`의 `ga4MeasurementId`(G-XXXX)를 넣으면 빌드 시 태그가 들어간다 | INFINITH GA4 속성 1개(병원별 데이터 스트림) |
|
||||
| 병원 공식 사이트 | 병원이 이미 쓰는 GA4 속성에 INFINITH 서비스 계정을 뷰어로 추가 | 병원 GA4 관리자 권한자 1명, 5분 |
|
||||
|
||||
코드 설치는 없다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고 그건 병원 웹 담당자 일이다.
|
||||
|
||||
## 2. 연동 절차 (병원 사이트)
|
||||
|
||||
1. INFINITH: Google Cloud 프로젝트에서 Google Analytics Data API 사용 설정, 서비스 계정 생성, 키(JSON) 발급. `.env`에 `GA4_SERVICE_ACCOUNT_JSON=<경로>`. 계정은 haewon이 지정한다(이전 GCP 재인증 이슈 있음).
|
||||
2. 병원: GA4 관리 → 속성 액세스 관리 → 사용자 추가 → 서비스 계정 이메일, 역할 **뷰어**.
|
||||
3. 병원: 속성 ID(숫자 9자리) 전달.
|
||||
4. INFINITH: `python3 scripts/ga4_ai_traffic.py --property <ID> --days 90 --clinic <id> --out docs/reports/<id>/05_ai_traffic`.
|
||||
5. 월간 실행 → 리포트 카드와 병원 확인 패널에 붙인다(다음 단계).
|
||||
|
||||
API 없이 받을 때: 병원이 GA4 탐색에서 날짜·세션 소스·세션 매체·방문 페이지·세션수·주요 이벤트를 CSV로 내보내면 `--from-csv`로 같은 리포트를 만든다.
|
||||
|
||||
## 3. AI 채널 분류 규칙
|
||||
|
||||
`data/ai_channels.json` 한 곳에서 관리한다. 세션 출처 호스트(경계 일치)와 `utm_source`로 묶는다.
|
||||
|
||||
| 채널 | 측정 | 이유 |
|
||||
|---|---|---|
|
||||
| ChatGPT | 가능 | 링크에 utm_source=chatgpt.com |
|
||||
| Gemini, Claude | 가능 | 고유 리퍼러 |
|
||||
| Perplexity, Copilot, 기타 AI | 일부만 | 리퍼러를 자주 비움 → direct로 섞임 |
|
||||
| Google AI Overview · AI Mode | 불가 | google / organic과 구분되지 않음 |
|
||||
| 네이버 AI 브리핑 | 불가 | search.naver.com과 구분되지 않음 |
|
||||
|
||||
리포트마다 "측정 가능 범위" 절이 자동으로 들어간다. 불가 채널에는 숫자를 만들지 않는다.
|
||||
|
||||
## 4. 키 이벤트
|
||||
|
||||
서포터즈 사이트 태그가 보내는 이벤트. GA4에서 **주요 이벤트로 표시**해야 전환으로 집계된다.
|
||||
|
||||
| 이벤트 | 조건 |
|
||||
|---|---|
|
||||
| click_tel | `tel:` 링크 |
|
||||
| click_reservation | 헤더 상담 예약 버튼(`a.cta`) 또는 `data-ga="reservation"` |
|
||||
| click_clinic_site | `data-ga="clinic-site"` (병원 홈페이지 이동) |
|
||||
| click_map | `data-ga="map"` (지도·길찾기) |
|
||||
|
||||
## 5. 개인정보
|
||||
|
||||
GA4 태그는 쿠키를 쓴다. `anonymize_ip`는 켜 두었다. 쿠키 고지 배너를 넣을지, 쿠키 없는 대안(서버 로그, Vercel Web Analytics)으로 갈지는 haewon 결정 대기. 결정 전까지 서포터즈 미리보기(noindex)에는 측정 ID를 넣지 않는다.
|
||||
|
||||
## 6. 남은 일
|
||||
|
||||
- 리포트 카드(`build_discovery_report.ts`)에 AI 유입 요약 카드와 실측 언급률 동일 시간축 그래프.
|
||||
- 병원 확인 패널(`/supporters/:id`)에 "GA4 뷰어 추가" 안내와 속성 ID 입력란.
|
||||
- 월간 실행을 워커 또는 cron에.
|
||||
@ -1,104 +0,0 @@
|
||||
# 다음 세션 핸드오버 · 2026-09-09
|
||||
|
||||
**2026-09-09 진행분은 §2 각 항목의 상태 표시를 본다.** §2-1~4·6 완료, 남은 것은 §2-5(GA4 실연결)·§2-7 과 2차 보정이다.
|
||||
|
||||
작성 2026-09-08 · 작성자 Claude (haewon 세션 핸드오버). 자동 빌드 전체 상태는 `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(§1·§2·§3 갱신됨)를 본다. 이 문서는 09-08에 새로 시작한 일(감성 분석·GA4·가격)과 내일 첫 할 일만 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH 저장소. 자동 빌드 코드는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 감성 분석 산출물은 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH 에 있다.
|
||||
먼저 docs/NEXT_SESSION_2026-09-09.md 를 읽고 §2 순서대로 진행한다. 자동 빌드 잔여 과제는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md §2.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다. 사람 게이트는 자동화하지 않는다. 잴 수 없는 것은 숫자를 만들지 않고 '측정 불가'로 적는다. 존댓말, 엠대시 금지, 은유 금지. 결정이 필요한 것은 §3을 보고 haewon 에게 묻는다.
|
||||
```
|
||||
|
||||
## 1. 09-08에 끝낸 것
|
||||
|
||||
| 일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 뉴스룸 워커 단계 + 뉴스 게이트 규칙 | Productize, 커밋 484601b·4e73798, 푸시·infinith-demo 배포 완료 | 뷰 회귀 1,105건(수작업 1,096), 원진 1,049건 반영·재배포 |
|
||||
| launchd 폴러 설치 | `~/Library/LaunchAgents/com.infinith.supporters-poll.plist`, 5분 간격, 로그 `~/supporters-builds/poll.log` | 큐가 비어 있어 실제 신청 1건 완주는 아직 못 봄 |
|
||||
| FrostAI 비교 + 가격 제안 v0.1 | 메인 체크아웃 `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html` | haewon 검토 대기 |
|
||||
| AI 답변 감성 판정 | 메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `scripts/sentiment_qb.py`, 결과 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md` | 뷰 240건 판정 완료(언급 84, 감성 53.6, 부정 15.5%, 규제 언급률 6.0%). **보정 라벨링 진행 중** |
|
||||
| GA4 AI 유입 연동 뼈대 | Productize `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `supporters/src/layouts/Base.astro` 태그, `docs/INFINITH_GA4_AI_Traffic_Integration.md` | 합성 데이터로 검증. 실제 속성 연결은 GCP 계정 결정 후 |
|
||||
| 리포트 "측정 가능 범위" 절 | Productize `scripts/build_discovery_report.ts` Integrity 절, 원진 리포트 v2.1 HTML·PDF | 감성·GA4 리포트에도 같은 절 자동 포함 |
|
||||
|
||||
**미커밋 상태** (haewon 지시 없이 커밋하지 않았다)
|
||||
- Productize: `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `scripts/build_discovery_report.ts`, `supporters/src/layouts/Base.astro`, 템플릿 site.json 2곳(`ga4MeasurementId`), `docs/INFINITH_GA4_AI_Traffic_Integration.md`, `docs/reports/wonjin/01_readiness_report/*v2.1*`, 이 문서.
|
||||
- 메인 체크아웃: `scripts/sentiment_qb.py`, `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment*.{md,xlsx}`, `scripts/out/qb_sentiment_*.jsonl`, `docs/INFINITH_vs_FrostAI_*.html`. 메인 체크아웃에는 09-08 이전부터 있던 무관한 수정(plan 컴포넌트 등)도 섞여 있으니 감성 파일만 골라 커밋한다.
|
||||
|
||||
## 2. 내일 순서
|
||||
|
||||
> 진행 상태 (2026-09-09 갱신): 1 완료 · 2 완료 · 3 완료 · 4 완료 · 5 미착수(§3-2 대기) · 6 완료(푸시 전) · 7 미착수
|
||||
|
||||
|
||||
1. **보정 라벨링 마무리** (haewon). 파일 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`, 시트 `라벨링` 30줄. D열 답변 전문을 읽고 F열(positive/neutral/negative/mixed/not_mentioned), 부정·혼합이면 G열 종류, 애매하면 H열 메모. 판정기 값은 숨김 시트에 있으니 보지 않는다.
|
||||
- 알려진 사례: **G-08(openai)** 은 영문 약칭 "View" 하나만 있음 → not_mentioned 로 볼지 haewon 판단. **G-04(openai)** 는 판정기 오판(뷰 언급 없음). **C-01(openai)** 은 "View Plastic Surgery" 영문 정식 명칭.
|
||||
2. **일치율 계산**: `python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`. sentiment 일치 80% 이상이면 기준표 v0.1 확정. 미만이면 불일치 줄을 기준표 §2 규칙 예시로 추가하고 240건 재판정(`--out` 파일을 지우고 다시 실행, 약 $1).
|
||||
3. **기준표 확정 사항 반영**: 약한 언급(한글 상호 없이 영문 약칭만, 3건) 포함 여부, mixed 25점 유지 여부를 기준표 §6에 적고 리포트 재생성. 감성 결과를 `docs/reports/viewclinic/03_question_bank/` 엑셀·POC 부록에 넣을지 결정.
|
||||
4. **감성 판정을 월간 실측에 붙이기**: 질문 뱅크 러너 결과가 나오면 자동으로 `sentiment_qb.py` 를 이어 돌리는 한 줄(워커 별도 단계 또는 cron). 결과를 `supporter_builds.report.sentiment` 에 저장해 리포트 카드에 표시.
|
||||
5. **GA4 실연결**: §3-2 계정이 정해지면 서비스 계정 키 발급 → `.env` `GA4_SERVICE_ACCOUNT_JSON` → 서포터즈 사이트용 GA4 속성 생성 → 원진 `site.json` 에 `ga4MeasurementId` → 재빌드. 쿠키 배너 결정(§3-3) 전에는 미리보기에 ID를 넣지 않는다.
|
||||
6. **커밋**: 위 미커밋 목록을 두 체크아웃에서 각각 커밋(감성 파일은 메인 체크아웃에서 골라서). 푸시 후 `vercel --prod` 는 프론트 변경이 있을 때만.
|
||||
7. 시간이 남으면 v3 §2의 감사 재실행 단계(Phase 9), 원진 수동 실측(강남언니·바비톡·GBP·플레이스 NAP).
|
||||
8. (2026-09-09 추가) 질문 뱅크 러너 병원별 매개변수화. `run_question_bank_openai.py` 가 `question_bank_viewclinic.json` 과 출력 경로를 하드코딩해 뷰성형외과 전용이다. 월간 체인을 원진 등 다른 병원에 쓰려면 여기가 먼저다.
|
||||
|
||||
### 2026-09-09 결과 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 보정 라벨링 | haewon 29/30 완료. G-08(openai)만 공란 |
|
||||
| 1차 일치율 | sentiment 22/29 = **75%**, 기준선 80% 미달. negative_type 은 G열 미기입으로 **측정 불가** |
|
||||
| 기준표 | v0.1 → **v0.3**(메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md`). 규칙 R1~R5 추가 |
|
||||
| 철회한 규칙 | R4-a·R6 시험 투입 후 철회. 일치율이 93%에서 89%로 떨어졌다. n=29 에서 과적합 |
|
||||
| 언급 탐지기 | 결정론적 `mention_kind()` 신설. URL 안의 "view", "리뷰"의 "뷰" 오탐 제거, 한글 상호 공백 허용. 언급 84 → 83, 약한 언급 3 → 1 |
|
||||
| 감성 점수 | 53.6 → **58.4** (약한 언급 1건 제외 시 57.9). mixed 10 → 0 |
|
||||
| 월간 체인 | `scripts/monthly_ai_measure.py` 신설(§2-4). 러너 → 감성 → 리포트 → `supporter_builds.report.sentiment` |
|
||||
| API 비용 | 총 $1.70 (v0.2 재판정 $0.79 + R4-a·R6 시험 $0.91). 최종본은 재호출 없이 후처리로 만들었다 |
|
||||
| 커밋 | 메인 체크아웃 `feature/ai-sentiment-rubric` 2eb3d5c · Productize 8776a15(GA4)·501d600(PRD v0.2) |
|
||||
|
||||
**아직 확정이 아니다.** 93%는 같은 표본으로 규칙을 고쳐 나온 값이다. 확정하려면 새 30건으로 2차 보정을 해야 하고, 1차 표본 30건은 제외한다. 리포트와 요약 JSON 에 `confirmed: false` 로 적어 두었다.
|
||||
|
||||
**메인 체크아웃이 브랜치에 있다.** `feature/ai-sentiment-rubric` 이며 main 이 아니다. main 으로 합치려면 그 체크아웃에서 `git checkout main && git merge feature/ai-sentiment-rubric` 을 한다. 두 체크아웃 모두 **푸시하지 않았다.**
|
||||
|
||||
## 3. haewon 결정 대기
|
||||
|
||||
1. 감성 기준표(2026-09-09 갱신): mixed 는 R2 적용 후 240건 중 0건이라 판정값에서 뺄지 정한다. 약한 언급은 오탐을 걷어내니 **1건**(G-08 openai)뿐이며 현재는 세고 표시해 두었다. 부정의 범위(규제 사실 서술을 negative 로)와 경쟁 노출 지표는 그대로 미결. 남은 불일치 2건(C-12 perplexity, A-03 openai)은 2차 보정에서 새 표본으로 본다.
|
||||
- 2차 보정을 언제 돌릴지도 결정 대상이다. 새 30건 추출 → haewon 라벨 → `--agreement`. 1차 표본 30건은 제외한다.
|
||||
2. GA4 서비스 계정을 만들 Google Cloud 계정(이전 재인증 이슈 계정 회피 여부).
|
||||
3. 서포터즈 사이트 쿠키 고지 배너 vs 쿠키 없는 대안(서버 로그·Vercel Web Analytics).
|
||||
4. 가격 제안 v0.1(문서 §5의 5개 항목: 무료 진단 범위, 최소 계약 3개월, 도메인 귀속, Frost 우위 3종 대응, 가격 공개 여부).
|
||||
5. 뉴스룸 "언론 언급" 종류 노출 여부(v3 §3-6).
|
||||
6. 자동 빌드 잔여 결정은 v3 §3.
|
||||
|
||||
## 4. 함정 (09-08 추가)
|
||||
|
||||
- 프레임워크 Python 3.13은 인증서 저장소가 비어 `CERTIFICATE_VERIFY_FAILED` 가 난다. 새 스크립트는 certifi 컨텍스트를 쓴다(`sentiment_qb.py` 참고).
|
||||
- 판정기의 `mentioned` 는 영문 약칭에 관대하다. 한글 상호 정규식으로 `weak_mention` 을 따로 표시하고 보정 때 결정한다.
|
||||
- `sentiment_qb.py --summary` 로 보정 엑셀을 다시 만들면 **haewon 라벨이 지워진다**. 라벨링 시작 후에는 `--calibration` 을 빼고 리포트만 다시 만든다. (2026-09-09: `--calibration` 없이 돌리면 §7 생성에서 죽던 버그를 고쳤다. 이제 그냥 빼고 돌리면 된다)
|
||||
- (2026-09-09) 감성 판정을 다시 돌릴 때는 `--out` 파일을 **옮기거나 지워야** 한다. 이어쓰기가 있어 그냥 실행하면 0건 처리하고 끝난다. `monthly_ai_measure.py --rejudge` 가 이 처리를 대신한다.
|
||||
- (2026-09-09) 병원 언급 탐지는 판정기(LLM)에 맡기지 않는다. URL 안의 상호와 "리뷰"의 "뷰" 를 언급으로 세면 모든 비율의 분모가 틀어진다. `mention_kind()` 를 먼저 통과시킨다.
|
||||
- 백그라운드 파이썬 로그는 버퍼링 때문에 끝날 때까지 비어 보인다. 진행은 JSONL 줄 수로 본다.
|
||||
- JS 정규식 `\W`·`\w`·`\b` 는 한글을 문자로 보지 않는다(v3 §5). 워드프레스 게시판 메타 날짜는 게시판 생성일이다.
|
||||
|
||||
## 5. 자주 쓰는 명령
|
||||
|
||||
```
|
||||
# 월간 실측 한 번에 (메인 체크아웃, 2026-09-09 신설)
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 # 러너부터 전부
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변으로 감성만
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --rejudge # 기준표 고친 뒤 재판정
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
|
||||
# 감성 판정 개별 (메인 체크아웃)
|
||||
python3 scripts/sentiment_qb.py --in scripts/out/qb_openai_results.jsonl --out scripts/out/qb_sentiment_openai.jsonl
|
||||
python3 scripts/sentiment_qb.py --summary scripts/out/qb_sentiment_openai.jsonl scripts/out/qb_sentiment_perplexity.jsonl --report docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md # 라벨링 중에는 --calibration 금지
|
||||
python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx
|
||||
# GA4 (Productize)
|
||||
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo
|
||||
python3 scripts/ga4_ai_traffic.py --property <ID> --days 90 --clinic <id> --out docs/reports/<id>/05_ai_traffic
|
||||
# 진단 리포트 (Productize)
|
||||
npx esbuild scripts/build_discovery_report.ts --bundle --platform=node --format=esm --outfile=/tmp/report.mjs && node /tmp/report.mjs --id <id> --out <html> --status ~/supporters-builds/<id>/status.json --site ~/supporters-builds/<id>/site --briefs ~/supporters-builds/<id>/briefs.json
|
||||
# 폴러 상태
|
||||
launchctl print gui/$(id -u)/com.infinith.supporters-poll | head -12 ; tail -20 ~/supporters-builds/poll.log
|
||||
```
|
||||
@ -1,61 +0,0 @@
|
||||
# 다음 세션 핸드오버 · 2026-09-11 · 오라클피부과 서포터즈 (피부과 첫 자동 빌드)
|
||||
|
||||
작성 2026-09-10 · 작성자 Claude (haewon 세션) · 이전 핸드오버 `docs/NEXT_SESSION_2026-09-09.md`, 자동 빌드 전체 상태 `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`. 이 문서는 2026-09-10 피부과 작업(세션 1: PRD·질문뱅크·Top3 진단, 세션 2: 오라클 자동 빌드)만 다룬다.
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH 저장소. 자동 빌드 코드는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 피부과 PRD·질문뱅크·진단·Before/After 는 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH (브랜치 feature/ai-sentiment-rubric) 에 있다. 둘 다 미커밋.
|
||||
먼저 docs/NEXT_SESSION_2026-09-11_ORACLE.md 를 읽고 §3 결정 대기의 답을 받은 뒤 §2 순서대로 진행한다.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다("확인 대기"). 사람 게이트(의학 검토·게시 승인·이미지 육안·채널 확인)는 자동화하지 않는다. 고객 화면에 운영자 설명 없음. 확정 디자인은 텍스트만. 존댓말, 엠대시 금지, 은유 금지, 흰 배경 위 연보라 텍스트 금지(인디고 #1D0024). 세 병원을 한 화면에서 비교하는 산출물은 만들지 않는다.
|
||||
```
|
||||
|
||||
## 1. 2026-09-10 에 끝낸 것
|
||||
|
||||
| 일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 피부과 Top3 리서치(오라클·톡스앤필·차앤박) | 메인 `docs/reports/dermatology/00_research/` | 완료. 미확인 항목은 (e)절 |
|
||||
| Top3 AEO/GEO 자동 채점 + 사람 보정 4항목 | 메인 `docs/reports/dermatology/01_readiness_report/*_{audit_aeo_geo,overrides,score}_2026-09-10.json` | 오라클 36/D(AEO 24·GEO 6) · 톡스앤필 68/B(54·67) · 차앤박 48/C(24·55). 표본 URL 2개 |
|
||||
| 점수 계산기 `scripts/score_discovery_json.py`, 질문뱅크 생성기 `scripts/build_qb_dermatology.py` | 메인 | 원진 v1 49/C 로 검증. v2 는 v2Results 사람 판정이 없어 페이지 값과 다를 수 있음 |
|
||||
| 질문뱅크 120문항 v0.1 (병원별 3칸 사전판정) | 메인 `docs/reports/dermatology/03_question_bank/` | 2엔진 실측 전. 러너 인자화 필요 |
|
||||
| PRD v0.1 | 메인 `docs/prd/INFINITH_Dermatology_Supporters_PRD_v0.1.html` | §13 결정 질문 10건. 인디고 규칙 반영 |
|
||||
| Before/After 페이지 빌더 `scripts/build_before_after.py` | 메인. 원진 픽스처 `docs/reports/wonjin/02_before_after/`, 오라클 `docs/reports/dermatology/02_before_after/Oracle_Before_After_2026-09-10.html` | 오라클 36/D → 75/B, AEO 24 → 77, GEO 6 → 81 |
|
||||
| 오라클 서포터즈 자동 빌드 | Productize 워커, 작업 폴더 `~/supporters-builds/oracle`, 미리보기 **https://supporters-oracle.vercel.app** (noindex) | 글 8편(소개·지점 안내·상담 질문·시술 5영역), $0.61, 게이트 오류 0. 의학 검토·승인 전 초안 |
|
||||
|
||||
### 파이프라인에 새로 들어간 것 (Productize, 미커밋)
|
||||
- `supporters/scripts/collect_evidence.mjs`: 프레임셋·meta refresh·JS 리다이렉트 추적, charset 감지(EUC-KR), 쿼리 보존(`index.php?doc=N`), 쿼리 변형 중복 판정, 피부과 시술 어휘·`branches` 유형, `--extra-url`, facts.draft.json 에 `branches`·`branchPages`.
|
||||
- `supporters/scripts/ocr_evidence.mjs` + `ocr/vision_ocr.swift`: macOS Vision OCR(무료). 오라클 시술 83페이지가 이미지 글자였다. 워커 `ocr` 단계(evidence 다음), `--no-ocr`.
|
||||
- `supporters/scripts/default_briefs.mjs`: `--industry derm|plastic` 자동 감지, 피부과 8영역, 조건 표 8행 브리프, 지점 안내 후보.
|
||||
- `supporters/scripts/gate/rules.mjs`: PRICE_MENTION(error)·PRACTITIONER_MISSING(warn)·DEVICE_CLAIM(error)·피부과 금칙어. "100%"는 의료 맥락일 때만, 운영자 어휘 "복제"는 낱말 시작일 때만(피로회복제 오탐), 뉴스 제목 "부작용 없이" 제외. 테스트 41/41.
|
||||
- `supporters/scripts/gen/prompt.mjs`(피부과 규칙 블록) · `gen/evidence.mjs`(normUrl 쿼리 보존, OCR 섹션 표기).
|
||||
- `scripts/fetch_medical_tourism.py` 병원별 인자화 + 워커 `tourism` 단계(images 다음). `/stay`·`/en/stay` 하드코딩 제거.
|
||||
- 워커 `inputs` 단계: `supporters/briefs/<clinic>/clinic.json` 의 `factSheet`·`site`(srcMatch)·`excludeImages` 를 자동값 위에 덮어씀(이미지 파일·갤러리·images.json 매니페스트에서 제외).
|
||||
- `supporters/briefs/oracle/clinic.json`: 유튜브 @oracle_medical_group 고정, 로고 PNG, kind 피부과, 갤러리는 검단점 로비 1장, `sub_0*` 이미지 제외.
|
||||
|
||||
## 2. 다음 순서
|
||||
|
||||
1. **§3 결정 회수** (haewon).
|
||||
2. **오라클 사람 확인**: (a) 의료진 67명 OCR 후보 확정(같은 이름 중복: 김승면 광주점·광주수완점), (b) 본사 시설 사진 요청(지금은 검단점 로비 1장), (c) 갤러리 육안 확인, (d) 푸터 표기 변경 확인(오라클랜드 주식회사 314-81-85931 대표 노영우 → 청담 오라클피부과의원 307-04-97234 대표 이영숙, 작업 중 사이트가 바뀜).
|
||||
3. **뷰·원진 회귀**: 이번 변경(수집기·게이트·생성기·워커)이 두 병원에서 깨지지 않는지 `npm run gate:test`(41/41 확인됨) + 뷰 정답지 회귀 `npm run generate:regress`(비용 약 $2, 미실행) + 원진 `run.mjs --from data --skip-generate`.
|
||||
4. **구조 기준선**: 오라클 첫 빌드라 `LAYOUT_NO_BASELINE` 경고. haewon 이 룩앤필을 승인하면 `node scripts/check.mjs --update-layout`.
|
||||
5. **질문뱅크 러너 인자화** → 오라클 120문항 2엔진 실측(약 $2~4) → Before/After 페이지의 "환자 질문에 답할 수 있나" 카드에 실측값. 감성(기준표 v0.3) 판정 포함.
|
||||
6. **커밋**: Productize(워커·스크립트·핸드오버)와 메인(PRD·뱅크·진단·Before/After·스크립트) 각각. 프론트 변경 없음이라 vercel --prod 불필요.
|
||||
7. 톡스앤필·차앤박도 같은 워커로 빌드하면 세일즈 자료 3세트(각각 개별 Before/After). 한 화면 비교는 만들지 않는다.
|
||||
|
||||
## 3. haewon 결정 대기
|
||||
|
||||
1. **세일즈 사용 범위**: 오라클은 고객이 아니다. Before/After 페이지와 미리보기를 영업에 쓰려면 병원 자산(로고·사진·OCR 원문 재구성) 사용 범위를 정해야 한다. 미리보기는 noindex 이며 검색·AI 에 노출되지 않는다.
|
||||
2. **지원 관계 고지 문구**(site.sponsorNotice): 비어 있어 글마다 DISCLOSURE_TOP_MISSING 경고. 고객이 아니므로 "지원 없음" 문안을 쓸지, 비워 둘지.
|
||||
3. **주변 안내 사진**: TourAPI 사진이 없는 곳은 네이버 이미지 검색 폴백(제3자 저작물)을 쓴다. 공개 배포 시 `--no-naver-images` 로 끌지.
|
||||
4. **의료진 OCR 후보 67명 노출**: 지금은 "OCR · 확인 대기"로 authors.json 에 있고 홈에는 안 나온다. 지점 안내 글의 담당 원장 표기에 쓸지.
|
||||
5. 기존 미결: 도메인, 서포터즈 실명, 워커 상시 실행(Trigger.dev), PRD §13 의 파일럿 병원·가격 표기·상표명·실측 예산·리포트 영업 사용.
|
||||
|
||||
## 4. 함정 (이번 세션)
|
||||
|
||||
- 사이트 스크립트 사본: 워커 `data` 단계가 템플릿을 작업 폴더에 복사하므로, 규칙을 고친 뒤에는 `templates/supporters-astro/scripts/` 동기화 + 작업 폴더 `site/scripts/` 재복사(또는 `--from data`)가 필요하다. 규칙만 고치고 `--from build` 하면 옛 규칙으로 검사한다.
|
||||
- `gen/evidence.mjs normUrl` 이 쿼리를 지우면 `doc=N` 페이지가 전부 한 페이지로 겹친다. 첫 생성 8편이 전부 휴지기탈모를 근거로 썼다. 고쳤고 재생성했다.
|
||||
- OCR 주의문이 강하면("수치·고유명사를 옮기지 않는다") 모델이 OCR 근거를 전부 버린다. 지금 문안은 "깨진 글자만 제외".
|
||||
- 색인 허용 빌드(PUBLIC_INDEXABLE=true)는 approvedAt 있는 글만 낸다. 승인 전 병원은 posts 가 0이므로 After 채점은 미리보기 빌드 + robots 보정으로 한다(overrides 에 근거 기록).
|
||||
- 이미지 자동 분류 "시설"에 시술 설명 이미지(모델 얼굴·전후 사진)가 섞였다. 사람 육안 확인은 계속 필요하다. 제외는 clinic.json excludeImages 로.
|
||||
- 오라클 공식 사이트는 Playwright networkidle 로 60초 타임아웃. `wait_until='load'` 로 캡처.
|
||||
- 게이트 "100%"·"복제"·"부작용 없이" 오탐 3종은 전부 제3자 텍스트(관광공사 식당 소개·뉴스 제목)에서 나왔다. 제3자 텍스트 영역은 규칙을 의료 맥락으로 좁혀야 한다.
|
||||
@ -1,42 +1,27 @@
|
||||
# 세션 핸드오버 · 2026-09-12(토) · INFINITH AI Discovery Product 빌드
|
||||
# 다음 세션 핸드오버 · 2026-09-14(월) · INFINITH AI Discovery Product 빌드
|
||||
|
||||
작성 2026-09-11 · 갱신 2026-09-12(토) · 작성자 Claude (haewon 세션) · 상태: §1 확인 완료, §4-1 완료
|
||||
작성 2026-09-11 (금요일 종료 시점) · 작성자 Claude (haewon 세션) · 상태: 초안(haewon 검토 전) · 다음 세션은 2026-09-14 월요일부터 Product 개발을 이어간다.
|
||||
하위 문서: 서포터즈 사이트 빌드 절차 `~/orca/workspaces/INFINITH/Productize/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md`, 자동 빌드 상세 `…/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`, 자동 채점 `docs/AI_DISCOVERY_BUILD2_HANDOVER.md`, 회고 `docs/INFINITH_AI_Discovery_Retrospective_2026-09-08.md`.
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI 답변 준비도 진단(AEO/GEO v2) → 서포터즈 사이트 자동 빌드(글·영상·뉴스룸·주변 안내·회복 일정 플래너·오시는 길) → 병원 확인·재빌드 → 측정(질문뱅크·감성·GA4·크롤러 로그)" 파이프라인이다.
|
||||
코드 위치: 2026-09-12 에 브랜치 3개를 main 으로 합쳤다. 작업은 워크트리 ~/orca/workspaces/INFINITH/Productize-2 (브랜치 main) 에서 한다. 프로덕션은 main → infinith-demo.vercel.app.
|
||||
메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH 는 브랜치 feature/ai-sentiment-rubric 이고 다른 작업의 미커밋 변경(src/ 등 39줄, docs 폴더 재정리)이 있으니 건드리지 않는다. Supabase CLI 링크는 그쪽에만 있어 DB 조회는 거기서 supabase db query --linked 로 한다.
|
||||
먼저 docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md 를 읽는다(§5-4 도메인·레지스트리, §8 인사이트 포함). 시작 절차: (1) git status 와 .vercel 연결을 확인하고 다른 세션이 열려 있지 않은지 haewon 에게 묻는다 (2) §1 표의 "확인" 칸을 git log·Supabase 로 실제 확인한다 (3) §5 결정 대기의 답을 받는다 (4) §4 순서대로 진행한다. 한 세션은 한 저장소·한 배포 대상만 다룬다.
|
||||
주소 상태: webforai.kr 등록·위임·연결 모두 끝났다(2026-09-14). webforai.kr 은 AI Discovery 랜딩(신설 프로젝트 infinith-discovery), viewpl·wonjin·oracle.webforai.kr 은 각 서포터즈 사이트다. 세 곳 모두 noindex 유지. 색인 허용(--indexable)은 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)이 끝나기 전에는 붙이지 않는다. hosting.kr 의 자동연장·도메인 잠금은 아직 꺼져 있어 켜야 한다.
|
||||
INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI 답변 준비도 진단(AEO/GEO v2) → 서포터즈 사이트 자동 빌드(글·영상·뉴스룸·회복 일정 플래너·오시는 길) → 병원 확인·재빌드 → 측정(질문뱅크·감성·GA4·크롤러 로그)" 파이프라인이다.
|
||||
코드 위치: 프런트·스크립트·문서는 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH (브랜치 feature/ai-sentiment-rubric, 프로덕션 main → infinith-demo.vercel.app),
|
||||
워커·템플릿·큐는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 랜딩 리드 CTA 는 브랜치 feature/discovery-url-cta(미머지).
|
||||
먼저 docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md 를 읽는다(§8 인사이트 포함). 시작 절차: (1) 두 저장소 git status 와 .vercel 연결을 확인하고 다른 세션이 열려 있지 않은지 haewon 에게 묻는다 (2) §1 표의 "확인" 칸을 git log·Supabase 로 실제 확인한다 (3) §5 결정 대기의 답을 받는다 (4) §4 순서대로 진행하되 1번 브랜치 통합을 먼저 끝낸다. 한 세션은 한 저장소·한 배포 대상만 다룬다.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다("확인 대기"). 사람 게이트(의학 검토·게시 승인·이미지 육안·채널 확인·구조 기준선)는 자동화하지 않는다. 언급률·점수는 "스키마를 넣으면 인용된다"고 약속하지 않는다(정직성 규약).
|
||||
고객 화면에 운영자 어휘 금지. 존댓말, 엠대시 금지(커밋 메시지 포함), 은유 금지, 흰 배경 위 연보라 텍스트 금지(인디고 #1D0024). INFINITH 리포트 PDF 는 PPTX 덱(infinith-deck 스킬)으로 만든 뒤 변환한다.
|
||||
클라이언트 격리: INFINITH 문서·코드에 다른 서비스 이름을 쓰지 않는다. 산출 전 grep.
|
||||
머지 직전에 브랜치 끝을 다시 읽는다. 세션 시작 때 읽은 커밋 값은 머지 시점에 낡아 있을 수 있다. 머지 뒤 git rev-list --count main..<branch> 가 0 인지 확인하고, 통합 뒤 페이지 목록을 이전 배포와 대조한다.
|
||||
운영 DB 에 db push 를 쓰지 않는다. 마이그레이션 이력이 실제와 어긋나 있다(docs/DB_SCHEMA_DIVERGENCE_2026-09-14.md). 새 마이그레이션은 supabase db query --linked --file 로 그 파일만 적용한다.
|
||||
```
|
||||
|
||||
## 1. 제품 여정과 현재 상태
|
||||
|
||||
> **2026-09-12 확인 결과.** 아래 표에서 "확인"으로 남겨 둔 두 항목은 이미 해결된 상태였다. 다음 세션은 이 전제로 시작한다.
|
||||
>
|
||||
> | 항목 | 이전 기재 | 실제 | 근거 |
|
||||
> |---|---|---|---|
|
||||
> | 랜딩 리드 CTA | 미머지 | **main 에 있음.** `feature/discovery-url-cta` 는 main 대비 ahead 0 / behind 22 인 낡은 포인터라 머지 대상이 아니다 | `DiscoveryLeadModal.tsx`·`DiscoveryLandingPage.tsx`·`20260831_discovery_leads.sql` 이 main 에 있고 blob 해시가 세 브랜치 동일 |
|
||||
> | AEO/GEO v2 기준표 | main 머지 여부 확인 필요 | **main 에 있음** | `aeoGeoRubricV2.ts`·`discoveryScoreV2.ts` blob 해시 세 브랜치 동일 |
|
||||
> | Supabase 마이그레이션 | 적용 상태 확인 필요 | **전부 적용됨.** `discovery_leads` 0행, `supporter_builds` 2행, `supporter_inputs` 0행 | REST 조회 HTTP 200 |
|
||||
> | `discovery_results` | (§4-3 대상) | **없음(HTTP 404).** §4-3 미착수 확정 | REST 조회 |
|
||||
> | launchd 폴러 | 설치됨 | **로드됨, 마지막 종료 코드 0** | `launchctl list` |
|
||||
> | 배포 | 3곳 | **4곳 모두 200.** 단 `supporters-viewclinic/plan` 은 404(2026-09-07 빌드라 플래너 이전 판) | HTTP 확인 |
|
||||
|
||||
|
||||
| 단계 | 고객이 보는 것 | 구현 | 위치 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| 1 진입 | `/discovery` 랜딩, "URL 입력으로 시작" | `DiscoveryLandingPage.tsx`, `DiscoveryLeadModal` → Supabase `discovery_leads` | 메인, 브랜치 feature/discovery-url-cta | 프리뷰만. **미머지, 마이그레이션 `20260831_discovery_leads.sql` 적용 여부 확인** |
|
||||
| 2 진단 | `/discovery/:id` 리포트 (AEO 100 + GEO 100, v2 기준표), 덱·PDF | `scripts/audit_aeo_geo.py`(auto 22항목) → `src/data/discovery_<id>.ts` → `DiscoveryReportPage.tsx`; `scripts/build_discovery_report.ts` → HTML/PDF; 덱 빌더 `docs/reports/<clinic>/01_readiness_report/build_*_deck*.py` | 메인 + Productize(v2 기준표 `src/data/aeoGeoRubricV2.ts`, `src/lib/discoveryScoreV2.ts`) | 반자동. 결과가 소스 파일 커밋 방식이라 병원마다 코드 배포가 필요하다. **v2 기준표가 main(프로덕션)에 머지됐는지 확인** |
|
||||
| 3 서포터즈 사이트 | `supporters-<clinic>.vercel.app` 미리보기(noindex): 글·영상·뉴스룸·주변 안내 `/stay`·`/en/stay`·방문 안내(지도·길찾기)·회복 일정 플래너 `/plan`·`/en/plan` | 워커 `workers/supporters-build/run.mjs` 15단계(evidence→…→tourism→recovery→planner→…→deploy), 템플릿 `templates/supporters-astro`, 게이트 `scripts/gate/rules.mjs` | Productize | 동작. 뷰(수작업 샘플 `view-supporters-sample`)·원진·오라클 3곳 배포. 병원당 약 $0.6, 30분 |
|
||||
| 3 서포터즈 사이트 | `supporters-<clinic>.vercel.app` 미리보기(noindex): 글·영상·뉴스룸·방문 안내(지도·길찾기)·회복 일정 플래너 `/plan`·`/en/plan` | 워커 `workers/supporters-build/run.mjs` 15단계(evidence→…→tourism→recovery→planner→…→deploy), 템플릿 `templates/supporters-astro`, 게이트 `scripts/gate/rules.mjs` | Productize | 동작. 뷰(수작업 샘플 `view-supporters-sample`)·원진·오라클 3곳 배포. 병원당 약 $0.6, 30분 |
|
||||
| 4 병원 확인 | `/supporters/:id` 또는 리포트 안 `ClinicInputsPanel`: 채널 확인, 의료진, 검토·승인, 자유 답변 | Supabase `supporter_builds`·`supporter_inputs`, `apply_inputs.mjs`, 재빌드 `run.mjs --from data --from-supabase --build-id` | 메인(UI) + Productize(반영) | 동작하되 **재빌드 자동 트리거 없음**(supporter_inputs insert → 큐 연결 필요) |
|
||||
| 5 운영 | 큐 → 빌드 → 미리보기 링크 | `poll.mjs` + Mac launchd 5분 폴러, `supporter_builds` 큐 | Productize | 파일럿 수준. Mac 이 켜져 있어야 돈다. 실제 랜딩 신청 1건이 큐 → 완주까지 간 적 없음 |
|
||||
| 6 측정 | 질문뱅크 언급률(2엔진), 감성, GA4 AI 유입, AI 크롤러 히트, 공식 vs 서포터즈 재채점 | `scripts/run_question_bank_openai.py`·`merge_qb_results.py`·`sentiment_qb.py`·`ga4_ai_traffic.py`, `data/ai_channels.json`, 리포트 Integrity 절 "측정 가능 범위" | 메인(QB·감성) + Productize(GA4) | 스크립트 단위. 리포트 카드 자동 갱신 없음. GA4 는 GCP 계정 대기 |
|
||||
@ -55,16 +40,13 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI
|
||||
| Supabase | `supabase/migrations/` (`supporter_builds`, `supporter_builds_queue`, `discovery_leads`) + Edge Functions(기존 리포트 파이프라인) | 메인 | 적용 상태 확인 필요(CLI 토큰 만료 이력) |
|
||||
| 키 | Productize `.env`(TOUR_API·NAVER·OPENAI·YOUTUBE), 메인 `.env`(진단·QB) | | 회사 계정. 세션 시작 때 `grep -E "^KEY=." .env` 로 값 존재만 확인 |
|
||||
|
||||
**2026-09-12 갱신**: feature/supporters-autobuild 와 feature/ai-sentiment-rubric 을 `feature/product-1.0` 으로 합쳤다. feature/discovery-url-cta 는 main 에 이미 담겨 있어 제외했다.
|
||||
핸드오버가 적지 않은 **네 번째 브랜치 `Productize`** 가 있다. 고유 커밋 1개(랜딩 SSR 프리렌더·메타/JSON-LD·robots/sitemap/llms.txt)를 갖고 main 대비 74 뒤이며 origin 에 올라가 있다. 랜딩의 AI 크롤러 가독성이라 AEO 와 직접 닿는데, 합칠지 버릴지 아직 안 정했다.
|
||||
|
||||
(아래는 머지 전 상태 기록) 세 브랜치는 서로 머지되지 않았다. `supporters/` 폴더가 양쪽에 있고 Base.astro 구현이 다르다(메인: i18n.ts 전역 전환·영어 홈; Productize: langswitch·영어 입구 /en/plan). 플래너·지도·헤더 CSS 는 같다.
|
||||
세 브랜치는 서로 머지되지 않았다. `supporters/` 폴더가 양쪽에 있고 Base.astro 구현이 다르다(메인: i18n.ts 전역 전환·영어 홈; Productize: langswitch·영어 입구 /en/plan). 플래너·지도·헤더 CSS 는 같다.
|
||||
|
||||
## 3. 지금 되는 것 / 안 되는 것 (한 줄씩)
|
||||
|
||||
되는 것
|
||||
- 병원 이름·URL 로 채널 발견·근거 수집·자동 채점·리포트 HTML/PDF·덱(뷰·원진·오라클·태하 사례).
|
||||
- 워커 한 번으로 서포터즈 사이트 초안 발행(글 6~8편, 영상, 뉴스룸, 주변 안내, 이미지 규칙, 지도, 플래너, 관광공사 데이터). 게이트 41건 테스트.
|
||||
- 워커 한 번으로 서포터즈 사이트 초안 발행(글 6~8편, 영상, 뉴스룸, 이미지 규칙, 지도, 플래너, 관광공사 데이터). 게이트 41건 테스트.
|
||||
- 병원 확인 패널 입력 → 수동 재빌드.
|
||||
- 질문뱅크 120문항 2엔진 실측, 감성 판정, Before/After 페이지(`scripts/build_before_after.py`).
|
||||
|
||||
@ -78,9 +60,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI
|
||||
|
||||
## 4. Product 빌드 순서 (권장)
|
||||
|
||||
1. ~~**브랜치 정리**~~ **완료 (2026-09-12, 브랜치 `feature/product-1.0`)**. feature/supporters-autobuild 는 main 의 상위집합이라 fast-forward 했고, feature/ai-sentiment-rubric 머지에서 충돌 8건이 났다. 겹친 22개 파일 중 실제로 다른 것은 9개뿐이었다. feature/discovery-url-cta 는 낡은 포인터라 제외했다.
|
||||
- **정본 결정(haewon)**: `supporters/`(뷰 수작업 샘플)를 정본으로 한다. 핸드오버 초안의 "템플릿을 정본으로" 와 반대 방향이다. `templates/supporters-astro/` 는 `npm run export:template` 로 `supporters/` 에서 생성되는 파생물이므로 구조적으로 맞아떨어진다.
|
||||
- Productize 쪽에만 있던 GA4 태그와 영문 법적 고지 처리를 정본으로 이식했다.
|
||||
1. **브랜치 정리**: `main` 기준으로 feature/supporters-autobuild(워커·템플릿·v2 기준표·GA4) → feature/ai-sentiment-rubric(감성·플래너·지도·설계 v0.2) → feature/discovery-url-cta(리드 CTA) 순으로 머지. 충돌 지점은 `supporters/`(Base.astro 두 구현)와 `src/data/aeoGeoRubric*`·`discoveryScoreV2`. 결정: `supporters/` 는 템플릿(Productize) 구현을 정본으로 하고 뷰 샘플은 워커 폴더로 옮긴다(§5-4).
|
||||
2. **리드 → 큐 연결**: `discovery_leads` 마이그레이션 적용 → insert 시 `supporter_builds` 큐에 행 생성(DB 트리거 또는 폴러가 leads 도 읽기) → 워커가 evidence 부터 완주 → 미리보기 URL 을 `supporter_builds.preview_url` 에 기록 → 신청자에게 링크 회신(초기에는 수동 메일, `o2oteam@o2o.kr`).
|
||||
3. **진단 결과 런타임화**: `audit_aeo_geo.py` 출력 JSON 을 Supabase(`discovery_results`) 또는 스토리지에 저장하고 `/discovery/:id` 가 런타임에 읽는다. 기존 `DISCOVERY_RESULTS` 레지스트리는 샘플(뷰) 전용으로 남긴다. 사람 판정(v2Results 4항목)은 확인 패널에서 입력.
|
||||
4. **재빌드 자동화**: `supporter_inputs` insert → 큐 → `run.mjs --from data --from-supabase`. 색인 허용 빌드(`PUBLIC_INDEXABLE=true`)는 `approvedAt` 있는 글만 낸다는 규칙 유지.
|
||||
@ -92,157 +72,15 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI
|
||||
|
||||
## 5. haewon 결정 대기
|
||||
|
||||
1. ~~**Product 1.0 범위**~~ **결정 (2026-09-12, haewon): (b) 진단 + 서포터즈 사이트 초안.** 랜딩 CTA 는 여기까지 약속한다. 측정·관리는 내부 참고로만 쓰고 약속하지 않는다. 남은 일은 §4-2 리드→큐 연결과 §4-3 진단 런타임화다.
|
||||
1. **Product 1.0 범위**: (a) 진단 리포트만, (b) 진단 + 서포터즈 사이트 초안, (c) b + 측정·관리 구독. 랜딩 CTA 의 약속 범위가 달라진다.
|
||||
2. **가격 티어**(§4-7)와 계약 최소 기간·해지 조건(2026-04 문서의 P0 두 항목이 그대로 미결).
|
||||
3. ~~**뷰 샘플 통일**~~ **결정 (2026-09-12, haewon): 메인 `supporters/` 가 정본.** 템플릿은 `npm run export:template` 로 여기서 생성한다. 영어 홈 `/en` 은 템플릿에 추가했고 영문 사실이 없는 병원에서도 문장이 성립하도록 조각 단위로 조립한다(§5-2 판단 2). `view-supporters-sample` 배포 자리를 워커로 옮길지는 아직 안 정했다.
|
||||
3. **뷰 샘플 통일**: 메인 `supporters/` 를 워커 폴더(`~/supporters-builds/viewclinic`)로 옮기고 `view-supporters-sample` 을 워커 배포로 바꿀지. 옮기면 뷰의 영어 홈 `/en` 은 템플릿에 없으므로 템플릿에 영어 홈을 추가할지 함께 결정.
|
||||
4. **법무**: 외국인환자 유치업 등록 여부(플래너·숙소 링크), 의료광고 사전심의 대상 여부, 지원 관계 고지 문구(sponsorNotice). 이 세 가지는 색인 허용 전환의 전제.
|
||||
5. ~~**도메인**~~ **결정 (2026-09-14, haewon): 공용 도메인 `webforai.kr` 의 병원별 서브도메인.** 뷰 `viewpl` · 원진 `wonjin` · 오라클 `oracle`. 구매 품의 결재 대기이고, 절차와 규모 대비는 §5-4 에 있다. 남은 미결은 `webforai.co.kr` 함께 확보할지, `webforai.com`(Afternic 파킹) 인수할지, 서포터즈 실명 모집 방식이다.
|
||||
5. **도메인**: 별도 도메인 vs 병원 서브도메인(설계 v0.1 §3 2안). 서포터즈 실명 모집 방식.
|
||||
6. **워커 호스팅**과 GCP 계정(GA4 서비스 계정).
|
||||
7. **규칙표 잠정값**: 뷰·원진·오라클 `procedures.json` 의 provisional 값(출국 최소일·입원·실밥)을 병원 확인 뒤 공개할지, 공개 전까지 "병원 확인 전" 배지로 둘지.
|
||||
8. 서포터즈 빌드 하위 결정(하위 문서 §4)과 오라클 핸드오버 §3 미결은 그대로.
|
||||
|
||||
## 5-2. 2026-09-12 세션에서 내린 판단 (기록)
|
||||
|
||||
핸드오버에 없던 것을 작업 중에 발견해 판단한 두 가지다. 되돌리려면 아래 "되돌리는 법"대로 하면 된다.
|
||||
|
||||
### 판단 1. 영문 의료광고 고지를 병원 승인 없이 번역하지 않는다
|
||||
|
||||
- **발견**: 정본이 된 `supporters/src/lib/i18n.ts` 가 지원 관계 고지와 부작용 고지를 영문으로 번역해 푸터에 싣고 있었다. `site.json` 에 `sponsorNoticeEn` 은 없었고, 어디에도 병원 승인 표시가 없었다. Productize 쪽 구현은 같은 자리에서 번역을 거부하고 한국어 원문에 확인 대기 표기를 붙이고 있었다.
|
||||
- **왜 문제인가**: 지원 관계 고지와 부작용 고지는 의료법 56조와 추천보증 심사지침의 규율 대상이다. 문구를 임의로 번역하면 병원이 승인하지 않은 문장이 병원 이름으로 나간다. §5-4 법무가 미결이고 색인 허용의 전제로 적혀 있는 항목이다.
|
||||
- **한 것**: `Base.astro` 가 `site.json` 의 `sponsorNoticeEn` 과 factSheet 의 `sideEffectNoticeEn` 을 먼저 읽고, 없으면 한국어 원문을 싣고 "The English wording of these notices is pending clinic approval." 을 함께 낸다. `i18n.ts` 의 영문 `foot_sponsor`·`foot_side` 는 이 자리에 쓰지 않는다고 주석으로 표시했다.
|
||||
- **되돌리는 법**: 병원이 영문 문구를 확정하면 `site.json` 에 `sponsorNoticeEn`, factSheet 에 `sideEffectNoticeEn` 을 넣는다. 코드 수정은 필요 없고 확인 대기 표기가 자동으로 사라진다.
|
||||
|
||||
### 판단 2. 누락 탐지기를 고쳐 상호 밖의 고유 사실까지 잡는다
|
||||
|
||||
- **발견**: `export_template.mjs` 의 `CLINIC_RE` 가 상호(`뷰성형외과`·`viewclinic`)만 찾아서 지명으로 적힌 사실을 놓쳤다. 빈 데이터로 템플릿을 빌드하니 영어 홈이 `Founded 확인 대기 · Sinnonhyeon Station Exit 3` 를 냈다. 또 이 검사는 빌드 도구의 기본 인자(`--clinic viewclinic`) 때문에 늘 실패해 신호가 되지 못했다.
|
||||
- **왜 문제인가**: 템플릿은 모든 병원 사이트의 원본이다. 화면을 그리는 코드에 한 병원의 사실이 문장으로 박히면 다른 병원 화면에 **틀린 사실**이 나간다. 값이 비어 화면이 비는 것과 다르다.
|
||||
- **한 것**: 지명·건물 표현(`Sinnonhyeon`·`Bongeunsa`·`Gimpo`·`신논현`·`봉은사`·`19-storey`·`View Plastic`)을 패턴에 넣었다. 그리고 심각도를 나눠, 문장을 그리는 코드(`src/pages`·`layouts`·`components`·`lib`)에 남으면 내보내기를 멈추고, 데이터(`src/data`)와 빌드 도구(`scripts`)는 알림만 한다. 데이터는 워커가 병원마다 갈아끼우고 빌드 도구는 고객이 보지 않기 때문이다.
|
||||
- **곁가지로 잡은 것**: 같은 점검에서 영어 홈의 하드코딩 7곳(공항 경로·건물 층수·히어로 alt·영문 병원명·404 상호·히어로 칩·머리말 기본값 `VIEW SUPPORTERS`)과, 영문 사실이 없는 병원에서 문장이 `is a at .` 로 깨지는 문제를 함께 고쳤다. 게이트는 두 가지 모두 오류로 잡지 못한다.
|
||||
|
||||
### 여기서 배운 규칙
|
||||
|
||||
- **템플릿에 넣기 전 빈 데이터로 빌드해 본다.** 한 병원의 데이터로만 확인하면 하드코딩과 데이터 참조가 구별되지 않는다. 화면이 같아 보이기 때문이다.
|
||||
- **게이트가 통과해도 화면은 깨질 수 있다.** 게이트는 의료광고 문구와 금칙어를 보지, 빈 값이 만든 비문을 보지 않는다. 배포 전 로컬 확인이 필요한 이유다.
|
||||
|
||||
## 5-3. 이미지 (2026-09-12) · 사람 게이트와 남은 일
|
||||
|
||||
### 자동 분류를 믿을 수 없다
|
||||
|
||||
뷰성형외과 수집 이미지 12장 중 6장이 의료광고 금지 유형인데 전부 시설(clinic)로 분류돼 있었다.
|
||||
전후 사진(밑선 절개 흉터 1개월·6개월 경과), 시술 행위 사진(팔 주사·드레싱), 광고 모델 신체 사진 4장,
|
||||
장비 제조사 성능 주장 그래픽, 가슴 라인 일러스트(랜딩 카드 썸네일로 노출)다.
|
||||
|
||||
**파일명·alt 로는 한 장도 걸리지 않는다.** 91장을 그 방식으로 검사해 0장이 걸렸다.
|
||||
파일명이 `img02` 이고 alt 가 "가슴확대 시설 사진" 이면 어떤 규칙도 전후 사진임을 알 수 없다.
|
||||
규칙 문서 §4 의 `IMAGE_RULE`(파일명·alt 검사) 계획은 이 유형에 효과가 없다.
|
||||
|
||||
→ **이미지 육안 확인은 사람 게이트로 남긴다.** `scripts/build_image_review.mjs` 가 검토 요청서를 만든다.
|
||||
화면에 실제로 나가는 것을 먼저 보여주고, 체크하면 `excludeImages` 에 넣을 목록이 만들어진다.
|
||||
`node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir>`
|
||||
|
||||
### 대표 이미지 배정 (워커 heroes 단계)
|
||||
|
||||
`PostCard` 의 대체 순서가 건물 사진 하나로 수렴해 목록에 같은 사진이 나란히 섰다.
|
||||
`scripts/assign_post_heroes.mjs` 가 글 분류별로 어울리는 유형을 골라 글마다 다른 사진을 배정한다.
|
||||
의료진 사진은 쓰지 않고(저자·검토자 표시가 지정 용도), 수집 alt 가 효과·결과를 주장하면 중립 문구로 바꾸며,
|
||||
이미지가 모자라면 다시 쓰지 않고 비운다.
|
||||
|
||||
### 남은 일
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| 원진 66장 육안 검토 | **요청 상태.** 그중 15장이 화면에 나간다. 아직 아무도 보지 않았다 |
|
||||
| 뷰 17장 | 12장 확인 완료(6장 제외). 의료진 13장은 검토자 표시 용도라 별도 |
|
||||
| 오라클 이미지 | **0장.** 근거 수집일 2026-09-10 이 EUC-KR 디코딩(2026-09-11 반영)보다 하루 빨라 `images[].alt` 가 전부 `????` 로 깨졌고 분류기가 한 장도 고르지 못한다. **코드는 이미 고쳐져 있으므로 `--from evidence` 재수집이면 풀린다.** 다만 `oracleclinic.com` 이 응답하지 않는다(포트 80 연결 거부, 2026-09-12 기준). 그룹 사이트는 살아 있으나 Next.js 이미지 최적화 뒤에 있고 내용이 브랜드 배너·인증 마크라 대부분 제외 대상이다 |
|
||||
| 뷰 이미지 부족 | 제외 후 쓸 수 있는 것이 4장이라 글 5편 중 2편이 건물 사진으로 떨어진다. 병원 제공 시설 사진이 필요하다 |
|
||||
|
||||
## 5-4. 사이트 주소와 병원 레지스트리 (2026-09-14)
|
||||
|
||||
### 결정
|
||||
|
||||
서포터즈 사이트는 **공용 도메인 `webforai.kr` 의 병원별 서브도메인**에 둔다(haewon 2026-09-14).
|
||||
병원마다 도메인을 사면 병원 수만큼 갱신 대상이 늘고 한 건만 놓쳐도 그 병원 사이트가 멈춘다.
|
||||
근거와 리스크 비교는 `docs/business/INFINITH_서포터즈_사이트주소_방식비교_2026-09-14.docx` 에 있다.
|
||||
|
||||
병원 도메인의 서브도메인(`supporters.viewclinic.com`)은 제외했다. `viewclinic.com` 네임서버가 Cafe24 라
|
||||
병원 웹 개발사 협조가 전제된다. 원진은 whoisdomain.kr, 오라클은 네임서버 응답이 없다.
|
||||
|
||||
### 지금 상태
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| `webforai.kr` 구매 | **완료 (2026-09-14).** (주)오투오 명의, 14,000원, 만료 2027-09-14 |
|
||||
| 네임서버 위임 | **완료.** `ns1·ns2.vercel-dns.com`. 존 전체를 Vercel 이 관리하므로 이후 서브도메인 추가에 사람이 DNS 를 만질 일이 없다 |
|
||||
| 주소 연결 | **완료.** 아래 표 참조 |
|
||||
| `clinic_registry` 서브도메인 컬럼 | **적용 완료** (`supabase/migrations/20260914_clinic_subdomain.sql`) |
|
||||
| `webforai.co.kr` | 미등록. 함께 확보할지 미결 |
|
||||
| `webforai.com` | 이미 등록됨(Afternic 파킹). 인수 여부 미결 |
|
||||
| 자동연장·도메인 잠금 | **꺼져 있음. 켜야 한다.** hosting.kr 도메인 상세 화면 왼쪽 패널 |
|
||||
|
||||
### 연결된 주소 (2026-09-14)
|
||||
|
||||
| 주소 | 내용 | Vercel 프로젝트 |
|
||||
|---|---|---|
|
||||
| `webforai.kr` · `www.webforai.kr` | AI Discovery 랜딩 | **`infinith-discovery` (신설)** |
|
||||
| `viewpl.webforai.kr` | 뷰성형외과 서포터즈 | `supporters-viewclinic` |
|
||||
| `wonjin.webforai.kr` | 원진성형외과 서포터즈 | `supporters-wonjin` |
|
||||
| `oracle.webforai.kr` | 오라클피부과 서포터즈 | `supporters-oracle` |
|
||||
|
||||
세 서포터즈 사이트 모두 canonical·사이트맵이 새 주소이고 noindex 는 유지된다.
|
||||
`infinith-demo` 는 건드리지 않았다. 기존 주소(`supporters-*.vercel.app`)도 그대로 살아 있다.
|
||||
|
||||
### AI Discovery 랜딩을 별도 프로젝트로 뺀 이유
|
||||
|
||||
`/discovery` 는 `infinith-demo` 안의 경로라 도메인만 붙이면 루트에 기존 INFINITH 랜딩이 뜬다.
|
||||
SPA 라 rewrite 로도 안 된다. React Router 가 주소로 판단하기 때문이다.
|
||||
|
||||
그래서 루트에서 무엇을 그릴지 환경변수로 정하게 했다(`VITE_SITE=discovery`, `src/main.tsx`).
|
||||
한 코드베이스로 두 사이트를 내므로 랜딩을 고치면 양쪽에 함께 반영된다.
|
||||
`index.html` 의 제목·설명·og 도 같은 변수로 갈아끼운다(`vite.config.ts`). 답변엔진이 읽는 첫 신호라 비워 두지 않는다.
|
||||
|
||||
새 프로젝트 `infinith-discovery` 의 환경변수(`VITE_SITE`·`VITE_SUPABASE_URL`·`VITE_SUPABASE_ANON_KEY`)는
|
||||
프로젝트 설정에 저장돼 있어 다음 배포에도 유지된다.
|
||||
|
||||
### 레지스트리에 넣은 것
|
||||
|
||||
`clinic_registry` 가 서브도메인의 단일 원본이다. 코드가 이름을 매번 짓지 않는다.
|
||||
수백 곳으로 늘면 "뷰"·"연세"·"그랜드" 처럼 여러 병원이 쓰는 이름에서 충돌이 난다.
|
||||
|
||||
- `subdomain`: 공용 도메인 앞 라벨. 소문자·숫자·붙임표, 2~40자, 붙임표 시작·끝 금지, 연속 붙임표 금지
|
||||
- `clinic_slug`: 워커 `--clinic` 값. 빌드 폴더·Vercel 프로젝트 이름에 쓰여 서브도메인과 다를 수 있다(뷰는 `viewpl` 대 `viewclinic`)
|
||||
- `subdomain_assigned_at`: 배정 시각
|
||||
- 둘 다 대소문자 무시 유니크. 예약어 70여 개 차단(메일·인증 계열 `mx`·`dmarc`·`autodiscover`·`ns1` 포함)
|
||||
|
||||
예약어를 병원에 내주면 나중에 메일·API·관리 화면을 붙일 때 주소를 회수해야 하고,
|
||||
회수 시점에는 이미 색인된 주소가 깨진다. 중복·예약어·형식 위반이 실제로 차단되는 것을 확인했다.
|
||||
|
||||
오라클피부과는 이 레지스트리가 성형외과 73곳으로 만들어져 등록돼 있지 않았다. 2026-09-14 에 넣어 74곳이다.
|
||||
|
||||
### 새 병원을 붙이는 순서 (2026-09-14 확립)
|
||||
|
||||
1. `clinic_registry` 에 `subdomain`·`clinic_slug` 를 넣는다. 이름은 코드가 짓지 않는다.
|
||||
2. 워커를 `--domain <서브도메인>.webforai.kr --deploy` 로 돌린다. DNS·인증서는 Vercel 이 자동으로 한다.
|
||||
`node workers/supporters-build/run.mjs --clinic viewclinic --url https://www.viewclinic.com --work ~/supporters-builds/viewclinic-test --from build --skip-generate --deploy --domain viewpl.webforai.kr`
|
||||
원진은 `--clinic wonjin --domain wonjin.webforai.kr`, 오라클은 `--clinic oracle --domain oracle.webforai.kr`.
|
||||
3. **`--indexable` 은 붙이지 않는다.** 법무 3건(외국인환자 유치업 등록, 의료광고 사전심의 대상 여부, 지원 관계 고지 문구)이 끝난 뒤 별도 품의로 올린다.
|
||||
4. 연결 뒤 주소 응답과 noindex 헤더·메타·샘플 배너가 남아 있는지 확인한다.
|
||||
|
||||
### 규모가 커질 때 대비할 것
|
||||
|
||||
공용 도메인은 평판을 공유한다. 비슷한 틀의 콘텐츠가 수백 개 서브도메인으로 쌓이면
|
||||
doorway 패턴으로 판정될 수 있고, 한 곳의 문제가 전체에 옮는다. 지금 없는 방어가 하나 있다.
|
||||
|
||||
- **병원 간 본문 유사도 상한을 발행 게이트에 추가한다.** 현재 `HOME_DUPLICATE` 는 같은 병원 안의 중복만 본다.
|
||||
- 상위 도메인 첫 화면을 병원 목록으로 만들지 않는다. 나열하면 네트워크로 읽힌다.
|
||||
- 전환이 필요해지는 경우는 병원이 자체 도메인을 요구하거나 평판 문제가 생길 때다. 전체를 옮기지 않고 해당 병원부터 옮긴다.
|
||||
|
||||
### DB 작업 시 주의
|
||||
|
||||
운영 DB 에 `supabase db push` 를 쓰지 않는다. 마이그레이션 이력에 10개 중 3개만 기록돼 있어
|
||||
나머지 7개를 다시 실행하려 든다. 새 마이그레이션은 `supabase db query --linked --file <파일>` 로 그 파일만 적용한다.
|
||||
대조표는 `docs/DB_SCHEMA_DIVERGENCE_2026-09-14.md` 에 있고, 정본은 마이그레이션 파일로 정했다(haewon 2026-09-14).
|
||||
표 17개 중 15개는 일치하고 손볼 곳은 `clinic_registry` 하나와 정의가 빠진 뷰 2개다.
|
||||
|
||||
## 6. 함정 (제품 관점)
|
||||
|
||||
- **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만.
|
||||
@ -263,16 +101,12 @@ doorway 패턴으로 판정될 수 있고, 한 곳의 문제가 전체에 옮는
|
||||
- 워커·템플릿: Productize `workers/supporters-build/`, `supporters/scripts/`, `templates/supporters-astro/`, 브리프 `supporters/briefs/<clinic>/`
|
||||
- 플래너·지도: 템플릿 `src/components/Planner.astro`·`VisitMap.astro`, `src/lib/plan.ts`·`tour.ts`, `supporters/scripts/build_planner_data.mjs`, 설계 `docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.2.md` §13
|
||||
- 측정: `scripts/run_question_bank_openai.py`, `scripts/sentiment_qb.py`, `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, Productize `scripts/ga4_ai_traffic.py`, `data/ai_channels.json`
|
||||
- 사이트 주소·레지스트리: `supabase/migrations/20260914_clinic_subdomain.sql`, `docs/DB_SCHEMA_DIVERGENCE_2026-09-14.md`, `docs/business/INFINITH_서포터즈_사이트주소_방식비교_2026-09-14.docx`, 품의서 `docs/business/INFINITH_품의서_webforai.kr_도메인구매_2026-09-14.docx`
|
||||
- 이미지 육안 검토: `supporters/scripts/build_image_review.mjs`, 대표 이미지 배정 `supporters/scripts/assign_post_heroes.mjs`
|
||||
- 가격·경쟁: `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html`, `docs/INFINITH_가격정책_사업부보고서_v1.1.docx`
|
||||
- PRD: `docs/prd/INFINITH_AI-Discovery_PRD_20260828_v02.md`, `docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.1.html`(Productize 에 v0.2), `docs/prd/INFINITH_Dermatology_Supporters_PRD_v0.1.html`
|
||||
|
||||
## 8. 이번 이틀(09-10~11)의 인사이트 · 다음 개발에 그대로 적용할 것
|
||||
|
||||
1. **한 세션 = 한 저장소 = 한 배포 대상.** 두 세션이 같은 Vercel 프로젝트와 같은 `supporters/` 폴더를 다른 브랜치에서 건드려 `/plan` 404 와 주변 안내 실종이 났다. 세션 시작 때 `git status`·`.vercel/project.json`·열린 세션 여부를 확인하고, 그 세션이 맡지 않은 자산은 손대지 않는다. 브랜치 통합(§4-1)이 이 문제의 근본 해결이므로 첫 일로 했다. **2026-09-12 완료.**
|
||||
- 다만 통합 중에 같은 사고를 한 번 더 냈다. 머지 기준으로 삼은 브랜치 끝이 세션 도중 9개 커밋만큼 앞서 있었고(주변 안내 복원·워커 publish 단계), 그것을 모르고 올린 첫 머지가 둘을 떨어뜨렸다. 푸시 전에 발견해 되돌렸다.
|
||||
- **머지 직전에 브랜치 끝을 다시 읽는다.** 세션 시작 때 읽은 `git rev-parse`/`git log` 값은 머지 시점에 낡아 있을 수 있다. 다른 워크트리가 열려 있으면 특히 그렇다. 머지 뒤에는 `git rev-list --count main..<branch>` 가 0 인지 확인한다.
|
||||
1. **한 세션 = 한 저장소 = 한 배포 대상.** 두 세션이 같은 Vercel 프로젝트와 같은 `supporters/` 폴더를 다른 브랜치에서 건드려 `/plan` 404 와 주변 안내 실종이 났다. 세션 시작 때 `git status`·`.vercel/project.json`·열린 세션 여부를 확인하고, 그 세션이 맡지 않은 자산은 손대지 않는다. 브랜치 통합(§4-1)이 이 문제의 근본 해결이므로 월요일 첫 일로 한다.
|
||||
2. **"완료"를 산출물이 아니라 고객 결과로 정의하면 자동·사람 경계가 드러난다.** 서포터즈 사이트 완료 = 색인 작업 + 사람 체크리스트로 바꾸자 IndexNow·GSC 는 자동, 네이버 등록·소유자 추가는 사람으로 갈렸고 워커 단계가 그 경계를 코드로 가졌다. 진단(사람 판정 4항목), 병원 확인(재빌드 트리거)도 같은 방식으로 "완료 정의 → 자동 부분 → 사람 요청"을 명시한다.
|
||||
3. **병원 원문은 인용 전에 걸러야 한다.** 원문 32문장 중 14건이 홍보문·정형 고지·표 조각이었다. 원문을 데이터로 쓰는 모든 경로(recoveryNotes, OCR, 뉴스 제목)에 화이트리스트 필터와 "제외된 문장 목록 눈으로 보기"를 둔다. 필터 없는 "병원 안내 기준" 배지는 의료광고 위반이다.
|
||||
4. **게이트는 데이터 값도 본다.** 좌표 출처의 "실측"이 화면에 새어 나가 빌드가 막혔다. 데이터 필드마다 고객 노출 여부를 정하고 운영 기록(source·coordSource·note)은 렌더하지 않는다.
|
||||
@ -282,5 +116,5 @@ doorway 패턴으로 판정될 수 있고, 한 곳의 문제가 전체에 옮는
|
||||
8. **전역 CSS 와 컴포넌트 클래스는 접두어로 분리한다.** `.kpi` 충돌로 그라디언트가 되살아났다. 컴포넌트 클래스는 `plan-`·`vm-` 처럼 접두어를 쓰고, 새 클래스는 전역 CSS 에 grep 한다.
|
||||
9. **모바일 검증은 하네스로.** 헤드리스 크롬 창 최소 폭 때문에 400px 캡처가 틀렸고, 실제 문제(헤더 겹침)는 아이프레임 하네스에서만 보였다. 8페이지×3사이트 그리드 캡처를 배포 전 기본 절차로 둔다.
|
||||
10. **언어 전환은 전역 하나.** 페이지별 토글은 사용자 관점에서 틀렸다. 영어 홈이 없는 사이트는 영어 입구(/en/plan)를 정해야 하고, 템플릿에 영어 홈을 넣을지는 §5-3 결정에 달렸다.
|
||||
11. **기존 페이지는 haewon 확인 전에 지우거나 리다이렉트하지 않는다.** 새 기능이 기존 페이지와 같은 주제로 보여도 병존이 기본이다(주변 안내 `/stay` 와 회복 일정 `/plan`). **2026-09-12 재발.** 브랜치 통합 머지가 `/stay` 를 떨어뜨렸다. 페이지를 지우려는 의도가 없어도 머지 기준을 잘못 잡으면 같은 결과가 난다. 통합 뒤 페이지 목록을 이전 배포와 대조한다.
|
||||
11. **기존 페이지는 haewon 확인 전에 지우거나 리다이렉트하지 않는다.** 새 기능이 기존 페이지와 같은 주제로 보여도 병존이 기본이다(주변 안내 `/stay` 와 회복 일정 `/plan`).
|
||||
12. **제품 병목은 세 가지로 좁혀졌다.** 진단 결과가 코드 파일, 리드→큐 미연결, 브랜치 3개 분산. 이 셋을 풀어야 "URL 입력 → 리포트 링크 → 서포터즈 미리보기"가 사람 손 없이 이어진다.
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
# 다음 세션 핸드오버 · 2026-09-12 · 서포터즈 사이트 빌드 절차 (회복 일정 플래너·오시는 길 지도 통합 이후)
|
||||
|
||||
> 이 문서는 하위 문서다. INFINITH AI Discovery Product 전체(제품 여정·브랜치 통합·리드→큐→빌드 자동화·측정·가격, §8 인사이트)의 핸드오버는 메인 체크아웃 `docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md` 를 먼저 읽는다. 다음 세션은 2026-09-14(월) Product 개발이다. 09-11 저녁 최종 상태: 세 사이트 모두 주변 안내(/stay)와 회복 일정(/plan)이 병존하고, 워커에 publish 단계가 있다.
|
||||
|
||||
작성 2026-09-11 · 작성자 Claude (haewon 세션) · 이전 핸드오버 `docs/NEXT_SESSION_2026-09-11_ORACLE.md`(오라클 첫 빌드), `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(자동 빌드 전체).
|
||||
이 문서는 2026-09-10~11 이틀간 붙인 기능(회복 일정 플래너 `/plan`·`/en/plan`, 오시는 길 지도, 전역 언어 전환, 모바일 헤더)과 그것을 워커·템플릿에 통합한 상태, 그리고 다음 병원을 Product로 빌드하는 절차를 다룬다.
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH 저장소. 자동 빌드(워커·템플릿)는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild),
|
||||
뷰성형외과 샘플 사이트(수동 배포)는 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH/supporters (브랜치 feature/ai-sentiment-rubric).
|
||||
병원별 빌드 산출물은 ~/supporters-builds/<clinic>/site 이고 Vercel 프로젝트는 supporters-<clinic> 이다.
|
||||
먼저 docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md 를 읽고 §4 결정 대기의 답을 받은 뒤 §3 절차대로 진행한다.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다("확인 대기"·"병원 확인 전"). 사람 게이트(의학 검토·게시 승인·이미지 육안·채널 확인·구조 기준선 승인)는 자동화하지 않는다.
|
||||
고객 화면에 운영자 설명 없음(게이트 OPERATOR_VOCAB). 확정 디자인은 텍스트만. 존댓말, 엠대시 금지(커밋 메시지 포함), 은유 금지, 흰 배경 위 연보라 텍스트 금지(인디고 #1D0024).
|
||||
데이터 타일에 웜 그라디언트 금지(CTA 카드 한 장에만). 새 페이지는 홈·영상 페이지의 섹션 리듬(다크 히어로+글래스 통계 → 틴트 카드 → 다크 통계 → 라이트 → 틴트 → 다크)으로 만든다.
|
||||
배포는 병원 폴더(~/supporters-builds/<clinic>/site)나 메인 supporters/ 에서만 한다. Productize/supporters 는 어떤 Vercel 프로젝트에도 연결하지 않는다.
|
||||
```
|
||||
|
||||
## 1. 2026-09-10~11 에 끝낸 것
|
||||
|
||||
| 일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 회복 일정 플래너 `/plan`·`/en/plan` (수술일 기준 입국·출국 계산, 외출 등급·식사 단계 필터, 큐레이션+관광공사 추천, 체류 기간 행사, 숙소·항공 딥링크, ICS, 하단 요약 바) | 템플릿 `src/components/Planner.astro`, `src/lib/plan.ts`·`tour.ts`, `src/styles/plan.css`, `src/data/planStrings.json`, `src/pages/plan.astro`·`en/plan.astro`·`404.astro` | 뷰·원진·오라클 배포. 설계 근거 메인 `docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.2.md` §13 |
|
||||
| 워커 `planner` 단계(recovery 다음) | `workers/supporters-build/run.mjs`, `supporters/scripts/build_planner_data.mjs`, 기본표 `supporters/scripts/template/planner/procedures.{plastic,derm}.json`·`places.base.json` | 업종별 기본 규칙표 + 병원 시술 페이지 원문(recoveryNotes) 중 관리 문장만 붙임(CARE/NOT_CARE 필터, 세부 시술명 접두). 장소는 `briefs/<clinic>/planner.places.json`(큐레이션) 또는 범용 기본표 |
|
||||
| 병원별 큐레이션 장소 브리프 | `supporters/briefs/{viewclinic,oracle,wonjin}/planner.places.json` | 뷰 31·오라클 28·원진 31곳. 전부 verify(출시 전 확인) |
|
||||
| 오시는 길 지도·길찾기 (Leaflet+OSM, 주소 복사, 네이버·카카오 길찾기, 영어는 구글 추가) | 템플릿 `src/components/VisitMap.astro`, 방문 안내 §3, 뷰 영어 홈 Getting Here | 좌표 `factSheet.geo`. 워커 tourism 단계가 관광공사 수집 기준 좌표(네이버 지역검색)를 geo 에 기록 |
|
||||
| 전역 언어 전환·모바일 헤더 | 템플릿 Base.astro(langswitch, T.nav), `global.css` ≤900px 헤더 규칙 | 로고·메뉴 겹침 해결. 390px 하네스로 8페이지×3사이트 검증 |
|
||||
| 주변 안내 복원 (2026-09-11 17시) | `/stay`·`/en/stay` (장소·축제·동선 지도·날씨·시술 후 관리 접이식). 내비 '주변 안내'·'Nearby', 언어 짝 /stay↔/en/stay. `/recovery`·`/en/recovery` → `/plan`·`/en/plan` 리다이렉트만 유지 | haewon 결정: 주변 안내와 회복 일정 플래너는 별개 페이지. 처음 통합 때 /stay 를 /plan 으로 덮어쓴 것을 되돌림. 스타일은 global.css '/* ── /en/stay ── */' 블록 |
|
||||
| 원진 관광공사 수집·원문 수집 | `~/supporters-builds/wonjin/site/src/data/medicalTourism*.json`, `recoveryNotes.json` | 장소 123·축제 24, 원문 32문장 중 관리 문장 16건 붙임 |
|
||||
| Vercel 사고 재발 방지 | Productize `supporters/.vercel/project.json` → `.disabled-2026-09-11`, `supporters/DEPLOY_NOTE.md` | 두 폴더가 같은 프로젝트에 배포해 `/plan` 이 404 된 사고(09-11 10:20) 이후 |
|
||||
|
||||
커밋: Productize `aa5b052`(플래너 통합) → `ed8f650`(지도) → `5263593`(모바일 헤더) → `008411b`(관리 문장 필터). 메인 `65fb9be`~`93509bf`.
|
||||
|
||||
## 2. 격리 구조 (무엇이 무엇을 건드리는가)
|
||||
|
||||
| 폴더 | 브랜치 | 역할 | Vercel | 워커가 건드리나 |
|
||||
|---|---|---|---|---|
|
||||
| `~/Claude/Agentic Marketing/INFINITH/supporters` | feature/ai-sentiment-rubric | 뷰성형외과 샘플(정본 `/plan`) | `view-supporters-sample` (수동 `vercel --prod`) | 아니오. 워커는 이 경로를 모른다 |
|
||||
| `Productize/supporters` | feature/supporters-autobuild | 템플릿 원본(개발용). `npm run export:template` 으로 `templates/supporters-astro` 생성 | 없음(연결 파일 비활성) | 아니오. 워커는 여기서 스크립트만 실행한다 |
|
||||
| `Productize/templates/supporters-astro` | 〃 | 워커가 복사하는 템플릿 | 없음 | 읽기만 |
|
||||
| `~/supporters-builds/<clinic>/site` | (저장소 밖) | 병원별 산출물 | `supporters-<clinic>` (워커가 `vercel link --project` 로 강제 연결) | **예.** `data` 단계가 `site/` 를 지우고 템플릿을 다시 복사한다(글·videos.json 만 보존) |
|
||||
|
||||
따라서: 워커를 어떤 병원으로 돌려도 뷰 샘플과 다른 병원 프로젝트는 영향이 없다. 단, 같은 병원을 `--from data` 이전 단계부터 다시 돌리면 그 병원 폴더의 **손수 고친 파일은 사라진다**. 이번에 손수 넣은 것은 전부 템플릿·브리프·워커 단계로 옮겨 두었으므로 재빌드 결과가 지금 라이브와 같아야 한다. 예외 두 가지는 §5.
|
||||
|
||||
## 3. Product build 절차 (다음 병원)
|
||||
|
||||
1. **키 확인**: `Productize/.env` 에 `TOUR_API_KEY`, `NAVER_CLIENT_ID/SECRET`, `OPENAI_API_KEY`(원문 번역), `YOUTUBE_API_KEY` 값이 있는지. `grep -E "^(TOUR_API_KEY|NAVER_CLIENT_ID)=." .env`.
|
||||
2. **브리프 작성** `supporters/briefs/<clinic>/`
|
||||
- `clinic.json`: 유튜브 채널 고정, 로고, `factSheet.kind`(피부과면 `"피부과"`, 이 값으로 업종 자동 판별) · `industry`(`derm`|`plastic`, 명시하면 우선), 갤러리·제외 이미지. 예시는 `briefs/oracle/clinic.json`.
|
||||
- `planner.places.json`(선택): 병원 기준 큐레이션 장소. 없으면 범용 기본표(약국·편의점·죽·스무디·탕류·경과 확인·호텔 라운지)에 병원 이름·지역만 치환된다. 예시는 `briefs/wonjin/planner.places.json`(뷰 목록을 기준점만 바꿔 거리 보정).
|
||||
3. **워커 실행** (Productize 루트)
|
||||
```
|
||||
node workers/supporters-build/run.mjs --clinic <id> --url https://<병원 홈페이지> --name "<상호>" [--industry derm] [--deploy]
|
||||
```
|
||||
단계 순서: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → **planner** → inputs → briefs → generate → build → deploy → **publish**. 부분 재실행은 `--from <단계>`. 생성 비용 약 $0.6/병원(글 6~8편).
|
||||
4. **확인**: `~/supporters-builds/<id>/status.json` 의 `report.tourism`(좌표·장소 수)·`report.planner`(붙은 원문 문장 수)·`report.recovery`. 빌드 게이트 오류 0 인지.
|
||||
5. **사람 게이트**: 의료진 후보 확정, 이미지 육안 확인, 구조 기준선 승인(`node scripts/check.mjs --update-layout`, 첫 빌드는 LAYOUT_NO_BASELINE 경고), 규칙표 provisional 값 원장 확인.
|
||||
6. **모바일 검증**: 헤드리스 크롬은 창 최소 폭이 약 500px 이라 400px 직접 캡처가 부정확하다. 1280 창 안에 390px iframe 을 넣은 하네스로 찍는다(스크래치 `harness*.html` 방식, 홈·영상·방문·plan·뉴스룸·목록·글·병원 정보 8페이지).
|
||||
7. **배포**: `--deploy` 또는 병원 폴더에서 `vercel --prod --yes`. 메인 `supporters/`(뷰 샘플)는 별도로 `git push` 후 `vercel --prod`.
|
||||
8. **발행(색인)**: 배포 뒤 워커 `publish` 단계가 자동 실행된다(`scripts/publish_site.mjs`). 샘플(noindex) 상태면 라이브 확인과 "색인 허용 전환 결정" 체크리스트만 낸다. 정식 공개는 `--indexable --deploy [--domain <도메인>]` 로 재실행: noindex 헤더 제거, 승인 글만 발행, IndexNow 알림, GSC 사이트맵 제출(서비스 계정 있을 때). 결과는 `status.report.publish`, 사람 몫은 `~/supporters-builds/<id>/publish-checklist.md` 와 `status.humanTasks`.
|
||||
9. **완료 판정**: 체크리스트의 사람 몫(네이버 서치어드바이저 등록·소유 확인·사이트맵 제출, GSC 소유자 추가, 도메인, 법무 3건, 백링크 요청)이 끝나야 "완료"다. 끝나기 전 상태는 `published_pending_tasks`.
|
||||
|
||||
기존 병원 재빌드(플래너·지도 포함 최신 템플릿 적용): `--from data` 로 돌리면 글·영상 집계는 보존되고 나머지는 새로 만든다. 원진처럼 원문 수집을 워커 밖에서 돌린 병원도 `recovery` 단계가 같은 결과를 낸다(`recoveryNotes.cache.json` 은 병원 폴더에 남아 번역 재사용).
|
||||
|
||||
## 4. haewon 결정 대기
|
||||
|
||||
1. **규칙표 provisional 값**(출국 가능 최소일·입원·실밥 등) 원장 확인. 뷰 `procedures.json` 의 clinic 값은 뷰 주의사항 페이지 근거, 나머지 병원은 원문 문장만 clinic. 화면에는 "병원 확인 전" 배지가 붙어 있으나 공개 전 확인 필요.
|
||||
2. **제휴 계정 주체**(부킹닷컴 aid, 항공): INFINITH vs 병원. 지금은 제휴 ID 없이 검색 링크만.
|
||||
3. **외국인환자 유치업 등록 여부 법무 확인**: 플래너는 정보 안내로 유지 중(예약·결제 없음).
|
||||
4. **뷰 샘플을 템플릿에서 다시 뽑아 통일할지**: 메인 `supporters/` Base.astro 는 템플릿과 구현이 다르다(메인: i18n.ts 전역 전환, 영어 홈 `/en`; 템플릿: langswitch, 영어 입구 `/en/plan`). 플래너·지도 코드는 동일. 통일하면 뷰도 워커 폴더에서 관리하고 `view-supporters-sample` 은 수동 배포에서 워커 배포로 바뀐다.
|
||||
5. **다음 병원**: 톡스앤필·차앤박(피부과 Top3 나머지)을 같은 워커로 빌드할지. 세 병원을 한 화면에서 비교하는 산출물은 만들지 않는다.
|
||||
6. **P1 남은 것**: 일본어·중국어 화면(planStrings 같은 키로 추가), 국적별 기본 칩(관광 데이터랩), 관광공사 재수집 주기.
|
||||
7. 기존 미결(오라클 핸드오버 §3): 세일즈 사용 범위, sponsorNotice 문구, 주변 안내 사진 폴백, 의료진 OCR 노출, 도메인·서포터즈 실명·워커 상시 실행.
|
||||
|
||||
## 5. 함정 (이번 이틀)
|
||||
|
||||
- **같은 Vercel 프로젝트에 두 폴더**: Productize/supporters 와 메인 supporters 가 둘 다 `view-supporters-sample` 에 연결돼 있어 서로 덮어썼다. 지금은 Productize 쪽 연결을 껐다. 워커 외 수동 `vercel link` 는 하지 않는다.
|
||||
- **재빌드 시 사라지는 손수 편집**: 원진 폴더의 `factSheetEn.json`, `lib/i18n.ts`, 메인식 Base.astro 는 인스턴스 전용이다. 재빌드하면 템플릿 Base(langswitch)로 바뀌며 이것이 의도한 상태다. 오라클 폴더의 `scripts/gate/plan.test.mjs` 는 오라클 전용(id 8종 기준)이라 재빌드 후 템플릿 범용 테스트로 바뀐다.
|
||||
- **전역 CSS 클래스 충돌**: 플래너 통계에 `kpi` 클래스를 쓰면 전역 `.kpi`(웜 그라디언트)가 덮어쓴다. `.stats.dates` 로 바꿨다. 새 클래스는 전역 CSS 와 이름이 겹치지 않는지 grep 한다.
|
||||
- **게이트 OPERATOR_VOCAB**: 좌표 출처 문구의 "실측"이 고객 화면에 나가 빌드가 막혔다. 운영 기록(source)은 데이터에만 둔다.
|
||||
- **관리 문장 필터가 필요한 이유**: 원진 원문 32문장 중 14건이 홍보문·정형 합병증 고지·표 조각이었다. 필터(`build_planner_data.mjs` CARE/NOT_CARE) 없이 "병원 안내 기준" 배지를 달면 의료광고 규정에 걸린다. 새 병원에서 제외된 문장 목록을 한 번 눈으로 본다(`status.report.planner`).
|
||||
- **zsh 에서 `set -- $var` 는 단어 분리를 안 한다**: 하네스 생성처럼 반복이 필요하면 파이썬으로 쓴다.
|
||||
- **엠대시**: 커밋 제목에 두 번 들어가 amend 했다. 커밋 메시지도 생성물이다.
|
||||
- **Nominatim 좌표는 임시**: 원진 첫 좌표는 OSM 주소 검색이었고, 관광공사 수집(네이버 지역검색)으로 바뀌었다. 좌표 출처는 `factSheet.geo.source` 에 남는다.
|
||||
|
||||
## 6. 관련 파일 빠른 참조
|
||||
|
||||
- 워커: `workers/supporters-build/run.mjs` (phase 'planner' 307행 부근), 폴러 `poll.mjs`, launchd
|
||||
- 플래너 데이터 생성: `supporters/scripts/build_planner_data.mjs`
|
||||
- 기본표: `supporters/scripts/template/planner/`
|
||||
- 관광 수집: `scripts/fetch_medical_tourism.py` (병원 좌표도 여기서 나온다)
|
||||
- 원문 수집: `supporters/scripts/collect_recovery.mjs`
|
||||
- 테스트: `supporters/scripts/gate/plan.test.mjs`(빈 관광 데이터면 관광 검사 건너뜀), `npm run gate:test`(41건)
|
||||
- 설계 문서(메인): `docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.2.md` §13, HTML판 `docs/prd/…_v0.2.html`
|
||||
- 비교 이미지(메인): `docs/reports/viewclinic/04_supporters/planner/Recovery_Planner_Before_After_2026-09-10.png`
|
||||
@ -1,82 +0,0 @@
|
||||
# 다음 세션 프롬프트 · 2026-09-19 이후
|
||||
|
||||
아래 §0 을 그대로 복사해 새 세션 첫 메시지로 넣으면 된다.
|
||||
|
||||
---
|
||||
|
||||
## §0. 복사해서 붙일 프롬프트
|
||||
|
||||
```
|
||||
INFINITH AI Discovery 제품 개발을 이어서 한다.
|
||||
|
||||
작업 폴더: ~/orca/workspaces/INFINITH/Productize-2 (main)
|
||||
파이프라인: ~/orca/workspaces/INFINITH/Productize (feature/supporters-autobuild)
|
||||
|
||||
시작하기 전에 읽어라:
|
||||
docs/HANDOVER_2026-09-18.md 어제까지의 상태와 결정 대기 목록
|
||||
docs/DESIGN_신청알림메일_v0.1.md 메일 설계 (결정 대기 4건은 §8)
|
||||
|
||||
두 저장소 모두 커밋은 되어 있고 푸시는 안 했다. 시작할 때 git status 와
|
||||
다른 세션이 같은 폴더를 열고 있지 않은지 먼저 확인해라.
|
||||
|
||||
오늘 할 일은 아래에서 고른다. 1번을 먼저 하고, 내가 확인해 준 뒤 2번으로 간다.
|
||||
|
||||
1. 어제 만든 화면 육안 확인
|
||||
npm run dev 로 띄우고 /discovery/viewclinic, /build/jkplastic,
|
||||
/images/jkplastic 를 열어 둬라. 내가 본다.
|
||||
특히 볼 것: What We Will Fix 섹션 문구·버튼 대비, 왼쪽 아래 이전 단계 버튼,
|
||||
사진 확인 썸네일 줄.
|
||||
|
||||
2. 분석 리포트(/report/:id)의 로드맵 타임라인을 진단 리포트로 옮긴다
|
||||
진단의 Action Plan 은 P0/P1/P2 목록만 있고 시간축이 없다.
|
||||
기존 RoadmapTimeline 컴포넌트와 result.actions 의 effort 값을 쓴다.
|
||||
데이터를 지어내지 마라. 없는 값은 확인 대기로 둔다.
|
||||
|
||||
지켜야 할 것:
|
||||
- 서포터즈 빌드는 병원 홈페이지를 고치지 않는다. 진단 실행 항목이
|
||||
빌드로 해결된다고 화면에 쓰지 마라.
|
||||
- 고객 화면에 "결과"라는 말을 쓰지 마라. 빌드가 끝나도 사진 확인과
|
||||
확인 항목이 남아 최종이 아니다.
|
||||
- 텍스트는 src/index.css 의 .ui-ask / .ui-body / .ui-note 역할 토큰을 쓴다.
|
||||
다크 섹션은 .on-dark, 그 안의 흰 카드는 .on-light, 버튼은 .btn-on-dark.
|
||||
- 프로덕션 배포(vercel --prod, 병원 사이트)는 내 확인을 받고 실행한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §1. 선택지 (위 프롬프트에서 2번 대신 고를 수 있는 것)
|
||||
|
||||
우선순위순이 아니라 성격순이다. 무엇을 할지는 haewon이 정한다.
|
||||
|
||||
### A. 분석 리포트 요소 흡수 (어제 정한 방향)
|
||||
|
||||
| 요소 | 난이도 | 비고 |
|
||||
|---|---|---|
|
||||
| 로드맵 타임라인 | 낮음 | 데이터가 이미 있다. 가장 값이 크다 |
|
||||
| 채널 건강도 링 | 중간 | `supporter_builds.report.channels` 를 쓴다 |
|
||||
| KPI 대시보드 | 보류 | 목표치 데이터가 없다. 지어내지 말고 재측정 기준으로 바꾸는 편이 맞다 |
|
||||
|
||||
### B. 메일 붙이기
|
||||
|
||||
결정 4건(설계 §8)이 먼저다. 다만 **순서 4번(모달 문구 수정)은 계정 없이 지금 할 수 있다.** 화면이 약속하는 것과 실제 동작의 어긋남부터 없애는 조치다.
|
||||
|
||||
### C. 파이프라인 잔여
|
||||
|
||||
- `clinicDoctors` 4명 고정을 병원 규모에 맞출지
|
||||
- planner가 tourism 좌표를 쓰도록
|
||||
- 뷰 의료진 id 두 벌 정리 (뷰 재빌드와 묶어서)
|
||||
- `check.mjs --update-layout` 레이아웃 기준선
|
||||
|
||||
### D. 분석 리포트 자체 수정
|
||||
|
||||
히어로 공백 · `NaN년` · 과한 여백. 옮기든 접든 남는 문제다.
|
||||
|
||||
---
|
||||
|
||||
## §2. 다음 측정에서 볼 값
|
||||
|
||||
09-15 실측에서 네이버 AI 브리핑이 120문항 중 119문항에 노출되는데 **논브랜드 83건 중 뷰성형외과 언급은 1건**이었다. 브랜드명을 아는 사람에게만 답변이 나오고, 시술이나 지역으로 찾는 사람에게는 도달하지 못한다.
|
||||
|
||||
서포터즈 사이트가 이 구간을 움직이는지가 제품의 핵심 증명이다. 뷰·원진·오라클이 색인 허용으로 전환된 뒤 같은 120문항을 다시 돌려 비교한다.
|
||||
|
||||
근거: `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md`, `scripts/out/naver_briefing_summary.json`
|
||||
@ -32,18 +32,17 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
|
||||
| 자동 채점·리포트 | `scripts/audit_aeo_geo.py` → `src/data/discovery_<id>.ts` → `/discovery/:id` · `scripts/build_discovery_report.ts` → HTML/PDF | 뷰 AEO 42/GEO 56 · 원진 AEO 15/GEO 45 |
|
||||
| 유튜브·로고 | `collect_youtube.mjs`, 워커 youtube 단계, 고정값 `supporters/briefs/<id>/clinic.json` | 원진 736편 집계, @wjwonjin 고정 |
|
||||
| 이미지 | `collect_images.mjs` + `img_process.py`, 규칙 `docs/INFINITH_Supporters_Image_Rules.md` | 원진 66장 |
|
||||
| 뉴스룸 | `collect_news.mjs`(네이버 뉴스 검색 다중 질의 + 언론보도 게시판 자동 발견) → 워커 news 단계, 게이트 `checkNewsItems` | 뷰 회귀 1,105건(수작업 1,096, 제목 일치 1,033, 종류 분포 기고 768/732 · 보도자료 101/107 · 언급 236/257) · 원진 1,049건(게시판 없음). 09-08 |
|
||||
| 기획·생성·게이트 | `default_briefs.mjs` → `generate_posts.mjs`(gen/) → `gate/rules.mjs`·`check.mjs`(테스트 16/16) | 편당 약 $0.09 |
|
||||
| 워커·큐·승인 UI | `workers/supporters-build/run.mjs`·`poll.mjs`, `supporter_builds`·`supporter_inputs`, `ClinicInputsPanel`(/discovery/:id·/supporters/:id) | 라이브. **폴러 설치됨(2026-09-08, Mac launchd 5분, 로그 `~/supporters-builds/poll.log`)**. Mac이 켜져 있어야 돈다 |
|
||||
| 워커·큐·승인 UI | `workers/supporters-build/run.mjs`·`poll.mjs`, `supporter_builds`·`supporter_inputs`, `ClinicInputsPanel`(/discovery/:id·/supporters/:id) | 라이브. 폴러는 미설치 |
|
||||
| 입력 반영 | `apply_inputs.mjs` (검토·승인 → frontmatter, 자유 답변 → clinicAnswers 근거 [A]) | dry-run 검증 |
|
||||
| 미리보기 | supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex) | 배포됨 |
|
||||
|
||||
## 2. 남은 일 (권장 순서)
|
||||
|
||||
1. ~~뉴스룸 단계~~ **완료(09-08)**: `collect_news.mjs` + 워커 `news` 단계(youtube 뒤, images 앞). 관련성 = 제목·요약에 병원 이름(v2 §8 반영). 제외 규칙은 게이트와 같은 정규식(`BANNED_NEWS_TITLE` 전후 사진·비교·후기·변신, `NEGATIVE_NEWS` 규제·사건, "무사고"는 통과). 뷰 수작업 news.json에서 렛미인 "변신" 기사 41건이 이 규칙으로 빠졌다(`excludedByGate`에 목록). 원진 홈페이지에는 언론 게시판이 없어 네이버만. `build_news.py`는 참조용으로 남김. **남은 판단(§3-6)**: "언론 언급"(병원 이름이 요약에만 나오는 기사, 원진 514건)을 뉴스룸에 보일지.
|
||||
1. **뉴스룸 단계**: `supporters/scripts/build_news.py`(홈페이지 언론 게시판 + 네이버 뉴스 API, 부정 기사 제외)를 워커 단계로. 원진은 게시판 위치부터 확인. 병원 무관 기사 본문 관련성 필터(v2 §8 미반영) 포함.
|
||||
2. **감사 재실행 단계(Phase 9)**: 색인 허용 빌드를 로컬에서 `audit_aeo_geo.py`로 다시 채점해 공식 vs 서포터즈 비교표를 리포트 카드에. `scripts/build_supporters_audit_xlsx.py` 로직 재사용.
|
||||
3. **수동 실측 항목 채우기(원진)**: 강남언니 2500·바비톡 3143·GBP·네이버 플레이스 NAP 대조(D1·D2·D4·D5). 실측하면 GEO 점수가 바뀐다. 리포트 재생성은 `npx esbuild scripts/build_discovery_report.ts ... && node report.mjs --id wonjin ...` + Chrome PDF (v2 §11 명령 참조).
|
||||
4. ~~워커 상시 실행~~ **완료(09-08)**: Mac launchd 폴러 설치(haewon 지시). 해제 `launchctl bootout gui/$(id -u)/com.infinith.supporters-poll`. Trigger.dev 이관은 월 수십 건이 되면. 실제 랜딩 신청 1건으로 큐 → 빌드 완주를 아직 못 봤다(큐가 비어 있었음). 첫 신청이 오면 `poll.log`를 본다.
|
||||
4. **워커 상시 실행**: Mac launchd 폴러 설치(`workers/supporters-build/launchd/`) 또는 Trigger.dev 이관(가입·키만 haewon). 랜딩 URL 신청 → 큐 → 자동 빌드가 이어지려면 이게 필요하다.
|
||||
5. **병원 송부 전 사람 확인**: 원진 이미지 갤러리 육안 확인(얼굴·신체 검출 없음), "확인 필요" 채널 12건 판정, 쇼츠 정리 답(사람이 영상 보고 한 문단).
|
||||
6. **검토 파이프라인**: 병원이 `/supporters/wonjin` 폼에 입력 → `run.mjs --from data --from-supabase --deploy --build-id 87cb75a2-2037-4cbb-ae27-c06e39f022e8` 재빌드. 이 재빌드도 폴러가 자동으로 집게 하려면 supporter_inputs insert 트리거를 큐에 연결.
|
||||
7. **뷰성형외과 자동 빌드 미리보기**(supporters-viewclinic)를 수작업 샘플(view-supporters-sample) 수준으로: 뷰는 자막·쇼츠 답이 사람 작업이라 격차가 있다. 자막 파이프라인(v1 §6, IP 차단 대비)이 붙어야 좁혀진다.
|
||||
@ -53,10 +52,9 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
|
||||
|
||||
1. 원진 "확인 필요" 채널 12건 중 병원 것(영문 채널 Wonjin Plastic Surgery Korea, WONJIN_OFFICIAL 등)을 사이트에 연결할지.
|
||||
2. 지원 관계 고지 문구(site.sponsorNotice)를 켤지와 문안. 켜면 글 첫 부분에 노출되고 게이트가 필수 검사로 바뀐다(v2 §12-2).
|
||||
3. ~~워커 상시 실행 위치~~ 결정됨(09-08): 파일럿은 Mac 폴러. Trigger.dev는 정착 단계에.
|
||||
3. 워커 상시 실행 위치(Mac 폴러 설치 vs Trigger.dev 가입).
|
||||
4. 원진에 미리보기·리포트 송부 시점과 병원 입력 담당자.
|
||||
5. 경영진 미결 그대로: 도메인(별도 vs 서브도메인), POC 범위·가격, 서포터즈 실존 개인 모집.
|
||||
6. 뉴스룸 "언론 언급" 종류(mention)를 보일지. 병원이 알린 기사·원장 기고만 남기면 원진은 1,049건 중 535건이 된다. 보이면 관계사(WJ코스메틱)·투자·대학 기사가 섞인다. 기본값은 지금처럼 보이되 뒤로 정렬.
|
||||
|
||||
## 4. 이번 세션들(09-07 세션 3 ~ 09-08)에서 확정한 것
|
||||
|
||||
@ -71,9 +69,6 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
|
||||
- 모델 근거 대조기는 "근거에 있다"고 적으면서 플래그를 세운다. 항목별 supported 판정으로 거른다.
|
||||
- 게이트는 영상 제목도 본다. "부작용은 없을까?" 제목이 홈·영상 페이지에서 배포를 막았다. 수집 단계에서 같은 규칙으로 거른다.
|
||||
- 인터넷이 끊기면 생성 단계가 fetch failed 로 멈춘다. `--from generate` 로 재개.
|
||||
- JS 정규식 `\W`·`\w`·`\b`는 한글을 문자로 보지 않는다. Python 스크립트를 옮길 때 제목 정규화에 `\W`를 쓰면 한글 제목이 전부 같은 키로 뭉쳐 중복으로 버려진다(뉴스 수집기에서 445건 중 397건이 사라졌다). `[^\p{L}\p{N}]`에 `u` 플래그.
|
||||
- 워드프레스 게시판의 `article:published_time` 메타는 글이 아니라 게시판 페이지 생성일이다. 글 날짜는 "작성일" 셀에서 읽고 메타는 마지막 후보로.
|
||||
- 네이버 검색 API는 질의당 최근 1,000건, 초당 10건. 여러 질의(상호·상호 원장·상호+의료진·상호+주제어)로 넓히고 429는 물러났다 다시.
|
||||
- `node --experimental-strip-types` 는 확장자 없는 import 를 못 푼다. TS 스크립트는 esbuild 번들로 돌린다.
|
||||
|
||||
## 6. 자주 쓰는 명령
|
||||
@ -85,8 +80,6 @@ node workers/supporters-build/run.mjs --clinic <id> --url <홈> --name <상호>
|
||||
node workers/supporters-build/run.mjs --clinic <id> --url <홈> --work ~/supporters-builds/<id> --from <단계> --deploy --build-id <id>
|
||||
# 큐 비우기 / 상주
|
||||
node workers/supporters-build/poll.mjs [--loop 300]
|
||||
# 뉴스룸만 다시 (supporters/). 게시판은 자동 발견, 없으면 --board 또는 briefs/<id>/clinic.json newsBoard
|
||||
node scripts/collect_news.mjs --clinic <id> --url <홈> --name <상호> --site ~/supporters-builds/<id>/site
|
||||
# 게이트 테스트 · 뷰 정답지 회귀 · 템플릿 내보내기 (supporters/)
|
||||
npm run gate:test · npm run generate:regress · npm run export:template
|
||||
# 진단 리포트 HTML/PDF
|
||||
|
||||
@ -1,318 +0,0 @@
|
||||
# 뷰성형외과 MOU · POC 범위 검토 보고서
|
||||
|
||||
작성 2026-09-15(화) · INFINITH AI Discovery · 대표 보고용 초안
|
||||
입력: 2026-09-15 회의록(황본부장·감본부장), Global Medical Hub PRD v0.2(2026-09-09), 뷰성형외과 POC 제안서(2026-09-01), 서포터즈 색인 보류 사유 보고(2026-09-14), 답변엔진 확장 검토 보고서(2026-09-15), 웹 조사 2건(2026-09-15)
|
||||
법 해석은 자문이 아니며, 계약·등록 전에 변호사 확인이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론
|
||||
|
||||
1. **이번 MOU·POC 범위는 서포터즈 사이트와 AEO/GEO 진단·측정까지로 한정할 것을 권고합니다.** 글로벌 의료관광 플랫폼(다병원 허브, 상담·예약 중개, 숙박·관광 연계)은 이번 범위에서 명시적으로 제외합니다.
|
||||
2. **플랫폼 사업은 현재 조직(개발팀장 1명, 주니어 개발자 5명, 운영팀 없음)으로 가능하지 않다고 판단합니다.** 개발보다 운영과 수요 확보가 병목입니다.
|
||||
- 선두 사업자 강남언니는 2025년 광고선전비로 343억 원을 썼습니다.
|
||||
- 오프라인 통역센터와 8개 언어 통역을 운영하고 있습니다.
|
||||
- 병원 제휴를 수천 곳 단위로 확보했습니다.
|
||||
3. **PRD v0.2가 차별점으로 삼은 "기존 앱은 정보를 앱 안에 가둔다"는 전제는 일부만 맞습니다.**
|
||||
- 크리에이트립과 여신티켓은 AI 크롤러 허용과 llms.txt, 다국어 블로그를 이미 운영합니다.
|
||||
- 강남언니도 AI 검색봇은 허용하고, 후기 페이지만 막고 있습니다.
|
||||
4. **MOU의 목적은 사례 확보와 홍보입니다.** 비용을 받지 않는 POC라면 우리가 제공할 범위, 뷰성형외과가 제공할 것, 끝나는 날짜를 문서로 고정해야 합니다. 범위를 열어 둔 미팅은 무상 플랫폼 POC 요청으로 이어질 가능성이 높습니다.
|
||||
5. **미팅 전에 이 문서의 7장 안건표로 대표 결정을 먼저 받는 순서를 권고합니다.**
|
||||
|
||||
---
|
||||
|
||||
## 1. 회의록 검토
|
||||
|
||||
### 1-1. 회의에서 확인된 사항
|
||||
|
||||
| 구분 | 내용 | 담당 |
|
||||
|---|---|---|
|
||||
| 뷰성형외과 내부 진행 | 실무진이 원장에게 보고를 마쳤고, 진행 결정이 났습니다 | 뷰성형외과 |
|
||||
| 진행 순서 | MOU → 홍보 기사 → 프로젝트 런칭 | 양사 |
|
||||
| 순서에 대한 이견 | 대표는 MOU 우선을 원합니다. 뷰성형외과는 범위를 조금이라도 구체화한 뒤 MOU를 하자는 입장입니다 | 황본부장 조율 |
|
||||
| 뷰성형외과 쪽 키 | 지난 미팅은 마케팅 담당자 3명이 나왔고, 보고 이후 원장 쪽 방향(B2C 의료관광 포털)이 무게를 갖게 됐습니다 | |
|
||||
| 감본부장 입장 | 비용 없는 플랫폼 POC라면 미팅에 가지 않고, 문서로 범위를 먼저 정리합니다. 유료 논의라면 참석합니다 | 감본부장 |
|
||||
| 범위 문서 | 향후 1~2개월에 무엇을 할지 구체화한 문서를 대표에게 보고한 뒤, 그 문서로 뷰성형외과와 협의합니다 | 감본부장 |
|
||||
| 황본부장 입장 | 서포터즈는 기본 범위로 보고, 메디컬·버티컬 포털은 결정되지 않은 큰 그림입니다. 1단계에서 큰 그림을 그릴 필요는 없다는 데 동의했습니다 | 황본부장 |
|
||||
| 배포 현황 | webforai.kr 랜딩과 서포터즈 3곳(서브도메인)을 배포했고, 구글·네이버 서치콘솔에 등록했습니다. 색인은 보류 중입니다 | 감본부장 |
|
||||
| 자동화 수준 | 뷰성형외과 품질을 100으로 보면, 다른 병원은 URL만으로 60~70% 수준입니다. 홈페이지 콘텐츠가 적은 병원은 결과가 부실합니다 | 감본부장, 금요일(9/18) 연구소 회의에서 시연 |
|
||||
| 공유 | 사이트 URL을 management 방에 공유합니다 | 감본부장 |
|
||||
|
||||
### 1-2. 문서에 옮기기 전에 바로잡을 발언
|
||||
|
||||
| 회의 발언 | 확인 결과 | 문서에 쓸 표현 |
|
||||
|---|---|---|
|
||||
| "모델 2개. 클로드·제미나이·구글 AI 오버뷰를 추가하면 한 번에 몇천 원이 몇만 원 단위로 오른다" | 비용 방향은 맞습니다. 120문항 기준 추정치(후처리 포함, 중앙값)는 아래와 같습니다. 그런데 **비용만의 문제가 아닙니다.** Gemini 검색 그라운딩 약관은 결과의 분석·자동 수집을 금지합니다. Google AI Overview는 공식 API가 없어 제3자 수집 방식입니다 | "현재 2개 엔진. Claude는 추가 가능하고, Gemini·AI Overview는 약관·수집 원칙 확인이 먼저입니다" |
|
||||
| "의료관광법상 기관 검증을 통하지 않은 실물 인물 콘텐츠를 배포하지 못해 색인을 안 했다" | 이 내용의 조문은 확인되지 않았습니다. 9/14 사유 보고서 기준 보류 사유는 두 가지이고, 법률 확인 항목이 따로 두 가지 있습니다 | "병원 승인 글 0편, 지원 관계 고지 문구 미확정. 법률 확인 2건(의료광고 사전심의, 유치업 등록 필요 여부)" |
|
||||
| "강남언니·바비톡은 사용자 600만~천만, 병원 3천~5천 곳 제휴" | 확인된 값은 아래와 같습니다. 방향은 맞지만 숫자는 출처가 있는 값으로 씁니다 | 아래 3-2 표 수치 |
|
||||
|
||||
**답변엔진 추가 시 비용 추정 (120문항, 후처리 포함, 중앙값)**
|
||||
|
||||
| 구성 | 1회 측정 | 5회 반복 |
|
||||
|---|---|---|
|
||||
| 현행 2개 (ChatGPT, Perplexity) | 약 ₩11,440 | 약 ₩57,200 |
|
||||
| + Claude | 약 ₩29,150 | 약 ₩145,740 |
|
||||
| + Claude, Gemini, Google AI Mode | 약 ₩35,790 | 약 ₩178,950 |
|
||||
|
||||
**강남언니·바비톡 확인 수치**
|
||||
|
||||
| 항목 | 확인된 값 |
|
||||
|---|---|
|
||||
| 강남언니 해외 | 누적회원 148만, MAU 23만(2025-09) |
|
||||
| 강남언니 일본 | 사용자 160만, 제휴병원 1,500곳(2025-10) |
|
||||
| 바비톡 | 병원 3,300곳·1,000만 다운로드는 채용 플랫폼 소개문에만 있고 1차 출처 미확인 |
|
||||
|
||||
### 1-3. 회의록 표기 정리 (음성 인식 오류)
|
||||
|
||||
| 회의록 표기 | 바른 표기 |
|
||||
|---|---|
|
||||
| 비성형외과, 비서약과, 뷰 성향 | 뷰성형외과 |
|
||||
| 김수원 원장 | 최순우 원장(대표자, 팩트 시트 기준) |
|
||||
| 데미나이 | Gemini |
|
||||
| AOG로, AO, AO 최적화 | AEO·GEO |
|
||||
| 인류관광 | 의료관광 |
|
||||
| 웹 포 AI | webforai.kr |
|
||||
| 메트코 | 확인 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Medical Hub PRD v0.2 검토
|
||||
|
||||
### 2-1. PRD가 제안한 것
|
||||
|
||||
- **3층 구조입니다.**
|
||||
- 1층 정보·발견: 다국어 병원 원문 허브
|
||||
- 2층 상담·예약: 유치업 등록 후 운영
|
||||
- 3층 회복기 여정: 숙박·식사·관광 제휴 링크
|
||||
- **구조:** AIO2O 단독 소유, 뷰성형외과는 앵커 파트너(첫 병원, 병원 2~4곳 소개, 의료 자문)입니다.
|
||||
- **1단계(8~12주) 게이트:** 병원 4곳 개설자 승인, 외국인 질문 뱅크 인용 기준선 확보, 상담 신청 월 10건.
|
||||
|
||||
### 2-2. 지금도 유효한 판단
|
||||
|
||||
- 파이프라인 IP는 AIO2O가 100% 가집니다. 병원 자회사나 합작법인 구조는 중립성과 IP 측면에서 불리합니다.
|
||||
- 1단계는 수수료를 받지 않고 병원에 직접 연결합니다. 대가를 받는 순간 유치업 등록 대상이 됩니다.
|
||||
- 후기, 전후 사진, 병원 비교, 할인으로 설득하는 기능은 만들지 않습니다(의료법 56조).
|
||||
|
||||
### 2-3. PRD의 공백과 보완할 점
|
||||
|
||||
| 공백 | 내용 | 보완 |
|
||||
|---|---|---|
|
||||
| 운영 인력 가정 없음 | "비개발자 운영자가 운영"을 전제로 했지만, 현재 운영팀이 없습니다. 게이트 실패 처리, 이미지 육안 확인, 채널 판정, 정정 접수, 병원 응대, 상담 신청 전달이 모두 사람 업무입니다 | 운영 인원을 선행 조건으로 명시 |
|
||||
| 수요(외국인 환자) 확보 계획 없음 | AI 인용만으로 유입을 기대합니다. 경쟁 앱은 광고비와 오프라인 센터로 수요를 확보합니다 | 유입 채널과 예산 추정 추가 |
|
||||
| 경쟁 전제가 일부 틀림 | "기존 앱은 정보를 앱 안에 가둔다"고 봤지만, 크리에이트립·여신티켓은 AI 크롤러 허용, llms.txt, 영·일·중 블로그를 운영합니다. BeautsGO는 AI 에이전트용 예약 스킬을 배포했습니다 | 차별점을 "병원 승인 원문 + 실측 측정"으로 좁혀 재정의 |
|
||||
| 1단계 개발량 | PRD §06 기준 1단계에 필요한 신규 7건과 부분 2건입니다. 신규: 외국인 질문 뱅크, 외국어 게이트, 다국어 라우팅·상담 폼, 다병원 허브 도메인, 계정·역할·권한, 상담 신청·알림, 측정 인프라. 부분: 운영자 콘솔, 상시 워커 | 현재 인력으로 8~12주는 낙관적. 4장 참고 |
|
||||
| 규제 변경 미반영 | 2026-05-15 시행규칙 개정으로 여행업자 자본금 5천만 원 예외가 삭제돼 유치업 등록은 자본금·보증보험 각 1억 원입니다. 등록 후 2년 안에 유치 실적이 없으면 등록취소 사유입니다. 2026-05-12부터 병원이 유치 경로를 보고합니다 | §05 표 갱신 |
|
||||
| 민감정보 리스크 | 2026-09-04~05 강남언니에서 22만 명(해외 약 6만 명) 민감정보 유출이 있었습니다. 상담 신청 폼만 받아도 건강정보 처리자가 됩니다 | 1단계 상담 폼 최소 수집, 처리방침·보안 체계 선행 |
|
||||
| 앵커 소개 역량 미확인 | 원장의 병원 네트워크가 명단·수·관계 단계로 확인되지 않았습니다 | MOU 전 확인 질문으로 유지 |
|
||||
| 브랜드 리스크 | 앵커 병원의 2026-07 공정위 시정명령 이력이 허브 브랜드에 붙을 수 있습니다 | MOU 홍보 기사 문안에서 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 글로벌 의료관광 플랫폼 딥리서치
|
||||
|
||||
### 3-1. 시장
|
||||
|
||||
| 항목 | 수치 | 출처 |
|
||||
|---|---|---|
|
||||
| 외국인 환자 | 2024년 117만 명 → 2025년 201만 명 | 보건복지부 2025-04, 2026-04 |
|
||||
| 2025 진료과 | 피부과 62.9%, 성형외과 11.2% | 보건복지부 2026-04 |
|
||||
| 2025 국적 | 중국 30.8%, 일본 29.8%, 대만 9.2%, 미국 8.6% | 보건복지부 2026-04 |
|
||||
| 의원급·서울 비중 | 87.7%, 87.2% | 보건복지부 2026-04 |
|
||||
| 2026 상반기 외국인 카드 결제 | 1조4,203억 원(전년 대비 +38.6%) | 하나카드 데이터, 2026-08-27 |
|
||||
| 미용성형 부가세 환급 | 2025-12-31 종료. 재도입 법안이 국회 소위에 있음. 성형외과의사회는 종료 후 20~30% 감소를 주장(통계 아님) | 2025-07-31, 2026-08-14 |
|
||||
|
||||
시장은 커지고 있습니다. 다만 성장의 대부분은 피부과이고, 성형외과 비중은 11%입니다.
|
||||
|
||||
### 3-2. 경쟁 플랫폼
|
||||
|
||||
| 플랫폼 | 외국인 서비스 현황 | 규모·실적 |
|
||||
|---|---|---|
|
||||
| 강남언니 | 일본 2019, 영어 2023-12, 태국 2024 말, 중국·대만 2025-10. 6개 언어. 2026-03 논현동 오프라인 센터, 2026-05 8개 언어 통역(환자 무료). 일본 앱 예약 연 약 55만 건 | 해외 누적회원 148만, 해외 MAU 23만(2025-09). 매출 2025년 752억, 영업이익 75억(전년 대비 -41.3%), 광고선전비 343억(+100.6%) |
|
||||
| 바비톡 | 2025-08-25 일본어 웹. 영어·중국어는 시범 | 2025 매출 367.7억, 영업이익 73억(재무정보 사이트). 해외 사용자 미공개 |
|
||||
| 크리에이트립 | 12개 언어, 숙소 예약·통역 버디. **유치업 등록 사업자** | MAU 160만, 제휴 약 1,200곳. 2025 거래의 51%가 뷰티·메디컬. 누적 투자 181억 원 이상 |
|
||||
| 여신티켓 | 2024-01 영·일·중 글로벌 앱. 부작용 보험 | 글로벌 MAU 4만 이상, 국내 병원 약 5,000곳 |
|
||||
| BeautsGO | 5개 언어, 숙박 지원, AI 에이전트용 예약 스킬 배포(2026-03) | 제휴 1,300곳 이상 |
|
||||
| 메디컬코리아(KHIDI) | 공공 포털, 유치기관 조회, 영·일문 | |
|
||||
|
||||
### 3-3. 이 사업에 필요한 규제 요건 (변호사 확인 필요)
|
||||
|
||||
| 요건 | 내용 |
|
||||
|---|---|
|
||||
| 유치업 등록 | 의료해외진출법 제6조, 시행규칙 제4조 기준입니다. 자본금 1억 원, 보증보험 1억 원, 국내 사무소가 필요하고 3년마다 갱신합니다. 등록 후 2년간 실적이 없으면 등록취소 사유입니다 |
|
||||
| 미등록 유치 | 3년 이하 징역 또는 3천만 원 이하 벌금 |
|
||||
| 수수료 상한 | 의원 30%, 병원·종합병원 20%, 상급종합병원 15% |
|
||||
| 환자 알선 금지 | 의료법 27조 3항. 강남언니는 진료비 비율 수수료 모델로 1·2심 모두 유죄(징역 8월, 집행유예 2년)였고, 이후 광고형으로 전환했습니다 |
|
||||
| 외국인 유치 광고 | 의료법 56조 2항 12호가 국내 광고를 금지합니다. 의료해외진출법 제15조는 공항·보세판매장 등 지정 장소만 허용합니다. 온라인 다국어 매체에 어떻게 적용되는지는 미확인입니다 |
|
||||
| 사전심의 매체 | 일평균 이용자 10만 명 이상 인터넷매체 |
|
||||
| 민감정보·국외 이전 | 개인정보보호법 제23조, 제28조의8에 따른 별도 동의 |
|
||||
| 숙박·관광 직접 판매 | 여행업 등록(종합 5천만 원, 국내외 3천만 원) |
|
||||
|
||||
### 3-4. 경제성
|
||||
|
||||
| 항목 | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| 강남언니 수익 모델 | 이벤트 페이지 조회당 과금(CPV) | 상담·내원과 무관하게 과금 |
|
||||
| 의료관광 예약 1건당 고객 확보 비용 | 600~1,800달러 이상 | 업체 블로그 수치, 실측 아님 |
|
||||
| 병원당 월 광고비, 운영 인력 구성 | 공신력 있는 공개 자료 없음 | |
|
||||
|
||||
---
|
||||
|
||||
## 4. 현재 조직으로 가능한 사업인가
|
||||
|
||||
### 4-1. 플랫폼이 요구하는 역량과 보유 역량
|
||||
|
||||
| 역량 | 플랫폼에 필요한 수준 | 현재 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 병원 원문 생성·발행 | 병원당 자동 온보딩, 다국어 | 서포터즈 워커 동작. URL만으로 60~70% 품질. 병원 승인 0편 | **보유(개선 필요)** |
|
||||
| 다병원 허브 개발 | 계정·권한, 허브 도메인, 다국어 라우팅, 상담 폼, 운영 콘솔 | 개발팀장 1명, 주니어 5명. 기존 제품 유지와 병행 | 1층만 가능. 기간은 PRD보다 길어짐 |
|
||||
| 운영 | 병원 응대, 콘텐츠 검수, 이미지 육안 확인, 정정, 게이트 실패 처리 | **운영팀 없음.** 현재 감본부장 1인이 운영 | **부족** |
|
||||
| 외국인 고객 응대 | 영·일·중 상담, 예약 조율, 통역, 분쟁 대응(경쟁사는 24시간 채팅, 오프라인 센터) | 없음 | **불가** |
|
||||
| 수요 확보 | 해외 광고, 인플루언서, 현지 법인(강남언니 일본법인 순손실 46억) | 없음. AI 인용에만 의존 | **불가** |
|
||||
| 병원 공급 확보 | 병원 영업 조직(경쟁사 수천 곳) | 앵커 소개에 의존, 명단 미확인 | **부족** |
|
||||
| 규제·자본 | 유치업 법인, 자본금·보증보험 2억 원, 2년 내 실적, 민감정보 보안 | 없음 | 2층 이상 **불가** |
|
||||
|
||||
### 4-2. 판단
|
||||
|
||||
- **2층(상담·예약)과 3층(여정)은 현재 조직으로 할 수 없습니다.** 개발 인력의 문제가 아니라, 고객 응대·병원 영업·자본·규제 대응 조직이 없기 때문입니다.
|
||||
- **1층(정보 허브)만 따로 만드는 것은 기술적으로 가능합니다.** 그러나 운영 인력과 유입 채널이 없으면, 경쟁 앱이 이미 가진 다국어 정보와 차별점이 약합니다. 1층은 서포터즈 사이트를 병원 수만큼 늘린 것과 결과가 비슷합니다.
|
||||
- **기회비용이 큽니다.** 같은 인력으로 할 일이 있습니다.
|
||||
- AI Discovery 진단 판매
|
||||
- 서포터즈 자동화 품질을 60~70%에서 올리기
|
||||
- 병원 승인 흐름 만들기
|
||||
- 측정 리포트 자동화
|
||||
|
||||
이 일들은 유료 전환과 직접 이어집니다.
|
||||
- **따라서 결론은 "서포터즈 사이트까지"입니다.** 회의에서 나온 감본부장 판단과 같습니다.
|
||||
|
||||
### 4-3. 플랫폼을 다시 검토할 조건 (제안)
|
||||
|
||||
아래 조건을 모두 채우면 PRD 1층부터 재검토합니다.
|
||||
|
||||
1. 서포터즈를 유료로 계약한 병원 10곳 이상
|
||||
2. 병원 승인 글 기준으로 외국인 질문 뱅크 인용률 개선을 2곳 이상에서 확인
|
||||
3. 운영 인력 2명 이상 확보(병원 응대·콘텐츠 검수)
|
||||
4. 유치업 등록 사업자와 제휴해 상담·예약을 위탁하는 구조(직접 등록하지 않는 방식) 확인
|
||||
5. 병원 소개 명단의 계약 가능성 확인
|
||||
|
||||
---
|
||||
|
||||
## 5. 권고 범위: MOU · POC (8주)
|
||||
|
||||
### 5-1. 포함
|
||||
|
||||
| 번호 | 항목 | 산출물 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1 | 뷰성형외과 서포터즈 사이트 운영 (국문·영문) | viewpl.webforai.kr. 글, 영상, 뉴스룸, 방문 안내, 회복 일정 플래너, 주변 안내 | 배포 완료. 색인은 병원 승인 후 |
|
||||
| 2 | 병원 승인 흐름 | 원장 의학 검토·개설자 게시 승인 기록, 지원 관계 고지 문구 확정 | 색인 전환의 조건 |
|
||||
| 3 | 색인 전환 | 승인 글만 공개하는 재배포, IndexNow·서치콘솔 제출 | 법률 확인 2건 결과에 따름 |
|
||||
| 4 | AEO/GEO 기준선과 재측정 | 국문 120문항·영문 60문항 × ChatGPT·Perplexity. 착수 시·4주·8주 3회, 5회 반복 | 언급률과 신뢰구간으로만 보고 |
|
||||
| 5 | 공식 홈페이지 개선 가이드 | 9/1 POC 제안서 P0·P1 항목(스키마, NAP, 정의문, FAQ)의 적용 명세 | 적용은 병원 웹 담당. 우리가 직접 수정할지는 7장 안건 |
|
||||
| 6 | 측정 연결 | GA4 AI 유입 채널그룹, 서치콘솔, 가능하면 서버 로그 | 병원 계정 권한 필요 |
|
||||
| 7 | 결과 보고서와 홍보용 사례 | 8주 결과 보고서 1부, 사례 요약 1쪽 | MOU 홍보와 타 병원 영업에 사용 |
|
||||
|
||||
### 5-2. 제외 (MOU 문구에 명시)
|
||||
|
||||
- 다병원 의료관광 포털, 병원 비교·목록 서비스
|
||||
- 외국인 환자 상담·예약 중개, 통역, 픽업, 결제, 수수료
|
||||
- 숙박·관광 상품 판매
|
||||
- 앱 개발
|
||||
- 답변엔진 추가(Claude, Gemini, Google AI Overview). 유료 전환 시 옵션으로 제안
|
||||
- 공식 홈페이지 직접 개발·유지보수(5-1의 5번 결정에 따름)
|
||||
|
||||
### 5-3. 뷰성형외과가 제공할 것
|
||||
|
||||
| 항목 | 이유 |
|
||||
|---|---|
|
||||
| 원장 의학 검토와 개설자 게시 승인(주기·담당자 지정) | 승인 없이는 색인을 열 수 없습니다 |
|
||||
| 지원 관계 고지 문구 확정 | 추천·보증 심사지침 |
|
||||
| 홈페이지·영상·이미지 자산 사용 허락 범위 | 저작권·의료광고 책임 |
|
||||
| GA4·서치콘솔 권한 | 측정 항목 4개가 계정 없이는 미검증입니다 |
|
||||
| 사례 공개와 홍보 기사 사용 동의(병원명, 수치 공개 범위) | MOU의 목적 |
|
||||
| (선택) 소개 가능한 병원 명단 | 향후 확장 검토용. 이번 범위의 의무 아님 |
|
||||
|
||||
### 5-4. 비용
|
||||
|
||||
- **우리 쪽 직접 비용은 작습니다.** 측정 3회 × 5회 반복 기준으로 약 17만 원입니다(현행 2개 엔진). 사이트 호스팅과 빌드 비용은 병원당 소액입니다.
|
||||
- **실제 비용은 인력 시간입니다.** 병원 응대, 승인 수집, 보고서 작성에 감본부장 시간이 들어갑니다. POC 기간을 8주로 고정하는 이유입니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 원장의 포털 제안에 대한 답변 문안 (안)
|
||||
|
||||
> 원장님께서 말씀하신 글로벌 의료관광 포털 방향에 공감합니다. 다만 외국인 환자 상담·예약 중개는 유치업 등록과 다국어 응대 조직이 필요한 사업이라, 이번 협약에서는 뷰성형외과 서포터즈 사이트와 AI 답변 노출 측정으로 효과를 먼저 확인하려고 합니다. 8주 결과가 나오고 참여 병원이 늘어나면, 포털 단계는 그 결과를 근거로 다시 제안드리겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 대표 보고 시 결정 요청 안건
|
||||
|
||||
| 번호 | 결정 사항 | 권고안 | 대안 |
|
||||
|---|---|---|---|
|
||||
| 1 | MOU·POC 범위 | 서포터즈 사이트 + 진단·측정(5-1) | PRD 1층 포함 |
|
||||
| 2 | 플랫폼 사업 추진 여부 | 이번에는 제외, 4-3 조건 충족 시 재검토 | PRD 1단계 착수 |
|
||||
| 3 | POC 비용 | 무상, 8주 고정, 홍보·사례 사용권을 대가로 | 유료 POC(최소 금액 제시) |
|
||||
| 4 | 공식 홈페이지 개선 | 가이드 제공, 적용은 병원 | 우리가 직접 수정(추가 인력 필요) |
|
||||
| 5 | 답변엔진 추가 | 현행 2개 유지, Claude는 유료 옵션 | Claude 포함 |
|
||||
| 6 | 색인 전환 전 법률 확인 | 사전심의·유치업 해당 여부 변호사 확인 후 전환 | 승인만 받고 전환 |
|
||||
| 7 | MOU 순서 | 범위 문서 합의 → MOU → 홍보 기사 | MOU 먼저, 범위는 부속서 |
|
||||
| 8 | 미팅 참석 | 문서 합의 후 실행 단계에서 참석 | 이번 조율 미팅부터 참석 |
|
||||
|
||||
## 8. 뷰성형외과 미팅에서 확인할 질문
|
||||
|
||||
1. MOU로 원하는 결과는 무엇입니까? 홍보, 외국인 환자 유입, 포털 참여 중 우선순위를 확인합니다.
|
||||
2. 원장 검토·승인은 누가, 어떤 주기로 할 수 있습니까?
|
||||
3. 홍보 기사에 병원명과 수치를 어디까지 공개할 수 있습니까?
|
||||
4. 공식 홈페이지 수정 권한과 담당자는 누구입니까?
|
||||
5. 소개 가능한 병원 명단, 병원 수, 현재 관계 단계는 어떻습니까?
|
||||
6. 포털을 원하신다면 비용 분담이나 투자 의향이 있습니까?
|
||||
|
||||
## 9. 다음 액션
|
||||
|
||||
| 날짜 | 할 일 | 담당 |
|
||||
|---|---|---|
|
||||
| 9/15(화) | 사이트 URL management 방 공유 | 감본부장 |
|
||||
| 9/16(수) | 이 문서 대표 보고, 7장 안건 결정 | 감본부장 → 대표 |
|
||||
| 9/17(목) | 결정안으로 범위 문서 확정, 황본부장 통해 뷰성형외과 전달 | 감본부장, 황본부장 |
|
||||
| 9/18(금) | 연구소 회의: 병원별 자동화 수준 시연(60~70% 구간 사례 포함) | 감본부장 |
|
||||
| 합의 후 | 변호사 확인 2건 요청, 병원 승인 요청서 발송 | 감본부장 |
|
||||
|
||||
---
|
||||
|
||||
## 부록. 출처
|
||||
|
||||
**내부 문서**
|
||||
- `docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html` (2026-09-09)
|
||||
- `docs/reports/viewclinic/02_poc_proposal/Viewclinic_POC_Proposal.pptx` (2026-09-01)
|
||||
- `docs/business/INFINITH_서포터즈_색인보류_사유보고_2026-09-14.docx` (메인 체크아웃)
|
||||
- `docs/reports/answer_engines/INFINITH_답변엔진_확장검토_2026-09-15.docx`
|
||||
|
||||
**시장·규제**
|
||||
- 보건복지부 2025 외국인환자 유치 실적: https://www.mohw.go.kr/board.es?mid=a10503010100&bid=0027&act=view&list_no=1490280 (2026-04)
|
||||
- 보건복지부 2024 실적: https://www.mohw.go.kr/board.es?mid=a10503000000&bid=0027&act=view&list_no=1485191 (2025-04)
|
||||
- 2026 상반기 외국인 카드 결제: https://www.medicaldaily.co.kr/post/22664 (2026-08-27)
|
||||
- 부가세 환급 종료 후 동향: https://www.hankyung.com/article/2026081424556 (2026-08-14)
|
||||
- 유치업 수수료 고시 제2024-79호: https://www.mohw.go.kr/boardDownload.es?bid=0026&list_no=1481343&seq=1 (2024-05-08. 현행 제2025-72호 본문은 미대조)
|
||||
- 의료해외진출법·시행령·시행규칙, 의료법, 개인정보보호법, 관광진흥법 시행령: 국가법령정보센터 원문(law.go.kr)
|
||||
- 강남언니 의료법 위반 1심: https://www.fnnews.com/news/202201271700056405 (2022-01-27)
|
||||
- 강남언니 의료법 위반 2심: https://www.fnnews.com/news/202307061827216638 (2023-07-06)
|
||||
- 대법원 2018도20928: https://www.scourt.go.kr/portal/news/NewsViewAction.work?seqnum=6637&gubun=4
|
||||
- 미등록 유치 병원 등록취소 소송: https://www.doctorsnews.co.kr/news/articleView.html?idxno=165135 (2026-06 판결 보도)
|
||||
|
||||
**경쟁 플랫폼**
|
||||
- 강남언니 글로벌 확장: https://zdnet.co.kr/view/?no=20251015091542 (2025-10-15)
|
||||
- 강남언니 글로벌 서비스 소개서: https://docs.channel.io/unni/ko/articles/%EA%B8%80%EB%A1%9C%EB%B2%8C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%86%8C%EA%B0%9C%EC%84%9C--a34d448f
|
||||
- 강남언니 통역·예약: https://www.mt.co.kr/industry/2026/08/12/2026081210155697732 (2026-08-12)
|
||||
- 강남언니 통역 도입: https://zdnet.co.kr/view/?no=20260511103126 (2026-05-11)
|
||||
- 강남언니 실적: https://www.newsspace.kr/news/article.html?no=15572 (발행일 미확인)
|
||||
- 강남언니 개인정보 유출: https://www.swtvnews.com/news/newsview.php?ncode=1065595512012384 (2026-09-08)
|
||||
- 바비톡 일본 진출: https://www.venturesquare.net/1001286 (2025-08)
|
||||
- 크리에이트립 규모: https://www.asiatime.co.kr/article/20250516500383 (2025-05-17)
|
||||
- 크리에이트립 거래: https://www.mt.co.kr/future/2026/01/29/2026012908442998862 (2026-01-29)
|
||||
- 크리에이트립 유치업 등록: https://creatrip.com/en/blog/14800
|
||||
- 여신티켓 글로벌: https://www.enetnews.co.kr/news/articleView.html?idxno=39846
|
||||
- BeautsGO: https://www.beautsgo.com/ · https://github.com/BeautsGO/beautsgo-booking
|
||||
- AI 크롤러·llms.txt 현황: 각 사이트 robots.txt·sitemap·llms.txt 직접 조회 (2026-09-15)
|
||||
|
||||
**답변엔진 약관·단가**
|
||||
- Gemini API 추가 약관: https://ai.google.dev/gemini-api/terms
|
||||
- Claude web search: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
|
||||
- 전체 단가 출처: 답변엔진 확장 검토 보고서 9장
|
||||
@ -1,469 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""뷰성형외과 MOU·POC 범위 검토 + 의료관광 허브 플랫폼 경쟁분석(상세) 워드 보고서 빌더.
|
||||
|
||||
원본 초안: 같은 폴더 INFINITH_뷰성형외과_MOU_POC범위_검토보고서_2026-09-15.md
|
||||
AI 답변 인용 집계(4-6): scripts/out/qb_*_results.jsonl(국문 120, 2026-09-01),
|
||||
docs/reports/viewclinic/05_global_en/qb_en_*_results.jsonl(영문 60, 2026-09-10)
|
||||
|
||||
python3 docs/business/build_mou_hub_competition_docx.py [--force]
|
||||
|
||||
주의: 2026-09-15 이후 haewon이 이 문서를 직접 편집했다. 이 빌더로 다시 만들면 편집이 사라지므로
|
||||
결과 파일이 있으면 멈춘다. 이후 변경은 patch_*.py 처럼 편집본에 직접 삽입한다.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent / "reports" / "answer_engines"))
|
||||
from build_engine_review_docx import Document, Cm, Pt, INDIGO, VIOLET, GRAY, bullets, heading, para, set_font, table # noqa: E402
|
||||
|
||||
OUT = HERE / "INFINITH_의료관광허브_경쟁분석_및_뷰성형외과_MOU범위_2026-09-15.docx"
|
||||
|
||||
|
||||
def h3(doc, text):
|
||||
heading(doc, text, 2)
|
||||
|
||||
|
||||
def note(doc, text):
|
||||
para(doc, text, 10, False, GRAY)
|
||||
|
||||
|
||||
def profile(doc, title, rows, implication):
|
||||
h3(doc, title)
|
||||
table(doc, ["항목", "내용"], rows, widths=[3.4, 13.6], size=9.5)
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(10)
|
||||
p.paragraph_format.line_spacing = 1.35
|
||||
set_font(p.add_run("허브 관점: "), 10.5, True)
|
||||
set_font(p.add_run(implication), 10.5)
|
||||
|
||||
|
||||
def build():
|
||||
doc = Document()
|
||||
sec = doc.sections[0]
|
||||
sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
|
||||
sec.left_margin = sec.right_margin = Cm(2.0)
|
||||
sec.top_margin = sec.bottom_margin = Cm(2.0)
|
||||
|
||||
# 표지
|
||||
para(doc, "INFINITH AI Discovery · 대표 보고", 11, True, VIOLET, 4)
|
||||
heading(doc, "의료관광 허브 플랫폼 경쟁분석과 뷰성형외과 MOU 범위 검토", 0)
|
||||
para(doc, "회의록 검토 · Global Medical Hub PRD v0.2 검토 · 경쟁분석(상세) · 조직 적합성 · MOU·POC 권고안", 12, False, INDIGO, 4)
|
||||
para(doc, "작성 2026-09-15(화) · 조사 기준일 2026-09-15 · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인이 필요합니다", 10, False, GRAY, 14)
|
||||
|
||||
# 0. 결론
|
||||
heading(doc, "0. 결론")
|
||||
bullets(doc, [
|
||||
("이번 MOU·POC 범위는 서포터즈 사이트와 AEO/GEO 진단·측정까지로 한정할 것을 권고합니다. ", "글로벌 의료관광 플랫폼(다병원 허브, 상담·예약 중개, 숙박·관광 연계)은 이번 범위에서 명시적으로 제외합니다."),
|
||||
("플랫폼 사업은 현재 조직(개발팀장 1명, 주니어 개발자 5명, 운영팀 없음)으로 가능하지 않다고 판단합니다. ", "병목은 개발이 아니라 운영, 수요 확보, 자본·규제 대응입니다. 강남언니는 2025년 광고선전비 343억 원을 썼고, 오프라인 통역센터와 8개 언어 통역을 운영합니다."),
|
||||
("허브가 노리는 'AI가 인용하는 다국어 원문' 영역에는 이미 경쟁자가 있습니다. ", "외국인 질문 60문항 실측에서 Perplexity는 Bookimed(28문항), ClinicsOnCall(26), Korea Clinic Guide(20), ClinicSeoul(17), 강남언니 영문(14)을 인용했습니다. 광고 없는 영문 가이드 사이트가 120개 FAQ 형태로 운영 중입니다."),
|
||||
("PRD v0.2의 '기존 앱은 정보를 앱 안에 가둔다'는 전제는 일부만 맞습니다. ", "크리에이트립·여신티켓은 AI 크롤러 허용, llms.txt, 다국어 블로그를 운영합니다. 강남언니도 AI 검색봇을 허용하고 후기만 막습니다."),
|
||||
("MOU의 목적은 사례 확보와 홍보입니다. ", "무상 POC라면 제공 범위, 뷰성형외과가 제공할 것, 종료일을 문서로 고정해야 합니다. 대표 결정 안건 8건은 8장에 있습니다."),
|
||||
])
|
||||
|
||||
# 1. 회의록 검토
|
||||
heading(doc, "1. 회의록 검토 (2026-09-15)")
|
||||
h3(doc, "1-1. 확인된 사항")
|
||||
table(doc, ["구분", "내용", "담당"], [
|
||||
["뷰성형외과 내부 진행", "실무진이 원장 보고를 마쳤고 진행 결정이 났습니다", "뷰성형외과"],
|
||||
["진행 순서", "MOU → 홍보 기사 → 프로젝트 런칭", "양사"],
|
||||
["순서 이견", "대표는 MOU 우선. 뷰성형외과는 범위를 구체화한 뒤 MOU", "황본부장 조율"],
|
||||
["뷰성형외과 쪽 방향", "보고 이후 원장 쪽 방향(B2C 의료관광 포털)이 무게를 가짐", ""],
|
||||
["감본부장 입장", "비용 없는 플랫폼 POC면 미팅 불참, 문서로 범위 선정리. 유료 논의면 참석", "감본부장"],
|
||||
["범위 문서", "향후 1~2개월 할 일을 구체화해 대표 보고 후 뷰성형외과와 협의", "감본부장"],
|
||||
["황본부장 입장", "서포터즈는 기본 범위. 메디컬·버티컬 포털은 미결정. 1단계에서 큰 그림은 불필요", "황본부장"],
|
||||
["배포 현황", "webforai.kr 랜딩과 서포터즈 3곳 배포, 구글·네이버 서치콘솔 등록, 색인 보류", "감본부장"],
|
||||
["자동화 수준", "뷰성형외과 품질을 100으로 보면 URL만으로 다른 병원은 60~70%", "감본부장, 9/18 연구소 회의 시연"],
|
||||
], widths=[3.4, 10.4, 3.2])
|
||||
|
||||
h3(doc, "1-2. 문서에 옮기기 전에 바로잡을 발언")
|
||||
table(doc, ["회의 발언", "확인 결과", "문서에 쓸 표현"], [
|
||||
["답변엔진을 추가하면 한 번에 몇천 원이 몇만 원 단위로 오른다",
|
||||
"비용 방향은 맞습니다. 다만 Gemini 검색 그라운딩 약관은 결과의 분석·자동 수집을 금지하고, Google AI Overview는 공식 API가 없습니다. 비용만의 문제가 아닙니다",
|
||||
"현재 2개 엔진. Claude는 추가 가능, Gemini·AI Overview는 약관·수집 원칙 확인이 먼저"],
|
||||
["의료관광법상 기관 검증을 거치지 않은 실물 인물 콘텐츠를 배포하지 못해 색인을 안 했다",
|
||||
"해당 조문을 확인하지 못했습니다. 9/14 사유 보고 기준 보류 사유는 병원 승인 글 0편과 지원 관계 고지 문구 미확정입니다. 법률 확인은 사전심의·유치업 2건입니다",
|
||||
"병원 승인 글 0편, 고지 문구 미확정. 법률 확인 2건"],
|
||||
["강남언니·바비톡은 사용자 600만~천만, 병원 3천~5천 곳",
|
||||
"강남언니 해외 누적회원 148만·해외 MAU 23만(2025-09), 일본 제휴병원 1,500곳(2025-10). 바비톡 병원 3,300곳은 1차 출처 미확인",
|
||||
"4장 확인 수치"],
|
||||
], widths=[4.6, 7.6, 4.8], size=9)
|
||||
table(doc, ["답변엔진 구성 (120문항, 후처리 포함, 중앙값)", "1회 측정", "5회 반복"], [
|
||||
["현행 2개 (ChatGPT, Perplexity)", "약 ₩11,440", "약 ₩57,200"],
|
||||
["+ Claude", "약 ₩29,150", "약 ₩145,740"],
|
||||
["+ Claude, Gemini, Google AI Mode", "약 ₩35,790", "약 ₩178,950"],
|
||||
], widths=[9.0, 4.0, 4.0])
|
||||
note(doc, "회의록 음성 인식 표기: 비성형외과·비서약과 → 뷰성형외과, 김수원 원장 → 최순우 원장(대표자), 데미나이 → Gemini, AOG·AO → AEO·GEO, 인류관광 → 의료관광, 웹 포 AI → webforai.kr. '메트코'는 확인 필요.")
|
||||
|
||||
# 2. PRD 검토
|
||||
heading(doc, "2. Global Medical Hub PRD v0.2 검토")
|
||||
h3(doc, "2-1. PRD가 제안한 것")
|
||||
bullets(doc, [
|
||||
"3층 구조: 1층 정보·발견(다국어 병원 원문 허브), 2층 상담·예약(유치업 등록 후), 3층 회복기 여정(숙박·식사·관광 제휴 링크)",
|
||||
"구조: AIO2O 단독 소유, 뷰성형외과는 앵커 파트너(첫 병원, 병원 2~4곳 소개, 의료 자문)",
|
||||
"1단계(8~12주) 게이트: 병원 4곳 개설자 승인, 외국인 질문 뱅크 인용 기준선, 상담 신청 월 10건",
|
||||
], size=10.5)
|
||||
h3(doc, "2-2. 지금도 유효한 판단")
|
||||
bullets(doc, [
|
||||
"파이프라인 IP는 AIO2O 100%. 병원 자회사·합작법인 구조는 중립성과 IP에서 불리",
|
||||
"1단계는 수수료 없이 병원 직결. 대가를 받으면 유치업 등록 대상",
|
||||
"후기·전후 사진·비교·할인으로 설득하는 기능은 만들지 않음(의료법 56조)",
|
||||
], size=10.5)
|
||||
h3(doc, "2-3. 공백과 보완")
|
||||
table(doc, ["공백", "내용", "보완"], [
|
||||
["운영 인력 가정 없음", "비개발자 운영자를 전제했으나 운영팀이 없습니다. 게이트 실패·이미지 육안·채널 판정·정정·병원 응대·상담 전달은 사람 업무입니다", "운영 인원을 선행 조건으로"],
|
||||
["수요 확보 계획 없음", "AI 인용만으로 유입을 기대합니다. 경쟁 앱은 광고비·오프라인 센터로 수요를 확보합니다", "유입 채널·예산 추정"],
|
||||
["경쟁 전제 일부 오류", "앱들이 정보를 가둔다고 봤지만 크리에이트립·여신티켓·BeautsGO는 오픈웹·AI 대응을 합니다. 영문 가이드 사이트가 이미 AI 인용을 받습니다(4-6)", "차별점 재정의(4-7)"],
|
||||
["1단계 개발량", "§06 기준 신규 7건(외국인 질문 뱅크, 외국어 게이트, 다국어 라우팅·상담 폼, 다병원 허브 도메인, 계정·권한, 상담 알림, 측정 인프라)과 부분 2건(운영 콘솔, 상시 워커)", "현재 인력으로 8~12주는 낙관적"],
|
||||
["규제 변경 미반영", "2026-05-15 시행규칙 개정으로 여행업자 자본금 5천만 원 예외 삭제. 유치업은 자본금·보증보험 각 1억 원. 등록 후 2년 무실적이면 취소 사유. 2026-05-12부터 병원이 유치 경로 보고", "§05 갱신"],
|
||||
["민감정보 리스크", "2026-09-04~05 강남언니 22만 명(해외 약 6만 명) 민감정보 유출. 상담 폼만 받아도 건강정보 처리자가 됩니다", "최소 수집, 보안 체계 선행"],
|
||||
["앵커 소개 역량 미확인", "원장의 병원 네트워크 명단·수·관계 단계 미확인", "MOU 전 확인 질문"],
|
||||
["브랜드 리스크", "앵커 병원의 2026-07 공정위 시정명령 이력", "홍보 기사 문안 관리"],
|
||||
], widths=[3.2, 9.8, 4.0], size=9)
|
||||
|
||||
# 3. 시장
|
||||
heading(doc, "3. 시장 개요")
|
||||
table(doc, ["항목", "수치", "출처"], [
|
||||
["외국인 환자", "2024년 117만 명 → 2025년 201만 명(연환자 272만 명)", "보건복지부 2025-04, 2026-04"],
|
||||
["2025 진료과", "피부과 62.9%(131.3만 명), 성형외과 11.2%(23.3만 명), 내과 9.2%", "보건복지부 2026-04"],
|
||||
["2025 국적", "중국 30.8%, 일본 29.8%, 대만 9.2%, 미국 8.6%, 태국 약 2.9%", "보건복지부 2026-04"],
|
||||
["의원급·서울 비중", "의원급 87.7%, 서울 87.2%", "보건복지부 2026-04"],
|
||||
["2026 상반기 외국인 카드 결제", "1조4,203억 원(전년 대비 +38.6%), 피부과 7,685억 원", "하나카드 데이터 2026-08-27"],
|
||||
["정부 목표", "2027년 70만 명(2023 발표), 2024년 조기 달성. 새 목표 미확인", "보건복지부 2023-05-29"],
|
||||
["미용성형 부가세 환급", "2025-12-31 종료. 재도입 법안 국회 소위(2026-07-29). 성형외과의사회는 종료 후 20~30% 감소 주장(통계 아님)", "2025-07-31, 2026-08-14"],
|
||||
], widths=[3.6, 9.4, 4.0], size=9.5)
|
||||
note(doc, "시장은 커지고 있으나 성장의 중심은 피부과입니다. 성형외과 비중은 11%이고, 부가세 환급 종료의 영향은 공식 통계로 아직 확인되지 않았습니다.")
|
||||
|
||||
# 4. 경쟁분석
|
||||
heading(doc, "4. 경쟁분석 (상세)")
|
||||
h3(doc, "4-1. 경쟁 구도")
|
||||
table(doc, ["유형", "사업자", "핵심 자산", "허브(PRD 1층)와 겹치는 부분"], [
|
||||
["국내 미용의료 앱의 해외 확장", "강남언니, 바비톡, 여신티켓", "병원 수천 곳 제휴, 후기·가격 데이터, 광고 매출", "다국어 병원 정보, 상담·예약"],
|
||||
["외국인 여행·뷰티 예약 플랫폼", "크리에이트립, BeautsGO, Klook", "외국인 사용자, 숙박·관광 결합, 유치업 등록(크리에이트립)", "상담·예약, 회복기 여정"],
|
||||
["해외 의료관광 중개 사이트", "Bookimed, ClinicsOnCall, PlacidWay", "다국가 병원 비교, 무료 코디네이터, 병원 수수료", "영문 병원 정보, AI 인용"],
|
||||
["영문 한국 클리닉 가이드 사이트", "Korea Clinic Guide, ClinicSeoul, 개별 가이드 사이트 다수", "영문 원문·FAQ·가격 정보, 저비용 운영", "AI가 인용하는 다국어 원문. 허브의 핵심 가치와 직접 겹침"],
|
||||
["공공 포털", "메디컬코리아(KHIDI), 강남구 의료관광, 한국관광공사 영문", "공신력, 유치기관 조회, 무료", "병원 목록·안내"],
|
||||
["커뮤니티·후기", "Reddit, RealSelf", "실사용자 경험담. AI 답변의 주요 근거", "외국인 질문 답변(후기는 의료법상 허브가 만들 수 없음)"],
|
||||
], widths=[3.4, 3.8, 4.8, 5.0], size=9)
|
||||
|
||||
h3(doc, "4-2. 사업자별 프로필")
|
||||
profile(doc, "① 강남언니 (힐링페이퍼)", [
|
||||
["해외 출시", "일본 2019-11, 영어(UNNI) 2023-12, 태국 2024 말, 중국·대만 2025-10-15. 한·영·일·태·중(간체·번체) 6개 언어"],
|
||||
["규모", "해외 누적회원 148만, 해외 MAU 23만, 월 상담 약 1.3만 건(2025-09). 일본 사용자 160만·제휴병원 1,500곳(2025-10). 일본 앱 예약 연 약 55만 건(2026-08). 73개국 이용"],
|
||||
["성장", "태국 7개월 만에 예약자 10배(2025-06). 2026-07 기준 전년 대비 태국 13배, 중국 12배"],
|
||||
["오프라인·통역", "2026-03 논현동 언니가이드 센터 개소. 2026-05 오프라인 통역(8개 언어, 환자 무료). LINE·WeChat·WhatsApp 상담. 5개월 통역 1,600건"],
|
||||
["수익 모델", "이벤트 페이지 조회당 과금(CPV). 상담·내원과 무관하게 과금. 성과형 광고 상품 병행. 입점 병원에 유치의료기관 등록 요구"],
|
||||
["실적", "매출 2023 417억 / 2024 530억 / 2025 752억. 영업이익 122억 / 128억 / 75억(2025년 -41.3%). 2025 광고선전비 343억(+100.6%), 일본법인 순손실 46억"],
|
||||
["규제 이력", "진료비 비율 수수료 모델로 의료법 27조 3항 위반 1심(2022-01)·2심(2023-07) 징역 8월·집행유예 2년. 이후 광고형 전환. 2026-09-04~05 민감정보 219,665명 유출(해외 약 6만 명)"],
|
||||
["AI·오픈웹", "AI 검색봇(OAI-SearchBot·PerplexityBot·Claude-SearchBot) 허용, 후기(/reviews) 차단, 학습 크롤러 차단. hreflang 6개 언어. 영문 unni.app/us, US 병원 사이트맵 20건. llms.txt 없음"],
|
||||
], "외국인 수요와 병원 공급을 이미 확보한 선두 사업자입니다. 허브가 정보 허브로 경쟁하면 병원 수·후기·통역에서 비교가 되지 않습니다. 다만 영업이익이 줄고 개인정보 사고가 난 시점이라 병원들의 대안 채널 수요는 있을 수 있습니다.")
|
||||
profile(doc, "② 바비톡 (케어랩스 계열)", [
|
||||
["해외 출시", "일본어 웹 2025-08-25. 영어·중국어 이벤트는 CPV 무과금 시범. 중국·대만·영미권은 계획 단계"],
|
||||
["병원 입점", "유치의료기관 등록 필수. 이벤트 등록 시 구글 예약 자동 연동"],
|
||||
["실적", "2025 매출 367.7억, 영업이익 73억(재무정보 사이트, 게시일 미확인)"],
|
||||
["미확인", "병원 3,300곳·1,000만 다운로드(채용 플랫폼 소개문만 있음), 해외 사용자 수, 유치업 등록"],
|
||||
["AI·오픈웹", "robots.txt 전체 허용, /ja 사이트맵. /en 주소가 일본어를 보여줌. llms.txt 없음"],
|
||||
], "해외 확장 초기이며 일본 중심입니다. 구글 예약 연동처럼 외부 표면으로 나가는 방향이라, 영문·다국어 원문 영역에 들어올 가능성이 있습니다.")
|
||||
profile(doc, "③ 크리에이트립 (Creatrip)", [
|
||||
["포지션", "외국인 한국 여행 앱. 뷰티·의료 예약, 숙소 예약, 통역 버디(14일)"],
|
||||
["규모", "누적가입 94만(80개국, 2024-11), MAU 160만, 제휴 약 1,200곳, 12개 언어(사이트 15개 로케일)"],
|
||||
["거래", "2025 거래 +40%, 뷰티·메디컬 +71%로 거래의 51%. 대만은 의료 비중 49%"],
|
||||
["투자", "시리즈B까지 누적 181억 원 이상. 마이리얼트립 30억(2024-08)"],
|
||||
["규제", "외국인환자 유치업 등록번호 A-2019-01-01-3686(자사 공지)"],
|
||||
["AI·오픈웹", "전체 허용, llms.txt 공개, 'Best Seoul Skin Clinics 2026' 등 영문 블로그"],
|
||||
], "PRD의 2층(상담·예약)과 3층(여정)을 이미 합법 구조로 운영하는 사업자입니다. 허브가 2~3층으로 가면 정면 경쟁이고, 반대로 상담·예약을 위탁할 제휴 후보이기도 합니다.")
|
||||
profile(doc, "④ 여신티켓 (YeoTi)", [
|
||||
["해외 출시", "2024-01 영·일·중 글로벌 앱"],
|
||||
["규모", "글로벌 MAU 4만 이상, 2025 상반기 +173.4%. 국내 병원 약 5,000곳"],
|
||||
["차별 기능", "부작용 보험(1건당 최대 100만 원), 앱 구매"],
|
||||
["AI·오픈웹", "GPTBot·ClaudeBot·PerplexityBot 개별 허용(결제 경로만 차단, 2026-08-12 갱신). 영문 llms.txt, 영·일·중 블로그"],
|
||||
], "국내 병원 수가 크고 AI 크롤러 대응을 명시적으로 하는 사업자입니다. '앱은 AI에 닿지 않는다'는 전제를 쓸 수 없는 근거입니다.")
|
||||
profile(doc, "⑤ BeautsGO", [
|
||||
["포지션", "외국인 대상 한국 뷰티·클리닉 예약. 5개 언어, 숙박 지원"],
|
||||
["규모", "제휴 클리닉 1,300곳 이상. 운영사·투자 미확인"],
|
||||
["AI 대응", "AI 에이전트용 예약 스킬을 GitHub(2026-03-31)·Smithery·ModelScope에 배포"],
|
||||
], "AI 답변엔진을 넘어 AI 에이전트가 직접 예약하는 경로까지 준비하고 있습니다. 허브가 '인용'만 목표로 하면 한 단계 뒤처집니다.")
|
||||
profile(doc, "⑥ 해외 의료관광 중개 사이트: Bookimed · ClinicsOnCall · PlacidWay", [
|
||||
["Bookimed", "우크라이나 기반 다국가 의료관광 마켓플레이스. 환자 무료, 병원이 수수료(요율 비공개). 연간 거래액 2,210만 달러, 문의 17만 건(자사 보고서). 영문 한국 병원 페이지 운영"],
|
||||
["ClinicsOnCall", "영국(글래스고) 소재. 환자 무료 코디네이션, 프리미엄 옵션 $20·$50. 통역·픽업·숙박·비자·24시간 코디네이터. 러시아어 중심 6개 언어. 한국 클리닉 수 미확인"],
|
||||
["PlacidWay", "해외 의료관광 중개 사이트. 운영 주체·한국 병원 수는 이번 조사에서 미확인(사이트 응답 시간 초과)"],
|
||||
["AI 인용 실측", "영문 60문항 Perplexity 답변에서 Bookimed 28문항, ClinicsOnCall 26문항, PlacidWay 14문항 인용(2026-09-10)"],
|
||||
], "외국인이 영어로 물을 때 AI가 실제로 가져다 쓰는 출처입니다. 허브의 'AI 인용' 목표는 이 사이트들과 경쟁합니다. 이들은 다국가·다언어 코디네이터 조직을 이미 갖고 있습니다.")
|
||||
profile(doc, "⑦ 영문 한국 클리닉 가이드 사이트: Korea Clinic Guide · ClinicSeoul", [
|
||||
["Korea Clinic Guide", "한국 클리닉 537곳 이상 목록, 20개 이상 진료 분야, 블로그·후기·영상·여행 계획 가이드. 병원 노출 광고형으로 보임. 운영 주체 미확인"],
|
||||
["ClinicSeoul", "RiskIntel Corp 운영 독립 가이드. '클리닉 후원 0, 유료 순위 0'을 표방. 35개 이상 심층 가이드, 120개 FAQ, 검증 가격 정보"],
|
||||
["AI 인용 실측", "영문 60문항 Perplexity 답변에서 Korea Clinic Guide 20문항, ClinicSeoul 17문항 인용. ChatGPT에서도 ClinicSeoul 4문항"],
|
||||
], "PRD 1층이 만들려는 것(중립, 원문, FAQ, 비교·순위 없음)과 가장 가까운 형태가 소규모 영문 사이트로 이미 운영되고 있고, AI 인용도 받고 있습니다. 허브의 차별점은 '병원 승인 원문'과 '실측 측정'만 남습니다.")
|
||||
profile(doc, "⑧ 공공 포털과 여행 OTA", [
|
||||
["메디컬코리아(KHIDI)", "유치기관 등록 조회 시스템, 영문·일문 사이트. 공신력"],
|
||||
["강남구 의료관광", "medicaltour.gangnam.go.kr. 국문 120문항 Perplexity 답변 14문항, ChatGPT 3문항에서 인용"],
|
||||
["한국관광공사 영문", "english.visitkorea.or.kr. 영문 60문항 Perplexity 12문항, ChatGPT 5문항 인용"],
|
||||
["Klook", "강남언니(UNNI) 연계 한국 뷰티·스킨케어 클리닉 예약 상품(페이지 접속 불가로 검색 결과만 확인)"],
|
||||
], "공공 포털은 무료이고 공신력이 있어 AI가 인용합니다. 허브가 공공 데이터와 같은 목록형 정보를 만들면 차별점이 없습니다.")
|
||||
|
||||
h3(doc, "4-3. 기능 비교")
|
||||
table(doc, ["기능", "강남언니", "바비톡 JP", "크리에이트립", "여신티켓", "BeautsGO", "해외 중개", "영문 가이드", "INFINITH 허브 1단계"], [
|
||||
["다국어 병원 정보", "O(6)", "O(일)", "O(12)", "O(3)", "O(5)", "O", "O(영)", "O(영·일·중 계획)"],
|
||||
["후기", "O", "O", "O", "O", "O", "O", "O", "X(의료법)"],
|
||||
["가격 공개", "이벤트가", "O", "O", "O", "가격표", "견적", "O", "비용 항목만"],
|
||||
["상담·예약", "O", "O", "O", "O", "O", "O", "X", "상담 신청 병원 직결"],
|
||||
["통역", "O(동행)", "병원별", "O(버디)", "미확인", "미확인", "O", "X", "X"],
|
||||
["숙박·여정", "미확인", "미확인", "O", "미확인", "O", "O", "안내", "제휴 링크(3단계)"],
|
||||
["결제", "O", "미확인", "O", "O", "미확인", "미확인", "X", "X(2단계)"],
|
||||
["병원 승인 원문", "X", "X", "X", "X", "X", "X", "X", "O"],
|
||||
["AI 인용 실측·리포트", "X", "X", "X", "X", "X", "X", "X", "O"],
|
||||
], widths=[2.6, 1.6, 1.6, 1.8, 1.6, 1.6, 1.6, 1.6, 2.4], size=8)
|
||||
note(doc, "O: 제공 확인, X: 제공하지 않음 또는 해당 없음, 미확인: 공개 자료로 확인 못 함. 병원 승인 원문과 AI 인용 실측은 공개 자료에서 다른 사업자의 제공 사실을 찾지 못한 항목입니다.")
|
||||
|
||||
h3(doc, "4-4. 수익 모델과 규제 대응")
|
||||
table(doc, ["사업자", "수익 모델", "유치업 등록", "규제 리스크"], [
|
||||
["강남언니", "CPV 광고, 성과형 광고", "입점 병원에 유치의료기관 등록 요구. 자사 등록 미확인", "과거 수수료 모델 유죄, 민감정보 유출"],
|
||||
["바비톡", "CPV 광고(해외 일부 무과금 시범)", "입점 병원에 유치의료기관 등록 요구", "공개 사례 미확인"],
|
||||
["크리에이트립", "예약 거래(수수료 구조 미공개)", "등록(A-2019-01-01-3686)", "공개 사례 미확인"],
|
||||
["여신티켓", "시술권 판매·광고", "미확인", "공개 사례 미확인"],
|
||||
["Bookimed", "병원 수수료", "해외 법인. 한국 등록 미확인", "한국 병원 알선 시 등록 여부가 쟁점이 될 수 있음"],
|
||||
["영문 가이드 사이트", "광고 또는 무광고(운영 주체별)", "해당 없음(중개 안 함)", "낮음"],
|
||||
["INFINITH 허브(PRD)", "1단계 무수수료, 2단계 병원 구독·예약 수수료", "2단계 전 등록 필요(자본금·보증보험 각 1억 원)", "1단계는 낮음, 2단계부터 알선·광고·민감정보"],
|
||||
], widths=[3.0, 4.6, 4.8, 4.6], size=9)
|
||||
table(doc, ["규제 요건", "내용 (변호사 확인 필요)"], [
|
||||
["유치업 등록", "의료해외진출법 제6조, 시행규칙 제4조: 자본금 1억 원, 보증보험 1억 원, 국내 사무소, 3년 갱신. 2년 무실적이면 등록취소 사유(제24조)"],
|
||||
["미등록 유치", "3년 이하 징역 또는 3천만 원 이하 벌금(제28조). 2026-06 미등록 업자 경유 유치 병원의 등록취소가 법원에서 유지됨"],
|
||||
["수수료 상한", "의원 30%, 병원·종합병원 20%, 상급종합병원 15%(보건복지부 고시)"],
|
||||
["환자 알선 금지", "의료법 제27조 제3항. 건강보험 미가입 외국인 유치는 예외 허용, 국내 거주 외국인은 제외"],
|
||||
["외국인 유치 광고", "의료법 제56조 제2항 제12호, 의료해외진출법 제15조: 국내 광고는 공항·보세판매장 등 지정 장소만. 온라인 다국어 매체 적용 여부는 미확인"],
|
||||
["사전심의 매체", "의료법 제57조, 시행령 제24조: 일평균 이용자 10만 명 이상 인터넷매체"],
|
||||
["개인정보", "개인정보보호법 제23조(민감정보 별도 동의), 제28조의8(국외 이전)"],
|
||||
["여행업", "관광진흥법 시행령: 종합여행업 자본금 5천만 원, 국내외여행업 3천만 원"],
|
||||
], widths=[3.4, 13.6], size=9)
|
||||
|
||||
h3(doc, "4-5. AI 답변엔진·오픈웹 대응 (2026-09-15 직접 조회)")
|
||||
table(doc, ["사업자", "AI 크롤러", "llms.txt", "다국어 오픈웹 콘텐츠"], [
|
||||
["강남언니", "검색봇 허용, 후기 차단, 학습봇 차단", "없음", "hreflang 6개 언어, 영문 병원 사이트맵 20건"],
|
||||
["바비톡", "전체 허용", "없음", "일본어 사이트맵"],
|
||||
["크리에이트립", "전체 허용", "있음", "15개 로케일, 영문 클리닉 블로그"],
|
||||
["여신티켓", "GPTBot·ClaudeBot·PerplexityBot 개별 허용", "있음(영문)", "영·일·중 블로그"],
|
||||
["BeautsGO", "미확인", "미확인", "5개 언어 사이트맵, AI 에이전트 예약 스킬"],
|
||||
], widths=[3.0, 5.4, 2.6, 6.0], size=9)
|
||||
|
||||
h3(doc, "4-6. 실측: AI 답변은 어떤 출처를 인용했나")
|
||||
para(doc, "INFINITH가 뷰성형외과 진단에서 수집한 공식 API 답변의 인용 URL을 도메인별로 집계했습니다. 수치는 해당 도메인을 1회 이상 인용한 답변 수입니다. 1회 측정이며 소비자 앱 화면과 다를 수 있습니다.", 10.5)
|
||||
table(doc, ["출처", "국문 120문항 ChatGPT", "국문 120문항 Perplexity"], [
|
||||
["네이버 블로그", "0", "81"],
|
||||
["강남언니", "4", "45"],
|
||||
["하이닥", "0", "28"],
|
||||
["캐시닥", "3", "27"],
|
||||
["병원 공식(뷰)", "6", "27"],
|
||||
["강남구 의료관광", "3", "14"],
|
||||
["바비톡", "3", "7"],
|
||||
["여신티켓", "0", "3"],
|
||||
["인용 URL이 있는 답변 수", "43", "120"],
|
||||
], widths=[7.0, 5.0, 5.0], size=9.5)
|
||||
note(doc, "국문 측정 2026-09-01. Perplexity는 robots.txt로 AI 크롤러를 막는 네이버 블로그를 81개 답변에서 인용했습니다. 네이버 자산의 기여도를 0으로 본 기존 판단은 Perplexity에 한해 수정이 필요합니다.")
|
||||
table(doc, ["출처", "영문 60문항 ChatGPT", "영문 60문항 Perplexity", "유형"], [
|
||||
["병원 공식(뷰 영문)", "18", "38", "병원"],
|
||||
["Reddit", "16", "33", "커뮤니티"],
|
||||
["Bookimed", "4", "28", "해외 중개"],
|
||||
["ClinicsOnCall", "0", "26", "해외 중개"],
|
||||
["Korea Clinic Guide", "0", "20", "영문 가이드"],
|
||||
["ClinicSeoul", "4", "17", "영문 가이드"],
|
||||
["강남언니(unni.app)", "0", "14", "국내 앱"],
|
||||
["PlacidWay", "0", "14", "해외 중개"],
|
||||
["한국관광공사 영문", "5", "12", "공공"],
|
||||
["RealSelf", "5", "11", "커뮤니티"],
|
||||
["크리에이트립", "0", "0", "여행 플랫폼"],
|
||||
["바비톡", "0", "0", "국내 앱"],
|
||||
], widths=[5.0, 3.6, 3.8, 4.6], size=9.5)
|
||||
note(doc, "영문 측정 2026-09-10, 미국 뉴욕 위치 기준. 인용 URL 수 ChatGPT 448개, Perplexity 2,316개.")
|
||||
bullets(doc, [
|
||||
("국내 질문에서는 강남언니와 네이버 블로그가 인용을 가져갑니다. ", "허브가 국문으로 들어갈 여지는 작습니다."),
|
||||
("외국인 질문에서는 국내 앱보다 해외 중개 사이트와 영문 가이드 사이트가 인용됩니다. ", "크리에이트립·바비톡은 0건이었습니다. 이 영역은 대형 앱이 비어 있지만, 소규모 영문 사이트가 이미 채우고 있습니다."),
|
||||
("병원 공식 영문 사이트가 가장 많이 인용됩니다. ", "병원 사이트를 강화하는 서포터즈·AEO 작업이 허브를 만드는 것보다 인용에 직접 연결됩니다."),
|
||||
], size=10.5)
|
||||
|
||||
h3(doc, "4-7. INFINITH 허브 차별점 검증")
|
||||
table(doc, ["PRD의 차별점", "경쟁 현실", "평가"], [
|
||||
["AI가 인용할 다국어 원문이 오픈웹에 없다", "영문 가이드·해외 중개 사이트가 이미 인용받고 있습니다(4-6)", "약함"],
|
||||
["기존 앱은 정보를 앱 안에 가둔다", "크리에이트립·여신티켓은 llms.txt·블로그, 강남언니는 AI 검색봇 허용", "일부만 유효"],
|
||||
["자동 온보딩(병원당 30분, $0.6)", "경쟁사는 병원이 직접 입력하거나 영업이 입력. 우리 자동화 품질은 60~70% 구간 존재", "유효하나 품질 보완 필요"],
|
||||
["병원 승인 원문(의학 검토·게시 승인 기록)", "공개 자료에서 같은 체계를 찾지 못함", "유효"],
|
||||
["AEO/GEO 실측 진단과 인용 측정 리포트", "플랫폼들은 제공하지 않음. GEO 대행사는 제공(항목·방법 비공개가 많음)", "유효"],
|
||||
["중립(비교·순위 없음)", "ClinicSeoul이 '후원 0, 유료 순위 0'을 표방", "약함"],
|
||||
["회복 일정·주변 안내 여정", "크리에이트립·BeautsGO·해외 중개가 숙박·통역까지 제공", "약함(정보 수준)"],
|
||||
], widths=[5.0, 8.4, 3.6], size=9)
|
||||
note(doc, "유효한 차별점 두 가지(병원 승인 원문, 실측 측정)는 플랫폼 없이 서포터즈 사이트와 진단 제품으로도 제공할 수 있습니다.")
|
||||
|
||||
h3(doc, "4-8. 진입 장벽")
|
||||
table(doc, ["장벽", "선두 사업자 수준", "INFINITH 현재"], [
|
||||
["병원 공급", "수천 곳 제휴(강남언니 일본 1,500곳, 여신티켓 국내 5,000곳)", "서포터즈 3곳, 승인 0편"],
|
||||
["외국인 수요", "해외 MAU 23만(강남언니), MAU 160만(크리에이트립)", "0. AI 인용 기준선만 있음"],
|
||||
["마케팅 자본", "광고선전비 343억(강남언니 2025), 누적 투자 181억 원 이상(크리에이트립)", "미편성"],
|
||||
["운영 조직", "오프라인 센터, 8개 언어 통역, 24시간 다국어 상담", "운영팀 없음"],
|
||||
["규제 자격", "유치업 등록(크리에이트립), 입점 병원 유치기관 등록 요구", "미등록, 자본금·보증보험 2억 원 필요"],
|
||||
["데이터·보안", "민감정보 처리 체계(그래도 유출 사고 발생)", "상담 데이터 없음, 체계 없음"],
|
||||
], widths=[3.2, 8.0, 5.8], size=9)
|
||||
|
||||
h3(doc, "4-9. 시사점")
|
||||
bullets(doc, [
|
||||
"플랫폼 경쟁의 승부는 병원 수, 외국인 사용자 수, 통역·예약 운영입니다. 이 셋에서 INFINITH는 출발점이 0입니다.",
|
||||
"INFINITH가 유효하게 가진 것은 병원 승인 원문 체계와 AI 인용 실측입니다. 이 둘은 병원 단위 B2B 제품으로 파는 편이 수요와 맞습니다.",
|
||||
"외국인 AI 답변에서 병원 공식 영문 사이트가 가장 많이 인용되므로, 병원별 서포터즈·영문 AEO 개선이 허브보다 효과 경로가 짧습니다.",
|
||||
"상담·예약이 필요해지면 직접 등록하지 말고, 유치업 등록 사업자(예: 크리에이트립)와 제휴하는 구조를 먼저 검토합니다.",
|
||||
], size=10.5)
|
||||
|
||||
# 5. 조직 적합성
|
||||
heading(doc, "5. 현재 조직으로 가능한 사업인가")
|
||||
table(doc, ["역량", "플랫폼에 필요한 수준", "현재", "판정"], [
|
||||
["병원 원문 생성·발행", "병원당 자동 온보딩, 다국어", "서포터즈 워커 동작, URL만으로 60~70% 품질, 승인 0편", "보유(개선 필요)"],
|
||||
["다병원 허브 개발", "계정·권한, 허브 도메인, 다국어 라우팅, 상담 폼, 운영 콘솔", "팀장 1명·주니어 5명, 기존 제품 병행", "1층만 가능, 기간 연장"],
|
||||
["운영", "병원 응대, 검수, 이미지 육안, 정정, 게이트 실패 처리", "운영팀 없음", "부족"],
|
||||
["외국인 응대", "영·일·중 상담, 예약 조율, 통역, 분쟁", "없음", "불가"],
|
||||
["수요 확보", "해외 광고, 현지 마케팅", "없음, AI 인용에만 의존", "불가"],
|
||||
["병원 공급", "병원 영업 조직", "앵커 소개 의존, 명단 미확인", "부족"],
|
||||
["규제·자본", "유치업 법인, 자본금·보증보험 2억 원, 2년 내 실적, 민감정보 보안", "없음", "2층 이상 불가"],
|
||||
], widths=[3.0, 5.4, 5.6, 3.0], size=9)
|
||||
bullets(doc, [
|
||||
("2층(상담·예약)과 3층(여정)은 현재 조직으로 할 수 없습니다. ", "개발 인력이 아니라 응대·영업·자본·규제 조직이 없습니다."),
|
||||
("1층(정보 허브)만은 기술적으로 가능합니다. ", "그러나 운영과 유입 없이는 서포터즈 사이트를 병원 수만큼 늘린 것과 결과가 비슷하고, 4-6에서 본 영문 가이드 사이트와 차별점이 약합니다."),
|
||||
("기회비용이 큽니다. ", "같은 인력으로 AI Discovery 진단 판매, 서포터즈 자동화 품질 개선(60~70% 구간), 병원 승인 흐름, 측정 리포트 자동화를 할 수 있고, 이 일들은 유료 전환과 직접 이어집니다."),
|
||||
], size=10.5)
|
||||
h3(doc, "5-1. 플랫폼을 다시 검토할 조건 (제안)")
|
||||
bullets(doc, [
|
||||
"서포터즈 유료 계약 병원 10곳 이상",
|
||||
"병원 승인 글 기준 외국인 질문 뱅크 인용률 개선을 2곳 이상에서 확인",
|
||||
"운영 인력 2명 이상 확보(병원 응대·콘텐츠 검수)",
|
||||
"유치업 등록 사업자와 상담·예약 위탁 제휴 구조 확인",
|
||||
"앵커 소개 병원 명단의 계약 가능성 확인",
|
||||
], size=10.5)
|
||||
|
||||
# 6. 권고 범위
|
||||
heading(doc, "6. 권고 범위: MOU · POC (8주)")
|
||||
h3(doc, "6-1. 포함")
|
||||
table(doc, ["번호", "항목", "산출물", "비고"], [
|
||||
["1", "뷰성형외과 서포터즈 사이트 운영(국문·영문)", "viewpl.webforai.kr: 글, 영상, 뉴스룸, 방문 안내, 회복 일정 플래너, 주변 안내", "배포 완료, 색인은 승인 후"],
|
||||
["2", "병원 승인 흐름", "원장 의학 검토·개설자 게시 승인 기록, 지원 관계 고지 문구 확정", "색인 전환 조건"],
|
||||
["3", "색인 전환", "승인 글만 공개 재배포, IndexNow·서치콘솔 제출", "법률 확인 2건 결과에 따름"],
|
||||
["4", "AEO/GEO 기준선과 재측정", "국문 120·영문 60문항 × ChatGPT·Perplexity, 착수·4주·8주, 5회 반복", "언급률·신뢰구간으로만 보고"],
|
||||
["5", "공식 홈페이지 개선 가이드", "9/1 POC 제안서 P0·P1(스키마, NAP, 정의문, FAQ) 적용 명세", "적용 주체는 8장 안건"],
|
||||
["6", "측정 연결", "GA4 AI 유입 채널그룹, 서치콘솔, 가능 시 서버 로그", "병원 계정 권한 필요"],
|
||||
["7", "결과 보고서와 사례", "8주 결과 보고서, 사례 요약 1쪽", "MOU 홍보·타 병원 영업"],
|
||||
], widths=[1.2, 4.4, 7.6, 3.8], size=9)
|
||||
h3(doc, "6-2. 제외 (MOU 문구에 명시)")
|
||||
bullets(doc, [
|
||||
"다병원 의료관광 포털, 병원 비교·목록 서비스",
|
||||
"외국인 환자 상담·예약 중개, 통역, 픽업, 결제, 수수료",
|
||||
"숙박·관광 상품 판매, 앱 개발",
|
||||
"답변엔진 추가(Claude, Gemini, Google AI Overview). 유료 전환 시 옵션",
|
||||
"공식 홈페이지 직접 개발·유지보수(8장 안건 4 결정에 따름)",
|
||||
], size=10.5)
|
||||
h3(doc, "6-3. 뷰성형외과가 제공할 것")
|
||||
table(doc, ["항목", "이유"], [
|
||||
["원장 의학 검토와 개설자 게시 승인(주기·담당자 지정)", "승인 없이는 색인을 열 수 없습니다"],
|
||||
["지원 관계 고지 문구 확정", "추천·보증 심사지침"],
|
||||
["홈페이지·영상·이미지 자산 사용 허락 범위", "저작권·의료광고 책임"],
|
||||
["GA4·서치콘솔 권한", "측정 항목 4개가 계정 없이는 미검증"],
|
||||
["사례 공개와 홍보 기사 사용 동의(병원명, 수치 공개 범위)", "MOU의 목적"],
|
||||
["(선택) 소개 가능한 병원 명단", "향후 확장 검토용, 이번 범위의 의무 아님"],
|
||||
], widths=[8.5, 8.5], size=9.5)
|
||||
h3(doc, "6-4. 비용")
|
||||
bullets(doc, [
|
||||
("직접 비용은 작습니다. ", "측정 3회 × 5회 반복 기준 약 17만 원(현행 2개 엔진)입니다. 사이트 호스팅·빌드 비용은 병원당 소액입니다."),
|
||||
("실제 비용은 인력 시간입니다. ", "병원 응대, 승인 수집, 보고서 작성에 감본부장 시간이 들어갑니다. POC 기간을 8주로 고정하는 이유입니다."),
|
||||
], size=10.5)
|
||||
|
||||
# 7. 답변 문안
|
||||
heading(doc, "7. 원장의 포털 제안에 대한 답변 문안 (안)")
|
||||
para(doc, "원장님께서 말씀하신 글로벌 의료관광 포털 방향에 공감합니다. 다만 외국인 환자 상담·예약 중개는 유치업 등록과 다국어 응대 조직이 필요한 사업이라, 이번 협약에서는 뷰성형외과 서포터즈 사이트와 AI 답변 노출 측정으로 효과를 먼저 확인하려고 합니다. 8주 결과가 나오고 참여 병원이 늘어나면, 포털 단계는 그 결과를 근거로 다시 제안드리겠습니다.", 11, False, INDIGO, 10)
|
||||
|
||||
# 8. 결정 안건
|
||||
heading(doc, "8. 대표 보고 시 결정 요청 안건")
|
||||
table(doc, ["번호", "결정 사항", "권고안", "대안"], [
|
||||
["1", "MOU·POC 범위", "서포터즈 사이트 + 진단·측정(6-1)", "PRD 1층 포함"],
|
||||
["2", "플랫폼 사업 추진", "이번에는 제외, 5-1 조건 충족 시 재검토", "PRD 1단계 착수"],
|
||||
["3", "POC 비용", "무상, 8주 고정, 홍보·사례 사용권을 대가로", "유료 POC(최소 금액 제시)"],
|
||||
["4", "공식 홈페이지 개선", "가이드 제공, 적용은 병원", "직접 수정(추가 인력 필요)"],
|
||||
["5", "답변엔진 추가", "현행 2개 유지, Claude는 유료 옵션", "Claude 포함"],
|
||||
["6", "색인 전환 전 법률 확인", "사전심의·유치업 해당 여부 변호사 확인 후 전환", "승인만 받고 전환"],
|
||||
["7", "MOU 순서", "범위 문서 합의 → MOU → 홍보 기사", "MOU 먼저, 범위는 부속서"],
|
||||
["8", "미팅 참석", "문서 합의 후 실행 단계에서 참석", "이번 조율 미팅부터 참석"],
|
||||
], widths=[1.2, 3.8, 7.0, 5.0], size=9.5)
|
||||
|
||||
# 9. 질문
|
||||
heading(doc, "9. 뷰성형외과 미팅에서 확인할 질문")
|
||||
bullets(doc, [
|
||||
"MOU로 원하는 결과의 우선순위: 홍보, 외국인 환자 유입, 포털 참여",
|
||||
"원장 검토·승인 담당자와 주기",
|
||||
"홍보 기사에 병원명과 수치를 공개할 범위",
|
||||
"공식 홈페이지 수정 권한과 담당자",
|
||||
"소개 가능한 병원 명단, 병원 수, 현재 관계 단계",
|
||||
"포털을 원한다면 비용 분담이나 투자 의향",
|
||||
], size=10.5)
|
||||
|
||||
# 10. 액션
|
||||
heading(doc, "10. 다음 액션")
|
||||
table(doc, ["날짜", "할 일", "담당"], [
|
||||
["9/15(화)", "사이트 URL management 방 공유", "감본부장"],
|
||||
["9/16(수)", "이 문서 대표 보고, 8장 안건 결정", "감본부장 → 대표"],
|
||||
["9/17(목)", "결정안으로 범위 문서 확정, 뷰성형외과 전달", "감본부장, 황본부장"],
|
||||
["9/18(금)", "연구소 회의: 병원별 자동화 수준 시연(60~70% 구간 사례 포함)", "감본부장"],
|
||||
["합의 후", "변호사 확인 2건 요청, 병원 승인 요청서 발송", "감본부장"],
|
||||
], widths=[2.4, 10.6, 4.0], size=9.5)
|
||||
|
||||
# 부록
|
||||
heading(doc, "부록. 출처")
|
||||
groups = [
|
||||
("내부 자료", [
|
||||
"Global Medical Hub PRD v0.2: docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html (2026-09-09)",
|
||||
"뷰성형외과 POC 제안서: docs/reports/viewclinic/02_poc_proposal/Viewclinic_POC_Proposal.pptx (2026-09-01)",
|
||||
"서포터즈 색인 보류 사유 보고: docs/business/INFINITH_서포터즈_색인보류_사유보고_2026-09-14.docx",
|
||||
"답변엔진 확장 검토 보고서: docs/reports/answer_engines/INFINITH_답변엔진_확장검토_2026-09-15.docx",
|
||||
"AI 답변 인용 원자료: scripts/out/qb_openai_results.jsonl, qb_perplexity_results.jsonl (2026-09-01), docs/reports/viewclinic/05_global_en/qb_en_*_results.jsonl (2026-09-10)",
|
||||
]),
|
||||
("시장·규제", [
|
||||
"보건복지부 2025 외국인환자 유치 실적 https://www.mohw.go.kr/board.es?mid=a10503010100&bid=0027&act=view&list_no=1490280 (2026-04)",
|
||||
"보건복지부 2024 실적 https://www.mohw.go.kr/board.es?mid=a10503000000&bid=0027&act=view&list_no=1485191 (2025-04)",
|
||||
"2026 상반기 외국인 카드 결제 https://www.medicaldaily.co.kr/post/22664 (2026-08-27)",
|
||||
"부가세 환급 종료 후 동향 https://www.hankyung.com/article/2026081424556 (2026-08-14)",
|
||||
"유치 수수료 고시 제2024-79호 https://www.mohw.go.kr/boardDownload.es?bid=0026&list_no=1481343&seq=1 (2024-05-08, 현행 제2025-72호 본문 미대조)",
|
||||
"의료해외진출법·시행령·시행규칙, 의료법, 개인정보보호법, 관광진흥법 시행령: 국가법령정보센터 law.go.kr",
|
||||
"대법원 2018도20928 https://www.scourt.go.kr/portal/news/NewsViewAction.work?seqnum=6637&gubun=4",
|
||||
"강남언니 의료법 위반 1심 https://www.fnnews.com/news/202201271700056405 (2022-01-27), 2심 https://www.fnnews.com/news/202307061827216638 (2023-07-06)",
|
||||
"미등록 유치 병원 등록취소 소송 https://www.doctorsnews.co.kr/news/articleView.html?idxno=165135",
|
||||
]),
|
||||
("경쟁 사업자", [
|
||||
"강남언니 글로벌 확장 https://zdnet.co.kr/view/?no=20251015091542 (2025-10-15)",
|
||||
"강남언니 글로벌 서비스 소개서 https://docs.channel.io/unni/ko/articles/%EA%B8%80%EB%A1%9C%EB%B2%8C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%86%8C%EA%B0%9C%EC%84%9C--a34d448f",
|
||||
"강남언니 CPV 소개서 https://docs.channel.io/unni/ko/articles/CPV%EA%B3%BC%EA%B8%88-%EB%B0%A9%EC%8B%9D-%EC%86%8C%EA%B0%9C%EC%84%9C--e045a4e3",
|
||||
"강남언니 태국 성장 https://www.news2day.co.kr/article/20250624500218 (2025-06-24)",
|
||||
"강남언니 통역·예약 https://www.mt.co.kr/industry/2026/08/12/2026081210155697732 (2026-08-12)",
|
||||
"강남언니 통역 도입 https://zdnet.co.kr/view/?no=20260511103126 (2026-05-11)",
|
||||
"강남언니 실적 https://www.newsspace.kr/news/article.html?no=15572 (발행일 미확인)",
|
||||
"강남언니 개인정보 유출 https://www.swtvnews.com/news/newsview.php?ncode=1065595512012384 (2026-09-08)",
|
||||
"바비톡 일본 진출 https://www.venturesquare.net/1001286 (2025-08)",
|
||||
"바비톡 글로벌 서비스 https://docs.channel.io/f47c5d3a/ko/articles/%EB%B0%94%EB%B9%84%ED%86%A1-%EA%B8%80%EB%A1%9C%EB%B2%8C-%EC%84%9C%EB%B9%84%EC%8A%A4--5dc781ba",
|
||||
"크리에이트립 규모 https://www.asiatime.co.kr/article/20250516500383 (2025-05-17), 거래 https://www.mt.co.kr/future/2026/01/29/2026012908442998862 (2026-01-29), 유치업 등록 https://creatrip.com/en/blog/14800",
|
||||
"크리에이트립 투자 https://zdnet.co.kr/view/?no=20240814174433 (2024-08-14)",
|
||||
"여신티켓 글로벌 https://www.enetnews.co.kr/news/articleView.html?idxno=39846",
|
||||
"BeautsGO https://www.beautsgo.com/ · https://github.com/BeautsGO/beautsgo-booking",
|
||||
"Bookimed 보고서 https://us-uk.bookimed.com/b2b-article/medical-tourism-report-bookimed-platform/",
|
||||
"ClinicsOnCall https://clinicsoncall.com (2026-09-15 조회)",
|
||||
"Korea Clinic Guide https://koreaclinicguide.com (2026-09-15 조회)",
|
||||
"ClinicSeoul https://clinicseoul.net (2026-09-15 조회)",
|
||||
"메디컬코리아 https://www.medicalkorea.or.kr/korp/main.do",
|
||||
"AI 크롤러·llms.txt: 각 사이트 robots.txt·sitemap·llms.txt 직접 조회 (2026-09-15)",
|
||||
]),
|
||||
]
|
||||
for title, items in groups:
|
||||
h3(doc, title)
|
||||
for it in items:
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
set_font(p.add_run(it), 9, False, GRAY)
|
||||
|
||||
if OUT.exists() and "--force" not in sys.argv:
|
||||
sys.exit(f"{OUT.name} 이 이미 있어 멈춥니다(haewon 편집본 보호). 새로 만들려면 --force")
|
||||
doc.save(OUT)
|
||||
print(OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
@ -1,190 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""haewon 편집본에 '4-10. 병원·의료 AEO/GEO 서비스 경쟁분석'과 부록 출처를 편집본에 직접 삽입한다.
|
||||
|
||||
문서를 다시 만들지 않는다. 기존 문단·표·서식은 그대로 두고, '5. 현재 조직으로 가능한 사업인가' 제목 앞에
|
||||
새 절을, 문서 끝(부록 출처 마지막)에 출처 묶음을 끼워 넣는다. '4-10.' 제목이 이미 있으면 아무것도 하지 않는다.
|
||||
조사 기준일 2026-09-15.
|
||||
|
||||
python3 docs/business/patch_aeo_geo_competition_docx.py
|
||||
"""
|
||||
import copy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent / "reports" / "answer_engines"))
|
||||
from build_engine_review_docx import Document, Pt, GRAY, bullets, heading, para, set_font, table # noqa: E402
|
||||
|
||||
TARGET = HERE / "INFINITH_의료관광허브_경쟁분석_및_뷰성형외과_MOU범위_2026-09-15.docx"
|
||||
ANCHOR = "5. 현재 조직으로 가능한 사업인가"
|
||||
MARK = "4-10. 병원·의료 AEO/GEO 서비스 경쟁분석"
|
||||
|
||||
|
||||
def text_of(el):
|
||||
return "".join(x.text or "" for x in el.iter() if x.tag.endswith("}t"))
|
||||
|
||||
|
||||
def h3(doc, t):
|
||||
heading(doc, t, 2)
|
||||
|
||||
|
||||
def note(doc, t):
|
||||
para(doc, t, 10, False, GRAY)
|
||||
|
||||
|
||||
def section(doc):
|
||||
h3(doc, MARK)
|
||||
para(doc, "플랫폼이 아니라 INFINITH가 지금 팔 수 있는 제품(AI Discovery 진단 + 서포터즈 사이트)이 실제로 경쟁하는 시장입니다. 국내 병원 특화 대행, 국내 범용 GEO SaaS, 해외 SaaS, 해외 의료 마케팅 에이전시를 각 사이트·보도자료에서 직접 확인했습니다. '미확인'은 공개 페이지에서 찾지 못했다는 뜻이며 기능이 없다는 뜻이 아닙니다.", 10.5)
|
||||
|
||||
h3(doc, "4-10-1. 시장 구도")
|
||||
table(doc, ["유형", "대표 사업자", "측정 방식", "가격대"], [
|
||||
["설문형 무료 진단", "이움코퍼레이션", "병원 담당자 설문 7문항, AI 엔진 질의 없음", "진단 무료, 대행 상담 후 제안"],
|
||||
["국내 병원 특화 대행", "마하닥-GEO, 아워마케팅, 크리앤서, 지오랭크, 와이더뷰티, BizSpring GEO Care for Clinic", "사이트 감사 + AI 엔진 질의(방식·문항 수 공개 수준이 업체마다 다름)", "월 29만~500만 원"],
|
||||
["국내 병원 대상 SaaS", "NATANA, 온더AI", "주간 또는 상시 AI 질의, 지표 정의 공개", "월 24만~250만 원"],
|
||||
["국내 범용 GEO", "서치폴라리스, 에이넥트, 팀하이, 블루닷, NNT Insights, RanketAI, FrostAI, 오픈타임, ZESTCOMPANY, Anymorph", "대행형은 진단+실행, SaaS는 프롬프트 추적", "월 7.9만~2,430만 원"],
|
||||
["해외 SaaS", "OtterlyAI, Profound, Peec AI, HubSpot AEO, Semrush, Ahrefs Brand Radar", "프롬프트 일간 추적, 일부 사이트 감사. 의료 특화 기능 미확인", "월 $29~$489"],
|
||||
["해외 의료 마케팅 에이전시", "Fuel Online, ADvance Media, Percepture, Specialist Practice Excellence, Plastic SEO", "코성형 등 시술별 AI visibility audit, 무료 프롬프트 배포", "월 $3,000~20,000+(Percepture)"],
|
||||
], widths=[3.0, 5.4, 5.2, 3.4], size=9)
|
||||
|
||||
h3(doc, "4-10-2. 국내 병원 특화 사업자")
|
||||
table(doc, ["사업자", "진단·측정", "방법론 공개", "국내 표면·의료법", "실행 범위", "가격", "성과 주장과 근거"], [
|
||||
["이움코퍼레이션", "설문 7문항(14점). AI 질의 없음", "문항만", "미확인", "FAQ·Schema·권위도", "상담 후", "AI 검색 이용 +68%, 재계약률 98%. 출처 없음. '200+ 병원' 실명 없음"],
|
||||
["마하닥-GEO", "AI 노출 지수 무료 진단. 항목 수 미확인", "미공개", "미확인", "JSON-LD, EEAT 콘텐츠, 홈페이지 제작", "미확인", "소아청소년과 인용 0→월 15회+, 피부과 객단가 +35%. 측정 방법 없음. '한국 1위' 근거 없음. FAQ에 자사 추천 문구"],
|
||||
["아워마케팅", "12개국 × 7개 플랫폼(ChatGPT·Claude·Gemini·Perplexity·AI Overview·AI Mode). 'API가 아닌 실제 Chrome + ISP 프록시' 화면 수집", "샘플 리포트만", "의료광고심의 가이드 메뉴", "다국어 콘텐츠, 전환 페이지, PR", "미확인", "한방 클리닉 SOV 90%+, 한의원 AI 봇 방문 462회. 레퍼런스 비공개"],
|
||||
["크리앤서", "공개 체크리스트 7항목. 진단 방식 미공개", "체크리스트만", "네이버 큐 포함. FAQ에 의료법·심의 이해 명시", "블로그, 검색광고, 플레이스, 홈페이지", "미확인", "성형외과 AI 노출 12%→78%. 측정 방법 미공개. 블로그 사례는 '가상 예시' 명시"],
|
||||
["지오랭크", "사이트 감사 7항목 0~100점 + 4개 LLM 매일 5회 새 세션", "항목·주기 공개", "미확인", "자사 도메인 발행, JSON-LD", "병의원 월 500만 원, 3개월 내 개선 없으면 50% 환불", "강남 S 피부과 ChatGPT 인용률 +280%, 예약 +42%. '자체 측정' 명시, 익명"],
|
||||
["NATANA", "ChatGPT·Gemini 매주. 페르소나 5~10명, 프롬프트 50~100개 반복. 무료 진단 5문항", "지표 6종·원문 보관 공개", "의료 표현은 고객 확인 후 실행", "수정안·초안. 발행은 Managed만", "월 24만·49만(출시 예정), Managed 250만부터", "해외 사례 인용 후 '동일 성과 보장 안 함' 명시"],
|
||||
["온더AI", "ChatGPT·Gemini·Perplexity 모니터링", "지표만", "블로그에 효과 단정·경험담 금지 안내", "콘텐츠 자동 생성, 서브도메인 자동 발행", "월 50만(50 프롬프트)·150만(200), 3개월 결제", "성모치과의원 SoV 26%→52%. 산출 방법 미공개. 실명 1곳"],
|
||||
["와이더뷰티", "미확인", "미확인", "의료광고 법률 자문 지원", "GEO·SEO, 브랜딩, 숏폼, 광고", "미확인", "미확인"],
|
||||
["BizSpring GEO Care for Clinic", "ChatGPT·Perplexity 기준 SoM", "미확인", "네이버 블로그·카카오채널 배포, 원장 승인 절차", "블로그·카카오 배포", "초기 49만+월 29만 / 초기 150만+월 100~150만", "'의료 사례 데이터 축적 중', 피부과 사례는 '예시 시나리오' 명시"],
|
||||
], widths=[2.2, 3.2, 1.6, 2.4, 2.4, 2.2, 3.0], size=7.5)
|
||||
|
||||
h3(doc, "4-10-3. 국내 범용 GEO 사업자")
|
||||
table(doc, ["사업자", "유형", "측정 방식", "국내 표면·의료법", "가격", "성과 주장 / 병원 사례"], [
|
||||
["서치폴라리스", "대행", "진단 '200개 항목'(목록 미공개). 4개 AI 엔진 + Google·Bing·Naver 검색", "브리핑 명시 없음", "스프린트 890만(4주), 월 890만, 엔터프라이즈 월 2,430만", "평균 언급률 +300% 산출법 미공개. 밝은눈안과 ChatGPT Top 3. 자사 1위 순위 글 게시"],
|
||||
["에이넥트", "진단 컨설팅", "4개 엔진 SoA·인용률·AVI, AID-Q 질문 설계", "미확인", "미확인", "병원 사례 미확인"],
|
||||
["팀하이", "SaaS", "ChatGPT·Gemini 감성 분석, GA4 연동", "미확인", "월 9.9만·49.5만", "병원 사례 미확인"],
|
||||
["블루닷", "SaaS+컨설팅", "DUCA 프레임워크, 문항 수 미확인", "미확인", "온보딩 490만부터, 3개월 3,000만부터", "평균 AI 인용 +65% 근거 미공개. 삼성서울병원 진단 글은 본문 404"],
|
||||
["NNT Insights", "SaaS", "ChatGPT·Gemini·Perplexity(Claude 옵션). 사용량 공식 공개", "네이버 AI 브리핑·AI Overview 스크린샷 수집(주 1회)", "월 50만·125만·250만", "병원 사례 미확인"],
|
||||
["오픈타임", "진단+대행", "기술 감사 배점 공개(robots 10, JSON-LD 15, llms.txt 5 등), 5개 엔진", "네이버 AI 브리핑 관측 베타", "미확인", "병원 사례 미확인"],
|
||||
["ZESTCOMPANY", "대행", "4개 엔진 + 네이버 AI 비교", "식약처·의료법 API로 주의 표현 점검", "3개월 단위, 금액 미확인", "안과 글은 현황 분석, 고객 성과 아님"],
|
||||
["RanketAI · FrostAI · Anymorph", "SaaS", "RanketAI 페이지 구조 5기준+3엔진, FrostAI 3엔진(월 7.9만부터), Anymorph 8엔진(월 110만부터)", "Anymorph 'Naver AI' 표기, 세부 미확인", "위 기재", "병원 사례 미확인"],
|
||||
], widths=[2.4, 1.6, 4.2, 3.0, 2.8, 3.0], size=8)
|
||||
|
||||
h3(doc, "4-10-4. 해외 SaaS와 의료 마케팅 에이전시")
|
||||
table(doc, ["사업자", "측정 방식", "의료·규제", "가격"], [
|
||||
["OtterlyAI", "ChatGPT·AI Overview·Perplexity·Copilot. GEO Audit 25+ 요인(크롤링·콘텐츠·Query Fan-Out)", "의료 특화 미확인", "$29(15)·$189(100)·$489(400 프롬프트)"],
|
||||
["Profound", "매일 추적. Starter는 ChatGPT만, Growth는 3엔진", "미확인", "$99·$399(연간)"],
|
||||
["Peec AI", "6엔진 중 3개 선택, 매일", "미확인", "€70·€180·€360(리뷰 사이트 기준)"],
|
||||
["HubSpot AEO", "ChatGPT·Gemini·Perplexity, 무료 Grader 1회 진단", "미확인", "$50/월(25 프롬프트)"],
|
||||
["Semrush · Ahrefs", "Semrush AI Visibility Toolkit(AI Mode·ChatGPT 등), Ahrefs 457M+ 프롬프트 DB", "미확인", "Semrush $99/월, Ahrefs $50~199/월"],
|
||||
["Fuel Online", "코성형 전용 AI visibility audit. mention rate·citation share(본문 403, 검색 스니펫 기준)", "미확인", "미확인"],
|
||||
["ADvance Media", "5개 AI 표면, 월 약 1,000단어 콘텐츠 + 소수 AI 질의 재확인", "HIPAA 준수 CRM 언급", "미공개"],
|
||||
["Percepture", "진단에 AI 인용 여부 포함(GSC·Screaming Frog·Semrush)", "'compliance awareness' 수준", "성형 SEO 월 $3,000~5,500, SEO+GEO+PR 월 $8,000~20,000+, 기술 감사 $2,500부터"],
|
||||
["Specialist Practice Excellence", "무료 마스터 프롬프트 공개(판매형 audit 아님)", "프롬프트에 AHPRA·FTC 등 규제 검토 지시", "해당 없음"],
|
||||
], widths=[3.2, 7.0, 3.2, 3.6], size=8.5)
|
||||
|
||||
h3(doc, "4-10-5. 비교 축별 INFINITH 위치")
|
||||
table(doc, ["비교 축", "경쟁사 수준 (대표 사례)", "INFINITH", "평가"], [
|
||||
["진단 기준 공개", "서치폴라리스 200항목 목록 비공개, 지오랭크 7항목, 오픈타임 배점 일부, Otterly 25+ 요인", "36항목, 항목별 0~3 레벨 기준과 판정 근거 URL 공개", "우위"],
|
||||
["자동·수동 구분, 미검증 처리", "공개 사례 없음", "자동 22·반자동 5·수동 9, 미검증은 분모 제외", "우위"],
|
||||
["답변 실측 방식", "NATANA 주간 50~100문항 반복, 지오랭크 4엔진 매일 5회, 아워마케팅 12개국×7플랫폼 브라우저 수집, NNT 브리핑 스크린샷", "120문항(국문)·60문항(영문) × ChatGPT·Perplexity 공식 API, 현재 1회", "엔진 수·빈도 열위. 공식 API 방식은 약관 측면에서 보수적"],
|
||||
["답변 품질 판정", "NATANA 정확률, 팀하이·HubSpot 감성", "gpt-4.1 어조 판정(기준표 v0.3)", "동등"],
|
||||
["국내 표면", "네이버 AI 브리핑 측정: NNT, 오픈타임(베타), ZEST. 강남언니 언급: 조사 대상 0곳", "강남언니·네이버 플레이스·GBP를 진단 항목(D1·D2·D5)으로 실측. 네이버 AI 브리핑 답변은 측정 안 함", "강남언니 우위, 브리핑 열위"],
|
||||
["의료법 56조 처리 위치", "안내문(온더AI), 법률 자문(와이더뷰티), API 표현 점검(ZEST), 원장 승인 절차(BizSpring)", "발행 게이트가 금지 유형을 코드로 차단, 원장 의학 검토·개설자 승인 기록이 있어야 공개", "우위"],
|
||||
["실행 산출물", "초안 전달(NATANA), 서브도메인 자동 발행(온더AI), 블로그·카카오 배포(BizSpring), 홈페이지 제작(마하닥)", "병원 승인 원문 기반 서포터즈 사이트 자동 빌드(병원당 약 30분·$0.6). URL만으로는 60~70% 품질 구간 존재", "우위(품질 보완 필요)"],
|
||||
["성과 검증 가능성", "익명·가상 예시·자체 측정이 대부분. 실명은 성모치과의원(온더AI), 밝은눈안과(서치폴라리스) 정도", "뷰성형외과 실명 기준선(같은 질문 세트로 전후 비교 설계). 재측정 전", "잠재 우위(POC 결과 필요)"],
|
||||
["일간 대시보드·셀프서브", "SaaS 대부분 제공", "없음", "열위"],
|
||||
["가격 구조", "월 7.9만~890만 원, 환불 조건(지오랭크 50%)", "미확정(9/8 제안 초안: 진단 49만, 서포터즈 월 99만·199만)", "결정 필요"],
|
||||
], widths=[2.8, 6.0, 5.6, 2.6], size=8.5)
|
||||
|
||||
h3(doc, "4-10-6. 이 시장의 공통 패턴")
|
||||
bullets(doc, [
|
||||
("성과 수치는 공개하고 산출 방법은 공개하지 않습니다. ", "크리앤서 12%→78%, 서치폴라리스 +300%, 블루닷 +65%, 마하닥 사례가 해당합니다. 사용량 공식이나 원문 보관을 공개한 곳은 NNT Insights와 NATANA 두 곳입니다."),
|
||||
("무료 진단의 다음 단계가 연락처 수집입니다. ", "설문형(이움)과 사이트 감사형(지오랭크 Scanner) 무료 진단은 실제 AI 답변을 측정하지 않습니다."),
|
||||
("자사를 추천하는 콘텐츠를 스스로 게시합니다. ", "마하닥 FAQ, 서치폴라리스·Anymorph의 업체 순위 글, 지오랭크 홈의 ChatGPT 답변 캡처가 해당합니다."),
|
||||
("국내 표면 반영이 약합니다. ", "강남언니를 언급한 곳은 없고, 네이버 AI 브리핑을 측정하는 곳은 범용 SaaS 3곳뿐입니다. 병원 특화 업체에서는 확인되지 않았습니다."),
|
||||
("의료법 대응은 선언 수준입니다. ", "56조나 사전심의를 발행 공정에 넣었다고 밝힌 곳은 없습니다. 병원 레퍼런스는 대부분 익명이거나 예시입니다."),
|
||||
], size=10)
|
||||
|
||||
h3(doc, "4-10-7. 시사점")
|
||||
bullets(doc, [
|
||||
("INFINITH가 실제로 경쟁하는 곳은 이 시장입니다. ", "사업자는 많지만, 기준·방법론 공개와 의료법의 공정 반영에서 INFINITH보다 앞선 곳을 찾지 못했습니다. 플랫폼 경쟁(4-1~4-9)과 달리 출발점이 앞서 있습니다."),
|
||||
("뷰성형외과 POC의 가치는 실명 전후 측정 사례입니다. ", "경쟁사 레퍼런스가 대부분 익명·예시인 시장에서, 같은 질문 세트로 측정한 실명 결과는 드뭅니다. MOU 홍보 포인트를 '실측 기준선과 8주 재측정'으로 잡을 것을 권고합니다."),
|
||||
("보완할 약점은 세 가지입니다. ", "① 네이버 AI 브리핑 답변 측정(국내 병원이 먼저 묻는 표면), ② 반복 측정(현재 1회, 5회 반복으로 신뢰구간 보고), ③ 가격 확정."),
|
||||
("비교 광고 표현에 주의합니다. ", "브라우저 수집 방식이나 성과 산출법을 문제로 삼아 경쟁사를 지목하면 표시광고법상 비교·비방 광고 문제가 될 수 있습니다. 외부 자료에는 'INFINITH는 공식 API로 측정하고 기준을 공개합니다'처럼 우리 방식만 서술합니다."),
|
||||
("랜딩 문구 방향과 일치합니다. ", "'36개 항목, 항목마다 측정 근거를 붙여 진단'이 이 시장에서 확인된 차별점(기준 공개)과 맞습니다."),
|
||||
], size=10)
|
||||
note(doc, "조회일 2026-09-15. 가격·기능은 각 사 공개 페이지 기준이며 변경될 수 있습니다. Fuel Online은 본문 403, 블루닷 삼성서울병원 글은 404로 제목만 확인했습니다. MindScope GEO는 공식 사이트를 찾지 못했습니다.")
|
||||
|
||||
|
||||
SOURCES = [
|
||||
"이움코퍼레이션 https://www.eumcorporation.com/aeo-geo/",
|
||||
"마하닥-GEO https://hospitalgeo.com",
|
||||
"아워마케팅 GEO 진단 https://ourmarketing.co.kr/geo-diagnosis · https://ourmarketing.co.kr/service/expertise/geo · 출시 기사 http://www.bogunuilyo.com/news/503413 (2026-07-10)",
|
||||
"와이더뷰티 https://mdtoday.co.kr/news/view/1065602577857715 (2026-05-28)",
|
||||
"크리앤서 https://www.creanswer.com · 병원 GEO 체크리스트 https://creanswer.com/blog/geo-checklist-7/ (2026-05-04)",
|
||||
"NATANA https://natana-ai.com/clinic",
|
||||
"온더AI https://onthe.ai/pricing · 병원 GEO 가이드 https://blog.onthe.ai/geo-ai-search-hospital-marketing (2026-04-02)",
|
||||
"지오랭크 https://georank.co.kr · 병원 사례 https://georank.co.kr/case/hospital-clinic-geo · 비용 https://georank.co.kr/geo/geo-cost-pricing",
|
||||
"BizSpring GEO Care for Clinic https://blog.bizspring.co.kr (2026-05-28, 수정 2026-08-27)",
|
||||
"서치폴라리스 https://searchpolaris.com · 가격 https://searchpolaris.com/pricing",
|
||||
"에이넥트 https://ainnect.co.kr",
|
||||
"팀하이 https://teamhai.co.kr",
|
||||
"블루닷 https://bi.bluedot.so/consulting",
|
||||
"NNT Insights https://nnt-insights.com/pricing",
|
||||
"RanketAI https://www.ranketai.com/ko/faq · AI 가시성 측정 도구 비교 https://www.ranketai.com/ko/blog/ai-visibility-tools-comparison-2026-05-16",
|
||||
"FrostAI https://www.frostai.co",
|
||||
"오픈타임 https://ai.idearabbit.co.kr",
|
||||
"ZESTCOMPANY https://zestcompany.co.kr",
|
||||
"Anymorph https://anymorph.ai · 한국 GEO·AEO 서비스 TOP 5 https://anymorph.ai/contents/best/korea-geo-aeo-services-top-5-2026 (2026-08-18)",
|
||||
"OtterlyAI https://otterly.ai/pricing · GEO Audit https://otterly.ai/blog/generative-engine-optimization-audit/ (2025-08-04) · https://help.otterly.ai/what-does-the-geo-audit-do (2026-08-18)",
|
||||
"Profound https://www.tryprofound.com/pricing",
|
||||
"Peec AI https://peec.ai/pricing · 리뷰 https://generatemore.ai/blog/peec-ai-review (2026-08-28)",
|
||||
"HubSpot AEO https://www.hubspot.com/products/aeo · 대안 비교 https://blog.hubspot.com/marketing/peec-ai-alternatives",
|
||||
"Semrush AI Toolkit https://www.semrush.com/kb/1493-ai-toolkit · Ahrefs Brand Radar https://ahrefs.com/brand-radar",
|
||||
"AI Search Visibility Tools 비교 https://www.surmado.com/blog/best-ai-visibility-tools-2026",
|
||||
"Fuel Online https://fuelonline.com/plastic-surgery-marketing/rhinoplasty/ai-visibility-audit/",
|
||||
"ADvance Media https://advancemedia.com/services/ai-visibility-aeo-geo/",
|
||||
"Specialist Practice Excellence https://specialistpracticeexcellence.com/blog/prompt-to-audit-aeo-ai-visibility-for-plastic-surgeons/ (2026-07-25)",
|
||||
"Plastic SEO https://www.plasticseo.com/how-ai-search-is-changing-plastic-surgery-seo/ (2026-07-01)",
|
||||
"Percepture https://percepture.com/healthcare-insights/plastic-surgery-seo-services/ (2026-06-11)",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
doc = Document(TARGET)
|
||||
body = doc.element.body
|
||||
children = list(body.iterchildren())
|
||||
if any(c.tag.endswith("}p") and text_of(c).strip().startswith("4-10.") for c in children):
|
||||
print("이미 4-10 절이 있어 아무것도 하지 않았습니다.")
|
||||
return
|
||||
anchor = next((c for c in children if c.tag.endswith("}p") and text_of(c).strip() == ANCHOR), None)
|
||||
if anchor is None:
|
||||
sys.exit(f"기준 제목을 찾지 못했습니다: {ANCHOR}")
|
||||
|
||||
tmp = Document()
|
||||
section(tmp)
|
||||
for el in [e for e in tmp.element.body.iterchildren() if not e.tag.endswith("}sectPr")]:
|
||||
anchor.addprevious(copy.deepcopy(el))
|
||||
|
||||
src = Document()
|
||||
heading(src, "병원·의료 AEO/GEO 서비스 (4-10, 조회일 2026-09-15)", 2)
|
||||
for s in SOURCES:
|
||||
p = src.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
set_font(p.add_run(s), 9, False, GRAY)
|
||||
sect = body.find("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sectPr")
|
||||
for el in [e for e in src.element.body.iterchildren() if not e.tag.endswith("}sectPr")]:
|
||||
new = copy.deepcopy(el)
|
||||
if sect is not None:
|
||||
sect.addprevious(new)
|
||||
else:
|
||||
body.append(new)
|
||||
|
||||
doc.save(TARGET)
|
||||
print(f"삽입 완료: {TARGET.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,298 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>INFINITH Global Medical Hub PRD v0.2</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--navy:#0A1128;--navy800:#1A2B5E;--near:#F4F6FB;--accent:#6C5CE7;--g1:#4F1DA1;--g2:#021341;--slate7:#334155;--slate6:#475569;--slate5:#64748B;--line:#E2E8F0;
|
||||
--crit-bg:#FFF0F0;--crit-tx:#7C3A4B;--crit-bd:#F5D5DC;--crit-dot:#D4889A;--warn-bg:#FFF6ED;--warn-tx:#7C5C3A;--warn-bd:#F5E0C5;--warn-dot:#D4A872;--good-bg:#F3F0FF;--good-tx:#4A3A7C;--good-bd:#D5CDF5;--good-dot:#9B8AD4;--info-bg:#EFF0FF;--info-tx:#3A3F7C;--info-bd:#C5CBF5;--info-dot:#7A84D4}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,Inter,-apple-system,sans-serif;color:var(--navy);background:#fff;line-height:1.65;font-size:15px;word-break:keep-all}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
section{padding:96px 24px}.wrap{max-width:1160px;margin:0 auto}
|
||||
.dark{background:var(--navy);color:#fff;position:relative}.dark::before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at top right,rgba(108,92,231,.15),transparent 60%);pointer-events:none}.dark .wrap{position:relative}
|
||||
.light{background:#fff}.near{background:var(--near)}
|
||||
.eyebrow{font-family:Inter,sans-serif;font-size:12px;font-weight:600;letter-spacing:.24em;text-transform:uppercase;color:var(--accent)}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:56px;line-height:1.05;margin:14px 0 10px;letter-spacing:-.02em}h2{font-size:38px;line-height:1.15;margin:10px 0 14px}h3{font-size:18px;font-weight:700;margin:0 0 8px}
|
||||
.dark h2{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.sub{font-size:17px;color:var(--slate6);max-width:820px}.dark .sub{color:#DDD6FE}
|
||||
p{margin:0 0 10px}.muted{color:var(--slate5);font-size:13.5px}.dark .muted{color:#C4B5FD}
|
||||
.grid{display:grid;gap:18px}.g2{grid-template-columns:repeat(2,1fr)}.g3{grid-template-columns:repeat(3,1fr)}.g4{grid-template-columns:repeat(4,1fr)}
|
||||
@media(max-width:900px){.g2,.g3,.g4{grid-template-columns:1fr}}
|
||||
.card{background:#fff;border:1px solid #F1F5F9;border-radius:18px;padding:22px 24px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy)}.card:hover{box-shadow:4px 6px 16px rgba(0,0,0,.09)}
|
||||
.card p{color:var(--slate7);font-size:14.5px}
|
||||
.grad{background:linear-gradient(90deg,var(--g1),var(--g2));color:#fff;border:0}.grad p,.grad .muted{color:#DDD6FE}.grad h3{color:#fff}
|
||||
.cta{background:linear-gradient(to right,#fff3eb,#e4cfff,#f5f9ff);border:0}
|
||||
.badge{display:inline-block;font-family:Inter,sans-serif;font-size:11.5px;font-weight:600;border:1px solid;border-radius:999px;padding:3px 10px;line-height:1.3}
|
||||
.b-crit{background:var(--crit-bg);color:var(--crit-tx);border-color:var(--crit-bd)}.b-warn{background:var(--warn-bg);color:var(--warn-tx);border-color:var(--warn-bd)}.b-good{background:var(--good-bg);color:var(--good-tx);border-color:var(--good-bd)}.b-info{background:var(--info-bg);color:var(--info-tx);border-color:var(--info-bd)}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:14px;overflow:hidden;font-size:13.5px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}th{background:var(--navy);color:#fff;text-align:left;padding:11px 13px;font-size:12.5px;font-weight:600}td{padding:11px 13px;border-bottom:1px solid #EEF2F7;vertical-align:top;color:var(--slate7)}tr:last-child td{border-bottom:0}td b{color:var(--navy)}
|
||||
.dark table{color:var(--navy)}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:#fff;border-radius:16px;padding:18px 20px;box-shadow:3px 4px 12px rgba(0,0,0,.06)}.kpi b{display:block;font-family:'Playfair Display',serif;font-size:34px;color:var(--accent);line-height:1}.kpi span{font-size:12.5px;color:var(--slate5)}
|
||||
@media(max-width:900px){.kpi{grid-template-columns:repeat(2,1fr)}}
|
||||
.flow{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;counter-reset:s}.flow .st{background:#fff;border-radius:14px;padding:14px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy);font-size:13px}.flow .st::before{counter-increment:s;content:counter(s);display:inline-block;font-family:'Playfair Display',serif;font-weight:700;color:var(--accent);font-size:20px;margin-bottom:4px}
|
||||
@media(max-width:900px){.flow{grid-template-columns:repeat(2,1fr)}}
|
||||
ul{margin:6px 0 0 18px;padding:0}li{margin:4px 0;color:var(--slate7)}.dark li{color:#E9E5FF}
|
||||
.pill{display:inline-block;background:#F1F5F9;color:var(--slate6);border-radius:999px;padding:2px 9px;font-size:11.5px;font-family:Inter,sans-serif;font-weight:500;margin:0 4px 4px 0}
|
||||
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent)}
|
||||
.toc a{color:var(--accent);text-decoration:none}.toc li{color:var(--slate6)}
|
||||
footer{padding:36px 24px;font-size:12.5px;color:var(--slate5);border-top:1px solid var(--line);text-align:center}
|
||||
@media print{@page{size:A4;margin:11mm}section{padding:26px 6px;page-break-inside:auto}.pb{page-break-before:always}h1{font-size:40px}h2{font-size:28px}.card,tr{page-break-inside:avoid}.dark,.grad,.badge,.kpi .k,.cta,th{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
||||
</style></head><body>
|
||||
|
||||
<!-- 0. Cover -->
|
||||
<section class="dark" style="padding-top:120px;padding-bottom:88px"><div class="wrap">
|
||||
<div class="eyebrow">INFINITH · Product Requirements Document · v0.2 Draft</div>
|
||||
<h1 class="serif">Global Medical Hub</h1>
|
||||
<div style="font-size:24px;font-weight:700;margin-bottom:6px">외국인 환자가 AI에 물었을 때 인용되는, AIO2O 운영 중립 의료관광 허브</div>
|
||||
<p class="sub">한국 성형외과·피부과의 시술·의료진·회복·비용 항목을 다국어 원문으로 공급하고, 상담·예약과 회복기 여정(숙박·식사·관광·미용)을 한 곳에서 연결하는 플랫폼. 병원이 아니라 AIO2O가 소유·운영한다. 지금 완성된 AI Discovery 진단과 서포터즈 자동 빌드 파이프라인이 공급 쪽 절반을 이미 자동화했다.</p>
|
||||
<div class="kpi" style="margin-top:34px">
|
||||
<div class="k"><b>3</b><span>제품 층 (정보·발견 / 상담·예약 / 여정 연결)</span></div>
|
||||
<div class="k"><b>2</b><span>검증된 병원 (뷰성형외과 18/18 · 원진 6/6, 사람 개입 0)</span></div>
|
||||
<div class="k"><b>$0.6</b><span>병원 1곳 온보딩 실측 비용 (30분)</span></div>
|
||||
<div class="k"><b>8~12주</b><span>1단계 글로벌 허브 MVP</span></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:22px">작성 2026-09-08 · 작성 Claude (haewon 세션) · 검토 haewon · 결정 CEO · 배경: 2026-09-08 CEO·뷰성형외과 미팅 (원장의 의료관광 포털 제안) · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인 필요<br>v0.2 개정 2026-09-09 · 앵커의 병원 소개 역량 반영 (§04 앵커 계약 조항, §05 소개 구조 확인, §08 소개 규모·CEO 결정, §10 앵커 의존 리스크·미결 2건)</p>
|
||||
</div></section>
|
||||
|
||||
<!-- TOC -->
|
||||
<section class="light" style="padding:56px 24px"><div class="wrap toc">
|
||||
<div class="eyebrow">Contents</div>
|
||||
<ol style="columns:2;gap:40px;margin:10px 0 0 18px;font-size:14.5px">
|
||||
<li><a href="#s1">배경과 문제</a></li><li><a href="#s2">제품 정의와 범위</a></li><li><a href="#s3">사용자와 핵심 여정</a></li><li><a href="#s4">중립성과 IP: 운영 구조 비교</a></li><li><a href="#s5">AIO2O가 신청·등록·갖춰야 하는 것</a></li><li><a href="#s6">기술 스택과 기능: 완료 / 부분 / 신규</a></li><li><a href="#s7">아키텍처와 데이터 흐름</a></li><li><a href="#s8">로드맵</a></li><li><a href="#s9">KPI와 측정</a></li><li><a href="#s10">리스크와 미결</a></li><li><a href="#s11">부록: 근거 자료</a></li>
|
||||
</ol>
|
||||
</div></section>
|
||||
|
||||
<!-- 1. 배경 -->
|
||||
<section id="s1" class="near"><div class="wrap">
|
||||
<div class="eyebrow">01 · Why Now</div><h2 class="serif">Why Now</h2>
|
||||
<p class="sub">병원 홈페이지와 네이버 자산은 글로벌 AI 답변에 닿지 않는다. 그 공백을 원문 매체로 채우는 것이 INFINITH의 처방이고, 외국인 환자 시장은 인용할 원문이 가장 부족한 곳이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-crit">진단 근거</span><h3 style="margin-top:10px">병원은 읽히지만 인용되지 않는다</h3><p>뷰성형외과 AEO 42 / GEO 56, 원진성형외과 AEO 15 / GEO 45. 크롤러 접근은 열려 있으나 정의문·FAQ·표·의료진 스키마가 없다. 원진 영문 사이트는 hreflang조차 없다.</p></div>
|
||||
<div class="card"><span class="badge b-warn">시장 신호</span><h3 style="margin-top:10px">병원이 먼저 포털을 원한다</h3><p>2026-09-08 CEO 미팅에서 뷰성형외과 원장이 성형외과·피부과를 모은 글로벌 의료관광 포털을 제안했다. 반응은 좋았으나 "비슷한 제안이 많다"는 말도 들었다. 차별점은 실측 진단과 인용되는 원문, 그리고 자동 온보딩이다.</p></div>
|
||||
<div class="card"><span class="badge b-good">준비된 것</span><h3 style="margin-top:10px">공급 쪽은 이미 자동이다</h3><p>이름 하나로 채널 확인, 근거 수집, 팩트 시트, 의료진, 영상, 이미지(의료법 필터), 글 6편, 발행 게이트, 미리보기까지 30분에 $0.6. 두 병원에서 사람 개입 없이 완주했다.</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>우리가 채울 공백</h3><p style="color:var(--navy)">외국인이 ChatGPT·Perplexity·Gemini에 "best rhinoplasty clinic in Seoul for foreigners"라고 물었을 때 인용할 다국어 원문(시술 조건 표·회복 일정·의료진·안전 체계·비용 항목)이 오픈웹에 사실상 없다. 기존 앱(강남언니 글로벌·바비톡·크리에이트립)은 정보를 앱 안에 가두고, 병원 영문 사이트는 슬로건과 이미지다. 네이버 자산은 robots.txt로 AI 크롤러를 막아 기여도 0이다(2026-08-26 실측).</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 2. 제품 정의 -->
|
||||
<section id="s2" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">02 · Product Definition</div><h2 class="serif">What We Build</h2>
|
||||
<p class="sub">세 층으로 나눈다. 첫 층은 우리가 만들고, 둘째 층은 자격을 갖춰 만들며, 셋째 층은 만들지 않고 연결한다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1층 · 만든다</span><h3 style="margin-top:10px">정보·발견</h3><p>병원별 다국어 원문 페이지(시술 조건 표·회복 일정·의료진·안전·비용 항목·FAQ), 병원 공통 허브(시술별 비교 없는 목록·질문별 답), AI 답변엔진·검색 인용 구조(MedicalClinic·Physician·Article·FAQPage·hreflang). 지금 파이프라인 그대로.</p></div>
|
||||
<div class="card"><span class="badge b-warn">2층 · 자격 후 만든다</span><h3 style="margin-top:10px">상담·예약</h3><p>병원 선택 → 상담 신청 → 통역 포함 예약 확정 → 결제·정산. 대가를 받는 순간 외국인환자 유치업자 등록이 필요하다. 1단계에서는 상담 신청을 병원에 직접 넘기고 수수료를 받지 않는다.</p></div>
|
||||
<div class="card"><span class="badge b-info">3층 · 연결한다</span><h3 style="margin-top:10px">회복기 여정</h3><p>숙박·식사·관광·축제·미용·배달은 제휴 링크와 파트너 API로 연결한다. 직접 판매하면 여행업·통신판매업 등록이 따라온다. 배달은 국내 앱이 외부 API를 열지 않아 정보 안내 수준.</p></div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>제품이 아닌 것</h3><ul><li>병원 홈페이지를 대체하는 사이트. 홈페이지는 그대로 두고 sameAs로 같은 엔티티에 묶는다.</li><li>후기·전후 사진·병원 비교·가격 할인으로 설득하는 앱. 의료법 56조 2항이 막는 것은 처음부터 없다.</li><li>슈퍼앱. 예약·숙박·배달 시스템을 처음부터 짓지 않는다.</li><li>특정 병원의 사이트. 뷰성형외과는 앵커 파트너이지 소유자가 아니다.</li></ul></div>
|
||||
<div class="card"><h3>성공의 정의 (1단계)</h3><ul><li>병원 4곳 이상이 다국어 원문으로 발행되고 각 병원 개설자 승인이 데이터로 켜진다.</li><li>외국인 질문 뱅크(영·일·중 각 60문항) 실측에서 허브 인용률이 0에서 유의미하게 오른다. 소유 신호(서버 로그 AI 크롤러 히트, GA4 AI 유입)로 잰다.</li><li>상담 신청이 병원으로 전달되고 병원이 응답한 건수를 센다.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 3. 사용자 -->
|
||||
<section id="s3" class="light"><div class="wrap">
|
||||
<div class="eyebrow">03 · Users & Journeys</div><h2 class="serif">Who It Serves</h2>
|
||||
<div class="grid g4" style="margin-top:22px">
|
||||
<div class="card"><h3>외국인 환자</h3><p>일본·중국어권·동남아·영어권. AI와 검색에 질문 → 허브 원문 → 병원 페이지 → 상담 신청 → 방문·회복. 원하는 것은 조건(시간·마취·회복·비용 항목)과 안전, 그리고 의사소통.</p></div>
|
||||
<div class="card"><h3>병원 담당자</h3><p>온보딩 폼(v2 §6 9항목), 글별 검토자·검토일 입력, 상담 신청 응답. 기술 지식 없음. 지금 만든 `/supporters/:id` 패널이 시작점.</p></div>
|
||||
<div class="card"><h3>원장·개설자</h3><p>의학 검토, 게시 승인. 의료광고 주체. 승인 기록(승인자·직함·일시·글 버전)이 법적 근거가 된다.</p></div>
|
||||
<div class="card"><h3>AIO2O 운영자</h3><p>큐·워커 운영, 게이트 실패 처리, 이미지 육안 확인, "확인 필요" 채널 판정, 정정 접수. 비개발자가 운영할 수 있어야 한다.</p></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px"><h3>핵심 여정 (외국인 환자)</h3>
|
||||
<div class="flow" style="margin-top:10px">
|
||||
<div class="st">AI·검색에 질문. 허브의 질문형 글이 인용된다</div><div class="st">시술 조건 표·회복 일정·비용 항목을 언어별로 읽는다</div><div class="st">병원 페이지에서 의료진·안전 체계·확인일을 본다</div><div class="st">상담 신청(언어·희망 시술·일정). 1단계는 병원 직결</div><div class="st">예약 확정·통역·결제 (2단계, 유치업 등록 후)</div><div class="st">회복기 숙박·식사·관광 제휴 링크와 주의사항 안내 (3단계)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 4. 중립성과 IP -->
|
||||
<section id="s4" class="near"><div class="wrap">
|
||||
<div class="eyebrow">04 · Neutrality & IP</div><h2 class="serif">Ownership Structure</h2>
|
||||
<p class="sub">포털의 생명은 중립성이다. 한 병원의 계열 플랫폼에는 두 번째 병원이 들어오지 않는다. 파이프라인 IP는 AIO2O 사업 자체이므로 어떤 구조에서도 밖에 두지 않는다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">기준</th><th>뷰성형외과 자회사</th><th>뷰 · AIO2O 합작법인</th><th>AIO2O 단독 + 뷰 앵커 파트너 (추천)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>다른 병원 참여</b></td><td><span class="dot" style="background:var(--crit-dot)"></span>어렵다. 경쟁 병원 계열사에 의료진·비용 항목을 올릴 이유가 없다</td><td><span class="dot" style="background:var(--warn-dot)"></span>뷰 지분이 보이면 같은 경계. 지분이 작고 공개돼 있으면 완화</td><td><span class="dot" style="background:var(--good-dot)"></span>문제없다</td></tr>
|
||||
<tr><td><b>의료광고·고지</b></td><td>자회사 매체가 모병원을 노출하면 경제적 이해관계 고지가 페이지마다 붙고 추천·보증 심사지침 대상</td><td>같다. 뷰 관련 페이지에 지분 관계 고지</td><td>병원별 지원 관계 고지만</td></tr>
|
||||
<tr><td><b>유치업 등록</b></td><td>의료기관도 유치 등록은 가능하나 타 병원 환자 중개는 별도 법인이 깔끔</td><td>합작법인이 유치업자로 등록하면 자연스럽다</td><td>AIO2O 법인이 등록 (2단계 전)</td></tr>
|
||||
<tr><td><b>자본·앵커</b></td><td>병원이 자본을 댄다</td><td>자본·첫 병원·의료 자문을 같이 얻는다</td><td>자본은 AIO2O 몫. 앵커는 계약으로</td></tr>
|
||||
<tr><td><b>IP·속도</b></td><td>파이프라인이 병원 소유가 된다</td><td>IP를 JV에 넣으면 사업이 JV에 묶인다. 의사결정 둘</td><td>IP·속도 모두 AIO2O</td></tr>
|
||||
<tr><td><b>브랜드 리스크</b></td><td>뷰의 2026-07 공정위 시정명령(후기 뒷광고) 이력이 그대로 붙는다</td><td>같은 이력이 JV 브랜드에 묻는다</td><td>파트너 병원 중 하나의 이력</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card grad"><h3>추천 구조</h3><ul style="color:#E9E5FF"><li>플랫폼·파이프라인·게이트·데이터 모델 IP는 AIO2O 100%. 라이선스도 넘기지 않는다.</li><li>뷰성형외과는 앵커 파트너 계약: 첫 병원 무상 온보딩, 다국어 첫 발행, 병원 2~4곳 소개, 의료 자문. 대가는 소액 지분(10% 미만, 공개) 또는 소개 수수료.</li><li>예약 수수료를 받는 2단계에서 유치업 전용 법인이 필요하면 그 법인만 합작 검토. 자본금·배상보험을 나눠 지고 플랫폼 IP는 밖에 둔다.</li></ul></div>
|
||||
<div class="card"><h3>합작을 하게 된다면 계약 필수 조항</h3><ul><li>IP 귀속: 파이프라인·게이트·데이터 모델은 AIO2O 소유, JV는 사용권.</li><li>뷰 지분 상한과 중립성 조항: 노출 순서·비교 금지, 타 병원 정보 접근 제한.</li><li>의료광고 책임 분리: 각 병원 개설자가 광고 주체, JV는 매체·대행. 사전심의·유치 광고 규정 준수는 병원 책임.</li><li>공정위 이력에 대한 표명·보증. JV 이름에 병원명을 넣지 않는다.</li><li>데이터: 병원 자산 사용 허락 범위, 환자 데이터 처리 주체.</li></ul></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px;border-color:var(--info-bd)">
|
||||
<span class="badge b-info"><span class="dot" style="background:var(--info-dot)"></span>v0.2 추가 · 2026-09-09</span>
|
||||
<h3 style="margin-top:10px">앵커의 병원 소개 역량이 큰 경우</h3>
|
||||
<p><b>전달 사항(미확인).</b> 2026-09-09 haewon 전달. 뷰성형외과 원장이 다른 병원 네트워크·파트너십이 많아 병원을 유치할 수 있다고 밝혔다. 병원 명단·수·과목·지역·현재 관계 단계는 확인되지 않았다. 이 문서는 이를 사실이 아니라 확인 대상으로 둔다.</p>
|
||||
<p><b>구조 판단은 바뀌지 않는다.</b> 소개는 계약으로 확보할 수 있다. IP 귀속과 중립성 요건은 소개 규모와 무관하다. 위 비교표의 "다른 병원 참여" 행은 자회사·합작법인 구조에 대한 판단이며, 추천 구조는 이미 앵커가 병원을 소개하는 것을 전제로 한다(§07 입력 경로, §08 1단계). 소개 역량이 크다면 바뀌는 것은 구조가 아니라 <b>소개 목표 수치와 그 대가</b>다.</p>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div>
|
||||
<h3>앵커 계약에 넣을 조항 (신규)</h3>
|
||||
<ul>
|
||||
<li>소개 목표를 수치로: 1단계 소개 N곳, 계약 체결 M곳. 미달·초과 시 대가 조정.</li>
|
||||
<li>대가는 성과 연동: 계약 체결 병원 수에 따른 지분 베스팅(상한 10% 미만·공개 유지) 또는 병원당 소개 수수료.</li>
|
||||
<li>소개받은 병원은 AIO2O와 직접 계약한다. 앵커를 거치는 계약 구조를 두지 않는다.</li>
|
||||
<li>노출 우대 없음: 소개 병원에도 노출 순서 규칙과 비교·순위 금지를 동일 적용한다.</li>
|
||||
<li>앵커 이탈 시 병원 계약 존속: 앵커 관계 종료가 병원 계약의 해지 사유가 아님을 명시한다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>다음 미팅 확인 질문</h3>
|
||||
<ul>
|
||||
<li>병원 수·이름·과목·지역, 그리고 현재 관계 단계(지인·구두·계약).</li>
|
||||
<li>관계의 성격: 공동 마케팅인지, 환자 리퍼럴인지, 학회·공동구매인지. 성격에 따라 §05 확인 항목이 달라진다.</li>
|
||||
<li>각 병원이 AIO2O와 직접 계약하는 데 동의하는지.</li>
|
||||
<li>대가로 원하는 것: 지분·수수료·노출 우대 중 무엇인지. 노출 우대를 원하면 중립성 원칙과 충돌하므로 재협의가 필요하다.</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:8px">답변 전까지 §08 1단계의 소개 병원 수는 최소선 2~4곳을 유지한다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 5. 신청·등록 -->
|
||||
<section id="s5" class="dark pb"><div class="wrap">
|
||||
<div class="eyebrow">05 · Registrations & Compliance</div><h2 class="serif">What AIO2O Must Obtain</h2>
|
||||
<p class="sub">단계별로 필요한 등록·신고·계약·계정을 전부 적었다. "확인"은 요건 수치나 적용 여부를 담당 기관 또는 변호사에게 확인해야 하는 항목이다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:56px">단계</th><th style="width:230px">항목</th><th>근거·요건</th><th style="width:170px">담당·비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>사업자 업종 추가</b></td><td>정보서비스업(포털·데이터), 광고대행업, 소프트웨어 개발. 의료관광 유치업은 등록 후 추가.</td><td>세무 대리인</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 간 상호 소개 구조 확인</b></td><td>의료법 27조 3항(영리 목적 환자 소개·알선·유인 금지). 앵커·파트너 병원이 서로 환자를 소개하는 기존 관계를 허브에 그대로 옮길 때 적용 여부. 앵커에게 지급하는 병원 소개 대가가 이 조항 또는 유치업 등록 전 수수료 제한에 걸리는지 함께 본다. 관계의 성격(공동 마케팅·리퍼럴·학회)에 따라 판단이 갈린다.</td><td>확인: 변호사. 앵커 소개 명단 확보 후</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 파트너 계약 (병원마다)</b></td><td>홈페이지·영상·이미지 자산 사용 허락 범위, 의료광고 주체는 개설자·AIO2O는 제작·운영 대행, 글별 의학 검토·게시 승인 의무와 주기, 지원 관계 고지 문구, 정정 절차, 환자 데이터 처리 역할. 뷰성형외과와의 앵커 조항(소개·자문·지분 또는 수수료)은 별도 부속.</td><td>변호사 검토. 표준 계약서 1종</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>의료광고 규정 준수 체계</b></td><td>의료법 56조 2항(치료경험담 2호, 비교 4호, 수술 장면 6호, 부작용 누락 7호, 과장 8호, 기사·전문가 형태 10호, 인증·보증 14호)을 발행 게이트와 이미지 규칙으로 차단. 경제적 이해관계 표시는 추천·보증 심사지침대로 글 첫 부분. 외국어 페이지도 동일 적용.</td><td>완료(코드). 외국어 게이트 어휘 추가 필요</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>의료광고 사전심의 (매체 기준)</b></td><td>의료법 57조. 자체 도메인 신규 매체는 전년도 일평균 이용자 10만 미만이라 의무 아님. 10만을 넘기면 대한의사협회 의료광고심의위원회 심의 대상. 네이버 블로그 재작성본은 게시물별 심의 대상이며 신청은 병원 명의.</td><td>이용자 수 모니터링. 확인: 외국어 매체 적용 여부</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>외국인환자 유치 광고 규정</b></td><td>의료해외진출 및 외국인환자 유치 지원법. 유치업자·의료기관의 외국인환자 유치 광고는 국내 지정 장소(공항·항만·면세구역 등) 외 제한, 진료 비용 할인·과장 금지. 온라인 다국어 매체가 이 규정에서 어떻게 취급되는지 확인.</td><td>확인: 보건복지부·KHIDI</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>외국인환자 유치업자 등록</b></td><td>같은 법 6조. 상담·예약을 중개하고 대가를 받으면 필수. 요건(확인): 법인, 자본금 1억 원 이상, 의료사고 배상책임보험 또는 공제(보장 한도 기준 있음), 사무실. 시·도에 등록, 3년마다 갱신, 유치 실적 연 1회 보고. 미등록 유치·수수료는 형사 처벌 대상.</td><td>AIO2O 법인. 2단계 착수 전 3개월</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>통신판매업 신고 · 결제(PG)</b></td><td>예약금·상담비를 온라인으로 받으면 통신판매업 신고(구청). 해외 카드 결제 PG(Stripe·토스 해외결제) 계약, 외화 정산 계좌. 환불·취소 약관.</td><td>2단계</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>개인정보·민감정보 처리</b></td><td>개인정보처리방침(한·영·일·중), 건강 정보는 민감정보라 별도 동의, 외국인 데이터의 국외 이전 동의(해외 결제·번역 API 사용 시), 병원과의 처리 위탁·제공 계약. GDPR·일본 개인정보보호법 적용 여부 확인(대상 국가별). 상담 신청 폼은 최소 수집.</td><td>변호사·개인정보 담당자 지정</td></tr>
|
||||
<tr><td><span class="badge b-info">3</span></td><td><b>여행업 등록 (직접 판매 시)</b></td><td>숙박·관광 상품을 직접 판매·알선하면 관광진흥법 여행업 등록. 제휴 링크(아고다·클룩·야놀자 어필리에이트)만 쓰면 불필요. 배달은 제휴 API 부재.</td><td>3단계에서 결정</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>상표·도메인</b></td><td>허브 브랜드 상표 출원(한국·주요 대상국), 국제 도메인, 언어별 서브패스(/en /ja /zh) 또는 서브도메인 결정(hreflang 설계에 영향).</td><td>브랜드 확정 후</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>API·플랫폼 계정</b></td><td>Google Cloud 프로젝트 쿼터(신청 중) → Places API(GBP 평점·리뷰 수·NAP 실측), YouTube Data API 쿼터 증량(기본 1만 유닛/일, 병원 10곳 이상 시 신청), OpenAI 조직 한도, Firecrawl·네이버 검색 API, 번역(DeepL 또는 GPT), Vercel Pro(팀·도메인), Supabase Pro, Trigger.dev(워커), Apify 미납 해소(인스타 수집).</td><td>AIO2O 계정 소유자 통일</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>보험·회계</b></td><td>매체 배상책임(명예훼손·저작권) 보험 검토, 병원 구독·수수료 세금계산서 체계, 외화 수익 회계.</td><td>확인</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 6. 기술 스택과 기능 -->
|
||||
<section id="s6" class="light pb"><div class="wrap">
|
||||
<div class="eyebrow">06 · Stack & Capabilities</div><h2 class="serif">Done, Partial, New</h2>
|
||||
<p class="sub">완료는 두 병원에서 검증된 것, 부분은 있으나 포털 규모로 바꿔야 하는 것, 신규는 없는 것. 근거 열은 저장소 파일이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><h3>현재 스택</h3><p><span class="pill">React 19</span><span class="pill">Vite 6</span><span class="pill">Tailwind 4</span><span class="pill">React Router 7</span><span class="pill">Astro 7 (서포터즈)</span><span class="pill">Supabase (DB·Edge Functions)</span><span class="pill">Vercel</span><span class="pill">Node 22 워커</span><span class="pill">Python (채점기·이미지)</span><span class="pill">GPT-4.1</span><span class="pill">YouTube Data API</span><span class="pill">Firecrawl</span><span class="pill">네이버 검색 API</span></p></div>
|
||||
<div class="card"><h3>추가 스택 (1~2단계)</h3><p><span class="pill">번역: GPT-4.1 근거 프로토콜 재실행 + DeepL 검수</span><span class="pill">Trigger.dev (워커 상시)</span><span class="pill">Supabase Auth (병원·운영자 역할)</span><span class="pill">Stripe / 토스 해외결제</span><span class="pill">Google Places API</span><span class="pill">Resend·SES (알림 메일)</span><span class="pill">Plausible 또는 GA4 (AI 유입 채널그룹)</span><span class="pill">Vercel 로그 드레인 (AI 크롤러 계측)</span></p></div>
|
||||
<div class="card"><h3>원칙 (변하지 않는 것)</h3><ul><li>근거 없는 사실은 생성하지 않는다. "확인 대기".</li><li>사람 게이트(검토·승인·채널 확인·이미지 육안)는 자동화하지 않는다.</li><li>고객 화면에 운영자 설명 없음. 확정 디자인은 텍스트만.</li><li>부재는 증명한다(채널 탐색 다섯 갈래).</li></ul></div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">영역</th><th>기능</th><th style="width:78px">상태</th><th style="width:260px">근거 파일 · 비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td rowspan="3"><b>진단 (AI Discovery)</b></td><td>기준표 v1.0 36항목 · v2.0 AEO/GEO 분리 채점, 미검증 분모 제외, 치명 게이트</td><td><span class="badge b-good">완료</span></td><td class="mono">src/data/aeoGeoRubric*.ts · src/lib/discoveryScore*.ts</td></tr>
|
||||
<tr><td>자동 채점기(auto 21~22항목) · 리포트 페이지 · 독립 HTML/PDF 리포트</td><td><span class="badge b-good">완료</span></td><td class="mono">scripts/audit_aeo_geo.py · /discovery/:id · scripts/build_discovery_report.ts</td></tr>
|
||||
<tr><td>외국인 질문 뱅크(영·일·중) · 다국어 답변엔진 실측 러너 · GBP·강남언니·플레이스 자동 실측(D1·D2·D4·D5)</td><td><span class="badge b-crit">신규</span></td><td>국문 120문항 러너 `scripts/run_question_bank_openai.py` 확장. Places API 키 필요</td></tr>
|
||||
<tr><td rowspan="4"><b>온보딩 파이프라인</b></td><td>채널 발견·엔티티 검증(5갈래, 근거 점수 3-state) · 근거 수집(223p) · 팩트 시트·의료진 초안</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/discover_channels.mjs · collect_evidence.mjs</td></tr>
|
||||
<tr><td>유튜브 집계(정보형 필터·쇼츠 판별) · 로고·파비콘 · 이미지(의료법 규칙·글자 이미지 검사)</td><td><span class="badge b-good">완료</span></td><td class="mono">collect_youtube.mjs · collect_images.mjs · img_process.py · docs/INFINITH_Supporters_Image_Rules.md</td></tr>
|
||||
<tr><td>첫 배치 기획(2층 구조) · GPT-4.1 글 생성 · 수치 대조 · 모델 근거 대조 · 수정 2회</td><td><span class="badge b-good">완료</span></td><td class="mono">default_briefs.mjs · generate_posts.mjs · gen/*</td></tr>
|
||||
<tr><td>뉴스룸 단계 · 감사 재실행(색인 허용 빌드 비교) · 유튜브 자막(IP 차단 대비 큐·프록시)</td><td><span class="badge b-warn">부분</span></td><td>뷰 수작업 스크립트 있음(`build_news.py`·`build_supporters_audit_xlsx.py`). 워커 단계로 미연결. 자막은 미구현</td></tr>
|
||||
<tr><td rowspan="2"><b>발행 게이트</b></td><td>error 11종 + warn 6종 + 비교 수치 · 승인 · 고지 위치 · 기자 화법 · 구조 지문, 픽스처 테스트 16/16</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/gate/rules.mjs · check.mjs</td></tr>
|
||||
<tr><td>외국어 금칙어·결론 단정·비교 표현 사전(영·일·중), 외국어 홈페이지 40자 중복 기준</td><td><span class="badge b-crit">신규</span></td><td>언어별 규칙 모듈. 원문 병원 페이지가 국문이면 번역 중복 검사 방식 결정 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>서포터즈 사이트 (병원 페이지)</b></td><td>Astro 템플릿(INFINITH 토큰) · 팩트 시트 단일 원본 · 신뢰 층(작성자·검토·승인·근거 유형·갱신·정정) · MedicalClinic·Physician·Article·FAQPage · noindex→색인 전환</td><td><span class="badge b-good">완료</span></td><td class="mono">templates/supporters-astro · supporters/src/lib.ts</td></tr>
|
||||
<tr><td>다국어(hreflang·언어별 라우팅·언어별 팩트 시트 표기·통화·날짜) · 언어별 상담 신청 폼</td><td><span class="badge b-crit">신규</span></td><td>같은 팩트 시트에서 언어별 렌더. 수치·시점은 언어 간 동일해야 함</td></tr>
|
||||
<tr><td>병원 N개를 한 도메인에서: 병원 서브패스 또는 서브도메인, 공통 홈·시술별 허브·질문별 답 페이지·병원 목록(비교·순위 없음)</td><td><span class="badge b-crit">신규</span></td><td>현재는 병원당 Vercel 프로젝트. 허브 도메인 설계와 canonical·sitemap 분리 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>승인·운영 UI</b></td><td>병원 확인 항목 폼(9항목) · 글별 검토·승인 기록 · 발견 채널 확인 · 미리보기 · 입력 반영 재빌드</td><td><span class="badge b-good">완료</span></td><td class="mono">src/components/discovery/ClinicInputsPanel.tsx · apply_inputs.mjs</td></tr>
|
||||
<tr><td>계정·역할(병원 담당자·원장·운영자) · 로그인 · 병원별 권한 · 승인 서명 기록(승인자·직함·일시·글 버전 해시)</td><td><span class="badge b-crit">신규</span></td><td>지금은 익명 입력. Supabase Auth + RLS 병원 스코프</td></tr>
|
||||
<tr><td>운영자 콘솔: 게이트 실패 목록, 이미지 육안 확인 체크, 확인 필요 채널 판정, 정정 접수, 비용·쿼터 대시보드</td><td><span class="badge b-warn">부분</span></td><td>status.json·report.md로 존재. 화면 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>워커·인프라</b></td><td>워커 11단계 · 큐(supporter_builds) · 폴러 · 랜딩 URL 신청 → 큐 · Vercel 미리보기 배포</td><td><span class="badge b-good">완료</span></td><td class="mono">workers/supporters-build/run.mjs · poll.mjs</td></tr>
|
||||
<tr><td>상시 실행(Trigger.dev) · 입력 변경 시 자동 재빌드 트리거 · 실패 알림 · 병원 10곳 이상 동시 큐</td><td><span class="badge b-warn">부분</span></td><td>launchd plist 준비, 미설치. Trigger.dev 이관은 가입·키만 필요</td></tr>
|
||||
<tr><td rowspan="2"><b>상담·예약 (2단계)</b></td><td>상담 신청 폼(언어·시술·일정·연락처) → 병원 이메일·카카오 알림 · 응답 추적</td><td><span class="badge b-crit">신규</span></td><td>1단계는 병원 직결(수수료 없음)</td></tr>
|
||||
<tr><td>예약 확정·통역 배정·예약금 결제·취소 환불·정산 · 유치 실적 보고 데이터</td><td><span class="badge b-crit">신규</span></td><td>유치업 등록 후. Stripe·토스, 정산 테이블, 실적 보고 양식</td></tr>
|
||||
<tr><td><b>여정 연결 (3단계)</b></td><td>회복 일정 기반 숙박·식사·관광 추천 · 제휴 링크 트래킹 · 축제·행사 데이터 연동(한국관광공사 API)</td><td><span class="badge b-crit">신규</span></td><td>제휴 API·어필리에이트 계정. 직접 판매 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>측정</b></td><td>서버 로그 AI 크롤러 히트 · GA4 AI 유입 채널그룹 · GSC AI Overviews · 기준선 스팟체크(질의 × 반복)</td><td><span class="badge b-crit">신규</span></td><td>E 카테고리 0/4. Vercel 로그 드레인 → Supabase, GA4 채널그룹 정의. 답변엔진 UI 자동 조회는 약관 위반이라 소유 신호만</td></tr>
|
||||
<tr><td>병원별 성과 리포트(인용·유입·상담 신청) 자동 생성</td><td><span class="badge b-warn">부분</span></td><td>리포트 빌더 있음. 측정 데이터가 없어 카드가 비어 있음</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 7. 아키텍처 -->
|
||||
<section id="s7" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">07 · Architecture</div><h2 class="serif">Data Flow</h2>
|
||||
<p class="sub">공급(병원)은 자동 온보딩 파이프라인, 수요(환자)는 AI·검색 인용, 그 사이를 승인 데이터가 잇는다.</p>
|
||||
<div class="flow" style="margin-top:22px">
|
||||
<div class="st"><b>입력</b><br>병원 이름 또는 URL (랜딩 신청·운영자·앵커 소개) → 큐</div>
|
||||
<div class="st"><b>발견·수집</b><br>채널 엔티티 검증, 근거 223p, 유튜브, 이미지(법 필터), 팩트 시트</div>
|
||||
<div class="st"><b>생성·게이트</b><br>기획 → GPT-4.1 → 수치·근거 대조 → 발행 게이트 → 국문 원문</div>
|
||||
<div class="st"><b>다국어</b><br>같은 팩트 시트·근거로 언어별 재생성(번역 아님) → 언어별 게이트 → hreflang</div>
|
||||
<div class="st"><b>승인</b><br>병원 폼 입력 → 검토·승인 기록 → 색인 허용 빌드에 승인 글만</div>
|
||||
<div class="st"><b>허브 배포·측정</b><br>허브 도메인(병원별 경로) → AI·검색 인용 → 로그·GA4·상담 신청 계측</div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>데이터 모델 (핵심 테이블)</h3><ul><li><b>clinics</b>: id, 이름(다국어), url, 팩트 시트(JSON, checkedAt/nextCheck), surfaces(확인된 채널), 파트너 계약 상태.</li><li><b>supporter_builds</b>(있음): 빌드 실행·단계·미리보기 URL·리포트.</li><li><b>supporter_inputs</b>(있음): 병원 입력, 글별 검토·승인.</li><li><b>posts</b>: 언어별 글, 근거 유형, 검토·승인, 갱신 기록. 지금은 md frontmatter, 허브 규모에서는 DB로.</li><li><b>inquiries</b>(신규): 상담 신청, 언어, 병원 전달·응답 시각.</li><li><b>bookings·payments</b>(2단계), <b>affiliate_clicks</b>(3단계), <b>ai_crawler_hits·ai_referrals</b>(측정).</li></ul></div>
|
||||
<div class="card"><h3>다국어 설계 원칙</h3><ul><li>번역이 아니라 근거 프로토콜을 언어별로 다시 태운다. 수치·시점·의료진은 팩트 시트 한 곳에서 나온다.</li><li>언어별 게이트: 금칙어·결론 단정·비교 표현 사전을 언어마다 둔다. 부작용 고지·지원 관계 고지는 언어별 문안 고정.</li><li>hreflang 상호 연결, canonical 언어별, sitemap 언어별. 병원 홈페이지 영문 사이트가 있으면 sameAs로 연결.</li><li>통화·단위: 비용 항목만 쓰고 금액은 쓰지 않으므로 환율 문제는 2단계 결제에서만.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 8. 로드맵 -->
|
||||
<section id="s8" class="near"><div class="wrap">
|
||||
<div class="eyebrow">08 · Roadmap</div><h2 class="serif">Three Phases</h2>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1단계 · 8~12주</span><h3 style="margin-top:10px">글로벌 허브 MVP</h3><ul><li>허브 브랜드·도메인·언어 라우팅 결정</li><li>다국어 생성(영·일·중) + 언어별 게이트</li><li>다병원 허브(공통 홈·시술 허브·병원 페이지·상담 신청 병원 직결)</li><li>뷰성형외과 + 앵커 소개 병원 온보딩(자동). 최소 2~4곳, 앵커 소개 명단 확인 후 재산정</li><li>외국인 질문 뱅크 실측(기준선)</li><li>측정 인프라: 로그 드레인·GA4 채널그룹</li><li>계정·역할·운영자 콘솔 최소판</li></ul><p class="muted">게이트: 병원 4곳 개설자 승인, 인용 기준선 확보</p></div>
|
||||
<div class="card"><span class="badge b-warn">2단계 · 3~6개월</span><h3 style="margin-top:10px">예약·수익화</h3><ul><li>유치업자 등록(법인·자본금·배상보험) 완료</li><li>예약 확정·통역·예약금 결제·환불·정산</li><li>병원 구독(온보딩·원문 공급·리포트) 요금제</li><li>뉴스룸·감사 재실행·자막 단계 워커 연결</li><li>Trigger.dev 상시 워커, 입력 변경 자동 재빌드</li><li>개인정보 국외 이전·민감정보 동의 체계</li></ul><p class="muted">게이트: 유치업 등록증, PG 계약, 상담→예약 전환 데이터</p></div>
|
||||
<div class="card"><span class="badge b-info">3단계 · 이후</span><h3 style="margin-top:10px">회복기 여정 연결</h3><ul><li>회복 일정 기반 숙박·식사·관광 추천(제휴)</li><li>축제·행사 데이터(관광공사 API)</li><li>미용 서비스 제휴, 배달은 안내</li><li>병원 10곳 이상, 피부과 확장</li><li>여행업 등록 여부 결정</li></ul><p class="muted">게이트: 제휴 계약, 3단계 수익 검증</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>1단계 착수 전 CEO 결정 3가지</h3><p style="color:var(--navy)">(1) 뷰성형외과 앵커 계약 조건(무상 온보딩·소개·자문 대가로 소액 지분 또는 수수료). 소개 목표 수치, 성과 연동 여부, 소개 병원의 AIO2O 직접 계약 원칙, 노출 우대 배제를 함께 정한다(§04). (2) 유치업 등록을 AIO2O 법인으로 할지, 등록된 유치업자와 제휴할지. (3) 수익 순서: 병원 구독이 먼저인지 예약 수수료가 먼저인지. 이 셋이 정해지면 나머지는 지금 파이프라인 위에 붙는 개발이다.</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 9. KPI -->
|
||||
<section id="s9" class="light"><div class="wrap">
|
||||
<div class="eyebrow">09 · Metrics</div><h2 class="serif">How We Measure</h2>
|
||||
<p class="sub">답변엔진 UI 자동 조회는 약관 위반이라 주력 지표는 소유 신호다. 효과는 반복 측정과 신뢰구간으로만 보고한다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:170px">축</th><th>지표</th><th style="width:220px">소스</th><th style="width:150px">1단계 목표</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>공급</b></td><td>온보딩 병원 수, 언어별 발행 글 수, 개설자 승인 글 비율, 게이트 오류율, 편당 생성 비용</td><td>supporter_builds·inputs, 생성기 리포트</td><td>병원 4곳 · 승인 60% 이상</td></tr>
|
||||
<tr><td><b>인용(AEO/GEO)</b></td><td>외국인 질문 뱅크 인용률(언어별, 5회 반복), 병원 AEO/GEO 점수 변화, AI 크롤러 히트(IP 검증)</td><td>QB 러너, 채점기, 서버 로그</td><td>기준선 확보, 크롤러 히트 발생</td></tr>
|
||||
<tr><td><b>유입</b></td><td>AI 유입 세션(GA4 채널그룹), 검색 유입, 언어별 체류</td><td>GA4·GSC</td><td>월 AI 유입 세션 측정 시작</td></tr>
|
||||
<tr><td><b>전환</b></td><td>상담 신청 수, 병원 응답률·응답 시간, (2단계) 예약 확정·결제</td><td>inquiries·bookings</td><td>상담 신청 월 10건</td></tr>
|
||||
<tr><td><b>신뢰</b></td><td>정정 접수·처리 시간, 게이트 차단 건수, 확인 대기 항목 해소율</td><td>corrections, 게이트 로그</td><td>정정 72시간 내 처리</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 10. 리스크 -->
|
||||
<section id="s10" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">10 · Risks & Open Questions</div><h2 class="serif">What Can Break</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>리스크와 대응</h3><ul><li><b>규제</b>: 유치업 미등록 상태의 수수료, 외국인 유치 광고 규정, 사전심의 매체 기준. 대응: 1단계 무수수료·병원 직결, 2단계 전 등록, 이용자 수 모니터링.</li><li><b>공급 정체</b>: 병원이 검토·승인을 안 한다. 대응: 미승인이어도 "확인 대기" 미리보기 발행, 검토 담당자·주기를 계약에.</li><li><b>중립성 의심</b>: 앵커 병원 편향. 대응: 지분 공개·고지, 노출 순서 규칙 공개, 비교·순위 없음.</li><li><b>생성 품질</b>: 근거 없는 수치·오역. 대응: 언어별 수치 대조·근거 대조, 사람 확인 플래그, 정정 페이지.</li><li><b>경쟁</b>: 크리에이트립·강남언니 글로벌이 AI 인용 구조를 갖추면 선점 기회가 사라진다. 대응: 속도. 온보딩 자동화가 유일한 시간 우위.</li><li><b>운영</b>: 비개발자 운영, 워커 상시 실행 미결정. 대응: Trigger.dev, 운영자 콘솔.</li><li><b>앵커 의존</b>: 공급이 앵커 개인의 관계에 붙는다. 소개 병원이 AIO2O와 직접 계약하지 않으면 앵커가 빠질 때 공급도 같이 빠진다. 대응: 병원별 직접 계약, 앵커 관계 종료가 해지 사유가 아님을 명시, 소개 경로와 계약 주체를 분리 기록.</li><li><b>브랜드</b>: 파트너 병원의 공정위·의료사고 이력. 대응: 계약 표명·보증, 뉴스룸 부정 기사 제외 규칙 유지, 허브 이름에 병원명 배제.</li></ul></div>
|
||||
<div class="card"><h3>미결 (결정 주체)</h3><ul><li>허브 브랜드·도메인·언어 라우팅 (CEO·haewon)</li><li>뷰 앵커 계약 조건, 합작 여부 (CEO)</li><li>앵커 소개 병원 명단·수·과목·지역·관계 단계 확인 (CEO)</li><li>소개 대가 구조: 성과 연동 지분 베스팅인지 병원당 수수료인지 (CEO)</li><li>유치업 등록 주체와 시점 (CEO·법무)</li><li>수익 모델 순서와 병원 구독 가격 (경영진)</li><li>대상 언어·국가 우선순위 (일본·중국어권·영어권) (CEO)</li><li>실존 서포터즈·편집 책임자 명의 (haewon)</li><li>워커 상시 실행 위치 (haewon)</li><li>기존 미결: 뷰 도메인, POC 8주 범위, 서포터즈 모집</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 11. 부록 -->
|
||||
<section id="s11" class="near"><div class="wrap">
|
||||
<div class="eyebrow">11 · Appendix</div><h2 class="serif">References</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>프로젝트 산출물</h3><ul>
|
||||
<li>진단 리포트: <span class="mono">docs/reports/viewclinic/01_readiness_report</span> · <span class="mono">docs/reports/wonjin/01_readiness_report</span> (HTML·PDF)</li>
|
||||
<li>미리보기: supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex)</li>
|
||||
<li>병원 확인 항목 패널: infinith-demo.vercel.app/supporters/wonjin</li>
|
||||
<li>핸드오버: <span class="mono">docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md</span>, 규칙: v2 §3·§4·§6·§12</li>
|
||||
<li>회고: <span class="mono">docs/INFINITH_AI_Discovery_Retrospective_2026-09-08.md</span></li>
|
||||
<li>이미지 규칙: <span class="mono">docs/INFINITH_Supporters_Image_Rules.md</span></li>
|
||||
<li>워커: <span class="mono">workers/supporters-build/README.md</span></li>
|
||||
</ul></div>
|
||||
<div class="card"><h3>법령·기준 (원문 확인 필요)</h3><ul>
|
||||
<li>의료법 제56조(의료광고의 금지 등) 제2항, 제57조(의료광고의 심의), 시행령 제23조·제24조</li>
|
||||
<li>의료 해외진출 및 외국인환자 유치 지원법 제6조(유치업자 등록), 제15조(의료광고 특례·제한), 시행규칙(등록 요건·보험)</li>
|
||||
<li>표시·광고의 공정화에 관한 법률, 추천·보증 등에 관한 표시·광고 심사지침(2024-12 개정)</li>
|
||||
<li>개인정보 보호법(민감정보·국외 이전), 정보통신망법</li>
|
||||
<li>전자상거래법(통신판매업 신고), 관광진흥법(여행업)</li>
|
||||
<li>공정거래위원회 2026-07-12 성형외과 후기 뒷광고 시정명령(뷰성형외과 포함 3곳)</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · o2oteam@o2o.kr · PRD v0.1 초안 2026-09-08 · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인이 필요합니다.</footer>
|
||||
</body></html>
|
||||
@ -12,7 +12,6 @@ docs/reports/
|
||||
│ └── 05_global_en/ 영문 사이트(viewplasticsurgery.com) 외국인 관점 AEO/GEO 진단 (2026-09-10) · 영문 60문항 뱅크 · Perplexity/OpenAI(뉴욕) 실측 · HTML/PDF 보고서 + 빌더
|
||||
├── taeha/ 태하 AI Answer Readiness 리포트 (html · pdf · pptx) + 빌더
|
||||
├── ibk/ 기업은행 블로그 AEO/GEO 감사
|
||||
├── answer_engines/ 답변엔진 확장 검토 보고서(.docx, 120문항 비용 추정 · 후보별 약관 검토) + 빌더
|
||||
└── landing/ AI Discovery 랜딩 시안 (픽셀 정답 HTML) · 스크린샷 · 리포트 페이지 HTML 내보내기 스크립트
|
||||
```
|
||||
|
||||
|
||||
@ -1,367 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""답변엔진 확장 검토 보고서(.docx) 빌더.
|
||||
|
||||
질문 뱅크 120문항 기준으로 후보 답변엔진별 비용을 추정하고, 약관 검토 결과와 함께 워드 문서로 낸다.
|
||||
단가는 2026-09-15 각사 공식 가격표 확인값. 토큰량은 가정이며 4장 표에 근거를 적는다.
|
||||
|
||||
사용:
|
||||
python3 docs/reports/answer_engines/build_engine_review_docx.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
OUT = Path(__file__).with_name("INFINITH_답변엔진_확장검토_2026-09-15.docx")
|
||||
|
||||
INDIGO = RGBColor(0x1D, 0x00, 0x24)
|
||||
VIOLET = RGBColor(0x4F, 0x1D, 0xA1)
|
||||
GRAY = RGBColor(0x55, 0x55, 0x60)
|
||||
FONT = "Pretendard"
|
||||
|
||||
KRW = 1345 # 2026-09-13 USD/KRW 1,343.56 기준 반올림
|
||||
QUESTIONS = 120
|
||||
REPEAT = 5
|
||||
|
||||
M = 1_000_000
|
||||
|
||||
# ── 후처리 단가(답변 1건당): 병원명 추출 gpt-4o-mini + 어조 판정 gpt-4.1 ──
|
||||
def post_cost(level):
|
||||
rubric_in = {"low": 4000, "mid": 5000, "high": 6000}[level]
|
||||
answer_in = {"low": 600, "mid": 1000, "high": 2900}[level]
|
||||
extract = (500 + answer_in) * 0.15 / M + 150 * 0.60 / M
|
||||
judge = (rubric_in + answer_in) * 2.00 / M + 300 * 8.00 / M
|
||||
return extract + judge
|
||||
|
||||
|
||||
# ── 엔진별 1문항 1회 호출 비용 모델 ──
|
||||
# searches: 호출당 검색 횟수, search_fee: 검색 1회 단가, tin/tout: 입력·출력 토큰, pin/pout: 1M 토큰 단가
|
||||
ENGINES = [
|
||||
dict(key="gpt4o", name="ChatGPT (현행)", setup="gpt-4o + web_search", status="운영 중",
|
||||
search_fee=0.025, pin=2.50, pout=10.00,
|
||||
low=dict(searches=1, tin=300, tout=400), mid=dict(searches=1, tin=300, tout=1000), high=dict(searches=1, tin=300, tout=2900),
|
||||
note="비추론 모델은 검색 호출 $25/1천 회, 검색 본문 토큰 무료. 출력은 실측 평균 962자·상위 10% 2,850자."),
|
||||
dict(key="gpt56", name="ChatGPT (최신화)", setup="GPT-5.6 Sol + web_search", status="모델 교체안",
|
||||
search_fee=0.010, pin=4.00, pout=20.00,
|
||||
low=dict(searches=1, tin=8000, tout=1500), mid=dict(searches=2, tin=20000, tout=3000), high=dict(searches=3, tin=40000, tout=5000),
|
||||
note="추론 모델은 검색 호출 $10/1천 회, 검색 본문 토큰을 모델 단가로 과금. 출력에 추론 토큰 포함."),
|
||||
dict(key="sonar", name="Perplexity (현행)", setup="sonar, 검색 컨텍스트 low", status="운영 중",
|
||||
search_fee=0.005, pin=1.00, pout=1.00,
|
||||
low=dict(searches=1, tin=50, tout=400), mid=dict(searches=1, tin=50, tout=650), high=dict(searches=1, tin=50, tout=1000),
|
||||
note="요청 수수료 $5/1천 회(low). 출력은 실측 평균 618자."),
|
||||
dict(key="sonarpro", name="Perplexity (상위)", setup="sonar-pro, 검색 컨텍스트 medium", status="모델 교체안",
|
||||
search_fee=0.010, pin=3.00, pout=15.00,
|
||||
low=dict(searches=1, tin=50, tout=700), mid=dict(searches=1, tin=50, tout=1000), high=dict(searches=1, tin=50, tout=1400),
|
||||
note="요청 수수료 $10/1천 회(medium)."),
|
||||
dict(key="sonnet5", name="Claude", setup="Claude Sonnet 5 + web_search", status="추가 권고",
|
||||
search_fee=0.010, pin=2.00, pout=10.00,
|
||||
low=dict(searches=1, tin=12000, tout=800), mid=dict(searches=2, tin=30000, tout=1500), high=dict(searches=3, tin=60000, tout=3000),
|
||||
note="검색 $10/1천 회. 검색 결과가 입력 토큰으로 과금되어 입력 토큰이 비용의 대부분."),
|
||||
dict(key="haiku45", name="Claude (저가)", setup="Claude Haiku 4.5 + web_search", status="대안",
|
||||
search_fee=0.010, pin=1.00, pout=5.00,
|
||||
low=dict(searches=1, tin=12000, tout=800), mid=dict(searches=2, tin=30000, tout=1500), high=dict(searches=3, tin=60000, tout=3000),
|
||||
note="Sonnet 5와 같은 토큰량 가정. 동적 필터링(입력 절감)은 4.6 이상 모델만 지원."),
|
||||
dict(key="gemini", name="Gemini", setup="Gemini 3.8 Flash + Google 검색 그라운딩", status="약관 확인 전 보류",
|
||||
search_fee=0.0, pin=0.75, pout=3.75,
|
||||
low=dict(searches=1, tin=300, tout=1000), mid=dict(searches=2, tin=300, tout=2000), high=dict(searches=3, tin=300, tout=4000),
|
||||
note="그라운딩 월 5,000회 무료(3.x 공통), 초과 시 $14/1천 회. 120문항 5회 반복은 최대 1,800회라 무료 범위 안."),
|
||||
dict(key="grok", name="Grok", setup="Grok 4.3 + web search", status="후순위",
|
||||
search_fee=0.005, pin=1.25, pout=2.50,
|
||||
low=dict(searches=1, tin=8000, tout=800), mid=dict(searches=2, tin=20000, tout=1500), high=dict(searches=3, tin=40000, tout=3000),
|
||||
note="검색 $5/1천 회는 제3자 정리 기준. 공식 가격표 재확인 필요."),
|
||||
dict(key="bing", name="Copilot 계열", setup="Azure Foundry + Grounding with Bing + gpt-4o", status="제외 (약관)",
|
||||
search_fee=0.014, pin=2.50, pout=10.00,
|
||||
low=dict(searches=1, tin=5000, tout=400), mid=dict(searches=1, tin=10000, tout=1000), high=dict(searches=2, tin=20000, tout=2900),
|
||||
note="참고용 산정. 결과 저장·데이터베이스화 금지 조항으로 측정에 쓸 수 없음."),
|
||||
dict(key="aio", name="Google AI Mode", setup="DataForSEO AI Mode SERP API", status="결정 필요",
|
||||
search_fee=0.004, pin=0.0, pout=0.0,
|
||||
low=dict(searches=1, tin=0, tout=0), mid=dict(searches=1, tin=0, tout=0), high=dict(searches=1, tin=0, tout=0),
|
||||
note="Live $0.004, Standard 대기열 $0.0012 / 요청. 모델 토큰 없음. 소비자 화면을 제3자가 수집하는 방식."),
|
||||
]
|
||||
|
||||
|
||||
def call_cost(e, level):
|
||||
p = e[level]
|
||||
return p["searches"] * e["search_fee"] + p["tin"] * e["pin"] / M + p["tout"] * e["pout"] / M
|
||||
|
||||
|
||||
def per_question(e, level, with_post=True):
|
||||
return call_cost(e, level) + (post_cost(level) if with_post else 0)
|
||||
|
||||
|
||||
def usd(x):
|
||||
return f"${x:,.2f}" if x >= 0.995 else f"${x:.3f}"
|
||||
|
||||
|
||||
def won(x):
|
||||
return f"₩{round(x * KRW, -1):,.0f}"
|
||||
|
||||
|
||||
# ── 문서 헬퍼 ──
|
||||
def set_font(run, size=11, bold=False, color=INDIGO):
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
rpr = run._element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
for attr in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
|
||||
rfonts.set(qn(attr), FONT)
|
||||
|
||||
|
||||
def para(doc, text, size=11, bold=False, color=INDIGO, space_after=6, align=None):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(space_after)
|
||||
p.paragraph_format.line_spacing = 1.35
|
||||
if align:
|
||||
p.alignment = align
|
||||
set_font(p.add_run(text), size, bold, color)
|
||||
return p
|
||||
|
||||
|
||||
def heading(doc, text, level=1):
|
||||
size = {0: 24, 1: 16, 2: 13}[level]
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_before = Pt(18 if level == 1 else 10)
|
||||
p.paragraph_format.space_after = Pt(8)
|
||||
p.paragraph_format.keep_with_next = True
|
||||
set_font(p.add_run(text), size, True, INDIGO if level != 2 else VIOLET)
|
||||
return p
|
||||
|
||||
|
||||
def bullets(doc, items, size=11):
|
||||
for t in items:
|
||||
p = doc.add_paragraph(style="List Bullet")
|
||||
p.paragraph_format.space_after = Pt(3)
|
||||
p.paragraph_format.line_spacing = 1.3
|
||||
if isinstance(t, tuple):
|
||||
set_font(p.add_run(t[0]), size, True)
|
||||
set_font(p.add_run(t[1]), size)
|
||||
else:
|
||||
set_font(p.add_run(t), size)
|
||||
|
||||
|
||||
def shade(cell, hex_fill):
|
||||
tcpr = cell._element.get_or_add_tcPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), hex_fill)
|
||||
tcpr.append(shd)
|
||||
|
||||
|
||||
def table(doc, header, rows, widths=None, size=9.5, bold_first_col=True, highlight_rows=()):
|
||||
t = doc.add_table(rows=1, cols=len(header))
|
||||
t.style = "Table Grid"
|
||||
t.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
for i, h in enumerate(header):
|
||||
c = t.rows[0].cells[i]
|
||||
c.text = ""
|
||||
set_font(c.paragraphs[0].add_run(h), size, True, RGBColor(0xFF, 0xFF, 0xFF))
|
||||
shade(c, "1D0024")
|
||||
trpr = t.rows[0]._tr.get_or_add_trPr()
|
||||
for tag in ("w:tblHeader", "w:cantSplit"):
|
||||
el = OxmlElement(tag)
|
||||
el.set(qn("w:val"), "true")
|
||||
trpr.append(el)
|
||||
for ri, r in enumerate(rows):
|
||||
cells = t.add_row().cells
|
||||
for i, v in enumerate(r):
|
||||
cells[i].text = ""
|
||||
bold = (i == 0 and bold_first_col) or ri in highlight_rows
|
||||
set_font(cells[i].paragraphs[0].add_run(str(v)), size, bold)
|
||||
if ri in highlight_rows:
|
||||
shade(cells[i], "F1ECFA")
|
||||
if widths:
|
||||
t.autofit = False
|
||||
for i, w in enumerate(widths):
|
||||
t.columns[i].width = Cm(w)
|
||||
for row in t.rows:
|
||||
for i, w in enumerate(widths):
|
||||
row.cells[i].width = Cm(w)
|
||||
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||||
return t
|
||||
|
||||
|
||||
def build():
|
||||
E = {e["key"]: e for e in ENGINES}
|
||||
doc = Document()
|
||||
sec = doc.sections[0]
|
||||
sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
|
||||
sec.left_margin = sec.right_margin = Cm(2.0)
|
||||
sec.top_margin = sec.bottom_margin = Cm(2.0)
|
||||
|
||||
# 표지 블록
|
||||
para(doc, "INFINITH AI Discovery", 11, True, VIOLET, 4)
|
||||
heading(doc, "답변엔진 확장 검토 보고서", 0)
|
||||
para(doc, "질문 뱅크 120문항 기준 비용 추정과 후보 엔진별 약관 검토", 13, False, INDIGO, 4)
|
||||
para(doc, "작성일 2026-09-15 · 단가 확인일 2026-09-15 · 환율 1 USD = ₩1,345 가정", 10, False, GRAY, 14)
|
||||
|
||||
# 1. 요약
|
||||
heading(doc, "1. 요약")
|
||||
gpt4o, sonar, sonnet, gem = E["gpt4o"], E["sonar"], E["sonnet5"], E["gemini"]
|
||||
cur_q = per_question(gpt4o, "mid") + per_question(sonar, "mid")
|
||||
add_claude_q = per_question(sonnet, "mid")
|
||||
bullets(doc, [
|
||||
("현재 실측 엔진은 2개입니다. ", "ChatGPT(gpt-4o + 웹 검색)와 Perplexity(sonar)입니다. 36개 진단 항목은 AI 모델 없이 규칙 검사로 판정합니다."),
|
||||
("바로 추가할 수 있는 엔진은 Claude입니다. ", "공식 API에 웹 검색 도구가 있고, 결과 저장·분석을 막는 조항을 확인하지 못했습니다."),
|
||||
("Gemini와 Copilot은 약관이 측정 용도와 충돌합니다. ", "Gemini는 그라운딩 결과의 분석·자동 수집을, Bing 그라운딩은 결과 저장·데이터베이스화를 금지합니다."),
|
||||
("비용은 크지 않습니다. ",
|
||||
f"현행 2개 엔진의 120문항 1회 측정은 약 {usd(cur_q * QUESTIONS)}({won(cur_q * QUESTIONS)})이고, "
|
||||
f"Claude를 더하면 1회 약 {usd(add_claude_q * QUESTIONS)}({won(add_claude_q * QUESTIONS)})가 늘어납니다. 모두 중앙 추정치이며 후처리(병원명 추출·어조 판정) 비용을 포함합니다."),
|
||||
("네이버 AI 브리핑은 측정 API가 없습니다. ", "수동 스팟체크를 유지합니다."),
|
||||
])
|
||||
|
||||
# 2. 현재 구성
|
||||
heading(doc, "2. 현재 측정 구성")
|
||||
table(doc, ["역할", "모델 · 방식", "코드 위치"], [
|
||||
["답변엔진 실측 ①", "OpenAI gpt-4o + Responses API web_search (서울 고정, 영문판 뉴욕)", "scripts/run_question_bank_openai.py"],
|
||||
["답변엔진 실측 ②", "Perplexity sonar (웹 검색 내장)", "scripts/run_question_bank_openai.py"],
|
||||
["병원명·순위 추출", "OpenAI gpt-4o-mini (키 없으면 Gemini 2.5 Flash)", "scripts/run_question_bank_openai.py"],
|
||||
["어조 판정", "OpenAI gpt-4.1, temperature 0", "scripts/sentiment_qb.py"],
|
||||
["36개 항목 진단", "AI 모델 미사용. 크롤링 규칙 검사 + Wikidata 조회", "scripts/audit_aeo_geo.py"],
|
||||
], widths=[3.2, 8.6, 5.2])
|
||||
para(doc, "측정은 공식 API 답변 기준입니다. 소비자 화면(chatgpt.com 등) 자동 조회는 약관상 하지 않으므로, API 답변과 화면 답변은 다를 수 있습니다.", 10, False, GRAY)
|
||||
|
||||
# 3. 후보별 검토
|
||||
heading(doc, "3. 후보 엔진별 검토")
|
||||
table(doc, ["엔진", "공식 API", "약관상 측정 가능 여부", "판정"], [
|
||||
["Claude", "Messages API web_search 도구. user_location으로 도시·국가 지정", "저장·분석 금지 조항 미확인. 최종 사용자에게 보여줄 때 출처 표시 필요", "추가 권고"],
|
||||
["Gemini", "Grounding with Google Search. GEMINI_API_KEY 보유", "그라운딩 결과의 cache·analyze·자동 수집 금지. 질문한 사용자에게 보여주는 용도만 허용", "법무 확인 전 보류"],
|
||||
["Copilot 계열", "Azure Foundry Grounding with Bing Search", "결과의 store·cache·archive·데이터베이스화 금지. 검색 경험 앱에서만 사용", "제외"],
|
||||
["Grok", "xAI API web search 도구", "저장 금지 조항 별도 확인 안 함", "후순위 (국내 이용 비중 낮음)"],
|
||||
["Google AI Overview · AI Mode", "Google 공식 API 없음. DataForSEO·SerpApi가 결과 화면을 수집해 제공", "제3자가 소비자 화면을 수집하는 방식. 현재 수집 원칙과 불일치", "haewon 결정 필요"],
|
||||
["네이버 AI 브리핑", "없음. CLOVA Studio는 모델 API이며 브리핑 화면을 재현하지 못함", "해당 없음", "제외, 수동 스팟체크"],
|
||||
["DeepSeek · 뤼튼 · 라이너", "웹 검색이 붙은 공식 측정 API 미확인", "해당 없음", "제외"],
|
||||
], widths=[3.0, 5.2, 6.0, 2.8])
|
||||
|
||||
heading(doc, "3.1 엔진별 판단 근거", 2)
|
||||
bullets(doc, [
|
||||
("Claude. ", "검색 1,000회당 $10에 검색 결과가 입력 토큰으로 과금됩니다. 응답에 인용 URL과 인용 문장(최대 150자)이 구조화되어 와서 현재 추출 로직에 붙이기 쉽습니다. 국내 병원 고객의 이용 비중은 ChatGPT보다 낮습니다."),
|
||||
("Gemini. ", "기술 난이도는 가장 낮고 국내 비중은 Claude보다 높습니다. 다만 Gemini API 추가 약관이 Grounded Results를 \"cache, frame, syndicate, resell, analyze\" 하는 것을 금지하고, 자동 수집을 허용하지 않습니다. Vertex AI 경로의 약관이 다른지와, 진단을 요청한 고객에게 결과를 보여주는 용도로 인정되는지 법무 판단이 필요합니다."),
|
||||
("Copilot 계열. ", "Bing 그라운딩 약관이 결과를 \"Copy, store, cache, archive, or create a database of Output\" 하는 것을 금지합니다. 질문 뱅크 결과를 누적하는 방식과 양립하지 않습니다."),
|
||||
("Google AI Overview · AI Mode. ", "병원 검색에서 노출 비중이 큰 화면이고 비용도 가장 낮습니다. 대신 Google 검색 결과를 제3자 도구로 수집하는 구조라, \"소비자 화면을 자동 조회하지 않는다\"는 현재 원칙을 바꾸는 결정입니다."),
|
||||
("기존 엔진 모델 점검. ", "ChatGPT 소비자 화면은 gpt-4o보다 새 모델로 답합니다. 측정 모델을 최신 추론 모델로 바꾸면 화면 답변과의 차이가 줄지만, 문항당 비용이 약 3.5배 늘어납니다(5장 표)."),
|
||||
])
|
||||
|
||||
# 4. 비용 산정 기준
|
||||
heading(doc, "4. 비용 산정 기준")
|
||||
table(doc, ["항목", "가정", "근거"], [
|
||||
["문항 수", "120문항", "질문 뱅크 v0.1"],
|
||||
["반복", "1회, 5회 반복 두 가지", "답변 변동을 신뢰구간으로 보고하려면 5~7회 반복 필요"],
|
||||
["답변 길이", "ChatGPT 평균 962자(상위 10% 2,850자), Perplexity 평균 618자", "scripts/out 실측 결과 240건"],
|
||||
["한국어 토큰 환산", "1자 ≈ 1토큰 (보수적)", "모델별 토크나이저 차이는 범위(최소·최대)로 흡수"],
|
||||
["검색 결과 입력 토큰", "Claude 1.2만~6만, OpenAI 추론 모델 0.8만~4만, Grok 0.8만~4만", "검색 1~3회 × 결과 본문. 실측 전 가정"],
|
||||
["후처리", f"답변 1건당 약 {usd(post_cost('mid'))}", "gpt-4o-mini 추출 + gpt-4.1 어조 판정(기준표 프롬프트 약 5천 토큰)"],
|
||||
["환율", "1 USD = ₩1,345", "2026-09-13 USD/KRW 1,343.56"],
|
||||
["할인", "미적용", "Batch API 50% 할인은 적용 시 토큰 비용만 절감"],
|
||||
], widths=[3.2, 7.3, 6.5])
|
||||
|
||||
# 5. 엔진별 비용
|
||||
heading(doc, "5. 120문항 비용 추정 (엔진별)")
|
||||
para(doc, "문항당 단가는 엔진 호출 1회와 후처리 1건을 합한 값입니다. 범위는 최소·최대 가정, 굵은 값은 중앙 추정입니다.", 10, False, GRAY)
|
||||
rows = []
|
||||
for e in ENGINES:
|
||||
lo, mid, hi = (per_question(e, lv) for lv in ("low", "mid", "high"))
|
||||
rows.append([
|
||||
e["name"], e["setup"], e["status"], usd(mid),
|
||||
f"{usd(mid * QUESTIONS)}\n{won(mid * QUESTIONS)}",
|
||||
f"{usd(mid * QUESTIONS * REPEAT)}\n{won(mid * QUESTIONS * REPEAT)}",
|
||||
f"{usd(lo * QUESTIONS * REPEAT)} ~ {usd(hi * QUESTIONS * REPEAT)}",
|
||||
])
|
||||
table(doc, ["엔진", "구성", "상태", "문항당", "120문항 1회", "120문항 5회", "5회 범위"], rows,
|
||||
widths=[2.4, 3.9, 2.2, 1.6, 2.3, 2.3, 2.6], size=9, highlight_rows=(0, 2, 4))
|
||||
bullets(doc, [
|
||||
"Gemini 행은 그라운딩 무료 범위(월 5,000회) 안에서 계산했습니다. 무료 범위를 넘으면 검색 1,000회당 $14가 더해져 5회 반복 기준 최대 "
|
||||
f"{usd(1800 * 0.014)}가 늘어납니다.",
|
||||
"Copilot 계열은 약관상 사용할 수 없어 참고용으로만 적었습니다.",
|
||||
"Google AI Mode는 모델 호출이 없어 비용 대부분이 후처리입니다. Standard 대기열($0.0012)을 쓰면 수집 비용이 70% 줄어듭니다.",
|
||||
], size=10)
|
||||
heading(doc, "5.1 엔진별 산정 메모", 2)
|
||||
table(doc, ["엔진", "과금 구조"], [[e["name"], e["note"]] for e in ENGINES], widths=[3.2, 13.8], size=9)
|
||||
|
||||
# 6. 구성안
|
||||
heading(doc, "6. 구성안별 합계")
|
||||
plans = [
|
||||
("A. 현행 유지", ["gpt4o", "sonar"], "현재 운영 구성"),
|
||||
("B. 현행 + Claude", ["gpt4o", "sonar", "sonnet5"], "권고안. 랜딩에 ChatGPT·Perplexity·Claude 표기 가능"),
|
||||
("C. 모델 최신화 + Claude", ["gpt56", "sonarpro", "sonnet5"], "소비자 화면과의 차이를 줄이는 안"),
|
||||
("D. B + Gemini", ["gpt4o", "sonar", "sonnet5", "gemini"], "Gemini 약관 확인이 끝난 경우"),
|
||||
("E. D + Google AI Mode", ["gpt4o", "sonar", "sonnet5", "gemini", "aio"], "수집 원칙 변경을 결정한 경우"),
|
||||
]
|
||||
prow = []
|
||||
for name, keys, desc in plans:
|
||||
lo = sum(per_question(E[k], "low") for k in keys)
|
||||
mid = sum(per_question(E[k], "mid") for k in keys)
|
||||
hi = sum(per_question(E[k], "high") for k in keys)
|
||||
prow.append([name, f"{len(keys)}개", desc,
|
||||
f"{usd(mid * QUESTIONS)}\n{won(mid * QUESTIONS)}",
|
||||
f"{usd(mid * QUESTIONS * REPEAT)}\n{won(mid * QUESTIONS * REPEAT)}",
|
||||
f"{won(lo * QUESTIONS * REPEAT)} ~ {won(hi * QUESTIONS * REPEAT)}"])
|
||||
table(doc, ["구성안", "엔진", "설명", "120문항 1회", "120문항 5회", "5회 범위(원)"], prow,
|
||||
widths=[3.2, 1.3, 4.8, 2.5, 2.5, 3.0], size=9, highlight_rows=(1,))
|
||||
para(doc, "병원 1곳을 월 1회, 5회 반복으로 측정하면 월 비용은 \"120문항 5회\" 열과 같습니다. 병원 수에 비례해 늘어납니다.", 10, False, GRAY)
|
||||
|
||||
# 7. 권고
|
||||
heading(doc, "7. 권고와 결정 필요 사항")
|
||||
heading(doc, "7.1 권고 순서", 2)
|
||||
bullets(doc, [
|
||||
("1단계. Claude 엔진 추가 (구성안 B). ", "run_question_bank_openai.py에 엔진 클래스를 하나 추가합니다. 10문항 시험 실행으로 실제 토큰량을 확인한 뒤 120문항을 돌립니다."),
|
||||
("2단계. 기존 엔진 모델 점검. ", "10문항을 gpt-4o와 최신 추론 모델로 나란히 돌려 병원 언급률 차이를 봅니다. 차이가 크면 구성안 C로 올립니다."),
|
||||
("3단계. Gemini 약관 확인. ", "Vertex AI 약관과 AI Studio 약관을 비교하고 법무 판단을 받습니다."),
|
||||
("4단계. Google AI Mode 수집 여부 결정. ", "수집 원칙 변경이 필요합니다."),
|
||||
])
|
||||
heading(doc, "7.2 haewon 결정 필요", 2)
|
||||
table(doc, ["번호", "결정 사항", "선택지"], [
|
||||
["1", "Claude 엔진 추가와 10문항 시험 실행", "진행 / 보류"],
|
||||
["2", "측정 모델 최신화 시험 (gpt-4o 대비 최신 모델)", "진행 / 보류"],
|
||||
["3", "Gemini 약관 법무 검토 요청", "요청 / 보류"],
|
||||
["4", "Google AI Overview · AI Mode 제3자 수집 허용", "허용 / 불허"],
|
||||
["5", "Anthropic API 회사 키 발급", "발급 / 기존 키 사용"],
|
||||
], widths=[1.4, 10.0, 5.6])
|
||||
|
||||
# 8. 한계
|
||||
heading(doc, "8. 이 추정의 한계")
|
||||
bullets(doc, [
|
||||
"검색 결과 입력 토큰은 실측 전 가정입니다. Claude·OpenAI 추론 모델·Grok 비용은 이 값에 따라 2배 안팎 달라질 수 있습니다. 10문항 시험 실행 후 5장 표를 갱신합니다.",
|
||||
"Grok 검색 단가는 제3자 정리 기준이고, 약관 조항은 요약본을 읽은 것입니다. 도입 판단 전에 원문을 다시 확인해야 합니다.",
|
||||
"Gemini 3.x Flash 할인 단가는 2026-12-31까지이며, 2027-01-01부터 입력·출력 단가가 2배로 오릅니다.",
|
||||
"API 답변은 각 서비스의 소비자 앱 답변과 같지 않습니다. 엔진을 늘려도 보고서에는 \"공식 API 답변 기준\"으로 표기해야 합니다.",
|
||||
"환율 변동과 부가세는 반영하지 않았습니다.",
|
||||
], size=10)
|
||||
|
||||
# 9. 출처
|
||||
heading(doc, "9. 출처 (2026-09-15 확인)")
|
||||
for label, url in [
|
||||
("OpenAI API Pricing", "https://developers.openai.com/api/docs/pricing"),
|
||||
("Perplexity Sonar Pricing", "https://docs.perplexity.ai/getting-started/pricing"),
|
||||
("Claude Pricing", "https://platform.claude.com/docs/en/about-claude/pricing"),
|
||||
("Claude Web search tool", "https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool"),
|
||||
("Gemini API Pricing", "https://ai.google.dev/gemini-api/docs/pricing"),
|
||||
("Gemini API Additional Terms", "https://ai.google.dev/gemini-api/terms"),
|
||||
("Grounding with Bing pricing", "https://www.microsoft.com/en-us/bing/apis/grounding-pricing"),
|
||||
("Grounding with Bing legal", "https://www.microsoft.com/en-us/bing/apis/grounding-legal"),
|
||||
("xAI models", "https://docs.x.ai/docs/models"),
|
||||
("xAI Grok API pricing (제3자 정리)", "https://mem0.ai/blog/xai-grok-api-pricing"),
|
||||
("DataForSEO Google AI Mode SERP API", "https://dataforseo.com/pricing/google-serp/google-ai-mode-serp-api"),
|
||||
("CLOVA Studio API 가이드", "https://api.ncloud-docs.com/docs/ai-naver-clovastudio-summary"),
|
||||
("원·달러 환율 (2026-09-13)", "https://ko.tradingeconomics.com/south-korea/currency"),
|
||||
]:
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
set_font(p.add_run(f"{label} "), 9.5, True)
|
||||
set_font(p.add_run(url), 9.5, False, GRAY)
|
||||
|
||||
doc.save(OUT)
|
||||
print(OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
@ -1,7 +1,7 @@
|
||||
# 뷰성형외과 Question Bank 실측 결과: ChatGPT(OpenAI) x Perplexity x 네이버 AI 브리핑 통합
|
||||
# 뷰성형외과 Question Bank 실측 결과: ChatGPT(OpenAI) x Perplexity 통합
|
||||
|
||||
측정일: ChatGPT(OpenAI) 2026-09-01 · Perplexity 2026-09-01 · 네이버 AI 브리핑 2026-09-15
|
||||
측정 방식: OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar) / 네이버 AI 브리핑(SerpApi naver_ai_overview, 제3자 SERP API, 모바일). 답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정했다. 네이버는 공식 API가 없어 제3자 API를 썼으며, 고객 리포트 사용 전 법무 확인이 필요하다.
|
||||
측정일: 2026-09-01
|
||||
측정 방식: OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar). 답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정했다.
|
||||
|
||||
## 요약
|
||||
|
||||
@ -9,9 +9,6 @@
|
||||
|---|---|---|
|
||||
| ChatGPT(OpenAI) | 35/36 (97%) | 10/84 (11%) |
|
||||
| Perplexity | 36/36 (100%) | 3/84 (3%) |
|
||||
| 네이버 AI 브리핑 | 36/36 (100%) | 1/84 (1%) |
|
||||
|
||||
네이버 AI 브리핑 노출: 119/120문항. 브리핑이 뜨지 않은 문항은 언급 없음으로 셌다.
|
||||
|
||||
브랜드 질문은 질문 문장에 '뷰성형외과'가 들어간 문항이다. 승부처인 논브랜드 질문(추천·비교·시술 정보)에서의 언급률이 AI Discovery의 실제 성적이다.
|
||||
|
||||
@ -55,199 +52,179 @@
|
||||
| 원진성형외과 | 2 | 0 |
|
||||
| 노트성형외과 | 2 | 0 |
|
||||
|
||||
### 네이버 AI 브리핑: 논브랜드 질문에서 언급된 병원 상위 15
|
||||
|
||||
| 병원 | 언급 질문 수 | 1순위 등장 수 |
|
||||
|---|---|---|
|
||||
| 더성형외과 | 2 | 2 |
|
||||
| 나나 | 2 | 0 |
|
||||
| 위쉬 | 2 | 0 |
|
||||
| 노트성형외과 | 2 | 0 |
|
||||
| 디에이성형외과 | 2 | 1 |
|
||||
| 플레저성형외과의원 | 1 | 1 |
|
||||
| 강남오엘의원 | 1 | 0 |
|
||||
| 밴스성형외과의원 | 1 | 0 |
|
||||
| 업투유 | 1 | 1 |
|
||||
| 뷰성형외과 | 1 | 0 |
|
||||
| 아이니크성형외과의원 | 1 | 1 |
|
||||
| 청담여신성형외과의원 | 1 | 0 |
|
||||
| 아이앤지성형외과의원 | 1 | 0 |
|
||||
| 오인성형외과의원 | 1 | 0 |
|
||||
| 강남제일 성형외과 | 1 | 0 |
|
||||
|
||||
## 문항별 결과
|
||||
|
||||
GPT/PPLX/NAVER 컬럼: O(순위) = 뷰성형외과 언급과 답변 내 등장 순번, X = 미언급, 브리핑 없음 = 네이버가 AI 브리핑을 띄우지 않음.
|
||||
GPT/PPLX 컬럼: O(순위) = 뷰성형외과 언급과 답변 내 등장 순번, X = 미언급.
|
||||
|
||||
### A. 브랜드 직접
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| A-01 | 뷰성형외과 어때? 믿을만한 병원이야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-02 | 뷰성형외과는 어떤 병원이야? 소개해줘 | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-03 | 뷰성형외과는 무슨 수술을 주로 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-04 | 뷰성형외과 최순우 원장 어떤 의사야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 서울대학교병원 성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-05 | 뷰성형외과 의료진 몇 명이야? 다 전문의야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 | O (1위) | 뷰성형외과의원 |
|
||||
| A-06 | 뷰성형외과가 렛미인 나온 병원 맞아? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-07 | 뷰성형외과 언제 생긴 병원이야? 오래됐어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-08 | 뷰성형외과 건물이 통째로 병원이라던데 맞아? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 스마일뷰치과의원 | O (1위) | 뷰성형외과의원 |
|
||||
| A-09 | 뷰성형외과 강남언니 평점 몇 점이야? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 | O (1위) | 뷰성형외과의원 |
|
||||
| A-10 | 뷰성형외과 유튜브 채널 있어? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 레아트성형외과의원 |
|
||||
| A-11 | 뷰성형외과랑 다른 강남 대형 성형외과랑 뭐가 달라? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-12 | 뷰성형외과 단점이나 주의할 점 있어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A-01 | 뷰성형외과 어때? 믿을만한 병원이야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-02 | 뷰성형외과는 어떤 병원이야? 소개해줘 | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-03 | 뷰성형외과는 무슨 수술을 주로 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-04 | 뷰성형외과 최순우 원장 어떤 의사야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 서울대학교병원 성형외과 |
|
||||
| A-05 | 뷰성형외과 의료진 몇 명이야? 다 전문의야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| A-06 | 뷰성형외과가 렛미인 나온 병원 맞아? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-07 | 뷰성형외과 언제 생긴 병원이야? 오래됐어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-08 | 뷰성형외과 건물이 통째로 병원이라던데 맞아? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 스마일뷰치과의원 |
|
||||
| A-09 | 뷰성형외과 강남언니 평점 몇 점이야? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| A-10 | 뷰성형외과 유튜브 채널 있어? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-11 | 뷰성형외과랑 다른 강남 대형 성형외과랑 뭐가 달라? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| A-12 | 뷰성형외과 단점이나 주의할 점 있어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
### B. 위치·운영
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| B-01 | 뷰성형외과 주소 어디야? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| B-02 | 뷰성형외과 신논현역에서 얼마나 걸려? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| B-03 | 뷰성형외과 전화번호 알려줘 | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-04 | 뷰성형외과 진료시간 어떻게 돼? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| B-05 | 뷰성형외과 일요일이나 공휴일에도 해? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과의원 |
|
||||
| B-06 | 뷰성형외과 주차 되나? | ✕ | O (1위) | 뷰성형외과, 아이비성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-07 | 뷰성형외과 상담 예약 어떻게 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-08 | 뷰성형외과 카톡으로 상담돼? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-09 | 뷰성형외과 상담받으러 가면 몇 층으로 가? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과, 뷰성형외과의원 |
|
||||
| B-10 | 신논현역 3번 출구 근처 성형외과 뭐 있어? | ◯ | O (1위) | VIEW Plastic Surgery, 스타라인성형외과, 포레성형외과의원, 링크성형외과, 플레저성형외과 | O (8위) | 우아인성형외과, 플레저성형외과, 링크성형외과, 나나성형외과, 밴스성형외과(신논현점) | X | 플레저성형외과의원, 강남오엘의원, 밴스성형외과의원 |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| B-01 | 뷰성형외과 주소 어디야? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-02 | 뷰성형외과 신논현역에서 얼마나 걸려? | ◯ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-03 | 뷰성형외과 전화번호 알려줘 | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-04 | 뷰성형외과 진료시간 어떻게 돼? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-05 | 뷰성형외과 일요일이나 공휴일에도 해? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-06 | 뷰성형외과 주차 되나? | ✕ | O (1위) | 뷰성형외과, 아이비성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-07 | 뷰성형외과 상담 예약 어떻게 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-08 | 뷰성형외과 카톡으로 상담돼? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-09 | 뷰성형외과 상담받으러 가면 몇 층으로 가? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| B-10 | 신논현역 3번 출구 근처 성형외과 뭐 있어? | ◯ | O (1위) | VIEW Plastic Surgery, 스타라인성형외과, 포레성형외과의원, 링크성형외과, 플레저성형외과 | O (8위) | 우아인성형외과, 플레저성형외과, 링크성형외과, 나나성형외과, 밴스성형외과(신논현점) |
|
||||
### C. 지역 추천·비교
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| C-01 | 강남 성형외과 추천해줘 | ✕ | O (5위) | Honesty Plastic Surgery, Kokoline Clinic, Sketch Plastic Surgery, Shimmian Clinic, The Door Plastic Surgery | X | 바노바기성형외과, 디에이성형외과, 1mm성형외과, 에디션성형외과의원, 시크릿성형외과·피부과 | O (1위) | 업투유, 뷰성형외과 |
|
||||
| C-02 | 강남에서 가슴성형 잘하는 병원 어디야? | ✕ | O (2위) | 우아성형외과, 뷰성형외과의원, 아이디병원, 바탕성형외과, 위쉬성형외과 | X | 엄나구모 성형외과, 유앤유성형외과, 윈느성형외과, 바노바기성형외과, 마블성형외과 | X | (병원 언급 없음) |
|
||||
| C-03 | 강남 안면윤곽 유명한 병원 알려줘 | ✕ | X | 플랜에스의원 강남, 한미인의원 강남본점, 오르타의원 신관, 팽팽의원 강남, Bondi Plastic Surgery | X | 아이디병원, 삼사오성형외과, 오늘성형외과, 유노성형외과, 현대미학성형외과 | X | 아이니크성형외과의원, 청담여신성형외과의원, 아이앤지성형외과의원, 오인성형외과의원, 강남제일 성형외과 |
|
||||
| C-04 | 코성형 잘하는 강남 병원 추천해줘 | ✕ | O (4위) | 기린성형외과 서초, 어린공주성형외과 강남, ID병원 강남, 뷰성형외과 강남, 프리미엄 리벤스 이비인후과의원 | X | 코원성형외과의원, 링크성형외과, 오엠성형외과, 코비쥬의원, 베리굿 성형외과 | X | (병원 언급 없음) |
|
||||
| C-05 | 눈 재수술 잘하는 병원 어디야? | ✕ | X | (병원 언급 없음) | X | 더스완성형외과, 강남서연성형외과, 팝성형외과, JK성형외과 | X | (병원 언급 없음) |
|
||||
| C-06 | 강남 성형외과 톱5 알려줘 | ✕ | O (2위) | The Door Plastic Surgery, View Plastic Surgery, Rohsanghoon Plastic Surgery, Gangnam Oz Plastic Surgery, The Plastic Surgery | X | 아이디병원, 원진성형외과, JK성형외과, 바노바기성형외과, 그랜드성형외과 | X | 탑클래스성형외과의원, 강남성형외과 |
|
||||
| C-07 | 가슴성형 병원 3곳만 골라서 비교해줘 | ✕ | X | 강남 세란성형외과, 병원ABC, 병원XYZ | X | 땡큐성형외과의원, 밴스성형외과의원, 티제이성형외과의원 | X | 더성형외과, 나나, 위쉬, 조이플, 노트성형외과 |
|
||||
| C-08 | 마취과 전문의가 상주하는 강남 성형외과 어디야? | △ | O (3위) | 디에이성형외과, 윈느성형외과, 뷰성형외과의원, 더성형외과, 제이스성형외과 | X | 디에이성형외과, 현대미학성형외과, 노트성형외과, 에이비성형외과, 우리성형외과 | X | 디에이성형외과, 현대미학성형외과, V&MJ피부과의원, 서울페이스21, 강남수병원 |
|
||||
| C-09 | 수술실 CCTV 있는 성형외과 추천해줘 | △ | X | 리브리 성형외과, 브라운 성형외과, 스케치 성형외과, 이룸 성형외과, 에이펙스 성형외과 | X | 이룸성형외과, BK성형외과, 라이크성형외과, 티와이성형외과 | X | 마인성형외과의원, 옐로우성형외과의원, 특정 강남역 성형외과 |
|
||||
| C-10 | 여의사한테 수술받을 수 있는 성형외과 있어? | △ | X | (병원 언급 없음) | X | 조이플성형외과의원, 시크릿성형외과, 홍승현성형외과의원 | X | (병원 언급 없음) |
|
||||
| C-11 | 후기 제일 많은 강남 성형외과 어디야? | △ | O (5위) | 한미인의원 강남본점, 쁨글로벌의원 강남점, 팽팽의원 강남, 아비쥬의원 강남점, 릴리브의원 압구정 | X | 우리성형외과의원, 어린공주성형외과의원, 반니성형외과의원 | X | (병원 언급 없음) |
|
||||
| C-12 | 강남언니에서 평점 높은 성형외과 알려줘 | △ | O (6위) | 디에이성형외과의원, 팝성형외과의원, 나나성형외과의원, 에이트성형외과의원, 아이디병원‑본원 | O (6위) | 인트로성형외과의원, 그날성형외과의원, 우리성형외과의원, 팝성형외과의원, 삼사오성형외과의원 | 브리핑 없음 | - |
|
||||
| C-13 | 20년 넘게 운영한 강남 성형외과 있어? | ✕ | X | 제림성형외과, 김종서 성형외과, JK 성형외과, 아이디 병원, JW 성형외과 | X | JK성형외과, 드림성형외과, 강남서연성형외과, 글램성형외과, 마이라인성형외과의원 | X | 함희정성형외과의원, JK성형외과의원, 더올림성형외과의원 |
|
||||
| C-14 | 리프팅 잘하는 강남 병원 추천해줘 | ✕ | O (None위) | 한미인의원 강남본점, 오르타의원 신관, 팽팽의원, 더클리닉 강남점, 청담 라비앙로즈 | X | 온리프의원, 리팅성형외과의원, 윌비성형외과의원, 톡스앤필 강남본점, 리베리의원 강남점 | X | (병원 언급 없음) |
|
||||
| C-15 | 의사 여러 명 있는 대형 성형외과가 나아? 어디 추천해? | ✕ | X | (병원 언급 없음) | X | 디에이성형외과의원, 미인성형외과, 베리굿 성형외과, 삼성서울병원 성형외과 | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| C-01 | 강남 성형외과 추천해줘 | ✕ | O (5위) | Honesty Plastic Surgery, Kokoline Clinic, Sketch Plastic Surgery, Shimmian Clinic, The Door Plastic Surgery | X | 바노바기성형외과, 디에이성형외과, 1mm성형외과, 에디션성형외과의원, 시크릿성형외과·피부과 |
|
||||
| C-02 | 강남에서 가슴성형 잘하는 병원 어디야? | ✕ | O (2위) | 우아성형외과, 뷰성형외과의원, 아이디병원, 바탕성형외과, 위쉬성형외과 | X | 엄나구모 성형외과, 유앤유성형외과, 윈느성형외과, 바노바기성형외과, 마블성형외과 |
|
||||
| C-03 | 강남 안면윤곽 유명한 병원 알려줘 | ✕ | X | 플랜에스의원 강남, 한미인의원 강남본점, 오르타의원 신관, 팽팽의원 강남, Bondi Plastic Surgery | X | 아이디병원, 삼사오성형외과, 오늘성형외과, 유노성형외과, 현대미학성형외과 |
|
||||
| C-04 | 코성형 잘하는 강남 병원 추천해줘 | ✕ | O (4위) | 기린성형외과 서초, 어린공주성형외과 강남, ID병원 강남, 뷰성형외과 강남, 프리미엄 리벤스 이비인후과의원 | X | 코원성형외과의원, 링크성형외과, 오엠성형외과, 코비쥬의원, 베리굿 성형외과 |
|
||||
| C-05 | 눈 재수술 잘하는 병원 어디야? | ✕ | X | (병원 언급 없음) | X | 더스완성형외과, 강남서연성형외과, 팝성형외과, JK성형외과 |
|
||||
| C-06 | 강남 성형외과 톱5 알려줘 | ✕ | O (2위) | The Door Plastic Surgery, View Plastic Surgery, Rohsanghoon Plastic Surgery, Gangnam Oz Plastic Surgery, The Plastic Surgery | X | 아이디병원, 원진성형외과, JK성형외과, 바노바기성형외과, 그랜드성형외과 |
|
||||
| C-07 | 가슴성형 병원 3곳만 골라서 비교해줘 | ✕ | X | 강남 세란성형외과, 병원ABC, 병원XYZ | X | 땡큐성형외과의원, 밴스성형외과의원, 티제이성형외과의원 |
|
||||
| C-08 | 마취과 전문의가 상주하는 강남 성형외과 어디야? | △ | O (3위) | 디에이성형외과, 윈느성형외과, 뷰성형외과의원, 더성형외과, 제이스성형외과 | X | 디에이성형외과, 현대미학성형외과, 노트성형외과, 에이비성형외과, 우리성형외과 |
|
||||
| C-09 | 수술실 CCTV 있는 성형외과 추천해줘 | △ | X | 리브리 성형외과, 브라운 성형외과, 스케치 성형외과, 이룸 성형외과, 에이펙스 성형외과 | X | 이룸성형외과, BK성형외과, 라이크성형외과, 티와이성형외과 |
|
||||
| C-10 | 여의사한테 수술받을 수 있는 성형외과 있어? | △ | X | (병원 언급 없음) | X | 조이플성형외과의원, 시크릿성형외과, 홍승현성형외과의원 |
|
||||
| C-11 | 후기 제일 많은 강남 성형외과 어디야? | △ | O (5위) | 한미인의원 강남본점, 쁨글로벌의원 강남점, 팽팽의원 강남, 아비쥬의원 강남점, 릴리브의원 압구정 | X | 우리성형외과의원, 어린공주성형외과의원, 반니성형외과의원 |
|
||||
| C-12 | 강남언니에서 평점 높은 성형외과 알려줘 | △ | O (6위) | 디에이성형외과의원, 팝성형외과의원, 나나성형외과의원, 에이트성형외과의원, 아이디병원‑본원 | O (6위) | 인트로성형외과의원, 그날성형외과의원, 우리성형외과의원, 팝성형외과의원, 삼사오성형외과의원 |
|
||||
| C-13 | 20년 넘게 운영한 강남 성형외과 있어? | ✕ | X | 제림성형외과, 김종서 성형외과, JK 성형외과, 아이디 병원, JW 성형외과 | X | JK성형외과, 드림성형외과, 강남서연성형외과, 글램성형외과, 마이라인성형외과의원 |
|
||||
| C-14 | 리프팅 잘하는 강남 병원 추천해줘 | ✕ | O (None위) | 한미인의원 강남본점, 오르타의원 신관, 팽팽의원, 더클리닉 강남점, 청담 라비앙로즈 | X | 온리프의원, 리팅성형외과의원, 윌비성형외과의원, 톡스앤필 강남본점, 리베리의원 강남점 |
|
||||
| C-15 | 의사 여러 명 있는 대형 성형외과가 나아? 어디 추천해? | ✕ | X | (병원 언급 없음) | X | 디에이성형외과의원, 미인성형외과, 베리굿 성형외과, 삼성서울병원 성형외과 |
|
||||
### D1. 가슴성형
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D1-01 | 뷰성형외과 가슴성형 잘해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D1-02 | 가슴 보형물 종류 뭐가 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-03 | 모티바랑 멘토 보형물 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-04 | 가슴성형 수술시간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-05 | 가슴성형 회복기간 어느 정도야? 언제 출근해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-06 | 가슴성형 흉터 어디에 남아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-07 | 구형구축이 뭐야? 예방할 수 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-08 | 가슴 지방이식이랑 보형물 중에 뭐가 나아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-09 | 가슴 재수술은 언제 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-10 | 가슴성형하면 수유에 영향 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-11 | 가슴성형 후 정기검진 뭐 받아야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-12 | 가슴성형 과정 잘 설명해주는 영상 있어? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D1-01 | 뷰성형외과 가슴성형 잘해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D1-02 | 가슴 보형물 종류 뭐가 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-03 | 모티바랑 멘토 보형물 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-04 | 가슴성형 수술시간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-05 | 가슴성형 회복기간 어느 정도야? 언제 출근해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-06 | 가슴성형 흉터 어디에 남아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-07 | 구형구축이 뭐야? 예방할 수 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-08 | 가슴 지방이식이랑 보형물 중에 뭐가 나아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-09 | 가슴 재수술은 언제 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-10 | 가슴성형하면 수유에 영향 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-11 | 가슴성형 후 정기검진 뭐 받아야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D1-12 | 가슴성형 과정 잘 설명해주는 영상 있어? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### D2. 눈성형
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D2-01 | 뷰성형외과 눈성형 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D2-02 | 쌍수 절개랑 매몰 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-03 | 자연유착 쌍수가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-04 | 눈매교정은 쌍수랑 같이 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-05 | 앞트임 뒤트임 밑트임 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-06 | 쌍수 회복기간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-07 | 눈 재수술은 첫 수술 후 언제부터 가능해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-08 | 앞트임하면 잘 어울리는 얼굴이 따로 있어? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-09 | 쌍수 붓기 빨리 빼는 법 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D2-01 | 뷰성형외과 눈성형 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D2-02 | 쌍수 절개랑 매몰 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-03 | 자연유착 쌍수가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-04 | 눈매교정은 쌍수랑 같이 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-05 | 앞트임 뒤트임 밑트임 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-06 | 쌍수 회복기간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-07 | 눈 재수술은 첫 수술 후 언제부터 가능해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-08 | 앞트임하면 잘 어울리는 얼굴이 따로 있어? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D2-09 | 쌍수 붓기 빨리 빼는 법 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### D3. 코성형
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D3-01 | 뷰성형외과 코성형 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | VIEW성형외과 |
|
||||
| D3-02 | 코성형 재료 뭐뭐 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-03 | 실리콘이랑 자가연골 중에 뭐가 나아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-04 | 콧대만 할까 코끝까지 같이 할까? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-05 | 코성형 회복기간 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-06 | 코 재수술은 언제부터 할 수 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-07 | 휜코 교정도 성형외과에서 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-08 | 복코 수술 어떻게 하는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-09 | 내 얼굴형에 맞는 코 모양 어떻게 정해? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D3-01 | 뷰성형외과 코성형 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D3-02 | 코성형 재료 뭐뭐 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-03 | 실리콘이랑 자가연골 중에 뭐가 나아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-04 | 콧대만 할까 코끝까지 같이 할까? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-05 | 코성형 회복기간 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-06 | 코 재수술은 언제부터 할 수 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-07 | 휜코 교정도 성형외과에서 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-08 | 복코 수술 어떻게 하는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D3-09 | 내 얼굴형에 맞는 코 모양 어떻게 정해? | △ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### D4. 안면윤곽·양악
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D4-01 | 뷰성형외과 안면윤곽 잘하는 곳 맞아? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D4-02 | 광대축소 수술 어떻게 하는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-03 | V라인 사각턱 수술 부작용 없어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-04 | 양악수술은 어떤 경우에 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-05 | 윤곽수술 신경 손상 위험 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-06 | 윤곽수술 회복기간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-07 | 윤곽수술 붓기 언제 빠져? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-08 | 윤곽이랑 코성형 같이 해도 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-09 | 안면윤곽으로 상 받은 병원 있어? | ✕ | X | 강남 오앤의원 | X | 서울페이스21치과병원, 디에이성형외과의원, 1퍼센트성형외과 | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D4-01 | 뷰성형외과 안면윤곽 잘하는 곳 맞아? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D4-02 | 광대축소 수술 어떻게 하는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-03 | V라인 사각턱 수술 부작용 없어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-04 | 양악수술은 어떤 경우에 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-05 | 윤곽수술 신경 손상 위험 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-06 | 윤곽수술 회복기간 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-07 | 윤곽수술 붓기 언제 빠져? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-08 | 윤곽이랑 코성형 같이 해도 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D4-09 | 안면윤곽으로 상 받은 병원 있어? | ✕ | X | 강남 오앤의원 | X | 서울페이스21치과병원, 디에이성형외과의원, 1퍼센트성형외과 |
|
||||
### D5. 지방성형
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D5-01 | 지방흡입 부위별로 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-02 | 얼굴 지방이식하면 자연스러워? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-03 | 지방이식 생착률 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-04 | 복부 지방흡입 회복 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-05 | 뷰성형외과에서 지방성형도 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D5-06 | 지방흡입 흉터 남아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D5-01 | 지방흡입 부위별로 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-02 | 얼굴 지방이식하면 자연스러워? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-03 | 지방이식 생착률 얼마나 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-04 | 복부 지방흡입 회복 얼마나 걸려? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D5-05 | 뷰성형외과에서 지방성형도 해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D5-06 | 지방흡입 흉터 남아? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### D6. 피부·리프팅
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D6-01 | 리프팅 종류별로 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-02 | 실리프팅이랑 안면거상 중에 뭐 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-03 | 울쎄라 효과 진짜 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-04 | 리프팅 효과 얼마나 유지돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-05 | 40대인데 안티에이징 뭐부터 시작해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-06 | 뷰성형외과에서 리프팅도 받을 수 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D6-07 | 수술 흉터 관리 어떻게 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D6-01 | 리프팅 종류별로 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-02 | 실리프팅이랑 안면거상 중에 뭐 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-03 | 울쎄라 효과 진짜 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-04 | 리프팅 효과 얼마나 유지돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-05 | 40대인데 안티에이징 뭐부터 시작해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D6-06 | 뷰성형외과에서 리프팅도 받을 수 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D6-07 | 수술 흉터 관리 어떻게 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### D7. 재수술
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D7-01 | 재수술 전문 병원은 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-02 | 뷰성형외과 재수술 잘해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D7-03 | 성형 재수술은 왜 하게 되는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-04 | 재수술은 처음보다 비용 더 비싸? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-05 | 재수술 하려면 첫 수술 후 얼마나 기다려야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-06 | 다른 병원에서 한 수술도 재수술 상담해줘? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D7-01 | 재수술 전문 병원은 뭐가 달라? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-02 | 뷰성형외과 재수술 잘해? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| D7-03 | 성형 재수술은 왜 하게 되는 거야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-04 | 재수술은 처음보다 비용 더 비싸? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-05 | 재수술 하려면 첫 수술 후 얼마나 기다려야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| D7-06 | 다른 병원에서 한 수술도 재수술 상담해줘? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### E. 신뢰·안전
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| E-01 | 성형외과 고를 때 뭘 확인해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-02 | 성형수술 마취 사고 안 나려면 어떤 병원 가야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-03 | 수술실 CCTV 있는지 어떻게 확인해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-04 | 전신마취랑 수면마취 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-05 | 성형외과 전문의인지 어떻게 확인해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-06 | 응급상황 대응 시스템 갖춘 성형외과 어디야? | △ | X | 압구정서울성형외과, 아인병원 성형외과, PDM 박동만 성형외과, 리프트리성형외과, 엄나구모 성형외과 | X | 우리성형외과, 소다성형외과, Wannap 성형외과, 원진성형외과, 플레저 성형외과 | X | 압구정서울성형외과, 마침표성형외과, 디에이성형외과 |
|
||||
| E-07 | 성형 부작용 생기면 어떻게 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-08 | 상담실장 말고 의사가 직접 상담해주는 병원 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-09 | 뷰성형외과 안전 시스템 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| E-01 | 성형외과 고를 때 뭘 확인해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-02 | 성형수술 마취 사고 안 나려면 어떤 병원 가야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-03 | 수술실 CCTV 있는지 어떻게 확인해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-04 | 전신마취랑 수면마취 차이가 뭐야? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-05 | 성형외과 전문의인지 어떻게 확인해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-06 | 응급상황 대응 시스템 갖춘 성형외과 어디야? | △ | X | 압구정서울성형외과, 아인병원 성형외과, PDM 박동만 성형외과, 리프트리성형외과, 엄나구모 성형외과 | X | 우리성형외과, 소다성형외과, Wannap 성형외과, 원진성형외과, 플레저 성형외과 |
|
||||
| E-07 | 성형 부작용 생기면 어떻게 해야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-08 | 상담실장 말고 의사가 직접 상담해주는 병원 있어? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| E-09 | 뷰성형외과 안전 시스템 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
### F. 가격·이벤트
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| F-01 | 강남 가슴성형 평균 비용 얼마야? | ✕ | X | Gangnam Beauty Guide, Seoul Plastic Surgery Guide, SeoulBeautyTalk, ClinicSeoul.net, Gangnam Plastic Surgeon Cost Guide | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-02 | 뷰성형외과 가슴성형 얼마야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| F-03 | 쌍수 가격 보통 얼마 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-04 | 코성형 견적 어느 정도 잡아야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-05 | 뷰성형외과 지금 하는 이벤트 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| F-06 | 성형외과 이벤트가 믿어도 돼? 싼 데는 왜 싸? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-07 | 성형 비용 카드 할부나 분할 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-08 | 성형 견적 여러 병원 비교하려면 어떻게 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| F-01 | 강남 가슴성형 평균 비용 얼마야? | ✕ | X | Gangnam Beauty Guide, Seoul Plastic Surgery Guide, SeoulBeautyTalk, ClinicSeoul.net, Gangnam Plastic Surgeon Cost Guide | X | (병원 언급 없음) |
|
||||
| F-02 | 뷰성형외과 가슴성형 얼마야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| F-03 | 쌍수 가격 보통 얼마 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-04 | 코성형 견적 어느 정도 잡아야 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-05 | 뷰성형외과 지금 하는 이벤트 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| F-06 | 성형외과 이벤트가 믿어도 돼? 싼 데는 왜 싸? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-07 | 성형 비용 카드 할부나 분할 돼? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| F-08 | 성형 견적 여러 병원 비교하려면 어떻게 해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
### G. 후기·평판
|
||||
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 | NAVER | NAVER 상위 병원 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| G-01 | 뷰성형외과 실제 후기 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-02 | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-03 | 강남언니 후기 믿을만해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| G-04 | 뷰성형외과 수술 전후 사진 어디서 봐? | △ | X | (병원 언급 없음) | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-05 | 성형 후기는 어디서 봐야 정확해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| G-06 | 뷰성형외과 인스타그램 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-07 | 뷰성형외과는 뭘로 유명한 병원이야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-08 | 가슴성형 후기 많은 병원 어디야? | △ | O (5위) | WOOA Plastic Surgery Clinic, JW Plastic Surgery Center, SAERO Plastic Surgery, Dite Plastic Surgery, TJ Plastic Surgery | O (1위) | 뷰성형외과의원, 아이디병원-본원, 우아성형외과의원, 노트성형외과의원, 엄나구모성형외과의원 | X | 더성형외과, 노트성형외과, 위쉬성형외과, 나나성형외과, 물방울성형외과 |
|
||||
| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| G-01 | 뷰성형외과 실제 후기 어때? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-02 | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | ✕ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-03 | 강남언니 후기 믿을만해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| G-04 | 뷰성형외과 수술 전후 사진 어디서 봐? | △ | X | (병원 언급 없음) | O (1위) | 뷰성형외과 |
|
||||
| G-05 | 성형 후기는 어디서 봐야 정확해? | ✕ | X | (병원 언급 없음) | X | (병원 언급 없음) |
|
||||
| G-06 | 뷰성형외과 인스타그램 있어? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-07 | 뷰성형외과는 뭘로 유명한 병원이야? | △ | O (1위) | 뷰성형외과 | O (1위) | 뷰성형외과 |
|
||||
| G-08 | 가슴성형 후기 많은 병원 어디야? | △ | O (5위) | WOOA Plastic Surgery Clinic, JW Plastic Surgery Center, SAERO Plastic Surgery, Dite Plastic Surgery, TJ Plastic Surgery | O (1위) | 뷰성형외과의원, 아이디병원-본원, 우아성형외과의원, 노트성형외과의원, 엄나구모성형외과의원 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# AI 답변 감성 판정 결과 · 뷰성형외과
|
||||
|
||||
판정 2026-09-15 · 기준표 v0.3(`docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md`) · 모델 gpt-4.1 · 답변 360건
|
||||
판정 2026-09-09 · 기준표 v0.3(`docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md`) · 모델 gpt-4.1 · 답변 240건
|
||||
|
||||
감성 점수 = (positive×100 + neutral×50 + mixed×25 + negative×0) ÷ 언급된 답변 수. 규제 언급률과 추천율은 언급된 답변 기준.
|
||||
|
||||
@ -8,9 +8,6 @@
|
||||
|
||||
| 엔진 | 질문군 | 답변 | 언급 | 감성 점수 | 긍정 | 중립 | 혼합 | 부정 | 부정 비율 | 규제 언급률 | 추천율 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| naver_briefing | 전체 | 120 | 36 | **75.0** | 21 | 12 | 0 | 3 | 8.3% | 0.0% | 58.3% |
|
||||
| naver_briefing | 브랜드(A·B) | 22 | 21 | **71.4** | 10 | 10 | 0 | 1 | 4.8% | 0.0% | 47.6% |
|
||||
| naver_briefing | 논브랜드 | 98 | 15 | **80.0** | 11 | 2 | 0 | 2 | 13.3% | 0.0% | 73.3% |
|
||||
| openai | 전체 | 120 | 44 | **64.8** | 16 | 25 | 0 | 3 | 6.8% | 6.8% | 36.4% |
|
||||
| openai | 브랜드(A·B) | 22 | 22 | **54.5** | 3 | 18 | 0 | 1 | 4.5% | 4.5% | 13.6% |
|
||||
| openai | 논브랜드 | 98 | 22 | **75.0** | 13 | 7 | 0 | 2 | 9.1% | 9.1% | 59.1% |
|
||||
@ -18,22 +15,22 @@
|
||||
| perplexity | 브랜드(A·B) | 22 | 22 | **52.3** | 2 | 19 | 0 | 1 | 4.5% | 4.5% | 9.1% |
|
||||
| perplexity | 논브랜드 | 98 | 17 | **50.0** | 2 | 13 | 0 | 2 | 11.8% | 0.0% | 11.8% |
|
||||
|
||||
전체 합산: 언급 119/360 · 감성 점수 **63.4** · 부정 비율 7.6% · 규제 언급률 3.4% · 추천율 34.5%
|
||||
전체 합산: 언급 83/240 · 감성 점수 **58.4** · 부정 비율 7.2% · 규제 언급률 4.8% · 추천율 24.1%
|
||||
|
||||
약한 언급 1건(G-08(openai))은 정식 상호 없이 영문 약칭만 나온 답변이다. 이를 빼면 언급 118건 · 감성 점수 **63.1**. 포함 여부는 기준표 §6 미결이다.
|
||||
약한 언급 1건(G-08(openai))은 정식 상호 없이 영문 약칭만 나온 답변이다. 이를 빼면 언급 82건 · 감성 점수 **57.9**. 포함 여부는 기준표 §6 미결이다.
|
||||
|
||||
## 2. 측면 태그 (언급된 답변)
|
||||
|
||||
- procedure: 65건
|
||||
- access: 59건
|
||||
- doctors: 50건
|
||||
- safety: 48건
|
||||
- reviews: 46건
|
||||
- price: 14건
|
||||
- media: 10건
|
||||
- regulatory: 7건
|
||||
- procedure: 39건
|
||||
- access: 39건
|
||||
- reviews: 33건
|
||||
- doctors: 29건
|
||||
- safety: 28건
|
||||
- price: 11건
|
||||
- media: 8건
|
||||
- regulatory: 5건
|
||||
|
||||
## 3. 부정·혼합 판정 전건 (9건)
|
||||
## 3. 부정·혼합 판정 전건 (6건)
|
||||
|
||||
| id | 엔진 | 판정 | 종류 | 질문 | 근거 문장 |
|
||||
|---|---|---|---|---|---|
|
||||
@ -43,11 +40,8 @@
|
||||
| A-12 | perplexity | negative | regulatory | 뷰성형외과 단점이나 주의할 점 있어? | 공정거래위원회가 뷰성형외과를 포함한 강남 성형외과들의 *수술비 할인 등 대가를 받고 작성한 후기를 자발적 후기처럼 보이게 한 행위*를 제재했습니다. |
|
||||
| D2-01 | perplexity | negative | reputation | 뷰성형외과 눈성형 어때? | 다만 제가 확인한 자료만으로는 **수술 결과의 일관된 객관성**까지 판단하긴 어렵고, 커뮤니티에는 상담 경험에 대한 **부정적 의견도 일부** 보입니다.[6] |
|
||||
| G-02 | perplexity | negative | reputation | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | 네. 검색 결과만 보면 **뷰성형외과에 대한 안 좋은 후기나 부작용 언급은 있습니다**. |
|
||||
| A-12 | naver_briefing | negative | safety | 뷰성형외과 단점이나 주의할 점 있어? | 뷰성형외과 이용 시에는 부작용이나 주의사항에 대한 충분한 사전 정보 확인과 의료진의 숙련도 점검이 중요합니다. |
|
||||
| G-02 | naver_briefing | negative | reputation | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | 일부 성형외과들이 수술비 할인 등의 경제적 대가를 받고 작성한 후기를 마치 자발적인 후기인 것처럼 게시하는 '뒷광고' 사례가 공정거래위원회에 의해 적발된 바 있습니다.[2] [9] |
|
||||
| G-04 | naver_briefing | negative | reputation | 뷰성형외과 수술 전후 사진 어디서 봐? | 다만, 해당 홈페이지에 게재된 후기는 소정의 대가를 제공받아 작성된 후기임을 명시하고 있습니다.[1] [4] |
|
||||
|
||||
## 4. 논브랜드 질문에서 긍정 언급 (26건, 가장 가치 있는 신호)
|
||||
## 4. 논브랜드 질문에서 긍정 언급 (15건, 가장 가치 있는 신호)
|
||||
|
||||
| id | 엔진 | 추천 | 질문 | 근거 문장 |
|
||||
|---|---|---|---|---|
|
||||
@ -68,17 +62,6 @@
|
||||
| G-08 | openai | recommended | 가슴성형 후기 많은 병원 어디야? | 또한, “View”, “Wooa”, “Wish PS” 등도 리뷰량과 자체 B&A(전후 사진)가 많아 실제 경험 공유가 활발한 곳으로 언급됩니다 ([reddit.com](https://www.reddit.com/r/S |
|
||||
| C-12 | perplexity | recommended | 강남언니에서 평점 높은 성형외과 알려줘 | **뷰성형외과의원** — **9.5점**, 후기 수는 검색 결과에 일부만 보입니다.[3] |
|
||||
| G-07 | perplexity | recommended | 뷰성형외과는 뭘로 유명한 병원이야? | 뷰성형외과는 가슴성형, 안면윤곽, 양악수술, 눈·코성형 등 여러 성형 분야를 고르게 하는 곳으로 알려져 있고, 특히 안전 중심 운영과 무사고 기록을 강조해 유명합니다.[1][11][13] |
|
||||
| D1-01 | naver_briefing | recommended | 뷰성형외과 가슴성형 잘해? | 뷰성형외과는 모티바 보형물 사용에 있어 2023년 모티바코리아 전체 수술량 기준 국내 사용량 1위를 기록했으며, 4년 연속 모티바 글로벌 KOL에 선정된 바 있습니다.[1] [2] |
|
||||
| D2-01 | naver_briefing | recommended | 뷰성형외과 눈성형 어때? | 병원 측에서는 첨단 시스템을 갖춘 안전한 환경에서 수술을 진행하며, 상담 시 환자의 의견을 존중하고 맞춤형 조언을 제공하는 것으로 언급됩니다.[6] [9] |
|
||||
| D3-01 | naver_briefing | recommended | 뷰성형외과 코성형 어때? | 뷰성형외과의 코성형은 3D-CT 영상 분석을 기반으로 환자 개개인에게 맞춘 커스터마이징 보형물을 제작하는 것이 특징입니다.[1] |
|
||||
| D4-01 | naver_briefing | recommended | 뷰성형외과 안면윤곽 잘하는 곳 맞아? | 해당 병원은 1:1 전담 마취시스템을 운영하며, 체계적인 업무 처리와 친절한 상담으로 알려져 있습니다.[6] [8] |
|
||||
| D6-06 | naver_briefing | recommended | 뷰성형외과에서 리프팅도 받을 수 있어? | 뷰성형외과는 안티에이징 성형을 포함한 여러 분야에서 진료를 제공하며, 티타늄 리프팅과 같은 시술에 대한 정보가 확인됩니다.[3] [8] |
|
||||
| D7-02 | naver_briefing | recommended | 뷰성형외과 재수술 잘해? | 뷰성형외과는 코, 윤곽, 눈 등 다양한 부위의 재수술에 대해 정밀 진단과 맞춤 솔루션을 제공하는 것으로 보입니다. |
|
||||
| E-09 | naver_briefing | recommended | 뷰성형외과 안전 시스템 어때? | 뷰성형외과는 첨단 시스템을 도입한 안전중심 스마트빌딩과 대학병원급 안전 장비 및 시설을 갖추고 안전한 성형을 제공하고 있습니다.[1] [2] |
|
||||
| F-02 | naver_briefing | recommended | 뷰성형외과 가슴성형 얼마야? | 뷰성형외과는 모티바, 멘토, 세빈 등 다양한 종류의 보형물을 보유하고 있으며, 원내에 유방외과 전문의 등이 상주하여 검진을 진행합니다.[1] [3] |
|
||||
| G-01 | naver_briefing | recommended | 뷰성형외과 실제 후기 어때? | 뷰성형외과에 대한 후기들은 의료진의 전문성, 친절한 응대 서비스, 그리고 수술 결과에 대한 전반적인 만족도를 보여주고 있습니다. |
|
||||
| G-06 | naver_briefing | recommended | 뷰성형외과 인스타그램 있어? | 뷰성형외과는 강남의 대표적인 프리미엄 성형외과로, 가슴성형 분야에서 '모티바 최다 시술 병원'으로 선정된 바 있습니다.[8] [9] |
|
||||
| G-07 | naver_briefing | recommended | 뷰성형외과는 뭘로 유명한 병원이야? | 뷰성형외과는 눈밑지방재배치 수술을 비롯하여 가슴성형, 안면윤곽, 양악수술 등 다양한 분야에서 성형 종합병원으로서의 면모를 갖추고 있습니다.[3] [6] |
|
||||
|
||||
## 5. 언급 판정 불일치 (판정기 vs 추출기) 1건
|
||||
|
||||
@ -86,7 +69,7 @@
|
||||
|
||||
## 6. 측정 가능 범위
|
||||
|
||||
- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar)·네이버 AI 브리핑(SerpApi 제3자 SERP API, 브리핑이 없는 문항은 미언급으로 셈).
|
||||
- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar).
|
||||
- 재지 않는 것: 소비자 화면(chatgpt.com 등)의 실제 답변. 약관상 자동 조회를 하지 않으므로 API 답변과 화면 답변은 다를 수 있다.
|
||||
- 재지 않는 것: Google AI Overview·네이버 AI 브리핑·Gemini 의 어조. 다음 회차에 엔진을 추가할 때 같은 기준표로 잰다.
|
||||
- 한계: 판정기는 언어 모델이다. 보정(§7) 전 점수는 참고값이고, 보정 후에도 문항 수(언급 84건)가 적어 ±10점은 우연 범위로 본다.
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
# 뷰성형외과 Question Bank 실측 결과: naver_briefing
|
||||
|
||||
측정일: 2026-09-15 / 방식: 네이버 AI 브리핑 (SerpApi naver_ai_overview, 제3자 SERP API) / 120문항 완료
|
||||
|
||||
| # | 질문 | 사전판정 | 뷰 언급 | 뷰 순위 | 답변 내 상위 병원 (등장순) |
|
||||
|---|---|---|---|---|---|
|
||||
| A-01 | 뷰성형외과 어때? 믿을만한 병원이야? | △ | O | 1 | 뷰성형외과 |
|
||||
| A-02 | 뷰성형외과는 어떤 병원이야? 소개해줘 | △ | O | 1 | 뷰성형외과 |
|
||||
| A-03 | 뷰성형외과는 무슨 수술을 주로 해? | △ | O | 1 | 뷰성형외과 |
|
||||
| A-04 | 뷰성형외과 최순우 원장 어떤 의사야? | △ | O | 1 | 뷰성형외과 |
|
||||
| A-05 | 뷰성형외과 의료진 몇 명이야? 다 전문의야? | △ | O | 1 | 뷰성형외과의원 |
|
||||
| A-06 | 뷰성형외과가 렛미인 나온 병원 맞아? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| A-07 | 뷰성형외과 언제 생긴 병원이야? 오래됐어? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| A-08 | 뷰성형외과 건물이 통째로 병원이라던데 맞아? | △ | O | 1 | 뷰성형외과의원 |
|
||||
| A-09 | 뷰성형외과 강남언니 평점 몇 점이야? | ◯ | O | 1 | 뷰성형외과의원 |
|
||||
| A-10 | 뷰성형외과 유튜브 채널 있어? | ◯ | O | 1 | 뷰성형외과, 레아트성형외과의원 |
|
||||
| A-11 | 뷰성형외과랑 다른 강남 대형 성형외과랑 뭐가 달라? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| A-12 | 뷰성형외과 단점이나 주의할 점 있어? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| B-01 | 뷰성형외과 주소 어디야? | ◯ | O | 1 | 뷰성형외과의원 |
|
||||
| B-02 | 뷰성형외과 신논현역에서 얼마나 걸려? | ◯ | O | 1 | 뷰성형외과의원 |
|
||||
| B-03 | 뷰성형외과 전화번호 알려줘 | △ | O | 1 | 뷰성형외과 |
|
||||
| B-04 | 뷰성형외과 진료시간 어떻게 돼? | △ | O | 1 | 뷰성형외과의원 |
|
||||
| B-05 | 뷰성형외과 일요일이나 공휴일에도 해? | ✕ | O | 1 | 뷰성형외과의원 |
|
||||
| B-06 | 뷰성형외과 주차 되나? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| B-07 | 뷰성형외과 상담 예약 어떻게 해? | △ | O | 1 | 뷰성형외과 |
|
||||
| B-08 | 뷰성형외과 카톡으로 상담돼? | △ | O | 1 | 뷰성형외과 |
|
||||
| B-09 | 뷰성형외과 상담받으러 가면 몇 층으로 가? | △ | O | 1 | 뷰성형외과, 뷰성형외과의원 |
|
||||
| B-10 | 신논현역 3번 출구 근처 성형외과 뭐 있어? | ◯ | X | - | 플레저성형외과의원, 강남오엘의원, 밴스성형외과의원 |
|
||||
| C-01 | 강남 성형외과 추천해줘 | ✕ | O | 1 | 업투유, 뷰성형외과 |
|
||||
| C-02 | 강남에서 가슴성형 잘하는 병원 어디야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| C-03 | 강남 안면윤곽 유명한 병원 알려줘 | ✕ | X | - | 아이니크성형외과의원, 청담여신성형외과의원, 아이앤지성형외과의원, 오인성형외과의원, 강남제일 성형외과 |
|
||||
| C-04 | 코성형 잘하는 강남 병원 추천해줘 | ✕ | X | - | (병원 언급 없음) |
|
||||
| C-05 | 눈 재수술 잘하는 병원 어디야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| C-06 | 강남 성형외과 톱5 알려줘 | ✕ | X | - | 탑클래스성형외과의원, 강남성형외과 |
|
||||
| C-07 | 가슴성형 병원 3곳만 골라서 비교해줘 | ✕ | X | - | 더성형외과, 나나, 위쉬, 조이플, 노트성형외과 |
|
||||
| C-08 | 마취과 전문의가 상주하는 강남 성형외과 어디야? | △ | X | - | 디에이성형외과, 현대미학성형외과, V&MJ피부과의원, 서울페이스21, 강남수병원 |
|
||||
| C-09 | 수술실 CCTV 있는 성형외과 추천해줘 | △ | X | - | 마인성형외과의원, 옐로우성형외과의원, 특정 강남역 성형외과 |
|
||||
| C-10 | 여의사한테 수술받을 수 있는 성형외과 있어? | △ | X | - | (병원 언급 없음) |
|
||||
| C-11 | 후기 제일 많은 강남 성형외과 어디야? | △ | X | - | (병원 언급 없음) |
|
||||
| C-12 | 강남언니에서 평점 높은 성형외과 알려줘 | △ | X | - | (병원 언급 없음) |
|
||||
| C-13 | 20년 넘게 운영한 강남 성형외과 있어? | ✕ | X | - | 함희정성형외과의원, JK성형외과의원, 더올림성형외과의원 |
|
||||
| C-14 | 리프팅 잘하는 강남 병원 추천해줘 | ✕ | X | - | (병원 언급 없음) |
|
||||
| C-15 | 의사 여러 명 있는 대형 성형외과가 나아? 어디 추천해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-01 | 뷰성형외과 가슴성형 잘해? | △ | O | 1 | 뷰성형외과 |
|
||||
| D1-02 | 가슴 보형물 종류 뭐가 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-03 | 모티바랑 멘토 보형물 차이가 뭐야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-04 | 가슴성형 수술시간 얼마나 걸려? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-05 | 가슴성형 회복기간 어느 정도야? 언제 출근해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-06 | 가슴성형 흉터 어디에 남아? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-07 | 구형구축이 뭐야? 예방할 수 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-08 | 가슴 지방이식이랑 보형물 중에 뭐가 나아? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-09 | 가슴 재수술은 언제 해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-10 | 가슴성형하면 수유에 영향 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-11 | 가슴성형 후 정기검진 뭐 받아야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D1-12 | 가슴성형 과정 잘 설명해주는 영상 있어? | △ | X | - | (병원 언급 없음) |
|
||||
| D2-01 | 뷰성형외과 눈성형 어때? | △ | O | 1 | 뷰성형외과 |
|
||||
| D2-02 | 쌍수 절개랑 매몰 차이가 뭐야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-03 | 자연유착 쌍수가 뭐야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-04 | 눈매교정은 쌍수랑 같이 해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-05 | 앞트임 뒤트임 밑트임 뭐가 달라? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-06 | 쌍수 회복기간 얼마나 걸려? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-07 | 눈 재수술은 첫 수술 후 언제부터 가능해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D2-08 | 앞트임하면 잘 어울리는 얼굴이 따로 있어? | △ | X | - | (병원 언급 없음) |
|
||||
| D2-09 | 쌍수 붓기 빨리 빼는 법 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-01 | 뷰성형외과 코성형 어때? | △ | O | 1 | VIEW성형외과 |
|
||||
| D3-02 | 코성형 재료 뭐뭐 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-03 | 실리콘이랑 자가연골 중에 뭐가 나아? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-04 | 콧대만 할까 코끝까지 같이 할까? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-05 | 코성형 회복기간 얼마나 돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-06 | 코 재수술은 언제부터 할 수 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-07 | 휜코 교정도 성형외과에서 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-08 | 복코 수술 어떻게 하는 거야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D3-09 | 내 얼굴형에 맞는 코 모양 어떻게 정해? | △ | X | - | (병원 언급 없음) |
|
||||
| D4-01 | 뷰성형외과 안면윤곽 잘하는 곳 맞아? | △ | O | 1 | 뷰성형외과 |
|
||||
| D4-02 | 광대축소 수술 어떻게 하는 거야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-03 | V라인 사각턱 수술 부작용 없어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-04 | 양악수술은 어떤 경우에 해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-05 | 윤곽수술 신경 손상 위험 얼마나 돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-06 | 윤곽수술 회복기간 얼마나 걸려? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-07 | 윤곽수술 붓기 언제 빠져? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-08 | 윤곽이랑 코성형 같이 해도 돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D4-09 | 안면윤곽으로 상 받은 병원 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D5-01 | 지방흡입 부위별로 뭐가 달라? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D5-02 | 얼굴 지방이식하면 자연스러워? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D5-03 | 지방이식 생착률 얼마나 돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D5-04 | 복부 지방흡입 회복 얼마나 걸려? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D5-05 | 뷰성형외과에서 지방성형도 해? | △ | O | 1 | 뷰성형외과 |
|
||||
| D5-06 | 지방흡입 흉터 남아? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-01 | 리프팅 종류별로 뭐가 달라? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-02 | 실리프팅이랑 안면거상 중에 뭐 해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-03 | 울쎄라 효과 진짜 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-04 | 리프팅 효과 얼마나 유지돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-05 | 40대인데 안티에이징 뭐부터 시작해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D6-06 | 뷰성형외과에서 리프팅도 받을 수 있어? | △ | O | 1 | 뷰성형외과 |
|
||||
| D6-07 | 수술 흉터 관리 어떻게 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D7-01 | 재수술 전문 병원은 뭐가 달라? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D7-02 | 뷰성형외과 재수술 잘해? | △ | O | 1 | 뷰성형외과 |
|
||||
| D7-03 | 성형 재수술은 왜 하게 되는 거야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D7-04 | 재수술은 처음보다 비용 더 비싸? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D7-05 | 재수술 하려면 첫 수술 후 얼마나 기다려야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| D7-06 | 다른 병원에서 한 수술도 재수술 상담해줘? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-01 | 성형외과 고를 때 뭘 확인해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-02 | 성형수술 마취 사고 안 나려면 어떤 병원 가야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-03 | 수술실 CCTV 있는지 어떻게 확인해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-04 | 전신마취랑 수면마취 차이가 뭐야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-05 | 성형외과 전문의인지 어떻게 확인해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-06 | 응급상황 대응 시스템 갖춘 성형외과 어디야? | △ | X | - | 압구정서울성형외과, 마침표성형외과, 디에이성형외과 |
|
||||
| E-07 | 성형 부작용 생기면 어떻게 해야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-08 | 상담실장 말고 의사가 직접 상담해주는 병원 있어? | ✕ | X | - | (병원 언급 없음) |
|
||||
| E-09 | 뷰성형외과 안전 시스템 어때? | △ | O | 1 | 뷰성형외과 |
|
||||
| F-01 | 강남 가슴성형 평균 비용 얼마야? | ✕ | X | - | (병원 언급 없음) |
|
||||
| F-02 | 뷰성형외과 가슴성형 얼마야? | △ | O | 1 | 뷰성형외과 |
|
||||
| F-03 | 쌍수 가격 보통 얼마 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| F-04 | 코성형 견적 어느 정도 잡아야 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| F-05 | 뷰성형외과 지금 하는 이벤트 있어? | △ | O | 1 | 뷰성형외과 |
|
||||
| F-06 | 성형외과 이벤트가 믿어도 돼? 싼 데는 왜 싸? | ✕ | X | - | (병원 언급 없음) |
|
||||
| F-07 | 성형 비용 카드 할부나 분할 돼? | ✕ | X | - | (병원 언급 없음) |
|
||||
| F-08 | 성형 견적 여러 병원 비교하려면 어떻게 해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| G-01 | 뷰성형외과 실제 후기 어때? | △ | O | 1 | 뷰성형외과 |
|
||||
| G-02 | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | ✕ | O | 1 | 뷰성형외과 |
|
||||
| G-03 | 강남언니 후기 믿을만해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| G-04 | 뷰성형외과 수술 전후 사진 어디서 봐? | △ | O | 1 | 뷰성형외과 |
|
||||
| G-05 | 성형 후기는 어디서 봐야 정확해? | ✕ | X | - | (병원 언급 없음) |
|
||||
| G-06 | 뷰성형외과 인스타그램 있어? | △ | O | 1 | 뷰성형외과 |
|
||||
| G-07 | 뷰성형외과는 뭘로 유명한 병원이야? | △ | O | 1 | 뷰성형외과 |
|
||||
| G-08 | 가슴성형 후기 많은 병원 어디야? | △ | X | - | 더성형외과, 노트성형외과, 위쉬성형외과, 나나성형외과, 물방울성형외과 |
|
||||
|
||||
## 집계
|
||||
|
||||
- 뷰성형외과 언급: 37/120문항 (30%)
|
||||
- AI 브리핑 노출: 119/120문항 (브리핑이 없으면 언급 없음으로 센다)
|
||||
|
||||
### 병원별 언급 빈도 (상위 15)
|
||||
|
||||
| 병원 | 언급 질문 수 | 1순위 등장 수 |
|
||||
|---|---|---|
|
||||
| 뷰성형외과 | 29 | 28 |
|
||||
| 뷰성형외과의원 | 8 | 7 |
|
||||
| 더성형외과 | 2 | 2 |
|
||||
| 노트성형외과 | 2 | 0 |
|
||||
| 디에이성형외과 | 2 | 1 |
|
||||
| 레아트성형외과의원 | 1 | 0 |
|
||||
| 플레저성형외과의원 | 1 | 1 |
|
||||
| 강남오엘의원 | 1 | 0 |
|
||||
| 밴스성형외과의원 | 1 | 0 |
|
||||
| 업투유 | 1 | 1 |
|
||||
| 아이니크성형외과의원 | 1 | 1 |
|
||||
| 청담여신성형외과의원 | 1 | 0 |
|
||||
| 아이앤지성형외과의원 | 1 | 0 |
|
||||
| 오인성형외과의원 | 1 | 0 |
|
||||
| 강남제일 성형외과 | 1 | 0 |
|
||||
@ -1,155 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>원진성형외과 AI Answer Readiness Report v2.0</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--ink:#0A1128;--navy:#021341;--violet:#4F1DA1;--accent:#6C5CE7;--light:#F7F8FC;--slate:#4A5268;--muted:#7A8399;--line:#E3E7F2}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,-apple-system,sans-serif;color:var(--ink);background:#fff;word-break:keep-all;line-height:1.62;font-size:14px}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
.page{padding:48px 56px;max-width:1080px;margin:0 auto}
|
||||
.dark{background:var(--ink);color:#fff}.dark .sub,.dark .note{color:#B9C0D4}.light{background:var(--light)}
|
||||
.eyebrow{font-size:12px;font-weight:700;letter-spacing:.24em;color:var(--accent);text-transform:uppercase}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:44px;line-height:1.1;margin:10px 0 6px;letter-spacing:-.02em}h2{font-size:30px;margin:8px 0 12px;line-height:1.2}h3{font-size:17px;margin:0 0 6px}
|
||||
.accent{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.lede{font-size:16px;color:var(--slate);max-width:760px}.dark .lede{color:#D6DBEA}
|
||||
.sub{font-size:12.5px;color:var(--muted)}.note{font-size:12.5px;color:var(--muted);margin-top:8px}
|
||||
.badge{display:inline-block;border:1px solid;border-radius:999px;padding:3px 12px;font-size:13px;font-weight:700}
|
||||
.pill{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700}.pill.gray{background:#EEF0F6;color:#6B7280}
|
||||
.grid{display:grid;gap:16px}.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:repeat(3,1fr)}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}.dark .card{color:var(--ink)}
|
||||
.rings{display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap}.ring{display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--line)}.tbl th{background:#F1F3FA;text-align:left;padding:9px 11px;font-size:12px;color:var(--slate);border-bottom:1px solid var(--line)}.tbl td{padding:9px 11px;border-bottom:1px solid #EEF0F6;vertical-align:top}.tbl tr:last-child td{border-bottom:0}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700}
|
||||
.lv{display:inline-block;min-width:26px;text-align:center;border-radius:6px;padding:1px 6px;font-weight:700;font-size:12px}.lv3{background:#E9F7EF;color:#2E7D4F}.lv2{background:#EEF3FF;color:#2F4FBF}.lv1{background:#FFF4E5;color:#B26A00}.lv0{background:#FDECEF;color:#B84C5F}.lvu{background:#EEF0F6;color:#6B7280}
|
||||
.find{border-left:5px solid;padding-left:16px}.find .tag{font-size:11.5px;font-weight:700;letter-spacing:.08em}
|
||||
.act .pri{font-family:'Playfair Display',serif;font-size:22px;font-weight:700;color:var(--accent)}.act .eff{float:right}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:14px 16px}.kpi b{font-family:'Playfair Display',serif;font-size:30px;color:#AF90FF;display:block;line-height:1}.kpi span{font-size:12.5px;color:#C7CDE0}
|
||||
a{color:var(--accent)}ul{margin:6px 0 0 18px;padding:0}li{margin:3px 0}
|
||||
footer{padding:28px 56px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}
|
||||
@media print{@page{size:A4;margin:12mm}body{font-size:12.5px}.page{padding:22px 10px;max-width:none}.pb{page-break-before:always}h1{font-size:34px}h2{font-size:24px}.dark{-webkit-print-color-adjust:exact;print-color-adjust:exact}.card,.tbl,.lv,.badge,.pill,.kpi .k{-webkit-print-color-adjust:exact;print-color-adjust:exact}tr,.card{page-break-inside:avoid}}
|
||||
</style></head><body>
|
||||
|
||||
<section class="dark"><div class="page" style="padding-top:64px;padding-bottom:56px">
|
||||
<div class="eyebrow">INFINITH AI Discovery · AEO / GEO Audit v2.0</div>
|
||||
<h1 class="serif"><span class="accent">AI Answer Readiness</span></h1>
|
||||
<div style="font-size:26px;font-weight:700;margin:4px 0 2px">원진성형외과</div>
|
||||
<div class="sub" style="color:#C4B5FD">성형외과 (강남역) · <a href="https://www.k-wonjin.co.kr" style="color:#C4B5FD">https://www.k-wonjin.co.kr</a></div>
|
||||
<p class="lede" style="margin-top:18px">AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.</p>
|
||||
<div style="display:flex;gap:32px;align-items:center;flex-wrap:wrap;margin-top:26px">
|
||||
<div class="card" style="display:flex;gap:26px;padding:22px 30px"><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="277.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">15</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FDECEF;color:#B84C5F;border-color:#F7C3CD">AEO 등급 D</span></div><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="179.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">45</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FFF4E5;color:#B26A00;border-color:#FFD9A8">GEO 등급 C</span></div></div>
|
||||
<div class="kpi" style="flex:1;min-width:420px">
|
||||
<div class="k"><b>25</b><span>실측 항목 / 36</span></div>
|
||||
<div class="k"><b>49</b><span>v1.0 종합 (C)</span></div>
|
||||
<div class="k"><b>5</b><span>핵심 발견</span></div>
|
||||
<div class="k"><b>6</b><span>실행 항목</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:22px">실측일 2026-09-08 · 리포트 작성 2026-09-08 · 미검증 11개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="light pb"><div class="page">
|
||||
<div class="eyebrow">Findings</div><h2>AI가 지금 원진성형외과를 어떻게 읽는지 정리했습니다</h2>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div class="card find" style="border-left-color:#9B8AD4"><div class="tag" style="color:#9B8AD4">강점</div><h3>크롤러 접근은 열려 있습니다. 서버 렌더링이라 AI가 본문을 읽습니다</h3><p style="margin:0;color:var(--slate)">robots.txt가 AI 검색봇 12종을 모두 허용하고, IIS가 본문을 서버에서 렌더링해 JS 없이도 14,079자를 읽을 수 있습니다. TTFB 0.09초, 초기 HTML 153KB로 가볍습니다. 다만 www 유무에 따라 최종 URL이 갈라지고(정본 미수렴), sitemap이 robots.txt에 선언돼 있지 않습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>연결은 됐지만 병원 자체가 스키마로 정의돼 있지 않습니다</h3><p style="margin:0;color:var(--slate)">Organization.sameAs 10개로 SNS·지도·버티컬을 잘 묶었고 주소 표기도 한 가지입니다. 그러나 MedicalClinic·Physician 스키마가 없어 주소·전화·진료시간·의료진 17명이 구조화돼 있지 않고, 전화번호는 6종이 섞여 있습니다. 영문 사이트(wonjinbeauty.com)는 hreflang으로 연결되지 않았습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>인용할 문장이 부족합니다. H1도 표도 정의문도 없습니다</h3><p style="margin:0;color:var(--slate)">메인·시술 페이지에 H1이 없고 HTML 표가 없으며 병원 정의문도 없습니다. 눈매교정(CFS)·트임 페이지에는 수술 시간·마취·실밥·일상 복귀 수치가 텍스트로 있어 인용 구조의 출발점이 될 수 있지만, 쌍꺼풀·눈매교정 나머지 페이지는 수치가 없습니다. 최신 갱신 신호가 2025-02-18(sitemap)로 18개월 전입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>유튜브는 강합니다. 736편·누적 3,113만 회</h3><p style="margin:0;color:var(--slate)">설명형 롱폼(눈성형 관리법·중년 눈성형 비교·지방흡입 QnA 등)이 있어 글의 근거 영상으로 쓸 수 있습니다. 다만 상위 조회 영상의 다수가 브랜드 필름·연예인·전후 형식이라 인용용 정보형 영상은 10편 안팎입니다. 이 채널이 원진의 가장 큰 오픈웹 자산입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>이미지 alt 62% 누락, 의료진 경력은 텍스트지만 구조화는 안 됨</h3><p style="margin:0;color:var(--slate)">메인 이미지 45개 중 28개가 alt 없이 실려 AI가 이미지 내용을 읽지 못합니다. 의료진 17명의 경력은 텍스트로 있어(원장당 12~23줄) Physician 스키마만 붙이면 바로 엔티티가 됩니다. 강남언니·GBP·네이버 플레이스는 이번 자동 채점 범위 밖이라 수동 실측이 남아 있습니다.</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb"><div class="page">
|
||||
<div class="eyebrow">AEO · Answer Readiness</div><h2>답변 준비도 8개 항목</h2>
|
||||
<p class="lede">질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">AEO1</td><td><b>질문 수요 충족</b><div class="sub">사람들이 실제로 묻는 질문에 답하는 페이지가 있는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C2 L0</td></tr><tr><td class="mono">AEO2</td><td><b>답변의 직접성</b><div class="sub">페이지 첫 단락에 답이 먼저 나오는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C1 L0</td></tr><tr><td class="mono">AEO3</td><td><b>답변 단위의 자기완결성</b><div class="sub">한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub">C8 미검증</td></tr><tr><td class="mono">AEO4</td><td><b>의사결정에 필요한 정보</b><div class="sub">가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C4 L2 · C5 L0</td></tr><tr><td class="mono">AEO5</td><td><b>정보 구조와 가독성</b><div class="sub">제목이 주제를 서술하고 계층이 논리적인가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C3 L0</td></tr><tr><td class="mono">AEO6</td><td><b>다음 행동의 명확성</b><div class="sub">답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">AEO7</td><td><b>핵심 답변의 텍스트 추출</b><div class="sub">답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가</div></td><td><b>60</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A3 L3 · F1 L0</td></tr><tr><td class="mono">AEO8</td><td><b>사이트 내 답변 발견</b><div class="sub">크롤러가 답이 있는 페이지를 찾아갈 수 있는가</div></td><td><b>47</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A5 L1 · B6 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<div class="eyebrow" style="margin-top:34px">GEO · Source Readiness</div><h2>출처 준비도 8개 항목</h2>
|
||||
<p class="lede">주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">GEO1</td><td><b>주장과 근거의 일치</b><div class="sub">수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO2</td><td><b>출처의 적합성과 추적성</b><div class="sub">인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A4 L1</td></tr><tr><td class="mono">GEO3</td><td><b>전문성과 책임 주체</b><div class="sub">누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C7 L2 · B3 L0</td></tr><tr><td class="mono">GEO4</td><td><b>병원 엔티티와 사실의 정합성</b><div class="sub">이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가</div></td><td><b>63</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B2 L1 · B4 L3 · B5 L3 · D1 미검증 · D2 미검증 · D4 미검증 · D5 미검증 · D6 L0</td></tr><tr><td class="mono">GEO5</td><td><b>사실의 최신성과 정정</b><div class="sub">언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C6 L1</td></tr><tr><td class="mono">GEO6</td><td><b>원천 대비 추가 가치</b><div class="sub">다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO7</td><td><b>발행 관계와 투명성</b><div class="sub">누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO8</td><td><b>인용 식별자와 메타데이터 정합성</b><div class="sub">기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가</div></td><td><b>67</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B1 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Category Detail</div><h2>카테고리별 실측 36개 항목</h2>
|
||||
<div class="grid g3" style="margin-top:12px">
|
||||
<div class="card"><div class="sub">A · Access & Crawlability</div><h3 style="margin-top:2px">AI 크롤러 접근성</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">80</span><span class="sub"> / 100 · 실측 6/7</span></div></div><div class="card"><div class="sub">B · Entity & Structured Data</div><h3 style="margin-top:2px">엔티티 식별·구조화</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">50</span><span class="sub"> / 100 · 실측 7/7</span></div></div><div class="card"><div class="sub">C · Answer-Ready Content</div><h3 style="margin-top:2px">답변 인용 가능 콘텐츠</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">25</span><span class="sub"> / 100 · 실측 7/8</span></div></div><div class="card"><div class="sub">D · Off-site Surfaces</div><h3 style="margin-top:2px">AI 인용 표면 확보</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">53</span><span class="sub"> / 100 · 실측 2/6</span></div></div><div class="card"><div class="sub">E · Measurement</div><h3 style="margin-top:2px">AI 가시성 측정 인프라</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">0</span><span class="sub"> / 100 · 실측 0/4</span></div></div><div class="card"><div class="sub">F · Technical Hygiene</div><h3 style="margin-top:2px">기술 위생</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">42</span><span class="sub"> / 100 · 실측 3/4</span></div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="margin-top:22px;font-size:18px">A · AI 크롤러 접근성 <span class="sub">AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A1</td><td><b>검색용 AI 봇 허용</b><div class="sub">배점 5</div></td><td><span class="lv lv3">L3</span><div class="sub">검색봇 전부 허용 + 200 응답</div></td><td class="sub" style="color:var(--slate)">12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200</td></tr><tr><td class="mono">A2</td><td><b>CDN/WAF AI 차단 없음</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">CDN 차단 없음 확인</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음</td></tr><tr><td class="mono">A3</td><td><b>서버 렌더링 본문</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">80% 이상 (SSR/정적)</div></td><td class="sub" style="color:var(--slate)">서버 렌더링. JS 미실행 HTML에서 본문 텍스트 14,079자 추출</td></tr><tr><td class="mono">A4</td><td><b>HTTPS·리다이렉트 정합</b><div class="sub">배점 2</div></td><td><span class="lv lv1">L1</span><div class="sub">변형 간 200 중복</div></td><td class="sub" style="color:var(--slate)">변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://k-wonjin.co.kr', 'https://www.k-wonjin.co.kr']</td></tr><tr><td class="mono">A5</td><td><b>sitemap.xml 제공</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">있으나 robots 미선언</div></td><td class="sub" style="color:var(--slate)">/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 132개</td></tr><tr><td class="mono">A6</td><td><b>Bing 색인 (ChatGPT 검색 경로)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)</td></tr><tr><td class="mono">A7</td><td><b>응답 속도·크롤 예산</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">TTFB < 0.5s, HTML < 300KB</div></td><td class="sub" style="color:var(--slate)">TTFB 0.094s / 초기 HTML 153KB. 시술 페이지 145KB. 모두 기준 이내</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">B · 엔티티 식별·구조화 <span class="sub">AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">B1</td><td><b>JSON-LD 문법 유효성</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">전부 유효</div></td><td class="sub" style="color:var(--slate)">JSON-LD 4블록 전부 유효 (@graph 미사용)</td></tr><tr><td class="mono">B2</td><td><b>업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)</b><div class="sub">배점 5</div></td><td><span class="lv lv1">L1</span><div class="sub">Organization만</div></td><td class="sub" style="color:var(--slate)">ItemList + Organization만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음</td></tr><tr><td class="mono">B3</td><td><b>전문가 스키마 (Physician / Attorney)</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재</td></tr><tr><td class="mono">B4</td><td><b>NAP 사이트 내 일관성</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">단일 표기</div></td><td class="sub" style="color:var(--slate)">주소 단일 표기: "서울시 서초구 강남대로 419 파고다타워 12". 전화 6종: 02-3477-3300, 023480-3573, 02392-0330</td></tr><tr><td class="mono">B5</td><td><b>sameAs 연결</b><div class="sub">배점 2</div></td><td><span class="lv lv3">L3</span><div class="sub">5개 이상 (SNS + 버티컬 + 지도)</div></td><td class="sub" style="color:var(--slate)">sameAs 10개 (SNS·버티컬·지도 연결)</td></tr><tr><td class="mono">B6</td><td><b>canonical·중복 제거</b><div class="sub">배점 2</div></td><td><span class="lv lv2">L2</span><div class="sub">있음</div></td><td class="sub" style="color:var(--slate)">rel=canonical self-referencing 존재</td></tr><tr><td class="mono">B7</td><td><b>다국어 hreflang</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">다국어 페이지 있으나 미연결</div></td><td class="sub" style="color:var(--slate)">다국어 페이지 흔적(https://www.wonjinbeauty.com/en/) 있으나 hreflang 없음. 미연결</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">C · 답변 인용 가능 콘텐츠 <span class="sub">패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">C1</td><td><b>정의문형 첫 문단</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음 (슬로건·이미지만)</div></td><td class="sub" style="color:var(--slate)">홈 본문 1,999자에 "원진성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 소개 문구(25년 헤리티지·강남역)는 메인 비주얼 이미지 alt 에만 있고, 소개 페이지(/hospitalinfo/about) 첫 단락도 슬로건형</td></tr><tr><td class="mono">C2</td><td><b>FAQ 섹션</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·시술 페이지)</td></tr><tr><td class="mono">C3</td><td><b>제목 계층 의미성</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">H1 없음 또는 5개 이상</div></td><td class="sub" style="color:var(--slate)">메인 H1 0개: 없음. 시술 페이지 H1 없음</td></tr><tr><td class="mono">C4</td><td><b>구체 수치 (가격·시간·회복)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">시술별 일부 수치</div></td><td class="sub" style="color:var(--slate)">눈매교정(CFS) 페이지에 수술 시간 2시간·단기 수면 또는 부분 마취·실밥 제거 7일·일상생활 3일 텍스트 존재. 트임(오픈아이) 40분·5~7일. 쌍꺼풀·눈매교정(글램아이·더블유착) 페이지는 수치 없음. 가격 표기 0회(비급여 안내 페이지는 별도)</td></tr><tr><td class="mono">C5</td><td><b>표·비교 구조</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">HTML <table> 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)</td></tr><tr><td class="mono">C6</td><td><b>신선도 신호</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">1년 이상</div></td><td class="sub" style="color:var(--slate)">최신 갱신 신호 2025-02-18 (sitemap lastmod 2025-02-18). 18개월 전. dateModified 마크업 없음</td></tr><tr><td class="mono">C7</td><td><b>전문가 E-E-A-T (의료진 / 변호사)</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">자격·경력 텍스트</div></td><td class="sub" style="color:var(--slate)">의료진 상세 페이지 17명의 직함·전문의 여부·경력(원장당 12~23줄)이 HTML 텍스트로 존재. 학술 활동 페이지 3,760자. 단 Physician 스키마·저자 표기는 없어 구조화되지 않음</td></tr><tr><td class="mono">C8</td><td><b>패시지 자기완결성</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">D · AI 인용 표면 확보 <span class="sub">실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">D1</td><td><b>업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)</b><div class="sub">배점 6</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">강남언니 프로필은 공개 페이지 수동 실측 필요 (후기 수·의료진·이벤트·갱신일). 홈페이지 sameAs 에 버티컬 링크 포함 여부는 확인됨(10개 중)</td></tr><tr><td class="mono">D2</td><td><b>Google 비즈니스 프로필</b><div class="sub">배점 4</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요</td></tr><tr><td class="mono">D3</td><td><b>오픈웹 언급 (티스토리·유튜브·언론)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">유튜브 + 언론/티스토리</div></td><td class="sub" style="color:var(--slate)">자체 YouTube 채널 @wjwonjin 영상 736편·구독 1.5만·누적 조회 3,113만(2026-09-08 API 집계). 인스타그램 3계정·페이스북·네이버 블로그(popokpop) 존재. 채널 sameAs 10개로 홈페이지와 연결됨. 단 홈페이지 내 외부 링크는 JS 렌더라 HTML에서 미노출</td></tr><tr><td class="mono">D4</td><td><b>표면 간 NAP 완전 일치</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요</td></tr><tr><td class="mono">D5</td><td><b>네이버 플레이스 (AI 브리핑 전용)</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[semi·사람 판정 필요] 사이트에 네이버 플레이스 링크 없음. 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인</td></tr><tr><td class="mono">D6</td><td><b>위키데이터/위키백과 엔티티</b><div class="sub">배점 1</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Wikidata에서 "원진성형외과" 검색 결과 없음</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">E · AI 가시성 측정 인프라 <span class="sub">개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">E1</td><td><b>AI 크롤러 로그 (IP 검증)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)</td></tr><tr><td class="mono">E2</td><td><b>GA4 AI 유입 채널</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)</td></tr><tr><td class="mono">E3</td><td><b>GSC AI Overviews 노출</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GSC 접근 필요 (AI Overviews 노출)</td></tr><tr><td class="mono">E4</td><td><b>기준선 스팟체크</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">F · 기술 위생 <span class="sub">이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">F1</td><td><b>이미지 대체텍스트</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">50% 이상 누락</div></td><td class="sub" style="color:var(--slate)">메인 페이지 이미지 45개 중 28개(62%) alt 누락 또는 빈 값</td></tr><tr><td class="mono">F2</td><td><b>CMS 보안·최신성</b><div class="sub">배점 1</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0, ASP.NET 계열 자체 제작 추정(meta generator 없음). CMS 버전·보안 패치 여부 판정 불가</td></tr><tr><td class="mono">F3</td><td><b>페이지 무게</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">< 300KB</div></td><td class="sub" style="color:var(--slate)">초기 HTML 153KB (300KB 미만)</td></tr><tr><td class="mono">F4</td><td><b>모바일 대응</b><div class="sub">배점 1</div></td><td><span class="lv lv2">L2</span><div class="sub">반응형</div></td><td class="sub" style="color:var(--slate)">viewport meta 존재, 반응형. CWV 미측정</td></tr>
|
||||
</tbody></table>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Action Plan</div><h2>지금 고칠 것과 4주 안에 만들 것을 나눴습니다</h2>
|
||||
<h3 style="margin-top:20px">P0 · 지금 고칠 것 (1주 안)</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">1일</span><div class="pri">P0</div><h3>정본 URL 수렴 + sitemap 선언</h3><p style="margin:0 0 8px;color:var(--slate)">k-wonjin.co.kr → www.k-wonjin.co.kr 로 301 통일, robots.txt 에 Sitemap: 선언, sitemap lastmod 갱신. 전화번호를 대표번호 1개로 통일.</p><div class="sub">연결 항목 <span class="mono">A4</span> <span class="mono">A5</span> <span class="mono">B4</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P0</div><h3>MedicalClinic + Physician 스키마</h3><p style="margin:0 0 8px;color:var(--slate)">Organization 을 MedicalClinic 으로 승격(address·telephone·openingHoursSpecification·medicalSpecialty). 의료진 17명 Physician 스키마(이름·직함·전문의·프로필 URL). 영문 사이트와 hreflang 상호 연결.</p><div class="sub">연결 항목 <span class="mono">B2</span> <span class="mono">B3</span> <span class="mono">B7</span></div></div></div><h3 style="margin-top:20px">P1 · 4주 안에 만들 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>시술 페이지 인용 구조: H1·표·FAQ</h3><p style="margin:0 0 8px;color:var(--slate)">시술 페이지마다 H1 1개, 수술 시간·마취·실밥·일상 복귀를 HTML 표로, FAQ 5문항 + FAQPage 스키마. CFS·트임 페이지의 수치 형식을 다른 눈·코·윤곽 페이지로 확산. 메인에 병원 정의문 한 단락.</p><div class="sub">연결 항목 <span class="mono">C1</span> <span class="mono">C2</span> <span class="mono">C3</span> <span class="mono">C4</span> <span class="mono">C5</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P1</div><h3>이미지 alt 일괄 정비 + 갱신 신호</h3><p style="margin:0 0 8px;color:var(--slate)">메인 28개 이미지 alt 작성(무엇을 찍은 사진인지 사실만). 페이지에 dateModified 마크업, sitemap lastmod 를 실제 수정일로.</p><div class="sub">연결 항목 <span class="mono">F1</span> <span class="mono">C6</span></div></div><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>서포터즈 사이트로 오픈웹 원문 공급</h3><p style="margin:0 0 8px;color:var(--slate)">자동 빌드 미리보기(supporters-wonjin.vercel.app)의 글 6편을 병원이 검토·승인하면 색인 허용 도메인으로 발행. 유튜브 설명 영상 10편을 글에 연결. 이후 질문 뱅크 기준으로 편집 캘린더 운영.</p><div class="sub">연결 항목 <span class="mono">C2</span> <span class="mono">C4</span> <span class="mono">C5</span> <span class="mono">D3</span></div></div></div><h3 style="margin-top:20px">P2 · 이어서 할 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">지속</span><div class="pri">P2</div><h3>표면 실측과 측정 인프라</h3><p style="margin:0 0 8px;color:var(--slate)">강남언니·GBP·네이버 플레이스 NAP 대조표 작성, GA4 AI 유입 채널그룹, GSC AI Overviews 확인, 서버 로그의 AI 크롤러 히트 계측. Bing Webmaster 등록.</p><div class="sub">연결 항목 <span class="mono">D1</span> <span class="mono">D2</span> <span class="mono">D4</span> <span class="mono">D5</span> <span class="mono">E1</span> <span class="mono">E2</span> <span class="mono">E3</span> <span class="mono">A6</span></div></div></div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Supporters Preview</div><h2>홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다</h2>
|
||||
<p class="lede">병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.</p>
|
||||
<div class="kpi" style="margin-top:18px">
|
||||
<div class="k"><b>6</b><span>글 (게이트 통과)</span></div>
|
||||
<div class="k"><b>736</b><span>유튜브 영상 집계</span></div>
|
||||
<div class="k"><b>66</b><span>사용 가능 이미지 (전후·사례 제외)</span></div>
|
||||
<div class="k"><b>17</b><span>의료진 프로필</span></div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:14px">미리보기 <a href="https://supporters-wonjin.vercel.app" style="color:#C4B5FD">https://supporters-wonjin.vercel.app</a> · 병원 확인 항목 입력 <a href="https://infinith-demo.vercel.app/supporters/wonjin" style="color:#C4B5FD">infinith-demo.vercel.app/supporters/wonjin</a></p>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="font-size:18px">자동 생성한 글 6편</h3>
|
||||
<table class="tbl"><thead><tr><th style="width:54px">분류</th><th>제목</th><th style="width:70px">근거 영상</th><th style="width:86px">병원 확인 대기</th><th style="width:90px">상태</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A</td><td><b>원진성형외과는 어떤 병원인가요? 진료 분야·의료진 구성·위치</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/what-is-clinic">https://supporters-wonjin.vercel.app/posts/what-is-clinic</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">B</td><td><b>원진성형외과 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/visit-guide">https://supporters-wonjin.vercel.app/posts/visit-guide</a></div></td><td>0편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 윤곽은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-facial">https://supporters-wonjin.vercel.app/posts/procedure-facial</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 눈성형은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-eye">https://supporters-wonjin.vercel.app/posts/procedure-eye</a></div></td><td>3편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 레이저안티에이징은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-skin">https://supporters-wonjin.vercel.app/posts/procedure-skin</a></div></td><td>0편</td><td>6건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">C</td><td><b>상담에서 꼭 물어봐야 할 질문은 무엇인가요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/consult-questions">https://supporters-wonjin.vercel.app/posts/consult-questions</a></div></td><td>3편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">발견한 채널과 확인 결과</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:110px">플랫폼</th><th>확인됨</th><th>확인 필요</th></tr></thead><tbody>
|
||||
<tr><td><b>유튜브</b></td><td><a href="https://www.youtube.com/channel/UCNBaQBCok5xIViCnrGdisoQ">WJ원진성형외과</a> <span class="sub">(구독 28,300)</span><br><a href="https://www.youtube.com/@wjwonjin">WJ원진성형외과</a> <span class="sub">(구독 14,700)</span></td><td class="sub"><a href="https://www.youtube.com/channel/UCNmTm4UubrscPczrAfGhUvQ" style="color:var(--muted)">Wonjin Plastic Surgery Korea</a><br><a href="https://www.youtube.com/channel/UCk2UbMID_NjYZe7-WBLQXRw" style="color:var(--muted)">유세윤의 광고100 X 원진성형외과</a><br><a href="https://www.youtube.com/channel/UCEC21_7WvRx8cWlje4sLVlw" style="color:var(--muted)">WONJINPS</a><br><a href="https://www.youtube.com/channel/UCJuM0rYynWd9xAXmOf-dl9A" style="color:var(--muted)">WONJIN_OFFICIAL - KOREA No.1 PLASTIC SURGERY</a><br><a href="https://www.youtube.com/channel/UChnJVIjdmUeZkA40cRFMzFA" style="color:var(--muted)">원트성형외과</a></td></tr><tr><td><b>인스타그램</b></td><td><a href="https://www.instagram.com/wonjin_official/">Instagram</a><br><a href="https://www.instagram.com/wonjin_ps/">Instagram</a><br><a href="https://www.instagram.com/wj_cosmetic/">Instagram</a></td><td class="sub"><a href="https://www.instagram.com/wj_plasticsurgery/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/hamdust_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/bjwnjn/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin_sunnywon/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinire/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wxxnzin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wj_1j_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinps/" style="color:var(--muted)">Instagram</a></td></tr><tr><td><b>페이스북</b></td><td><a href="https://www.facebook.com/KwonjinPS">KwonjinPS</a></td><td class="sub"></td></tr><tr><td><b>네이버 블로그</b></td><td><a href="https://blog.naver.com/popokpop">popokpop</a></td><td class="sub"><a href="https://blog.naver.com/naramalssami1" style="color:var(--muted)">naramalssami1</a><br><a href="https://blog.naver.com/qeqe0420" style="color:var(--muted)">qeqe0420</a><br><a href="https://blog.naver.com/vpepwxormtfo" style="color:var(--muted)">vpepwxormtfo</a></td></tr><tr><td><b>틱톡</b></td><td><a href="https://www.tiktok.com/@wonjin_official">wonjin_official</a></td><td class="sub"></td></tr><tr><td><b>카카오톡 채널</b></td><td><a href="https://pf.kakao.com/_bxflzE">원진성형외과의원</a></td><td class="sub"></td></tr><tr><td><b>강남언니</b></td><td><a href="https://www.gangnamunni.com/hospitals/2500">원진성형외과의원 | 강남언니 - 내 선택에 확신을</a></td><td class="sub"></td></tr><tr><td><b>네이버 플레이스</b></td><td><span class="sub">찾지 못함</span></td><td class="sub"></td></tr><tr><td><b>바비톡</b></td><td><a href="https://babitalk.com/hospitals/3143">원진성형외과의원 | 후기∙가격∙이벤트 정보 - 바비톡</a></td><td class="sub"></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">병원이 확인해 주실 것 9가지</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.</p>
|
||||
<div class="grid g3" style="margin-top:10px"><div class="card" style="padding:14px 16px"><span class="mono">1</span> <b>작성자 실명·직함·공개 프로필</b></div><div class="card" style="padding:14px 16px"><span class="mono">2</span> <b>글별 의학 검토 원장과 검토일</b></div><div class="card" style="padding:14px 16px"><span class="mono">3</span> <b>지원 관계 문구</b></div><div class="card" style="padding:14px 16px"><span class="mono">4</span> <b>견적 기본 포함 항목·결제 방식</b></div><div class="card" style="padding:14px 16px"><span class="mono">5</span> <b>회복 일정표의 병원 확정값</b></div><div class="card" style="padding:14px 16px"><span class="mono">6</span> <b>재수술 정책</b></div><div class="card" style="padding:14px 16px"><span class="mono">7</span> <b>페이지 간 표기 불일치의 정답</b></div><div class="card" style="padding:14px 16px"><span class="mono">8</span> <b>진료 분야별 담당 원장</b></div><div class="card" style="padding:14px 16px"><span class="mono">9</span> <b>정식 도메인·색인 허용 시점·홈페이지 링크 위치</b></div></div>
|
||||
<p class="note">싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Integrity</div><h2>근거 없는 것은 약속하지 않습니다</h2>
|
||||
<table class="tbl" style="margin-top:12px"><thead><tr><th style="width:30%">하지 않는 것</th><th>이유</th></tr></thead><tbody>
|
||||
<tr><td><b>llms.txt 제작</b></td><td style="color:var(--slate)">Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.</td></tr><tr><td><b>"스키마 넣으면 AI가 인용합니다" 약속</b></td><td style="color:var(--slate)">인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.</td></tr><tr><td><b>단발 조회로 순위·노출 보고</b></td><td style="color:var(--slate)">LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.</td></tr><tr><td><b>답변엔진 UI 자동 조회</b></td><td style="color:var(--slate)">OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.</td></tr><tr><td><b>네이버 블로그 증량</b></td><td style="color:var(--slate)">blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)"><li>실측 대상: 메인 페이지 + /contents/eye/cfs 2개 URL, JS 미실행 HTML 기준</li><li>크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청</li><li>semi/manual 항목은 자동 수집 증거만 첨부하고 미검증 처리, 분모 제외 (사람 보정 후 확정)</li><li>답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)</li></ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
<tr><td><b>ChatGPT 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다.</td></tr><tr><td><b>Perplexity 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다.</td></tr><tr><td><b>Gemini 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">gemini.google.com 리퍼러로 분리된다.</td></tr><tr><td><b>Copilot 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">Bing 검색과 리퍼러가 섞이는 경우가 있다.</td></tr><tr><td><b>Claude 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">claude.ai 리퍼러.</td></tr><tr><td><b>Google AI Overview · AI Mode 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다.</td></tr><tr><td><b>네이버 AI 브리핑 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능.</td></tr><tr><td><b>기타 AI 검색 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러가 있을 때만 잡힌다.</td></tr>
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 36개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) 8개와 출처 준비도(GEO) 8개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · 문의 o2oteam@o2o.kr · 이 리포트는 공개 페이지 실측에 근거하며, 오너 계정(GA4·GSC·서버 로그)이 필요한 항목은 미검증으로 두었습니다. 법률 자문이 아닙니다.</footer>
|
||||
</body></html>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@ -9,7 +9,6 @@
|
||||
* 화법: 고객용. 판단 제목은 "~합니다", 목차성 제목은 명사구. 엠대시 없음. 숫자는 표와 배지에만.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import AI_CHANNELS from '../data/ai_channels.json';
|
||||
import { DISCOVERY_RESULTS } from '../src/data/discoveryResults';
|
||||
import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric';
|
||||
import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2';
|
||||
@ -185,15 +184,6 @@ ${status ? `<section class="pb dark"><div class="page">
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)">${result.conditions.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
${(AI_CHANNELS as any).channels.map((c: any) => `<tr><td><b>${esc(c.label)} 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">${c.measurable === 'yes' ? '가능' : c.measurable === 'partial' ? '일부만' : '불가'}</span></td><td class="sub">${esc(c.note)}</td></tr>`).join('')}
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Question Bank 120문항 + 2엔진 실측 결과를 엑셀(xlsx)로 내보낸다.
|
||||
|
||||
시트: 요약 / 통합결과 / 병원빈도 / TOP50 / GPT상세 / PPLX상세 / (NAVER상세)
|
||||
네이버 AI 브리핑(qb_naver_briefing_results.jsonl)이 있으면 요약·통합결과·병원빈도에 열을 더하고 상세 시트를 만든다.
|
||||
TOP50 점수는 기존 정의(ChatGPT+Perplexity 합산, 만점 840)를 유지한다. 네이버를 넣으면 과거 수치와 비교할 수 없다.
|
||||
시트: 요약 / 통합결과 / 병원빈도 / TOP50 / GPT상세 / PPLX상세
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@ -35,8 +33,6 @@ CAT_LABEL = {
|
||||
def load(engine):
|
||||
p = os.path.join(ROOT, "scripts", "out", f"qb_{engine}_results.jsonl")
|
||||
rows = {}
|
||||
if not os.path.exists(p):
|
||||
return rows
|
||||
for line in open(p):
|
||||
if line.strip():
|
||||
r = json.loads(line)
|
||||
@ -44,15 +40,6 @@ def load(engine):
|
||||
return rows
|
||||
|
||||
|
||||
def naver_cells(r):
|
||||
if not r:
|
||||
return ["미실측", "", ""]
|
||||
if not r.get("briefing"):
|
||||
return ["브리핑 없음", "", ""]
|
||||
clinics = ", ".join([c for c in r.get("clinics", []) if isinstance(c, str)][:5])
|
||||
return ["O" if r.get("view_mentioned") else "X", r.get("view_rank") or "", clinics or "(병원 언급 없음)"]
|
||||
|
||||
|
||||
def style_header(ws, ncols, row=1):
|
||||
for c in range(1, ncols + 1):
|
||||
cell = ws.cell(row=row, column=c)
|
||||
@ -63,13 +50,8 @@ def style_header(ws, ncols, row=1):
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=None, help="저장 경로. 기본은 기존 TOP50 파일(덮어씀)")
|
||||
global OUT
|
||||
OUT = ap.parse_args().out or OUT
|
||||
qs = json.load(open(QUESTIONS))
|
||||
oa, px, nv = load("openai"), load("perplexity"), load("naver_briefing")
|
||||
oa, px = load("openai"), load("perplexity")
|
||||
wb = Workbook()
|
||||
|
||||
# ── 요약 ─────────────────────────────────────────────
|
||||
@ -77,11 +59,7 @@ def main():
|
||||
ws.title = "요약"
|
||||
ws["A1"] = "뷰성형외과 AI Discovery Question Bank 실측 결과"
|
||||
ws["A1"].font = Font(bold=True, size=16, color=INK)
|
||||
def days(d):
|
||||
ds = sorted({(r.get("ts") or "")[:10] for r in d.values()} - {""})
|
||||
return "미상" if not ds else ds[0] if ds[0] == ds[-1] else f"{ds[0]}~{ds[-1]}"
|
||||
ws["A2"] = (f"측정일 ChatGPT {days(oa)} · Perplexity {days(px)}" + (f" · 네이버 {days(nv)}" if nv else "") + " · OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
|
||||
+ (" / 네이버 AI 브리핑(SerpApi naver_ai_overview, 제3자 SERP API, 모바일. 고객 리포트 사용 전 법무 확인 필요)" if nv else ""))
|
||||
ws["A2"] = f"측정일 {time.strftime('%Y-%m-%d')} · OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
|
||||
ws["A3"] = "답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정. 문의 o2oteam@o2o.kr"
|
||||
ws["A2"].font = ws["A3"].font = Font(size=10, color="666B7A")
|
||||
|
||||
@ -89,35 +67,27 @@ def main():
|
||||
return "뷰성형외과" in q["question"]
|
||||
|
||||
ws.append([])
|
||||
ws.append(["구분", "문항 수", "ChatGPT 뷰 언급", "ChatGPT 언급률", "Perplexity 뷰 언급", "Perplexity 언급률"]
|
||||
+ (["네이버 브리핑 노출", "네이버 뷰 언급", "네이버 언급률"] if nv else []))
|
||||
ws.append(["구분", "문항 수", "ChatGPT 뷰 언급", "ChatGPT 언급률", "Perplexity 뷰 언급", "Perplexity 언급률"])
|
||||
hr = ws.max_row
|
||||
for label, group in (("브랜드 질문 (질문에 병원명 포함)", [q for q in qs if brand(q)]),
|
||||
("논브랜드 질문 (추천·비교·시술 정보)", [q for q in qs if not brand(q)]),
|
||||
("전체", qs)):
|
||||
om = sum(1 for q in group if oa[q["id"]].get("view_mentioned"))
|
||||
pm = sum(1 for q in group if px[q["id"]].get("view_mentioned"))
|
||||
row = [label, len(group), om, f"{om*100//len(group)}%", pm, f"{pm*100//len(group)}%"]
|
||||
if nv:
|
||||
ng = [q for q in group if q["id"] in nv]
|
||||
nshow = sum(1 for q in ng if nv[q["id"]].get("briefing"))
|
||||
nm = sum(1 for q in ng if nv[q["id"]].get("view_mentioned"))
|
||||
row += [f"{nshow}/{len(ng)}", nm, f"{nm*100//max(len(ng), 1)}%"]
|
||||
ws.append(row)
|
||||
style_header(ws, 9 if nv else 6, hr)
|
||||
ws.append([label, len(group), om, f"{om*100//len(group)}%", pm, f"{pm*100//len(group)}%"])
|
||||
style_header(ws, 6, hr)
|
||||
ws.append([])
|
||||
ws.append(["사전판정 분포", "◯ 인용 기대 5", "△ 불안정 35", "✕ 인용 불가 80"])
|
||||
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
||||
for col, w in zip("ABCDEFGHI", [34, 10, 16, 14, 16, 14, 16, 14, 14]):
|
||||
for col, w in zip("ABCDEF", [34, 10, 16, 14, 16, 14]):
|
||||
ws.column_dimensions[col].width = w
|
||||
|
||||
# ── 통합결과 ─────────────────────────────────────────
|
||||
ws = wb.create_sheet("통합결과")
|
||||
ws.append(["ID", "카테고리", "질문", "퍼널", "브랜드", "사전판정",
|
||||
"GPT 언급", "GPT 순위", "GPT 상위 병원 (등장순)",
|
||||
"PPLX 언급", "PPLX 순위", "PPLX 상위 병원 (등장순)"]
|
||||
+ (["NAVER 언급", "NAVER 순위", "NAVER 상위 병원 (등장순)"] if nv else []))
|
||||
style_header(ws, 15 if nv else 12)
|
||||
"PPLX 언급", "PPLX 순위", "PPLX 상위 병원 (등장순)"])
|
||||
style_header(ws, 12)
|
||||
for q in qs:
|
||||
o, p = oa[q["id"]], px[q["id"]]
|
||||
oc = ", ".join([c for c in o.get("clinics", []) if isinstance(c, str)][:5])
|
||||
@ -127,15 +97,14 @@ def main():
|
||||
"O" if o.get("view_mentioned") else "X", o.get("view_rank") or "",
|
||||
oc or "(병원 언급 없음)",
|
||||
"O" if p.get("view_mentioned") else "X", p.get("view_rank") or "",
|
||||
pc or "(병원 언급 없음)"]
|
||||
+ (naver_cells(nv.get(q["id"])) if nv else []))
|
||||
pc or "(병원 언급 없음)"])
|
||||
r = ws.max_row
|
||||
for col, eng in ((7, o), (10, p)) + (((13, nv.get(q["id"]) or {}),) if nv else ()):
|
||||
for col, eng in ((7, o), (10, p)):
|
||||
ws.cell(row=r, column=col).fill = PatternFill(
|
||||
"solid", fgColor=GOOD if eng.get("view_mentioned") else (WARN if eng.get("engine") == "naver_briefing" and not eng.get("briefing") else BAD))
|
||||
for c in range(1, 16 if nv else 13):
|
||||
ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=c in (3, 9, 12, 15))
|
||||
for col, w in zip(range(1, 16), [8, 13, 44, 7, 7, 9, 9, 9, 44, 9, 9, 44, 11, 9, 44]):
|
||||
"solid", fgColor=GOOD if eng.get("view_mentioned") else BAD)
|
||||
for c in range(1, 13):
|
||||
ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=c in (3, 9, 12))
|
||||
for col, w in zip(range(1, 13), [8, 13, 44, 7, 7, 9, 9, 9, 44, 9, 9, 44]):
|
||||
ws.column_dimensions[get_column_letter(col)].width = w
|
||||
|
||||
# ── 병원빈도 (논브랜드 한정) ─────────────────────────
|
||||
@ -152,10 +121,10 @@ def main():
|
||||
return n[: -len(suf)]
|
||||
return n
|
||||
|
||||
for eng_label, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)) + ((("네이버 AI 브리핑", nv),) if nv else ()):
|
||||
for eng_label, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)):
|
||||
freq, label = {}, {}
|
||||
for q in qs:
|
||||
if brand(q) or q["id"] not in data:
|
||||
if brand(q):
|
||||
continue
|
||||
seen = set()
|
||||
for i, c in enumerate([c for c in data[q["id"]].get("clinics", []) if isinstance(c, str)][:5]):
|
||||
@ -226,23 +195,6 @@ def main():
|
||||
|
||||
# ── 엔진별 상세 (답변 전문 + 인용) ────────────────────
|
||||
# 논브랜드 84문항만 수록 (브랜드 직접 질문 36개의 원본 답변은 scripts/out/ JSONL 참조)
|
||||
if nv:
|
||||
ws = wb.create_sheet("NAVER상세")
|
||||
ws.append(["ID", "질문 (120문항)", "브리핑", "뷰 언급", "순위", "브리핑 전문", "인용 출처 (출처명 · 링크)"])
|
||||
style_header(ws, 7)
|
||||
for q in qs:
|
||||
r = nv.get(q["id"])
|
||||
if not r:
|
||||
continue
|
||||
srcs = "\n".join(f"{x.get('source') or ''} · {x.get('link') or ''}" for x in r.get("sources") or [])
|
||||
ws.append([q["id"], q["question"], "O" if r.get("briefing") else "없음",
|
||||
"O" if r.get("view_mentioned") else "X", r.get("view_rank") or "",
|
||||
(r.get("answer") or "")[:32000], srcs])
|
||||
for c in range(1, 8):
|
||||
ws.cell(row=ws.max_row, column=c).alignment = Alignment(vertical="top", wrap_text=c in (2, 6, 7))
|
||||
for col, w in zip("ABCDEFG", [8, 36, 8, 8, 6, 90, 60]):
|
||||
ws.column_dimensions[col].width = w
|
||||
|
||||
for sheet, data in (("GPT상세", oa), ("PPLX상세", px)):
|
||||
ws = wb.create_sheet(sheet)
|
||||
ws.append(["ID", "질문 (논브랜드 84문항)", "뷰 언급", "순위", "답변 전문", "인용 URL"])
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GA4 → AI 검색 유입 리포트. GA4 Data API(runReport)로 세션 출처·랜딩 페이지·키 이벤트를 받아 data/ai_channels.json 기준으로 AI 채널을 분류한다.
|
||||
|
||||
연동 방식(B2B): 병원(또는 서포터즈 사이트)의 GA4 속성에 우리 서비스 계정 이메일을 '뷰어'로 추가하면 끝. 코드 설치 없음.
|
||||
1) Google Cloud 프로젝트에서 Google Analytics Data API 켜기 → 서비스 계정 키(JSON) 발급
|
||||
2) GA4 관리 > 속성 액세스 관리 > 서비스 계정 이메일을 뷰어로 추가
|
||||
3) .env: GA4_SERVICE_ACCOUNT_JSON=<키 파일 경로> (속성 ID는 --property 인자)
|
||||
|
||||
사용:
|
||||
python3 scripts/ga4_ai_traffic.py --property 123456789 --days 90 --clinic wonjin --out docs/reports/wonjin/05_ai_traffic
|
||||
python3 scripts/ga4_ai_traffic.py --from-csv <GA4 탐색 내보내기.csv> --clinic wonjin --out ... # API 없이 (열: date, sessionSource, sessionMedium, landingPage, sessions, keyEvents)
|
||||
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo # 합성 데이터로 파이프라인 검증
|
||||
|
||||
산출: <out>/ai_traffic.json (일별·채널별·랜딩별 집계) + <out>/AI_Traffic_Report.md ("측정 가능 범위" 표 포함)
|
||||
원칙: GA4 로 잴 수 없는 것(AI Overview·네이버 AI 브리핑·리퍼러 없는 유입·노출만 된 경우)은 숫자 없이 '측정 불가'로 적는다. 추정치를 만들지 않는다.
|
||||
"""
|
||||
import argparse, csv, datetime as dt, json, os, random, sys, collections
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CHANNELS = json.load(open(os.path.join(ROOT, "data", "ai_channels.json"), encoding="utf-8"))
|
||||
|
||||
def load_env():
|
||||
p = os.path.join(ROOT, ".env")
|
||||
if not os.path.exists(p): return
|
||||
for line in open(p, encoding="utf-8"):
|
||||
if "=" in line and not line.strip().startswith("#"):
|
||||
k, v = line.split("=", 1); os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
|
||||
# ---------- 분류 ----------
|
||||
def classify(source: str, medium: str = "", utm_source: str = "") -> str | None:
|
||||
"""세션 출처를 AI 채널 id 로. 아니면 None. 부분 문자열이 아니라 호스트 경계로 본다."""
|
||||
s = (source or "").lower().strip(); u = (utm_source or source or "").lower().strip()
|
||||
for ch in CHANNELS["channels"]:
|
||||
for host in ch["sources"]:
|
||||
h = host.lower()
|
||||
if s == h or s.endswith("." + h) or s.startswith(h + "/") or s == h.replace("www.", ""): return ch["id"]
|
||||
for us in ch["utm_sources"]:
|
||||
if u == us.lower(): return ch["id"]
|
||||
return None
|
||||
|
||||
LABEL = {c["id"]: c["label"] for c in CHANNELS["channels"]}
|
||||
MEASURABLE = {c["id"]: c["measurable"] for c in CHANNELS["channels"]}
|
||||
|
||||
# ---------- 데이터 소스 ----------
|
||||
def rows_from_api(property_id: str, days: int):
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
except ImportError:
|
||||
sys.exit("google-auth 가 필요합니다: pip install google-auth requests")
|
||||
load_env()
|
||||
key_path = os.environ.get("GA4_SERVICE_ACCOUNT_JSON")
|
||||
if not key_path or not os.path.exists(key_path): sys.exit("GA4_SERVICE_ACCOUNT_JSON(서비스 계정 키 파일 경로) 필요 (.env)")
|
||||
creds = service_account.Credentials.from_service_account_file(key_path, scopes=["https://www.googleapis.com/auth/analytics.readonly"])
|
||||
sess = AuthorizedSession(creds)
|
||||
body = {"dateRanges": [{"startDate": f"{days}daysAgo", "endDate": "yesterday"}],
|
||||
"dimensions": [{"name": "date"}, {"name": "sessionSource"}, {"name": "sessionMedium"}, {"name": "landingPagePlusQueryString"}],
|
||||
"metrics": [{"name": "sessions"}, {"name": "keyEvents"}, {"name": "engagedSessions"}],
|
||||
"limit": 100000}
|
||||
r = sess.post(f"https://analyticsdata.googleapis.com/v1beta/properties/{property_id}:runReport", json=body, timeout=60)
|
||||
if r.status_code != 200: sys.exit(f"GA4 Data API {r.status_code}: {r.text[:300]}")
|
||||
j = r.json()
|
||||
for row in j.get("rows", []):
|
||||
d = [x["value"] for x in row["dimensionValues"]]; m = [x["value"] for x in row["metricValues"]]
|
||||
yield {"date": f"{d[0][:4]}-{d[0][4:6]}-{d[0][6:]}", "source": d[1], "medium": d[2], "landing": d[3].split("?")[0], "utm_source": _utm(d[3]),
|
||||
"sessions": int(float(m[0])), "key_events": int(float(m[1])), "engaged": int(float(m[2]))}
|
||||
|
||||
def _utm(landing_with_qs: str) -> str:
|
||||
if "utm_source=" not in landing_with_qs: return ""
|
||||
return landing_with_qs.split("utm_source=")[1].split("&")[0]
|
||||
|
||||
def rows_from_csv(path: str):
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
for r in csv.DictReader(f):
|
||||
g = lambda *ks: next((r[k] for k in ks if k in r and r[k] not in (None, "")), "")
|
||||
yield {"date": g("date", "날짜"), "source": g("sessionSource", "세션 소스", "source"), "medium": g("sessionMedium", "세션 매체", "medium"),
|
||||
"landing": g("landingPage", "landingPagePlusQueryString", "방문 페이지", "landing").split("?")[0], "utm_source": _utm(g("landingPagePlusQueryString", "landing")),
|
||||
"sessions": int(float(g("sessions", "세션수", "세션") or 0)), "key_events": int(float(g("keyEvents", "주요 이벤트", "conversions") or 0)), "engaged": int(float(g("engagedSessions", "참여 세션수") or 0))}
|
||||
|
||||
def rows_mock(days: int = 60):
|
||||
random.seed(7); today = dt.date.today()
|
||||
srcs = [("google", "organic", 40), ("(direct)", "(none)", 18), ("chatgpt.com", "referral", 6), ("perplexity.ai", "referral", 2), ("gemini.google.com", "referral", 1), ("search.naver.com", "organic", 25), ("instagram.com", "referral", 4)]
|
||||
pages = ["/", "/posts/consult-questions", "/posts/procedure-eye", "/visit", "/videos", "/newsroom"]
|
||||
for i in range(days):
|
||||
d = (today - dt.timedelta(days=days - i)).isoformat()
|
||||
for s, m, w in srcs:
|
||||
for p in random.sample(pages, 3):
|
||||
n = max(0, int(random.gauss(w / 3, w / 6)))
|
||||
if n: yield {"date": d, "source": s, "medium": m, "landing": p, "utm_source": "chatgpt.com" if s == "chatgpt.com" else "", "sessions": n, "key_events": sum(1 for _ in range(n) if random.random() < 0.06), "engaged": int(n * 0.6)}
|
||||
|
||||
# ---------- 집계 ----------
|
||||
def aggregate(rows):
|
||||
daily = collections.defaultdict(lambda: collections.Counter()); by_ch = collections.Counter(); ke_ch = collections.Counter()
|
||||
landing = collections.defaultdict(lambda: collections.Counter()); total = collections.Counter(); direct = 0; weeks = collections.defaultdict(lambda: collections.Counter())
|
||||
for r in rows:
|
||||
total["sessions"] += r["sessions"]; total["key_events"] += r["key_events"]
|
||||
ch = classify(r["source"], r["medium"], r.get("utm_source", ""))
|
||||
if r["source"] in ("(direct)", "direct"): direct += r["sessions"]
|
||||
if not ch: continue
|
||||
daily[r["date"]][ch] += r["sessions"]; by_ch[ch] += r["sessions"]; ke_ch[ch] += r["key_events"]
|
||||
landing[r["landing"]][ch] += r["sessions"]; landing[r["landing"]]["_ke"] += r["key_events"]
|
||||
y, w, _ = dt.date.fromisoformat(r["date"]).isocalendar(); weeks[f"{y}-W{w:02d}"][ch] += r["sessions"]
|
||||
ai_total = sum(by_ch.values())
|
||||
return {"total_sessions": total["sessions"], "total_key_events": total["key_events"], "direct_sessions": direct,
|
||||
"ai_sessions": ai_total, "ai_share_pct": round(ai_total / total["sessions"] * 100, 2) if total["sessions"] else 0,
|
||||
"by_channel": [{"id": c, "label": LABEL[c], "measurable": MEASURABLE[c], "sessions": n, "key_events": ke_ch[c], "cvr_pct": round(ke_ch[c] / n * 100, 1) if n else 0} for c, n in by_ch.most_common()],
|
||||
"weekly": {w: dict(v) for w, v in sorted(weeks.items())}, "daily": {d: dict(v) for d, v in sorted(daily.items())},
|
||||
"landing": sorted([{"page": p, "ai_sessions": sum(n for k, n in v.items() if k != "_ke"), "key_events": v["_ke"], "by": {k: n for k, n in v.items() if k != "_ke"}} for p, v in landing.items()], key=lambda x: -x["ai_sessions"])[:20]}
|
||||
|
||||
# ---------- 리포트 ----------
|
||||
def scope_table_md():
|
||||
lines = ["| 채널 | GA4로 측정 | 비고 |", "|---|---|---|"]
|
||||
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
|
||||
for c in CHANNELS["channels"]: lines.append(f"| {c['label']} | **{M[c['measurable']]}** | {c['note']} |")
|
||||
lines += ["", "GA4로 잴 수 없는 것:", ""] + [f"- {x}" for x in CHANNELS["not_measurable_by_ga4"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
def report_md(agg, clinic, source_desc, days):
|
||||
L = [f"# AI 검색 유입 리포트 · {clinic}", "", f"기간 최근 {days}일 · 데이터 {source_desc} · 분류 기준 `data/ai_channels.json` v{CHANNELS['version']} · 작성 {dt.date.today()}", "",
|
||||
"## 1. 요약", "", "| 지표 | 값 |", "|---|---|",
|
||||
f"| 전체 세션 | {agg['total_sessions']:,} |", f"| AI 검색 유입 세션(측정 가능분) | {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) |",
|
||||
f"| AI 유입의 키 이벤트(전화 탭·예약·홈페이지 이동) | {sum(c['key_events'] for c in agg['by_channel']):,} |", f"| direct 세션(리퍼러 없음, AI 유입 일부가 여기 섞임) | {agg['direct_sessions']:,} |", "",
|
||||
"## 2. 채널별", "", "| 채널 | 세션 | 키 이벤트 | 전환율 | 측정 |", "|---|---|---|---|---|"]
|
||||
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
|
||||
for c in agg["by_channel"]: L.append(f"| {c['label']} | {c['sessions']:,} | {c['key_events']} | {c['cvr_pct']}% | {M[c['measurable']]} |")
|
||||
for c in CHANNELS["channels"]:
|
||||
if c["measurable"] == "no": L.append(f"| {c['label']} | 측정 불가 | | | 불가 |")
|
||||
L += ["", "## 3. AI 유입 랜딩 페이지 (상위)", "", "| 페이지 | AI 세션 | 키 이벤트 | 채널 |", "|---|---|---|---|"]
|
||||
for p in agg["landing"][:12]: L.append(f"| {p['page']} | {p['ai_sessions']} | {p['key_events']} | {', '.join(f'{LABEL[k]} {n}' for k, n in sorted(p['by'].items(), key=lambda x: -x[1]))} |")
|
||||
L += ["", "## 4. 주별 추이", "", "| 주 | " + " | ".join(LABEL[c["id"]] for c in agg["by_channel"]) + " | 합 |", "|---|" + "---|" * (len(agg["by_channel"]) + 1)]
|
||||
for w, v in agg["weekly"].items(): L.append(f"| {w} | " + " | ".join(str(v.get(c["id"], 0)) for c in agg["by_channel"]) + f" | {sum(v.values())} |")
|
||||
L += ["", "## 5. 측정 가능 범위", "", "이 리포트의 숫자는 GA4가 리퍼러나 utm으로 구분할 수 있는 유입만 셉니다. 아래 표의 '일부만'과 '불가'는 실제보다 적게 잡히거나 전혀 잡히지 않는 채널입니다. AI 답변에 병원이 얼마나 나오는지(노출)는 이 리포트가 아니라 답변엔진 실측(질문 뱅크)으로 잽니다. 두 숫자를 같은 시간축에 놓고 읽어야 합니다.", "", scope_table_md(), "",
|
||||
"## 6. 연동 방식", "", "GA4 속성 액세스 관리에서 INFINITH 서비스 계정 이메일을 '뷰어'로 추가하면 매일 자동으로 갱신됩니다. 코드 설치는 없습니다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고, 서포터즈 사이트는 INFINITH가 직접 계측합니다."]
|
||||
return "\n".join(L) + "\n"
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--property"); ap.add_argument("--days", type=int, default=90); ap.add_argument("--from-csv"); ap.add_argument("--mock", action="store_true")
|
||||
ap.add_argument("--clinic", required=True); ap.add_argument("--out", required=True)
|
||||
a = ap.parse_args()
|
||||
if a.mock: rows, desc = list(rows_mock(a.days if a.days < 90 else 60)), "합성 데이터(파이프라인 검증용)"
|
||||
elif a.from_csv: rows, desc = list(rows_from_csv(a.from_csv)), f"GA4 내보내기 {os.path.basename(a.from_csv)}"
|
||||
elif a.property: rows, desc = list(rows_from_api(a.property, a.days)), f"GA4 Data API 속성 {a.property}"
|
||||
else: sys.exit("--property 또는 --from-csv 또는 --mock 중 하나")
|
||||
agg = aggregate(rows)
|
||||
os.makedirs(a.out, exist_ok=True)
|
||||
json.dump({"clinic": a.clinic, "source": desc, "days": a.days, "generatedAt": dt.date.today().isoformat(), "channels_version": CHANNELS["version"], **agg}, open(os.path.join(a.out, "ai_traffic.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
open(os.path.join(a.out, "AI_Traffic_Report.md"), "w", encoding="utf-8").write(report_md(agg, a.clinic, desc, a.days))
|
||||
print(f"세션 {agg['total_sessions']:,} · AI 유입 {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) · 채널 {', '.join(f'{c['label']} {c['sessions']}' for c in agg['by_channel'])} → {a.out}")
|
||||
@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""링크 공유 미리보기 이미지(og:image 1200x630 JPEG)와 파비콘 세트를 만든다.
|
||||
|
||||
카카오톡·슬랙·iMessage 에 주소를 붙였을 때 회색 링크 아이콘 대신 이 이미지와 아이콘이 나오게 한다.
|
||||
|
||||
# AI Discovery 랜딩(webforai.kr). 결과: public/og-discovery.jpg, public/discovery-favicon-*.png 외
|
||||
# public/ 은 infinith-demo 배포와 공용이라 파일명에 접두어를 붙이고, 링크 태그는 VITE_SITE=discovery 빌드에만 넣는다.
|
||||
python3 scripts/make_share_assets.py discovery --out public
|
||||
|
||||
# 서포터즈 사이트. 워커 build 단계가 부른다. 결과: <site>/public/og.jpg, favicon.ico 외
|
||||
python3 scripts/make_share_assets.py supporters --site ~/supporters-builds/oracle/site
|
||||
|
||||
서포터즈 입력: src/data/site.json(shareNameEn·buildingImage·heroImage), src/data/factSheet.json(shortName),
|
||||
vercel.json build.env.SITE_URL(하단 주소 표기). 사진은 heroImage, 없으면 buildingImage. 사진이 없으면 사진 칸 없이 글자 판만 만든다.
|
||||
서포터즈 이미지는 해외 환자용이라 영어로 낸다. 파비콘은 병원 로고 대신 영문 상호 첫 글자를 쓴다. 가로형 로고는 16px 에서 읽히지 않는다.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
W, H = 1200, 630
|
||||
INK = (10, 17, 40) # #0A1128
|
||||
INDIGO = (29, 0, 36) # #1D0024
|
||||
VIOLET = (79, 29, 161) # #4F1DA1
|
||||
NAVY = (2, 19, 65) # #021341
|
||||
LIGHT = (247, 248, 252) # #F7F8FC
|
||||
SLATE = (71, 85, 105) # #475569
|
||||
LAVENDER = (233, 213, 255) # #E9D5FF, 어두운 배경 전용
|
||||
|
||||
FONT_DIRS = [Path.home() / "Library/Fonts", Path("/Library/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")]
|
||||
|
||||
|
||||
def font(weight: str, size: int) -> ImageFont.FreeTypeFont:
|
||||
for d in FONT_DIRS:
|
||||
for name in (f"Pretendard-{weight}.otf", f"Pretendard-{weight}.ttf"):
|
||||
for p in [d / name, *d.rglob(name)] if d.exists() else []:
|
||||
if p.exists():
|
||||
return ImageFont.truetype(str(p), size)
|
||||
for fallback in ("/System/Library/Fonts/AppleSDGothicNeo.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc"):
|
||||
if Path(fallback).exists():
|
||||
return ImageFont.truetype(fallback, size)
|
||||
sys.exit("한글 글꼴을 찾지 못했습니다. Pretendard 를 설치하세요.")
|
||||
|
||||
|
||||
def gradient(size, start, end, diagonal=True):
|
||||
w, h = size
|
||||
base = Image.new("RGB", size, start)
|
||||
top = Image.new("RGB", size, end)
|
||||
mask = Image.new("L", size)
|
||||
md = mask.load()
|
||||
for y in range(h):
|
||||
for x in range(0, w, 1 if w <= 64 else 2):
|
||||
t = ((x / max(w - 1, 1)) + (y / max(h - 1, 1))) / 2 if diagonal else y / max(h - 1, 1)
|
||||
v = int(255 * t)
|
||||
md[x, y] = v
|
||||
if w > 64 and x + 1 < w:
|
||||
md[x + 1, y] = v
|
||||
return Image.composite(top, base, mask)
|
||||
|
||||
|
||||
def wrap(draw, text, fnt, max_w):
|
||||
"""한국어 어절 단위 줄바꿈(keep-all). 줄바꿈 문자는 그대로 존중한다."""
|
||||
lines = []
|
||||
for para in text.split("\n"):
|
||||
cur = ""
|
||||
for word in para.split(" "):
|
||||
trial = f"{cur} {word}".strip()
|
||||
if draw.textlength(trial, font=fnt) <= max_w or not cur:
|
||||
cur = trial
|
||||
else:
|
||||
lines.append(cur)
|
||||
cur = word
|
||||
lines.append(cur)
|
||||
return lines
|
||||
|
||||
|
||||
def save_favicons(out: Path, label: str, prefix: str = ""):
|
||||
"""상호 첫 글자(또는 AI) 모노그램. 둥근 정사각 그라디언트 위 흰 글자."""
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def icon(px, radius_ratio=0.22, full_bleed=False):
|
||||
s = px * 4
|
||||
bg = gradient((s, s), VIOLET, NAVY)
|
||||
mask = Image.new("L", (s, s), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, s - 1, s - 1], radius=0 if full_bleed else int(s * radius_ratio), fill=255)
|
||||
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
||||
img.paste(bg, (0, 0), mask)
|
||||
d = ImageDraw.Draw(img)
|
||||
size = int(s * (0.62 if len(label) == 1 else 0.46))
|
||||
f = font("Bold", size)
|
||||
box = d.textbbox((0, 0), label, font=f)
|
||||
tw, th = box[2] - box[0], box[3] - box[1]
|
||||
d.text(((s - tw) / 2 - box[0], (s - th) / 2 - box[1]), label, font=f, fill="white")
|
||||
return img.resize((px, px), Image.LANCZOS)
|
||||
|
||||
written = []
|
||||
for name, px, bleed in [("favicon-32.png", 32, False), ("favicon-192.png", 192, False), ("favicon-512.png", 512, False), ("apple-touch-icon.png", 180, True)]:
|
||||
icon(px, full_bleed=bleed).save(out / f"{prefix}{name}")
|
||||
written.append(f"{prefix}{name}")
|
||||
icon(256).save(out / f"{prefix}favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||
written.append(f"{prefix}favicon.ico")
|
||||
return written
|
||||
|
||||
|
||||
def discovery(out: Path):
|
||||
img = Image.new("RGB", (W, H), LIGHT)
|
||||
d = ImageDraw.Draw(img)
|
||||
# 왼쪽 세로 띠
|
||||
img.paste(gradient((16, H), VIOLET, NAVY, diagonal=False), (0, 0))
|
||||
x = 96
|
||||
d.text((x, 92), "INFINITH AI Discovery", font=font("SemiBold", 30), fill=VIOLET)
|
||||
title = font("ExtraBold", 84)
|
||||
d.text((x, 170), "AI가 추천하는 병원은", font=title, fill=INDIGO)
|
||||
d.text((x, 272), "따로 있습니다.", font=title, fill=VIOLET)
|
||||
sub = font("Medium", 34)
|
||||
d.text((x, 410), "고객은 이제 AI에게 병원을 묻습니다.", font=sub, fill=SLATE)
|
||||
d.text((x, 458), "답변에 인용될 근거를 실측해 진단하고, 8주 안에 구축합니다.", font=sub, fill=SLATE)
|
||||
d.line([(x, 540), (W - 96, 540)], fill=(226, 232, 240), width=2)
|
||||
d.text((x, 556), "webforai.kr", font=font("SemiBold", 28), fill=INDIGO)
|
||||
img.save(out / "og-discovery.jpg", quality=88, optimize=True, progressive=True)
|
||||
return ["og-discovery.jpg", *save_favicons(out, "AI", prefix="discovery-")]
|
||||
|
||||
|
||||
def playfair(size: int) -> ImageFont.FreeTypeFont:
|
||||
for d in FONT_DIRS:
|
||||
p = d / "PlayfairDisplay-Bold.ttf"
|
||||
if p.exists():
|
||||
return ImageFont.truetype(str(p), size)
|
||||
return font("ExtraBold", size)
|
||||
|
||||
|
||||
def supporters(site: Path):
|
||||
"""해외 환자가 받는 링크라 영어로 낸다. 병원 영문 표기는 site.json shareNameEn(병원 공식 영문 표기, briefs 에서 전달)만 쓴다.
|
||||
값이 없으면 한국어 상호를 그대로 쓴다. 영문 이름을 지어내지 않는다."""
|
||||
data = site / "src/data"
|
||||
s = json.loads((data / "site.json").read_text())
|
||||
fact = json.loads((data / "factSheet.json").read_text())
|
||||
short_ko = (fact.get("shortName") or "").strip() or (s.get("siteName") or "").replace(" 서포터즈", "").strip()
|
||||
name = (s.get("shareNameEn") or "").strip() or short_ko
|
||||
if not name:
|
||||
sys.exit("site.json shareNameEn 과 factSheet.json shortName 이 모두 비어 있습니다.")
|
||||
url = ""
|
||||
vj = site / "vercel.json"
|
||||
if vj.exists():
|
||||
url = json.loads(vj.read_text()).get("build", {}).get("env", {}).get("SITE_URL", "")
|
||||
host = url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
|
||||
photo = None
|
||||
for key in ("heroImage", "buildingImage"):
|
||||
src = (s.get(key) or {}).get("src") if isinstance(s.get(key), dict) else None
|
||||
if src and (site / "public" / src.lstrip("/")).exists():
|
||||
photo = site / "public" / src.lstrip("/")
|
||||
break
|
||||
|
||||
img = Image.new("RGB", (W, H), INK)
|
||||
panel_w = 700 if photo else W
|
||||
img.paste(gradient((panel_w, H), VIOLET, NAVY), (0, 0))
|
||||
if photo:
|
||||
ph = ImageOps.fit(Image.open(photo).convert("RGB"), (W - panel_w, H), Image.LANCZOS, centering=(0.5, 0.4))
|
||||
img.paste(ph, (panel_w, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
x, max_w = 72, panel_w - 72 * 2
|
||||
d.text((x, 78), "SUPPORTERS · FOR INTERNATIONAL PATIENTS", font=font("SemiBold", 24), fill=LAVENDER)
|
||||
name_f = playfair(72)
|
||||
while max(d.textlength(w, font=name_f) for w in name.split(" ")) > max_w and name_f.size > 44:
|
||||
name_f = playfair(name_f.size - 4)
|
||||
y = 150
|
||||
for line in wrap(d, name, name_f, max_w):
|
||||
d.text((x, y), line, font=name_f, fill="white")
|
||||
y += int(name_f.size * 1.18)
|
||||
y += 30
|
||||
title_f = font("SemiBold", 42)
|
||||
for line in wrap(d, "Answers to the questions\npatients ask first.", title_f, max_w):
|
||||
d.text((x, y), line, font=title_f, fill="white")
|
||||
y += int(title_f.size * 1.32)
|
||||
d.text((x, H - 96), "Recovery planner · Getting here · Clinic facts", font=font("Medium", 26), fill=LAVENDER)
|
||||
if host:
|
||||
d.text((x, H - 58), host, font=font("SemiBold", 24), fill="white")
|
||||
pub = site / "public"
|
||||
# JPEG 로 낸다. WhatsApp 등 일부 미리보기는 300KB 를 넘는 이미지를 버린다.
|
||||
img.save(pub / "og.jpg", quality=86, optimize=True, progressive=True)
|
||||
if (pub / "og.png").exists():
|
||||
(pub / "og.png").unlink()
|
||||
# 템플릿 기본(Astro) 파비콘 제거. 로고 기반 favicon.svg 가 있어도 16px 에서 읽히지 않아 모노그램으로 통일한다.
|
||||
for old in ("favicon.svg",):
|
||||
if (pub / old).exists():
|
||||
(pub / old).unlink()
|
||||
return ["og.jpg", *save_favicons(pub, name[0].upper())]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="mode", required=True)
|
||||
a = sub.add_parser("discovery"); a.add_argument("--out", default="public")
|
||||
b = sub.add_parser("supporters"); b.add_argument("--site", required=True)
|
||||
args = ap.parse_args()
|
||||
if args.mode == "discovery":
|
||||
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
|
||||
files = discovery(out)
|
||||
else:
|
||||
files = supporters(Path(args.site).expanduser())
|
||||
print("공유 이미지·파비콘: " + ", ".join(files))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenAI·Perplexity·네이버 AI 브리핑 실측 JSONL을 합쳐 질문별 통합 비교표 MD를 생성한다.
|
||||
네이버 결과 파일이 없으면 두 엔진만으로 만든다."""
|
||||
"""OpenAI·Perplexity 실측 JSONL 2개를 합쳐 질문별 통합 비교표 MD를 생성한다."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@ -43,19 +42,9 @@ def load(engine):
|
||||
return rows
|
||||
|
||||
|
||||
def measured_on(data):
|
||||
"""엔진 결과의 실제 측정일(ts 최소~최대). 생성일로 찍으면 과거 측정이 오늘 측정처럼 보인다."""
|
||||
days = sorted({(r.get("ts") or r.get("measured_at") or "")[:10] for r in data.values()} - {""})
|
||||
if not days:
|
||||
return "미상"
|
||||
return days[0] if days[0] == days[-1] else f"{days[0]}~{days[-1]}"
|
||||
|
||||
|
||||
def cell(r):
|
||||
if not r:
|
||||
return "미실측", "-"
|
||||
if r.get("engine") == "naver_briefing" and not r.get("briefing"):
|
||||
return "브리핑 없음", "-"
|
||||
clinics = [c for c in r.get("clinics", []) if isinstance(c, str)][:5]
|
||||
rank = r.get("view_rank")
|
||||
mark = f"O ({rank}위)" if r.get("view_mentioned") else "X"
|
||||
@ -64,19 +53,16 @@ def cell(r):
|
||||
|
||||
def main():
|
||||
qs = json.load(open(QUESTIONS))
|
||||
oa, px, nv = load("openai"), load("perplexity"), load("naver_briefing")
|
||||
engines = [("ChatGPT(OpenAI)", oa), ("Perplexity", px)] + ([("네이버 AI 브리핑", nv)] if nv else [])
|
||||
oa, px = load("openai"), load("perplexity")
|
||||
|
||||
lines = ["# 뷰성형외과 Question Bank 실측 결과: " + " x ".join(e for e, _ in engines) + " 통합", "",
|
||||
"측정일: " + " · ".join(f"{e} {measured_on(d)}" for e, d in engines),
|
||||
"측정 방식: OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
|
||||
+ (" / 네이버 AI 브리핑(SerpApi naver_ai_overview, 제3자 SERP API, 모바일)" if nv else "") + ". "
|
||||
"답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정했다."
|
||||
+ (" 네이버는 공식 API가 없어 제3자 API를 썼으며, 고객 리포트 사용 전 법무 확인이 필요하다." if nv else ""), ""]
|
||||
lines = ["# 뷰성형외과 Question Bank 실측 결과: ChatGPT(OpenAI) x Perplexity 통합", "",
|
||||
f"측정일: {time.strftime('%Y-%m-%d')}",
|
||||
"측정 방식: OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar). "
|
||||
"답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정했다.", ""]
|
||||
|
||||
# 집계
|
||||
stats = {}
|
||||
for eng, data in engines:
|
||||
for eng, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)):
|
||||
b = [q for q in qs if is_brand(q) and q["id"] in data]
|
||||
nb = [q for q in qs if not is_brand(q) and q["id"] in data]
|
||||
bm = sum(1 for q in b if data[q["id"]].get("view_mentioned"))
|
||||
@ -87,15 +73,12 @@ def main():
|
||||
"| 엔진 | 브랜드 질문 뷰 언급 | 논브랜드 질문 뷰 언급 |", "|---|---|---|"]
|
||||
for eng, (b, bm, nb, nbm) in stats.items():
|
||||
lines.append(f"| {eng} | {bm}/{b} ({bm*100//max(b,1)}%) | {nbm}/{nb} ({nbm*100//max(nb,1)}%) |")
|
||||
if nv:
|
||||
shown = [r for r in nv.values() if r.get("briefing")]
|
||||
lines += ["", f"네이버 AI 브리핑 노출: {len(shown)}/{len(nv)}문항. 브리핑이 뜨지 않은 문항은 언급 없음으로 셌다."]
|
||||
lines += ["",
|
||||
"브랜드 질문은 질문 문장에 '뷰성형외과'가 들어간 문항이다. "
|
||||
"승부처인 논브랜드 질문(추천·비교·시술 정보)에서의 언급률이 AI Discovery의 실제 성적이다.", ""]
|
||||
|
||||
# 병원별 빈도 (논브랜드 질문 한정)
|
||||
for eng, data in engines:
|
||||
for eng, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)):
|
||||
freq, label = {}, {}
|
||||
for q in qs:
|
||||
if is_brand(q) or q["id"] not in data:
|
||||
@ -120,24 +103,20 @@ def main():
|
||||
|
||||
# 문항별 표 (카테고리 섹션별)
|
||||
lines += ["## 문항별 결과", "",
|
||||
"GPT/PPLX" + ("/NAVER" if nv else "") + " 컬럼: O(순위) = 뷰성형외과 언급과 답변 내 등장 순번, X = 미언급"
|
||||
+ (", 브리핑 없음 = 네이버가 AI 브리핑을 띄우지 않음" if nv else "") + ".", ""]
|
||||
"GPT/PPLX 컬럼: O(순위) = 뷰성형외과 언급과 답변 내 등장 순번, X = 미언급.", ""]
|
||||
cur = None
|
||||
for q in qs:
|
||||
cat = q["id"].split("-")[0]
|
||||
if cat != cur:
|
||||
cur = cat
|
||||
head = "| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |" + (" NAVER | NAVER 상위 병원 |" if nv else "")
|
||||
lines += [f"### {CAT_LABEL.get(cat, cat)}", "", head, "|" + "---|" * (head.count("|") - 1)]
|
||||
lines += [f"### {CAT_LABEL.get(cat, cat)}", "",
|
||||
"| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |",
|
||||
"|---|---|---|---|---|---|---|"]
|
||||
om, oc = cell(oa.get(q["id"]))
|
||||
pm, pc = cell(px.get(q["id"]))
|
||||
row = f"| {q['id']} | {q['question']} | {q['prior']} | {om} | {oc} | {pm} | {pc} |"
|
||||
if nv:
|
||||
nm, nc = cell(nv.get(q["id"]))
|
||||
row += f" {nm} | {nc} |"
|
||||
lines.append(row)
|
||||
lines.append(f"| {q['id']} | {q['question']} | {q['prior']} | {om} | {oc} | {pm} | {pc} |")
|
||||
open(OUT_MD, "w").write("\n".join(lines) + "\n")
|
||||
print(f"저장: {OUT_MD} (openai {len(oa)} / perplexity {len(px)} / naver_briefing {len(nv)}문항)")
|
||||
print(f"저장: {OUT_MD} (openai {len(oa)} / perplexity {len(px)}문항)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -17,9 +17,7 @@ import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error,
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(ROOT, "scripts", "out")
|
||||
ALL_ENGINES = ("openai", "perplexity", "naver_briefing")
|
||||
# naver_briefing 은 SerpApi(제3자 SERP API)로 네이버 AI 브리핑을 조회한다(haewon 결정 2026-09-15).
|
||||
# SERPAPI_API_KEY 가 없으면 건너뛴다. 결과를 고객 리포트에 쓰기 전 법무 확인이 필요하다(run_question_bank_openai.py 머리말).
|
||||
ENGINES = ("openai", "perplexity")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("sqb", os.path.join(ROOT, "scripts", "sentiment_qb.py"))
|
||||
sqb = importlib.util.module_from_spec(spec); spec.loader.exec_module(sqb)
|
||||
@ -31,30 +29,8 @@ def sh(argv):
|
||||
if r.returncode != 0: sys.exit(f"실패: {' '.join(argv)}")
|
||||
|
||||
|
||||
def naver_briefing_summary(path):
|
||||
"""네이버 AI 브리핑 노출률·브리핑 내 언급률·인용 출처 상위. 브리핑이 없는 질문과 언급 없음을 나눠 센다."""
|
||||
rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]
|
||||
shown = [r for r in rows if r.get("briefing")]
|
||||
src = {}
|
||||
for r in shown:
|
||||
for s in {(x.get("source") or x.get("link") or "").strip() for x in r.get("sources") or []}:
|
||||
if s: src[s] = src.get(s, 0) + 1
|
||||
brand = lambda r: "뷰성형외과" in r.get("question", "")
|
||||
nb_shown = [r for r in shown if not brand(r)]
|
||||
return {
|
||||
"source": "SerpApi naver_ai_overview", "device": rows[0].get("device") if rows else None,
|
||||
"measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"questions": len(rows), "briefingShown": len(shown),
|
||||
"mentionedInBriefing": sum(1 for r in shown if r.get("view_mentioned")),
|
||||
"nonBrandBriefingShown": len(nb_shown),
|
||||
"nonBrandMentioned": sum(1 for r in nb_shown if r.get("view_mentioned")),
|
||||
"topSources": [{"source": k, "answers": v} for k, v in sorted(src.items(), key=lambda x: -x[1])[:10]],
|
||||
"legalReviewed": False,
|
||||
}
|
||||
|
||||
|
||||
def patch_supabase(build_id, sentiment, extra=None):
|
||||
"""supporter_builds.report 에 sentiment(와 extra 키)만 병합한다. 다른 단계가 쓴 키를 지우지 않는다."""
|
||||
def patch_supabase(build_id, sentiment):
|
||||
"""supporter_builds.report 에 sentiment 키만 병합한다. 다른 단계가 쓴 키를 지우지 않는다."""
|
||||
sqb.load_env()
|
||||
url = os.environ.get("VITE_SUPABASE_URL"); key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
|
||||
if not (url and key): sys.exit("VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 필요 (.env)")
|
||||
@ -66,12 +42,10 @@ def patch_supabase(build_id, sentiment, extra=None):
|
||||
if not rows: sys.exit(f"supporter_builds 에 {build_id} 없음")
|
||||
report = rows[0].get("report") or {}
|
||||
report["sentiment"] = sentiment
|
||||
for k, v in (extra or {}).items():
|
||||
report[k] = v
|
||||
body = json.dumps({"report": report, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S")}).encode()
|
||||
req = urllib.request.Request(ep, data=body, headers={**H, "prefer": "return=minimal"}, method="PATCH")
|
||||
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
|
||||
print(f" supporter_builds.report 갱신: sentiment{''.join(' · ' + k for k in (extra or {}))} (HTTP {r.status})")
|
||||
print(f" supporter_builds.report.sentiment 갱신 (HTTP {r.status})")
|
||||
|
||||
|
||||
def main():
|
||||
@ -82,13 +56,7 @@ def main():
|
||||
ap.add_argument("--build-id", help="주면 supporter_builds.report.sentiment 에 반영한다")
|
||||
ap.add_argument("--skip-run", action="store_true", help="질문 뱅크 러너를 건너뛰고 기존 답변으로 감성만 다시 낸다")
|
||||
ap.add_argument("--rejudge", action="store_true", help="기존 감성 판정을 지우고 다시 판정한다(기준표를 고친 뒤)")
|
||||
ap.add_argument("--engines", default=",".join(ALL_ENGINES), help="쉼표 구분. 기본: openai,perplexity,naver_briefing")
|
||||
a = ap.parse_args()
|
||||
sqb.load_env()
|
||||
ENGINES = [e for e in a.engines.split(",") if e in ALL_ENGINES]
|
||||
if "naver_briefing" in ENGINES and not os.environ.get("SERPAPI_API_KEY"):
|
||||
print(" SERPAPI_API_KEY 없음. naver_briefing 은 건너뛴다.")
|
||||
ENGINES.remove("naver_briefing")
|
||||
|
||||
if not a.skip_run:
|
||||
print("[1/3] 질문 뱅크 실측")
|
||||
@ -133,14 +101,7 @@ def main():
|
||||
print(f" 요약 → {p}")
|
||||
print(f" 언급 {s['mentioned']}/{s['answers']} · 감성 점수 {s['score']} · 약한 언급 제외 {sentiment['scoreStrongOnly']}")
|
||||
|
||||
extra = {}
|
||||
nb_path = os.path.join(OUT, "qb_naver_briefing_results.jsonl")
|
||||
if "naver_briefing" in ENGINES and os.path.exists(nb_path):
|
||||
extra["naverBriefing"] = nb = naver_briefing_summary(nb_path)
|
||||
open(os.path.join(OUT, "naver_briefing_summary.json"), "w", encoding="utf-8").write(json.dumps(nb, ensure_ascii=False, indent=2))
|
||||
print(f" 네이버 AI 브리핑 노출 {nb['briefingShown']}/{nb['questions']} · 브리핑 내 뷰 언급 {nb['mentionedInBriefing']} · 논브랜드 {nb['nonBrandMentioned']}/{nb['nonBrandBriefingShown']}")
|
||||
|
||||
if a.build_id: patch_supabase(a.build_id, sentiment, extra)
|
||||
if a.build_id: patch_supabase(a.build_id, sentiment)
|
||||
else: print(" --build-id 없음. Supabase 반영은 건너뛴다.")
|
||||
|
||||
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
{"id": "A-01", "engine": "naver_briefing", "question": "뷰성형외과 어때? 믿을만한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "access", "reviews", "price"], "evidence": "뷰성형외과는 종합적인 미용 성형 솔루션을 제공하는 대형 성형외과로, 다양한 분야에서 진료를 받고 있으며 전반적으로 긍정적인 평가를 받고 있습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:27"}
|
||||
{"id": "A-02", "engine": "naver_briefing", "question": "뷰성형외과는 어떤 병원이야? 소개해줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "access", "procedure"], "evidence": "전문성과 체계적인 시스템을 갖추고 있으며, 의료진이 개인의 특성을 정확히 분석하여 현실적인 방향을 제시하는 것으로 평가받고 있습니다.[2] [3]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:29"}
|
||||
{"id": "A-03", "engine": "naver_briefing", "question": "뷰성형외과는 무슨 수술을 주로 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "doctors", "safety", "access", "reviews"], "evidence": "뷰성형외과는 2005년에 개원했으며, 첨단 시스템을 도입한 안전 중심의 스마트 빌딩을 갖추고 있습니다.[1]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:30"}
|
||||
{"id": "A-04", "engine": "naver_briefing", "question": "뷰성형외과 최순우 원장 어떤 의사야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure"], "evidence": "원장은 환자의 안전을 최우선으로 하며, 무리한 성장이 아닌 진료의 본질을 지키며 지속 가능한 성장을 추구한다고 언급했습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:32"}
|
||||
{"id": "A-05", "engine": "naver_briefing", "question": "뷰성형외과 의료진 몇 명이야? 다 전문의야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["doctors", "procedure", "safety"], "evidence": "뷰성형외과의원에는 다수의 의료진이 있으며, 일부 의료진은 전문의 자격을 갖추고 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:34"}
|
||||
{"id": "A-06", "engine": "naver_briefing", "question": "뷰성형외과가 렛미인 나온 병원 맞아?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "reviews", "access"], "evidence": "이 병원은 19층 규모의 자체 빌딩을 운영하며 대학병원급 안전 시스템과 자체 검진 센터를 갖추고 있습니다.[5]", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-15T15:22:36"}
|
||||
{"id": "A-07", "engine": "naver_briefing", "question": "뷰성형외과 언제 생긴 병원이야? 오래됐어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "safety", "access"], "evidence": "이 병원은 첨단 시스템을 갖춘 안전 중심 스마트빌딩에서 진료를 제공하며, 가족을 진료하는 마음으로 수준 높은 서비스를 제공하고자 노력하고 있습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:37"}
|
||||
{"id": "A-08", "engine": "naver_briefing", "question": "뷰성형외과 건물이 통째로 병원이라던데 맞아?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["access", "safety", "procedure"], "evidence": "해당 병원은 첨단 시스템을 도입한 안전중심 스마트빌딩과 대학병원급 안전 장비 및 시설을 갖추고 있다고 소개하고 있습니다.[2]", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-15T15:22:39"}
|
||||
{"id": "A-09", "engine": "naver_briefing", "question": "뷰성형외과 강남언니 평점 몇 점이야?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews", "doctors", "procedure", "access"], "evidence": "뷰성형외과의원 강남언니 평점은 9.6점이며, 총 19,277개의 후기가 있습니다.[2]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:41"}
|
||||
{"id": "A-10", "engine": "naver_briefing", "question": "뷰성형외과 유튜브 채널 있어?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["media"], "evidence": "뷰성형외과는 '뷰TV'라는 유튜브 채널을 운영하고 있으며, 이 채널에서는 가슴성형, 눈성형, 코성형 등 다양한 성형 관련 영상 콘텐츠를 제공하고 있습니다.[2]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:43"}
|
||||
{"id": "A-11", "engine": "naver_briefing", "question": "뷰성형외과랑 다른 강남 대형 성형외과랑 뭐가 달라?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "access"], "evidence": "해당 병원은 1:1 진료 시스템을 통해 개인의 얼굴형과 피부 상태에 맞춰 수술 방향을 안내하며, 오랜 경험을 바탕으로 진료를 진행합니다.[1]", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-15T15:22:45"}
|
||||
{"id": "A-12", "engine": "naver_briefing", "question": "뷰성형외과 단점이나 주의할 점 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "safety", "aspects": ["safety", "doctors", "reviews", "procedure"], "evidence": "뷰성형외과 이용 시에는 부작용이나 주의사항에 대한 충분한 사전 정보 확인과 의료진의 숙련도 점검이 중요합니다.", "recommendation": "cautioned", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-15T15:22:47"}
|
||||
{"id": "B-01", "engine": "naver_briefing", "question": "뷰성형외과 주소 어디야?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과의원은 서울특별시 강남구 봉은사로 107 (논현동)에 위치하고 있으며, 9호선 및 신분당선 신논현역에서 도보로 가까운 거리에 있습니다.[1] [4]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:48"}
|
||||
{"id": "B-02", "engine": "naver_briefing", "question": "뷰성형외과 신논현역에서 얼마나 걸려?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과의원은 지하철 9호선 신논현역 3번 출구에서 50M 이내에 위치하고 있어 도보로 가까이 접근할 수 있습니다.[2] [3]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:50"}
|
||||
{"id": "B-03", "engine": "naver_briefing", "question": "뷰성형외과 전화번호 알려줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "뷰성형외과의 전화번호는 02-539-1177이며, 상담 이메일은 *********@viewclinic.com입니다 .", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:51"}
|
||||
{"id": "B-04", "engine": "naver_briefing", "question": "뷰성형외과 진료시간 어떻게 돼?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과의원 진료시간은 요일별로 상이하며, 방문 전 병원에 직접 문의하시는 것이 가장 정확합니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:53"}
|
||||
{"id": "B-05", "engine": "naver_briefing", "question": "뷰성형외과 일요일이나 공휴일에도 해?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과의원(논현동)은 일요일과 공휴일에는 휴진하는 것으로 확인됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:54"}
|
||||
{"id": "B-06", "engine": "naver_briefing", "question": "뷰성형외과 주차 되나?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과는 자차 방문 시 주차 공간이 여유롭게 마련되어 있어 주차 문제로 스트레스받을 일이 없다는 후기가 있습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:56"}
|
||||
{"id": "B-07", "engine": "naver_briefing", "question": "뷰성형외과 상담 예약 어떻게 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "access", "reviews"], "evidence": "방문 후기 등에 따르면, 의료진이 현재 상태를 정확히 짚어주고 필요한 부분과 그렇지 않은 부분을 명확히 구분해 주는 전문적인 상담이 이루어지는 것으로 언급됩니다.[1] [2]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:57"}
|
||||
{"id": "B-08", "engine": "naver_briefing", "question": "뷰성형외과 카톡으로 상담돼?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access", "procedure"], "evidence": "뷰성형외과는 카카오톡 채널을 통해 상담이 가능하며, 공식 웹사이트에서도 카카오톡 상담을 제공하고 있습니다.[1] [3]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:22:59"}
|
||||
{"id": "B-09", "engine": "naver_briefing", "question": "뷰성형외과 상담받으러 가면 몇 층으로 가?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "정확한 상담을 위한 방문 층수나 세부적인 안내는 병원에 직접 전화로 문의하시는 것이 가장 확실합니다.[8] [10]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:01"}
|
||||
{"id": "B-10", "engine": "naver_briefing", "question": "신논현역 3번 출구 근처 성형외과 뭐 있어?", "prior": "◯", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:02"}
|
||||
{"id": "C-01", "engine": "naver_briefing", "question": "강남 성형외과 추천해줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:03"}
|
||||
{"id": "C-02", "engine": "naver_briefing", "question": "강남에서 가슴성형 잘하는 병원 어디야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:05"}
|
||||
{"id": "C-03", "engine": "naver_briefing", "question": "강남 안면윤곽 유명한 병원 알려줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:06"}
|
||||
{"id": "C-04", "engine": "naver_briefing", "question": "코성형 잘하는 강남 병원 추천해줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:07"}
|
||||
{"id": "C-05", "engine": "naver_briefing", "question": "눈 재수술 잘하는 병원 어디야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:09"}
|
||||
{"id": "C-06", "engine": "naver_briefing", "question": "강남 성형외과 톱5 알려줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:11"}
|
||||
{"id": "C-07", "engine": "naver_briefing", "question": "가슴성형 병원 3곳만 골라서 비교해줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:12"}
|
||||
{"id": "C-08", "engine": "naver_briefing", "question": "마취과 전문의가 상주하는 강남 성형외과 어디야?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:14"}
|
||||
{"id": "C-09", "engine": "naver_briefing", "question": "수술실 CCTV 있는 성형외과 추천해줘", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:15"}
|
||||
{"id": "C-10", "engine": "naver_briefing", "question": "여의사한테 수술받을 수 있는 성형외과 있어?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:17"}
|
||||
{"id": "C-11", "engine": "naver_briefing", "question": "후기 제일 많은 강남 성형외과 어디야?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:18"}
|
||||
{"id": "C-12", "engine": "naver_briefing", "question": "강남언니에서 평점 높은 성형외과 알려줘", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 0.0, "weak_mention": false, "ts": "2026-09-15T15:23:18"}
|
||||
{"id": "C-13", "engine": "naver_briefing", "question": "20년 넘게 운영한 강남 성형외과 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:20"}
|
||||
{"id": "C-14", "engine": "naver_briefing", "question": "리프팅 잘하는 강남 병원 추천해줘", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:21"}
|
||||
{"id": "C-15", "engine": "naver_briefing", "question": "의사 여러 명 있는 대형 성형외과가 나아? 어디 추천해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:22"}
|
||||
{"id": "D1-01", "engine": "naver_briefing", "question": "뷰성형외과 가슴성형 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "doctors", "reviews", "safety"], "evidence": "뷰성형외과는 모티바 보형물 사용에 있어 2023년 모티바코리아 전체 수술량 기준 국내 사용량 1위를 기록했으며, 4년 연속 모티바 글로벌 KOL에 선정된 바 있습니다.[1] [2]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:24"}
|
||||
{"id": "D1-02", "engine": "naver_briefing", "question": "가슴 보형물 종류 뭐가 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:26"}
|
||||
{"id": "D1-03", "engine": "naver_briefing", "question": "모티바랑 멘토 보형물 차이가 뭐야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:27"}
|
||||
{"id": "D1-04", "engine": "naver_briefing", "question": "가슴성형 수술시간 얼마나 걸려?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:28"}
|
||||
{"id": "D1-05", "engine": "naver_briefing", "question": "가슴성형 회복기간 어느 정도야? 언제 출근해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:30"}
|
||||
{"id": "D1-06", "engine": "naver_briefing", "question": "가슴성형 흉터 어디에 남아?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:31"}
|
||||
{"id": "D1-07", "engine": "naver_briefing", "question": "구형구축이 뭐야? 예방할 수 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:33"}
|
||||
{"id": "D1-08", "engine": "naver_briefing", "question": "가슴 지방이식이랑 보형물 중에 뭐가 나아?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:34"}
|
||||
{"id": "D1-09", "engine": "naver_briefing", "question": "가슴 재수술은 언제 해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:36"}
|
||||
{"id": "D1-10", "engine": "naver_briefing", "question": "가슴성형하면 수유에 영향 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:37"}
|
||||
{"id": "D1-11", "engine": "naver_briefing", "question": "가슴성형 후 정기검진 뭐 받아야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:39"}
|
||||
{"id": "D1-12", "engine": "naver_briefing", "question": "가슴성형 과정 잘 설명해주는 영상 있어?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:40"}
|
||||
{"id": "D2-01", "engine": "naver_briefing", "question": "뷰성형외과 눈성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "safety", "reviews"], "evidence": "병원 측에서는 첨단 시스템을 갖춘 안전한 환경에서 수술을 진행하며, 상담 시 환자의 의견을 존중하고 맞춤형 조언을 제공하는 것으로 언급됩니다.[6] [9]", "recommendation": "recommended", "confidence": 0.98, "weak_mention": false, "ts": "2026-09-15T15:23:42"}
|
||||
{"id": "D2-02", "engine": "naver_briefing", "question": "쌍수 절개랑 매몰 차이가 뭐야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:43"}
|
||||
{"id": "D2-03", "engine": "naver_briefing", "question": "자연유착 쌍수가 뭐야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:45"}
|
||||
{"id": "D2-04", "engine": "naver_briefing", "question": "눈매교정은 쌍수랑 같이 해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:46"}
|
||||
{"id": "D2-05", "engine": "naver_briefing", "question": "앞트임 뒤트임 밑트임 뭐가 달라?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:47"}
|
||||
{"id": "D2-06", "engine": "naver_briefing", "question": "쌍수 회복기간 얼마나 걸려?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:49"}
|
||||
{"id": "D2-07", "engine": "naver_briefing", "question": "눈 재수술은 첫 수술 후 언제부터 가능해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:50"}
|
||||
{"id": "D2-08", "engine": "naver_briefing", "question": "앞트임하면 잘 어울리는 얼굴이 따로 있어?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:52"}
|
||||
{"id": "D2-09", "engine": "naver_briefing", "question": "쌍수 붓기 빨리 빼는 법 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:53"}
|
||||
{"id": "D3-01", "engine": "naver_briefing", "question": "뷰성형외과 코성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "safety", "doctors", "price"], "evidence": "뷰성형외과의 코성형은 3D-CT 영상 분석을 기반으로 환자 개개인에게 맞춘 커스터마이징 보형물을 제작하는 것이 특징입니다.[1]", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-15T15:23:55"}
|
||||
{"id": "D3-02", "engine": "naver_briefing", "question": "코성형 재료 뭐뭐 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:57"}
|
||||
{"id": "D3-03", "engine": "naver_briefing", "question": "실리콘이랑 자가연골 중에 뭐가 나아?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:58"}
|
||||
{"id": "D3-04", "engine": "naver_briefing", "question": "콧대만 할까 코끝까지 같이 할까?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:23:59"}
|
||||
{"id": "D3-05", "engine": "naver_briefing", "question": "코성형 회복기간 얼마나 돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:01"}
|
||||
{"id": "D3-06", "engine": "naver_briefing", "question": "코 재수술은 언제부터 할 수 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:02"}
|
||||
{"id": "D3-07", "engine": "naver_briefing", "question": "휜코 교정도 성형외과에서 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:04"}
|
||||
{"id": "D3-08", "engine": "naver_briefing", "question": "복코 수술 어떻게 하는 거야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:05"}
|
||||
{"id": "D3-09", "engine": "naver_briefing", "question": "내 얼굴형에 맞는 코 모양 어떻게 정해?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:06"}
|
||||
{"id": "D4-01", "engine": "naver_briefing", "question": "뷰성형외과 안면윤곽 잘하는 곳 맞아?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "safety", "access"], "evidence": "해당 병원은 1:1 전담 마취시스템을 운영하며, 체계적인 업무 처리와 친절한 상담으로 알려져 있습니다.[6] [8]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:08"}
|
||||
{"id": "D4-02", "engine": "naver_briefing", "question": "광대축소 수술 어떻게 하는 거야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:09"}
|
||||
{"id": "D4-03", "engine": "naver_briefing", "question": "V라인 사각턱 수술 부작용 없어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:10"}
|
||||
{"id": "D4-04", "engine": "naver_briefing", "question": "양악수술은 어떤 경우에 해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:12"}
|
||||
{"id": "D4-05", "engine": "naver_briefing", "question": "윤곽수술 신경 손상 위험 얼마나 돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:13"}
|
||||
{"id": "D4-06", "engine": "naver_briefing", "question": "윤곽수술 회복기간 얼마나 걸려?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:15"}
|
||||
{"id": "D4-07", "engine": "naver_briefing", "question": "윤곽수술 붓기 언제 빠져?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:16"}
|
||||
{"id": "D4-08", "engine": "naver_briefing", "question": "윤곽이랑 코성형 같이 해도 돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:18"}
|
||||
{"id": "D4-09", "engine": "naver_briefing", "question": "안면윤곽으로 상 받은 병원 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:19"}
|
||||
{"id": "D5-01", "engine": "naver_briefing", "question": "지방흡입 부위별로 뭐가 달라?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:20"}
|
||||
{"id": "D5-02", "engine": "naver_briefing", "question": "얼굴 지방이식하면 자연스러워?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:21"}
|
||||
{"id": "D5-03", "engine": "naver_briefing", "question": "지방이식 생착률 얼마나 돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:22"}
|
||||
{"id": "D5-04", "engine": "naver_briefing", "question": "복부 지방흡입 회복 얼마나 걸려?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:24"}
|
||||
{"id": "D5-05", "engine": "naver_briefing", "question": "뷰성형외과에서 지방성형도 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure"], "evidence": "뷰성형외과에서는 얼굴 지방흡입과 같은 지방 관련 시술을 제공하고 있습니다.[3]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:25"}
|
||||
{"id": "D5-06", "engine": "naver_briefing", "question": "지방흡입 흉터 남아?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:27"}
|
||||
{"id": "D6-01", "engine": "naver_briefing", "question": "리프팅 종류별로 뭐가 달라?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:28"}
|
||||
{"id": "D6-02", "engine": "naver_briefing", "question": "실리프팅이랑 안면거상 중에 뭐 해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:30"}
|
||||
{"id": "D6-03", "engine": "naver_briefing", "question": "울쎄라 효과 진짜 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:31"}
|
||||
{"id": "D6-04", "engine": "naver_briefing", "question": "리프팅 효과 얼마나 유지돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:33"}
|
||||
{"id": "D6-05", "engine": "naver_briefing", "question": "40대인데 안티에이징 뭐부터 시작해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:34"}
|
||||
{"id": "D6-06", "engine": "naver_briefing", "question": "뷰성형외과에서 리프팅도 받을 수 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "doctors"], "evidence": "뷰성형외과는 안티에이징 성형을 포함한 여러 분야에서 진료를 제공하며, 티타늄 리프팅과 같은 시술에 대한 정보가 확인됩니다.[3] [8]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:35"}
|
||||
{"id": "D6-07", "engine": "naver_briefing", "question": "수술 흉터 관리 어떻게 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:37"}
|
||||
{"id": "D7-01", "engine": "naver_briefing", "question": "재수술 전문 병원은 뭐가 달라?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:39"}
|
||||
{"id": "D7-02", "engine": "naver_briefing", "question": "뷰성형외과 재수술 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "access"], "evidence": "뷰성형외과는 코, 윤곽, 눈 등 다양한 부위의 재수술에 대해 정밀 진단과 맞춤 솔루션을 제공하는 것으로 보입니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:40"}
|
||||
{"id": "D7-03", "engine": "naver_briefing", "question": "성형 재수술은 왜 하게 되는 거야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:42"}
|
||||
{"id": "D7-04", "engine": "naver_briefing", "question": "재수술은 처음보다 비용 더 비싸?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:44"}
|
||||
{"id": "D7-05", "engine": "naver_briefing", "question": "재수술 하려면 첫 수술 후 얼마나 기다려야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:45"}
|
||||
{"id": "D7-06", "engine": "naver_briefing", "question": "다른 병원에서 한 수술도 재수술 상담해줘?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:46"}
|
||||
{"id": "E-01", "engine": "naver_briefing", "question": "성형외과 고를 때 뭘 확인해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:48"}
|
||||
{"id": "E-02", "engine": "naver_briefing", "question": "성형수술 마취 사고 안 나려면 어떤 병원 가야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:49"}
|
||||
{"id": "E-03", "engine": "naver_briefing", "question": "수술실 CCTV 있는지 어떻게 확인해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:50"}
|
||||
{"id": "E-04", "engine": "naver_briefing", "question": "전신마취랑 수면마취 차이가 뭐야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:52"}
|
||||
{"id": "E-05", "engine": "naver_briefing", "question": "성형외과 전문의인지 어떻게 확인해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:53"}
|
||||
{"id": "E-06", "engine": "naver_briefing", "question": "응급상황 대응 시스템 갖춘 성형외과 어디야?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:55"}
|
||||
{"id": "E-07", "engine": "naver_briefing", "question": "성형 부작용 생기면 어떻게 해야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:57"}
|
||||
{"id": "E-08", "engine": "naver_briefing", "question": "상담실장 말고 의사가 직접 상담해주는 병원 있어?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:24:58"}
|
||||
{"id": "E-09", "engine": "naver_briefing", "question": "뷰성형외과 안전 시스템 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["safety", "doctors", "procedure"], "evidence": "뷰성형외과는 첨단 시스템을 도입한 안전중심 스마트빌딩과 대학병원급 안전 장비 및 시설을 갖추고 안전한 성형을 제공하고 있습니다.[1] [2]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:00"}
|
||||
{"id": "F-01", "engine": "naver_briefing", "question": "강남 가슴성형 평균 비용 얼마야?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:01"}
|
||||
{"id": "F-02", "engine": "naver_briefing", "question": "뷰성형외과 가슴성형 얼마야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "reviews", "safety"], "evidence": "뷰성형외과는 모티바, 멘토, 세빈 등 다양한 종류의 보형물을 보유하고 있으며, 원내에 유방외과 전문의 등이 상주하여 검진을 진행합니다.[1] [3]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:02"}
|
||||
{"id": "F-03", "engine": "naver_briefing", "question": "쌍수 가격 보통 얼마 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:04"}
|
||||
{"id": "F-04", "engine": "naver_briefing", "question": "코성형 견적 어느 정도 잡아야 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:05"}
|
||||
{"id": "F-05", "engine": "naver_briefing", "question": "뷰성형외과 지금 하는 이벤트 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["price", "procedure", "access"], "evidence": "뷰성형외과에서 진행하는 이벤트 정보는 시술 종류와 시기에 따라 상이하므로, 정확한 내용은 병원에 직접 문의하시는 것이 가장 좋습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:08"}
|
||||
{"id": "F-06", "engine": "naver_briefing", "question": "성형외과 이벤트가 믿어도 돼? 싼 데는 왜 싸?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:09"}
|
||||
{"id": "F-07", "engine": "naver_briefing", "question": "성형 비용 카드 할부나 분할 돼?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:10"}
|
||||
{"id": "F-08", "engine": "naver_briefing", "question": "성형 견적 여러 병원 비교하려면 어떻게 해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:12"}
|
||||
{"id": "G-01", "engine": "naver_briefing", "question": "뷰성형외과 실제 후기 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "reviews", "procedure", "safety"], "evidence": "뷰성형외과에 대한 후기들은 의료진의 전문성, 친절한 응대 서비스, 그리고 수술 결과에 대한 전반적인 만족도를 보여주고 있습니다.", "recommendation": "recommended", "confidence": 0.98, "weak_mention": false, "ts": "2026-09-15T15:25:13"}
|
||||
{"id": "G-02", "engine": "naver_briefing", "question": "뷰성형외과 부작용 후기나 안 좋은 얘기 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "reputation", "aspects": ["reviews", "regulatory", "safety"], "evidence": "일부 성형외과들이 수술비 할인 등의 경제적 대가를 받고 작성한 후기를 마치 자발적인 후기인 것처럼 게시하는 '뒷광고' 사례가 공정거래위원회에 의해 적발된 바 있습니다.[2] [9]", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:15"}
|
||||
{"id": "G-03", "engine": "naver_briefing", "question": "강남언니 후기 믿을만해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:16"}
|
||||
{"id": "G-04", "engine": "naver_briefing", "question": "뷰성형외과 수술 전후 사진 어디서 봐?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "reputation", "aspects": ["reviews", "regulatory"], "evidence": "다만, 해당 홈페이지에 게재된 후기는 소정의 대가를 제공받아 작성된 후기임을 명시하고 있습니다.[1] [4]", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:18"}
|
||||
{"id": "G-05", "engine": "naver_briefing", "question": "성형 후기는 어디서 봐야 정확해?", "prior": "✕", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:20"}
|
||||
{"id": "G-06", "engine": "naver_briefing", "question": "뷰성형외과 인스타그램 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "reviews", "doctors", "safety", "media"], "evidence": "뷰성형외과는 강남의 대표적인 프리미엄 성형외과로, 가슴성형 분야에서 '모티바 최다 시술 병원'으로 선정된 바 있습니다.[8] [9]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:21"}
|
||||
{"id": "G-07", "engine": "naver_briefing", "question": "뷰성형외과는 뭘로 유명한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "safety", "access"], "evidence": "뷰성형외과는 눈밑지방재배치 수술을 비롯하여 가슴성형, 안면윤곽, 양악수술 등 다양한 분야에서 성형 종합병원으로서의 면모를 갖추고 있습니다.[3] [6]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:23"}
|
||||
{"id": "G-08", "engine": "naver_briefing", "question": "가슴성형 후기 많은 병원 어디야?", "prior": "△", "extractor_mentioned": false, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": false, "sentiment": "not_mentioned", "negative_type": "none", "aspects": [], "evidence": "", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-15T15:25:24"}
|
||||
@ -1,17 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""뷰성형외과 Question Bank 120문항 답변엔진 실측 러너 (OpenAI / Perplexity / 네이버 AI 브리핑).
|
||||
"""뷰성형외과 Question Bank 120문항 답변엔진 실측 러너 (OpenAI / Perplexity).
|
||||
|
||||
측정 방식:
|
||||
- openai: Responses API + web_search 도구 (서울 위치 고정)
|
||||
- perplexity: chat/completions, model=sonar (자체 웹검색 내장)
|
||||
- naver_briefing: SerpApi naver_ai_overview (제3자 SERP API). 네이버는 공식 API가 없다.
|
||||
- openai: Responses API + web_search 도구 (서울 위치 고정)
|
||||
- perplexity: chat/completions, model=sonar (자체 웹검색 내장)
|
||||
ChatGPT 소비자 UI 자동 조회는 약관 위반이므로 쓰지 않는다.
|
||||
|
||||
naver_briefing 은 haewon 결정(2026-09-15)으로 넣었다. search.naver.com robots.txt 가 모든 봇과
|
||||
AI·RAG 목적 수집을 금지하고 있어, 결과를 고객 리포트에 쓰기 전에 법무 확인이 필요하다.
|
||||
브리핑이 뜨지 않는 질문은 answer 를 비우고 briefing=false 로 기록한다(그것도 결과다).
|
||||
SerpApi 는 같은 파라미터를 1시간 캐시한다. 반복 측정은 --no-cache 로 새로 조회한다(검색 1회씩 과금).
|
||||
|
||||
각 질문을 원문 그대로 1회 질의하고, 답변에서 언급된 병원명 상위 5개를
|
||||
후처리 모델(OpenAI gpt-4o-mini, 없으면 Gemini 2.5 Flash)로 추출해 JSONL 기록.
|
||||
재실행 시 완료된 문항은 건너뛴다.
|
||||
@ -20,7 +14,6 @@ SerpApi 는 같은 파라미터를 1시간 캐시한다. 반복 측정은 --no-c
|
||||
python3 scripts/run_question_bank_openai.py --engine perplexity
|
||||
OPENAI_API_KEY=... python3 scripts/run_question_bank_openai.py --engine openai
|
||||
python3 scripts/run_question_bank_openai.py --engine openai --summarize
|
||||
SERPAPI_API_KEY=... python3 scripts/run_question_bank_openai.py --engine naver_briefing --device mobile --limit 5
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@ -30,7 +23,6 @@ import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
@ -70,28 +62,6 @@ def post(url, payload, headers, timeout=180):
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def get_retry(url, tries=4, timeout=120):
|
||||
for i in range(tries):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout, context=SSL_CTX) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
if e.code in (429, 500, 502, 503) and i < tries - 1:
|
||||
time.sleep(15 * (i + 1))
|
||||
continue
|
||||
# 결과 없음도 4xx 본문으로 올 수 있어 JSON 이면 그대로 돌려준다
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"HTTP {e.code}: {body[:500]}") from e
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
if i < tries - 1:
|
||||
time.sleep(10)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def post_retry(url, payload, headers, tries=4):
|
||||
for i in range(tries):
|
||||
try:
|
||||
@ -176,41 +146,6 @@ class PerplexityEngine:
|
||||
return text, urls
|
||||
|
||||
|
||||
class NaverBriefingEngine:
|
||||
"""SerpApi naver_ai_overview. 반환 (answer, urls). 브리핑이 없으면 ("", []) 와 self.last_meta.briefing=False."""
|
||||
name = "naver_briefing"
|
||||
|
||||
def __init__(self, device, no_cache):
|
||||
self.key = os.environ.get("SERPAPI_API_KEY")
|
||||
if not self.key:
|
||||
sys.exit("SERPAPI_API_KEY가 필요합니다 (.env). serpapi.com 가입 후 대시보드의 API Key")
|
||||
self.device = device
|
||||
self.no_cache = no_cache
|
||||
self.model = f"serpapi:naver_ai_overview:{device}"
|
||||
self.last_meta = {}
|
||||
|
||||
def ask(self, question):
|
||||
params = {"engine": "naver_ai_overview", "query": question, "device": self.device, "api_key": self.key}
|
||||
if self.no_cache:
|
||||
params["no_cache"] = "true"
|
||||
resp = get_retry("https://serpapi.com/search.json?" + urllib.parse.urlencode(params))
|
||||
ov = resp.get("ai_overview") if isinstance(resp.get("ai_overview"), dict) else resp
|
||||
text = (ov.get("markdown") or "").strip()
|
||||
if not text:
|
||||
text = "\n".join(b.get("snippet", "") for b in ov.get("text_blocks") or [] if isinstance(b, dict)).strip()
|
||||
refs = [r for r in ov.get("references") or [] if isinstance(r, dict)]
|
||||
urls = [r["link"] for r in refs if r.get("link")]
|
||||
self.last_meta = {
|
||||
"briefing": bool(text),
|
||||
"device": self.device,
|
||||
"sources": [{"title": r.get("title"), "source": r.get("source"), "link": r.get("link")} for r in refs][:10],
|
||||
"serpapi_id": (resp.get("search_metadata") or {}).get("id"),
|
||||
}
|
||||
if not text and resp.get("error"):
|
||||
self.last_meta["no_briefing_reason"] = str(resp["error"])[:200]
|
||||
return text, urls
|
||||
|
||||
|
||||
# ---------- extraction ----------
|
||||
|
||||
def extract_clinics(answer):
|
||||
@ -265,8 +200,7 @@ def summarize(engine):
|
||||
print(f"{engine}: 결과 없음")
|
||||
return
|
||||
label = {"openai": "OpenAI 웹검색(Responses API + web_search, 서울 고정)",
|
||||
"perplexity": "Perplexity API (sonar)",
|
||||
"naver_briefing": "네이버 AI 브리핑 (SerpApi naver_ai_overview, 제3자 SERP API)"}[engine]
|
||||
"perplexity": "Perplexity API (sonar)"}[engine]
|
||||
qs = json.load(open(QUESTIONS))
|
||||
freq, mentioned = {}, 0
|
||||
lines = [f"# 뷰성형외과 Question Bank 실측 결과: {engine}", "",
|
||||
@ -291,8 +225,6 @@ def summarize(engine):
|
||||
f"{rank if rank else '-'} | {', '.join(clinics) if clinics else '(병원 언급 없음)'} |")
|
||||
lines += ["", "## 집계", "",
|
||||
f"- 뷰성형외과 언급: {mentioned}/{len(done)}문항 ({mentioned*100//max(len(done),1)}%)",
|
||||
*([f"- AI 브리핑 노출: {sum(1 for r in done.values() if r.get('briefing'))}/{len(done)}문항 "
|
||||
"(브리핑이 없으면 언급 없음으로 센다)"] if engine == "naver_briefing" else []),
|
||||
"", "### 병원별 언급 빈도 (상위 15)", "",
|
||||
"| 병원 | 언급 질문 수 | 1순위 등장 수 |", "|---|---|---|"]
|
||||
for name, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0])[:15]:
|
||||
@ -304,9 +236,7 @@ def summarize(engine):
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--engine", choices=["openai", "perplexity", "naver_briefing"], default="openai")
|
||||
ap.add_argument("--device", choices=["mobile", "desktop"], default="mobile", help="naver_briefing 전용")
|
||||
ap.add_argument("--no-cache", action="store_true", help="naver_briefing 반복 측정 시 SerpApi 캐시 무시")
|
||||
ap.add_argument("--engine", choices=["openai", "perplexity"], default="openai")
|
||||
ap.add_argument("--model", default=None)
|
||||
ap.add_argument("--search-tool", default="web_search")
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
@ -321,8 +251,6 @@ def main():
|
||||
|
||||
if args.engine == "openai":
|
||||
eng = OpenAIEngine(args.model or "gpt-4o", args.search_tool)
|
||||
elif args.engine == "naver_briefing":
|
||||
eng = NaverBriefingEngine(args.device, args.no_cache)
|
||||
else:
|
||||
eng = PerplexityEngine(args.model or "sonar")
|
||||
|
||||
@ -343,7 +271,6 @@ def main():
|
||||
row = {"id": q["id"], "question": q["question"], "prior": q["prior"],
|
||||
"engine": args.engine, "model": eng.model,
|
||||
"answer": answer, "citations": urls[:10], **ext,
|
||||
**getattr(eng, "last_meta", {}),
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
f.flush()
|
||||
|
||||
@ -142,11 +142,7 @@ def run(args):
|
||||
if r["id"] in done: continue
|
||||
kind = mention_kind(r.get("answer", ""), args.clinic, aliases, weak_tokens)
|
||||
hint = kind != "none"
|
||||
if not (r.get("answer") or "").strip():
|
||||
# 답변이 비어 있다(네이버 AI 브리핑이 뜨지 않은 질문 등). 판정할 문장이 없으므로 API를 부르지 않는다.
|
||||
out, usage = {"mentioned": False}, {}
|
||||
else:
|
||||
out, usage = judge(key, args.model, system, r["question"], r.get("answer", ""))
|
||||
out, usage = judge(key, args.model, system, r["question"], r.get("answer", ""))
|
||||
n = normalize(out, hint)
|
||||
# 탐지기가 none 이면 판정기 판단보다 우선한다. 상호가 아예 없는 답변을 언급으로 셀 수 없다.
|
||||
if kind == "none" and n["mentioned"]:
|
||||
@ -211,8 +207,7 @@ def summary(args):
|
||||
lines += ["", f"## 5. 언급 판정 불일치 (판정기 vs 추출기) {len(dis)}건", ""]
|
||||
lines += [f"- {r['id']} {r['engine']}: 판정기 {r['mentioned']} / 추출기 {r['extractor_mentioned']} · {r['evidence'][:80]}" for r in dis[:30]]
|
||||
lines += ["", "## 6. 측정 가능 범위", "",
|
||||
"- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar)"
|
||||
+ ("·네이버 AI 브리핑(SerpApi 제3자 SERP API, 브리핑이 없는 문항은 미언급으로 셈)" if "naver_briefing" in engines else "") + ".",
|
||||
"- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar).",
|
||||
"- 재지 않는 것: 소비자 화면(chatgpt.com 등)의 실제 답변. 약관상 자동 조회를 하지 않으므로 API 답변과 화면 답변은 다를 수 있다.",
|
||||
"- 재지 않는 것: Google AI Overview·네이버 AI 브리핑·Gemini 의 어조. 다음 회차에 엔진을 추가할 때 같은 기준표로 잰다.",
|
||||
"- 한계: 판정기는 언어 모델이다. 보정(§7) 전 점수는 참고값이고, 보정 후에도 문항 수(언급 84건)가 적어 ±10점은 우연 범위로 본다."]
|
||||
|
||||
10
src/App.tsx
@ -1,4 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router';
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
@ -9,15 +8,6 @@ export default function App() {
|
||||
const location = useLocation();
|
||||
const isLoadingPage = location.pathname.startsWith('/report/loading');
|
||||
|
||||
// 라우트가 바뀌어도 브라우저가 스크롤 위치를 그대로 들고 온다. 진단 리포트처럼 긴 페이지를
|
||||
// 내려 보다가 "다음 단계"를 누르면, 새로 연 페이지가 그 위치(짧으면 하단 빈 공간)에서 시작해
|
||||
// 항상 위부터 보게 되지 않는다. 라우트 전환마다 맨 위로 되돌린다.
|
||||
// 해시(#breakdown 같은 인앱 앵커)가 있으면 그 앵커로 가려는 의도이므로 건드리지 않는다.
|
||||
useEffect(() => {
|
||||
if (location.hash) return;
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname, location.hash]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
||||
{!isLoadingPage && <Navbar />}
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 병원 작업 공간 상단 메뉴.
|
||||
*
|
||||
* 마케팅 메뉴(Product·Pricing·Use Cases)는 진단을 받은 병원에게 쓸모가 없다.
|
||||
* 이 화면들은 진단 → 빌드 → 사진 확인 → 확인 항목이라는 하나의 절차이므로,
|
||||
* 상단을 그 절차로 바꾼다. 지금 어디에 있고 다음이 무엇인지가 한 줄에 보여야 한다.
|
||||
*
|
||||
* 단계의 완료 여부는 각 화면이 판단한다. 여기서는 위치만 표시한다.
|
||||
*/
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { PrismFilled } from './icons/FilledIcons';
|
||||
|
||||
type Step = { key: string; label: string; to: (id: string) => string; match: RegExp };
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
|
||||
{ key: 'actions', label: '개선 제안', to: (id) => `/actions/${id}`, match: /^\/actions\/[^/]+/ },
|
||||
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
|
||||
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
|
||||
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
|
||||
];
|
||||
|
||||
/** 작업 공간 경로면 병원 id 를 돌려준다. 아니면 null. */
|
||||
export function clinicIdFromPath(pathname: string): string | null {
|
||||
for (const s of STEPS) {
|
||||
if (s.match.test(pathname)) return pathname.split('/')[2] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ClinicNav({ clinicId }: { clinicId: string }) {
|
||||
const { pathname } = useLocation();
|
||||
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
|
||||
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
|
||||
const next = currentIndex > -1 && currentIndex < STEPS.length - 1 ? STEPS[currentIndex + 1] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md" data-no-print>
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center gap-6">
|
||||
{/* 로고 + 병원 */}
|
||||
<Link to="/" className="flex items-center gap-3 shrink-0" aria-label="INFINITH 홈">
|
||||
<PrismFilled size={22} className="text-[#6C5CE7]" />
|
||||
<span className="hidden sm:block font-serif text-xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">
|
||||
INFINITH
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="hidden md:block w-px h-7 bg-slate-200 shrink-0" />
|
||||
|
||||
<span className="hidden md:block text-sm font-semibold text-slate-500 shrink-0 max-w-[160px] truncate" title={clinicId}>
|
||||
{clinicId}
|
||||
</span>
|
||||
|
||||
{/* 단계 */}
|
||||
<ol className="flex items-center gap-1 overflow-x-auto ml-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{STEPS.map((s, i) => {
|
||||
const isCurrent = i === currentIndex;
|
||||
const isPast = currentIndex > -1 && i < currentIndex;
|
||||
return (
|
||||
<li key={s.key} className="flex items-center shrink-0">
|
||||
{i > 0 && <span className="w-4 h-px bg-slate-200 mx-0.5" aria-hidden="true" />}
|
||||
<Link
|
||||
to={s.to(clinicId)}
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={`group inline-flex items-center gap-2 rounded-full pl-2 pr-3.5 py-2 text-sm font-semibold transition-colors
|
||||
${isCurrent
|
||||
? 'bg-[#F3F0FF] text-[#4A3A7C]'
|
||||
: 'text-slate-500 hover:bg-slate-100 hover:text-[#1D0024]'}`}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors
|
||||
${isCurrent
|
||||
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
|
||||
: isPast
|
||||
? 'bg-[#D5CDF5] text-[#4A3A7C]'
|
||||
: 'bg-slate-100 text-slate-400 group-hover:bg-slate-200'}`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{s.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 이전 단계로. 상단 절차 메뉴는 좁은 화면에서 가로 스크롤 안으로 밀려 눈에 안 들어온다.
|
||||
되돌아가는 길은 늘 같은 자리에 있어야 한다. 첫 단계에서는 돌아갈 곳이 없어 띄우지 않는다.
|
||||
|
||||
다음 단계로 가는 길도 같은 이유로 여기에 둔다. 화면마다 따로 심으면 빠지는 곳이 생긴다.
|
||||
실제로 사이트 빌드 화면은 빌드가 끝났을 때만 다음 단계 카드를 그려서, 빌드 중이거나
|
||||
멈췄을 때는 상단 메뉴 말고는 넘어갈 길이 없었다. 마지막 단계에서는 갈 곳이 없어 띄우지 않는다. */}
|
||||
{prev && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
className="fixed left-4 bottom-4 md:left-6 md:bottom-6 z-40"
|
||||
data-no-print
|
||||
>
|
||||
<Link
|
||||
to={prev.to(clinicId)}
|
||||
className="group inline-flex items-center gap-2.5 rounded-full bg-white/95 backdrop-blur-md border border-slate-200
|
||||
shadow-[0_4px_20px_rgba(29,0,36,0.14)] pl-3 pr-5 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.2)]"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-full bg-slate-100 text-[#1D0024] flex items-center justify-center
|
||||
transition-colors group-hover:bg-[#F3F0FF]">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M8.9 1.4 3.3 7l5.6 5.6a1.1 1.1 0 0 0 1.6-1.6L6.5 7l4-4A1.1 1.1 0 0 0 8.9 1.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-left leading-tight">
|
||||
<span className="block text-[11px] font-semibold text-slate-400">이전 단계</span>
|
||||
<span className="block text-sm font-bold text-[#1D0024] whitespace-nowrap">{prev.label}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{next && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
className="fixed right-4 bottom-4 md:right-6 md:bottom-6 z-40"
|
||||
data-no-print
|
||||
>
|
||||
<Link
|
||||
to={next.to(clinicId)}
|
||||
className="group inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white
|
||||
shadow-[0_4px_20px_rgba(29,0,36,0.24)] pl-5 pr-3 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.32)]"
|
||||
>
|
||||
<span className="text-right leading-tight">
|
||||
<span className="block text-[11px] font-semibold text-white/60">다음 단계</span>
|
||||
<span className="block text-sm font-bold whitespace-nowrap">{next.label}</span>
|
||||
</span>
|
||||
<span className="w-7 h-7 rounded-full bg-white/15 flex items-center justify-center
|
||||
transition-colors group-hover:bg-white/25">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M5.1 1.4 10.7 7l-5.6 5.6a1.1 1.1 0 0 1-1.6-1.6L7.5 7l-4-4A1.1 1.1 0 0 1 5.1 1.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -18,17 +18,11 @@
|
||||
* 예컨대 `/pricing`에서 `#solution`을 누르면 /pricing 페이지 내의 #solution을 찾다가 실패합니다.
|
||||
* `/#solution`은 React Router가 홈으로 이동시킨 뒤 브라우저가 해시 스크롤을 처리합니다.
|
||||
*/
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { buildContactMailto } from '../lib/contact';
|
||||
import ClinicNav, { clinicIdFromPath } from './ClinicNav';
|
||||
|
||||
export default function Navbar() {
|
||||
const { pathname } = useLocation();
|
||||
// 병원 작업 공간(진단·빌드·사진 확인·확인 항목)에서는 절차 메뉴로 바꾼다.
|
||||
const clinicId = clinicIdFromPath(pathname);
|
||||
if (clinicId) return <ClinicNav clinicId={clinicId} />;
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
|
||||
@ -41,7 +41,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="on-dark relative overflow-hidden bg-[#0A1128] rounded-2xl px-8 py-7 flex flex-col items-center text-left"
|
||||
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" />
|
||||
@ -65,7 +65,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
|
||||
</svg>
|
||||
|
||||
{/* Center orb */}
|
||||
<div className="on-dark 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">
|
||||
<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>
|
||||
@ -77,7 +77,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
|
||||
{/* Nodes */}
|
||||
{NODES.map((node) => (
|
||||
<div key={node.letter} className={`absolute ${node.pos} z-[3] flex flex-col items-center gap-[7px]`}>
|
||||
<div className="on-dark 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">
|
||||
<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>
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 개선 제안(/actions/:id) 1부: 우선순위별 실행 항목.
|
||||
* 원래 진단 리포트 안의 "Action Plan" 섹션이었다. 진단(증거)과 처방(할 일)은 읽는 목적이 달라
|
||||
* 별도 단계로 뗐다. 내용은 그대로다.
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||
import { Tag } from './Tag';
|
||||
import type { ActionPriority, DiscoveryResult } from '../../types/discovery';
|
||||
|
||||
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 PRIORITY_HEADING: Record<ActionPriority, string> = {
|
||||
P0: '즉시: 크롤러 접근·엔티티·측정',
|
||||
P1: '4주 내: 콘텐츠 구조',
|
||||
P2: '지속: 신선도·시딩',
|
||||
};
|
||||
|
||||
export function ActionPlanSection({ result }: { result: DiscoveryResult }) {
|
||||
return (
|
||||
<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]">{PRIORITY_HEADING[p]}</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="on-light 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="ui-note shrink-0">{a.effort}</span>
|
||||
</div>
|
||||
<p className="ui-body 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>
|
||||
);
|
||||
}
|
||||
@ -16,14 +16,14 @@ function ItemRow({ it }: { it: ItemScoreV2; key?: string }) {
|
||||
return (
|
||||
<div className="py-4 grid md:grid-cols-[200px_1fr_120px] gap-3 items-start">
|
||||
<div>
|
||||
<span className="ui-note font-semibold">{it.item.id}</span>
|
||||
<span className="text-sm font-semibold text-slate-400">{it.item.id}</span>
|
||||
<p className="text-base font-bold text-[#0A1128]">{it.item.name}</p>
|
||||
<p className="ui-note mt-0.5">{it.item.weight}점 · {METHOD_LABEL[it.item.method]}</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{it.item.weight}점 · {METHOD_LABEL[it.item.method]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="ui-body">{it.item.question}</p>
|
||||
{it.evidence && <p className="ui-note mt-1">{it.evidence}</p>}
|
||||
<p className="ui-note mt-1">
|
||||
<p className="text-base text-slate-700">{it.item.question}</p>
|
||||
{it.evidence && <p className="text-sm text-slate-500 mt-1">{it.evidence}</p>}
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{BASIS_LABEL[it.basis]}
|
||||
{it.signals.length > 0 && ` · ${it.signals.map((s) => `${s.criterionId}=${s.level === 'unverified' ? '미검증' : s.level}`).join(' · ')}`}
|
||||
</p>
|
||||
@ -51,7 +51,7 @@ function AxisCard({ ax }: { ax: AxisScoreV2 }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="ui-note">검증 {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}점</p>
|
||||
<p className="text-sm text-slate-400">검증 {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}점</p>
|
||||
{ax.cappedBy.length > 0 && (
|
||||
<p className="text-sm font-semibold text-[#C084CF]">게이트 상한 {Math.min(...ax.cappedBy.map((g) => g.gate.cap))} (원점수 {ax.rawScore})</p>
|
||||
)}
|
||||
@ -60,7 +60,7 @@ function AxisCard({ ax }: { ax: AxisScoreV2 }) {
|
||||
<ScoreRing score={ax.score} size={96} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="ui-body mb-3">{ax.axis.description}</p>
|
||||
<p className="text-base text-slate-600 mb-3">{ax.axis.description}</p>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{ax.items.map((it) => <ItemRow key={it.item.id} it={it} />)}
|
||||
</div>
|
||||
@ -81,8 +81,8 @@ export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: Over
|
||||
<ScoreRing score={ax.score} size={120} label={ax.axis.nameEn} />
|
||||
<div>
|
||||
<p className="font-serif text-2xl font-bold text-[#1D0024]">{ax.axis.code} · {ax.axis.name}</p>
|
||||
<p className="ui-body mt-1">{ax.axis.question}</p>
|
||||
<p className="ui-note mt-2">
|
||||
<p className="text-base text-slate-600 mt-1">{ax.axis.question}</p>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
등급 <span className="font-bold text-[#0A1128]">{ax.grade}</span> · 검증 {ax.verifiedCount}/{ax.items.length}
|
||||
{ax.cappedBy.length > 0 && ` · 게이트 상한 적용 (원점수 ${ax.rawScore})`}
|
||||
</p>
|
||||
@ -95,7 +95,7 @@ export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: Over
|
||||
<p className="text-base font-bold text-[#0A1128] mb-2">게이트 (점수가 아니라 상한)</p>
|
||||
<ul className="grid md:grid-cols-2 gap-2">
|
||||
{score.gates.map((g) => (
|
||||
<li key={g.gate.id} className="ui-body">
|
||||
<li key={g.gate.id} className="text-base text-slate-700">
|
||||
<span className={`font-bold ${g.passed ? 'text-[#6C5CE7]' : 'text-[#C084CF]'}`}>{g.gate.id} {g.gate.name} · {g.passed ? '통과' : `실패 (${g.failedBy.join(', ')}) → 상한 ${g.gate.cap}`}</span>
|
||||
<span className="text-slate-500"> {g.gate.reason}</span>
|
||||
</li>
|
||||
|
||||
@ -38,7 +38,7 @@ export default function AiAnswerMock({ clinicName, criteriaCount }: AiAnswerMock
|
||||
<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="ui-body font-medium">
|
||||
<span className="text-base font-medium text-slate-700 break-keep">
|
||||
강남에서 안전하게 가슴성형 상담받을 수 있는 병원 추천해줘
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
/**
|
||||
* 진단 요약 → 이렇게 보완하겠습니다 → 빌드.
|
||||
*
|
||||
* 버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 무엇을 보완하는지 말한 뒤 누르게 한다.
|
||||
*
|
||||
* 이 화면에서 틀리면 안 되는 것: 빌드는 병원 홈페이지를 한 줄도 고치지 않는다.
|
||||
* 그래서 진단의 실행 항목은 단 하나도 이 빌드로 해결되지 않는다. 전부 병원 몫이다.
|
||||
* 빌드가 하는 일은 AI가 인용할 새 표면을 하나 더 만들어 약한 카테고리를 우회 보완하는 것이다.
|
||||
* "실행 항목 N건 해결"로 쓰면 지키지 못할 약속이 된다.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { STATE_LABEL } from '../../lib/buildPhases';
|
||||
import type { BuildState } from '../../lib/buildPhases';
|
||||
import { weakSpots } from '../../lib/buildCoverage';
|
||||
import type { DiscoveryResult, OverallScore } from '../../types/discovery';
|
||||
import { CheckFilled, WarningFilled } from '../icons/FilledIcons';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
result: DiscoveryResult;
|
||||
overall: OverallScore;
|
||||
summary: { rubricVersion: string; grade: string; score: number; aeo?: number; geo?: number; verified: number; total: number };
|
||||
};
|
||||
|
||||
export function BuildPlanSection({ result, overall, summary }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [existing, setExisting] = useState<BuildState | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const weak = weakSpots(overall.categories);
|
||||
const critical = result.keyFindings.filter((f) => f.severity === 'critical');
|
||||
const p0 = result.actions.filter((a) => a.priority === 'P0');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
const { data } = await supabase.from('supporter_builds').select('status')
|
||||
.eq('clinic_id', result.id).order('created_at', { ascending: false }).limit(1);
|
||||
if (!alive) return;
|
||||
setExisting((data?.[0]?.status as BuildState) ?? null);
|
||||
setChecking(false);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [result.id]);
|
||||
|
||||
async function approve() {
|
||||
setBusy(true); setError(null);
|
||||
await supabase.from('supporter_inputs').insert({
|
||||
clinic_id: result.id, key: 'report_approval',
|
||||
value: { ...summary, weakSpots: weak.map((w) => w.id), approvedAt: new Date().toISOString() },
|
||||
});
|
||||
const { error: e } = await supabase.from('supporter_builds').insert({
|
||||
clinic_id: result.id, clinic_name: result.clinicName, url: result.url, status: 'queued',
|
||||
});
|
||||
setBusy(false);
|
||||
if (e) { setError(e.message); return; }
|
||||
navigate(`/build/${result.id}`);
|
||||
}
|
||||
|
||||
const inFlight = existing === 'queued' || existing === 'running';
|
||||
|
||||
return (
|
||||
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden py-16 md:py-20 px-6" data-no-print>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(108,92,231,0.16),transparent_60%)]" />
|
||||
<div className="relative max-w-5xl mx-auto">
|
||||
<h2 className="font-serif text-3xl md:text-5xl font-bold tracking-[-0.02em] mb-8">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">What We Will Fix</span>
|
||||
</h2>
|
||||
|
||||
{/* 1. 진단 요약 */}
|
||||
<div className="on-light bg-white rounded-2xl p-6 md:p-7 shadow-[3px_4px_12px_rgba(0,0,0,0.2)] mb-6">
|
||||
<p className="ui-note mb-3">진단 요약 · {result.auditedAt} 실측</p>
|
||||
<div className="flex flex-wrap items-baseline gap-x-8 gap-y-3 mb-4">
|
||||
<span className="text-[#1D0024]">
|
||||
<strong className="font-serif text-4xl font-black">{summary.grade}</strong>
|
||||
<span className="ui-note ml-2">종합 {summary.score}점</span>
|
||||
</span>
|
||||
{typeof summary.aeo === 'number' && (
|
||||
<span className="text-[#1D0024]"><strong className="text-2xl font-bold">{summary.aeo}</strong><span className="ui-note ml-1.5">AEO 답변 준비도</span></span>
|
||||
)}
|
||||
{typeof summary.geo === 'number' && (
|
||||
<span className="text-[#1D0024]"><strong className="text-2xl font-bold">{summary.geo}</strong><span className="ui-note ml-1.5">GEO 출처 준비도</span></span>
|
||||
)}
|
||||
<span className="ui-note">실측 {summary.verified} / {summary.total}항목</span>
|
||||
</div>
|
||||
{critical.length > 0 && (
|
||||
<ul className="flex flex-col gap-1.5 pt-4 border-t border-slate-100">
|
||||
{critical.slice(0, 3).map((f) => (
|
||||
<li key={f.title} className="ui-body !text-[#1D0024] flex gap-2">
|
||||
<span className="mt-[9px] w-1.5 h-1.5 rounded-full bg-[#D4889A] shrink-0" />
|
||||
<span>{f.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. 이렇게 보완하겠습니다 */}
|
||||
<p className="ui-ask ui-ask-block mb-5">
|
||||
홈페이지를 고치지 않고, AI가 인용할 수 있는 사이트를 하나 더 만들어 아래를 보완하겠습니다.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
{weak.map((w, i) => (
|
||||
<motion.div
|
||||
key={w.id}
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="on-light bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-3">
|
||||
<span className="ui-note">{w.name}</span>
|
||||
<span className="font-serif text-2xl font-black text-[#1D0024]">{w.pct}<span className="ui-note">%</span></span>
|
||||
</div>
|
||||
<p className="ui-note mb-1">지금</p>
|
||||
<h3 className="font-bold text-[#1D0024] mb-3">{w.now}</h3>
|
||||
<p className="ui-note mb-1">보완</p>
|
||||
<p className="ui-body">{w.fix}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3. 빌드가 대신할 수 없는 것 */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 mb-8">
|
||||
<div className="flex items-center gap-2 mb-3 text-[#F5E0C5]">
|
||||
<WarningFilled size={15} />
|
||||
<h3 className="text-base font-bold">이 빌드가 대신할 수 없는 것</h3>
|
||||
</div>
|
||||
<p className="ui-body mb-3">
|
||||
이 빌드는 별도 사이트를 만들 뿐, 병원 홈페이지를 고치지 않습니다.{' '}
|
||||
진단의 실행 항목 {result.actions.length}건은 그대로 병원 홈페이지에서 하셔야 합니다.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{p0.map((a) => (
|
||||
<li key={a.title} className="ui-note"><span className="font-semibold text-[#F5E0C5] mr-1.5">P0</span>{a.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 4. 실행
|
||||
버튼 배경은 밝은 보라로 둔다. 기본 CTA 그라디언트(#4F1DA1→#021341)는 흰 배경용이라
|
||||
다크 섹션(#0A1128) 위에 올리면 배경에 묻혀 버튼으로 읽히지 않는다.
|
||||
문구에 "결과"를 쓰지 않는다. 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니다. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{checking ? (
|
||||
<span className="ui-note">빌드 상태를 확인하는 중입니다.</span>
|
||||
) : existing ? (
|
||||
<>
|
||||
<button type="button" onClick={() => navigate(`/build/${result.id}`)}
|
||||
className="btn-on-dark">
|
||||
{inFlight ? '만드는 과정 보기' : '만들어진 사이트 보기'} <ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
{!inFlight && (
|
||||
<button type="button" onClick={() => void approve()} disabled={busy}
|
||||
className="rounded-full bg-white/10 border border-white/20 text-white font-semibold px-6 py-4 text-base hover:bg-white/15 transition-colors disabled:opacity-50">
|
||||
{busy ? '시작하는 중' : '다시 빌드하기'}
|
||||
</button>
|
||||
)}
|
||||
<span className="ui-note">{STATE_LABEL[existing]}</span>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => void approve()} disabled={busy}
|
||||
className="btn-on-dark">
|
||||
<CheckFilled size={18} />{busy ? '시작하는 중' : '이대로 빌드하기'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-[#F5D5DC] text-base mt-4">시작하지 못했습니다. {error}</p>}
|
||||
<p className="ui-note mt-5">만들어진 사이트는 검색에 노출되지 않는 상태로 올라갑니다. 공개 여부는 병원이 확인한 뒤에 정합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -40,7 +40,6 @@ const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{
|
||||
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
|
||||
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'diet_guide', title: '5b. 수술 전후 식이 가이드', hint: '의료관광 연결의 음식점 추천에 그대로 실립니다. 개별 식당의 적합 여부는 저희가 판정하지 않고, 여기 적힌 분류만 표시합니다.', inputs: [{ name: 'recommended', label: '권장 음식 분류 (회복 단계별)', type: 'textarea' }, { name: 'avoid', label: '피해야 할 음식 분류', type: 'textarea' }, { name: 'stageDays', label: '각 단계가 수술 후 며칠인지' }] },
|
||||
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },
|
||||
@ -97,12 +96,8 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">SUPPORTERS BUILD · 병원 확인 항목</div>
|
||||
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">서포터즈 사이트 미리보기와 병원이 확인해 주실 것</h2>
|
||||
<p className="ui-body mb-4">
|
||||
병원 홈페이지 공개 자료만으로 만든 초안입니다.{' '}
|
||||
입력 전까지는 사이트에 "확인 대기"로 표시되고, 의학 검토와 게시 승인은 병원이 확정합니다.
|
||||
</p>
|
||||
<p className="ui-ask ui-ask-block mb-10">
|
||||
아래 항목은 공개 자료에 없어 병원만 답할 수 있는 값입니다. 채워 주세요.
|
||||
<p className="text-[17px] leading-[1.7] text-slate-500 mb-10 break-keep">
|
||||
병원 홈페이지 공개 자료만으로 만든 초안입니다. 아래 항목은 자료에 없어 병원만 답할 수 있는 값이며, 입력 전까지는 사이트에 "확인 대기"로 표시됩니다. 의학 검토와 게시 승인은 병원이 확정합니다.
|
||||
</p>
|
||||
|
||||
{/* 빌드 상태 */}
|
||||
@ -112,12 +107,12 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
||||
{build && (
|
||||
<div className="grid md:grid-cols-[1fr_auto] gap-6 items-start">
|
||||
<div>
|
||||
<p className="ui-note mb-1">상태</p>
|
||||
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="ui-note font-medium ml-2">단계 {build.phase}</span> : null}</p>
|
||||
<p className="ui-note mt-1">갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
|
||||
<p className="text-sm text-slate-500 mb-1">상태</p>
|
||||
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="text-sm font-medium text-slate-500 ml-2">단계 {build.phase}</span> : null}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
|
||||
</div>
|
||||
{build.preview_url && (
|
||||
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">{build.status === 'published' || build.status === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}</a>
|
||||
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">미리보기 열기 (noindex)</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -139,7 +134,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
||||
<h3 className="font-bold text-lg text-primary-900 break-keep">{f.title}</h3>
|
||||
<span className={`shrink-0 text-xs font-bold px-2.5 py-1 rounded-full ${cur ? 'bg-[#E9F7EF] text-[#2E7D4F]' : 'bg-[#FFF4E5] text-[#B26A00]'}`}>{cur ? `입력됨 ${String(current(f.key)?.created_at ?? '').slice(0, 10)}` : '확인 대기'}</span>
|
||||
</div>
|
||||
<p className="ui-body mb-4">{f.hint}</p>
|
||||
<p className="text-sm text-slate-500 mb-4 break-keep">{f.hint}</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{f.inputs.map((inp) => {
|
||||
const value = draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '');
|
||||
@ -179,7 +174,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
||||
{build?.report?.channels && (
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
|
||||
<h3 className="font-bold text-lg text-primary-900 mb-1">발견한 채널</h3>
|
||||
<p className="ui-note mb-3">홈페이지·웹검색·레지스트리에서 찾아 병원 것인지 확인한 채널입니다. "확인 필요"는 병원이 맞는지 알려 주셔야 사이트에 연결합니다.</p>
|
||||
<p className="text-sm text-slate-500 mb-3 break-keep">홈페이지·웹검색·레지스트리에서 찾아 병원 것인지 확인한 채널입니다. "확인 필요"는 병원이 맞는지 알려 주셔야 사이트에 연결합니다.</p>
|
||||
<ul className="text-sm flex flex-col gap-2">
|
||||
{(['youtube', 'instagram', 'facebook', 'naverBlog', 'tiktok', 'kakao', 'gangnamunni', 'naverPlace', 'babitalk'] as const).map((p) => {
|
||||
const ok = build.report?.channels?.confirmed[p] ?? [];
|
||||
@ -199,15 +194,15 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
||||
)}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
|
||||
<h3 className="font-bold text-lg text-primary-900 mb-1">의료진 ID</h3>
|
||||
<p className="ui-note mb-3">담당 원장·검토자 입력에 쓰는 ID 입니다. 홈페이지 의료진 페이지에서 읽었습니다.</p>
|
||||
<p className="text-sm text-slate-500 mb-3 break-keep">담당 원장·검토자 입력에 쓰는 ID 입니다. 홈페이지 의료진 페이지에서 읽었습니다.</p>
|
||||
{physicians.length ? (
|
||||
<ul className="text-sm text-slate-700 flex flex-col gap-1">{physicians.map((p) => <li key={p.id}><span className="font-mono text-accent">{p.id}</span> · {p.name} {p.title}</li>)}</ul>
|
||||
) : <p className="ui-note">빌드 후 표시됩니다.</p>}
|
||||
) : <p className="text-sm text-slate-400">빌드 후 표시됩니다.</p>}
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
|
||||
<h3 className="font-bold text-lg text-primary-900 mb-1">2. 글별 의학 검토 · 게시 승인</h3>
|
||||
<p className="ui-note mb-4">검토가 끝난 글만 승인할 수 있고, 승인된 글만 색인 허용 빌드에 실립니다. 승인자는 의료기관 개설자(대표원장) 이름과 직함으로 적습니다.</p>
|
||||
{posts.length === 0 && <p className="ui-note">빌드 후 글 목록이 표시됩니다.</p>}
|
||||
<p className="text-sm text-slate-500 mb-4 break-keep">검토가 끝난 글만 승인할 수 있고, 승인된 글만 색인 허용 빌드에 실립니다. 승인자는 의료기관 개설자(대표원장) 이름과 직함으로 적습니다.</p>
|
||||
{posts.length === 0 && <p className="text-sm text-slate-400">빌드 후 글 목록이 표시됩니다.</p>}
|
||||
<div className="flex flex-col gap-4">
|
||||
{posts.map((p) => {
|
||||
const rv = current('post_review', p.id)?.value as { reviewer?: string; reviewedAt?: string } | undefined;
|
||||
|
||||
@ -12,7 +12,7 @@ interface DiscoveryCtaProps {
|
||||
|
||||
export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaProps) {
|
||||
return (
|
||||
<section className="on-dark bg-[#0A1128] px-6 py-24">
|
||||
<section className="bg-[#0A1128] px-6 py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@ -30,7 +30,7 @@ export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaPro
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
className="on-light 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"
|
||||
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"
|
||||
>
|
||||
URL 입력으로 시작하기
|
||||
</button>
|
||||
|
||||
@ -69,7 +69,7 @@ export default function DiscoveryHero({
|
||||
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"
|
||||
>
|
||||
고객은 이제 AI에게 병원을 묻습니다.
|
||||
고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.
|
||||
<br className="hidden md:block" /> 답변에 인용될 근거를 {criteriaCount}개 항목으로 실측해
|
||||
답변 준비도와 출처 준비도로 진단하고, 8주 안에 구축합니다.
|
||||
</motion.p>
|
||||
|
||||
@ -93,7 +93,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="on-dark fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
data-no-print
|
||||
>
|
||||
@ -102,7 +102,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="on-light relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
|
||||
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@ -134,7 +134,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-primary-900 mb-2">신청이 접수되었습니다</h3>
|
||||
<p className="ui-body mb-6">
|
||||
<p className="text-sm leading-relaxed text-slate-600 mb-6">
|
||||
입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다.
|
||||
<br />
|
||||
문의: {DISCOVERY_CONTACT_EMAIL}
|
||||
@ -150,7 +150,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3 className="text-xl font-bold text-primary-900 mb-1.5">URL 입력으로 시작하기</h3>
|
||||
<p className="ui-body mb-6">
|
||||
<p className="text-sm leading-relaxed text-slate-600 mb-6">
|
||||
병원 홈페이지 URL만 입력하면 AI 검색 인용 가능성을 실측 진단합니다. 진단은
|
||||
무료입니다.
|
||||
</p>
|
||||
@ -175,7 +175,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
value={clinicName}
|
||||
onChange={(e) => setClinicName(e.target.value)}
|
||||
placeholder="뷰성형외과"
|
||||
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
|
||||
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||
@ -186,7 +186,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="marketing@clinic.com"
|
||||
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
|
||||
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
|
||||
/>
|
||||
|
||||
{fieldError && (
|
||||
|
||||
@ -1,410 +0,0 @@
|
||||
/**
|
||||
* 이미지 확인 패널 — 서포터즈 사이트에 쓸 사진을 병원이 한 장씩 고른다.
|
||||
* - 읽기: supporter_builds(최신 1건).report.images · supporter_inputs(key='image_review')
|
||||
* - 쓰기: supporter_inputs insert (anon RLS). 저장하면 워커가 다음 빌드에서 제외 목록을 반영한다.
|
||||
* - 사진 파일은 미리보기 사이트(preview_url)에서 불러온다. 배포 전 빌드는 볼 수 없다.
|
||||
* - 대상은 병원 담당자다. 제작 과정이나 내부 규칙은 쓰지 않고, 무엇을 판단하면 되는지만 둔다.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons';
|
||||
|
||||
type Role = 'hero' | 'used' | 'stored';
|
||||
type ImageItem = {
|
||||
id: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
category: 'clinic' | 'procedure' | 'equipment' | 'doctors';
|
||||
width: number;
|
||||
height: number;
|
||||
sourceUrl: string;
|
||||
pages: string[];
|
||||
role: Role;
|
||||
};
|
||||
type Build = {
|
||||
id: string;
|
||||
status: string;
|
||||
preview_url: string | null;
|
||||
updated_at: string;
|
||||
report: { images?: { capturedAt: string; total: number; used: number; items: ImageItem[] } } | null;
|
||||
};
|
||||
type Decision = 'use' | 'exclude';
|
||||
|
||||
const ROLE_LABEL: Record<Role, string> = {
|
||||
hero: '첫 화면 대표 사진',
|
||||
used: '사이트에 실립니다',
|
||||
stored: '아직 쓰이지 않습니다',
|
||||
};
|
||||
const CATEGORY_LABEL: Record<ImageItem['category'], string> = {
|
||||
clinic: '병원 공간',
|
||||
procedure: '시술 설명',
|
||||
equipment: '장비',
|
||||
doctors: '의료진',
|
||||
};
|
||||
|
||||
/** 병원이 판단할 때 떠올릴 것. 법 조항이 아니라 눈으로 확인할 수 있는 말로 둔다. */
|
||||
const CHECKPOINTS = [
|
||||
'환자분의 얼굴이나 몸이 알아볼 수 있게 나오나요',
|
||||
'수술 전과 후를 나란히 놓고 비교한 사진인가요',
|
||||
'주사·절개처럼 시술하는 장면이 담겼나요',
|
||||
'효과나 성능을 숫자·화살표로 내세운 그림인가요',
|
||||
'상장·수상·인증 마크가 들어 있나요',
|
||||
];
|
||||
|
||||
export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
|
||||
const [build, setBuild] = useState<Build | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [inputBy, setInputBy] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
const [b, inputs] = await Promise.all([
|
||||
supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
|
||||
supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }),
|
||||
]);
|
||||
if (!alive) return;
|
||||
if (b.error) setLoadError(b.error.message);
|
||||
setBuild((b.data?.[0] as Build) ?? null);
|
||||
// 같은 key 는 최신 행이 유효값이다.
|
||||
const last = inputs.data?.[inputs.data.length - 1]?.value as { decisions?: Record<string, Decision> } | undefined;
|
||||
if (last?.decisions) setDecisions(last.decisions);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [clinicId]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list = build?.report?.images?.items ?? [];
|
||||
// 첫 화면 대표 사진과 실제로 실리는 사진을 먼저 본다.
|
||||
const rank: Record<Role, number> = { hero: 0, used: 1, stored: 2 };
|
||||
return [...list].sort((a, b) => rank[a.role] - rank[b.role]);
|
||||
}, [build]);
|
||||
|
||||
const decidedCount = items.filter((i) => decisions[i.id]).length;
|
||||
const excluded = items.filter((i) => decisions[i.id] === 'exclude');
|
||||
const done = items.length > 0 && decidedCount === items.length;
|
||||
const current = items[cursor];
|
||||
const previewUrl = build?.preview_url ?? '';
|
||||
|
||||
function decide(id: string, d: Decision) {
|
||||
setDecisions((p) => ({ ...p, [id]: d }));
|
||||
setSaved(null);
|
||||
setCursor((c) => Math.min(c + 1, items.length - 1));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const { error } = await supabase.from('supporter_inputs').insert({
|
||||
clinic_id: clinicId,
|
||||
key: 'image_review',
|
||||
value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() },
|
||||
input_by: inputBy.trim() || null,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) { setSaveError(error.message); return; }
|
||||
setSaved(new Date().toISOString().slice(0, 16).replace('T', ' '));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="on-light bg-white py-20 px-6">
|
||||
<div className="max-w-7xl mx-auto"><p className="text-slate-500">사진을 불러오는 중입니다.</p></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items.length || !previewUrl) {
|
||||
return (
|
||||
<section className="on-light bg-white py-20 px-6 [word-break:keep-all]">
|
||||
<div className="on-light max-w-3xl mx-auto bg-white border border-slate-200 rounded-2xl p-8 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
|
||||
<h2 className="font-bold text-xl text-primary-900 mb-2">아직 확인하실 사진이 없습니다</h2>
|
||||
<p className="ui-body">
|
||||
{loadError
|
||||
? `자료를 불러오지 못했습니다. ${loadError}`
|
||||
: !previewUrl
|
||||
? '사이트 초안이 준비되면 이 화면에서 사진을 한 장씩 확인하실 수 있습니다.'
|
||||
: '홈페이지에서 모은 사진이 아직 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="on-light bg-white py-16 md:py-20 px-6 [word-break:keep-all]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 진행 */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-3xl md:text-4xl font-bold text-[#0A1128]">Photo Check</h2>
|
||||
<p className="ui-ask ui-ask-block mt-3">
|
||||
홈페이지에서 모은 사진 {items.length}장입니다.{' '}
|
||||
서포터즈 사이트에 실어도 괜찮은 사진인지 한 장씩 봐 주세요.
|
||||
</p>
|
||||
{build?.report?.images?.used ? (
|
||||
<p className="ui-body mt-2">이 중 {build.report.images.used}장이 지금 사이트에 실립니다.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="ui-note">확인</p>
|
||||
<p className="text-2xl font-bold text-[#1D0024]">{decidedCount}<span className="text-slate-400 text-lg"> / {items.length}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-slate-100 overflow-hidden mb-10">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#021341]"
|
||||
animate={{ width: `${(decidedCount / items.length) * 100}%` }}
|
||||
transition={{ duration: 0.35 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-8 items-start">
|
||||
{/* 한 장씩 보기 */}
|
||||
<div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current.id}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
|
||||
>
|
||||
<div className="bg-slate-50 flex items-center justify-center p-4">
|
||||
<img
|
||||
src={`${previewUrl}${current.src}`}
|
||||
alt={current.alt}
|
||||
className="max-h-[420px] w-auto object-contain rounded-lg"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 md:p-7">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<RoleBadge role={current.role} />
|
||||
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">{CATEGORY_LABEL[current.category]}</span>
|
||||
</div>
|
||||
<h3 className="ui-ask text-lg mb-1.5">이 사진을 사이트에 써도 될까요?</h3>
|
||||
<p className="ui-body mb-1">
|
||||
{current.alt || '사진에 붙은 설명이 없습니다.'}
|
||||
</p>
|
||||
<p className="ui-note">
|
||||
출처 {current.pages[0] ?? '홈페이지'}{current.pages.length > 1 ? ` 외 ${current.pages.length - 1}곳` : ''}
|
||||
{' · '}
|
||||
<a href={current.sourceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4 text-slate-500">
|
||||
원본 열기 <LinkExternalFilled size={12} />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide(current.id, 'use')}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
|
||||
decisions[current.id] === 'use'
|
||||
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
}`}
|
||||
>
|
||||
<CheckFilled size={16} /> 사용합니다
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide(current.id, 'exclude')}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
|
||||
decisions[current.id] === 'exclude'
|
||||
? 'bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
}`}
|
||||
>
|
||||
<CrossFilled size={16} /> 빼 주세요
|
||||
</button>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCursor((c) => Math.max(c - 1, 0))}
|
||||
disabled={cursor === 0}
|
||||
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-sm text-slate-400 tabular-nums px-1">{cursor + 1} / {items.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCursor((c) => Math.min(c + 1, items.length - 1))}
|
||||
disabled={cursor === items.length - 1}
|
||||
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 전체 훑기 */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3">
|
||||
<h3 className="text-base font-bold text-[#1D0024]">다른 사진으로 이동</h3>
|
||||
<p className="ui-note">
|
||||
{items.length - decidedCount > 0
|
||||
? <>아직 <strong className="text-[#1D0024]">{items.length - decidedCount}장</strong>이 남았습니다</>
|
||||
: '모두 확인하셨습니다'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{items.map((i, idx) => {
|
||||
const d = decisions[i.id];
|
||||
const isCur = idx === cursor;
|
||||
return (
|
||||
<button
|
||||
key={i.id}
|
||||
type="button"
|
||||
onClick={() => setCursor(idx)}
|
||||
aria-label={`${idx + 1}번째 사진으로 이동${d ? (d === 'use' ? ', 사용으로 정함' : ', 빼기로 정함') : ', 아직 안 정함'}`}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
title={i.alt || `${idx + 1}번째 사진`}
|
||||
className={`group relative w-[72px] h-[72px] rounded-xl overflow-hidden bg-slate-100 cursor-pointer
|
||||
transition-all duration-150 outline-none
|
||||
hover:-translate-y-0.5 hover:shadow-[3px_5px_14px_rgba(0,0,0,0.14)]
|
||||
focus-visible:ring-2 focus-visible:ring-[#6C5CE7] focus-visible:ring-offset-2
|
||||
${isCur
|
||||
? 'ring-2 ring-[#6C5CE7] ring-offset-2 shadow-[3px_4px_12px_rgba(108,92,231,0.28)]'
|
||||
: 'ring-1 ring-slate-200 hover:ring-[#9B8AD4]'}`}
|
||||
>
|
||||
<img
|
||||
src={`${previewUrl}${i.src}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={`w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.06]
|
||||
${d === 'exclude' ? 'grayscale opacity-45' : ''}`}
|
||||
/>
|
||||
|
||||
{/* 순번. 어디까지 봤는지 세기 쉽게 한다 */}
|
||||
<span className="on-dark absolute top-1 left-1 min-w-[18px] h-[18px] px-1 rounded-md bg-[#0A1128]/65 text-white text-[11px] font-semibold leading-[18px] text-center">
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* 정한 것 */}
|
||||
{d === 'exclude' && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="w-6 h-6 rounded-full bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] flex items-center justify-center">
|
||||
<CrossFilled size={12} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{d === 'use' && (
|
||||
<span className="absolute bottom-1 right-1 w-5 h-5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5] text-[#4A3A7C] flex items-center justify-center">
|
||||
<CheckFilled size={11} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 아직 안 정한 것은 아래에 띠를 둬서 남은 일이 보이게 한다 */}
|
||||
{!d && <span className="absolute inset-x-0 bottom-0 h-1 bg-[#D4A872]" />}
|
||||
|
||||
{/* 현재 보고 있는 것 */}
|
||||
{isCur && (
|
||||
<span className="absolute inset-x-0 bottom-0 bg-[#6C5CE7] text-white text-[10px] font-bold text-center py-0.5">
|
||||
보는 중
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ui-note flex flex-wrap items-center gap-x-4 gap-y-1 mt-3">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="w-3 h-1 rounded-full bg-[#D4A872]" /> 아직 안 정함</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5]" /> 사용</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#FFF0F0] border border-[#F5D5DC]" /> 빼기</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 판단 기준 + 제출 */}
|
||||
<div className="flex flex-col gap-5 lg:sticky lg:top-24">
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||
<div className="flex items-center gap-2 mb-3 text-[#7C5C3A]">
|
||||
<WarningFilled size={16} />
|
||||
<h3 className="text-base font-bold">이런 사진이면 빼 주세요</h3>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{CHECKPOINTS.map((c) => (
|
||||
<li key={c} className="ui-body !text-[#1D0024] flex gap-2">
|
||||
<span className="mt-[9px] w-1.5 h-1.5 rounded-full bg-[#D4A872] shrink-0" />
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="ui-ask text-[15px] mt-4">판단이 서지 않으면 빼는 쪽으로 봐 주세요.{' '}<span className="ui-note">나중에 다시 넣을 수 있습니다.</span></p>
|
||||
</div>
|
||||
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||
<h3 className="text-base font-bold text-[#1D0024] mb-3">확인 결과</h3>
|
||||
<dl className="flex flex-col gap-1.5 text-[15px]">
|
||||
<div className="flex justify-between"><dt className="text-slate-500">사용</dt><dd className="font-bold text-[#4A3A7C]">{decidedCount - excluded.length}장</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-500">빼기</dt><dd className="font-bold text-[#7C3A4B]">{excluded.length}장</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-500">남음</dt><dd className="font-bold text-slate-400">{items.length - decidedCount}장</dd></div>
|
||||
</dl>
|
||||
<label className="flex flex-col gap-1.5 mt-5">
|
||||
<span className="text-sm text-slate-600">확인하신 분 (이름·직함)</span>
|
||||
<input
|
||||
value={inputBy}
|
||||
onChange={(e) => setInputBy(e.target.value)}
|
||||
placeholder="예: 김담당 실장"
|
||||
className="border border-slate-200 rounded-lg px-3 py-2 text-base"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving || decidedCount === 0}
|
||||
className="w-full mt-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-6 py-3 text-base disabled:opacity-40"
|
||||
>
|
||||
{saving ? '보내는 중' : done ? '확인 결과 보내기' : `여기까지 보내기 (${decidedCount}장)`}
|
||||
</button>
|
||||
{saved && (
|
||||
<p className="text-sm text-[#4A3A7C] mt-3 flex items-start gap-1.5">
|
||||
<span className="mt-0.5"><CheckFilled size={13} /></span>
|
||||
<span>{saved} 에 보냈습니다. 빼기로 하신 사진은 다음 초안부터 빠집니다.</span>
|
||||
</p>
|
||||
)}
|
||||
{saveError && <p className="text-sm text-[#7C3A4B] mt-3">보내지 못했습니다. {saveError}</p>}
|
||||
{!done && decidedCount > 0 && !saved && (
|
||||
<p className="ui-note mt-3">지금 보내고 나중에 이어서 보셔도 됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="on-light inline-flex items-center justify-center gap-2 rounded-full bg-white border border-slate-200 text-[#021341] font-semibold px-6 py-3 text-base"
|
||||
>
|
||||
<EyeFilled size={15} /> 사이트 초안 열기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: Role }) {
|
||||
const style: Record<Role, string> = {
|
||||
hero: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]',
|
||||
used: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
||||
stored: 'bg-slate-100 text-slate-500 border-slate-200',
|
||||
};
|
||||
return <span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${style[role]}`}>{ROLE_LABEL[role]}</span>;
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 개선 제안(/actions/:id) 2부: 하지 않을 것 + 측정 조건.
|
||||
* "이 개선안이 무엇을 안 파는지"라 처방 쪽 내용이다. 진단 리포트에서 옮겨왔다.
|
||||
*/
|
||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||
import { CheckFilled, CrossFilled } from '../icons/FilledIcons';
|
||||
import type { DiscoveryResult } from '../../types/discovery';
|
||||
|
||||
export function IntegritySection({ result }: { result: DiscoveryResult }) {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="integrity"
|
||||
title="What We Don't Sell"
|
||||
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
|
||||
dark
|
||||
>
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="on-light 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="ui-note mt-0.5">{n.reason}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="on-light 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="ui-body">{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>
|
||||
);
|
||||
}
|
||||
@ -89,7 +89,7 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
|
||||
<div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[ax.grade]}`}>
|
||||
<span className="text-sm font-bold">{ax.code} 등급 {ax.grade}</span>
|
||||
</div>
|
||||
<span className="ui-note">{ax.name}</span>
|
||||
<span className="text-sm text-slate-500">{ax.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -161,7 +161,7 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
|
||||
<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="ui-note leading-[1.7]">{finding.detail}</p>
|
||||
<p className="text-sm leading-[1.7] text-slate-500 break-keep">{finding.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 작은 라벨 배지. 진단 리포트(Scorecard의 근거/통제/채점 태그)와 개선 제안(항목 ID 태그)
|
||||
* 양쪽에서 쓰여 공용 파일로 뺐다.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@ -28,7 +28,7 @@ const STATS = [
|
||||
|
||||
export default function WhyNow() {
|
||||
return (
|
||||
<section id="why-now" className="on-dark bg-[#0A1128] px-6 py-22">
|
||||
<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 }}
|
||||
|
||||
@ -324,76 +324,3 @@ export function PrismFilled({ size = 20, className = '' }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 의료관광 연결 (숙박·식사·웰니스·관광·축제) ── */
|
||||
|
||||
export function BedFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="2" y="10" width="20" height="8" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="6" width="16" height="5" rx="2" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="7.5" cy="9" r="2" fill="currentColor" />
|
||||
<rect x="2" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
<rect x="19" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForkFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 4v6a2 2 0 0 0 2 2v8h1.5v-8a2 2 0 0 0 2-2V4H12v5h-1V4H9.5v5h-1V4H8z" fill="currentColor" />
|
||||
<path d="M17 4c-1.4 1-2 2.8-2 4.5 0 1.4.6 2.4 1.5 2.9V20H18V4h-1z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 5c2.2 1.8 3.4 4 3.4 6.2 0 1.3-.4 2.4-1.1 3.2C13.4 13.2 12.7 11.4 12 9c-.7 2.4-1.4 4.2-2.3 5.4-.7-.8-1.1-1.9-1.1-3.2C8.6 9 9.8 6.8 12 5z" fill="currentColor" />
|
||||
<ellipse cx="12" cy="17.5" rx="5.5" ry="1.6" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="9" r="3.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5V7z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11.2" y="7" width="1.6" height="3" rx="0.8" fill="currentColor" />
|
||||
<rect x="11.2" y="12" width="1.6" height="5" rx="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BagFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M4 8h16l-1.2 12H5.2L4 8z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8.5 9V7a3.5 3.5 0 1 1 7 0v2h-2V7a1.5 1.5 0 1 0-3 0v2h-2z" fill="currentColor" />
|
||||
<rect x="4" y="7" width="16" height="2.4" rx="1.2" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TheaterFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="4" width="18" height="14" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<path d="M7 9.5h10v2H7zM7 13h6v2H7z" fill="currentColor" />
|
||||
<rect x="8" y="18" width="8" height="2" rx="1" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export function SectionWrapper({
|
||||
id={id}
|
||||
className={`
|
||||
${dark
|
||||
? 'on-dark bg-[#0A1128] text-white relative overflow-hidden'
|
||||
? 'bg-[#0A1128] text-white relative overflow-hidden'
|
||||
: 'bg-white'
|
||||
}
|
||||
py-16 md:py-20 px-6
|
||||
|
||||
@ -6,11 +6,9 @@ import type { DiscoveryResult } from '../types/discovery';
|
||||
import { DISCOVERY_VIEWCLINIC } from './discovery_viewclinic';
|
||||
import { DISCOVERY_TAEHA } from './discovery_taeha';
|
||||
import { DISCOVERY_WONJIN } from './discovery_wonjin';
|
||||
import { DISCOVERY_JKPLASTIC } from './discovery_jkplastic';
|
||||
|
||||
export const DISCOVERY_RESULTS: Record<string, DiscoveryResult> = {
|
||||
viewclinic: DISCOVERY_VIEWCLINIC,
|
||||
taeha: DISCOVERY_TAEHA,
|
||||
wonjin: DISCOVERY_WONJIN,
|
||||
jkplastic: DISCOVERY_JKPLASTIC,
|
||||
};
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
/**
|
||||
* JK성형외과 영문 사이트 (www.jkplastic.com/en/) AEO/GEO 진단
|
||||
* 자동 채점: audit_aeo_geo.py · 2026-09-20 · JS 미실행 HTML 기준
|
||||
* 사람 보정: 2026-09-20 · 영문 페이지 11개 + 국문 의료진 1개 실측
|
||||
*
|
||||
* 영문 기준으로 본다. 국문 사이트는 별도 진단 대상이며 여기 점수에 섞지 않는다.
|
||||
* 자동 채점기의 수치·NAP·alt 정규식은 국문 표현을 전제해 만들어져 영문 페이지에서
|
||||
* B4·C4·F1 세 항목을 과소 판정했다. 아래는 원본 HTML을 다시 읽어 보정한 값이다.
|
||||
*/
|
||||
import type { DiscoveryResult } from '../types/discovery';
|
||||
|
||||
export const DISCOVERY_JKPLASTIC: DiscoveryResult = {
|
||||
id: 'jkplastic',
|
||||
clinicName: 'JK성형외과',
|
||||
url: 'https://www.jkplastic.com/en/',
|
||||
industry: '성형외과 (강남 압구정) · 영문 사이트 기준',
|
||||
auditedAt: '2026-09-20',
|
||||
rubricVersion: '1.0',
|
||||
conditions: [
|
||||
'실측 대상: 영문 페이지 11개 (홈·의료진·오시는길·JK스토리·연혁·안전·쌍꺼풀·코·모티바·사각턱·수술후기) + 대조용 국문 의료진 1개, JS 미실행 HTML 기준',
|
||||
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',
|
||||
'영문 기준 진단이다. 국문 사이트(jkplastic.com/)의 페이지·수치·표면은 채점에 넣지 않았다',
|
||||
'자동 채점기의 국문 전제 정규식이 놓친 B4·C4·F1은 영문 원본 HTML을 다시 읽어 보정했다',
|
||||
'semi/manual 항목 중 외부 실측이 가능한 C1·C4·C7·C8·D1·D3은 확정했고, 오너 계정이 필요한 A6·D2·D4·E1~E4는 미검증으로 두어 분모에서 뺐다',
|
||||
'답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)',
|
||||
],
|
||||
results: [
|
||||
// A. 접근성
|
||||
{ criterionId: 'A1', level: 3, evidence: '12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200', source: 'robots.txt: User-agent: * / Disallow: /admincenter/' },
|
||||
{ criterionId: 'A2', level: 3, evidence: 'Server 미표기 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음' },
|
||||
{ criterionId: 'A3', level: 3, evidence: '서버 렌더링(ASP). JS 미실행 HTML에서 영문 홈 본문 5,110자 추출. 시술 페이지도 3,692~7,667자로 본문이 HTML에 그대로 있다' },
|
||||
{ criterionId: 'A4', level: 1, evidence: '변형 간 최종 URL 불일치(중복 콘텐츠 위험): [\'https://jkplastic.com\', \'https://jkplastic.com/index.asp\', \'https://www.jkplastic.com\', \'https://www.jkplastic.com/index.asp\']' },
|
||||
{ criterionId: 'A5', level: 1, evidence: 'robots.txt에 sitemap 선언, 200 응답, URL 236개. 그러나 lastmod 최신값이 2024-05-28로 28개월 전이고, 영문 55개 URL 안에 의료진(/en/about-us/our-value/doctors.asp)과 오시는길(/en/about-us/medical-info/map.asp)이 빠져 있다. 두 페이지 모두 직접 요청하면 200이다. 영문 권위·위치 페이지가 발견 경로에서 누락된 상태', source: 'sitemap.xml 파싱 + 두 URL 직접 GET 200 (2026-09-20)' },
|
||||
{ criterionId: 'A6', level: 'unverified', evidence: '[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요' },
|
||||
{ criterionId: 'A7', level: 3, evidence: 'TTFB 0.147s / 초기 HTML 93KB. 영문 시술 페이지 67~83KB — 모두 기준 이내' },
|
||||
// B. 엔티티
|
||||
{ criterionId: 'B1', level: 0, evidence: 'JSON-LD 블록 없음. 영문 11개 페이지 전부 구조화 데이터 0' },
|
||||
{ criterionId: 'B2', level: 0, evidence: 'MedicalClinic·LocalBusiness 등 업종 엔티티 스키마 없음' },
|
||||
{ criterionId: 'B3', level: 0, evidence: 'Physician 스키마 없음. 의료진 학력·경력은 HTML 텍스트로만 존재' },
|
||||
{ criterionId: 'B4', level: 2, evidence: '영문 12개 페이지 중 11개가 지번 표기 "584-2 Sinsa-dong, Gangnam-gu, Seoul", 홈 푸터만 도로명 표기 "835, Nonhyeon-ro, Gangnam-gu, Seoul". 같은 위치의 2개 변형이다. 대표전화는 "(+82) 2-777-7797" 단일 표기이며 Whatsapp 번호(+82-10-9738-4042)는 용도가 달라 변형으로 세지 않았다. 자동 채점은 홈·시술 페이지만 보고 "미표기 0점"으로 판정했으나 오시는길 페이지에 완전한 NAP이 텍스트로 있다', source: '영문 11개 페이지 주소·전화 정규식 추출 (2026-09-20)' },
|
||||
{ criterionId: 'B5', level: 0, evidence: '스키마 자체가 없어 sameAs 부재. 푸터에 Facebook·YouTube·Instagram 링크가 있으나 스키마에 미연결' },
|
||||
{ criterionId: 'B6', level: 0, evidence: 'rel=canonical 없음. A4의 4변형 중복과 겹쳐 정본 URL 신호가 없다' },
|
||||
{ criterionId: 'B7', level: 0, evidence: '언어 버전 7종(ENG·KOR·CHN·JPN·RUS·ARAB·IND)을 헤더에서 전환하지만 hreflang 태그는 0개. 외국인 환자 질의가 주력인 사이트인데 언어판이 서로 연결되어 있지 않다' },
|
||||
// C. 콘텐츠
|
||||
{ criterionId: 'C1', level: 1, evidence: '본문 첫 200자가 전부 슬로건이다. 홈 "Live a life True to yourself / Mindful of your beauty", JK스토리 "Virtue We take the right path", 쌍꺼풀 "Simple Beauty is in the eye of the beholder". "JK Plastic Surgery is a plastic surgery clinic in Apgujeong, Seoul, specializing in ..." 형태의 정의문이 어느 핵심 페이지에도 없다. 상호·지역·전문분야를 한 문장에 담은 텍스트는 메타 타이틀에만 있다', source: '영문 홈·JK스토리·쌍꺼풀 본문 첫 200자 (2026-09-20)' },
|
||||
{ criterionId: 'C2', level: 0, evidence: 'FAQ·Q&A 섹션이 영문 11개 페이지에 없다. FAQPage 스키마도 없다. 후기 게시판(/en/boad/bd_free/5/)은 있으나 질문·답변 구조가 아니다' },
|
||||
{ criterionId: 'C3', level: 2, evidence: 'H1 1개: "JK PLASTIC". 다만 상호 자체가 H1이라 페이지 주제를 나타내지 않고, H2는 라벨형 중복이 있다' },
|
||||
{ criterionId: 'C4', level: 2, evidence: '시술 4개 중 3개에 스펙이 텍스트로 있다. 쌍꺼풀 "Operation Time 30-40 minutes / Stitch Removal 4days after the surgery / Hospitalization Not necessary", 모티바 "Operation Time 1.5 Hours / Stitch Removal 10 days", 사각턱 "Operation Time 1~1.5 hours / Stitch Removal 2 weeks / Avoid hard and tough food for 4 weeks". 코 성형 페이지는 수치 0. 가격은 영문 어느 시술 페이지에도 없다. 자동 채점이 "수치 0회"로 판정한 것은 국문 단위(분·일·원)만 찾는 정규식 때문이다', source: '영문 시술 4개 페이지 수치 맥락 추출 (2026-09-20)' },
|
||||
{ criterionId: 'C5', level: 1, evidence: 'HTML <table> 0개. 정의 리스트 <dl>은 모티바 6개·홈 4개가 있으나 내용이 제품 특징 설명(Shell·Silicone Gel·BluSeal®)이라 비교표가 아니다. 시술별 스펙(수술시간·실밥제거)은 표가 아닌 이미지와 div 레이아웃으로 배치되어 있다' },
|
||||
{ criterionId: 'C6', level: 0, evidence: '최신 갱신 신호 2024-05-28 (sitemap lastmod) — 28개월 전. dateModified·article:modified_time 마크업 없음. 영문 사이트에 갱신 시점을 알리는 신호가 하나도 없다' },
|
||||
{ criterionId: 'C7', level: 2, evidence: '영문 의료진 페이지(7,225자)에 대표원장 학력·전공·수련이 텍스트로 있다. "Joo Kwon / Plastic Surgeon / Representative Doctor / Graduated Catholic University Medical School (majored in Plastic Surgery) / Finished Intern·Resident at Seoul St. Mary\'s Hospital / Outpatient Professor of Plastic Surgery School of Catholic University". 학회·학술 언급 87회, 전문의·인증 표현 2회, "20 years of know-how" 경력 표기. 다만 Physician 스키마가 없어 구조화되지 않았고, 이 페이지가 sitemap에서 빠져 있다', source: '/en/about-us/our-value/doctors.asp 본문 실측 (2026-09-20)' },
|
||||
{ criterionId: 'C8', level: 2, evidence: '영문 10개 페이지의 60자 이상 단락 144개 중 10개를 표본 추출해 채점: 7/10(70%)이 시술명 또는 상호를 포함한다. 다만 상호(JK)를 포함한 단락은 10개 중 1개뿐이고 나머지는 시술명으로만 맞았다. 답변엔진이 단락을 인용해도 "어느 병원 이야기인지"가 문장 안에 남지 않는 구조다', source: '무작위 표본 10개 (seed 고정) 수동 채점 (2026-09-20)' },
|
||||
// D. 표면
|
||||
{ criterionId: 'D1', level: 2, evidence: '영문 기준 버티컬은 해외 의료관광 플랫폼이다. Bookimed(리뷰 9건 표기, 요약 평점 4.5/5)·Medijump·MediGlobus·PlacidWay·Clinics on Call·seoul.clinic에 프로필이 있고 가격·의료진·후기 항목이 채워져 있다. 다만 병원이 직접 관리하는 프로필인지, 갱신이 3개월 이내인지는 확인하지 못해 상위 단계로 올리지 않았다', source: '웹 검색 실측 (2026-09-20)' },
|
||||
{ criterionId: 'D2', level: 'unverified', evidence: '[manual] 2차 출처(seoul.clinic 리뷰 기사)에 Google 리뷰 약 80건·평점 4.5 언급이 있으나, 프로필 소유권·카테고리·NAP 일치는 Google Maps에서 직접 확인해야 한다. 2차 출처 수치로 판정하지 않는다' },
|
||||
{ criterionId: 'D3', level: 2, evidence: '자체 YouTube 채널(JK Plastic Surgery Center) 운영 중이고, 의료 전문지 기사(Korea Biomedical Review "In a way, JK plastic surgeons are artists")와 해외 의료관광 도메인 6곳의 소개 문서가 있다. 유튜브와 언론이 모두 있어 2단계. 다만 2025~2026년 신규 언론 노출은 검색에서 확인되지 않아, 언급이 과거에 쏠려 있다', source: '웹 검색 실측, naver.com 제외 (2026-09-20)' },
|
||||
{ criterionId: 'D4', level: 'unverified', evidence: '[manual] 표면 간 NAP 대조표는 D2(GBP) 실측이 선행되어야 작성할 수 있다. 사이트 내부 변형 2종은 B4 참조' },
|
||||
{ criterionId: 'D5', level: 'unverified', evidence: '[semi] 네이버 플레이스는 영문 사이트에서 링크되지 않고, 검색으로도 플레이스 프로필을 확인하지 못했다. 글로벌 AI 기여도가 0인 항목이라 영문 기준 진단에서 미검증으로 둔다. 국문 트랙에서 별도 실측한다' },
|
||||
{ criterionId: 'D6', level: 0, evidence: 'Wikidata에 "JK성형외과"·"JK Plastic Surgery" 항목 없음' },
|
||||
// E. 측정
|
||||
{ criterionId: 'E1', level: 'unverified', evidence: '[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)' },
|
||||
{ criterionId: 'E2', level: 'unverified', evidence: '[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)' },
|
||||
{ criterionId: 'E3', level: 'unverified', evidence: '[manual] GSC 접근 필요 (AI Overviews 노출)' },
|
||||
{ criterionId: 'E4', level: 'unverified', evidence: '[manual] 영문 질의 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)' },
|
||||
// F. 위생
|
||||
{ criterionId: 'F1', level: 1, evidence: '영문 11개 페이지 이미지 483장 중 142장(29%) alt 누락. 페이지 편차가 크다: 쌍꺼풀 36/67·홈 36/64가 절반 이상 누락인 반면 의료진 2/31·JK스토리 2/26은 양호. 자동 채점의 61%는 홈 한 페이지만 본 값이다', source: '영문 11개 페이지 img 태그 전수 파싱 (2026-09-20)' },
|
||||
{ criterionId: 'F2', level: 'unverified', evidence: 'meta generator 없음 — CMS 종류·버전 판정 근거 없음. ASP 기반으로 보이나 확정하지 않는다' },
|
||||
{ criterionId: 'F3', level: 3, evidence: '초기 HTML 93KB (300KB 미만)' },
|
||||
{ criterionId: 'F4', level: 2, evidence: 'viewport meta 존재, 반응형. CWV 미측정' },
|
||||
],
|
||||
keyFindings: [
|
||||
{
|
||||
title: 'AI는 이 사이트를 읽을 수 있습니다',
|
||||
detail: 'robots.txt가 AI 검색봇 12종을 모두 허용하고, 차단 계층도 없으며, 본문이 서버에서 렌더링되어 JS 없이도 페이지마다 3,700~7,700자가 그대로 읽힙니다. 응답 속도와 문서 크기도 기준 안입니다. 영문 답변에 잘 나오지 않는 원인은 접근 차단이 아니라 읽은 다음에 있습니다.',
|
||||
severity: 'good',
|
||||
},
|
||||
{
|
||||
title: '읽어도 어느 병원인지 확정할 근거가 없습니다',
|
||||
detail: '영문 11개 페이지 전부 구조화 데이터가 0입니다. MedicalClinic 스키마도, Physician 스키마도, canonical도, sameAs도 없습니다. 언어판을 7종 운영하면서 hreflang 태그가 0개라 영어·중국어·일본어 페이지가 서로 같은 병원이라는 연결도 없습니다. 외국인 환자 유치가 주력인 사이트에서 이 항목들이 비어 있습니다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: '문장이 병원 이름을 데리고 다니지 않습니다',
|
||||
detail: '단락 표본 10개 중 7개가 시술명을 담고 있어 내용 자체는 인용될 만합니다. 그런데 상호가 들어간 단락은 10개 중 1개뿐이고, 핵심 페이지의 첫 200자는 모두 슬로건입니다. 답변엔진이 단락을 가져가도 문장 안에 병원 이름이 남지 않아, 인용은 되고 이름은 안 나오는 상태가 됩니다.',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
title: '영문 권위 페이지가 발견 경로에서 빠져 있습니다',
|
||||
detail: '영문 의료진 페이지에는 대표원장의 출신 학교·전공·수련 병원·외래교수 이력과 학회 언급 87회가 텍스트로 있고, 오시는 길 페이지에는 주소·전화·진료시간이 완전한 형태로 있습니다. 두 페이지 모두 직접 열면 정상입니다. 그런데 sitemap의 영문 55개 URL 목록에 둘 다 없습니다. 권위와 위치를 증명할 자료가 준비되어 있는데 크롤러에게 알려지지 않고 있습니다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
title: '해외 플랫폼에는 있고, 사이트 갱신은 28개월째 멈춰 있습니다',
|
||||
detail: 'Bookimed·Medijump·MediGlobus·PlacidWay 등 해외 의료관광 플랫폼 6곳에 프로필이 있고 유튜브 채널과 전문지 기사도 있어, 오픈웹 표면 자체는 확보되어 있습니다. 반면 사이트의 최신 갱신 신호는 2024년 5월이 마지막이고 갱신 시점 마크업도 없습니다. 시술 페이지에 수술시간·실밥제거 같은 스펙은 있으나 가격은 한 건도 없어, 비용을 묻는 영문 질의에 답할 문장이 사이트 안에 없습니다.',
|
||||
severity: 'warning',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '영문 핵심 페이지에 MedicalClinic·Physician 스키마와 canonical을 넣습니다',
|
||||
detail: '오시는 길 페이지에 이미 텍스트로 있는 주소·전화·진료시간을 그대로 MedicalClinic 스키마(address·telephone·openingHoursSpecification·medicalSpecialty·geo)로 옮깁니다. 의료진 페이지의 학력·수련·외래교수 이력은 Physician 스키마로 구조화합니다. sameAs에 YouTube·Instagram·Facebook과 해외 플랫폼 프로필 URL을 연결하고, 전 페이지에 self-referencing canonical을 넣어 A4의 4변형 중복을 정리합니다.',
|
||||
criterionIds: ['B1', 'B2', 'B3', 'B5', 'B6', 'A4'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P0',
|
||||
title: '언어판 7종을 hreflang으로 연결합니다',
|
||||
detail: '헤더에서 전환만 되고 태그로는 연결되지 않은 ENG·KOR·CHN·JPN·RUS·ARAB·IND 7종에 상호 hreflang과 x-default를 넣습니다. 외국인 환자 질의는 언어별로 다른 표면에서 일어나므로, 언어판이 하나의 병원으로 묶여야 각 언어 답변에서 같은 엔티티로 인식됩니다.',
|
||||
criterionIds: ['B7'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'sitemap에 영문 의료진·오시는 길 페이지를 넣고 lastmod를 되살립니다',
|
||||
detail: '영문 55개 URL 목록에서 빠진 /en/about-us/our-value/doctors.asp 와 /en/about-us/medical-info/map.asp 를 추가합니다. 두 페이지는 이미 200으로 열립니다. 그리고 2024-05-28에 멈춘 lastmod를 실제 수정일로 갱신하고, 시술 페이지에 dateModified 마크업을 넣어 갱신 신호를 만듭니다.',
|
||||
criterionIds: ['A5', 'C6'],
|
||||
effort: '1일',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: '핵심 페이지 첫 단락을 슬로건에서 정의문으로 바꿉니다',
|
||||
detail: '홈·시술·소개 페이지의 첫 200자를 "JK Plastic Surgery is a plastic surgery clinic in Apgujeong, Gangnam, Seoul, specializing in ... , certified by the Korean Ministry of Health and Welfare as an outstanding medical institution for foreign patients." 형태로 바꿉니다. 시술 페이지 본문 단락에도 상호를 넣어, 인용된 문장 안에 병원 이름이 남게 합니다.',
|
||||
criterionIds: ['C1', 'C8'],
|
||||
effort: '2~4주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: '시술 페이지에 가격대와 FAQ를 텍스트로 넣습니다',
|
||||
detail: '수술시간·실밥제거·입원 여부는 이미 텍스트로 있습니다. 여기에 USD 가격대와 체류 기간을 더하고, 시술별 FAQ 5~7문항을 FAQPage 스키마와 함께 넣습니다. 스펙을 이미지·div가 아닌 HTML 표로 옮겨 비교 구조를 만듭니다. 코 성형 페이지는 수치가 하나도 없어 먼저 채웁니다.',
|
||||
criterionIds: ['C4', 'C2', 'C5'],
|
||||
effort: '2~4주',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'Google 비즈니스 프로필과 해외 플랫폼 프로필을 실측하고 NAP을 맞춥니다',
|
||||
detail: 'Google Maps에서 소유권·카테고리·리뷰·NAP을 직접 확인합니다. 2차 출처에 리뷰 약 80건·평점 4.5 언급이 있으나 이것으로 판정하지 않습니다. 확인 후 홈 푸터의 도로명 표기(835, Nonhyeon-ro)와 나머지 11개 페이지의 지번 표기(584-2 Sinsa-dong)를 한쪽으로 통일하고, 해외 플랫폼 6곳의 주소·전화도 같은 표기로 맞춥니다.',
|
||||
criterionIds: ['D2', 'D4', 'B4', 'D1'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: 'alt 누락 142장을 채웁니다',
|
||||
detail: '영문 11개 페이지 이미지 483장 중 142장이 alt 없이 들어가 있습니다. 쌍꺼풀 페이지 36장, 홈 36장이 집중 구간입니다. 시술 전후 사진과 시술 설명 이미지부터 채웁니다.',
|
||||
criterionIds: ['F1'],
|
||||
effort: '1주',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: '영문 질의 기준선을 측정합니다',
|
||||
detail: '영문 질의 10개를 5회씩 반복 조회해 언급률 기준선과 신뢰구간을 만듭니다. 서버 로그에서 AI 크롤러 히트를 IP 검증과 함께 집계하고, GA4에 AI 유입 커스텀 채널그룹을 만듭니다. 개선 전후 비교는 이 기준선이 있어야 말할 수 있습니다.',
|
||||
criterionIds: ['E1', 'E2', 'E3', 'E4'],
|
||||
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 크롤링을 전면 금지. 영문 질의 기여도 0이다.' },
|
||||
{ item: '국문 사이트 항목을 영문 점수에 합산', reason: '국문에만 있는 의료진 페이지·전화번호 6종은 영문 표면의 근거가 되지 않는다. 국문은 별도 진단으로 낸다.' },
|
||||
],
|
||||
};
|
||||
113
src/index.css
@ -20,22 +20,6 @@
|
||||
|
||||
--color-accent: #6C5CE7; /* Purple accent */
|
||||
|
||||
/* 흰·밝은 배경 위 강조 텍스트. haewon이 말하는 "인디고" */
|
||||
--color-indigo: #1D0024;
|
||||
|
||||
/* 텍스트 역할 토큰.
|
||||
ask = 요청문. 사람에게 무엇을 해달라고 하는 문장 (읽고 끝나는 설명과 구분한다)
|
||||
body = 설명 본문
|
||||
note = 보조 설명·출처 */
|
||||
--color-text-ask: #1D0024;
|
||||
--color-text-body: #475569;
|
||||
--color-text-note: #94A3B8;
|
||||
--color-ask-rule: #D5CDF5;
|
||||
|
||||
--text-ask: 1.0625rem; /* 17px. 본문(16px)보다 한 단 크게 */
|
||||
--text-body: 1rem; /* 16px. 기존 text-base 와 같다 */
|
||||
--text-note: 0.875rem; /* 14px. 기존 text-sm 과 같다 */
|
||||
|
||||
/* INFINITH Semantic Status Colors — pastel palette derived from brand gradient */
|
||||
/* Critical/Negative: warm rose (peach-toned) */
|
||||
--color-status-critical-bg: #FFF0F0;
|
||||
@ -174,99 +158,4 @@
|
||||
|
||||
.animation-delay-14000 {
|
||||
animation-delay: 14s;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
텍스트 역할 클래스
|
||||
|
||||
요청문(.ui-ask)과 설명문(.ui-body)을 눈에 띄게 가른다. 요청문은 사람이
|
||||
무엇을 해야 하는지 말하는 문장이라, 읽고 넘어가는 설명과 같은 무게로 두면
|
||||
지나친다. 색·굵기·크기를 함께 올리고 왼쪽에 규칙선을 둔다.
|
||||
|
||||
한글 줄바꿈:
|
||||
word-break: keep-all → 단어(어절) 중간에서 자르지 않는다
|
||||
overflow-wrap: anywhere → 긴 URL·영문 토큰은 예외적으로 자른다
|
||||
text-wrap: pretty → 마지막 줄에 한 어절만 남는 것을 줄인다
|
||||
max-width 62ch → 한 줄이 길어 눈이 되돌아오는 것을 막는다
|
||||
────────────────────────────────────────────────────────────── */
|
||||
.ui-ask,
|
||||
.ui-body,
|
||||
.ui-note {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.ui-ask {
|
||||
font-size: var(--text-ask);
|
||||
line-height: 1.75;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-ask);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
/* 문단으로 서는 요청문. 왼쪽 규칙선으로 설명 본문과 구분한다 */
|
||||
.ui-ask-block {
|
||||
border-left: 3px solid var(--color-ask-rule);
|
||||
padding-left: 0.875rem;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.75;
|
||||
color: var(--color-text-body);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.ui-note {
|
||||
font-size: var(--text-note);
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-note);
|
||||
}
|
||||
|
||||
/* 다크 섹션 위에서는 같은 역할을 밝은 값으로 뒤집는다 */
|
||||
.on-dark .ui-ask { color: #FFFFFF; }
|
||||
.on-dark .ui-ask-block { border-left-color: rgba(213, 205, 245, 0.45); }
|
||||
.on-dark .ui-body { color: rgba(255, 255, 255, 0.72); }
|
||||
.on-dark .ui-note { color: rgba(255, 255, 255, 0.45); }
|
||||
|
||||
/* 다크 섹션 안의 흰 카드는 다시 라이트 값으로 되돌린다.
|
||||
이걸 두지 않으면 흰 글씨가 흰 배경에 얹혀 보이지 않는다. 흰 카드에 .on-light 를 붙인다. */
|
||||
.on-dark .on-light .ui-ask,
|
||||
.on-light .ui-ask { color: var(--color-text-ask); }
|
||||
.on-dark .on-light .ui-ask-block,
|
||||
.on-light .ui-ask-block { border-left-color: var(--color-ask-rule); }
|
||||
.on-dark .on-light .ui-body,
|
||||
.on-light .ui-body { color: var(--color-text-body); }
|
||||
.on-dark .on-light .ui-note,
|
||||
.on-light .ui-note { color: var(--color-text-note); }
|
||||
|
||||
/* 제목도 어절 단위로 끊는다. 한글 제목이 글자 중간에서 잘리면 읽기 나쁘다 */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
word-break: keep-all;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* 다크 섹션(#0A1128) 위의 주 버튼.
|
||||
기본 CTA 그라디언트(#4F1DA1 → #021341)는 흰 배경용이라 다크 위에 올리면 배경에 묻힌다.
|
||||
밝은 보라 + 글로우로 떼어 놓는다. */
|
||||
.btn-on-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
background-image: linear-gradient(to right, #7C6DD8, #6C5CE7);
|
||||
box-shadow: 0 6px 28px rgba(108, 92, 231, 0.55);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.25);
|
||||
outline-offset: -1px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.btn-on-dark:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 34px rgba(108, 92, 231, 0.7);
|
||||
}
|
||||
.btn-on-dark:disabled { opacity: 0.6; }
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 서포터즈 사이트 빌드가 진단의 어느 약점을 보완하는지 고른다.
|
||||
*
|
||||
* 중요한 전제. 이 빌드는 병원 홈페이지를 한 줄도 고치지 않는다. 별도 사이트를 하나 만들 뿐이다.
|
||||
* 그래서 진단의 실행 항목(깨진 JSON-LD 수정·GBP 전화번호 등록·H1 재설계 등)은 단 하나도
|
||||
* 이 빌드로 해결되지 않는다. 전부 병원 몫이다.
|
||||
*
|
||||
* 빌드가 하는 일은 다르다. AI가 인용할 수 있는 새 표면을 하나 더 만들어, 홈페이지를 고치지 않고도
|
||||
* 답변·출처로 쓰일 텍스트를 확보한다. 그래서 "실행 항목 중 몇 건 해결"이 아니라
|
||||
* "어느 약점을 우회해서 보완하는가"로 말해야 한다.
|
||||
*/
|
||||
import type { CategoryId, CategoryScore } from '../types/discovery';
|
||||
|
||||
/** 빌드가 보완할 수 있는 카테고리. 크롤러 접근(A)·측정(E)·기술 위생(F)은 홈페이지 쪽이라 손댈 수 없다. */
|
||||
const ADDRESSABLE: CategoryId[] = ['content', 'entity', 'surface'];
|
||||
|
||||
/** 카테고리별로, 빌드가 무엇을 만들어 어떻게 보완하는지 */
|
||||
export const COVERAGE_PLAN: Record<string, { now: string; fix: string }> = {
|
||||
content: {
|
||||
now: '패시지 단위로 인용할 문장이 없습니다',
|
||||
fix: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 한 단락만 떼어내도 뜻이 통하는 글로 답합니다.',
|
||||
},
|
||||
entity: {
|
||||
now: '누가 말하는지 기계가 확정하지 못합니다',
|
||||
fix: '의료진 이력과 병원 사실을 한자리에 모으고, 글마다 작성자와 감수 원장을 구조화해 밝힙니다.',
|
||||
},
|
||||
surface: {
|
||||
now: 'AI가 인용할 표면이 좁습니다',
|
||||
fix: '원장 설명 영상과 언론 보도를 글의 근거로 연결하고, 회복 일정과 방문 안내를 별도 페이지로 만듭니다.',
|
||||
},
|
||||
};
|
||||
|
||||
export type WeakSpot = { id: CategoryId; name: string; pct: number; now: string; fix: string };
|
||||
|
||||
/** 빌드가 보완할 수 있는 카테고리 중 점수가 낮은 순 */
|
||||
export function weakSpots(categories: CategoryScore[]): WeakSpot[] {
|
||||
return categories
|
||||
.filter((c) => ADDRESSABLE.includes(c.category.id) && COVERAGE_PLAN[c.category.id])
|
||||
.sort((a, b) => a.pct - b.pct)
|
||||
.map((c) => ({
|
||||
id: c.category.id,
|
||||
name: c.category.name,
|
||||
pct: c.pct,
|
||||
now: COVERAGE_PLAN[c.category.id].now,
|
||||
fix: COVERAGE_PLAN[c.category.id].fix,
|
||||
}));
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 서포터즈 빌드 17단계를 고객이 읽을 수 있는 5묶음으로 옮긴다.
|
||||
*
|
||||
* 워커(workers/supporters-build/run.mjs)의 PHASES 와 순서가 같아야 한다.
|
||||
* 워커가 단계를 늘리면 여기 STEPS 의 phases 에도 넣는다. 매핑에 없는 단계는
|
||||
* 진행률 계산에서 빠지므로, 모르는 값이 와도 화면이 멈추지는 않는다.
|
||||
*/
|
||||
|
||||
/** 워커가 supporter_builds.phase 에 쓰는 값 */
|
||||
export type WorkerPhase =
|
||||
| 'evidence' | 'ocr' | 'data' | 'discover' | 'youtube' | 'news' | 'images'
|
||||
| 'tourism' | 'recovery' | 'planner' | 'inputs' | 'briefs' | 'generate'
|
||||
| 'build' | 'deploy' | 'publish' | 'done';
|
||||
|
||||
export type PhaseRow = { phase: string; status: 'running' | 'ok' | 'warning' | 'failed'; summary?: string };
|
||||
|
||||
export type BuildStep = {
|
||||
key: string;
|
||||
/** 진행 중일 때 문구 */
|
||||
label: string;
|
||||
/** 끝났을 때 문구 */
|
||||
labelDone: string;
|
||||
/** 무엇을 하는지 한 줄 */
|
||||
detail: string;
|
||||
phases: WorkerPhase[];
|
||||
};
|
||||
|
||||
export const BUILD_STEPS: BuildStep[] = [
|
||||
{
|
||||
key: 'collect',
|
||||
label: '홈페이지를 읽는 중입니다',
|
||||
labelDone: '홈페이지 자료 확보',
|
||||
detail: '공개된 페이지와 이미지 속 글자까지 읽어 병원 정보와 의료진을 정리합니다.',
|
||||
phases: ['evidence', 'ocr', 'data'],
|
||||
},
|
||||
{
|
||||
key: 'sources',
|
||||
label: '채널과 근거를 모으는 중입니다',
|
||||
labelDone: '채널과 근거 확보',
|
||||
detail: '유튜브·인스타그램·블로그 채널을 찾고, 기사와 사진을 근거로 모읍니다.',
|
||||
phases: ['discover', 'youtube', 'news', 'images'],
|
||||
},
|
||||
{
|
||||
key: 'guides',
|
||||
label: '방문·회복 안내를 만드는 중입니다',
|
||||
labelDone: '방문·회복 안내 완성',
|
||||
detail: '오시는 길, 회복 일정, 머무는 동안의 주변 안내를 병원 위치 기준으로 만듭니다.',
|
||||
phases: ['tourism', 'recovery', 'planner'],
|
||||
},
|
||||
{
|
||||
key: 'write',
|
||||
label: '질문에 답하는 글을 쓰는 중입니다',
|
||||
labelDone: '글 작성 완료',
|
||||
detail: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 공개 자료를 근거로 답합니다.',
|
||||
phases: ['inputs', 'briefs', 'generate'],
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
label: '사이트를 만들고 올리는 중입니다',
|
||||
labelDone: '사이트 준비 완료',
|
||||
detail: '발행 기준을 검사한 뒤 사이트를 만들어 올립니다. 검색 노출은 확인을 마친 뒤에 켭니다.',
|
||||
phases: ['build', 'deploy', 'publish', 'done'],
|
||||
},
|
||||
];
|
||||
|
||||
const PHASE_TO_STEP = new Map<string, number>();
|
||||
BUILD_STEPS.forEach((s, i) => s.phases.forEach((p) => PHASE_TO_STEP.set(p, i)));
|
||||
|
||||
/** 전체 워커 단계 수. 진행률을 단계 묶음이 아니라 실제 단계 기준으로 센다. */
|
||||
const TOTAL_PHASES = BUILD_STEPS.reduce((n, s) => n + s.phases.length, 0);
|
||||
|
||||
export type BuildState = 'queued' | 'running' | 'gate_failed' | 'failed' | 'preview' | 'published' | 'published_pending_tasks' | 'approved';
|
||||
|
||||
/** 지금 어느 묶음에 있는지. 모르는 단계면 -1. */
|
||||
export function stepIndexOf(phase: string | null | undefined): number {
|
||||
if (!phase) return -1;
|
||||
return PHASE_TO_STEP.get(phase) ?? -1;
|
||||
}
|
||||
|
||||
/** 끝난 워커 단계 수 기준 진행률 0~1 */
|
||||
export function progressOf(phases: PhaseRow[] | null | undefined, state: BuildState): number {
|
||||
if (state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved') return 1;
|
||||
if (!phases?.length) return 0;
|
||||
const done = phases.filter((p) => (p.status === 'ok' || p.status === 'warning') && PHASE_TO_STEP.has(p.phase)).length;
|
||||
return Math.min(done / TOTAL_PHASES, 0.98);
|
||||
}
|
||||
|
||||
/** 묶음별 상태. 화면에서 체크·스피너·대기를 고르는 데 쓴다. */
|
||||
export type StepState = 'done' | 'active' | 'waiting' | 'failed';
|
||||
|
||||
export function stepStates(phases: PhaseRow[] | null | undefined, phase: string | null, state: BuildState): StepState[] {
|
||||
const finished = state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved';
|
||||
const cur = stepIndexOf(phase);
|
||||
const rows = new Map((phases ?? []).map((p) => [p.phase, p.status]));
|
||||
return BUILD_STEPS.map((s, i) => {
|
||||
if (finished) return 'done';
|
||||
if ((state === 'failed' || state === 'gate_failed') && i === cur) return 'failed';
|
||||
const allDone = s.phases.every((p) => { const st = rows.get(p); return st === 'ok' || st === 'warning'; });
|
||||
if (allDone) return 'done';
|
||||
if (i === cur) return 'active';
|
||||
if (cur > i) return 'done';
|
||||
return 'waiting';
|
||||
});
|
||||
}
|
||||
|
||||
/** 경고가 남은 단계. 완료 화면에서 "확인 대기"로 보여 준다. */
|
||||
export function warningsOf(phases: PhaseRow[] | null | undefined): PhaseRow[] {
|
||||
return (phases ?? []).filter((p) => p.status === 'warning');
|
||||
}
|
||||
|
||||
export const STATE_LABEL: Record<BuildState, string> = {
|
||||
queued: '차례를 기다리는 중입니다',
|
||||
running: '만드는 중입니다',
|
||||
gate_failed: '발행 기준에서 멈췄습니다',
|
||||
failed: '만들다가 멈췄습니다',
|
||||
preview: '준비됐습니다',
|
||||
approved: '승인 완료',
|
||||
published: '검색 노출까지 켜졌습니다',
|
||||
published_pending_tasks: '발행됐고 남은 확인이 있습니다',
|
||||
};
|
||||
14
src/main.tsx
@ -17,24 +17,15 @@ 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 ActionPlanPage from './pages/ActionPlanPage.tsx';
|
||||
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
|
||||
import ImageReviewPage from './pages/ImageReviewPage.tsx';
|
||||
import SiteBuildPage from './pages/SiteBuildPage.tsx';
|
||||
import './index.css';
|
||||
|
||||
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
|
||||
const DISCOVERY_SITE = import.meta.env.VITE_SITE === 'discovery';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
{/* 루트에서 무엇을 그릴지는 배포마다 다르다. VITE_SITE=discovery 로 빌드한 배포(webforai.kr)는
|
||||
AI Discovery 랜딩을 루트에 놓고, 값이 없으면 지금까지처럼 INFINITH 랜딩을 놓는다.
|
||||
한 코드베이스로 두 사이트를 내되 기존 배포의 동작은 바꾸지 않는다. */}
|
||||
<Route index element={DISCOVERY_SITE ? <DiscoveryLandingPage /> : <LandingPage />} />
|
||||
<Route index element={<LandingPage />} />
|
||||
<Route path="pricing" element={<PricingPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="report/loading" element={<AnalysisLoadingPage />} />
|
||||
@ -50,10 +41,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
||||
<Route path="discovery" element={<DiscoveryLandingPage />} />
|
||||
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
||||
<Route path="actions/:id" element={<ActionPlanPage />} />
|
||||
<Route path="supporters/:id" element={<SupportersBuildPage />} />
|
||||
<Route path="images/:id" element={<ImageReviewPage />} />
|
||||
<Route path="build/:id" element={<SiteBuildPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
/**
|
||||
* /actions/:id — 개선 제안 (Action Plan). 진단 리포트(/discovery/:id)에서 분리된 처방 단계.
|
||||
*
|
||||
* 진단은 "지금 무엇이 문제인가"(증거), 여기는 "그래서 무엇을 하는가"(처방)다.
|
||||
* 읽는 목적이 다르고, 둘을 한 페이지에 두면 스크롤이 길어져 둘 다 읽기 힘들어진다.
|
||||
* 진단 데이터가 없으면 처방도 쓸 수 없으므로 진단 리포트로 돌려보낸다.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery } from '../lib/discoveryScore';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { ActionPlanSection } from '../components/discovery/ActionPlanSection';
|
||||
import { IntegritySection } from '../components/discovery/IntegritySection';
|
||||
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
|
||||
import { PrismFilled } from '../components/icons/FilledIcons';
|
||||
|
||||
export default function ActionPlanPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const result = id ? DISCOVERY_RESULTS[id] : undefined;
|
||||
const overall = useMemo(
|
||||
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
|
||||
[result],
|
||||
);
|
||||
const overallV2 = useMemo(
|
||||
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
|
||||
[result],
|
||||
);
|
||||
|
||||
if (!id) return null;
|
||||
if (!result || !overall) {
|
||||
// 개선 제안은 진단 항목 위에 세운다. 진단이 없으면 여기서 보여줄 근거가 없으니
|
||||
// 진단 리포트로 보낸다. 그 페이지가 "아직 진단 전" 안내와 다음 링크를 갖고 있다.
|
||||
return <Navigate to={`/discovery/${id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-report-content className="[word-break:keep-all]">
|
||||
<section className="on-dark 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">
|
||||
<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 · Action Plan
|
||||
</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">
|
||||
개선 제안
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
|
||||
<p className="ui-body text-white/70 max-w-2xl">
|
||||
진단(v{result.rubricVersion} · {overall.score}/{overall.grade})에서 확인된 항목을 근거로,
|
||||
무엇을 언제까지 할지와 저희가 하지 않을 것을 정리했습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ActionPlanSection result={result} />
|
||||
<IntegritySection result={result} />
|
||||
<BuildPlanSection
|
||||
result={result}
|
||||
overall={overall}
|
||||
summary={{
|
||||
rubricVersion: result.rubricVersion,
|
||||
grade: overall.grade,
|
||||
score: overall.score,
|
||||
aeo: overallV2?.aeo.score,
|
||||
geo: overallV2?.geo.score,
|
||||
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
|
||||
total: AEO_GEO_RUBRIC.criteria.length,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -192,7 +192,7 @@ export default function ClinicProfilePage() {
|
||||
<div className="pt-20 min-h-screen bg-slate-50">
|
||||
|
||||
{/* ── Hero / Clinic Header ── */}
|
||||
<div className="on-dark bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
|
||||
<div className="bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#7B2D8E]/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
|
||||
@ -253,7 +253,7 @@ export default function ClinicProfilePage() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<Building2 size={18} className="text-[#6C5CE7]" />
|
||||
@ -304,7 +304,7 @@ export default function ClinicProfilePage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<Star size={18} className="text-[#6C5CE7]" />
|
||||
@ -348,7 +348,7 @@ export default function ClinicProfilePage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<Stethoscope size={18} className="text-[#6C5CE7]" />
|
||||
@ -396,7 +396,7 @@ export default function ClinicProfilePage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<Heart size={18} className="text-[#6C5CE7]" />
|
||||
@ -428,7 +428,7 @@ export default function ClinicProfilePage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<ShieldCheck size={18} className="text-[#6C5CE7]" />
|
||||
@ -464,7 +464,7 @@ export default function ClinicProfilePage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-[#6C5CE7]" />
|
||||
|
||||
@ -237,7 +237,7 @@ export default function DataValidationPage() {
|
||||
return (
|
||||
<div className="pt-20 min-h-screen">
|
||||
{/* ── Header ── */}
|
||||
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
<div className="max-w-6xl mx-auto relative">
|
||||
@ -259,7 +259,7 @@ export default function DataValidationPage() {
|
||||
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2">수집 프로세스</h2>
|
||||
<p className="text-slate-500 text-sm mb-6">URL 하나로 시작하는 멀티소스 데이터 수집 파이프라인</p>
|
||||
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-0">
|
||||
{CRAWL_STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
@ -309,7 +309,7 @@ export default function DataValidationPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="p-5 pb-4 flex items-center justify-between">
|
||||
@ -409,7 +409,7 @@ export default function DataValidationPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* Alternatives Table */}
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-x-4 px-5 py-3 bg-slate-50 border-b border-slate-100 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
<span>차단된 데이터</span>
|
||||
<span>대안 솔루션</span>
|
||||
@ -443,7 +443,7 @@ export default function DataValidationPage() {
|
||||
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2">전체 데이터 커버리지</h2>
|
||||
<p className="text-slate-500 text-sm mb-6">3가지 수집 방법 조합으로 100% 분석 데이터 확보</p>
|
||||
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8 space-y-5">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8 space-y-5">
|
||||
{COVERAGE.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.method}
|
||||
@ -548,7 +548,7 @@ export default function DataValidationPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 text-center"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 text-center"
|
||||
>
|
||||
<p className="text-xs text-slate-400 mb-1">{item.name}</p>
|
||||
<p className="text-xl font-bold text-primary-900 mb-0.5">{item.cost}</p>
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ComponentType } 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 { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||
import { Tag } from '../components/discovery/Tag';
|
||||
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
ActionPriority,
|
||||
CategoryId,
|
||||
CategoryScore,
|
||||
ControlLevel,
|
||||
@ -63,6 +64,12 @@ const METHOD_LABEL: Record<EvidenceMethod, string> = {
|
||||
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';
|
||||
|
||||
@ -80,10 +87,13 @@ export default function DiscoveryReportPage() {
|
||||
[result],
|
||||
);
|
||||
|
||||
// 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
|
||||
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
|
||||
if (!result || !overall) {
|
||||
return <NoReport clinicId={id ?? ''} />;
|
||||
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]));
|
||||
@ -91,7 +101,7 @@ export default function DiscoveryReportPage() {
|
||||
return (
|
||||
<div data-report-content className="[word-break:keep-all]">
|
||||
{/* ── 1. 헤더 (dark) ── */}
|
||||
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
|
||||
<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>
|
||||
@ -122,19 +132,19 @@ export default function DiscoveryReportPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
|
||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
|
||||
<div className="flex items-start gap-8">
|
||||
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
|
||||
<div key={ax.axis.id} className="flex flex-col items-center gap-2">
|
||||
<ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
|
||||
<span className="ui-note">{ax.axis.code} {ax.axis.name}</span>
|
||||
<span className="text-sm text-slate-500">{ax.axis.code} {ax.axis.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="ui-note">
|
||||
<p className="text-sm text-slate-500">
|
||||
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
|
||||
</p>
|
||||
</div>
|
||||
@ -165,13 +175,13 @@ export default function DiscoveryReportPage() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
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="ui-body">{f.detail}</p>
|
||||
<p className="text-base text-slate-600 leading-relaxed">{f.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@ -191,7 +201,7 @@ export default function DiscoveryReportPage() {
|
||||
return (
|
||||
<div
|
||||
key={cs.category.id}
|
||||
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
|
||||
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>
|
||||
@ -207,7 +217,7 @@ export default function DiscoveryReportPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="ui-note">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
|
||||
<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} />
|
||||
@ -224,14 +234,14 @@ export default function DiscoveryReportPage() {
|
||||
return (
|
||||
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
|
||||
<div>
|
||||
<span className="ui-note font-semibold">{c.id}</span>
|
||||
<span className="text-sm font-semibold text-slate-400">{c.id}</span>
|
||||
<p className="text-base font-bold">{c.name}</p>
|
||||
<p className="ui-note mt-0.5">{c.weight}점</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{c.weight}점</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="ui-body">{r?.evidence}</p>
|
||||
<p className="text-base text-slate-700">{r?.evidence}</p>
|
||||
{r?.source && (
|
||||
<p className="ui-note mt-1 font-mono break-all">{r.source}</p>
|
||||
<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>
|
||||
@ -242,7 +252,7 @@ export default function DiscoveryReportPage() {
|
||||
<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="ui-note">
|
||||
<span className="text-sm text-slate-400">
|
||||
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
|
||||
</span>
|
||||
)}
|
||||
@ -257,19 +267,106 @@ export default function DiscoveryReportPage() {
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
|
||||
{/* 개선 제안(Action Plan)·하지 않을 것·빌드 제안·확인 항목 패널은 /actions/:id 로 옮겼다.
|
||||
증거(진단)와 처방(할 일)은 읽는 목적이 달라 여기 한 페이지에 두면 스크롤만 길어진다.
|
||||
확인 항목 패널은 /supporters/:id 라는 전용 단계가 이미 있어 여기서 다시 보여줄 필요가 없다. */}
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── 4. 채점 기준표 (light) ── */}
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
|
||||
<ClinicInputsPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 6. 채점 기준표 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="rubric"
|
||||
title="Scoring Rubric"
|
||||
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
|
||||
>
|
||||
<div className="on-light overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
|
||||
<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="on-dark bg-[#0A1128] text-white">
|
||||
<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>
|
||||
@ -298,50 +395,6 @@ export default function DiscoveryReportPage() {
|
||||
|
||||
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
||||
|
||||
/** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */
|
||||
function NoReport({ clinicId }: { clinicId: string }) {
|
||||
return (
|
||||
<div className="[word-break:keep-all]">
|
||||
<section className="on-dark 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-3xl mx-auto">
|
||||
<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</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-4">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">No Audit Yet</span>
|
||||
</h1>
|
||||
<p className="text-2xl font-bold mb-3">{clinicId}</p>
|
||||
<p className="ui-body">
|
||||
이 병원은 아직 AEO/GEO 진단을 받지 않았습니다.{' '}
|
||||
진단은 36개 항목을 실제로 물어보고 답변을 확인해 매기는 별도 작업이라, 사이트 빌드만으로는 만들어지지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white py-16 md:py-20 px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<p className="ui-ask ui-ask-block mb-8">진단 없이도 아래는 바로 보실 수 있습니다.</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Link to={`/build/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||
<p className="font-bold text-[#1D0024] mb-1">사이트 빌드 결과</p>
|
||||
<p className="ui-note">만들어진 글과 안내 페이지를 봅니다.</p>
|
||||
</Link>
|
||||
<Link to={`/images/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||
<p className="font-bold text-[#1D0024] mb-1">사진 확인</p>
|
||||
<p className="ui-note">사이트에 실을 사진을 고릅니다.</p>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="ui-note mt-8">
|
||||
진단 리포트 예시는 <Link to="/discovery/viewclinic" className="underline underline-offset-4 text-[#4A3A7C]">뷰성형외과 리포트</Link>에서 보실 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
|
||||
const Icon = CATEGORY_ICON[cs.category.id];
|
||||
return (
|
||||
@ -350,14 +403,14 @@ function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: nu
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 + index * 0.06 }}
|
||||
className="on-light 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]"
|
||||
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="ui-note">{cs.weight}점</span>
|
||||
<span className="text-sm text-slate-400">{cs.weight}점</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<motion.div
|
||||
@ -384,6 +437,14 @@ function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }
|
||||
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 (
|
||||
<>
|
||||
@ -397,8 +458,8 @@ function RubricRows({ categoryName, criteria }: { key?: string; categoryName: st
|
||||
<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="ui-note mt-1">{c.rationale}</p>
|
||||
<p className="ui-note mt-1 font-mono">{c.howToCheck}</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>
|
||||
|
||||
@ -97,7 +97,7 @@ export default function DistributionPage() {
|
||||
return (
|
||||
<div className="pt-20 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* /images/:id — 서포터즈 사이트에 실을 사진을 병원이 한 장씩 확인하는 화면.
|
||||
* 병원 담당자에게 링크로 보낸다. 확인 항목 입력(/supporters/:id)과 같은 supporter_inputs 트랙을 쓴다.
|
||||
*/
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { ImageReviewPanel } from '../components/discovery/ImageReviewPanel';
|
||||
|
||||
export default function ImageReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
if (!id) return null;
|
||||
return (
|
||||
<div className="[word-break:keep-all]">
|
||||
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 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">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
|
||||
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Photo Check</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-3">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">사진을 확인해 주세요</span>
|
||||
</h1>
|
||||
<p className="ui-body">
|
||||
병원 홈페이지에 올라와 있는 사진을 그대로 가져왔습니다.{' '}
|
||||
그중에는 사이트에 싣기 어려운 사진이 섞여 있을 수 있습니다.
|
||||
</p>
|
||||
<p className="ui-ask ui-ask-block mt-4">병원에서 한 장씩 봐 주셔야 합니다.</p>
|
||||
<p className="text-purple-200 text-sm mt-4">
|
||||
병원 ID <span className="font-mono text-white">{id}</span>
|
||||
{' · '}
|
||||
<Link to={`/supporters/${id}`} className="underline underline-offset-4 text-purple-300">확인 항목 입력으로 가기</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<ImageReviewPanel clinicId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -143,7 +143,7 @@ export default function PerformancePage() {
|
||||
return (
|
||||
<div className="pt-20 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||
<div className="max-w-6xl mx-auto relative">
|
||||
@ -177,7 +177,7 @@ export default function PerformancePage() {
|
||||
{OVERVIEW_STATS.map((stat, i) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||
@ -190,7 +190,7 @@ export default function PerformancePage() {
|
||||
</div>
|
||||
|
||||
{/* ═══ Section 1: Marketing Funnel ═══ */}
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
||||
@ -260,7 +260,7 @@ export default function PerformancePage() {
|
||||
</div>
|
||||
|
||||
{/* ═══ Section 2: Channel Trend (Stacked Bar) ═══ */}
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
||||
@ -298,7 +298,7 @@ export default function PerformancePage() {
|
||||
animate={{ scaleY: 1 }}
|
||||
transition={{ duration: 0.5, delay: wi * 0.1 }}
|
||||
>
|
||||
<div className="on-dark absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
|
||||
{ch.label}: {val}K
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -321,7 +321,7 @@ export default function PerformancePage() {
|
||||
</div>
|
||||
|
||||
{/* ═══ Section 3: Day × Time Heatmap ═══ */}
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
||||
@ -387,7 +387,7 @@ export default function PerformancePage() {
|
||||
return (
|
||||
<motion.div
|
||||
key={ch.id}
|
||||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
||||
@ -421,8 +421,8 @@ export default function PerformancePage() {
|
||||
|
||||
{/* Top Content */}
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
||||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
|
||||
<div className="on-dark grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
|
||||
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
|
||||
<span>콘텐츠</span>
|
||||
<span>채널</span>
|
||||
<span className="text-right">조회수</span>
|
||||
|
||||
@ -194,7 +194,7 @@ const TierCard: React.FC<TierCardProps> = ({ tier, billing, onSelect }) => {
|
||||
{/* 이름 + 태그라인 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-2xl font-serif font-bold text-primary-900 mb-2">{tier.name}</h3>
|
||||
<p className="ui-note">{tier.tagline}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed break-keep">{tier.tagline}</p>
|
||||
</div>
|
||||
|
||||
{/* 가격 */}
|
||||
@ -203,7 +203,7 @@ const TierCard: React.FC<TierCardProps> = ({ tier, billing, onSelect }) => {
|
||||
<span className="text-4xl font-bold text-primary-900 tracking-tight">
|
||||
{formatKRW(price)}
|
||||
</span>
|
||||
<span className="ui-note">/월</span>
|
||||
<span className="text-slate-500 text-sm">/월</span>
|
||||
</div>
|
||||
{isAnnual && (
|
||||
<p className="text-xs text-accent font-semibold mt-2">
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
/**
|
||||
* /build/:id — 진단 리포트에서 "사이트로 빌드"를 누른 뒤 보는 진행 화면.
|
||||
*
|
||||
* 예전 전략마케팅 툴의 분석 진행 화면(/report/loading)의 단계 UX를 그대로 이어받되,
|
||||
* 진행 상황을 프런트가 직접 만들지 않고 supporter_builds 를 폴링해 워커가 쓴 값을 읽는다.
|
||||
* 프런트가 파이프라인을 돌리지 않으므로 창을 닫아도 빌드는 계속된다.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases';
|
||||
import type { BuildState, PhaseRow } from '../lib/buildPhases';
|
||||
import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons';
|
||||
|
||||
type Build = {
|
||||
id: string;
|
||||
clinic_id: string;
|
||||
clinic_name: string | null;
|
||||
status: BuildState;
|
||||
phase: string | null;
|
||||
phases: PhaseRow[] | null;
|
||||
posts: Array<{ id: string; title: string }> | null;
|
||||
preview_url: string | null;
|
||||
error: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
export default function SiteBuildPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [build, setBuild] = useState<Build | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
|
||||
async function tick() {
|
||||
const { data, error } = await supabase
|
||||
.from('supporter_builds')
|
||||
.select('id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at')
|
||||
.eq('clinic_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1);
|
||||
if (!alive) return;
|
||||
if (error) setLoadError(error.message);
|
||||
const row = (data?.[0] as Build) ?? null;
|
||||
setBuild(row);
|
||||
setLoading(false);
|
||||
// 끝났으면 더 부르지 않는다.
|
||||
const done = row && ['preview', 'published', 'published_pending_tasks', 'approved', 'failed', 'gate_failed'].includes(row.status);
|
||||
if (!done) timer.current = window.setTimeout(tick, POLL_MS);
|
||||
}
|
||||
|
||||
void tick();
|
||||
return () => { alive = false; if (timer.current) window.clearTimeout(timer.current); };
|
||||
}, [id]);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const state: BuildState = build?.status ?? 'queued';
|
||||
const finished = ['preview', 'published', 'published_pending_tasks', 'approved'].includes(state);
|
||||
const stopped = state === 'failed' || state === 'gate_failed';
|
||||
const states = stepStates(build?.phases, build?.phase ?? null, state);
|
||||
const pct = Math.round(progressOf(build?.phases, state) * 100);
|
||||
const warnings = warningsOf(build?.phases);
|
||||
const name = build?.clinic_name || id;
|
||||
|
||||
return (
|
||||
<div className="on-dark min-h-screen bg-[#0A1128] text-white relative overflow-hidden">
|
||||
<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-3xl mx-auto px-6 pt-28 pb-24 md:pt-36">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-8">
|
||||
<PrismFilled size={16} className="text-purple-300" />
|
||||
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Site Build</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-3">
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
|
||||
{finished ? 'Your Site Is Ready' : 'Building Your Site'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-2xl font-bold mb-1">{name}</p>
|
||||
<p className="text-purple-200 text-base mb-12">{STATE_LABEL[state]}</p>
|
||||
|
||||
{loading && <p className="text-white/50">상태를 불러오는 중입니다.</p>}
|
||||
|
||||
{!loading && !build && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-8">
|
||||
<h2 className="text-lg font-bold mb-2">아직 빌드가 시작되지 않았습니다</h2>
|
||||
<p className="text-white/60 text-base leading-[1.75]">
|
||||
{loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}
|
||||
</p>
|
||||
<Link to={`/discovery/${id}`} className="inline-block mt-6 rounded-full bg-white/10 border border-white/20 px-6 py-3 text-base font-semibold hover:bg-white/15 transition-colors">
|
||||
진단 리포트로 가기
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && build && (
|
||||
<>
|
||||
{/* 단계 */}
|
||||
<div className="space-y-6 mb-12">
|
||||
{BUILD_STEPS.map((step, i) => {
|
||||
const st = states[i];
|
||||
return (
|
||||
<motion.div
|
||||
key={step.key}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={{ opacity: st === 'waiting' ? 0.35 : 1, x: 0 }}
|
||||
transition={{ duration: 0.4, delay: i * 0.08 }}
|
||||
className="flex items-start gap-4"
|
||||
>
|
||||
<div className="w-7 h-7 shrink-0 flex items-center justify-center mt-0.5">
|
||||
{st === 'done' ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
||||
>
|
||||
<CheckFilled size={14} className="text-white" />
|
||||
</motion.div>
|
||||
) : st === 'active' ? (
|
||||
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||
) : st === 'failed' ? (
|
||||
<div className="w-7 h-7 rounded-full bg-[#FFF0F0] flex items-center justify-center">
|
||||
<WarningFilled size={14} className="text-[#7C3A4B]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-base font-semibold ${st === 'done' ? 'text-white' : st === 'active' ? 'text-purple-200' : st === 'failed' ? 'text-[#F5D5DC]' : 'text-white/40'}`}>
|
||||
{st === 'done' ? step.labelDone : step.label}
|
||||
</p>
|
||||
<p className="ui-note mt-0.5">{step.detail}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 진행바 */}
|
||||
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white/40 text-sm mt-4">
|
||||
{finished
|
||||
? `${build.posts?.length ?? 0}편의 글과 안내 페이지를 만들었습니다.`
|
||||
: '창을 닫으셔도 계속 진행됩니다. 보통 10분 안팎 걸립니다.'}
|
||||
</p>
|
||||
|
||||
{/* 멈춤 */}
|
||||
{stopped && (
|
||||
<div className="mt-10 bg-white/5 border border-[#F5D5DC]/30 rounded-2xl p-6">
|
||||
<h2 className="text-lg font-bold mb-2 text-[#F5D5DC]">
|
||||
{state === 'gate_failed' ? '발행 기준에서 멈췄습니다' : '만들다가 멈췄습니다'}
|
||||
</h2>
|
||||
<p className="ui-body">
|
||||
{state === 'gate_failed'
|
||||
? '사이트에 실으면 안 되는 내용이 걸려 배포하지 않고 멈췄습니다. 담당자가 확인한 뒤 다시 진행합니다.'
|
||||
: '자동으로 처리하지 못한 지점이 있습니다. 담당자가 확인한 뒤 다시 진행합니다.'}
|
||||
</p>
|
||||
{build.error && <p className="text-white/40 text-sm mt-3 font-mono break-all">{build.error.slice(0, 300)}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완료 */}
|
||||
{finished && (
|
||||
<div className="mt-12 grid gap-4">
|
||||
<div className="on-light bg-white rounded-2xl p-6 md:p-7 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]">
|
||||
<h2 className="ui-ask text-lg mb-2">다음은 병원에서 확인해 주셔야 합니다</h2>
|
||||
<p className="ui-body mb-5">
|
||||
지금 사이트는 검색에 노출되지 않는 상태입니다.{' '}
|
||||
아래 두 가지를 확인해 주시면 공개 준비가 끝납니다.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<Link to={`/images/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||
<p className="font-bold text-[#1D0024] mb-1">사진 확인</p>
|
||||
<p className="ui-body">사이트에 실을 사진을 한 장씩 골라 주세요.</p>
|
||||
</Link>
|
||||
<Link to={`/supporters/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||
<p className="font-bold text-[#1D0024] mb-1">확인 항목 입력</p>
|
||||
<p className="ui-body">공개 자료에 없어 병원만 답할 수 있는 값입니다.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{build.preview_url && (
|
||||
<a
|
||||
href={build.preview_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn-on-dark justify-center"
|
||||
>
|
||||
<EyeFilled size={16} /> {state === 'published' || state === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 mb-2 text-[#F5E0C5]">
|
||||
<WarningFilled size={15} />
|
||||
<h3 className="text-base font-bold">확인 대기로 발행된 부분</h3>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{warnings.map((w) => (
|
||||
<li key={w.phase} className="ui-note">
|
||||
{w.summary?.split('\n')[0] ?? w.phase}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-white/40 mt-3">자동으로 채우지 못한 값입니다. 지어내지 않고 빈 자리를 그대로 두었습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-white/30 text-xs mt-10">
|
||||
갱신 {build.updated_at.slice(0, 16).replace('T', ' ')}
|
||||
{' · '}
|
||||
<Link to={`/discovery/${build.clinic_id}`} className="underline underline-offset-4">진단 리포트</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -10,7 +10,7 @@ export default function SupportersBuildPage() {
|
||||
if (!id) return null;
|
||||
return (
|
||||
<div className="[word-break:keep-all]">
|
||||
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 px-6">
|
||||
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 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">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
|
||||
@ -20,8 +20,8 @@ export default function SupportersBuildPage() {
|
||||
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">Supporters Preview</span>
|
||||
</h1>
|
||||
<p className="text-purple-200 text-base">병원 ID <span className="font-mono text-white">{id}</span></p>
|
||||
<p className="ui-ask ui-ask-block mt-4">
|
||||
아래에서 미리보기를 열고, 병원만 답할 수 있는 항목을 입력해 주세요.
|
||||
<p className="text-white/60 text-sm mt-4">
|
||||
홈페이지 공개 자료만으로 만든 서포터즈 사이트 초안입니다. 아래에서 미리보기를 열고, 병원만 답할 수 있는 항목을 입력해 주세요.
|
||||
{' '}<Link to="/discovery" className="underline underline-offset-4 text-purple-300">AI Discovery 소개</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 서포터즈 사이트 주소: 병원별 서브도메인
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 서포터즈 사이트는 공용 도메인(webforai.kr)의 병원별 서브도메인에 둔다.
|
||||
-- 병원마다 도메인을 사면 병원 수만큼 갱신 대상이 늘고, 한 건만 놓쳐도 그 병원 사이트가 멈춘다.
|
||||
-- 근거: docs/business/INFINITH_서포터즈_사이트주소_방식비교_2026-09-14.docx
|
||||
--
|
||||
-- 이 표가 서브도메인의 단일 원본이다. 워커·배포 스크립트가 이름을 매번 짓지 않고 여기서 읽는다.
|
||||
-- 이름을 코드가 지으면 "뷰"·"연세"·"그랜드"처럼 여러 병원이 쓰는 이름에서 충돌이 난다.
|
||||
|
||||
ALTER TABLE clinic_registry
|
||||
ADD COLUMN IF NOT EXISTS subdomain TEXT,
|
||||
-- 워커가 쓰는 병원 id(--clinic). 서브도메인과 다를 수 있어 따로 둔다.
|
||||
ADD COLUMN IF NOT EXISTS clinic_slug TEXT,
|
||||
-- 주소를 언제 붙였는지. 색인 전환 판단에 쓴다.
|
||||
ADD COLUMN IF NOT EXISTS subdomain_assigned_at TIMESTAMPTZ;
|
||||
|
||||
-- 한 서브도메인은 한 병원에만. 대소문자 구분 없이 막는다.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS clinic_registry_subdomain_key
|
||||
ON clinic_registry (lower(subdomain))
|
||||
WHERE subdomain IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS clinic_registry_clinic_slug_key
|
||||
ON clinic_registry (lower(clinic_slug))
|
||||
WHERE clinic_slug IS NOT NULL;
|
||||
|
||||
-- 형식: 소문자·숫자·붙임표만. 2~40자(1자는 실수일 가능성이 높고 짧은 이름을 병원에 내주게 된다). 붙임표로 시작하거나 끝날 수 없고 연속 붙임표도 막는다.
|
||||
-- 대문자·밑줄·점을 넣으면 DNS 레이블로 쓸 수 없거나 브라우저가 다르게 해석한다.
|
||||
ALTER TABLE clinic_registry
|
||||
DROP CONSTRAINT IF EXISTS clinic_registry_subdomain_format;
|
||||
ALTER TABLE clinic_registry
|
||||
ADD CONSTRAINT clinic_registry_subdomain_format
|
||||
CHECK (subdomain IS NULL OR (subdomain ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$' AND subdomain !~ '--'));
|
||||
|
||||
-- 예약어. 서비스가 쓰거나 쓸 수 있는 이름은 병원에 주지 않는다.
|
||||
-- 여기 있는 이름을 병원에 내주면 나중에 메일·API·관리 화면을 붙일 때 주소를 회수해야 한다.
|
||||
-- 회수하면 그 시점에 이미 색인된 주소가 깨진다.
|
||||
ALTER TABLE clinic_registry
|
||||
DROP CONSTRAINT IF EXISTS clinic_registry_subdomain_reserved;
|
||||
ALTER TABLE clinic_registry
|
||||
ADD CONSTRAINT clinic_registry_subdomain_reserved
|
||||
CHECK (subdomain IS NULL OR subdomain NOT IN (
|
||||
-- 웹·서비스 기본
|
||||
'www', 'web', 'app', 'apps', 'api', 'admin', 'dashboard', 'console', 'portal',
|
||||
'static', 'assets', 'cdn', 'img', 'images', 'media', 'files', 'download', 'downloads',
|
||||
-- 메일·인증 (잘못 내주면 메일 수신과 도메인 소유 확인이 깨진다)
|
||||
'mail', 'email', 'smtp', 'imap', 'pop', 'mx', 'ns', 'ns1', 'ns2', 'dns',
|
||||
'autodiscover', 'autoconfig', 'mta-sts', 'dmarc', 'spf', 'dkim',
|
||||
-- 환경·배포
|
||||
'dev', 'test', 'stage', 'staging', 'preview', 'demo', 'sandbox', 'beta', 'alpha', 'local',
|
||||
'status', 'health', 'metrics', 'log', 'logs', 'monitor',
|
||||
-- 제품·조직
|
||||
'infinith', 'webforai', 'supporters', 'clinic', 'clinics', 'hospital', 'report', 'reports',
|
||||
'discovery', 'plan', 'planner', 'blog', 'news', 'newsroom', 'help', 'support', 'docs',
|
||||
'about', 'contact', 'login', 'auth', 'account', 'billing', 'pay', 'payment',
|
||||
-- 오인 방지
|
||||
'en', 'ko', 'ja', 'zh', 'kr', 'go', 'or', 'co', 'ne', 're'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN clinic_registry.subdomain IS
|
||||
'서포터즈 사이트 주소의 서브도메인 라벨. 공용 도메인 앞에 붙는다(예: viewpl → viewpl.webforai.kr). 소문자·숫자·붙임표만.';
|
||||
COMMENT ON COLUMN clinic_registry.clinic_slug IS
|
||||
'워커 --clinic 에 넘기는 병원 id. 빌드 폴더·Vercel 프로젝트 이름에 쓰인다. 서브도메인과 다를 수 있다.';
|
||||
COMMENT ON COLUMN clinic_registry.subdomain_assigned_at IS
|
||||
'서브도메인을 배정한 시각. 색인 허용 전환 전에 주소를 바꾸는 편이 싸므로 그 판단에 쓴다.';
|
||||
6
supporters/.gitignore
vendored
@ -26,9 +26,3 @@ pnpm-debug.log*
|
||||
|
||||
# 글 생성기 산출물 (리포트는 docs 로 옮겨 커밋)
|
||||
generated/
|
||||
|
||||
# Vision OCR 빌드 산출물 (ocr_evidence.mjs 가 swiftc 로 만든다)
|
||||
scripts/ocr/vision_ocr
|
||||
|
||||
# 시술 후 관리 안내 번역 캐시 (collect_recovery.mjs --translate)
|
||||
src/data/recoveryNotes.cache.json
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
# 배포 안내 (2026-09-11, haewon 결정)
|
||||
|
||||
이 워크트리(feature/supporters-autobuild)의 supporters 폴더는 더 이상 Vercel 프로젝트 `view-supporters-sample`에 배포하지 않습니다.
|
||||
프로덕션 정본은 메인 저장소 `/Users/haewonkam/Claude/Agentic Marketing/INFINITH/supporters` (브랜치 feature/ai-sentiment-rubric)이며,
|
||||
헤더 "회복 일정"은 그쪽의 `/plan`·`/en/plan`(수술일 기준 입국·출국 계산 플래너)을 가리킵니다.
|
||||
|
||||
- `.vercel/project.json` 은 `.vercel/project.json.disabled-2026-09-11` 로 이름을 바꿔 두었습니다. `vercel --prod` 를 실행해도 이 프로젝트로 올라가지 않습니다.
|
||||
- 이 워크트리의 `/recovery`·`/stay`·`/en/stay`(Tour API 주변 장소·축제·동선)는 정본 브랜치의 `/plan` 안에 "주변 장소·축제" 보강 데이터로 합치는 것을 다음 단계로 검토합니다. 합치기 전까지 프로덕션에 올리지 않습니다.
|
||||
@ -5,18 +5,15 @@
|
||||
```
|
||||
node scripts/generate_posts.mjs --clinic <id> --brief briefs/<id>/<name>.json [--out DIR] [--dry-run] [--no-verify]
|
||||
node scripts/generate_posts.mjs --clinic viewclinic --from-golden # 정답지 18편에서 brief 를 자동 추출해 회귀
|
||||
node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6] [--industry derm|plastic] [--areas N] # 첫 배치 자동 제안
|
||||
```
|
||||
|
||||
## 파일
|
||||
|
||||
```json
|
||||
{
|
||||
"industry": "plastic", // default_briefs 가 적는 업종. plastic(성형외과) | derm(피부과). 글마다 industry 를 두면 그것이 우선
|
||||
"posts": [
|
||||
{
|
||||
"id": "slug", // 파일명. posts/<id>.md
|
||||
"industry": "derm", // 선택. derm 이면 생성 프롬프트에 피부과 추가 규칙(조건 표 8행·시술자·상표·금액 금지·부작용 어휘)이 붙고, 게이트가 PRACTITIONER_MISSING 을 본다
|
||||
"title": "질문형 한 문장?", // H1. 물음표로 끝난다
|
||||
"category": "D", "categoryLabel": "시술 정보",
|
||||
"qbIds": ["D2-02"], // 질문 뱅크 문항 ID (화면에는 안 나옴)
|
||||
@ -55,44 +52,3 @@ node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site
|
||||
- 자막 없는 영상의 발언을 만들지 않는다. 영상 근거는 `shortsInfo[].answer` 또는 `transcripts` 뿐이다.
|
||||
- 기사 본문을 추정하지 않는다. 제목·매체·날짜만.
|
||||
- 근거에 없는 수치를 쓰지 않는다. 쓰면 `NUMBER_NOT_IN_EVIDENCE` 로 수정 대상이 된다.
|
||||
|
||||
## 업종 (industry)
|
||||
|
||||
`default_briefs.mjs` 가 정하고 brief 파일 최상위와 글마다 `industry` 로 적는다. 생성기·게이트는 글의 `industry` 를 우선 보고, 없으면 환경변수 `SUPPORTERS_INDUSTRY`, 그것도 없으면 `plastic` 이다.
|
||||
|
||||
| 값 | 판정 | 달라지는 것 |
|
||||
|---|---|---|
|
||||
| `plastic` (기본) | `--industry plastic`, 또는 병원 이름에 "성형외과" | 기존 동작 그대로. 시술 영역은 URL 세그먼트(`/contents/facial/...`)로 묶고, 세그먼트가 일반어(`new`·`doc`·`index.php`·숫자)일 때만 제목·헤딩·본문 키워드 폴백 |
|
||||
| `derm` | `--industry derm`, 병원 이름에 "피부과"(성형외과보다 앞에 있으면), 또는 시술 페이지 제목의 피부과 어휘가 3개 이상이고 성형외과 어휘보다 많을 때 | 시술 페이지를 URL 과 무관하게 아래 8영역으로 배정. 시술 글 제목·intent 가 조건 표 8행을 요구. `avoid` 에 피부과 금칙 추가. 지점 페이지(`branches` 유형)나 `facts.draft.json`·팩트 시트의 `branches` 가 2개 이상이면 "지점 선택 가이드"(B) 후보 추가. 시술 영역 기본 8개(`--areas`) |
|
||||
|
||||
### 피부과 8영역 (질문 뱅크 D1~D8 과 1:1)
|
||||
|
||||
| key | 영역 | 문항 | 판정 낱말(일부) |
|
||||
|---|---|---|---|
|
||||
| pigment | 색소·토닝 | D1 | 색소, 토닝, 기미, 잡티, 검버섯, 피코, 미백 |
|
||||
| lifting | 리프팅·탄력 | D2 | 리프팅, 탄력, 처짐, 울쎄라, 써마지, 인모드, 슈링크, 고주파 |
|
||||
| booster | 스킨부스터·재생 | D3 | 스킨부스터, 리쥬란, 물광, 엑소좀, 재생, 콜라겐 |
|
||||
| toxin-filler | 톡신·필러 | D4 | 보톡스, 톡신, 필러, 주름, 사각턱, 이마, 미간 |
|
||||
| acne | 여드름·흉터·모공 | D5 | 여드름, 흉터, 모공, 피지, 스케일링, 필링, 프락셀 |
|
||||
| hair-body | 제모·바디 | D6 | 제모, 바디, 체형, 겨드랑이, 셀룰라이트, 쿨스컬프팅 |
|
||||
| disease-hair | 피부질환·모발 | D7 | 홍조, 혈관, 다한증, 액취증, 아토피, 사마귀, 탈모, 두피 |
|
||||
| plan | 계획·주기·조합 | D8 | 시술 주기, 회차, 병행, 조합, 시술 순서, 패키지 |
|
||||
|
||||
점수: 제목·H1·URL 에 낱말이 있으면 5점, 헤딩 2점, 본문 등장 횟수 × 0.5(최대 2.5). 2점 미만이면 영역 없음(기획하지 않음). 근거 충분성(영역의 페이지 글자 합 1,500자 이상)은 업종과 무관하게 같다. 본문이 이미지 글자인 페이지는 글자 수가 0에 가까워 자동으로 빠진다.
|
||||
|
||||
피부과 시술 글의 intent 는 시술마다 조건 표 8행(시술시간·마취·통증 정도·회복(다운타임)·유지기간·권장 회차·권장 주기·시술자)을 요구하고, 페이지에 없는 칸은 "확인", 시술자 표기가 없으면 "확인 대기"로 두라고 지시한다. 고정 글(병원 소개·방문 안내·상담 질문 체크리스트)은 업종과 관계없이 나온다.
|
||||
|
||||
## 게이트 규칙 (피부과 추가분, 성형외과 글에도 적용)
|
||||
|
||||
`scripts/gate/rules.mjs` 의 `checkPostSource` 에 들어 있다. 근거 조문은 코드 주석에 있고, 법 해석은 자문이 아니다.
|
||||
|
||||
| 코드 | 수준 | 조건 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `PRICE_MENTION` | error | 요약·본문·FAQ 에 금액(150,000원·15만원·₩·%할인·이벤트가 99,000·1+1). "비급여 진료비용 고지" 안내 문장은 통째로 허용 | 의료법 45조(비급여 고지는 의료기관의 의무, 서포터즈는 링크만) · 56조 2항(유인) |
|
||||
| `DEVICE_CLAIM` | error | (1) 식약처 허가·인증, FDA 승인·허가, MFDS·KFDA, CE 인증 표기가 있는데 `sources` 에 `regulation` 이 없음 (2) 영구·완치·재발 없음·100%·평생 유지·한 번으로 끝 같은 효과 보장. 부정문("영구라는 뜻은 아닙니다")·"반영구"(시술명)는 통과 | 의료기기법 24조 · 약사법 68조 · 의료법 56조 2항 |
|
||||
| `PRACTITIONER_MISSING` | warn | `industry: derm` 이고 분류 D 인 글에 시술자 표기("시술자" 행, "의사 직접", "의사 감독")가 없음. 성형외과 글은 검사하지 않음 | 의료법 27조(무면허 의료행위, 대리시술) |
|
||||
| `BANNED_PHRASE` (사전 확장) | error | "정품 정량 인증"·"정품 인증"(인증 어휘. "정품·정량 사용을 밝힙니다" 같은 서약 사실 서술은 통과), "비포애프터"·"전후 사진을 보세요/확인하세요/공개" 유도 문구("전후 사진을 싣지 않습니다" 원칙 서술과 "수술 전후 모니터링" 시기 표현은 통과) | 의료법 56조 2항 2호·14호 |
|
||||
|
||||
부작용 고지 문안은 팩트 시트 `sideEffectNotice` 를 템플릿이 붙이므로 글 단위 검사는 없다. 생성 프롬프트(`gen/prompt.mjs` 의 `DERM_RULES`)는 시술 글에 "## 시술 후 관리와 부작용" 절과 화상·색소침착·멍·부기·감염 같은 구체 어휘를 요구한다.
|
||||
|
||||
픽스처: `scripts/gate/fixtures/price-mention.md`(실패) · `price-notice-link.md`(통과) · `device-claim-approval.md`·`device-claim-guarantee.md`(실패) · `device-claim-ok.md`(통과) · `practitioner-missing.md`(경고) · `practitioner-present.md`·`practitioner-plastic-skip.md`(경고 없음) · `derm-cert-phrase.html`·`before-after-phrase.html`(실패) · `derm-cert-ok.html`(통과). `npm run gate:test` 로 돈다. 픽스처 frontmatter 의 `expectWarn:`·`expectNoWarn:` 으로 warn 코드를 검사한다.
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
{
|
||||
"_comment": "병원별 고정값. 2026-09-10 Claude 작성(haewon 확인 전). 유튜브는 YouTube Data API로 확인한 공식 채널 @oracle_medical_group(구독 1,310·영상 318·조회 113만). 로고는 공식 홈페이지 상단 logo.gif(alt '오라클피부과 로고')를 PNG로 변환. 그룹 사이트 oraclemedicalgroup.com에 국내 47·해외 18 지점 목록이 있다. 2026-09-10 사람 확인(Claude, haewon 확인 전): 자동 수집 이미지 12장 중 11장(sub_0*)은 시술 설명 이미지(모델 얼굴·전후 사진·글자)라 제외, 시설 사진은 검단점 로비 1장. 업종 피부과·지역 청담은 주소 기준. 이 파일 값은 워커 inputs 단계에서 자동값 위에 덮어쓴다.",
|
||||
"youtube": "UC5husl0zvJ_W2zILCb6Wb6A",
|
||||
"youtubeHandle": "@oracle_medical_group",
|
||||
"logo": "supporters/briefs/oracle/logo-oracle.png",
|
||||
"groupSite": "http://www.oraclemedicalgroup.com/",
|
||||
"instagram": "@oracle_cd",
|
||||
"naverBlog": "https://blog.naver.com/oracleclinic",
|
||||
"gangnamunni": "https://www.gangnamunni.com/hospitals/125",
|
||||
"factSheet": {
|
||||
"kind": "피부과",
|
||||
"areaLabel": "서울 강남 청담",
|
||||
"name": "오라클피부과의원",
|
||||
"shortName": "오라클피부과"
|
||||
},
|
||||
"site": {
|
||||
"heroImage": {
|
||||
"file": "supporters/briefs/oracle/hq-building.jpg",
|
||||
"alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판",
|
||||
"width": 888,
|
||||
"height": 540
|
||||
},
|
||||
"insideGallery": [
|
||||
{
|
||||
"file": "supporters/briefs/oracle/hq-building.jpg",
|
||||
"alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판",
|
||||
"caption": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지"
|
||||
},
|
||||
{
|
||||
"srcMatch": "gumdan_1",
|
||||
"alt": "오라클피부과 검단점 로비. 접수 데스크와 대기 의자",
|
||||
"caption": "검단점 로비. 출처: 공식 홈페이지 지점 안내"
|
||||
}
|
||||
],
|
||||
"clinicGallery": [],
|
||||
"buildingImage": {
|
||||
"file": "supporters/briefs/oracle/hq-building.jpg",
|
||||
"alt": "오라클피부과의원 본사 건물 외관. 흰색 외벽 건물 상단과 1층 입구에 오라클 간판",
|
||||
"caption": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지"
|
||||
},
|
||||
"naverSiteVerification": "f083f28e6b117805e9722f0a1adf7834a8219688",
|
||||
"shareNameEn": "Oracle Dermatology"
|
||||
},
|
||||
"excludeImages": [
|
||||
"sub_0"
|
||||
],
|
||||
"_logoNote": "2026-09-10 haewon 지적(녹색 배너형 GIF는 완성도가 떨어짐) → 그룹 사이트 oraclemedicalgroup.com/images/logo_oracle_02.png (176×68, 투명 배경 워드마크)로 교체. 클리닉 사이트에는 CI 페이지가 없음(메뉴 CI소개가 빈 링크). 이전 배너형은 logo-horizontal.png 로 보존. haewon 제안(2026-09-10)대로 원본 배너에서 왼쪽 문구 영역을 잘라낸 logo-horizontal-cropped.png(133×49, 녹색 배경 유지)도 보관. 녹색 박스형을 쓰려면 logo 값을 이 파일로 바꾸고 --from youtube 재실행.",
|
||||
"_imageNote": "2026-09-10: 본사 건물 외관은 그룹 사이트 oraclemedicalgroup.com/images/company_02.jpg (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외.",
|
||||
"_factSheetNote": "상호 고정. 홈 제목 추정은 슬로건을 상호로 삼을 수 있다. 근거: supporters-oracle 기존 배포본 표기.",
|
||||
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, oracle.webforai.kr 용). 사이트마다 값이 달라 다른 병원에 옮겨 쓰면 확인되지 않는다.",
|
||||
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 공식 영문 사이트 en.cdoracleclinic.com/about 'Introduction to Oracle Dermatology' (2026-09-15 확인)"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@ -1,661 +0,0 @@
|
||||
{
|
||||
"_comment": "오라클피부과(청담) 회복 일정 플래너 큐레이션 장소. 기준점은 병원(선릉로 612). distanceKm 는 직선 거리 근사값이고 verify 항목은 출시 전 확인. 링크는 구글 지도 검색과 예약 사이트 검색 결과만. 2026-09-11 작성.",
|
||||
"origin": {
|
||||
"label": {
|
||||
"ko": "오라클피부과 청담(선릉로 612)",
|
||||
"en": "Oracle Dermatology Cheongdam"
|
||||
},
|
||||
"lat": 37.5115427,
|
||||
"lng": 127.0436528
|
||||
},
|
||||
"categories": {
|
||||
"sightseeing": {
|
||||
"ko": "관광",
|
||||
"en": "Sightseeing"
|
||||
},
|
||||
"shopping": {
|
||||
"ko": "쇼핑",
|
||||
"en": "Shopping"
|
||||
},
|
||||
"food": {
|
||||
"ko": "미식",
|
||||
"en": "Food"
|
||||
},
|
||||
"kpop": {
|
||||
"ko": "K-pop·핫플",
|
||||
"en": "K-pop & hotspots"
|
||||
},
|
||||
"care": {
|
||||
"ko": "휴식·케어",
|
||||
"en": "Rest & care"
|
||||
},
|
||||
"essentials": {
|
||||
"ko": "생활",
|
||||
"en": "Essentials"
|
||||
},
|
||||
"hotel": {
|
||||
"ko": "숙소",
|
||||
"en": "Hotels"
|
||||
}
|
||||
},
|
||||
"places": [
|
||||
{
|
||||
"id": "pharmacy",
|
||||
"name": {
|
||||
"ko": "청담역 주변 약국",
|
||||
"en": "Pharmacies near Cheongdam Station"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.2,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "처방약과 냉찜질 팩을 구할 수 있습니다.",
|
||||
"en": "Prescriptions and cold packs."
|
||||
},
|
||||
"mapQuery": "약국 청담역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "convenience",
|
||||
"name": {
|
||||
"ko": "편의점 (청담·선릉로 일대)",
|
||||
"en": "Convenience stores (Cheongdam)"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.1,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "생수, 죽, 얼음을 24시간 살 수 있습니다.",
|
||||
"en": "Water, porridge and ice, 24 hours."
|
||||
},
|
||||
"mapQuery": "편의점 청담역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "porridge",
|
||||
"name": {
|
||||
"ko": "죽 전문점 (본죽 등 체인)",
|
||||
"en": "Korean porridge (Bonjuk and similar chains)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담·삼성",
|
||||
"en": "Cheongdam / Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "부드러운 식사 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.",
|
||||
"en": "Porridge for the soft-food stage, by delivery or takeout."
|
||||
},
|
||||
"mapQuery": "본죽 청담",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "smoothie",
|
||||
"name": {
|
||||
"ko": "스무디·주스 카페 (청담)",
|
||||
"en": "Smoothie and juice cafes (Cheongdam)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.",
|
||||
"en": "Straw-friendly options. Choose nothing hot."
|
||||
},
|
||||
"mapQuery": "스무디 카페 청담역",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "seolleongtang",
|
||||
"name": {
|
||||
"ko": "설렁탕·곰탕 (청담·삼성 일대)",
|
||||
"en": "Seolleongtang beef bone soup (Cheongdam area)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "국물 위주라 부드러운 음식 단계부터 가능합니다.",
|
||||
"en": "Broth-based, fine from the soft-food stage."
|
||||
},
|
||||
"mapQuery": "설렁탕 청담역",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cafe-cheongdam",
|
||||
"name": {
|
||||
"ko": "청담 카페 거리",
|
||||
"en": "Cheongdam cafes"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "조용한 카페가 많아 오후에 잠깐 앉아 있기 좋습니다.",
|
||||
"en": "Quiet cafes, good for a short afternoon sit-down."
|
||||
},
|
||||
"mapQuery": "청담 카페",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hansik-cheongdam",
|
||||
"name": {
|
||||
"ko": "청담·압구정 한식",
|
||||
"en": "Korean set meals (Cheongdam / Apgujeong)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.7,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "일반식이 가능한 단계부터입니다.",
|
||||
"en": "From the regular-diet stage."
|
||||
},
|
||||
"mapQuery": "한식 청담",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cheongdam-luxury",
|
||||
"name": {
|
||||
"ko": "청담 명품거리",
|
||||
"en": "Cheongdam luxury street"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "청담",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "명품 플래그십 매장이 이어지는 거리입니다. 택시로 3분.",
|
||||
"en": "A street of luxury flagship stores. 3 minutes by taxi."
|
||||
},
|
||||
"mapQuery": "청담 명품거리",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "galleria",
|
||||
"name": {
|
||||
"ko": "갤러리아백화점 명품관",
|
||||
"en": "Galleria Department Store"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.9,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "압구정",
|
||||
"en": "Apgujeong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "실내 백화점입니다. 배송 서비스를 이용하면 짐을 들지 않아도 됩니다.",
|
||||
"en": "Indoor department store. Use the delivery counter so you carry nothing."
|
||||
},
|
||||
"mapQuery": "갤러리아백화점 명품관",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "apgujeong-rodeo",
|
||||
"name": {
|
||||
"ko": "압구정 로데오·K-Star Road",
|
||||
"en": "Apgujeong Rodeo and K-Star Road"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.6,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "압구정",
|
||||
"en": "Apgujeong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "기획사 건물과 아이돌 조형물이 있는 거리입니다. 걸어서도 갑니다.",
|
||||
"en": "Street of entertainment agencies and idol art figures, within walking distance."
|
||||
},
|
||||
"mapQuery": "K-Star Road 압구정",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "coex",
|
||||
"name": {
|
||||
"ko": "코엑스몰·별마당도서관",
|
||||
"en": "COEX Mall and Starfield Library"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.1,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "삼성동",
|
||||
"en": "Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "실내 쇼핑몰과 도서관, 아쿠아리움이 한 건물에 있습니다.",
|
||||
"en": "Indoor mall, library and aquarium in one building."
|
||||
},
|
||||
"mapQuery": "코엑스몰",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "dosan-park",
|
||||
"name": {
|
||||
"ko": "도산공원",
|
||||
"en": "Dosan Park"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.9,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "신사동",
|
||||
"en": "Sinsa"
|
||||
},
|
||||
"note": {
|
||||
"ko": "평지 산책로가 있는 공원입니다. 20분 정도 걷기 좋습니다.",
|
||||
"en": "Flat paths, good for a 20-minute walk."
|
||||
},
|
||||
"mapQuery": "도산공원",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "seonjeongneung",
|
||||
"name": {
|
||||
"ko": "선정릉",
|
||||
"en": "Seonjeongneung Royal Tombs"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 1.6,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "선릉",
|
||||
"en": "Seolleung"
|
||||
},
|
||||
"note": {
|
||||
"ko": "유네스코 세계유산 왕릉입니다. 숲길이 그늘져서 햇빛을 피하기 좋습니다.",
|
||||
"en": "UNESCO World Heritage royal tombs with shaded forest paths."
|
||||
},
|
||||
"mapQuery": "선정릉",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "bongeunsa",
|
||||
"name": {
|
||||
"ko": "봉은사",
|
||||
"en": "Bongeunsa Temple"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.2,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "삼성동",
|
||||
"en": "Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "코엑스 맞은편의 사찰입니다. 경사가 완만합니다.",
|
||||
"en": "Temple across from COEX. Gentle slopes."
|
||||
},
|
||||
"mapQuery": "봉은사",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hangang",
|
||||
"name": {
|
||||
"ko": "압구정·잠원 한강공원",
|
||||
"en": "Apgujeong and Jamwon Hangang Park"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 1.5,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "압구정",
|
||||
"en": "Apgujeong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "강변 산책로입니다. 저녁에 걷기 좋습니다.",
|
||||
"en": "Riverside walk, good in the evening."
|
||||
},
|
||||
"mapQuery": "압구정 한강공원",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "seongsu",
|
||||
"name": {
|
||||
"ko": "성수동 카페·팝업",
|
||||
"en": "Seongsu cafes and pop-ups"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 3.5,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "성수동",
|
||||
"en": "Seongsu"
|
||||
},
|
||||
"note": {
|
||||
"ko": "브랜드 팝업과 카페가 모인 동네입니다. 주말은 인파가 많습니다.",
|
||||
"en": "Brand pop-ups and cafes. Crowded on weekends."
|
||||
},
|
||||
"mapQuery": "성수동 팝업",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "kakao-gangnam",
|
||||
"name": {
|
||||
"ko": "카카오프렌즈 강남 플래그십",
|
||||
"en": "Kakao Friends Gangnam Flagship"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남역",
|
||||
"en": "Gangnam Station"
|
||||
},
|
||||
"note": {
|
||||
"ko": "캐릭터 굿즈 매장입니다. 택시로 10분.",
|
||||
"en": "Character merchandise store, 10 minutes by taxi."
|
||||
},
|
||||
"mapQuery": "카카오프렌즈 강남플래그십스토어",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "lotte-tower",
|
||||
"name": {
|
||||
"ko": "롯데월드타워·서울스카이",
|
||||
"en": "Lotte World Tower and Seoul Sky"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "잠실",
|
||||
"en": "Jamsil"
|
||||
},
|
||||
"note": {
|
||||
"ko": "실내 전망대와 쇼핑몰입니다. 택시로 20분.",
|
||||
"en": "Indoor observatory and mall. 20 minutes by taxi."
|
||||
},
|
||||
"mapQuery": "롯데월드타워",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "gyeongbokgung",
|
||||
"name": {
|
||||
"ko": "경복궁·북촌",
|
||||
"en": "Gyeongbokgung Palace and Bukchon"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 8,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "종로",
|
||||
"en": "Jongno"
|
||||
},
|
||||
"note": {
|
||||
"ko": "택시로 30분 이상 걸립니다. 자유 외출 단계에서 가세요.",
|
||||
"en": "Over 30 minutes by taxi. Save for full-day outings."
|
||||
},
|
||||
"mapQuery": "경복궁",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "myeongdong",
|
||||
"name": {
|
||||
"ko": "명동",
|
||||
"en": "Myeongdong"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 7,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "중구",
|
||||
"en": "Jung-gu"
|
||||
},
|
||||
"note": {
|
||||
"ko": "화장품과 길거리 음식이 많습니다. 인파가 많으니 부딪히지 않게 다니세요.",
|
||||
"en": "Cosmetics and street food. Crowded, so mind bumps."
|
||||
},
|
||||
"mapQuery": "명동",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hongdae",
|
||||
"name": {
|
||||
"ko": "홍대",
|
||||
"en": "Hongdae"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 12,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "홍대입구",
|
||||
"en": "Hongik Univ."
|
||||
},
|
||||
"note": {
|
||||
"ko": "버스킹과 굿즈숍이 있습니다. 왕복 1시간 이상 걸립니다.",
|
||||
"en": "Busking and merch shops. Over an hour round trip."
|
||||
},
|
||||
"mapQuery": "홍대 걷고싶은거리",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "clinic-aftercare",
|
||||
"name": {
|
||||
"ko": "오라클피부과 경과 확인",
|
||||
"en": "Oracle Dermatology follow-up"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.05,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "병원",
|
||||
"en": "Clinic"
|
||||
},
|
||||
"note": {
|
||||
"ko": "경과 확인과 관리는 병원 안내에 따라 예약합니다.",
|
||||
"en": "Follow-up checks and care, booked per the clinic's guidance."
|
||||
},
|
||||
"mapQuery": "오라클피부과 청담",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hotel-lounge",
|
||||
"name": {
|
||||
"ko": "호텔 스파·라운지 (숙소 안)",
|
||||
"en": "Hotel spa and lounge (at your hotel)"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "숙소",
|
||||
"en": "Your hotel"
|
||||
},
|
||||
"note": {
|
||||
"ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.",
|
||||
"en": "Skip sauna and massage until your doctor allows. Lounge and room service are fine."
|
||||
},
|
||||
"mapQuery": null,
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hotel-andaz",
|
||||
"name": {
|
||||
"ko": "안다즈 서울 강남",
|
||||
"en": "Andaz Seoul Gangnam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 1.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "압구정 로데오",
|
||||
"en": "Apgujeong Rodeo"
|
||||
},
|
||||
"note": {
|
||||
"ko": "압구정 로데오의 고급 호텔입니다. 병원까지 택시로 5분.",
|
||||
"en": "Upscale hotel at Apgujeong Rodeo. 5 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "안다즈 서울 강남",
|
||||
"verify": true,
|
||||
"bookingQuery": "Andaz Seoul Gangnam"
|
||||
},
|
||||
{
|
||||
"id": "hotel-riviera",
|
||||
"name": {
|
||||
"ko": "호텔 리베라 청담",
|
||||
"en": "Hotel Riviera Cheongdam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 1.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "청담동",
|
||||
"en": "Cheongdam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "영동대교 남단의 호텔입니다. 병원까지 택시로 5분.",
|
||||
"en": "Hotel at the south end of Yeongdong Bridge. 5 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "호텔 리베라 청담",
|
||||
"verify": true,
|
||||
"bookingQuery": "Hotel Riviera Cheongdam Seoul"
|
||||
},
|
||||
{
|
||||
"id": "hotel-parkhyatt",
|
||||
"name": {
|
||||
"ko": "파크 하얏트 서울",
|
||||
"en": "Park Hyatt Seoul"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 2.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "삼성역",
|
||||
"en": "Samseong Station"
|
||||
},
|
||||
"note": {
|
||||
"ko": "삼성역 앞 고급 호텔입니다. 병원까지 택시로 10분.",
|
||||
"en": "Luxury hotel at Samseong Station. 10 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "파크 하얏트 서울",
|
||||
"verify": true,
|
||||
"bookingQuery": "Park Hyatt Seoul"
|
||||
},
|
||||
{
|
||||
"id": "hotel-intercontinental",
|
||||
"name": {
|
||||
"ko": "인터컨티넨탈 서울 코엑스",
|
||||
"en": "InterContinental Seoul COEX"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 2.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "삼성동",
|
||||
"en": "Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "코엑스와 연결된 호텔입니다. 병원까지 택시로 10분.",
|
||||
"en": "Connected to COEX. 10 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "인터컨티넨탈 서울 코엑스",
|
||||
"verify": true,
|
||||
"bookingQuery": "InterContinental Seoul COEX"
|
||||
},
|
||||
{
|
||||
"id": "hotel-josun",
|
||||
"name": {
|
||||
"ko": "조선 팰리스 강남",
|
||||
"en": "Josun Palace Seoul Gangnam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 2.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "역삼동",
|
||||
"en": "Yeoksam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "역삼동의 고급 호텔입니다. 병원까지 택시로 10분.",
|
||||
"en": "Luxury hotel in Yeoksam. 10 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "조선 팰리스 서울 강남",
|
||||
"verify": true,
|
||||
"bookingQuery": "Josun Palace Seoul Gangnam"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
{
|
||||
"_comment": "병원별 고정값. 워커가 --name 등 인자보다 먼저 읽는다. factSheet 값은 뷰성형외과 홈페이지 표기와 대조해 확정한 것으로, 수작업 샘플 supporters/src/data/factSheet.json 과 같다. 홈 제목에서 상호를 추정하면 슬로건까지 들어가므로(\"안전을 최우선하는 뷰성형외과\") 여기서 고정한다.",
|
||||
"factSheet": {
|
||||
"name": "뷰성형외과의원",
|
||||
"shortName": "뷰성형외과",
|
||||
"kind": "성형외과",
|
||||
"areaLabel": "서울 강남 신논현역",
|
||||
"founded": "2005",
|
||||
"urlEn": "https://www.viewplasticsurgery.com"
|
||||
},
|
||||
"excludeImages": [
|
||||
"img02",
|
||||
"stemcell_about_01",
|
||||
"stemcell_count_01_mo",
|
||||
"view-abdominoplasty",
|
||||
"view-motiva-preserve-premium-standard",
|
||||
"view-motiva-preserve-motiva-smooth-silk"
|
||||
],
|
||||
"_excludeNote": "2026-09-12 육안 확인으로 제외. img02 는 밑선 절개 흉터의 1개월·6개월 경과를 나란히 놓은 전후 사진이고, stemcell_about_01 은 팔에 주사·드레싱을 하는 시술 행위 사진이며, view-abdominoplasty 두 장과 view-motiva-preserve-premium-standard 두 장은 '이미지 광고 모델' 표기가 있는 모델 신체 사진, stemcell_count_01_mo 는 장비 제조사의 성능 주장 그래픽이다. 파일명과 alt 에 신호가 없어 자동 분류가 전부 시설(clinic)로 넣었다. 2026-09-12 추가: view-motiva-preserve-motiva-smooth-silk 는 가슴·보형물 라인 일러스트다. 규칙상 시술 설명 일러스트로 허용되는 유형이지만 랜딩의 '상담 전 질문' 카드 썸네일로 붙어 주제가 어긋나고 첫 화면에 신체 그림이 노출된다(haewon 지적).",
|
||||
"site": {
|
||||
"heroImage": {
|
||||
"srcMatch": "view-building_pc",
|
||||
"alt": "뷰성형외과 건물 외관",
|
||||
"width": 886,
|
||||
"height": 885
|
||||
},
|
||||
"naverSiteVerification": "e47011473eef74d7b2c4f20e93f97a889cb699cc",
|
||||
"shareNameEn": "VIEW Plastic Surgery"
|
||||
},
|
||||
"_siteNote": "홈 히어로가 가슴 라인 일러스트로 잡혀 있었고 건물 사진의 alt 도 '가슴확대 시설 사진'으로 잘못 붙어 있었다. 건물 외관으로 고정한다.",
|
||||
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, viewpl.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다. 구글은 webforai.kr 도메인 속성을 DNS TXT 로 확인해 서브도메인 전부가 함께 확인된다.",
|
||||
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 병원 홈페이지 로고·본문 표기 VIEW PLASTIC SURGERY (근거 수집 OCR)"
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
{
|
||||
"_comment": "회복기 스케줄러 큐레이션 장소 (설계 v0.2 §13.5 1순위 소스). 기준점은 신논현역(뷰성형외과). distanceKm 는 직선 거리 근사값이고 verify 가 true 인 항목은 출시 전 영업 여부·거리를 확인한다. 링크는 구글 지도 검색과 예약 사이트 검색 결과만 쓴다(지어낸 상세 URL 없음). 축제·행사(날짜 의존)는 한국관광공사 Tour API 연결(P1) 뒤에 추가한다.",
|
||||
"origin": { "label": { "ko": "신논현역 3번 출구", "en": "Sinnonhyeon Station Exit 3" }, "lat": 37.5045, "lng": 127.0250 },
|
||||
"categories": {
|
||||
"sightseeing": { "ko": "관광", "en": "Sightseeing" },
|
||||
"shopping": { "ko": "쇼핑", "en": "Shopping" },
|
||||
"food": { "ko": "미식", "en": "Food" },
|
||||
"kpop": { "ko": "K-pop·핫플", "en": "K-pop & hotspots" },
|
||||
"care": { "ko": "휴식·케어", "en": "Rest & care" },
|
||||
"essentials": { "ko": "생활", "en": "Essentials" },
|
||||
"hotel": { "ko": "숙소", "en": "Hotels" }
|
||||
},
|
||||
"places": [
|
||||
{ "id": "pharmacy-sinnonhyeon", "name": { "ko": "신논현역 주변 약국", "en": "Pharmacies near Sinnonhyeon Station" }, "category": "essentials", "minLevel": 1, "distanceKm": 0.2, "indoor": true, "area": { "ko": "신논현", "en": "Sinnonhyeon" }, "note": { "ko": "병원 건물 옆 골목에 약국이 있습니다. 처방약과 냉찜질 팩을 구할 수 있습니다.", "en": "Pharmacies in the alley next to the clinic building. Prescriptions and cold packs." }, "mapQuery": "약국 신논현역", "verify": true },
|
||||
{ "id": "convenience-sinnonhyeon", "name": { "ko": "편의점 (신논현역 일대)", "en": "Convenience stores (Sinnonhyeon area)" }, "category": "essentials", "minLevel": 1, "distanceKm": 0.1, "indoor": true, "area": { "ko": "신논현", "en": "Sinnonhyeon" }, "note": { "ko": "생수, 죽, 빨대, 얼음을 24시간 살 수 있습니다.", "en": "Water, porridge, straws and ice, 24 hours." }, "mapQuery": "편의점 신논현역", "verify": false },
|
||||
{ "id": "porridge-chain", "name": { "ko": "죽 전문점 (본죽 등 체인)", "en": "Korean porridge (Bonjuk and similar chains)" }, "category": "food", "minLevel": 1, "dietOk": ["liquid", "soft", "mild", "normal"], "distanceKm": 0.4, "indoor": true, "area": { "ko": "신논현·강남", "en": "Sinnonhyeon / Gangnam" }, "note": { "ko": "유동식 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.", "en": "Porridge suitable for the liquid-diet stage, by delivery or takeout." }, "mapQuery": "본죽 신논현역", "verify": true },
|
||||
{ "id": "smoothie-cafe", "name": { "ko": "스무디·주스 카페 (강남대로)", "en": "Smoothie and juice cafes (Gangnam-daero)" }, "category": "food", "minLevel": 1, "dietOk": ["liquid", "soft", "mild", "normal"], "distanceKm": 0.5, "indoor": true, "area": { "ko": "강남대로", "en": "Gangnam-daero" }, "note": { "ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.", "en": "Plenty of straw-friendly options. Choose nothing hot." }, "mapQuery": "스무디 카페 신논현역", "verify": true },
|
||||
{ "id": "seolleongtang", "name": { "ko": "설렁탕·곰탕 (강남 일대)", "en": "Seolleongtang beef bone soup (Gangnam area)" }, "category": "food", "minLevel": 1, "dietOk": ["soft", "mild", "normal"], "distanceKm": 0.6, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "국물 위주라 부드러운 음식 단계부터 가능합니다. 밥을 말아 천천히 드세요.", "en": "Broth-based, fine from the soft-food stage. Eat slowly with rice in the soup." }, "mapQuery": "설렁탕 강남역", "verify": true },
|
||||
{ "id": "sulbing", "name": { "ko": "설빙 (빙수 디저트)", "en": "Sulbing shaved-ice dessert" }, "category": "food", "minLevel": 1, "dietOk": ["soft", "mild", "normal"], "distanceKm": 0.6, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "차갑고 부드러워 부기가 있을 때도 먹기 편합니다.", "en": "Cold and soft, comfortable even with swelling." }, "mapQuery": "설빙 강남", "verify": true },
|
||||
{ "id": "cafe-garosugil", "name": { "ko": "가로수길 카페", "en": "Garosu-gil cafes" }, "category": "food", "minLevel": 2, "dietOk": ["soft", "mild", "normal"], "distanceKm": 1.2, "indoor": true, "area": { "ko": "신사동 가로수길", "en": "Garosu-gil, Sinsa" }, "note": { "ko": "조용한 카페가 많아 오후에 잠깐 앉아 있기 좋습니다.", "en": "Quiet cafes, good for a short afternoon sit-down." }, "mapQuery": "가로수길 카페", "verify": false },
|
||||
{ "id": "korean-bbq-gangnam", "name": { "ko": "강남 한식·구이", "en": "Korean BBQ and set meals (Gangnam)" }, "category": "food", "minLevel": 2, "dietOk": ["normal"], "distanceKm": 0.8, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "일반식이 가능한 단계부터입니다. 담당 원장이 술을 허용하기 전에는 음료로 대신하세요.", "en": "From the regular-diet stage. Skip alcohol until your surgeon allows it." }, "mapQuery": "한식 맛집 강남역", "verify": false },
|
||||
{ "id": "garosugil", "name": { "ko": "가로수길", "en": "Garosu-gil" }, "category": "shopping", "minLevel": 2, "distanceKm": 1.2, "indoor": false, "area": { "ko": "신사동", "en": "Sinsa" }, "note": { "ko": "편집숍과 뷰티 매장이 모여 있는 거리입니다. 택시로 5분.", "en": "A street of boutiques and beauty stores. 5 minutes by taxi." }, "mapQuery": "가로수길", "verify": false },
|
||||
{ "id": "oliveyoung-gangnam", "name": { "ko": "올리브영 강남 타운", "en": "Olive Young Gangnam Town" }, "category": "shopping", "minLevel": 2, "distanceKm": 0.9, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "K-뷰티 대형 매장입니다. 수술 부위에 닿는 화장품은 담당 원장 안내를 따르세요.", "en": "Large K-beauty store. Follow your surgeon's guidance on products near the surgical area." }, "mapQuery": "올리브영 강남타운", "verify": true },
|
||||
{ "id": "shinsegae-gangnam", "name": { "ko": "신세계백화점 강남점", "en": "Shinsegae Department Store Gangnam" }, "category": "shopping", "minLevel": 2, "distanceKm": 2.4, "indoor": true, "area": { "ko": "고속터미널", "en": "Express Bus Terminal" }, "note": { "ko": "지하 파미에스테이션과 연결됩니다. 실내라 날씨와 상관없이 다닐 수 있습니다.", "en": "Connected to the Famille Station underground mall. Fully indoors." }, "mapQuery": "신세계백화점 강남점", "verify": false },
|
||||
{ "id": "hyundai-apgujeong", "name": { "ko": "현대백화점 압구정본점", "en": "Hyundai Department Store Apgujeong" }, "category": "shopping", "minLevel": 2, "distanceKm": 2.0, "indoor": true, "area": { "ko": "압구정", "en": "Apgujeong" }, "note": { "ko": "택시로 10분. 배송 서비스를 이용하면 짐을 들지 않아도 됩니다.", "en": "10 minutes by taxi. Use the delivery counter so you carry nothing." }, "mapQuery": "현대백화점 압구정본점", "verify": false },
|
||||
{ "id": "coex-mall", "name": { "ko": "코엑스몰·별마당도서관", "en": "COEX Mall and Starfield Library" }, "category": "shopping", "minLevel": 2, "distanceKm": 3.4, "indoor": true, "area": { "ko": "삼성동", "en": "Samseong" }, "note": { "ko": "실내 쇼핑몰과 도서관, 아쿠아리움이 한 건물에 있습니다.", "en": "Indoor mall, library and aquarium in one building." }, "mapQuery": "코엑스몰", "verify": false },
|
||||
{ "id": "apgujeong-rodeo", "name": { "ko": "압구정 로데오·K-Star Road", "en": "Apgujeong Rodeo and K-Star Road" }, "category": "kpop", "minLevel": 2, "distanceKm": 2.6, "indoor": false, "area": { "ko": "압구정", "en": "Apgujeong" }, "note": { "ko": "기획사 건물과 아이돌 조형물이 있는 거리입니다.", "en": "Street of entertainment agencies and idol art figures." }, "mapQuery": "K-Star Road 압구정", "verify": true },
|
||||
{ "id": "kwangya-seongsu", "name": { "ko": "KWANGYA@SEOUL (성수)", "en": "KWANGYA@SEOUL (Seongsu)" }, "category": "kpop", "minLevel": 3, "distanceKm": 5.5, "indoor": true, "area": { "ko": "성수동", "en": "Seongsu" }, "note": { "ko": "SM 엔터테인먼트 공식 굿즈 매장입니다. 운영 시간을 확인하고 가세요.", "en": "SM Entertainment's official merchandise store. Check hours before visiting." }, "mapQuery": "KWANGYA@SEOUL", "verify": true },
|
||||
{ "id": "seongsu", "name": { "ko": "성수동 카페·팝업", "en": "Seongsu cafes and pop-ups" }, "category": "kpop", "minLevel": 3, "distanceKm": 5.5, "indoor": false, "area": { "ko": "성수동", "en": "Seongsu" }, "note": { "ko": "브랜드 팝업과 카페가 모인 동네입니다. 주말은 인파가 많습니다.", "en": "Brand pop-ups and cafes. Crowded on weekends." }, "mapQuery": "성수동 팝업", "verify": false },
|
||||
{ "id": "hongdae", "name": { "ko": "홍대", "en": "Hongdae" }, "category": "kpop", "minLevel": 3, "distanceKm": 11, "indoor": false, "area": { "ko": "홍대입구", "en": "Hongik Univ." }, "note": { "ko": "버스킹과 굿즈숍이 있습니다. 왕복 1시간 이상 걸리니 자유 외출 단계에서 가세요.", "en": "Busking and merch shops. Over an hour round trip, so save it for full-day outings." }, "mapQuery": "홍대 걷고싶은거리", "verify": false },
|
||||
{ "id": "kakao-friends-gangnam", "name": { "ko": "카카오프렌즈 강남 플래그십", "en": "Kakao Friends Gangnam Flagship" }, "category": "kpop", "minLevel": 2, "distanceKm": 0.9, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "캐릭터 굿즈 매장입니다. 걸어서도 갈 수 있는 거리입니다.", "en": "Character merchandise store, within walking distance." }, "mapQuery": "카카오프렌즈 강남플래그십스토어", "verify": true },
|
||||
{ "id": "bongeunsa", "name": { "ko": "봉은사", "en": "Bongeunsa Temple" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 3.6, "indoor": false, "area": { "ko": "삼성동", "en": "Samseong" }, "note": { "ko": "코엑스 맞은편의 사찰입니다. 경사가 완만해 천천히 걷기 좋습니다.", "en": "Temple across from COEX. Gentle slopes, good for a slow walk." }, "mapQuery": "봉은사", "verify": false },
|
||||
{ "id": "seonjeongneung", "name": { "ko": "선정릉", "en": "Seonjeongneung Royal Tombs" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 2.4, "indoor": false, "area": { "ko": "선릉", "en": "Seolleung" }, "note": { "ko": "유네스코 세계유산 왕릉입니다. 숲길이 그늘져서 햇빛을 피하기 좋습니다.", "en": "UNESCO World Heritage royal tombs. Shaded forest paths." }, "mapQuery": "선정릉", "verify": false },
|
||||
{ "id": "banpo-hangang", "name": { "ko": "반포한강공원·달빛무지개분수", "en": "Banpo Hangang Park and Moonlight Rainbow Fountain" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 2.8, "indoor": false, "area": { "ko": "반포", "en": "Banpo" }, "note": { "ko": "저녁에 분수 공연을 볼 수 있습니다. 운영 기간을 확인하세요.", "en": "Evening fountain show. Check the season schedule." }, "mapQuery": "반포한강공원", "verify": false },
|
||||
{ "id": "gyeongbokgung", "name": { "ko": "경복궁·북촌", "en": "Gyeongbokgung Palace and Bukchon" }, "category": "sightseeing", "minLevel": 3, "distanceKm": 9, "indoor": false, "area": { "ko": "종로", "en": "Jongno" }, "note": { "ko": "택시로 30분 이상 걸립니다. 걷는 시간이 길어 자유 외출 단계에서 가세요.", "en": "Over 30 minutes by taxi and a lot of walking. Save for full-day outings." }, "mapQuery": "경복궁", "verify": false },
|
||||
{ "id": "lotte-tower", "name": { "ko": "롯데월드타워·서울스카이", "en": "Lotte World Tower and Seoul Sky" }, "category": "sightseeing", "minLevel": 3, "distanceKm": 8, "indoor": true, "area": { "ko": "잠실", "en": "Jamsil" }, "note": { "ko": "실내 전망대와 쇼핑몰입니다. 택시로 25분.", "en": "Indoor observatory and mall. 25 minutes by taxi." }, "mapQuery": "롯데월드타워", "verify": false },
|
||||
{ "id": "myeongdong", "name": { "ko": "명동", "en": "Myeongdong" }, "category": "shopping", "minLevel": 3, "distanceKm": 7, "indoor": false, "area": { "ko": "중구", "en": "Jung-gu" }, "note": { "ko": "화장품과 길거리 음식이 많습니다. 인파가 많으니 부딪히지 않게 다니세요.", "en": "Cosmetics and street food. Crowded, so mind bumps." }, "mapQuery": "명동", "verify": false },
|
||||
{ "id": "clinic-aftercare", "name": { "ko": "뷰성형외과 애프터케어", "en": "View Plastic Surgery Aftercare" }, "category": "care", "minLevel": 1, "distanceKm": 0.05, "indoor": true, "area": { "ko": "병원", "en": "Clinic" }, "note": { "ko": "고주파 관리와 경과 확인은 병원 안내에 따라 예약합니다.", "en": "Radiofrequency care and follow-up checks, booked per the clinic's guidance." }, "mapQuery": "뷰성형외과", "verify": false },
|
||||
{ "id": "clinic-dental", "name": { "ko": "뷰성형외과 15층 치과", "en": "View Plastic Surgery Dental Clinic (15F)" }, "category": "care", "minLevel": 1, "distanceKm": 0.05, "indoor": true, "area": { "ko": "병원", "en": "Clinic" }, "note": { "ko": "양악·윤곽 협진 치과입니다. 여유 시간에 검진을 받을 수 있는지 상담에서 물어보세요.", "en": "Dental clinic for jaw and contouring cases. Ask at consultation about a check-up during your stay." }, "mapQuery": "뷰성형외과", "verify": false },
|
||||
{ "id": "hotel-spa-gangnam", "name": { "ko": "호텔 스파·라운지 (숙소 안)", "en": "Hotel spa and lounge (at your hotel)" }, "category": "care", "minLevel": 0, "distanceKm": 0, "indoor": true, "area": { "ko": "숙소", "en": "Your hotel" }, "note": { "ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.", "en": "Skip sauna and massage until your surgeon allows. Lounge and room service are fine." }, "mapQuery": null, "verify": false },
|
||||
{ "id": "hotel-cappuccino", "name": { "ko": "호텔 카푸치노", "en": "Hotel Cappuccino" }, "category": "hotel", "minLevel": 0, "distanceKm": 0.5, "indoor": true, "area": { "ko": "논현동 봉은사로", "en": "Nonhyeon, Bongeunsa-ro" }, "note": { "ko": "병원과 같은 봉은사로에 있어 걸어서 다닐 수 있습니다.", "en": "On the same road as the clinic, within walking distance." }, "mapQuery": "호텔 카푸치노 논현", "bookingQuery": "Hotel Cappuccino Seoul", "verify": true },
|
||||
{ "id": "novotel-gangnam", "name": { "ko": "노보텔 앰배서더 강남", "en": "Novotel Ambassador Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.0, "indoor": true, "area": { "ko": "역삼동", "en": "Yeoksam" }, "note": { "ko": "강남역 근처 대형 호텔입니다. 룸서비스와 24시간 프런트가 있습니다.", "en": "Large hotel near Gangnam Station with room service and a 24-hour front desk." }, "mapQuery": "노보텔 앰배서더 강남", "bookingQuery": "Novotel Ambassador Seoul Gangnam", "verify": true },
|
||||
{ "id": "l7-gangnam", "name": { "ko": "L7 강남 바이 롯데", "en": "L7 Gangnam by Lotte" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.4, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "강남역 인근이며 택시로 5분입니다.", "en": "Near Gangnam Station, 5 minutes by taxi." }, "mapQuery": "L7 강남", "bookingQuery": "L7 Gangnam by Lotte", "verify": true },
|
||||
{ "id": "andaz-gangnam", "name": { "ko": "안다즈 서울 강남", "en": "Andaz Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 2.6, "indoor": true, "area": { "ko": "압구정 로데오", "en": "Apgujeong Rodeo" }, "note": { "ko": "압구정 로데오에 있는 고급 호텔입니다. 병원까지 택시로 10분.", "en": "Upscale hotel at Apgujeong Rodeo. 10 minutes to the clinic by taxi." }, "mapQuery": "안다즈 서울 강남", "bookingQuery": "Andaz Seoul Gangnam", "verify": true },
|
||||
{ "id": "josun-palace", "name": { "ko": "조선 팰리스 강남", "en": "Josun Palace Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.8, "indoor": true, "area": { "ko": "역삼동", "en": "Yeoksam" }, "note": { "ko": "역삼동의 고급 호텔입니다. 병원까지 택시로 10분.", "en": "Luxury hotel in Yeoksam. 10 minutes to the clinic by taxi." }, "mapQuery": "조선 팰리스 서울 강남", "bookingQuery": "Josun Palace Seoul Gangnam", "verify": true }
|
||||
]
|
||||
}
|
||||
@ -2,25 +2,5 @@
|
||||
"_comment": "병원별 고정값. 워커가 --youtube 등 인자보다 먼저 읽는다. haewon 결정 2026-09-08: 유튜브 기준 채널은 @wjwonjin (UC-EBM4e0VST1W_PQ0NzFMQg, 원장 설명 영상 736편). @wj2541(UCNBaQBCok5xIViCnrGdisoQ)은 브랜드 필름 채널이라 쓰지 않는다.",
|
||||
"youtube": "UC-EBM4e0VST1W_PQ0NzFMQg",
|
||||
"youtubeHandle": "@wjwonjin",
|
||||
"logo": "supporters/briefs/wonjin/logo-horizontal.svg",
|
||||
"factSheet": {
|
||||
"name": "원진성형외과의원",
|
||||
"shortName": "원진성형외과",
|
||||
"kind": "성형외과"
|
||||
},
|
||||
"_factSheetNote": "상호 고정. 홈 제목에서 추정하면 슬로건 '당신만 봅니다'가 상호가 되어 화면 전체와 네이버 지역검색(주변 안내 기준 좌표)까지 틀어진다. 근거: supporters-wonjin 기존 배포본 표기. kind 는 상호에 들어 있는 값이다. areaLabel·founded 는 근거를 확인하지 못해 비워 둔다.",
|
||||
"site": {
|
||||
"heroImage": {
|
||||
"srcMatch": "img_about_cover_03",
|
||||
"alt": "원진성형외과 글로벌 허브 리셉션과 대기 공간",
|
||||
"width": 1600,
|
||||
"height": 633
|
||||
},
|
||||
"naverSiteVerification": "5df40b24e88fd3fdf7ece9dd0fb69cf83439ad44",
|
||||
"shareNameEn": "WONJIN Plastic Surgery"
|
||||
},
|
||||
"_siteNote": "2026-09-12 haewon 지정. 랜딩 히어로를 img_about_cover_03(글로벌 허브 리셉션·대기 공간)으로 고정한다. 이전 값 img_about_cover_01 은 원장이 모니터를 가리키는 사진이라 글 카드와 겹쳐 보였다. alt 는 수집 원문 대신 무엇을 찍은 것인지만 밝히는 문구로 쓴다.",
|
||||
"_imageReviewNote": "2026-09-12 haewon 육안 검토: 원진 수집 이미지 66장 전부 사용 가능. 제외할 것 없음.",
|
||||
"_verifyNote": "네이버 서치어드바이저 소유 확인 값(2026-09-14 발급, wonjin.webforai.kr 용). 사이트마다 값이 다르므로 다른 병원에 옮겨 쓰면 확인되지 않는다.",
|
||||
"_shareNameEnNote": "shareNameEn: 링크 공유 이미지(og.jpg)의 영문 병원명. 출처: 병원 로고 표기 WJ WONJIN PLASTIC SURGERY (근거 수집 OCR)"
|
||||
"logo": "supporters/briefs/wonjin/logo-horizontal.svg"
|
||||
}
|
||||
|
||||
@ -1,729 +0,0 @@
|
||||
{
|
||||
"_comment": "원진성형외과(강남대로 419, 신논현역과 강남역 사이) 회복 일정 플래너 큐레이션. 뷰성형외과 큐레이션을 기준점만 바꿔 거리를 보정한 초안이라 출시 전 확인(verify). 2026-09-11.",
|
||||
"origin": {
|
||||
"label": {
|
||||
"ko": "원진성형외과 (강남대로 419)",
|
||||
"en": "Wonjin Plastic Surgery, Gangnam-daero"
|
||||
},
|
||||
"lat": 37.4998653,
|
||||
"lng": 127.0262161
|
||||
},
|
||||
"categories": {
|
||||
"sightseeing": {
|
||||
"ko": "관광",
|
||||
"en": "Sightseeing"
|
||||
},
|
||||
"shopping": {
|
||||
"ko": "쇼핑",
|
||||
"en": "Shopping"
|
||||
},
|
||||
"food": {
|
||||
"ko": "미식",
|
||||
"en": "Food"
|
||||
},
|
||||
"kpop": {
|
||||
"ko": "K-pop·핫플",
|
||||
"en": "K-pop & hotspots"
|
||||
},
|
||||
"care": {
|
||||
"ko": "휴식·케어",
|
||||
"en": "Rest & care"
|
||||
},
|
||||
"essentials": {
|
||||
"ko": "생활",
|
||||
"en": "Essentials"
|
||||
},
|
||||
"hotel": {
|
||||
"ko": "숙소",
|
||||
"en": "Hotels"
|
||||
}
|
||||
},
|
||||
"places": [
|
||||
{
|
||||
"id": "pharmacy-sinnonhyeon",
|
||||
"name": {
|
||||
"ko": "강남대로 주변 약국",
|
||||
"en": "Pharmacies on Gangnam-daero"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.3,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "신논현",
|
||||
"en": "Sinnonhyeon"
|
||||
},
|
||||
"note": {
|
||||
"ko": "강남대로 큰길가에 약국이 여럿 있습니다. 처방약과 냉찜질 팩을 구할 수 있습니다.",
|
||||
"en": "Several pharmacies along Gangnam-daero. Prescriptions and cold packs."
|
||||
},
|
||||
"mapQuery": "약국 강남대로 419",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "convenience-sinnonhyeon",
|
||||
"name": {
|
||||
"ko": "편의점 (강남대로 일대)",
|
||||
"en": "Convenience stores (Gangnam-daero)"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.1,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "신논현",
|
||||
"en": "Sinnonhyeon"
|
||||
},
|
||||
"note": {
|
||||
"ko": "생수, 죽, 빨대, 얼음을 24시간 살 수 있습니다.",
|
||||
"en": "Water, porridge, straws and ice, 24 hours."
|
||||
},
|
||||
"mapQuery": "편의점 강남대로 419",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "porridge-chain",
|
||||
"name": {
|
||||
"ko": "죽 전문점 (본죽 등 체인)",
|
||||
"en": "Korean porridge (Bonjuk and similar chains)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "신논현·강남",
|
||||
"en": "Sinnonhyeon / Gangnam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "유동식 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.",
|
||||
"en": "Porridge suitable for the liquid-diet stage, by delivery or takeout."
|
||||
},
|
||||
"mapQuery": "본죽 신논현역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "smoothie-cafe",
|
||||
"name": {
|
||||
"ko": "스무디·주스 카페 (강남대로)",
|
||||
"en": "Smoothie and juice cafes (Gangnam-daero)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남대로",
|
||||
"en": "Gangnam-daero"
|
||||
},
|
||||
"note": {
|
||||
"ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.",
|
||||
"en": "Plenty of straw-friendly options. Choose nothing hot."
|
||||
},
|
||||
"mapQuery": "스무디 카페 신논현역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "seolleongtang",
|
||||
"name": {
|
||||
"ko": "설렁탕·곰탕 (강남 일대)",
|
||||
"en": "Seolleongtang beef bone soup (Gangnam area)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남",
|
||||
"en": "Gangnam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "국물 위주라 부드러운 음식 단계부터 가능합니다. 밥을 말아 천천히 드세요.",
|
||||
"en": "Broth-based, fine from the soft-food stage. Eat slowly with rice in the soup."
|
||||
},
|
||||
"mapQuery": "설렁탕 강남역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "sulbing",
|
||||
"name": {
|
||||
"ko": "설빙 (빙수 디저트)",
|
||||
"en": "Sulbing shaved-ice dessert"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남",
|
||||
"en": "Gangnam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "차갑고 부드러워 부기가 있을 때도 먹기 편합니다.",
|
||||
"en": "Cold and soft, comfortable even with swelling."
|
||||
},
|
||||
"mapQuery": "설빙 강남",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "cafe-garosugil",
|
||||
"name": {
|
||||
"ko": "가로수길 카페",
|
||||
"en": "Garosu-gil cafes"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 2,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 1.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "신사동 가로수길",
|
||||
"en": "Garosu-gil, Sinsa"
|
||||
},
|
||||
"note": {
|
||||
"ko": "조용한 카페가 많아 오후에 잠깐 앉아 있기 좋습니다.",
|
||||
"en": "Quiet cafes, good for a short afternoon sit-down."
|
||||
},
|
||||
"mapQuery": "가로수길 카페",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "korean-bbq-gangnam",
|
||||
"name": {
|
||||
"ko": "강남 한식·구이",
|
||||
"en": "Korean BBQ and set meals (Gangnam)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 2,
|
||||
"dietOk": [
|
||||
"normal"
|
||||
],
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남",
|
||||
"en": "Gangnam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "일반식이 가능한 단계부터입니다. 담당 원장이 술을 허용하기 전에는 음료로 대신하세요.",
|
||||
"en": "From the regular-diet stage. Skip alcohol until your surgeon allows it."
|
||||
},
|
||||
"mapQuery": "한식 맛집 강남역",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "garosugil",
|
||||
"name": {
|
||||
"ko": "가로수길",
|
||||
"en": "Garosu-gil"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 1.6,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "신사동",
|
||||
"en": "Sinsa"
|
||||
},
|
||||
"note": {
|
||||
"ko": "편집숍과 뷰티 매장이 모여 있는 거리입니다. 택시로 5분.",
|
||||
"en": "A street of boutiques and beauty stores. 5 minutes by taxi."
|
||||
},
|
||||
"mapQuery": "가로수길",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "oliveyoung-gangnam",
|
||||
"name": {
|
||||
"ko": "올리브영 강남 타운",
|
||||
"en": "Olive Young Gangnam Town"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남역",
|
||||
"en": "Gangnam Station"
|
||||
},
|
||||
"note": {
|
||||
"ko": "K-뷰티 대형 매장입니다. 수술 부위에 닿는 화장품은 담당 원장 안내를 따르세요.",
|
||||
"en": "Large K-beauty store. Follow your surgeon's guidance on products near the surgical area."
|
||||
},
|
||||
"mapQuery": "올리브영 강남타운",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "shinsegae-gangnam",
|
||||
"name": {
|
||||
"ko": "신세계백화점 강남점",
|
||||
"en": "Shinsegae Department Store Gangnam"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "고속터미널",
|
||||
"en": "Express Bus Terminal"
|
||||
},
|
||||
"note": {
|
||||
"ko": "지하 파미에스테이션과 연결됩니다. 실내라 날씨와 상관없이 다닐 수 있습니다.",
|
||||
"en": "Connected to the Famille Station underground mall. Fully indoors."
|
||||
},
|
||||
"mapQuery": "신세계백화점 강남점",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hyundai-apgujeong",
|
||||
"name": {
|
||||
"ko": "현대백화점 압구정본점",
|
||||
"en": "Hyundai Department Store Apgujeong"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "압구정",
|
||||
"en": "Apgujeong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "택시로 10분. 배송 서비스를 이용하면 짐을 들지 않아도 됩니다.",
|
||||
"en": "10 minutes by taxi. Use the delivery counter so you carry nothing."
|
||||
},
|
||||
"mapQuery": "현대백화점 압구정본점",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "coex-mall",
|
||||
"name": {
|
||||
"ko": "코엑스몰·별마당도서관",
|
||||
"en": "COEX Mall and Starfield Library"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 3.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "삼성동",
|
||||
"en": "Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "실내 쇼핑몰과 도서관, 아쿠아리움이 한 건물에 있습니다.",
|
||||
"en": "Indoor mall, library and aquarium in one building."
|
||||
},
|
||||
"mapQuery": "코엑스몰",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "apgujeong-rodeo",
|
||||
"name": {
|
||||
"ko": "압구정 로데오·K-Star Road",
|
||||
"en": "Apgujeong Rodeo and K-Star Road"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 3.0,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "압구정",
|
||||
"en": "Apgujeong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "기획사 건물과 아이돌 조형물이 있는 거리입니다.",
|
||||
"en": "Street of entertainment agencies and idol art figures."
|
||||
},
|
||||
"mapQuery": "K-Star Road 압구정",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "kwangya-seongsu",
|
||||
"name": {
|
||||
"ko": "KWANGYA@SEOUL (성수)",
|
||||
"en": "KWANGYA@SEOUL (Seongsu)"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 6.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "성수동",
|
||||
"en": "Seongsu"
|
||||
},
|
||||
"note": {
|
||||
"ko": "SM 엔터테인먼트 공식 굿즈 매장입니다. 운영 시간을 확인하고 가세요.",
|
||||
"en": "SM Entertainment's official merchandise store. Check hours before visiting."
|
||||
},
|
||||
"mapQuery": "KWANGYA@SEOUL",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "seongsu",
|
||||
"name": {
|
||||
"ko": "성수동 카페·팝업",
|
||||
"en": "Seongsu cafes and pop-ups"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 6.0,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "성수동",
|
||||
"en": "Seongsu"
|
||||
},
|
||||
"note": {
|
||||
"ko": "브랜드 팝업과 카페가 모인 동네입니다. 주말은 인파가 많습니다.",
|
||||
"en": "Brand pop-ups and cafes. Crowded on weekends."
|
||||
},
|
||||
"mapQuery": "성수동 팝업",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hongdae",
|
||||
"name": {
|
||||
"ko": "홍대",
|
||||
"en": "Hongdae"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 11,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "홍대입구",
|
||||
"en": "Hongik Univ."
|
||||
},
|
||||
"note": {
|
||||
"ko": "버스킹과 굿즈숍이 있습니다. 왕복 1시간 이상 걸리니 자유 외출 단계에서 가세요.",
|
||||
"en": "Busking and merch shops. Over an hour round trip, so save it for full-day outings."
|
||||
},
|
||||
"mapQuery": "홍대 걷고싶은거리",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "kakao-friends-gangnam",
|
||||
"name": {
|
||||
"ko": "카카오프렌즈 강남 플래그십",
|
||||
"en": "Kakao Friends Gangnam Flagship"
|
||||
},
|
||||
"category": "kpop",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남역",
|
||||
"en": "Gangnam Station"
|
||||
},
|
||||
"note": {
|
||||
"ko": "캐릭터 굿즈 매장입니다. 걸어서도 갈 수 있는 거리입니다.",
|
||||
"en": "Character merchandise store, within walking distance."
|
||||
},
|
||||
"mapQuery": "카카오프렌즈 강남플래그십스토어",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "bongeunsa",
|
||||
"name": {
|
||||
"ko": "봉은사",
|
||||
"en": "Bongeunsa Temple"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 3.8,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "삼성동",
|
||||
"en": "Samseong"
|
||||
},
|
||||
"note": {
|
||||
"ko": "코엑스 맞은편의 사찰입니다. 경사가 완만해 천천히 걷기 좋습니다.",
|
||||
"en": "Temple across from COEX. Gentle slopes, good for a slow walk."
|
||||
},
|
||||
"mapQuery": "봉은사",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "seonjeongneung",
|
||||
"name": {
|
||||
"ko": "선정릉",
|
||||
"en": "Seonjeongneung Royal Tombs"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.4,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "선릉",
|
||||
"en": "Seolleung"
|
||||
},
|
||||
"note": {
|
||||
"ko": "유네스코 세계유산 왕릉입니다. 숲길이 그늘져서 햇빛을 피하기 좋습니다.",
|
||||
"en": "UNESCO World Heritage royal tombs. Shaded forest paths."
|
||||
},
|
||||
"mapQuery": "선정릉",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "banpo-hangang",
|
||||
"name": {
|
||||
"ko": "반포한강공원·달빛무지개분수",
|
||||
"en": "Banpo Hangang Park and Moonlight Rainbow Fountain"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 2,
|
||||
"distanceKm": 2.6,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "반포",
|
||||
"en": "Banpo"
|
||||
},
|
||||
"note": {
|
||||
"ko": "저녁에 분수 공연을 볼 수 있습니다. 운영 기간을 확인하세요.",
|
||||
"en": "Evening fountain show. Check the season schedule."
|
||||
},
|
||||
"mapQuery": "반포한강공원",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "gyeongbokgung",
|
||||
"name": {
|
||||
"ko": "경복궁·북촌",
|
||||
"en": "Gyeongbokgung Palace and Bukchon"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 9,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "종로",
|
||||
"en": "Jongno"
|
||||
},
|
||||
"note": {
|
||||
"ko": "택시로 30분 이상 걸립니다. 걷는 시간이 길어 자유 외출 단계에서 가세요.",
|
||||
"en": "Over 30 minutes by taxi and a lot of walking. Save for full-day outings."
|
||||
},
|
||||
"mapQuery": "경복궁",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "lotte-tower",
|
||||
"name": {
|
||||
"ko": "롯데월드타워·서울스카이",
|
||||
"en": "Lotte World Tower and Seoul Sky"
|
||||
},
|
||||
"category": "sightseeing",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 8,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "잠실",
|
||||
"en": "Jamsil"
|
||||
},
|
||||
"note": {
|
||||
"ko": "실내 전망대와 쇼핑몰입니다. 택시로 25분.",
|
||||
"en": "Indoor observatory and mall. 25 minutes by taxi."
|
||||
},
|
||||
"mapQuery": "롯데월드타워",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "myeongdong",
|
||||
"name": {
|
||||
"ko": "명동",
|
||||
"en": "Myeongdong"
|
||||
},
|
||||
"category": "shopping",
|
||||
"minLevel": 3,
|
||||
"distanceKm": 7,
|
||||
"indoor": false,
|
||||
"area": {
|
||||
"ko": "중구",
|
||||
"en": "Jung-gu"
|
||||
},
|
||||
"note": {
|
||||
"ko": "화장품과 길거리 음식이 많습니다. 인파가 많으니 부딪히지 않게 다니세요.",
|
||||
"en": "Cosmetics and street food. Crowded, so mind bumps."
|
||||
},
|
||||
"mapQuery": "명동",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "clinic-aftercare",
|
||||
"name": {
|
||||
"ko": "원진성형외과 경과 확인",
|
||||
"en": "Wonjin Plastic Surgery follow-up"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.05,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "병원",
|
||||
"en": "Clinic"
|
||||
},
|
||||
"note": {
|
||||
"ko": "경과 확인과 관리는 병원 안내에 따라 예약합니다.",
|
||||
"en": "Follow-up checks and care, booked per the clinic's guidance."
|
||||
},
|
||||
"mapQuery": "원진성형외과",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hotel-spa-gangnam",
|
||||
"name": {
|
||||
"ko": "호텔 스파·라운지 (숙소 안)",
|
||||
"en": "Hotel spa and lounge (at your hotel)"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "숙소",
|
||||
"en": "Your hotel"
|
||||
},
|
||||
"note": {
|
||||
"ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.",
|
||||
"en": "Skip sauna and massage until your surgeon allows. Lounge and room service are fine."
|
||||
},
|
||||
"mapQuery": null,
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "hotel-cappuccino",
|
||||
"name": {
|
||||
"ko": "호텔 카푸치노",
|
||||
"en": "Hotel Cappuccino"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 1.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "논현동 봉은사로",
|
||||
"en": "Nonhyeon, Bongeunsa-ro"
|
||||
},
|
||||
"note": {
|
||||
"ko": "병원과 같은 봉은사로에 있어 걸어서 다닐 수 있습니다.",
|
||||
"en": "On the same road as the clinic, within walking distance."
|
||||
},
|
||||
"mapQuery": "호텔 카푸치노 논현",
|
||||
"bookingQuery": "Hotel Cappuccino Seoul",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "novotel-gangnam",
|
||||
"name": {
|
||||
"ko": "노보텔 앰배서더 강남",
|
||||
"en": "Novotel Ambassador Seoul Gangnam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 0.8,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "역삼동",
|
||||
"en": "Yeoksam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "강남역 근처 대형 호텔입니다. 병원까지 걸어서 10분.",
|
||||
"en": "Large hotel near Gangnam Station, a 10-minute walk to the clinic."
|
||||
},
|
||||
"mapQuery": "노보텔 앰배서더 강남",
|
||||
"bookingQuery": "Novotel Ambassador Seoul Gangnam",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "l7-gangnam",
|
||||
"name": {
|
||||
"ko": "L7 강남 바이 롯데",
|
||||
"en": "L7 Gangnam by Lotte"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 0.9,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "강남역",
|
||||
"en": "Gangnam Station"
|
||||
},
|
||||
"note": {
|
||||
"ko": "강남역 인근이며 병원까지 걸어서 10분.",
|
||||
"en": "Near Gangnam Station, a 10-minute walk to the clinic."
|
||||
},
|
||||
"mapQuery": "L7 강남",
|
||||
"bookingQuery": "L7 Gangnam by Lotte",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "andaz-gangnam",
|
||||
"name": {
|
||||
"ko": "안다즈 서울 강남",
|
||||
"en": "Andaz Seoul Gangnam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 3.0,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "압구정 로데오",
|
||||
"en": "Apgujeong Rodeo"
|
||||
},
|
||||
"note": {
|
||||
"ko": "압구정 로데오에 있는 고급 호텔입니다. 병원까지 택시로 10분.",
|
||||
"en": "Upscale hotel at Apgujeong Rodeo. 10 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "안다즈 서울 강남",
|
||||
"bookingQuery": "Andaz Seoul Gangnam",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "josun-palace",
|
||||
"name": {
|
||||
"ko": "조선 팰리스 강남",
|
||||
"en": "Josun Palace Seoul Gangnam"
|
||||
},
|
||||
"category": "hotel",
|
||||
"minLevel": 0,
|
||||
"distanceKm": 1.9,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "역삼동",
|
||||
"en": "Yeoksam"
|
||||
},
|
||||
"note": {
|
||||
"ko": "역삼동의 고급 호텔입니다. 병원까지 택시로 10분.",
|
||||
"en": "Luxury hotel in Yeoksam. 10 minutes to the clinic by taxi."
|
||||
},
|
||||
"mapQuery": "조선 팰리스 서울 강남",
|
||||
"bookingQuery": "Josun Palace Seoul Gangnam",
|
||||
"verify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
9
supporters/public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@ -1,164 +0,0 @@
|
||||
// 글마다 서로 다른 대표 이미지를 배정한다.
|
||||
// node scripts/assign_post_heroes.mjs --site <siteDir> [--dry]
|
||||
//
|
||||
// 왜 필요한가: PostCard 의 썸네일 순서는 "thumbnail → hero.src → 첫 영상 → 건물 사진 → 로고" 다.
|
||||
// 앞의 셋이 없으면 모든 글이 건물 사진 한 장으로 떨어져 목록에 같은 사진이 나란히 선다.
|
||||
// 글마다 thumbnail 이 있어도 값이 같으면 결과는 똑같다. 그래서 "비어 있는 글"과 "겹치는 글"을 함께 다시 배정한다.
|
||||
//
|
||||
// 규칙(docs/INFINITH_Supporters_Image_Rules.md §2)
|
||||
// - 글 히어로의 지정 용도는 시술 설명 일러스트(procedure)와 장비(equipment)다. 시설(clinic)은 그 다음으로 쓴다.
|
||||
// - 의료진 사진(doctors)은 쓰지 않는다. 저자·검토자 표시가 지정 용도이고, 글 대표 이미지로 쓰면 그 원장이 쓴 글로 읽힌다.
|
||||
// - site.heroImage·buildingImage 로 이미 쓰는 사진은 뺀다. 홈에서 보이는 사진이 목록에 또 서면 중복으로 읽힌다.
|
||||
// - alt 는 수집 원문을 그대로 쓰지 않는다. 원문 alt 는 술기 설명 문단이라 고객 화면에 낼 수 없다. 첫 구절만 줄여 쓴다.
|
||||
// - 이미지가 글보다 적으면 남는 글은 비워 둔다. 같은 사진을 다시 쓰면 처음 문제로 돌아간다.
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
||||
const flag = (k) => args.includes(`--${k}`);
|
||||
const SITE = resolve(opt('site', '.'));
|
||||
const DRY = flag('dry');
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
|
||||
const DATA = join(SITE, 'src', 'data');
|
||||
const POSTS = join(SITE, 'src', 'content', 'posts');
|
||||
if (!existsSync(POSTS)) { console.log('글 폴더 없음 · 건너뜀'); process.exit(0); }
|
||||
const manifestPath = join(DATA, 'images.json');
|
||||
if (!existsSync(manifestPath)) { console.log('images.json 없음 · 건너뜀 (수집된 이미지가 없다)'); process.exit(0); }
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const site = existsSync(join(DATA, 'site.json')) ? readJson(join(DATA, 'site.json')) : {};
|
||||
const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
|
||||
const clinicName = fact.shortName || fact.name || '';
|
||||
|
||||
// ---------- 글 읽기 ----------
|
||||
const HERO_BLOCK = /^hero:\n(?:[ \t]+\S.*\n)+/m; // 블록 표기
|
||||
const HERO_FLOW = /^hero:[ \t]*\{.*\}[ \t]*\n/m; // 한 줄 표기
|
||||
const THUMB_LINE = /^thumbnail:[ \t]*.*\n/m;
|
||||
|
||||
const posts = readdirSync(POSTS).filter((f) => f.endsWith('.md')).sort().map((file) => {
|
||||
const path = join(POSTS, file);
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const fm = (text.match(/^---\n([\s\S]*?)\n---/) ?? [, ''])[1];
|
||||
const thumb = (fm.match(/^thumbnail:[ \t]*["']?([^"'\n]+)/m)?.[1] ?? '').trim();
|
||||
const heroSrc = (fm.match(/^hero:[\s\S]*?src:[ \t]*["']?([^"'\n,}]+)/m)?.[1] ?? '').trim();
|
||||
return {
|
||||
file, path, text, fm,
|
||||
slug: file.replace(/\.md$/, ''),
|
||||
title: (fm.match(/^title:[ \t]*(.+)$/m)?.[1] ?? '').trim(),
|
||||
category: (fm.match(/^category:[ \t]*(\S+)/m)?.[1] ?? 'A').trim(),
|
||||
tags: (fm.match(/^tags:\n((?:[ \t]+-[ \t].+\n)+)/m)?.[1] ?? '').replace(/[ \t]+-[ \t]/g, ' '),
|
||||
// 카드에 실제로 쓰이는 값. PostCard 가 thumbnail 을 먼저 본다.
|
||||
current: thumb || heroSrc,
|
||||
};
|
||||
});
|
||||
|
||||
// 홈 히어로·건물 사진과 같은 사진을 쓰는 글도 다시 배정한다. 홈 맨 위 사진이 바로 아래 카드에 또 나오면 중복으로 읽힌다.
|
||||
const shown = new Set([site.heroImage?.src, site.buildingImage?.src].filter(Boolean));
|
||||
|
||||
// 겹치는 글은 앞의 한 편만 두고 나머지를 다시 배정한다.
|
||||
const seen = new Set(), dup = new Set();
|
||||
for (const p of posts) {
|
||||
if (!p.current) continue;
|
||||
if (seen.has(p.current)) dup.add(p.slug); else seen.add(p.current);
|
||||
}
|
||||
const kept = new Set(posts.filter((p) => p.current && !dup.has(p.slug) && !shown.has(p.current)).map((p) => p.current));
|
||||
const targets = posts.filter((p) => !p.current || dup.has(p.slug) || shown.has(p.current));
|
||||
|
||||
// ---------- 후보 풀 ----------
|
||||
const inGallery = new Set([...(site.insideGallery ?? []), ...(site.clinicGallery ?? [])].map((g) => g?.src).filter(Boolean));
|
||||
// 글 분류(A 병원 소개·B 방문·예약·C 선택 기준·D 시술 정보·E 안전·마취)마다 어울리는 이미지 유형이 다르다.
|
||||
// 방문 안내에 시술 일러스트가 붙으면 주제가 어긋난다. 분류별로 선호 순서를 둔다.
|
||||
const PREF = {
|
||||
A: ['clinic', 'equipment', 'procedure'],
|
||||
B: ['clinic', 'equipment', 'procedure'],
|
||||
C: ['clinic', 'procedure', 'equipment'],
|
||||
D: ['procedure', 'equipment', 'clinic'],
|
||||
E: ['equipment', 'clinic', 'procedure'],
|
||||
F: ['clinic', 'procedure', 'equipment'],
|
||||
G: ['clinic', 'procedure', 'equipment'],
|
||||
};
|
||||
const CATS = new Set(['procedure', 'equipment', 'clinic']);
|
||||
// 수집한 alt 가 효과·결과를 주장하면 그 문장을 쓰지 않는다. 카드와 글 머리의 alt 로 나가면
|
||||
// 의료법 56조 2항 8호(객관적 사실 과장) 대상이 된다. 이미지 자체는 문제가 없으므로
|
||||
// 빼지 않고 무엇을 찍은 것인지만 밝히는 중립 문구로 바꾼다.
|
||||
const base = (manifest.items ?? []).filter((i) => CATS.has(i.category) && !shown.has(i.src) && !kept.has(i.src));
|
||||
// 갤러리에 실린 사진은 뺀다. 홈에서 글 목록 바로 아래가 갤러리라, 카드 마지막 장과 갤러리 첫 장이
|
||||
// 같으면 나란히 두 번 보인다. 다만 이렇게 빼서 글 수를 못 채우면 더 나빠진다. 못 채운 글은 건물 사진으로
|
||||
// 떨어져 같은 사진이 여러 장 생기기 때문이다. 그래서 전부 채울 수 있을 때만 뺀다.
|
||||
const notInGallery = base.filter((i) => !inGallery.has(i.src));
|
||||
const pool = (notInGallery.length >= targets.length ? notInGallery : base).map((i) => ({ ...i, inGallery: inGallery.has(i.src) }));
|
||||
|
||||
if (!targets.length) { console.log(`글 ${posts.length}편 · 겹치는 대표 이미지 없음 · 배정 없음`); process.exit(0); }
|
||||
if (!pool.length) { console.log(`대상 ${targets.length}편이지만 쓸 수 있는 이미지가 없음 (의료진 사진은 글 히어로로 쓰지 않는다)`); process.exit(0); }
|
||||
|
||||
// ---------- 문구 ----------
|
||||
const EFFICACY = /개선|완화|제거|재생|촉진|강화|효과|없애|줄여|늘려|맑아|밝아|탄력|안전하게|빠른\s*회복|통증\s*없/;
|
||||
// 수집기의 분류를 그대로 믿지 않는다. 일러스트가 시설(clinic)로 들어오는 경우가 있어
|
||||
// 유형을 단정하는 문구 대신 출처만 밝힌다.
|
||||
function shortAlt(item) {
|
||||
const raw = String(item.alt || '').replace(/\s+/g, ' ').trim();
|
||||
const fallback = [clinicName, '홈페이지 이미지'].filter(Boolean).join(' ');
|
||||
if (!raw || EFFICACY.test(raw)) return fallback;
|
||||
let s = raw.split(/(?<=[.!?])\s|(?=\s\d\s)/)[0].replace(/\s*\d+\s*$/, '').trim();
|
||||
if (s.length > 60) s = s.slice(0, 58).replace(/\s\S*$/, '') + '…';
|
||||
return EFFICACY.test(s) ? fallback : (s || fallback);
|
||||
}
|
||||
function caption(item) {
|
||||
const t = String(item.page?.title || '').replace(/\s+/g, ' ').trim();
|
||||
const generic = !t || t === clinicName || t.includes(clinicName) || t.length < 3;
|
||||
const where = generic ? `${clinicName} 홈페이지`.trim() : `${clinicName} 홈페이지 ${t}`.trim();
|
||||
return `출처: ${where} (${item.capturedAt || manifest.capturedAt} 캡처)`;
|
||||
}
|
||||
|
||||
// ---------- 배정 ----------
|
||||
const words = (s) => String(s || '').toLowerCase().match(/[가-힣]{2,}|[a-z]{3,}/g) ?? [];
|
||||
function score(post, item) {
|
||||
const a = new Set(words(`${post.title} ${post.tags} ${post.slug}`));
|
||||
let n = 0;
|
||||
for (const w of words(`${item.alt} ${item.src} ${item.page?.url}`)) if (a.has(w)) n += 1;
|
||||
return n;
|
||||
}
|
||||
const prefRank = (post, item) => {
|
||||
const order = PREF[post.category] ?? PREF.A;
|
||||
const i = order.indexOf(item.category);
|
||||
return (i < 0 ? order.length : i) + (item.inGallery ? 0.5 : 0);
|
||||
};
|
||||
// 유형이 맞는 것을 먼저 쓰고, 같은 유형 안에서 낱말이 겹치는 짝을 먼저 확정한다.
|
||||
const pairs = [];
|
||||
for (const p of targets) for (const i of pool) pairs.push({ p, i, s: score(p, i), r: prefRank(p, i) });
|
||||
pairs.sort((a, b) => (a.r - b.r) || (b.s - a.s) || a.p.slug.localeCompare(b.p.slug));
|
||||
|
||||
const usedPost = new Set(), usedImg = new Set(), chosen = new Map();
|
||||
for (const { p, i, s } of pairs) {
|
||||
if (usedPost.has(p.slug) || usedImg.has(i.src)) continue;
|
||||
usedPost.add(p.slug); usedImg.add(i.src); chosen.set(p.slug, { item: i, s });
|
||||
}
|
||||
|
||||
// ---------- 쓰기 ----------
|
||||
let wrote = 0;
|
||||
for (const p of targets) {
|
||||
const pick = chosen.get(p.slug);
|
||||
if (!pick) { console.log(` ${p.slug} ← (배정 없음 · 남은 이미지 부족)`); continue; }
|
||||
const { item } = pick;
|
||||
const block = `hero:\n src: ${item.src}\n alt: ${JSON.stringify(shortAlt(item))}\n caption: ${JSON.stringify(caption(item))}\nthumbnail: ${item.src}\n`;
|
||||
let next = p.text;
|
||||
const hadHero = HERO_BLOCK.test(next) || HERO_FLOW.test(next);
|
||||
next = next.replace(HERO_BLOCK, '').replace(HERO_FLOW, '').replace(THUMB_LINE, '');
|
||||
if (hadHero || p.current) {
|
||||
// 있던 자리를 대신한다. description 다음 줄에 넣어 자리를 고정한다.
|
||||
const anchor = next.match(/^description:[ \t]*.+$/m);
|
||||
if (!anchor) { console.warn(` ! ${p.file}: description 줄이 없어 건너뜀`); continue; }
|
||||
next = next.replace(anchor[0], `${anchor[0]}\n${block.trimEnd()}`);
|
||||
} else {
|
||||
const anchor = next.match(/^description:[ \t]*.+$/m);
|
||||
if (!anchor) { console.warn(` ! ${p.file}: description 줄이 없어 건너뜀`); continue; }
|
||||
next = next.replace(anchor[0], `${anchor[0]}\n${block.trimEnd()}`);
|
||||
}
|
||||
if (!DRY) writeFileSync(p.path, next);
|
||||
wrote += 1;
|
||||
const why = dup.has(p.slug) ? '겹침 해소' : '새로 배정';
|
||||
console.log(` ${p.slug} [${p.category}] ← ${item.src.replace(/^\/img\//, '')} (${item.category} · ${why}${pick.s ? ` · 낱말 일치 ${pick.s}` : ''})`);
|
||||
}
|
||||
console.log(`대표 이미지 ${wrote}편 배정 / 대상 ${targets.length}편 · 후보 ${pool.length}장 · 그대로 둔 글 ${posts.length - targets.length}편${DRY ? ' · dry' : ''}`);
|
||||
@ -1,180 +0,0 @@
|
||||
// 수집 이미지 육안 검토 요청서를 만든다.
|
||||
// node scripts/build_image_review.mjs --out <파일.html> --site <id>=<siteDir> [--site ...]
|
||||
//
|
||||
// 왜 필요한가: 이미지 분류는 파일명·alt·페이지 유형으로 추정한다. 그런데 전후 사진·광고 모델 신체 사진·
|
||||
// 시술 행위 사진은 파일명이 img02 처럼 아무 신호도 주지 않는다. 2026-09-12 뷰성형외과에서 91장을
|
||||
// 파일명·alt 로 검사했을 때 걸린 것이 0장이었고, 눈으로 보니 12장 중 6장이 금지 유형이었다.
|
||||
// 그래서 이 단계는 사람 게이트로 둔다. 이 페이지는 그 판정을 한 화면에서 하고,
|
||||
// 결과를 briefs/<clinic>/clinic.json 의 excludeImages 에 넣을 수 있는 형태로 돌려준다.
|
||||
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
||||
const sites = args.reduce((acc, a, i) => (args[i - 1] === '--site' ? [...acc, a] : acc), []);
|
||||
const OUT = resolve(opt('out', 'image-review.html'));
|
||||
const ASSETS = join(dirname(OUT), 'image-review-files');
|
||||
const ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..', '..');
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
// 파일명에서 수집기가 붙인 해시를 떼어 excludeImages 에 쓸 접두어를 만든다.
|
||||
const prefixOf = (src) => basename(src).replace(/-[0-9a-f]{10}\.\w+$/, '').replace(/\.\w+$/, '');
|
||||
|
||||
const blocks = [];
|
||||
for (const spec of sites) {
|
||||
const [id, dir] = spec.split('=');
|
||||
const SITE = resolve(dir);
|
||||
const DATA = join(SITE, 'src', 'data');
|
||||
const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
|
||||
const briefPath = join(ROOT, 'supporters', 'briefs', id, 'clinic.json');
|
||||
const already = existsSync(briefPath) ? (readJson(briefPath).excludeImages ?? []) : [];
|
||||
if (!existsSync(join(DATA, 'images.json'))) { blocks.push({ id, clinic: fact.shortName || id, items: [], already, note: '수집된 이미지가 없습니다. 검토할 것이 없습니다.' }); continue; }
|
||||
|
||||
const manifest = readJson(join(DATA, 'images.json'));
|
||||
const site = existsSync(join(DATA, 'site.json')) ? readJson(join(DATA, 'site.json')) : {};
|
||||
|
||||
// 화면 어디에 쓰이는지. 실제로 노출되는 것부터 보게 한다.
|
||||
const usage = new Map();
|
||||
const mark = (src, where) => { if (!src) return; usage.set(src, [...(usage.get(src) ?? []), where]); };
|
||||
mark(site.heroImage?.src, '홈 히어로');
|
||||
mark(site.buildingImage?.src, '건물 사진');
|
||||
for (const g of site.insideGallery ?? []) mark(g?.src, '내부 갤러리');
|
||||
for (const g of site.clinicGallery ?? []) mark(g?.src, '병원 갤러리');
|
||||
const POSTS = join(SITE, 'src', 'content', 'posts');
|
||||
if (existsSync(POSTS)) for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
||||
const t = readFileSync(join(POSTS, f), 'utf8');
|
||||
for (const m of t.matchAll(/(?:thumbnail:|src:)[ \t]*["']?(\/img\/[^\s"',}]+)/g)) mark(m[1], `글 카드 ${f.replace(/\.md$/, '')}`);
|
||||
}
|
||||
|
||||
mkdirSync(join(ASSETS, id), { recursive: true });
|
||||
const items = (manifest.items ?? []).map((it) => {
|
||||
const from = join(SITE, 'public', it.src.replace(/^\//, ''));
|
||||
const name = `${it.category}__${basename(it.src)}`;
|
||||
if (existsSync(from)) copyFileSync(from, join(ASSETS, id, name));
|
||||
return { ...it, name, prefix: prefixOf(it.src), used: usage.get(it.src) ?? [] };
|
||||
}).sort((a, b) => (b.used.length > 0) - (a.used.length > 0) || a.category.localeCompare(b.category) || a.src.localeCompare(b.src));
|
||||
blocks.push({ id, clinic: fact.shortName || id, items, already, counts: manifest.counts ?? {}, capturedAt: manifest.capturedAt });
|
||||
}
|
||||
|
||||
const style = `
|
||||
:root{--ink:#1D0024;--muted:#5b5570;--line:#e6e2ee;--bad:#B3261E;--live:#7A4B00;--bg:#fbfafd;--accent:#4F1DA1}
|
||||
*{box-sizing:border-box}body{margin:0;font:15px/1.65 Pretendard,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);word-break:keep-all}
|
||||
.wrap{max-width:1200px;margin:0 auto;padding:2.4rem 20px 5rem}
|
||||
h1{font-size:1.75rem;margin:0 0 .5rem}h2{font-size:1.3rem;margin:2.8rem 0 .2rem;padding-top:1.5rem;border-top:1px solid var(--line)}
|
||||
.lede{color:var(--muted);margin:0 0 1.5rem;max-width:70ch}
|
||||
.ask{background:#fff;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:10px;padding:1rem 1.2rem;margin:1.2rem 0 1.8rem}
|
||||
.ask p{margin:.3rem 0}
|
||||
.sum{background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin:1.2rem 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:.87rem}th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:1rem;margin-top:1rem}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
|
||||
.card.live{border-color:#E0B000;box-shadow:0 0 0 2px rgba(224,176,0,.16)}
|
||||
.card.out{opacity:.45}
|
||||
.card img{width:100%;height:180px;object-fit:contain;background:#f1eff6;display:block}
|
||||
.meta{padding:.7rem .8rem .9rem;font-size:.8rem;flex:1;display:flex;flex-direction:column;gap:.3rem}
|
||||
.cat{font-weight:700;font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
|
||||
.fn{font-family:ui-monospace,Menlo,monospace;font-size:.7rem;word-break:break-all;color:var(--muted)}
|
||||
.alt{color:var(--ink)}
|
||||
.use{font-size:.72rem;color:var(--live);font-weight:700}
|
||||
.pick{display:flex;gap:.4rem;align-items:center;padding:.55rem .8rem;border-top:1px solid var(--line);background:#fdfcff;font-size:.85rem;cursor:pointer}
|
||||
.pick input{width:16px;height:16px;accent-color:var(--bad);cursor:pointer}
|
||||
.out-box{position:sticky;bottom:0;background:#fff;border:1px solid var(--line);border-radius:12px;padding:1rem 1.2rem;margin-top:1.6rem;box-shadow:0 -4px 18px rgba(29,0,36,.07)}
|
||||
textarea{width:100%;min-height:96px;font-family:ui-monospace,Menlo,monospace;font-size:.78rem;border:1px solid var(--line);border-radius:8px;padding:.6rem;background:#fbfafd;color:var(--ink)}
|
||||
button{font:inherit;font-size:.85rem;padding:.45rem .9rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer}
|
||||
code{background:#f1eff6;padding:.1rem .35rem;border-radius:4px;font-size:.86em}
|
||||
.badge{display:inline-block;font-size:.7rem;font-weight:700;padding:.1rem .4rem;border-radius:5px;background:#f1eff6;color:var(--muted);margin-left:.3rem}
|
||||
.none{color:var(--muted);padding:1rem 0}
|
||||
@media (max-width:640px){.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.card img{height:130px}}
|
||||
`;
|
||||
|
||||
const body = blocks.map((b) => {
|
||||
const head = `<h2>${esc(b.clinic)} <span style="font-weight:400;color:var(--muted);font-size:.88rem">${esc(Object.entries(b.counts ?? {}).map(([k, v]) => `${k} ${v}`).join(' · '))}${b.capturedAt ? ` · ${esc(b.capturedAt)} 수집` : ''}</span></h2>`;
|
||||
if (!b.items.length) return `${head}<p class="none">${esc(b.note ?? '수집된 이미지가 없습니다.')}</p>`;
|
||||
const liveCount = b.items.filter((i) => i.used.length).length;
|
||||
const cards = b.items.map((it) => `
|
||||
<figure class="card${it.used.length ? ' live' : ''}" data-prefix="${esc(it.prefix)}">
|
||||
<img src="image-review-files/${esc(b.id)}/${esc(it.name)}" alt="${esc(String(it.alt).slice(0, 120))}" loading="lazy">
|
||||
<figcaption class="meta">
|
||||
<span class="cat">${esc(it.category)}${it.used.length ? '<span class="badge">화면에 노출</span>' : ''}</span>
|
||||
<div class="fn">${esc(basename(it.src))}</div>
|
||||
<div class="alt">${esc(String(it.alt).slice(0, 100))}${String(it.alt).length > 100 ? '…' : ''}</div>
|
||||
${it.used.length ? `<div class="use">${esc(it.used.join(' · '))}</div>` : ''}
|
||||
</figcaption>
|
||||
<label class="pick"><input type="checkbox" data-clinic="${esc(b.id)}" data-prefix="${esc(it.prefix)}"> 이 이미지 빼기</label>
|
||||
</figure>`).join('');
|
||||
const alreadyRow = b.already.length
|
||||
? `<p style="margin:.6rem 0 0;color:var(--muted);font-size:.85rem">이미 빠져 있는 것 ${b.already.length}건: <code>${esc(b.already.join('</code>, <code>'))}</code></p>`
|
||||
: '';
|
||||
return `${head}
|
||||
<p style="margin:.4rem 0 0;color:var(--muted)">${b.items.length}장 · 이 중 <strong style="color:var(--live)">${liveCount}장이 지금 화면에 나갑니다</strong>(노란 테두리). 이것부터 보시면 됩니다.${alreadyRow ? '' : ''}</p>
|
||||
${alreadyRow}
|
||||
<div class="grid">${cards}</div>`;
|
||||
}).join('\n');
|
||||
|
||||
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>서포터즈 수집 이미지 육안 검토 요청</title><style>${style}</style></head><body><div class="wrap">
|
||||
<h1>서포터즈 수집 이미지 육안 검토 요청</h1>
|
||||
<p class="lede">병원 홈페이지에서 자동으로 모은 이미지입니다. 어떤 이미지를 빼야 하는지는 파일명이나 설명문으로 가려지지 않아 눈으로 보셔야 합니다.</p>
|
||||
|
||||
<div class="ask">
|
||||
<p><strong>부탁드리는 것</strong></p>
|
||||
<p>아래 표의 유형에 해당하는 이미지를 찾아 카드 아래 <strong>"이 이미지 빼기"</strong>를 체크해 주세요. 다 고르시면 맨 아래 상자에 제외 목록이 만들어집니다. 그 값을 알려주시면 다음 빌드부터 빠집니다.</p>
|
||||
<p><strong>노란 테두리가 지금 화면에 나가는 이미지</strong>입니다. 시간이 없으시면 그것만 보셔도 됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="sum">
|
||||
<p style="margin:.2rem 0 .7rem"><strong>빼야 하는 유형</strong> · 근거 <code>docs/INFINITH_Supporters_Image_Rules.md</code></p>
|
||||
<table>
|
||||
<tr><th style="width:22%">유형</th><th style="width:34%">어떻게 생겼나</th><th>근거</th></tr>
|
||||
<tr><td>전후 사진 · 경과 비교</td><td>같은 부위를 두 시점으로 나란히 놓은 사진. "수술 후 1개월 / 6개월" 같은 글자가 박혀 있기도 합니다</td><td>의료광고 심의 기준. 동일 조건 촬영·무보정·환자 동의·부작용 병기 네 조건을 자동으로 확인할 수 없습니다</td></tr>
|
||||
<tr><td>환자 · 광고 모델 신체 사진</td><td>얼굴이나 몸이 나오는 인물 사진. 구석에 "이미지 광고 모델" 표기가 있는 경우가 많습니다</td><td>의료법 56조 2항 2호 · 개인정보보호법 · 초상권</td></tr>
|
||||
<tr><td>시술 행위 장면</td><td>주사·절개·드레싱처럼 시술하는 손과 환자 신체가 함께 나오는 사진. 수술실 공간만 찍은 사진은 괜찮습니다</td><td>의료법 56조 2항 6호</td></tr>
|
||||
<tr><td>효과 · 성능 주장 그래픽</td><td>"개선", "재생", 숫자와 화살표로 성능을 내세운 제조사 홍보 이미지</td><td>의료법 56조 2항 8호</td></tr>
|
||||
<tr><td>인증 · 수상 마크</td><td>상장, 어워드 로고, 인증 배지</td><td>의료법 56조 2항 14호</td></tr>
|
||||
<tr><td>타 병원 비교</td><td>다른 병원과 견주는 표나 그래프</td><td>의료법 56조 2항 4호</td></tr>
|
||||
</table>
|
||||
<p style="margin:.9rem 0 0;color:var(--muted)">판단이 서지 않으면 빼는 쪽으로 봐 주세요. 뺀 이미지는 병원이 조건을 서면으로 확인해 주면 다시 넣을 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
${body}
|
||||
|
||||
<div class="out-box">
|
||||
<p style="margin:.1rem 0 .5rem"><strong>제외 목록</strong> <span style="color:var(--muted);font-size:.85rem">체크하면 여기에 쌓입니다. 이 내용을 그대로 알려주시면 됩니다.</span></p>
|
||||
<textarea id="out" readonly placeholder="아직 고른 것이 없습니다."></textarea>
|
||||
<p style="margin:.6rem 0 0"><button id="copy">복사</button> <span id="msg" style="color:var(--muted);font-size:.85rem;margin-left:.5rem"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var out = document.getElementById('out'), msg = document.getElementById('msg');
|
||||
function refresh() {
|
||||
var by = {};
|
||||
document.querySelectorAll('input[type=checkbox]').forEach(function (c) {
|
||||
var card = c.closest('.card');
|
||||
if (card) card.classList.toggle('out', c.checked);
|
||||
if (!c.checked) return;
|
||||
(by[c.dataset.clinic] = by[c.dataset.clinic] || []).push(c.dataset.prefix);
|
||||
});
|
||||
var keys = Object.keys(by);
|
||||
out.value = keys.length ? keys.map(function (k) {
|
||||
return k + ': ' + JSON.stringify(by[k].filter(function (v, i, a) { return a.indexOf(v) === i; }));
|
||||
}).join('\\n') : '';
|
||||
}
|
||||
document.addEventListener('change', function (e) { if (e.target.type === 'checkbox') refresh(); });
|
||||
document.getElementById('copy').addEventListener('click', function () {
|
||||
if (!out.value) { msg.textContent = '고른 것이 없습니다.'; return; }
|
||||
out.select();
|
||||
try { document.execCommand('copy'); msg.textContent = '복사했습니다.'; }
|
||||
catch (e) { msg.textContent = '직접 선택해 복사해 주세요.'; }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, html);
|
||||
const total = blocks.reduce((n, b) => n + b.items.length, 0);
|
||||
const live = blocks.reduce((n, b) => n + b.items.filter((i) => i.used.length).length, 0);
|
||||
console.log(`검토 요청서 → ${OUT}`);
|
||||
for (const b of blocks) console.log(` ${b.clinic}: ${b.items.length}장 (화면 노출 ${b.items.filter((i) => i.used.length).length}장 · 이미 제외 ${b.already.length}건)`);
|
||||
console.log(`합계 ${total}장 · 화면 노출 ${live}장`);
|
||||
@ -1,94 +0,0 @@
|
||||
// 회복 일정 플래너 병원별 데이터 생성 (워커 planner 단계). 설계 v0.2 §13.
|
||||
// node scripts/build_planner_data.mjs --clinic <id> --site <siteDir> [--industry derm|plastic]
|
||||
//
|
||||
// 만드는 것
|
||||
// <site>/src/data/procedures.json 업종별 기본 규칙표(scripts/template/planner/procedures.<industry>.json)에
|
||||
// 병원 시술 페이지 원문(recoveryNotes.json)의 관리 문장을 matchKeywords 로 시술에 붙인다.
|
||||
// 원문 문장은 status clinic 으로 출처(URL)와 함께 들어가고, 나머지 값은 provisional(병원 확인 전) 그대로다.
|
||||
// <site>/src/data/places.json briefs/<clinic>/planner.places.json 이 있으면 그것(병원별 큐레이션), 없으면 범용 기본표
|
||||
// (scripts/template/planner/places.base.json)에 병원 이름·지역을 치환하고 origin 을 관광공사 수집 기준 좌표로 채운다.
|
||||
// 원칙: 없는 값을 채우지 않는다. 의학 수치는 병원 원문 문장만 clinic 이고, 그 밖은 화면에 '병원 확인 전' 배지가 붙는다.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SUP = resolve(fileURLToPath(new URL('../', import.meta.url)));
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
||||
const clinic = opt('clinic'); const SITE = opt('site');
|
||||
if (!clinic || !SITE) { console.error('사용법: --clinic <id> --site <siteDir> [--industry derm|plastic]'); process.exit(2); }
|
||||
const DATA = join(SITE, 'src', 'data');
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
const writeJson = (p, o) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, JSON.stringify(o, null, 2) + '\n'); };
|
||||
|
||||
const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
|
||||
const pinPath = join(SUP, 'briefs', clinic, 'clinic.json');
|
||||
const pin = existsSync(pinPath) ? readJson(pinPath) : {};
|
||||
|
||||
// 업종: --industry > briefs/clinic.json industry > 팩트 시트 kind(피부과) > plastic
|
||||
const industry = (() => {
|
||||
const forced = opt('industry') || pin.industry;
|
||||
if (forced && ['derm', 'plastic'].includes(forced)) return forced;
|
||||
return /피부/.test(String(fact.kind ?? '') + String(pin.factSheet?.kind ?? '')) ? 'derm' : 'plastic';
|
||||
})();
|
||||
const basePath = join(SUP, 'scripts', 'template', 'planner', `procedures.${industry}.json`);
|
||||
const base = readJson(basePath);
|
||||
|
||||
// ---------- 규칙표: 병원 원문 문장 붙이기 ----------
|
||||
const notesPath = join(DATA, 'recoveryNotes.json');
|
||||
const notes = existsSync(notesPath) ? readJson(notesPath) : { items: [] };
|
||||
const shortName = fact.shortName || fact.name || clinic;
|
||||
// 병원 원문 중 '회복기 관리·시기' 문장만 쓴다. 효과 주장·홍보문·정형 합병증 고지·표 조각·재수술 시기 조언은 규칙표에 넣지 않는다(의료광고 규정, 그리고 플래너의 목적 밖).
|
||||
const CARE = /일상\s?생활|일상\s?복귀|실밥|봉합|샤워|세안|화장|운동|사우나|찜질|음주|흡연|술|담배|압박|붓기|부기|냉찜질|온찜질|자외선|차단제|마사지|누워|수면|식사|음식|유동식|안정|보호자|컷트|펌|염색|가글|양치|콘택트|안경|모자|주의해야|피해|삼가|금지|퇴원|입원/;
|
||||
const NOT_CARE = /티가 나지|자연스러|가능할 만큼|부담이 적|원해요|싶어요|예측 가능|만족|아름|예쁘|낮아지지 않|보존되고|수유가 가능|합병증인|재수술|수술을 결정|\|/;
|
||||
const isCare = (ko) => ko.length >= 6 && CARE.test(ko) && !NOT_CARE.test(ko);
|
||||
// 페이지 제목에서 세부 시술명만: "코성형(매부리코성형)-당신만 봅니다-원진성형외과" → "매부리코성형"
|
||||
const subName = (title) => { const full = String(title); const m = full.match(/\(([^)]+)\)/); if (m) return m[1].replace(/FAQ.*$/, '').trim(); return full.split(/\s*[-|]\s*/)[0].replace(/FAQ.*$/, '').trim(); };
|
||||
let attached = 0, unmatched = [], dropped = 0;
|
||||
const seen = new Set();
|
||||
for (const it of notes.items ?? []) {
|
||||
const target = base.procedures.find((p) => p.matchKeywords && new RegExp(p.matchKeywords).test(it.procedure));
|
||||
if (!target) { unmatched.push(it.procedure); continue; }
|
||||
const doc = (it.url.match(/doc=(\d+)/) || [])[1] ?? it.url.replace(/[^a-z0-9]+/gi, '-').slice(-24);
|
||||
const srcId = `note-${doc}`;
|
||||
if (!base.clinicSources.some((s) => s.id === srcId)) {
|
||||
base.clinicSources.push({ id: srcId, label: { ko: `${shortName} 홈페이지 · ${it.procedure} 시술 안내`, en: `${shortName} website · ${it.procedureEn || it.procedure}` }, url: it.url, accessed: it.fetchedAt || notes.generatedAt || '' });
|
||||
}
|
||||
const sub = subName(it.procedure), subEn = (it.procedureEn || sub).split(/\s*[-|]\s*/)[0].trim();
|
||||
(it.ko ?? []).forEach((ko, i) => {
|
||||
if (!isCare(ko)) { dropped++; return; }
|
||||
const key = `${target.id}|${ko.replace(/\s+/g, '')}`; if (seen.has(key)) return; seen.add(key);
|
||||
const en = (it.en ?? [])[i] || ko; // 번역 실패 문장은 한글 원문 그대로 (워커 recovery 단계와 같은 규칙)
|
||||
const cid = `${srcId}-${i}`;
|
||||
base.constraints[cid] = { ko: `${sub}: ${ko.trim()}`, en: `${subEn}: ${en.trim()}` };
|
||||
if (!target.constraints.includes(cid)) target.constraints.unshift(cid); // 병원 원문이 잠정 안내보다 먼저 보이게
|
||||
attached++;
|
||||
});
|
||||
if (target.constraints.some((c) => c.startsWith(srcId + '-')) && !target.sources.includes(srcId)) target.sources.push(srcId);
|
||||
}
|
||||
for (const p of base.procedures) delete p.matchKeywords;
|
||||
base.clinicSources = base.clinicSources.filter((src) => base.procedures.some((p) => p.sources.includes(src.id)));
|
||||
base._comment = `${shortName} 회복 일정 플래너 규칙표 (${industry === 'derm' ? '피부과' : '성형외과'} 기본표 + 병원 시술 페이지 원문 ${attached}문장). clinic = 병원 원문, provisional = 병원 확인 전 잠정값(화면 배지). 생성 ${new Date().toISOString().slice(0, 10)}, 워커 planner 단계.`;
|
||||
writeJson(join(DATA, 'procedures.json'), base);
|
||||
|
||||
// ---------- 장소: 병원별 큐레이션 또는 범용 기본표 ----------
|
||||
const curated = join(SUP, 'briefs', clinic, 'planner.places.json');
|
||||
let placesSummary;
|
||||
if (existsSync(curated)) {
|
||||
writeJson(join(DATA, 'places.json'), readJson(curated));
|
||||
placesSummary = `큐레이션 ${readJson(curated).places.length}곳 (briefs/${clinic}/planner.places.json)`;
|
||||
} else {
|
||||
const tpl = readJson(join(SUP, 'scripts', 'template', 'planner', 'places.base.json'));
|
||||
const tourPath = join(DATA, 'medicalTourismKo.json');
|
||||
const origin = existsSync(tourPath) ? (readJson(tourPath).meta?.origin ?? {}) : {};
|
||||
const areaKo = fact.areaLabel || fact.address?.locality || shortName;
|
||||
const areaEn = fact.areaLabelEn || fact.address?.localityEn || (fact.shortNameEn || shortName);
|
||||
const nameEn = fact.shortNameEn || fact.nameEn || shortName;
|
||||
const sub = (s) => String(s).replaceAll('{CLINIC_KO}', shortName).replaceAll('{CLINIC_EN}', nameEn).replaceAll('{AREA_KO}', areaKo).replaceAll('{AREA_EN}', areaEn);
|
||||
const out = JSON.parse(sub(JSON.stringify(tpl)));
|
||||
out.origin = { label: { ko: `${shortName}${fact.address?.street ? ` (${fact.address.street})` : ''}`, en: nameEn }, lat: origin.lat ?? 0, lng: origin.lng ?? 0 };
|
||||
out._comment = `${shortName} 범용 큐레이션(기본표 치환). 병원별 큐레이션은 briefs/${clinic}/planner.places.json 으로 준다. 숙소·관광지·행사는 관광공사 데이터(medicalTourism*.json)가 맡는다.`;
|
||||
writeJson(join(DATA, 'places.json'), out);
|
||||
placesSummary = `범용 기본표 ${out.places.length}곳 (origin ${origin.lat ? `${origin.lat}, ${origin.lng}` : '없음'})`;
|
||||
}
|
||||
console.log(`planner: 업종 ${industry} · 시술 ${base.procedures.length}종 · 병원 원문 ${attached}문장 붙임(관리 문장 아님 ${dropped}건 제외)${unmatched.length ? ` · 매칭 안 된 시술 ${unmatched.length}(${unmatched.slice(0, 4).join(', ')})` : ''} · ${placesSummary}`);
|
||||
@ -9,7 +9,6 @@ import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from '
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as R from './gate/rules.mjs';
|
||||
import { parse as parseHtml } from 'node-html-parser';
|
||||
|
||||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
const DIST = here('../dist/');
|
||||
@ -36,17 +35,6 @@ function walk(dir, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// 홈페이지 40자 일치 검사에서 뺄 블록. 둘 다 출처를 밝히고 병원 원문을 그대로 옮기는 자리라 설계상 일치한다.
|
||||
// #recovery-quote : /stay 의 "시술 후 관리 안내" 인용(출처 링크·확인일 표시)
|
||||
// .doctor-strip : 글의 검토자 표시줄. 원장 이력을 병원 의료진 페이지에서 옮기고 프로필 링크를 단다.
|
||||
// 다른 말로 바꿔 쓰면 이력이 사실과 달라지므로 원문 그대로 두는 것이 맞다.
|
||||
const QUOTED_BLOCKS = ['#recovery-quote', '.doctor-strip'];
|
||||
function withoutQuotedBlocks(html) {
|
||||
const root = parseHtml(html);
|
||||
for (const sel of QUOTED_BLOCKS) root.querySelectorAll(sel).forEach((el) => el.remove());
|
||||
return root.toString();
|
||||
}
|
||||
|
||||
const findings = []; // { where, level, code, msg }
|
||||
const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }));
|
||||
|
||||
@ -59,8 +47,6 @@ const layoutNow = {};
|
||||
for (const f of files) {
|
||||
const html = readFileSync(f, 'utf8');
|
||||
const rel = f.replace(DIST, '/');
|
||||
// astro.config 의 redirects 가 만든 meta refresh 페이지(/stay → /recovery). 본문이 없으니 검사하지 않는다.
|
||||
if (/<meta http-equiv="refresh"/.test(html) && !/<h1\b/.test(html)) continue;
|
||||
const text = R.stripHtml(html);
|
||||
|
||||
// JSON-LD
|
||||
@ -78,11 +64,7 @@ for (const f of files) {
|
||||
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) add(rel, [{ level: R.E, code: 'H1_COUNT', msg: `H1 ${h1}개 (1개여야 함)` }]);
|
||||
// 금칙 표현·홈페이지 중복·운영자 어휘·제목 화법
|
||||
add(rel, R.checkBannedBody(text));
|
||||
// 홈페이지 40자 연속 일치(§3-11)는 글이 홈페이지 문장을 베끼는 것을 막는 규칙이다.
|
||||
// 출처를 밝힌 인용 블록(QUOTED_BLOCKS)은 설계상 일치하므로 빼고 본다.
|
||||
// 금칙어·운영자 어휘 검사는 인용 블록에도 그대로 적용한다.
|
||||
const quoted = /id="recovery-quote"|class="[^"]*doctor-strip/.test(html);
|
||||
add(rel, R.checkHomeDuplicate(quoted ? R.stripHtml(withoutQuotedBlocks(html)) : text, HOME_TEXT));
|
||||
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
|
||||
add(rel, R.checkOperatorVocab(text, rel));
|
||||
add(rel, R.checkHeadingEndings(html));
|
||||
// §12-2 (1) 글 첫 부분 지원 고지. site.sponsorNotice 가 켜져 있으면 error, 아니면 warn
|
||||
@ -118,8 +100,6 @@ add('data/factSheet.json', R.checkNextCheck(fact, today));
|
||||
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
|
||||
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
|
||||
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다.
|
||||
const news = existsSync(here('../src/data/news.json')) ? JSON.parse(readFileSync(here('../src/data/news.json'), 'utf8')) : { items: [] };
|
||||
add('data/news.json', R.checkNewsItems(news.items)); // 뉴스룸: 후기·전후 비교·변신 제목, 규제·사건 보도, 날짜 형식
|
||||
|
||||
// ---------- 출력 ----------
|
||||
const errors = findings.filter((x) => x.level === R.E);
|
||||
|
||||
@ -1,22 +1,15 @@
|
||||
// 근거 수집기 (NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §7-3, §4-1).
|
||||
// 병원 홈페이지의 원문 페이지(주의사항·애프터케어·검진·안전·시술 상세·의료진·오시는길·지점)를 curl+파서로 받아
|
||||
// 병원 홈페이지의 원문 페이지(주의사항·애프터케어·검진·안전·시술 상세·의료진·오시는길)를 curl+파서로 받아
|
||||
// evidence/<clinic>/ 에 구조화해 저장한다. 요약 도구를 쓰지 않는다(§3-10). 없는 것은 없다고 남긴다.
|
||||
//
|
||||
// node scripts/collect_evidence.mjs --clinic viewclinic --url https://www.viewclinic.com [--out ../evidence] [--max 80] [--delay 400]
|
||||
// [--extra-url http://group-site.example/branches] 같은 수집기로 받을 추가 시작 페이지(쉼표 구분·반복 가능. 다른 호스트 허용)
|
||||
//
|
||||
// 산출물
|
||||
// evidence/<clinic>/index.json 페이지 목록(유형·글자 수·표·이미지·플래그)
|
||||
// evidence/<clinic>/pages/<slug>.json 페이지별 원문(제목·헤딩·섹션·표·목록·이미지·링크)
|
||||
// evidence/<clinic>/doctors.json 의료진 페이지에서 읽은 이름·직함·약력(이미지 글자면 textInImages=true)
|
||||
// evidence/<clinic>/facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기"). 지점 목록 페이지가 있으면 branches 배열
|
||||
// evidence/<clinic>/facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기")
|
||||
// evidence/<clinic>/home_text.txt 전 페이지 본문 공백 제거본. 발행 게이트의 40자 연속 일치 검사 기준
|
||||
//
|
||||
// 사이트 편차 대응(2026-09-10 오라클피부과 실측으로 추가)
|
||||
// 1. 루트가 프레임셋·meta refresh·JS location.href 껍데기인 사이트: 리다이렉트를 최대 3단계 따라가 실제 홈을 시작 URL로 쓴다. 상대 URL은 갱신된 홈 기준으로 푼다.
|
||||
// 2. EUC-KR 사이트: HTTP Content-Type → meta charset → utf-8 순으로 감지해 TextDecoder 로 디코드한다.
|
||||
// 3. index.php?doc=N 처럼 쿼리가 페이지를 구분하는 사이트: 추적용 파라미터만 빼고 쿼리를 보존한다. slug 에도 쿼리를 넣는다.
|
||||
// 4. 페이지 유형이 URL 경로에 없는 사이트: 앵커 텍스트·제목·h1 의 키워드(시술 어휘 포함)로 분류한다.
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@ -24,102 +17,52 @@ import { parse } from 'node-html-parser';
|
||||
|
||||
// ---------- 인자 ----------
|
||||
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; };
|
||||
const args = (k) => process.argv.flatMap((x, i) => (x === k && process.argv[i + 1] ? process.argv[i + 1].split(',') : [])).map((s) => s.trim()).filter(Boolean);
|
||||
const CLINIC = arg('--clinic'); const START = arg('--url');
|
||||
if (!CLINIC || !START) { console.error('사용법: --clinic <id> --url <https://...> [--extra-url <url,url>]'); process.exit(2); }
|
||||
if (!CLINIC || !START) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
|
||||
const OUT = join(arg('--out', fileURLToPath(new URL('../../evidence/', import.meta.url))), CLINIC);
|
||||
const MAX = Number(arg('--max', 250)); const DELAY = Number(arg('--delay', 400));
|
||||
const EXTRA = args('--extra-url');
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)'; // 일부 병원(원진)은 비브라우저 UA 를 403 으로 막는다. 브라우저 UA + 식별 문자열
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ---------- 페이지 유형 ----------
|
||||
// 시술 어휘. URL 경로에 유형이 없는 사이트(index.php?doc=N)는 앵커·제목이 시술명 그대로라서 이 사전으로 잡는다.
|
||||
// 성형외과·피부과 공통. 병원 상호에 흔한 "성형외과"·"피부과" 자체는 넣지 않는다(제목 접미어 오분류 방지).
|
||||
const PROCEDURE_VOCAB = /토닝|기미|색소|잡티|주근깨|검버섯|리프팅|울쎄라|써마지|인모드|슈링크|리쥬란|스킨부스터|물광|보톡스|톡신|필러|여드름|흉터|모공|제모|다한증|홍조|주름|탈모|모발이식|비만|지방분해|지방흡입|스케일링|필링|미백|레이저|사마귀|무좀|피부염|아토피|점\s*제거|문신|타투|눈성형|코성형|쌍꺼풀|매몰|절개|트임|눈매교정|안검|눈재수술|코재수술|콧볼|매부리|양악|윤곽|사각턱|광대|무턱|턱끝|가슴확대|가슴축소|가슴재수술|힙업|리프트|줄기세포|재생|주사|시술|수술|성형술/;
|
||||
// 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다. branches 는 지점 목록·지점별 페이지(네트워크 병원).
|
||||
// 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다.
|
||||
const TYPES = [
|
||||
['precautions', /precaution|caution|주의사항|주의\s*사항/i],
|
||||
['aftercare', /after-?care|사후관리|애프터/i],
|
||||
['checkup', /examination|check-?up|검진/i],
|
||||
['safety', /\bsafe|안전/i],
|
||||
['doctor', /\bdoctors?\b|의료진|\bstaff\b|physician|원장\s*(?:소개|단)/i],
|
||||
['branches', /\bbranch|\bnetwork\b|지점|네트워크\s*병원|(?:^|\s)[가-힣A-Za-z0-9]{1,10}점$/i],
|
||||
['doctor', /\bdoctors?\b|의료진|\bstaff\b|physician/i],
|
||||
['direction', /\bdirections?\d*\b|\blocation\b|오시는\s*길|찾아오|\bcontact\b|\bway-?to/i],
|
||||
['facilities', /facilit|\btour\b|둘러보|시설|병원\s*전경/i],
|
||||
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting|인사말|소개$/i],
|
||||
['facilities', /facilit|\btour\b|둘러보|시설/i],
|
||||
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting/i],
|
||||
['reservation', /\breserv|\bcounsel|예약|상담/i],
|
||||
['pricing', /\bprice|\bcost\b|비급여|수가|비용/i],
|
||||
['procedure', PROCEDURE_VOCAB],
|
||||
];
|
||||
const BRANCH_CAP = 20; // 지점별 페이지는 20개까지. 네트워크 병원은 지점이 수십 개라 MAX 를 다 먹는다
|
||||
const SKIP = /\/(board|news|event|community|review|gallery|model|login|register|mypage|cart|feed|tag|category|author|wp-|xmlrpc)|[?&](s|q|search|lang|language)=|\/(en|eng|zh|cn|chn|jp|jpn|ja|ru|th|vi|vn|mn|id)(\/|$)|before-?and-?after|before-?after|\/(before|after)\/|com\.php\?code=|photo\.php|\/mem_|\/join|\/logout|\/claim|\/clause|\/privacy|\/policy|\/recruit|\/real\.php|_movi|\/alliance|\.(jpg|jpeg|png|gif|svg|webp|pdf|css|js|ico|zip|ai|eps|psd|doc|docx|hwp|xls|xlsx|ppt|pptx|mp4|mov|avi)(\?|$)|#|mailto:|tel:|javascript:/i;
|
||||
const SKIP = /\/(board|news|event|community|review|gallery|model|login|register|mypage|cart|feed|tag|category|author|wp-|xmlrpc|\?s=)|before-?and-?after|before-?after|\/(before|after)\/|\.(jpg|jpeg|png|gif|svg|pdf|css|js|ico|zip)(\?|$)|#|mailto:|tel:|javascript:/i;
|
||||
const PROCEDURE_HINT = /수술\s*시간|마취|회복|실밥|입원|붓기|절개|시술\s*시간|수술\s*방법/;
|
||||
// 지점명 후보에서 뺄 일반 명사("장점"·"단점" 같은 X점)
|
||||
const NOT_BRANCH = new Set(['장점', '단점', '관점', '시점', '초점', '중점', '종점', '요점', '강점', '득점', '이점', '문제점', '차이점', '공통점', '출발점', '기준점', '지점', '거점', '정점', '원점', '점점', '접점', '분기점', '전환점', '만점', '평점', '채점', '감점', '흑점', '반점', '얼룩점']);
|
||||
|
||||
// 경로를 먼저 보고, 경로로 못 정하면 짧은 앵커 텍스트(메뉴 항목)만 본다. 메뉴 전체가 붙은 긴 앵커는 무시.
|
||||
function classify(path, anchor = '') {
|
||||
const segs = path.split('/').filter(Boolean).map((x) => x.replace(/[-_]/g, ' '));
|
||||
// 경로에는 시술 어휘를 적용하지 않는다(경로 "/surgery/" 같은 일반어가 전부 procedure 로 잡히는 것을 막는다)
|
||||
for (const [t, re] of TYPES) if (t !== 'procedure' && (segs.some((x) => re.test(x)) || re.test(path))) return t;
|
||||
for (const [t, re] of TYPES) if (segs.some((x) => re.test(x)) || re.test(path)) return t;
|
||||
if (anchor && anchor.length <= 30) for (const [t, re] of TYPES) if (re.test(anchor)) return t;
|
||||
return 'candidate';
|
||||
}
|
||||
// breadcrumb 상위 항목 전용 어휘. "Home > 메디칼스킨케어 > 여드름/모공관리 > 알라딘필" 처럼 제목이 제품명이어도 상위 분류로 시술 페이지임을 안다
|
||||
const CRUMB_PROCEDURE = /피부과|성형외과|피부\s*관리|스킨\s*케어|시술|수술|클리닉|치료|안티에이징|에스테틱|메디칼|메디컬/;
|
||||
// 본문 첫 항목들에서 breadcrumb("Home > A > B > 제목") 을 찾아 상위 항목 배열로
|
||||
function breadcrumbs(items) {
|
||||
for (const it of items.slice(0, 6)) {
|
||||
const m = (it.text ?? '').match(/^(?:home|홈|메인)\s*[>›»]\s*(.+)$/i);
|
||||
if (m) return m[1].split(/\s*[>›»]\s*/).map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// 제목·h1 → breadcrumb 상위 항목 순으로 분류. 경로·앵커로 못 정한 페이지(candidate)에만 쓴다. 사이트 공통 접미어("| 뷰성형외과")는 잘라낸다.
|
||||
function classifyByTitle(title = '', h1 = '', crumbs = []) {
|
||||
const head = [h1, title.split(/\s*[|::]\s*|\s+-\s+/)[0]].map((s) => (s ?? '').trim()).filter((s) => s && s.length <= 40);
|
||||
for (const s of head) for (const [t, re] of TYPES) if (re.test(s)) return t;
|
||||
for (const c of crumbs.slice(0, -1)) { for (const [t, re] of TYPES) if (re.test(c)) return t; if (CRUMB_PROCEDURE.test(c)) return 'procedure'; }
|
||||
return null;
|
||||
}
|
||||
// 본문에서 지점명 후보("청담점"·"부산서면점"). 8개 이상 나오면 지점 목록 페이지로 본다
|
||||
function branchNames(text) {
|
||||
const names = new Set();
|
||||
for (const m of text.matchAll(/(?:^|[\s>|·,(\-])((?:[가-힣]{1,4}\s)?[가-힣A-Za-z0-9]{1,10}점)(?=$|[\s<|·,)\-:])/g)) { const n = m[1].replace(/^(서울|경기|인천|부산|대구|대전|광주|울산|세종)\s/, ''); if (!NOT_BRANCH.has(n) && !/^\d+점$/.test(n)) names.add(n); }
|
||||
return [...names];
|
||||
}
|
||||
|
||||
// ---------- fetch ----------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// charset: HTTP 헤더 → 첫 4KB 의 meta charset → utf-8. EUC-KR 계열 별칭은 모두 euc-kr 로 (Node 22 TextDecoder 가 지원)
|
||||
function detectCharset(contentType, bytes) {
|
||||
const fromHeader = /charset=["']?\s*([\w-]+)/i.exec(contentType ?? '')?.[1];
|
||||
if (fromHeader) return fromHeader;
|
||||
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096));
|
||||
return /<meta[^>]+charset=["']?\s*([\w-]+)/i.exec(head)?.[1] ?? 'utf-8';
|
||||
}
|
||||
function decodeBody(bytes, charset) {
|
||||
let label = charset.toLowerCase().replace(/^x-/, '');
|
||||
if (/^(euc-?kr|ks_?c_?5601(?:-1987|-1992)?|ksc5601|cp949|windows-949|uhc)$/.test(label)) label = 'euc-kr';
|
||||
try { return new TextDecoder(label).decode(bytes); } catch { return new TextDecoder('utf-8').decode(bytes); }
|
||||
}
|
||||
async function fetchText(url, tries = 2) {
|
||||
for (let i = 0; i <= tries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(25000) });
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const charset = detectCharset(contentType, bytes);
|
||||
return { status: res.status, finalUrl: res.url, body: decodeBody(bytes, charset), contentType, charset };
|
||||
const body = await res.text();
|
||||
return { status: res.status, finalUrl: res.url, body, contentType: res.headers.get('content-type') ?? '' };
|
||||
} catch (e) { if (i === tries) return { status: 0, finalUrl: url, body: '', error: e.message }; await sleep(800 * (i + 1)); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- robots (origin 별 캐시) ----------
|
||||
const robotsCache = new Map();
|
||||
// ---------- robots ----------
|
||||
async function loadRobots(origin) {
|
||||
if (robotsCache.has(origin)) return robotsCache.get(origin);
|
||||
const r = await fetchText(`${origin}/robots.txt`, 0);
|
||||
const dis = [];
|
||||
if (r.status === 200) {
|
||||
@ -130,56 +73,30 @@ async function loadRobots(origin) {
|
||||
else if (star && /^disallow:/i.test(l)) { const p = l.replace(/^disallow:\s*/i, '').trim(); if (p) dis.push(p); }
|
||||
}
|
||||
}
|
||||
const out = { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
|
||||
robotsCache.set(origin, out);
|
||||
return out;
|
||||
return { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
|
||||
}
|
||||
|
||||
// ---------- 링크 정규화 ----------
|
||||
const TRACKING = /^(utm_|fbclid|gclid|ref$|PHPSESSID|sid$|_ga)/i;
|
||||
function normalize(href, base) {
|
||||
try {
|
||||
const u = new URL(href, base);
|
||||
if (!/^https?:$/.test(u.protocol)) return null;
|
||||
u.hash = '';
|
||||
// 쿼리는 보존한다(index.php?doc=14 처럼 쿼리가 곧 페이지인 사이트). 추적 파라미터만 빼고 키 순으로 정렬해 같은 페이지가 한 키가 되게 한다
|
||||
const keep = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b));
|
||||
u.search = ''; for (const [k, v] of keep) u.searchParams.append(k, v);
|
||||
u.hash = ''; u.search = '';
|
||||
// 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다.
|
||||
// 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다.
|
||||
let s = u.toString();
|
||||
if (u.pathname !== '/' && !u.search && s.endsWith('/')) s = s.slice(0, -1);
|
||||
if (u.pathname !== '/' && s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s;
|
||||
} catch { return null; }
|
||||
}
|
||||
const hostOf = (u) => new URL(u).host.replace(/^www\./, '');
|
||||
const HOSTS = new Set([hostOf(START)]); // 허용 호스트: 시작 URL + 리다이렉트로 옮겨간 홈 + --extra-url
|
||||
const sameHost = (u) => { try { return HOSTS.has(hostOf(u)); } catch { return false; } };
|
||||
|
||||
// ---------- 시작 URL 해석(프레임셋·meta refresh·JS 리다이렉트) ----------
|
||||
// 껍데기 페이지(프레임셋이 있거나 본문이 200자 미만)에서만 리다이렉트 대상을 찾는다. 일반 페이지의 onclick 핸들러 속 location.href 는 대상이 아니다.
|
||||
function redirectTargets(html, base) {
|
||||
const out = [];
|
||||
const add = (h) => { const u = h && normalize(h.trim(), base); if (u && u !== normalize(base, base)) out.push(u); };
|
||||
for (const m of html.matchAll(/<meta[^>]+http-equiv=["']?refresh["']?[^>]*content=["']?\s*\d+\s*;\s*url=\s*([^"'>\s]+)/gi)) add(m[1]);
|
||||
for (const m of html.matchAll(/<frame\b[^>]*\ssrc=["']?([^"'>\s]+)/gi)) add(m[1]);
|
||||
for (const s of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
|
||||
// 주석을 지우고, 중괄호 깊이 0(함수·블록 바깥)의 문장만 남긴다
|
||||
const src = s[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"\\])\/\/[^\n]*/g, '$1');
|
||||
let depth = 0, top = '';
|
||||
for (const ch of src) { if (ch === '{') depth++; else if (ch === '}') depth = Math.max(0, depth - 1); else if (depth === 0) top += ch; }
|
||||
for (const m of top.matchAll(/(?:window\.|document\.|top\.|self\.|parent\.)?location(?:\.href)?\s*=\s*["']([^"']+)["']|location\.replace\(\s*["']([^"']+)["']/g)) add(m[1] ?? m[2]);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
const isShell = (html, ex) => /<frameset\b/i.test(html) || clean(ex.items.map((i) => i.text ?? '').join(' ')).length < 200;
|
||||
const sameHost = (u, origin) => new URL(u).host.replace(/^www\./, '') === new URL(origin).host.replace(/^www\./, '');
|
||||
|
||||
// ---------- 본문 추출 ----------
|
||||
const MAIN_SELECTORS = ['main', '#ajax-content-wrap', '#main-content', '#content', '#contents', '#container', '.container-wrap', '#sub_content', '.sub_content', '.sub-content', '#subContent', 'article', '#wrap', '#wrapper'];
|
||||
const NOISE_TAGS = 'script,style,noscript,iframe,form,svg,template,video,audio,object,embed';
|
||||
const NOISE_SELECTORS = 'header,footer,nav,[id*="menu"],[class*="menu"],[id*="popup"],[class*="popup"],[id*="modal"],[class*="modal"],[class*="share"],[class*="quick"],[class*="floating"],[class*="breadcrumb"],[class*="cookie"],[class*="sns"],[id*="search"],[class*="widget"],[class*="wpcf7"],[class*="eform"],[class*="offcanvas"],[class*="gnb"],[class*="lnb"],[id*="gnb"],[id*="lnb"],[class*="topbar"],[class*="top-bar"]';
|
||||
const BLOCK = new Set(['div', 'section', 'article', 'main', 'aside', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figure', 'figcaption', 'blockquote', 'dl', 'dt', 'dd', 'pre', 'address']);
|
||||
const clean = (s) => s.replace(/ /g, ' ').replace(/[ \t\r\f\v]+/g, ' ').replace(/\s*\n\s*/g, ' ').trim();
|
||||
const clean = (s) => s.replace(/ /g, ' ').replace(/[ \t\r\f\v]+/g, ' ').replace(/\s*\n\s*/g, ' ').trim();
|
||||
|
||||
function hasBlockDescendant(el) {
|
||||
return el.querySelectorAll('*').some((c) => BLOCK.has(c.rawTagName?.toLowerCase()));
|
||||
@ -191,9 +108,7 @@ function walk(el, items) {
|
||||
if (!tag) return;
|
||||
if (/^h[1-6]$/.test(tag)) { const t = clean(el.text); if (t) items.push({ kind: 'h', level: Number(tag[1]), text: t }); return; }
|
||||
if (tag === 'table') {
|
||||
// 중첩 레이아웃 표는 같은 글이 행마다 되풀이된다. 인접한 같은 셀과 같은 행은 한 번만
|
||||
const seen = new Set();
|
||||
const rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text)).filter((c, i, a) => i === 0 || c !== a[i - 1])).filter((r) => r.some(Boolean)).filter((r) => { const k = r.join('|'); if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
const rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text))).filter((r) => r.some(Boolean));
|
||||
if (rows.length) items.push({ kind: 'table', rows });
|
||||
return;
|
||||
}
|
||||
@ -223,12 +138,8 @@ function extract(html, url) {
|
||||
const items = []; walk(main, items);
|
||||
const images = main.querySelectorAll('img').map((i) => ({ src: normalize(i.getAttribute('data-src') || i.getAttribute('src') || '', url), alt: clean(i.getAttribute('alt') ?? '') })).filter((i) => i.src && !/\.(svg|gif)$/i.test(i.src) && !/icon|logo|btn|button|arrow|blank|spacer|sns|share|1x1/i.test(i.src));
|
||||
const links = [...new Set(main.querySelectorAll('a[href]').map((a) => normalize(a.getAttribute('href'), url)).filter(Boolean))];
|
||||
// <a> 가 아니라 onclick 의 location.href·window.open 으로만 이어지는 페이지(지점 카드 등). 앵커 텍스트가 없으니 후순위
|
||||
const jsLinks = [...new Set([...html.matchAll(/(?:location(?:\.href)?\s*=|location\.replace\(|window\.open\()\s*["']([^"'\s]+)["']/g)].map((m) => normalize(m[1], url)).filter(Boolean))];
|
||||
return { title, metaDesc, mainSelector, items, images, links, anchors, jsLinks };
|
||||
return { title, metaDesc, mainSelector, items, images, links, anchors };
|
||||
}
|
||||
// 사실 후보용 전문(메뉴·푸터 포함). body 가 없는 옛 HTML 은 root 전체
|
||||
const fullText = (html) => { const r = parse(html); return clean((r.querySelector('body') ?? r).text ?? '').slice(0, 200000); };
|
||||
|
||||
/** 항목을 헤딩 기준 섹션으로 묶는다 */
|
||||
function sectionize(items) {
|
||||
@ -244,39 +155,17 @@ function sectionize(items) {
|
||||
}
|
||||
|
||||
// ---------- 사실 후보(정규식) ----------
|
||||
const REGION = '(?:서울|경기|인천|부산|대구|대전|광주|울산|세종|강원|충북|충남|충청|전북|전남|전라|경북|경남|경상|제주)';
|
||||
const PHONE = '(?:0\\d{1,2}[-.)\\s]\\d{3,4}[-.\\s]\\d{4}|1[5-9]\\d{2}[-.\\s]\\d{4})'; // 02-544-2777 · 02)352-2184 · 1577-1175(대표번호)
|
||||
// 주소 시작: 지역명 뒤에 행정단위 접미어(특별시·광역시·도 등)나 공백이 와야 한다. "서울대입구점서초점…" 처럼 띄어쓰기 없는 지점명 나열을 주소로 잡지 않게
|
||||
const REGION_START = `${REGION}(?:(?:특별시|광역시|특별자치시|특별자치도|남도|북도|도|시)\\s*|\\s+)(?:[가-힣]{1,6}(?:시|구|군|읍|면)\\s)?`;
|
||||
// 주소: 지역명 ~ (빌딩|층|호|번지|로 N|길 N) 까지 짧게 잡고, 뒤에 "한일빌딩 4층" 같은 건물·층 토큰이 이어지면 붙인다
|
||||
// 확장 토큰은 "한일빌딩"·"4층"·"304호"·"B동" 꼴만. "지점연결번호" 처럼 호로 끝나는 일반어는 붙지 않게 한다
|
||||
const ADDR_EXT = '(?:,?\\s(?:\\d+[-\\d]*호|\\d{1,3}(?:,\\d{1,3})?층|[A-Za-z0-9]{1,3}동|[A-Za-z0-9가-힣]{1,12}(?:빌딩|건물|타워|프라자|센터|센타|아파트|상가|병원)))*';
|
||||
const ADDRESS = new RegExp(`(${REGION_START}[^\\n|·]{4,60}?(?:빌딩|건물|층|호|\\d+번지|로\\s?\\d+(?:길\\s?\\d+)?(?:-\\d+)?|길\\s?\\d+(?:-\\d+)?)${ADDR_EXT})`, 'g');
|
||||
function factCandidates(text) {
|
||||
const pick = (re) => [...new Set([...text.matchAll(re)].map((m) => clean(m[1] ?? m[0])))];
|
||||
return {
|
||||
phone: pick(new RegExp(`(?<!\\d)(${PHONE})(?!\\d)`, 'g')),
|
||||
address: pick(ADDRESS),
|
||||
phone: pick(/(?<!\d)(0\d{1,2}[-.\s]\d{3,4}[-.\s]\d{4})(?!\d)/g),
|
||||
address: pick(/((?:서울|경기|인천|부산|대구|대전|광주|울산|세종|강원|충북|충남|전북|전남|경북|경남|제주)[^\n|·]{6,60}?(?:빌딩|건물|층|호|\d+번지|로\s?\d+|길\s?\d+))/g),
|
||||
businessNo: pick(/(?<!\d)(\d{3}-\d{2}-\d{5})(?!\d)/g),
|
||||
representative: pick(/대표(?:자|원장|의사)?\s*[::]?\s*(?!님|원장|전화|번호)([가-힣]{2,4})(?=\s|$|[,·|/])/g),
|
||||
hours: pick(/((?:월|화|수|목|금|토|일|평일|주말|공휴일)[^\n|]{0,20}?\d{1,2}[:시]\d{0,2}\s*[~\-–]\s*(?:AM|PM|오전|오후)?\s*\d{1,2}[:시]\d{0,2})/gi),
|
||||
representative: pick(/대표(?:자|원장|의사)?\s*[::]?\s*([가-힣]{2,4})(?=\s|$|[,·|])/g),
|
||||
hours: pick(/((?:월|화|수|목|금|토|일|평일|주말|공휴일)[^\n|]{0,20}?\d{1,2}[:시]\d{0,2}\s*[~\-–]\s*\d{1,2}[:시]\d{0,2})/g),
|
||||
founded: pick(/((?:19|20)\d{2})\s*년\s*(?:개원|설립|오픈)/g),
|
||||
};
|
||||
}
|
||||
// 지점 목록: "지점명 주소 TEL : 번호" 3종 세트. 표 행·줄 단위(| 또는 줄바꿈으로 나뉜 조각)로 읽는다. 전화가 없는 행은 phone null
|
||||
// 주소는 지역명 뒤에 시·도·구·군이 바로 와야 한다("대구점 광주·전라" 같은 지점명 나열을 주소로 잡지 않게)
|
||||
const BRANCH_LINE = new RegExp(`^\\s*((?:[가-힣]{1,4}\\s)?[가-힣A-Za-z0-9]{1,10}점)\\s*[::]?\\s*(${REGION_START}.{4,120}?)\\s*(?:(?:TEL|Tel|전화|T\\.|☎)\\s*[::.]?\\s*(${PHONE})|$)`);
|
||||
function branchCandidates(text) {
|
||||
const out = []; const seen = new Set();
|
||||
for (const seg of text.split(/\n|\s\|\s/)) {
|
||||
const m = seg.match(BRANCH_LINE); if (!m) continue;
|
||||
const name = m[1].trim(); if (NOT_BRANCH.has(name.replace(/^[가-힣]{1,4}\s/, ''))) continue;
|
||||
const addr = clean(m[2]).replace(/\s*(?:TEL|Tel|전화|T\.|☎|FAX|Fax|팩스).*$/, ''); const phone = m[3] ? clean(m[3]) : null;
|
||||
const key = `${name}|${phone ?? addr}`; if (seen.has(key)) continue; seen.add(key);
|
||||
out.push({ name, address: addr, phone });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 의료진 ----------
|
||||
function parseDoctor(page) {
|
||||
@ -286,53 +175,23 @@ function parseDoctor(page) {
|
||||
const isRep = /대표\s*원장/.test(text);
|
||||
const credentials = page.items.filter((i) => i.kind === 'li' || (i.kind === 'p' && /대학|병원|학회|전문의|회원|교수|수료|연수|졸업|위촉|박사|석사/.test(i.text) && i.text.length < 80)).map((i) => i.text).filter((t) => !/원장$/.test(t));
|
||||
const creds = [...new Set(credentials)].filter((c) => c !== specialty);
|
||||
const status = !name ? (page.flags.textInImages ? '이름 미확인(이미지 글자) · 확인 대기' : '이름 미확인 · 확인 대기') : creds.length < 2 ? (page.flags.textInImages ? '약력 이미지 글자 · 확인 대기' : '약력 본문 없음 · 확인 대기') : 'ok';
|
||||
const status = !name ? '이름 미확인 · 확인 대기' : creds.length < 2 ? (page.flags.textInImages ? '약력 이미지 글자 · 확인 대기' : '약력 본문 없음 · 확인 대기') : 'ok';
|
||||
return { id: page.slug.replace(/^.*doctors?-?/, ''), url: page.url, name, title: [isRep ? '대표원장' : '원장', specialty].filter(Boolean).join(' · ') || null, credentials: creds, images: page.images.slice(0, 5), textInImages: page.flags.textInImages, status };
|
||||
}
|
||||
|
||||
// ---------- 메인 ----------
|
||||
const origin = new URL(START).origin;
|
||||
mkdirSync(join(OUT, 'pages'), { recursive: true });
|
||||
console.log(`[1/4] 시작 ${START}`);
|
||||
|
||||
// 시작 URL 해석: 껍데기면 리다이렉트 대상들을 받아 같은 호스트 링크가 가장 많은 페이지를 홈으로, 나머지는 씨앗 페이지로
|
||||
let home = await fetchText(START);
|
||||
if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); }
|
||||
let homeEx = extract(home.body, home.finalUrl);
|
||||
const seeds = []; // { url, ex } 홈 외에 앵커를 긁을 페이지(프레임·리다이렉트 대상·--extra-url)
|
||||
const redirectChain = [];
|
||||
for (let hop = 0; hop < 3 && isShell(home.body, homeEx); hop++) {
|
||||
const targets = redirectTargets(home.body, home.finalUrl).filter(sameHost).slice(0, 5);
|
||||
if (!targets.length) break;
|
||||
const fetched = [];
|
||||
for (const u of targets) { const r = await fetchText(u); if (r.status === 200 && /html/.test(r.contentType)) fetched.push({ url: u, r, ex: extract(r.body, r.finalUrl) }); }
|
||||
if (!fetched.length) break;
|
||||
fetched.sort((a, b) => b.ex.anchors.filter(([u]) => sameHost(u)).length - a.ex.anchors.filter(([u]) => sameHost(u)).length);
|
||||
const [best, ...rest] = fetched;
|
||||
redirectChain.push({ from: home.finalUrl, to: best.r.finalUrl, kind: /<frameset\b/i.test(home.body) ? 'frameset' : 'redirect', others: rest.map((x) => x.r.finalUrl) });
|
||||
console.log(` 껍데기 페이지(${redirectChain.at(-1).kind}) → 홈 ${best.r.finalUrl}${rest.length ? ` · 씨앗 ${rest.map((x) => x.r.finalUrl).join(', ')}` : ''}`);
|
||||
for (const x of rest) seeds.push({ url: x.r.finalUrl, ex: x.ex, anchor: '' });
|
||||
home = best.r; homeEx = best.ex;
|
||||
}
|
||||
HOSTS.add(hostOf(home.finalUrl));
|
||||
const origin = new URL(home.finalUrl).origin;
|
||||
const robots = await loadRobots(origin);
|
||||
console.log(` 홈 ${home.finalUrl} (charset ${home.charset ?? 'utf-8'} · robots Disallow ${robots.disallow.length}개)`);
|
||||
for (const u of EXTRA) {
|
||||
HOSTS.add(hostOf(u)); await loadRobots(new URL(u).origin);
|
||||
const r = await fetchText(u);
|
||||
if (r.status !== 200 || !/html/.test(r.contentType)) { console.log(` 추가 URL 실패 ${u}: ${r.status} ${r.error ?? ''}`); continue; }
|
||||
seeds.push({ url: r.finalUrl, ex: extract(r.body, r.finalUrl), anchor: '', extra: true });
|
||||
console.log(` 추가 URL ${r.finalUrl} (charset ${r.charset})`);
|
||||
}
|
||||
console.log(`[1/4] 시작 ${START} (robots Disallow ${robots.disallow.length}개)`);
|
||||
|
||||
// 링크 발견: 홈 + 씨앗 + 사이트맵
|
||||
const EXTRA_SET = new Set(seeds.filter((s) => s.extra).map((s) => normalize(s.url, s.url)));
|
||||
const robotsAllowed = (u) => { const o = new URL(u).origin; const rb = robotsCache.get(o); return rb ? rb.allowed(new URL(u).pathname) : true; };
|
||||
// 링크 발견: 홈 + 사이트맵
|
||||
const home = await fetchText(START);
|
||||
if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); }
|
||||
const homeEx = extract(home.body, home.finalUrl);
|
||||
const queue = new Map(); // url → { anchor, fromHome }
|
||||
const addLink = (u, anchor = '', fromHome = false) => { if (!u || !sameHost(u) || SKIP.test(u) || /\.xml$/i.test(u) || !robotsAllowed(u)) return; const cur = queue.get(u); if (!cur) queue.set(u, { anchor, fromHome }); else { if (fromHome && !cur.fromHome) cur.fromHome = true; if (!cur.anchor && anchor) cur.anchor = anchor; } };
|
||||
const addLink = (u, anchor = '', fromHome = false) => { if (!u || !sameHost(u, origin) || SKIP.test(u) || /\.xml$/i.test(u) || !robots.allowed(new URL(u).pathname)) return; const cur = queue.get(u); if (!cur) queue.set(u, { anchor, fromHome }); else if (fromHome && !cur.fromHome) cur.fromHome = true; };
|
||||
for (const [u, a] of homeEx.anchors) addLink(u, a, true);
|
||||
for (const s of seeds) { addLink(normalize(s.url, s.url), s.anchor, true); for (const [u, a] of s.ex.anchors) addLink(u, a, true); }
|
||||
for (const u of [...homeEx.jsLinks, ...seeds.flatMap((s) => s.ex.jsLinks)]) addLink(u, '', false);
|
||||
for (const sm of ['/sitemap.xml', '/wp-sitemap.xml', '/sitemap_index.xml', '/page-sitemap.xml']) {
|
||||
const r = await fetchText(`${origin}${sm}`, 0);
|
||||
if (r.status !== 200 || !/<(urlset|sitemapindex)/.test(r.body)) continue;
|
||||
@ -344,33 +203,22 @@ for (const sm of ['/sitemap.xml', '/wp-sitemap.xml', '/sitemap_index.xml', '/pag
|
||||
console.log(` 사이트맵 ${sm}: 링크 누적 ${queue.size}`);
|
||||
break;
|
||||
}
|
||||
queue.delete(normalize(home.finalUrl, home.finalUrl)); queue.delete(normalize(START, START));
|
||||
console.log(`[2/4] 후보 링크 ${queue.size}개 → 유형 분류`);
|
||||
|
||||
// 유형 분류 후 우선순위대로 MAX까지
|
||||
const ranked = [...queue.entries()].map(([u, { anchor, fromHome }]) => ({ url: u, anchor, fromHome, depth: new URL(u).pathname.split('/').filter(Boolean).length, type: classify(new URL(u).pathname, anchor) }));
|
||||
// 같은 경로의 쿼리 변형(?id=1, ?tab=x)은 클라이언트 상태인 경우가 많다(원진 doctor?id=N·뷰 after-care?tab=X 는 같은 HTML).
|
||||
// 경로별 첫 변형(variant 0)을 모든 유형에서 먼저 받고, 나머지 변형은 그 뒤로. index.php?doc=N 처럼 쿼리가 곧 페이지인 사이트는 변형이 곧 본문이라 뒤에 오더라도 MAX 안에서 받는다
|
||||
{
|
||||
const byPath = new Map();
|
||||
for (const x of ranked.slice().sort((a, b) => a.url.localeCompare(b.url))) { const k = hostOf(x.url) + new URL(x.url).pathname.replace(/\/$/, ''); const n = byPath.get(k) ?? 0; x.variant = n; byPath.set(k, n + 1); }
|
||||
}
|
||||
const prio = (t) => { const i = TYPES.findIndex(([k]) => k === t); return i === -1 ? 99 : i; };
|
||||
const vk = (x) => Math.min(x.variant, 1);
|
||||
// 유형이 잡힌 페이지 먼저(의료진 상세는 40명까지), 그다음 후보는 홈 메뉴에서 연결된 것 → 얕은 경로 순
|
||||
const isDoctorDetail = (x) => x.type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(x.url).pathname);
|
||||
// 버킷 순서: 유형 확정(시술 제외) → 의료진 상세(40명까지) → 지점(BRANCH_CAP 까지) → 시술 → 후보(홈 메뉴에서 연결된 것 → 얕은 경로 순).
|
||||
// 시술 페이지는 수가 많아(원진 55·오라클 110) 앞에 두면 의료진 상세를 밀어낸다
|
||||
const bucket = (x) => (x.type === 'candidate' ? 4 : x.type === 'procedure' ? 3 : x.type === 'branches' ? 2 : isDoctorDetail(x) ? 1 : 0);
|
||||
// 유형 버킷 안에서는 홈 메뉴 순서(큐 삽입 순)를 지킨다(의료진은 대표원장이 먼저 나온다). 후보만 URL 길이·사전순으로 안정화
|
||||
const ordered = ranked.slice().sort((a, b) => vk(a) - vk(b) || bucket(a) - bucket(b) || (bucket(a) === 4 ? Number(b.fromHome) - Number(a.fromHome) : prio(a.type) - prio(b.type)) || a.depth - b.depth || (bucket(a) === 4 ? a.url.length - b.url.length || a.url.localeCompare(b.url) : 0));
|
||||
const caps = { 1: 40, 2: BRANCH_CAP }; const taken = {};
|
||||
const targets = ordered.filter((x) => { const b = bucket(x); taken[b] = (taken[b] ?? 0) + 1; return !(b in caps) || taken[b] <= caps[b]; }).slice(0, MAX);
|
||||
const n = (b) => ranked.filter((x) => bucket(x) === b).length;
|
||||
console.log(` 유형 확정 ${n(0)} · 의료진 상세 ${Math.min(n(1), 40)}/${n(1)} · 지점 ${Math.min(n(2), BRANCH_CAP)}/${n(2)} · 시술 ${n(3)} · 후보 ${n(4)} · 쿼리 변형 ${ranked.filter((x) => x.variant > 0).length} → 수집 ${targets.length}`);
|
||||
const typed = ranked.filter((x) => x.type !== 'candidate').sort((a, b) => prio(a.type) - prio(b.type) || a.depth - b.depth);
|
||||
const doctorDetails = typed.filter(isDoctorDetail).slice(0, 40); const typedRest = typed.filter((x) => !isDoctorDetail(x));
|
||||
const cands = ranked.filter((x) => x.type === 'candidate').sort((a, b) => Number(b.fromHome) - Number(a.fromHome) || a.depth - b.depth || a.url.length - b.url.length || a.url.localeCompare(b.url));
|
||||
const targets = [...typedRest, ...doctorDetails, ...cands].slice(0, MAX);
|
||||
console.log(` 유형 확정 ${typedRest.length} · 의료진 상세 ${doctorDetails.length} · 후보 ${cands.length} → 수집 ${targets.length}`);
|
||||
|
||||
// 수집
|
||||
const pages = [];
|
||||
const slugOf = (u) => { const x = new URL(u); return ((x.pathname.replace(/^\/|\/$/g, '') + (x.search ? '-' + x.search.slice(1) : '')).replace(/[^a-zA-Z0-9가-힣]+/g, '-').replace(/^-|-$/g, '') || 'home'); };
|
||||
const slugOf = (u) => (new URL(u).pathname.replace(/^\/|\/$/g, '').replace(/[^a-zA-Z0-9가-힣]+/g, '-') || 'home');
|
||||
async function collect(t) {
|
||||
let r = await fetchText(t.url);
|
||||
if (r.status === 404) { // 슬래시 유무 반대 형태 재시도
|
||||
@ -383,10 +231,9 @@ async function collect(t) {
|
||||
const h1 = ex.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
|
||||
let type = t.type;
|
||||
const bodyText = ex.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n');
|
||||
// 경로·앵커로 못 정한 페이지: 제목·h1 키워드 → 본문의 지점명 밀도 → 시술 본문 단서 순
|
||||
if (type === 'candidate') type = classifyByTitle(ex.title, h1, breadcrumbs(ex.items)) ?? (branchNames(bodyText).length >= 8 ? 'branches' : PROCEDURE_HINT.test(bodyText) ? 'procedure' : 'other');
|
||||
if (type === 'candidate') type = PROCEDURE_HINT.test(bodyText) ? 'procedure' : 'other';
|
||||
if (type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(t.url).pathname) && !/\/doctors?\/?$/.test(new URL(t.url).pathname)) type = 'doctor-detail';
|
||||
pages.push({ url: r.finalUrl, requestedUrl: t.url, slug: slugOf(r.finalUrl), type, extra: EXTRA_SET.has(t.url) || undefined, anchor: t.anchor, status: r.status, charset: r.charset, title: ex.title, metaDesc: ex.metaDesc, h1, mainSelector: ex.mainSelector, items: ex.items, images: ex.images, links: ex.links.filter(sameHost), rawText: bodyText, fullTextForFacts: fullText(r.body), flags: {} });
|
||||
pages.push({ url: r.finalUrl, requestedUrl: t.url, slug: slugOf(r.finalUrl), type, anchor: t.anchor, status: r.status, title: ex.title, metaDesc: ex.metaDesc, h1, mainSelector: ex.mainSelector, items: ex.items, images: ex.images, links: ex.links.filter((l) => sameHost(l, origin)), rawText: bodyText, fullTextForFacts: clean(parse(r.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
|
||||
}
|
||||
{
|
||||
let i = 0; const workers = Array.from({ length: 3 }, async () => { while (i < targets.length) { const t = targets[i++]; await collect(t); process.stdout.write(`\r 수집 ${pages.length}/${targets.length}`); await sleep(DELAY); } });
|
||||
@ -395,28 +242,12 @@ async function collect(t) {
|
||||
// 홈도 페이지로
|
||||
{
|
||||
const h1 = homeEx.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
|
||||
pages.unshift({ url: home.finalUrl, requestedUrl: START, slug: 'home', type: 'home', anchor: '', status: 200, charset: home.charset, title: homeEx.title, metaDesc: homeEx.metaDesc, h1, mainSelector: homeEx.mainSelector, items: homeEx.items, images: homeEx.images, links: homeEx.links.filter(sameHost), rawText: homeEx.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n'), fullTextForFacts: fullText(home.body), flags: {} });
|
||||
pages.unshift({ url: home.finalUrl, requestedUrl: START, slug: 'home', type: 'home', anchor: '', status: 200, title: homeEx.title, metaDesc: homeEx.metaDesc, h1, mainSelector: homeEx.mainSelector, items: homeEx.items, images: homeEx.images, links: homeEx.links.filter((l) => sameHost(l, origin)), rawText: homeEx.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n'), fullTextForFacts: clean(parse(home.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
|
||||
}
|
||||
// slug 충돌 방지(같은 slug 로 정규화되는 두 URL)
|
||||
{
|
||||
const used = new Map();
|
||||
for (const p of pages) { const n = used.get(p.slug) ?? 0; used.set(p.slug, n + 1); if (n) p.slug = `${p.slug}-${n + 1}`; }
|
||||
}
|
||||
// 같은 경로에서 본문(제목+항목)이 같은 쿼리 변형은 중복. 첫 페이지만 남기고 나머지는 type=duplicate 로 index 에만 기록한다
|
||||
{
|
||||
const seen = new Map();
|
||||
for (const p of pages.slice().sort((a, b) => a.url.length - b.url.length)) { // 짧은 URL(쿼리 없는 쪽)을 대표로
|
||||
if (p.status !== 200 || p.error) continue;
|
||||
const key = hostOf(p.url) + new URL(p.url).pathname + '\n' + p.title + '\n' + p.rawText; // http/https 는 같은 페이지
|
||||
const first = seen.get(key);
|
||||
if (first) { p.type = 'duplicate'; p.duplicateOf = first.url; p.items = []; p.images = []; if (p.extra) first.extra = true; } else seen.set(key, p);
|
||||
}
|
||||
}
|
||||
const dupCount = pages.filter((p) => p.type === 'duplicate').length;
|
||||
console.log(`\n[3/4] 페이지 ${pages.length}개 수집${dupCount ? ` (쿼리 변형 중복 ${dupCount})` : ''}. 반복 줄 제거`);
|
||||
console.log(`\n[3/4] 페이지 ${pages.length}개 수집. 반복 줄 제거`);
|
||||
|
||||
// 반복 줄(boilerplate) 제거: 페이지의 30% 이상(최소 3)에 같은 줄이 있으면 메뉴·푸터로 본다
|
||||
const ok = pages.filter((p) => p.status === 200 && !p.error && p.type !== 'duplicate');
|
||||
const ok = pages.filter((p) => p.status === 200 && !p.error);
|
||||
const freq = new Map();
|
||||
for (const p of ok) for (const t of new Set(p.items.filter((i) => i.kind !== 'table').map((i) => i.text))) freq.set(t, (freq.get(t) ?? 0) + 1);
|
||||
const threshold = Math.max(3, Math.ceil(ok.length * 0.3));
|
||||
@ -428,8 +259,7 @@ for (const p of ok) {
|
||||
p.sections = sectionize(p.items);
|
||||
p.text = p.items.map((i) => i.kind === 'table' ? i.rows.map((r) => r.join(' | ')).join('\n') : i.text).join('\n');
|
||||
p.chars = p.text.replace(/\s+/g, '').length;
|
||||
// 이미지 글자: 본문 500자 미만에 이미지 3장 이상, 또는 본문 100자 미만에 본문 이미지가 1장이라도 있으면(시술 설명을 통짜 이미지로 넣는 사이트)
|
||||
p.flags = { textInImages: (p.chars < 500 && p.images.length >= 3) || (p.chars < 100 && p.images.length >= 1), noText: p.chars < 100, boilerplateRemoved: before - p.items.length, tables: p.items.filter((i) => i.kind === 'table').length };
|
||||
p.flags = { textInImages: p.chars < 500 && p.images.length >= 3, noText: p.chars < 100, boilerplateRemoved: before - p.items.length, tables: p.items.filter((i) => i.kind === 'table').length };
|
||||
}
|
||||
|
||||
// 저장
|
||||
@ -438,30 +268,20 @@ for (const p of pages) {
|
||||
const file = `pages/${p.slug}.json`;
|
||||
const { fullTextForFacts, rawText, ...save } = p;
|
||||
writeFileSync(join(OUT, file), JSON.stringify({ clinic: CLINIC, fetchedAt: today, ...save }, null, 1));
|
||||
index.push({ file, url: p.url, type: p.type, duplicateOf: p.duplicateOf, status: p.status, mainSelector: p.mainSelector ?? null, title: p.title ?? null, h1: p.h1 ?? null, chars: p.chars ?? 0, tables: p.flags?.tables ?? 0, images: p.images?.length ?? 0, flags: Object.entries(p.flags ?? {}).filter(([k, v]) => v === true).map(([k]) => k) });
|
||||
index.push({ file, url: p.url, type: p.type, status: p.status, mainSelector: p.mainSelector ?? null, title: p.title ?? null, h1: p.h1 ?? null, chars: p.chars ?? 0, tables: p.flags?.tables ?? 0, images: p.images?.length ?? 0, flags: Object.entries(p.flags ?? {}).filter(([k, v]) => v === true).map(([k]) => k) });
|
||||
}
|
||||
const byType = index.reduce((m, x) => ((m[x.type] = (m[x.type] ?? 0) + 1), m), {});
|
||||
writeFileSync(join(OUT, 'index.json'), JSON.stringify({ clinic: CLINIC, start: START, resolvedStart: home.finalUrl, redirectChain, extraUrls: EXTRA, charset: home.charset ?? null, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1));
|
||||
writeFileSync(join(OUT, 'index.json'), JSON.stringify({ clinic: CLINIC, start: START, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1));
|
||||
|
||||
// 의료진: 상세 페이지가 있으면 상세, 없으면 목록 페이지(이미지 글자면 이름 미확인으로 남긴다)
|
||||
let doctors = ok.filter((p) => p.type === 'doctor-detail').map(parseDoctor);
|
||||
const doctorSource = doctors.length ? 'doctor-detail' : 'doctor';
|
||||
if (!doctors.length) doctors = ok.filter((p) => p.type === 'doctor').map(parseDoctor);
|
||||
writeFileSync(join(OUT, 'doctors.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, source: doctorSource, note: 'textInImages=true 인 약력은 이미지 글자라 본문에 없다. authors.json에 옮기기 전에 사람이 확인한다.', doctors }, null, 1));
|
||||
// 의료진
|
||||
const doctors = ok.filter((p) => p.type === 'doctor-detail').map(parseDoctor);
|
||||
writeFileSync(join(OUT, 'doctors.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: 'textInImages=true 인 약력은 이미지 글자라 본문에 없다. authors.json에 옮기기 전에 사람이 확인한다.', doctors }, null, 1));
|
||||
|
||||
// 사실 후보
|
||||
const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type) || p.extra); // --extra-url 로 지정한 페이지는 사람이 고른 원천이라 항상 포함
|
||||
const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type));
|
||||
const facts = {};
|
||||
for (const p of factSrc) { const c = factCandidates(p.fullTextForFacts); for (const [k, vals] of Object.entries(c)) for (const val of vals) { facts[k] ??= {}; facts[k][val] ??= []; if (!facts[k][val].includes(p.url)) facts[k][val].push(p.url); } }
|
||||
// 지점 후보: 지점 목록·지점별 페이지의 "지점명 주소 TEL" 3종 세트. 본문(메뉴·푸터 제외)에서 먼저 찾고, 없으면 전문에서
|
||||
const branches = []; const branchPagesNap = [];
|
||||
for (const p of ok.filter((p) => p.type === 'branches' || p.extra)) {
|
||||
let found = branchCandidates(p.text ?? ''); if (!found.length) found = branchCandidates(p.fullTextForFacts);
|
||||
for (const b of found) if (!branches.some((x) => x.name === b.name && x.phone === b.phone)) branches.push({ ...b, source: p.url });
|
||||
// 지점 한 곳짜리 페이지(net_main.php?netId=N 같은): 3종 세트가 없으면 본문의 주소·전화·진료시간을 그대로 후보로
|
||||
if (found.length < 3) { const c = factCandidates(p.text ?? ''); if (c.address.length || c.phone.length || c.hours.length) branchPagesNap.push({ url: p.url, title: p.title, h1: p.h1, address: c.address, phone: c.phone, hours: c.hours }); }
|
||||
}
|
||||
writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기". branches 는 지점 목록 페이지에서 뽑은 지점명·주소·전화 후보.', candidates: facts, branches, branchPages: branchPagesNap }, null, 1));
|
||||
writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기".', candidates: facts }, null, 1));
|
||||
|
||||
// 40자 중복 검사 기준 텍스트
|
||||
writeFileSync(join(OUT, 'home_text.txt'), ok.map((p) => p.text.replace(/\s+/g, '')).join('\n'));
|
||||
@ -471,7 +291,7 @@ const postsDir = arg('--posts');
|
||||
if (postsDir && existsSync(postsDir)) {
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const cited = new Set();
|
||||
for (const f of readdirSync(postsDir).filter((x) => x.endsWith('.md'))) for (const m of readFileSync(join(postsDir, f), 'utf8').matchAll(/url:\s*"([^"]+)",\s*accessed:[^}]*type:\s*clinic/g)) if (sameHost(m[1])) cited.add(normalize(m[1], origin));
|
||||
for (const f of readdirSync(postsDir).filter((x) => x.endsWith('.md'))) for (const m of readFileSync(join(postsDir, f), 'utf8').matchAll(/url:\s*"([^"]+)",\s*accessed:[^}]*type:\s*clinic/g)) if (sameHost(m[1], origin)) cited.add(normalize(m[1], origin));
|
||||
const have = new Set(pages.flatMap((p) => [p.url, p.requestedUrl].filter(Boolean)));
|
||||
const missing = [];
|
||||
for (const u of cited) { if (have.has(u)) continue; const r = await fetchText(u, 0); if (!have.has(normalize(r.finalUrl, origin))) missing.push(u); }
|
||||
@ -479,5 +299,5 @@ if (postsDir && existsSync(postsDir)) {
|
||||
}
|
||||
console.log(`[4/4] 저장 ${OUT}`);
|
||||
console.log(` 유형별: ${Object.entries(byType).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
|
||||
console.log(` 의료진 ${doctors.length}명 (약력 확인 대기 ${doctors.filter((d) => d.status !== 'ok').length}명, 출처 ${doctorSource}) · 지점 후보 ${branches.length}개 · 반복 줄 ${boiler.size}개 제거 · home_text ${ok.reduce((n, p) => n + p.chars, 0).toLocaleString()}자`);
|
||||
console.log(` 의료진 ${doctors.length}명 (약력 확인 대기 ${doctors.filter((d) => d.status !== 'ok').length}명) · 반복 줄 ${boiler.size}개 제거 · home_text ${ok.reduce((n, p) => n + p.chars, 0).toLocaleString()}자`);
|
||||
const bad = index.filter((x) => x.status !== 200); if (bad.length) console.log(` 실패 ${bad.length}: ${bad.map((b) => `${b.status} ${b.url}`).join(', ')}`);
|
||||
|
||||
@ -1,225 +0,0 @@
|
||||
// 뉴스룸 수집기 (v2 §8 "병원 무관 기사 관련성 필터", v3 §2-1 워커 단계). 네이버 뉴스 검색 + (있으면) 홈페이지 언론보도 게시판 → src/data/news.json
|
||||
// node scripts/collect_news.mjs --clinic <id> --name <상호> --url <홈> [--site <astro 사이트 폴더>] [--out <news.json>]
|
||||
// [--board <게시판 URL> | --no-board] [--no-naver(테스트용)] [--max-naver 1000(질의당)] [--max-detail 600] [--max-pages 60]
|
||||
// 규칙 (사이트 원칙 §3-8, 게이트 rules.mjs 와 같은 정규식):
|
||||
// 1) 관련성: 제목 또는 요약에 병원 이름이 있어야 한다. 관계사(코스메틱·다른 지점)·동명 기사는 뺀다. 게시판 항목은 병원이 직접 올린 것이라 이 검사를 생략한다.
|
||||
// 2) 종류: 병원이 알린 것 release · 원장이 설명한 것 column · 그 밖의 언급 mention.
|
||||
// 3) 제외: 규제·사건 보도(NEGATIVE_NEWS), 후기·전후 비교·변신 계열 제목(BANNED_NEWS_TITLE). 걸러진 건수는 stats 에 남긴다.
|
||||
// 4) 제목·매체·날짜·원문 링크만 쓴다. 기사 본문은 저장하지 않는다.
|
||||
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'node-html-parser';
|
||||
import * as R from './gate/rules.mjs';
|
||||
import { OUTLETS } from './news_outlets.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
||||
const flag = (k) => args.includes(`--${k}`);
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
(function loadEnv() { let dir = here; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } dir = dirname(dir); } })();
|
||||
|
||||
const clinic = opt('clinic'); const HOME = opt('url');
|
||||
if (!clinic || !HOME) { console.error('사용법: --clinic <id> --url <https://...> --name <상호>'); process.exit(2); }
|
||||
const SITE = opt('site') ? resolve(opt('site')) : null;
|
||||
const DATA = SITE ? join(SITE, 'src/data') : null;
|
||||
const OUT = resolve(opt('out', DATA ? join(DATA, 'news.json') : join(here, '../src/data/news.json')));
|
||||
const ID = process.env.NAVER_CLIENT_ID, SECRET = process.env.NAVER_CLIENT_SECRET;
|
||||
if (!ID || !SECRET) { console.error('NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 없음'); process.exit(2); }
|
||||
const MAX_NAVER = Number(opt('max-naver', '1000')), MAX_DETAIL = Number(opt('max-detail', '600')), MAX_PAGES = Number(opt('max-pages', '60')); // max-naver 는 질의당
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36 INFINITH-supporters/1.0';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
|
||||
// ---------- 병원 이름·의료진 ----------
|
||||
const fact = DATA && existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
|
||||
const authors = DATA && existsSync(join(DATA, 'authors.json')) ? readJson(join(DATA, 'authors.json')) : {};
|
||||
const names = [...new Set([opt('name'), fact.shortName, fact.name].filter(Boolean).map((n) => n.replace(/\s+/g, '')))];
|
||||
if (!names.length) { console.error('--name 또는 factSheet.name 필요'); process.exit(2); }
|
||||
const variants = [...new Set(names.flatMap((n) => [n, n.replace(/의원$/, '')]).filter((n) => n.length >= 3))];
|
||||
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const loose = (s) => s.split('').map(esc).join('\\s?'); // "원진성형외과" → 띄어쓰기 변형 허용
|
||||
const NAME = variants.map(loose).join('|');
|
||||
const NAME_RX = new RegExp(`(${NAME})`);
|
||||
const doctorNames = Object.values(authors.physicians ?? {}).map((p) => p.name).filter((n) => /^[가-힣]{2,4}$/.test(n));
|
||||
const DOCTOR = doctorNames.length ? `(${doctorNames.map(esc).join('|')})` : null;
|
||||
// 보도자료: "상호," / "상호 ○○○ 원장" / "상호 … 도입·개최·수상" / "[상호]"
|
||||
const RELEASE = new RegExp(`(${NAME})(의원)?\\s*[,:·\\-–—]|(${NAME})(의원)?\\s*[가-힣]{2,4}\\s*(대표)?원장|(${NAME})(의원)?.*(도입|진행|실시|이수|참여|선정|수상|위촉|개최|강화|출시|후원|기부|협약|체결|발표|공개|오픈|운영|확대|인터뷰|노하우|전수|기념)|\\[(${NAME})\\]`);
|
||||
// 원장 기고·인터뷰: "상호 ○○○ 원장" / "○○○ 상호 원장" / "상호 원장·전문의" / 의료진 실명 + 원장
|
||||
const COLUMN = new RegExp(`(${NAME})(의원)?\\s*(피부과\\s*|안티에이징센터\\s*)?[가-힣]{2,4}\\s*(대표)?원장|[가-힣]{2,4}\\s*(${NAME})(의원)?\\s*(대표)?원장|(${NAME})(의원)?\\s*(대표)?원장|(${NAME})(의원)?\\s*(성형외과\\s*)?전문의${DOCTOR ? `|${DOCTOR}\\s*(대표)?원장` : ''}`);
|
||||
const TOPICS = [
|
||||
['가슴성형', /가슴|유방|보형물|모티바|멘토|프리저베|맘모톰/], ['안면윤곽·양악', /윤곽|양악|광대|사각턱|턱|돌출입/], ['눈·코성형', /쌍꺼풀|눈매|눈성형|눈밑|안검|코성형|코재수술|콧|비중격|기능코/],
|
||||
['리프팅·피부·안티에이징', /리프팅|거상|주름|피부|울쎄라|써마지|리쥬란|보톡스|필러|줄기세포|안티에이징|온다/], ['지방·바디', /지방|복부|바디|체형/], ['모발·남성', /모발|탈모|남자|남성/],
|
||||
['수상·인증', /수상|어워드|표창|인증|선정|위촉|KOL|1위/], ['행사·교육·협약', /심포지엄|세미나|학회|교육|견학|협약|MOU|박람회|개막|참여|참가|초청|심사위원|강연|발표/],
|
||||
['해외·의료관광', /해외|외국인|의료관광|글로벌|중국|베트남|인도네시아|몽골|태국|일본|러시아|K-뷰티/], ['안전·시스템', /안전|마취|CCTV|검진|무사고|실명제|응급|회복|사후관리|애프터케어|방역|고압산소/],
|
||||
['사회공헌', /기부|후원|봉사|나눔|장학|기탁/], ['방송·출연', /방송|TV|출연|건강한의사/],
|
||||
];
|
||||
const topicOf = (t) => TOPICS.find(([, rx]) => rx.test(t))?.[0] ?? '기타';
|
||||
const kindOf = (title, desc = '') => (RELEASE.test(title) ? 'release' : COLUMN.test(`${title} ${desc}`) ? 'column' : 'mention');
|
||||
const norm = (t) => t.replace(/^\[[^\]]+\]\s*/, '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase(); // \W 는 한글도 지우므로 쓰지 않는다
|
||||
const stripTags = (s) => String(s ?? '').replace(/<[^>]+>/g, '').replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'|'/g, "'").replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const kstDate = (d) => { const x = new Date(d); return isNaN(x) ? null : x.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }); };
|
||||
const DATE_RX = /(20\d\d)[.\-/년]\s?(\d{1,2})[.\-/월]\s?(\d{1,2})/;
|
||||
const dateIn = (s) => { const m = String(s ?? '').match(DATE_RX); return m ? `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}` : null; };
|
||||
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
|
||||
const outletOf = (u) => { const h = host(u); if (!h) return '출처 미상'; if (OUTLETS[h]) return OUTLETS[h]; const k = Object.keys(OUTLETS).find((d) => h.endsWith('.' + d) || h === d); return k ? OUTLETS[k] : h; };
|
||||
|
||||
const items = []; const seenT = new Set(); const seenU = new Set();
|
||||
const stats = { naver: { fetched: 0, irrelevant: 0, excludedNegative: 0, excludedBanned: 0, dup: 0, kept: 0 }, board: { pages: 0, listed: 0, noDate: 0, excludedNegative: 0, excludedBanned: 0, dup: 0, kept: 0, detailFetched: 0 } };
|
||||
function push(it, bucket) {
|
||||
if (R.NEGATIVE_NEWS.test(it.title)) { bucket.excludedNegative++; return; }
|
||||
if (R.BANNED_NEWS_TITLE.test(it.title)) { bucket.excludedBanned++; return; }
|
||||
const k = norm(it.title);
|
||||
if (seenT.has(k) || seenU.has(it.url)) { bucket.dup++; return; }
|
||||
seenT.add(k); seenU.add(it.url); bucket.kept++; items.push(it);
|
||||
}
|
||||
|
||||
// ---------- 1. 홈페이지 언론보도 게시판 (선택) ----------
|
||||
async function get(u) {
|
||||
try {
|
||||
const r = await fetch(u, { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(20000) });
|
||||
return { ok: r.ok, status: r.status, url: r.url ?? u, html: r.ok ? await r.text() : '' };
|
||||
} catch (e) { stats.board.fetchErrors = (stats.board.fetchErrors ?? 0) + 1; return { ok: false, status: 0, url: u, html: '', error: String(e?.message ?? e) }; }
|
||||
}
|
||||
const abs = (h, base) => { try { return new URL(h, base).toString(); } catch { return null; } };
|
||||
async function detectBoard() {
|
||||
const r = await get(HOME); if (!r.ok) return null;
|
||||
const origin = new URL(r.url).origin;
|
||||
const cands = [];
|
||||
for (const a of parse(r.html).querySelectorAll('a[href]')) {
|
||||
const href = abs(a.getAttribute('href'), r.url); if (!href || !href.startsWith(origin)) continue;
|
||||
const text = `${a.text} ${a.getAttribute('title') ?? ''} ${a.querySelector('img')?.getAttribute('alt') ?? ''}`.replace(/\s+/g, ' ').trim();
|
||||
if (/언론|보도|press|in\s?the\s?news|미디어|media|뉴스|news/i.test(text) && !/뉴스레터|newsletter|유튜브|youtube|블로그|blog/i.test(text)) cands.push({ href, text, score: /언론|보도|press/i.test(text) ? 2 : 1 });
|
||||
}
|
||||
cands.sort((a, b) => b.score - a.score);
|
||||
return cands[0] ?? null;
|
||||
}
|
||||
async function crawlBoard(listUrl) {
|
||||
const origin = new URL(listUrl).origin;
|
||||
const pageUrl = (param, n) => { const u = new URL(listUrl); u.searchParams.set(param, String(n)); return u.toString(); };
|
||||
const boardPath = new URL(listUrl).pathname.replace(/\/$/, '');
|
||||
// 같은 글이 pageid 만 다른 링크로 여러 번 나오므로(kboard) 목록 페이지 인자를 떼고 비교한다
|
||||
const canon = (h) => { const u = new URL(h); for (const k of ['page', 'pageid', 'page_id', 'pg', 'p']) u.searchParams.delete(k); u.hash = ''; return u.toString(); };
|
||||
const parseList = (html, base) => {
|
||||
const found = [];
|
||||
for (const a of parse(html).querySelectorAll('a[href]')) {
|
||||
const raw = abs(a.getAttribute('href'), base); if (!raw || !raw.startsWith(origin)) continue;
|
||||
const href = canon(raw); if (href === canon(listUrl)) continue;
|
||||
if (!new URL(href).pathname.replace(/\/$/, '').startsWith(boardPath)) continue; // 게시판 경로 아래 링크만 (메뉴·푸터 제외)
|
||||
if (!/[?&](uid|id|idx|no|seq|wr_id|num|document_srl|bbs_no|article_no)=\d+|\/(view|detail|read|article|post|document)\/\d+|\/\d{2,}\/?($|\?)/i.test(href)) continue; // 상세 글 링크만
|
||||
const title = stripTags(a.text) || a.getAttribute('title') || a.querySelector('img')?.getAttribute('alt') || '';
|
||||
if (title.length < 8 || /^(이전|다음|처음|마지막|더보기|목록|글쓰기|prev|next|first|last|more|list)$/i.test(title)) continue;
|
||||
found.push({ href, title, listDate: dateIn(a.parentNode?.text) }); // 목록의 날짜는 상세 글에 날짜가 없을 때만 쓴다
|
||||
}
|
||||
return found;
|
||||
};
|
||||
let param = 'page'; const seenHref = new Set(); const rows = [];
|
||||
for (let n = 1; n <= MAX_PAGES; n++) {
|
||||
const r = await get(n === 1 ? listUrl : pageUrl(param, n)); if (!r.ok) break;
|
||||
stats.board.pages++;
|
||||
let list = parseList(r.html, r.url).filter((x) => !seenHref.has(x.href));
|
||||
if (n === 2 && !list.length && param === 'page') { param = 'pageid'; const r2 = await get(pageUrl(param, n)); if (r2.ok) list = parseList(r2.html, r2.url).filter((x) => !seenHref.has(x.href)); }
|
||||
if (!list.length) break;
|
||||
for (const x of list) { seenHref.add(x.href); rows.push(x); }
|
||||
await sleep(250);
|
||||
}
|
||||
stats.board.listed = rows.length;
|
||||
// 상세 글에서 작성일과 원문 매체 링크를 읽는다(상한 --max-detail, 동시 4). 날짜는 메타 → "작성일·등록일" 표기 → 본문 첫 날짜 → 목록 날짜 순.
|
||||
const detail = async (x) => {
|
||||
x.source = null;
|
||||
const m = x.title.match(/^\[([^\]]+)\]\s*(.*)$/);
|
||||
if (m) { x.title = m[2].trim(); if (/칼럼|기고|인터뷰|Q&A/i.test(m[1])) x.kindHint = 'column'; else x.outletHint = m[1].trim(); } // "[칼럼]"은 매체가 아니라 종류
|
||||
if (stats.board.detailFetched >= MAX_DETAIL) return;
|
||||
stats.board.detailFetched++;
|
||||
const d = await get(x.href);
|
||||
if (!d.ok) return;
|
||||
const doc = parse(d.html);
|
||||
const body = doc.querySelector('#kboard-document, .kboard-document, .kboard-document-wrap, .board-view, .board_view, .board-detail, .view-content, article, main, #content') ?? doc.querySelector('body');
|
||||
const meta = doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content') ?? doc.querySelector('meta[itemprop="datePublished"], meta[name="date"]')?.getAttribute('content');
|
||||
const labeled = (body?.text ?? '').replace(/\s+/g, ' ').match(/(작성일|등록일|게시일|날짜|Date)\s*:?\s*(20\d\d[.\-/년]\s?\d{1,2}[.\-/월]\s?\d{1,2})/);
|
||||
const cell = doc.querySelector('.detail-date .detail-value, .kboard-detail-date, .board-date, .date, time[datetime]');
|
||||
// 메타(article:published_time)는 마지막에 본다. 워드프레스 게시판은 글이 아니라 게시판 페이지의 생성일을 넣어 전 글이 같은 날짜가 된다.
|
||||
x.date = dateIn(cell?.getAttribute?.('datetime') ?? cell?.text) ?? (labeled ? dateIn(labeled[2]) : null) ?? (meta ? kstDate(meta) : null) ?? dateIn(body?.text);
|
||||
for (const a of (body ?? doc).querySelectorAll('a[href]')) { const h = abs(a.getAttribute('href'), d.url); if (h && !h.startsWith(origin) && OUTLETS[host(h)] !== undefined) { x.source = h; break; } }
|
||||
};
|
||||
const queue = [...rows];
|
||||
await Promise.all(Array.from({ length: 4 }, async () => { while (queue.length) { await detail(queue.shift()); await sleep(80); } }));
|
||||
for (const x of rows) {
|
||||
x.date = x.date ?? x.listDate ?? null;
|
||||
if (!x.date) { stats.board.noDate++; continue; }
|
||||
const url = x.source ?? x.href;
|
||||
// 게시판 글은 병원이 올린 것이므로 release 가 기본. 제목에 병원 이름이 없는 시술 설명형은 원장 기고(column)로 본다(수작업 build_news.py 와 같은 규칙)
|
||||
const kind = x.kindHint ?? (kindOf(x.title) === 'column' || !NAME_RX.test(x.title) ? 'column' : 'release');
|
||||
push({ date: x.date, title: x.title, outlet: x.outletHint ?? (x.source ? outletOf(x.source) : `${fact.shortName ?? names[0]} 언론보도 게시판`), url, kind, origin: 'board', topic: topicOf(x.title), ...(x.source ? { boardUrl: x.href } : {}) }, stats.board);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. 네이버 뉴스 검색 ----------
|
||||
async function naver(query) {
|
||||
let fetched = 0, idle = 0;
|
||||
for (let start = 1; start <= 1000 && fetched < MAX_NAVER && idle < 2; start += 100) {
|
||||
const before = stats.naver.kept;
|
||||
const u = new URL('https://openapi.naver.com/v1/search/news.json'); u.searchParams.set('query', `"${query}"`); u.searchParams.set('display', '100'); u.searchParams.set('start', String(start)); u.searchParams.set('sort', 'date');
|
||||
let r, tries = 0;
|
||||
for (;;) { // 초당 10건 제한. 429 면 물러났다가 다시(최대 5회)
|
||||
r = await fetch(u, { headers: { 'X-Naver-Client-Id': ID, 'X-Naver-Client-Secret': SECRET } });
|
||||
if (r.status !== 429 || ++tries > 5) break;
|
||||
stats.naver.rateLimited = (stats.naver.rateLimited ?? 0) + 1; await sleep(1500 * tries);
|
||||
}
|
||||
if (!r.ok) throw new Error(`네이버 뉴스 API ${r.status} ${(await r.text()).slice(0, 200)}`);
|
||||
const j = await r.json(); const list = j.items ?? [];
|
||||
for (const n of list) {
|
||||
stats.naver.fetched++; fetched++;
|
||||
const title = stripTags(n.title), desc = stripTags(n.description);
|
||||
if (!NAME_RX.test(title) && !NAME_RX.test(desc)) { stats.naver.irrelevant++; continue; } // v2 §8: 병원 무관 기사
|
||||
const date = kstDate(n.pubDate); if (!date) continue;
|
||||
const url = n.originallink || n.link;
|
||||
push({ date, title, outlet: outletOf(url), url, kind: kindOf(title, desc), origin: 'naver', topic: topicOf(`${title} ${desc}`) }, stats.naver);
|
||||
}
|
||||
if (list.length < 100) break;
|
||||
idle = stats.naver.kept === before ? idle + 1 : 0;
|
||||
await sleep(350);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 실행 ----------
|
||||
// 순서: 네이버 → 게시판. 같은 기사가 양쪽에 있으면 매체 원문 URL 이 있는 네이버 항목을 남기고 게시판 사본은 중복으로 센다.
|
||||
// 질의 한 개는 최근 1,000건까지만 돌려주므로 여러 각도로 묻는다. 관련성 필터는 질의와 무관하게 같다.
|
||||
const TOPIC_WORDS = ['가슴성형', '안면윤곽', '코성형', '눈성형', '리프팅', '재수술', '의료관광', '수상', '안전'];
|
||||
const queries = flag('no-naver') ? [] : [...new Set(variants.slice(0, 2).flatMap((v) => [v, `${v} 원장`, ...doctorNames.slice(0, 8).map((d) => `${v} ${d}`), ...TOPIC_WORDS.map((t) => `${v} ${t}`)]))];
|
||||
for (const q of queries) await naver(q);
|
||||
stats.naver.queries = queries.length;
|
||||
if (queries.length) console.log(`네이버: 질의 ${queries.length} · 수신 ${stats.naver.fetched} · 무관 ${stats.naver.irrelevant} · 채택 ${stats.naver.kept} · 제외 사건 ${stats.naver.excludedNegative} 후기·변신 ${stats.naver.excludedBanned} · 중복 ${stats.naver.dup}`);
|
||||
else console.log('네이버: 건너뜀 (--no-naver)');
|
||||
|
||||
let boardUrl = null, boardNote = '건너뜀';
|
||||
if (!flag('no-board')) {
|
||||
const pinPath = join(here, '..', 'briefs', clinic, 'clinic.json');
|
||||
const pin = existsSync(pinPath) ? readJson(pinPath) : {};
|
||||
boardUrl = opt('board') ?? pin.newsBoard ?? null;
|
||||
if (!boardUrl) { const d = await detectBoard(); if (d) { boardUrl = d.href; boardNote = `자동 발견 "${d.text}"`; } else boardNote = '홈페이지에 언론보도 게시판 링크 없음'; }
|
||||
else boardNote = opt('board') ? '--board 지정' : 'clinic.json newsBoard';
|
||||
if (boardUrl) { console.log(`게시판: ${boardUrl} (${boardNote})`); await crawlBoard(boardUrl); console.log(` 목록 ${stats.board.listed} · 채택 ${stats.board.kept} · 날짜 없음 ${stats.board.noDate} · 제외 사건 ${stats.board.excludedNegative} 후기·변신 ${stats.board.excludedBanned} · 중복(네이버와 겹침 포함) ${stats.board.dup}`); }
|
||||
else console.log(`게시판: ${boardNote}`);
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.date.localeCompare(a.date));
|
||||
const count = (f) => items.reduce((m, x) => ((m[f(x)] = (m[f(x)] ?? 0) + 1), m), {});
|
||||
const kinds = count((x) => x.kind), outlets = count((x) => x.outlet);
|
||||
const topOutlets = Object.entries(outlets).sort((a, b) => b[1] - a[1]).map(([k]) => k).filter((k) => k !== '출처 미상' && !/게시판$/.test(k));
|
||||
const gate = R.checkNewsItems(items); if (gate.length) { console.error(gate.map((g) => ` ✗ [${g.code}] ${g.msg}`).join('\n')); process.exit(1); } // 수집기 자체 검사. 여기서 걸리면 규칙과 필터가 어긋난 것
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify({ generatedAt: today, clinic, queries, sources: { board: boardUrl, boardNote, naver: `네이버 뉴스 검색, ${today}` }, stats: { kinds, outlets: Object.keys(outlets).length, naver: stats.naver, board: stats.board, years: count((x) => x.date.slice(0, 4)) }, items }, null, 1) + '\n');
|
||||
|
||||
// 사이트 문구: 매체 예시·출처 문장 (비어 있을 때만. 사람이 적은 값은 유지)
|
||||
if (DATA && existsSync(join(DATA, 'site.json'))) {
|
||||
const site = readJson(join(DATA, 'site.json')); const clinicName = fact.shortName || names[0]; const before = JSON.stringify(site);
|
||||
if (!site.newsOutletsExample && topOutlets.length) site.newsOutletsExample = `${topOutlets.slice(0, 4).join(', ')} 등`;
|
||||
if (!site.newsNote) site.newsNote = boardUrl
|
||||
? `${clinicName} 홈페이지 언론보도 게시판과 네이버 뉴스 검색에서 모은 기사입니다(${today} 기준). 병원이 알리거나 원장이 설명한 기사, 병원 이름이 제목이나 요약에 나온 기사만 모았고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`
|
||||
: `네이버 뉴스 검색에서 ${clinicName} 이름이 제목이나 요약에 나온 기사를 모았습니다(${today} 기준). 병원이 알리거나 원장이 설명한 기사가 앞에 오고, 병원에 대한 규제·사건 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있습니다.`;
|
||||
if (JSON.stringify(site) !== before) writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n'); // 바뀐 게 없으면 사람이 맞춘 포맷을 건드리지 않는다
|
||||
}
|
||||
console.log(`뉴스 ${items.length}건 (보도자료 ${kinds.release ?? 0} · 기고·인터뷰 ${kinds.column ?? 0} · 언급 ${kinds.mention ?? 0}) · 매체 ${Object.keys(outlets).length} · 게시판 ${boardUrl ? '있음' : '없음'} → ${OUT}`);
|
||||
@ -1,249 +0,0 @@
|
||||
// 병원 홈페이지 시술 페이지 원문에서 "시술 후 관리" 문장을 골라 /stay·/en/stay 의 "시술 후 관리 안내" 블록 데이터를 만든다.
|
||||
// 근거는 collect_evidence.mjs 결과(index.json + pages/*.json). 이미지 글자를 OCR 한 페이지(flags.ocr)도 text 에 들어 있으므로 같이 본다.
|
||||
// 의학 판단을 하지 않는다. 병원이 적어 둔 문장을 그대로 옮기고, 출처 URL 과 확인일을 붙인다.
|
||||
//
|
||||
// node scripts/collect_recovery.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--translate] [--max-per-page 5] [--max-pages 30] [--max-cost-usd 0.5]
|
||||
//
|
||||
// 채택 조건 (문장 단위. 마침표·물음표·줄바꿈·" / " 로 나누고 10~160자)
|
||||
// (a) 시점 표현: 시술 후 / 치료 후 / 수술 후 / 당일 / 다음 날 / N일·주·개월 (정도·간·이내·후·까지) / 이후 / 기간
|
||||
// (b) 행동·주의 표현: 피하·삼가·주의·금지·권장·가능·세안·자외선·사우나·음주·흡연·운동·화장·샤워·부기·멍·실밥 등
|
||||
// 둘 다 있어야 채택. 효과·홍보 문장(효과·개선·매력·아름다·만족·최고·추천)은 제외. 병원 이름·전화·주소 문장 제외.
|
||||
// 수술 전 준비 문장("수술 전 3일간 금주")은 시술 후 관리가 아니므로 제외.
|
||||
// OCR 문장은 그대로 쓰되 글자가 깨진 흔적(~~, 홀로 남은 조사, 표 라벨 뒤섞임)이 있으면 뺀다.
|
||||
// 발행 게이트(gate/rules.mjs)의 금칙어·효과 보장·금액·운영자 어휘·비교 수치 규칙을 문장마다 적용해 걸리는 것은 뺀다.
|
||||
//
|
||||
// 출력 <site>/src/data/recoveryNotes.json. 빈 결과여도 파일을 만든다(items []). 페이지는 items 가 없으면 블록을 그리지 않는다.
|
||||
// --translate : OPENAI_API_KEY 로 gpt-4.1-mini 배치 번역. 숫자·단위가 원문과 같지 않으면 그 문장의 en 은 비우고 enFailed 에 남긴다.
|
||||
// 시술 이름도 같은 배치로 번역해 procedureEn 에 넣는다(고유명사·제품명은 로마자 병기 허용. 예 "물광주사" → "Water-glow injection (Mulgwang)").
|
||||
// 같은 ko 문장은 캐시(<site>/src/data/recoveryNotes.cache.json)에서 다시 쓴다. 비용 상한 기본 $0.5.
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as R from './gate/rules.mjs';
|
||||
|
||||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
const SUP = here('../');
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); if (i >= 0 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; const eq = args.find((a) => a.startsWith(`--${k}=`)); return eq ? eq.slice(k.length + 3) : d; };
|
||||
const flag = (k) => args.includes(`--${k}`);
|
||||
|
||||
const clinic = opt('clinic');
|
||||
if (!clinic) { console.error('사용법: --clinic <id> --evidence <dir> --site <dir> [--translate]'); process.exit(2); }
|
||||
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
|
||||
const SITE = resolve(opt('site', SUP));
|
||||
const maxPerPage = Number(opt('max-per-page', '5'));
|
||||
// 주의사항·애프터케어 전용 페이지는 관리 문장이 수십 개라 상한을 따로 둔다 (haewon 결정 2026-09-11: "수술 전후 주의사항 길게 해도 돼")
|
||||
const maxPerCarePage = Number(opt('max-per-care-page', '40'));
|
||||
const capFor = (type) => (type === 'aftercare' || type === 'precautions' ? maxPerCarePage : maxPerPage);
|
||||
const maxPages = Number(opt('max-pages', '30'));
|
||||
const maxCostUsd = Number(opt('max-cost-usd', '0.5'));
|
||||
const doTranslate = flag('translate');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ---------- .env (generate_posts.mjs 와 같은 방식: 저장소 루트까지 위로 4단계 탐색) ----------
|
||||
function loadEnv() {
|
||||
let dir = SUP;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = join(dir, '.env');
|
||||
if (existsSync(p)) {
|
||||
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
const OUT = join(SITE, 'src', 'data', 'recoveryNotes.json');
|
||||
const CACHE = join(SITE, 'src', 'data', 'recoveryNotes.cache.json');
|
||||
const writeOut = (j) => { mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, JSON.stringify(j, null, 2) + '\n'); };
|
||||
|
||||
if (!existsSync(join(EV, 'index.json'))) {
|
||||
console.error(`근거 없음: ${EV}/index.json. 빈 결과를 씁니다.`);
|
||||
writeOut({ clinic, generatedAt: today, sourceNote: '병원 홈페이지 시술 페이지 원문', items: [], counts: { pages: 0, candidates: 0, sentences: 0, translated: 0, enFailed: 0, costUsd: 0 } });
|
||||
process.exit(0);
|
||||
}
|
||||
const index = readJson(join(EV, 'index.json'));
|
||||
const factPath = join(SITE, 'src', 'data', 'factSheet.json');
|
||||
const fact = existsSync(factPath) ? readJson(factPath) : {};
|
||||
const clinicNames = [fact.name, fact.shortName, index.clinic].filter((s) => s && /[가-힣]/.test(s));
|
||||
const phone = fact.phone || '';
|
||||
// 홈페이지 40자 연속 일치 검사 기준(발행 게이트와 같은 파일). 인용 블록은 게이트에서 제외되지만, 다른 페이지의 문장과 겹치는지는 여기서 보지 않는다.
|
||||
|
||||
// ---------- 문장 규칙 ----------
|
||||
const TIME = /시술\s*후|치료\s*후|수술\s*후|당일|다음\s*날|\d+\s*(일|주|개월)\s*(정도|간|이내|후|까지)|이후|기간/;
|
||||
const ACTION = /피하|피해|삼가|주의|금지|권장|가능|세안|자외선|선크림|사우나|찜질|음주|흡연|운동|화장|샤워|목욕|일상\s*생활|복귀|출근|부기|멍|딱지|보습|냉찜질|온찜질|실밥/;
|
||||
const PROMO = /효과|개선|매력|아름다|만족|최고|추천|노하우|프리미엄|서비스|제공|전담|책임|케어해|서포트|장비|기기|시스템|자체|생기있|탄탄|오래\s*유지|정밀|안전한|거의\s*(보이지|남지)\s*않|에\s*비해|단축/; // 뒤쪽은 시술 소개·장비 홍보·타 술식 비교 문장의 어휘. 관리 지시문에는 잘 안 나온다
|
||||
// 수술 전 준비 문장. "수술 전 3일간 금주" 는 시술 후 관리가 아니다. "수술 전/후" 처럼 후가 같이 있으면 통과.
|
||||
const PRE_OP = /(수술|시술|치료)\s*(\d+\s*(일|주|시간|개월)\s*)?전(?!\s*[\/·후])/;
|
||||
const CONTACT = /\d{2,4}[-.]\d{3,4}[-.]\d{4}|\d{4}-\d{4}|(서울|부산|대구|인천|광주|대전|울산|경기|강원|충북|충남|전북|전남|경북|경남|제주)[특별광역]*[시도]?\s*[가-힣]+[구군시]|[가-힣]+(로|길)\s*\d+|빌딩|층\b|오시는\s*길|찾아오시는/;
|
||||
// 표 라벨(수술시간·마취방법 등)이 한 줄에 뒤섞인 OCR 문장은 행 순서가 깨져 있어 통째로 뺀다.
|
||||
const TABLE_LABEL = /(수술\s*시간|마취\s*방법|입원\s*여부|입원\s*기간|시술\s*시간|수술\s*방법)\s*[::]/;
|
||||
// 라벨 뒤만 살리는 경우: "회복기간 : …" 처럼 콜론이 있는 라벨, 또는 소제목("수술 후 관리", "시술 후 주의점")이 문장 앞에 붙은 OCR 문장
|
||||
const COLON_LABEL = /(회복\s*기간|실밥\s*제거)\s*[::]\s*/;
|
||||
const HEADING_GLUE = /^.{0,12}?(시술|수술)\s*후\s*(주의\s*(점|사항)|관리(\s*방법)?)\s+(?=\S)/;
|
||||
// OCR 파손 흔적: 물결 두 개, 홀로 남은 조사, 숫자 뒤 알파벳(3-4t시간), 표시 기호, 단독 숫자
|
||||
const BROKEN = /~~|\s[을를이가은는의]\s|\d[a-zA-Z]시간|[✓√■►▶▪◆●▲]|(?<![\d~\-.,:])\d{1,2}(?!\d|\s*(분|일|주|개월|시간|cc|mm|cm|회|%|배|도|번|시|세|주일|달|kg|g|ml|차|층|단계|가지)|\s*[~\-:]\s*\d|[.,]\d)/;
|
||||
|
||||
const compact = (s) => s.replace(/\s+/g, '');
|
||||
const clean = (s) => s
|
||||
.replace(/^[\s\-•●▪■#*·√✓※]+/, '') // 목록 기호
|
||||
.replace(/^\(?\d{1,2}[.)]\s+(?=[가-힣])/, '') // "5. 다음날부터" 목록 번호
|
||||
.replace(/^\d{1,2}\s+(?=[가-힣])/, '') // "5 다음날부터"
|
||||
.replace(/\s+\d{1,2}[.,]?$/, '') // 문장 끝에 붙은 다음 항목 번호 "… 가능 2."
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
function splitSentences(text) {
|
||||
return String(text || '')
|
||||
.split(/(?<=[.?!。])\s+|\n+|\s\/\s/)
|
||||
.map(clean)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 채택 여부. 실패 사유를 문자열로 돌려주고, 통과면 정제된 문장을 돌려준다. */
|
||||
function judge(raw, { ocr }) {
|
||||
let s = raw;
|
||||
if (TABLE_LABEL.test(s)) return { drop: 'table' };
|
||||
const g = s.match(HEADING_GLUE);
|
||||
if (g) s = s.slice(g[0].length).trim();
|
||||
const c = s.match(COLON_LABEL);
|
||||
if (c && c.index > 0) s = s.slice(c.index + c[0].length).trim();
|
||||
if (s.length < 10 || s.length > 160) return { drop: 'length' };
|
||||
if (!TIME.test(s)) return { drop: 'time' };
|
||||
if (!ACTION.test(s)) return { drop: 'action' };
|
||||
if (s.split(' ').length < 4) return { drop: 'fragment' }; // "수술 다음날부터 가능" 처럼 무엇이 가능한지 없는 표 조각
|
||||
if (PROMO.test(s)) return { drop: 'promo' };
|
||||
if (PRE_OP.test(s) && !/후/.test(s)) return { drop: 'preop' };
|
||||
if (CONTACT.test(s) || clinicNames.some((n) => s.includes(n)) || (phone && s.includes(phone))) return { drop: 'contact' };
|
||||
if (BROKEN.test(s)) return { drop: ocr ? 'ocr-broken' : 'broken' };
|
||||
if (ocr && s.length > 100 && !/[,.]/.test(s.slice(0, 80))) return { drop: 'ocr-broken' }; // 표 칸이 한 줄로 이어 붙은 OCR 문장(쉼표·마침표 없이 길다)
|
||||
if (!/[.다요음함됨]\s*[.)]?$/.test(s) && !/(가능|불가|금지|권장|주의|필요)$/.test(s)) return { drop: 'unfinished' }; // 문장이 끝나지 않은 조각
|
||||
// 발행 게이트 규칙. 걸리면 뺀다.
|
||||
const fields = [{ field: 'ko', text: s }];
|
||||
const gate = [
|
||||
...R.checkBannedBody(s),
|
||||
...R.checkOperatorVocab(s),
|
||||
...R.checkPriceMention(fields),
|
||||
...R.checkDeviceClaim({}, fields),
|
||||
...R.checkConclusions(fields),
|
||||
].filter((x) => x.level === R.E);
|
||||
if (gate.length) return { drop: `gate:${gate[0].code}` };
|
||||
return { ok: s };
|
||||
}
|
||||
|
||||
// ---------- 페이지 순회 ----------
|
||||
const TYPES = new Set(['procedure', 'aftercare', 'precautions']);
|
||||
const pages = (index.pages ?? []).filter((p) => TYPES.has(p.type) && p.status === 200 && p.file);
|
||||
const dropCounts = {};
|
||||
const items = [];
|
||||
const seenGlobal = new Set(); // 페이지가 달라도 같은 문장(공백 제거 후)은 한 번만
|
||||
const cleanTitle = (t) => {
|
||||
let s = String(t || '').split('|')[0].trim();
|
||||
for (const n of clinicNames) s = s.replace(new RegExp(`^${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`), '');
|
||||
return s.trim();
|
||||
};
|
||||
for (const meta of pages) {
|
||||
const fp = join(EV, meta.file);
|
||||
if (!existsSync(fp)) continue;
|
||||
const pg = readJson(fp);
|
||||
const flags = Array.isArray(pg.flags) ? pg.flags : Object.keys(pg.flags ?? {}).filter((k) => pg.flags[k]);
|
||||
const ocr = flags.includes('ocr');
|
||||
const text = pg.text || '';
|
||||
// 시술 영역: 빵부스러기("Home > 필러 & 보톡스 > 보톡스") 의 첫 항목
|
||||
const crumb = text.split('\n').find((l) => /^Home\s*>/.test(l.trim()));
|
||||
const area = crumb ? (crumb.split('>').map((s) => s.trim())[1] ?? '') : '';
|
||||
const ko = [];
|
||||
const seenPage = new Set();
|
||||
for (const raw of splitSentences(text)) {
|
||||
const j = judge(raw, { ocr });
|
||||
if (j.drop) { dropCounts[j.drop] = (dropCounts[j.drop] ?? 0) + 1; continue; }
|
||||
const key = compact(j.ok);
|
||||
if (seenPage.has(key) || seenGlobal.has(key)) { dropCounts.dup = (dropCounts.dup ?? 0) + 1; continue; }
|
||||
seenPage.add(key); seenGlobal.add(key);
|
||||
ko.push(j.ok);
|
||||
if (ko.length >= capFor(meta.type)) break;
|
||||
}
|
||||
if (!ko.length) continue;
|
||||
items.push({ procedure: cleanTitle(pg.h1) || cleanTitle(pg.title) || cleanTitle(meta.title), procedureEn: '', area, url: pg.url || meta.url, fetchedAt: pg.fetchedAt || index.fetchedAt || today, ko, en: ko.map(() => ''), ocr });
|
||||
}
|
||||
items.sort((a, b) => b.ko.length - a.ko.length || a.procedure.localeCompare(b.procedure, 'ko'));
|
||||
const kept = items.slice(0, maxPages);
|
||||
const totalSentences = kept.reduce((n, it) => n + it.ko.length, 0);
|
||||
|
||||
// ---------- 번역 (선택) ----------
|
||||
const PRICE = { in: 0.4, out: 1.6 }; // gpt-4.1-mini USD / 1M tokens (2025 목록가)
|
||||
const numsOf = (s) => (s.match(/\d+(?:[.,]\d+)?/g) ?? []).map((x) => x.replace(',', '.')).sort().join('|');
|
||||
let costUsd = 0, translated = 0, enFailed = 0, cacheHits = 0;
|
||||
const failed = [];
|
||||
async function translateAll() {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
const cache = existsSync(CACHE) ? readJson(CACHE) : {};
|
||||
const pending = []; // { it, i, ko, kind } · kind 'name' 은 시술 이름(i = -1). 캐시 키는 'name:' 접두어로 문장과 구분한다.
|
||||
for (const it of kept) {
|
||||
it.ko.forEach((s, i) => {
|
||||
if (cache[s]) { it.en[i] = cache[s]; cacheHits++; }
|
||||
else pending.push({ it, i, ko: s, kind: 'sentence' });
|
||||
});
|
||||
if (it.procedure) {
|
||||
if (cache[`name:${it.procedure}`]) { it.procedureEn = cache[`name:${it.procedure}`]; cacheHits++; }
|
||||
else pending.push({ it, i: -1, ko: it.procedure, kind: 'name' });
|
||||
}
|
||||
}
|
||||
if (!pending.length) return;
|
||||
if (!key) { console.warn('OPENAI_API_KEY 없음. 번역을 비운 채 씁니다.'); return; }
|
||||
const clinicEn = fact.shortNameEn || fact.nameEn || 'the clinic';
|
||||
const system = [
|
||||
'You translate Korean post-treatment care instructions from a medical clinic website into plain English for international patients.',
|
||||
'Rules: translate each sentence faithfully as an instruction; do not add, soften or strengthen medical advice; do not add explanations.',
|
||||
'Keep every number exactly as written in the source (digits, ranges such as 3~4 become "3 to 4", percentages, units converted to English words like days/weeks/months/hours). Never turn a number word into a digit and never drop a digit.',
|
||||
'Items with kind "name" are treatment or page names, not sentences: give a short English treatment name as a title (no trailing period); for proper nouns, brand or product names, keep a romanized Korean form in parentheses, e.g. "물광주사" -> "Water-glow injection (Mulgwang)", "맥스리프트" -> "Max Lift". Glossary: 주걱턱 = protruding chin (mandibular prognathism), 무턱 = receding chin, 사각턱 = square jaw, 매몰법 = non-incision double eyelid (buried suture), 앞트임/뒷트임 = epicanthoplasty / lateral canthoplasty, 광대 = cheekbone, 양악 = two-jaw (orthognathic), 눈매교정 = ptosis correction.',
|
||||
`The clinic is ${clinicEn}. Do not mention any other clinic. Output JSON only.`,
|
||||
].join(' ');
|
||||
const schema = { name: 'translations', strict: true, schema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { i: { type: 'integer' }, en: { type: 'string' } }, required: ['i', 'en'] } } }, required: ['items'] } };
|
||||
const BATCH = 40;
|
||||
for (let b = 0; b < pending.length; b += BATCH) {
|
||||
const batch = pending.slice(b, b + BATCH);
|
||||
const est = (batch.reduce((n, x) => n + x.ko.length, 0) * 1.5 + 400) / 1e6 * PRICE.in + (batch.reduce((n, x) => n + x.ko.length, 0) * 1.2) / 1e6 * PRICE.out;
|
||||
if (costUsd + est > maxCostUsd) { console.warn(`비용 상한 $${maxCostUsd} 도달 예상. 남은 ${pending.length - b}문장은 번역하지 않습니다.`); break; }
|
||||
const user = JSON.stringify({ items: batch.map((x, i) => ({ i, kind: x.kind, ko: x.ko })) });
|
||||
const body = { model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], response_format: { type: 'json_schema', json_schema: schema } };
|
||||
let j = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body) });
|
||||
if (res.status === 429 || res.status >= 500) { await new Promise((r) => setTimeout(r, 4000 * (attempt + 1))); continue; }
|
||||
if (!res.ok) { console.warn(`번역 호출 실패 ${res.status}: ${(await res.text()).slice(0, 200)}`); break; }
|
||||
j = await res.json(); break;
|
||||
}
|
||||
if (!j) { batch.forEach((x) => { enFailed++; failed.push({ ko: x.ko, why: 'api' }); }); continue; }
|
||||
const u = j.usage ?? {};
|
||||
costUsd += ((u.prompt_tokens ?? 0) * PRICE.in + (u.completion_tokens ?? 0) * PRICE.out) / 1e6;
|
||||
let out;
|
||||
try { out = JSON.parse(j.choices?.[0]?.message?.content ?? '{}').items ?? []; } catch { out = []; }
|
||||
const byI = new Map(out.map((o) => [o.i, String(o.en ?? '').trim()]));
|
||||
batch.forEach((x, i) => {
|
||||
const en = byI.get(i) ?? '';
|
||||
if (!en) { if (x.kind !== 'name') { enFailed++; failed.push({ ko: x.ko, why: 'empty' }); } return; }
|
||||
if (x.kind === 'name') { x.it.procedureEn = en.replace(/\.$/, ''); cache[`name:${x.ko}`] = x.it.procedureEn; translated++; return; }
|
||||
if (numsOf(en) !== numsOf(x.ko)) { enFailed++; failed.push({ ko: x.ko, en, why: 'numbers' }); return; }
|
||||
x.it.en[x.i] = en; cache[x.ko] = en; translated++;
|
||||
});
|
||||
}
|
||||
writeFileSync(CACHE, JSON.stringify(cache, null, 1) + '\n');
|
||||
}
|
||||
if (doTranslate && kept.length) await translateAll();
|
||||
|
||||
// ---------- 출력 ----------
|
||||
const result = {
|
||||
clinic,
|
||||
generatedAt: today,
|
||||
sourceNote: '병원 홈페이지 시술 페이지 원문',
|
||||
items: kept.map(({ fetchedAt, ...it }) => ({ ...it, fetchedAt })),
|
||||
counts: { pagesScanned: pages.length, pagesWithNotes: items.length, pages: kept.length, sentences: totalSentences, translated: translated + cacheHits, cacheHits, enFailed, costUsd: +costUsd.toFixed(4), dropped: dropCounts },
|
||||
...(failed.length ? { enFailed: failed } : {}),
|
||||
};
|
||||
writeOut(result);
|
||||
console.log(`시술 후 관리 문장: 페이지 ${pages.length}개 중 ${items.length}개에서 ${totalSentences}문장 (상위 ${kept.length}페이지) · 번역 ${translated + cacheHits}(캐시 ${cacheHits}) · 실패 ${enFailed} · $${costUsd.toFixed(4)} → ${OUT}`);
|
||||