Compare commits
55 Commits
f6701c2788
...
82aa56fa40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82aa56fa40 | ||
|
|
0a0f64c29e | ||
|
|
bb68bdacad | ||
|
|
b79565a6a6 | ||
|
|
e7a11ef67a | ||
|
|
e60ef04503 | ||
|
|
1a7195a96a | ||
|
|
730f7822c3 | ||
|
|
da6397ca3c | ||
|
|
f26ba37948 | ||
|
|
7b1be074ad | ||
|
|
dca494fc21 | ||
|
|
d66e6279ed | ||
|
|
daf6350c49 | ||
|
|
891b7bdece | ||
|
|
f01bc6cafc | ||
|
|
6c3e2888a7 | ||
|
|
c044546614 | ||
|
|
99195f09f3 | ||
|
|
008411b929 | ||
|
|
5263593816 | ||
|
|
0108fe1ff1 | ||
|
|
0cc37b1b89 | ||
|
|
93509bf9b3 | ||
|
|
620b2cda59 | ||
|
|
94d7edec45 | ||
|
|
df6ad3bbd2 | ||
|
|
d13ac35365 | ||
|
|
ed8f65030d | ||
|
|
587462552b | ||
|
|
6b01cc9f7b | ||
|
|
aa5b05227f | ||
|
|
65fb9be9c6 | ||
|
|
83b8343262 | ||
|
|
f9e91d3305 | ||
|
|
24dbe03471 | ||
|
|
e47c5e2fc6 | ||
|
|
295401f356 | ||
|
|
c750cfd6d9 | ||
|
|
378ca7e7cd | ||
|
|
27e77add65 | ||
|
|
e40f9ceb94 | ||
|
|
5c08fa97ab | ||
|
|
67f1cb8295 | ||
|
|
b92f29c958 | ||
|
|
2fc3f93163 | ||
|
|
e32633e9a6 | ||
|
|
a807d5dd9c | ||
|
|
5d3ce40f77 | ||
|
|
b2caef9d02 | ||
|
|
501d6002c6 | ||
|
|
8776a15183 | ||
|
|
2eb3d5cd06 | ||
|
|
4e73798cce | ||
|
|
484601b0f5 |
34
data/ai_channels.json
Normal file
34
data/ai_channels.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "AI 검색 유입 분류 기준(단일 원본). GA4 세션 출처(sessionSource)·리퍼러·utm_source 를 이 표로 AI 채널에 묶는다. measurable 은 리포트의 '측정 가능 범위' 표에 그대로 나간다. 수정하면 scripts/ga4_ai_traffic.py 와 리포트 빌더가 같이 바뀐다.",
|
||||
"version": "2026-09-08",
|
||||
"channels": [
|
||||
{ "id": "chatgpt", "label": "ChatGPT", "sources": ["chatgpt.com", "chat.openai.com", "openai.com"], "utm_sources": ["chatgpt.com", "chatgpt", "openai"],
|
||||
"measurable": "yes", "note": "링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다." },
|
||||
{ "id": "perplexity", "label": "Perplexity", "sources": ["perplexity.ai", "www.perplexity.ai"], "utm_sources": ["perplexity", "perplexity.ai"],
|
||||
"measurable": "partial", "note": "리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다." },
|
||||
{ "id": "gemini", "label": "Gemini", "sources": ["gemini.google.com", "bard.google.com"], "utm_sources": ["gemini"],
|
||||
"measurable": "yes", "note": "gemini.google.com 리퍼러로 분리된다." },
|
||||
{ "id": "copilot", "label": "Copilot", "sources": ["copilot.microsoft.com", "bing.com/chat", "edgeservices.bing.com"], "utm_sources": ["copilot"],
|
||||
"measurable": "partial", "note": "Bing 검색과 리퍼러가 섞이는 경우가 있다." },
|
||||
{ "id": "claude", "label": "Claude", "sources": ["claude.ai"], "utm_sources": ["claude"],
|
||||
"measurable": "yes", "note": "claude.ai 리퍼러." },
|
||||
{ "id": "google_aio", "label": "Google AI Overview · AI Mode", "sources": [], "utm_sources": [],
|
||||
"measurable": "no", "note": "구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다." },
|
||||
{ "id": "naver_ai", "label": "네이버 AI 브리핑", "sources": [], "utm_sources": [],
|
||||
"measurable": "no", "note": "네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능." },
|
||||
{ "id": "other_ai", "label": "기타 AI 검색", "sources": ["you.com", "search.brave.com", "phind.com", "deepseek.com", "chat.deepseek.com", "grok.com", "x.ai", "meta.ai", "kagi.com", "liner.com", "getliner.com", "wrtn.ai"], "utm_sources": ["deepseek", "grok", "you.com", "wrtn"],
|
||||
"measurable": "partial", "note": "리퍼러가 있을 때만 잡힌다." }
|
||||
],
|
||||
"not_measurable_by_ga4": [
|
||||
"AI 답변에 병원이 언급됐지만 사용자가 링크를 누르지 않은 경우(노출만 된 경우). 이건 답변엔진 실측(질문 뱅크)으로 잰다.",
|
||||
"AI 크롤러(GPTBot·PerplexityBot·ClaudeBot·Google-Extended)의 방문. 사람 방문이 아니라 GA4에 잡히지 않는다. 서버 로그로 잰다.",
|
||||
"리퍼러를 비운 채 넘어온 AI 유입. GA4에는 direct 로 들어간다.",
|
||||
"전화 통화·오프라인 방문으로 이어진 전환. 사이트의 전화 탭·예약 클릭까지만 잰다."
|
||||
],
|
||||
"conversion_events": [
|
||||
{ "name": "click_tel", "label": "전화 탭", "match": "a[href^='tel:']" },
|
||||
{ "name": "click_reservation", "label": "상담 예약 클릭", "match": "a.cta, a[data-ga='reservation']" },
|
||||
{ "name": "click_clinic_site", "label": "병원 홈페이지 이동", "match": "a[data-ga='clinic-site']" },
|
||||
{ "name": "click_map", "label": "지도·길찾기 클릭", "match": "a[data-ga='map']" }
|
||||
]
|
||||
}
|
||||
65
docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md
Normal file
65
docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md
Normal file
@ -0,0 +1,65 @@
|
||||
# AI 답변 감성 판정 기준표 v0.1
|
||||
|
||||
작성 2026-09-08 · 상태: 초안, haewon 보정 대기 · 적용 스크립트 `scripts/sentiment_qb.py`
|
||||
|
||||
## 1. 무엇을 재는가
|
||||
|
||||
질문 뱅크(120문항)를 답변엔진에 물어 받은 답변 안에서 **대상 병원이 어떤 어조로 언급되는지**를 판정한다. 브랜드 범용 도구(FrostAI 등)의 "감성 점수"에 대응하는 지표지만, 병원은 부정 신호의 대부분이 규제·사건 보도이므로 그 항목을 따로 센다.
|
||||
|
||||
측정 원칙은 질문 뱅크와 같다. 소비자 UI 자동 조회는 하지 않고 공식 API 답변만 쓴다. 답변 원문은 저장하되 리포트에는 근거 문장 한 줄만 인용한다.
|
||||
|
||||
## 2. 판정 단위와 값
|
||||
|
||||
답변 1건 = 판정 1건. 판정기는 아래 JSON을 낸다.
|
||||
|
||||
| 필드 | 값 | 설명 |
|
||||
|---|---|---|
|
||||
| mentioned | true / false | 대상 병원(별칭 포함)이 답변에 나오는가. 추출기의 `view_mentioned`와 교차 확인 |
|
||||
| sentiment | positive / neutral / negative / mixed / not_mentioned | 병원에 대한 어조. 언급이 없으면 not_mentioned |
|
||||
| aspects | 배열 | 아래 §3 측면 태그 중 해당하는 것 |
|
||||
| negative_type | none / regulatory / safety / service / price / reputation / other | 부정이면 어떤 종류인가 |
|
||||
| evidence | 문장 1개 | 판정 근거가 된 답변 원문 문장 (요약 금지, 그대로 인용) |
|
||||
| recommendation | recommended / listed / cautioned / not_applicable | 병원을 추천했는가, 목록에 나열만 했는가, 주의를 붙였는가 |
|
||||
| confidence | 0~1 | 판정기 자신감 |
|
||||
|
||||
### 어조 판정 규칙
|
||||
|
||||
- **positive**: 병원의 강점을 서술하거나 추천 근거로 든다. 예: "안전 시스템이 갖춰진 병원", "가슴성형 전문으로 평가받는다".
|
||||
- **neutral**: 사실 나열, 목록 포함, 위치·진료과목 안내처럼 평가 없는 언급.
|
||||
- **negative**: 부정 사실을 서술하거나 주의를 권한다. 예: "공정위 시정명령을 받았다", "후기 광고 논란이 있었다", "부작용 사례가 보고됐다".
|
||||
- **mixed**: 긍정과 부정이 한 답변에 함께 있다. 점수 계산에서는 negative 쪽으로 센다(보수적).
|
||||
- 다른 병원에 대한 평가는 세지 않는다. 대상 병원 문장만 본다.
|
||||
- "확인이 필요하다", "직접 상담을 권한다" 같은 일반 주의 문구는 negative가 아니라 neutral이다. 병원 고유의 문제를 지목할 때만 negative다.
|
||||
|
||||
## 3. 측면 태그
|
||||
|
||||
| 태그 | 포함 |
|
||||
|---|---|
|
||||
| doctors | 의료진, 전문의, 경력, 원장 |
|
||||
| safety | 안전 시스템, 마취, 부작용, 사고, 회복 관리 |
|
||||
| price | 가격, 비용, 견적, 할인 |
|
||||
| reviews | 후기, 평판, 만족도, 리뷰 수 |
|
||||
| regulatory | 공정위, 시정명령, 의료법 위반, 광고 제재, 소송 |
|
||||
| procedure | 시술·수술 종류, 장비, 기술, 전문 분야 |
|
||||
| access | 위치, 예약, 외국어 응대, 편의 |
|
||||
| media | 방송 출연, 언론 보도, 수상 |
|
||||
|
||||
## 4. 점수
|
||||
|
||||
- **감성 점수(0~100)** = (positive × 100 + neutral × 50 + mixed × 25 + negative × 0) ÷ 언급된 답변 수. Frost의 감성 점수와 같은 척도로 비교할 수 있게 했다.
|
||||
- **부정 비율** = negative + mixed ÷ 언급된 답변 수.
|
||||
- **규제 언급률** = negative_type이 regulatory인 답변 ÷ 언급된 답변 수. 병원 전용 보조 지표.
|
||||
- **추천율** = recommendation이 recommended인 답변 ÷ 언급된 답변 수.
|
||||
- 엔진별로 따로 내고, 브랜드 질문(A·B)과 논브랜드 질문을 분리한다. 논브랜드에서 언급되며 긍정인 것이 가장 가치 있는 신호다.
|
||||
|
||||
## 5. 보정 절차 (공신력)
|
||||
|
||||
1. 판정 결과에서 30건을 무작위로 뽑아 `*_calibration.xlsx`에 담는다. 판정기 값은 숨기고 haewon이 sentiment와 negative_type을 직접 적는다.
|
||||
2. 일치율을 계산한다. sentiment 일치 80% 이상이면 기준표를 확정한다. 미만이면 불일치 사례를 §2 규칙에 예시로 추가하고 다시 돌린다.
|
||||
3. 확정 후에는 같은 기준표·같은 모델로만 월간 실행한다. 모델을 바꾸면 보정을 다시 한다.
|
||||
|
||||
## 6. haewon이 정할 것
|
||||
|
||||
- **부정의 범위**: "후기 광고 논란"처럼 사실이지만 병원에 불리한 언급을 negative로 볼지, 규제 사실 서술은 neutral로 볼지. 초안은 negative다.
|
||||
- **mixed 처리**: 점수에서 25점으로 셀지, 제외할지. 초안은 25점.
|
||||
- **다른 병원 추천 문맥**: 대상 병원이 언급되지 않고 경쟁 병원만 추천된 답변을 별도 지표(경쟁 노출)로 셀지.
|
||||
180
docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md
Normal file
180
docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md
Normal file
@ -0,0 +1,180 @@
|
||||
# AI 답변 감성 판정 기준표 v0.3
|
||||
|
||||
작성 2026-09-08 · 개정 2026-09-09 · 상태: 1차 보정 반영, 2차 보정 대기 · 적용 스크립트 `scripts/sentiment_qb.py`
|
||||
|
||||
v0.1 대비 바뀐 것은 둘이다.
|
||||
|
||||
1. **어조 규칙 R1~R5** (§2). 1차 보정에서 haewon 라벨 29건 대비 일치율이 75%로 기준선 80%에 미달해, 불일치 7건에서 뽑았다.
|
||||
2. **결정론적 언급 탐지기** (§2-1). 상호가 URL 안에만 있거나 "리뷰"의 "뷰"를 병원 언급으로 세던 오탐을 없앴다.
|
||||
|
||||
R4-a와 R6은 시험 투입했다가 되돌렸다. 이유는 §5에 있다.
|
||||
|
||||
## 1. 무엇을 재는가
|
||||
|
||||
질문 뱅크(120문항)를 답변엔진에 물어 받은 답변 안에서 **대상 병원이 어떤 어조로 언급되는지**를 판정한다. 브랜드 범용 도구(FrostAI 등)의 "감성 점수"에 대응하는 지표지만, 병원은 부정 신호의 대부분이 규제·사건 보도이므로 그 항목을 따로 센다.
|
||||
|
||||
측정 원칙은 질문 뱅크와 같다. 소비자 UI 자동 조회는 하지 않고 공식 API 답변만 쓴다. 답변 원문은 저장하되 리포트에는 근거 문장 한 줄만 인용한다.
|
||||
|
||||
## 2-1. 언급 탐지 (v0.3 추가)
|
||||
|
||||
`sentiment` 를 매기기 전에 대상 병원이 답변에 실제로 나오는지를 **정규식으로 먼저 판정한다**(`mention_kind()`). 판정기가 언급이라고 해도 탐지기가 없다고 하면 `not_mentioned` 로 덮어쓴다. 언급 수가 모든 비율의 분모라서, 여기가 틀리면 점수 전체가 틀린다.
|
||||
|
||||
| 값 | 조건 |
|
||||
|---|---|
|
||||
| strong | 정식 상호가 답변 본문에 있다. 한글은 음절 사이 공백을 허용한다("뷰 성형외과"도 잡는다). 영문 정식 명칭·도메인도 포함한다 |
|
||||
| weak | 정식 상호 없이 영문 약칭만 있다(예: 병원 목록 안의 "View") |
|
||||
| none | 위 어느 것도 없다 |
|
||||
|
||||
두 가지를 반드시 처리한다.
|
||||
|
||||
- **URL을 먼저 지운다.** `news.nate.com/view/...`, `clinic/view.do`, `?bmode=view` 의 "view"를 병원 언급으로 세면 안 된다. 1차 판정에서 C-13·C-14·E-06 세 건이 이 오탐이었다.
|
||||
- **다른 낱말의 일부인 약칭을 뺀다.** "리뷰"의 "뷰"가 대표적이다. G-04(openai)는 "리뷰 사이트에서도"만 있고 병원 언급이 없는데 언급으로 세고 있었다.
|
||||
|
||||
이 수정으로 언급 수가 84에서 83으로 바뀌었다.
|
||||
|
||||
## 2. 판정 단위와 값
|
||||
|
||||
답변 1건 = 판정 1건. 판정기는 아래 JSON을 낸다.
|
||||
|
||||
| 필드 | 값 | 설명 |
|
||||
|---|---|---|
|
||||
| mentioned | true / false | 대상 병원(별칭 포함)이 답변에 나오는가. 추출기의 `view_mentioned`와 교차 확인 |
|
||||
| sentiment | positive / neutral / negative / mixed / not_mentioned | 병원에 대한 어조. 언급이 없으면 not_mentioned |
|
||||
| aspects | 배열 | 아래 §3 측면 태그 중 해당하는 것 |
|
||||
| negative_type | none / regulatory / safety / service / price / reputation / other | 부정이면 어떤 종류인가 |
|
||||
| evidence | 문장 1개 | 판정 근거가 된 답변 원문 문장 (요약 금지, 그대로 인용) |
|
||||
| recommendation | recommended / listed / cautioned / not_applicable | 병원을 추천했는가, 목록에 나열만 했는가, 주의를 붙였는가 |
|
||||
| confidence | 0~1 | 판정기 자신감 |
|
||||
|
||||
### 어조 판정 규칙
|
||||
|
||||
- **positive**: 병원의 강점을 서술하거나 추천 근거로 든다. 예: "안전 시스템이 갖춰진 병원", "가슴성형 전문으로 평가받는다".
|
||||
- **neutral**: 사실 나열, 목록 포함, 위치·진료과목 안내처럼 평가 없는 언급.
|
||||
- **negative**: 부정 사실을 서술하거나 주의를 권한다. 예: "공정위 시정명령을 받았다", "후기 광고 논란이 있었다", "부작용 사례가 보고됐다".
|
||||
- **mixed**: 긍정과 부정이 한 답변에 함께 있다. 점수 계산에서는 negative 쪽으로 센다(보수적).
|
||||
- 다른 병원에 대한 평가는 세지 않는다. 대상 병원 문장만 본다.
|
||||
- "확인이 필요하다", "직접 상담을 권한다" 같은 일반 주의 문구는 negative가 아니라 neutral이다. 병원 고유의 문제를 지목할 때만 negative다.
|
||||
|
||||
### 우선순위 규칙 (v0.2 추가, 1차 보정에서 도출)
|
||||
|
||||
아래 규칙은 위 어조 정의보다 우선한다. 괄호 안은 근거가 된 보정 사례다.
|
||||
|
||||
**R1. 결론 단락이 개별 항목보다 우선한다.** 답변에 종합 평가·결론 단락이 있으면 그 단락의 어조로 판정한다. 본문에 나열된 개별 항목의 어조로 판정하지 않는다.
|
||||
- A-01(perplexity): 본문에 강남언니 평점 9.4~9.5가 있으나 결론이 "후기 기반 평판은 좋은 편이지만 추가 확인이 필요"라는 확인 권고다. → **neutral** (mixed 아님)
|
||||
- D4-01(openai): 본문에 "모두에게 동일한 설명을 하는 듯한 인상"이라는 후기 지적 한 줄이 있으나 종합 평이 "안전성과 전문성을 갖춘 병원으로 평가받고 있습니다"다. → **positive** (mixed 아님)
|
||||
- D2-01(perplexity): 결론이 "수술 결과의 일관된 객관성까지 판단하긴 어렵고, 커뮤니티에는 부정적 의견도 일부 보입니다"다. → **negative** (mixed 아님)
|
||||
|
||||
**R2. mixed는 긍정과 부정이 대등하게 병존할 때만 쓴다.** 부정 성분이 문장 하나의 부수 언급이면 mixed로 세지 않는다. 긍정과 부정이 각각 독립된 근거로 서술되고 결론이 어느 쪽으로도 기울지 않을 때만 mixed다.
|
||||
|
||||
**R3. "후기 수가 많다", "인지도가 있다"는 규모 서술이며 긍정 평가가 아니다.** 이 서술만 있고 병원 고유의 문제 지목이 함께 있으면 mixed가 아니라 negative다. (D2-01)
|
||||
|
||||
**R4. 평가 축이 있는 목록에 포함되면 positive다.** 질문이 조건이나 우수성을 묻고(평점이 높은 곳, 마취과 전문의가 상주하는 곳, 잘하는 곳) 그 목록에 대상 병원이 들어가면 positive다. 조건 없는 단순 나열(위치·진료과목 안내, 지역 병원 열거)만 neutral이다.
|
||||
- C-12(openai): "강남언니에서 평점 높은 성형외과" 목록에 뷰성형외과가 포함됐다. → **positive**
|
||||
- C-08(openai): "마취과 전문의가 상주하는 강남 성형외과" 목록에 포함되고 "마취과 전문의 365일 상주", "3D-CT, 체계적 사후 관리"가 함께 서술됐다. → **positive**
|
||||
|
||||
**R5. 강점·역량 서술은 출처가 병원 홍보라도 positive로 센다.** 답변이 "환자의 안전과 만족을 최우선", "최신 기술과 장비"처럼 강점을 서술하면, 그 서술이 병원 광고를 인용한 것이라도 답변의 어조는 positive다.
|
||||
- A-02(openai): "환자의 안전과 만족을 최우선으로 하며, 최신 기술과 장비를 사용하여 서비스를 제공한다고 광고합니다" → **positive**
|
||||
|
||||
**R6. (경계 사례, 미확정) 사실 조회 질문의 항목 나열은 neutral이다.** 질문이 "무슨 수술을 주로 하나", "어디에 있나"처럼 사실을 묻고 답변이 진료 항목 카탈로그이면, 개별 항목에 붙은 수식어("풍부한 임상 경험")는 긍정으로 세지 않는다.
|
||||
- A-03(openai): 진료 분야 나열이고 항목마다 홍보성 수식이 붙어 있다. haewon은 **neutral**, 판정기는 positive였다.
|
||||
- **주의**: R6과 R5의 경계가 규칙으로 깔끔히 갈리지 않는다. A-02(positive)와 A-03(neutral) 모두 답변에 병원 홍보 문구가 인용돼 있는데 라벨이 갈렸다. 2차 보정에서 haewon이 이 경계를 확정해야 한다(§6).
|
||||
|
||||
## 3. 측면 태그
|
||||
|
||||
| 태그 | 포함 |
|
||||
|---|---|
|
||||
| doctors | 의료진, 전문의, 경력, 원장 |
|
||||
| safety | 안전 시스템, 마취, 부작용, 사고, 회복 관리 |
|
||||
| price | 가격, 비용, 견적, 할인 |
|
||||
| reviews | 후기, 평판, 만족도, 리뷰 수 |
|
||||
| regulatory | 공정위, 시정명령, 의료법 위반, 광고 제재, 소송 |
|
||||
| procedure | 시술·수술 종류, 장비, 기술, 전문 분야 |
|
||||
| access | 위치, 예약, 외국어 응대, 편의 |
|
||||
| media | 방송 출연, 언론 보도, 수상 |
|
||||
|
||||
## 4. 점수
|
||||
|
||||
- **감성 점수(0~100)** = (positive × 100 + neutral × 50 + mixed × 25 + negative × 0) ÷ 언급된 답변 수. Frost의 감성 점수와 같은 척도로 비교할 수 있게 했다.
|
||||
- **부정 비율** = negative + mixed ÷ 언급된 답변 수.
|
||||
- **규제 언급률** = negative_type이 regulatory인 답변 ÷ 언급된 답변 수. 병원 전용 보조 지표.
|
||||
- **추천율** = recommendation이 recommended인 답변 ÷ 언급된 답변 수.
|
||||
- 엔진별로 따로 내고, 브랜드 질문(A·B)과 논브랜드 질문을 분리한다. 논브랜드에서 언급되며 긍정인 것이 가장 가치 있는 신호다.
|
||||
|
||||
## 5. 보정 절차 (공신력)
|
||||
|
||||
1. 판정 결과에서 30건을 무작위로 뽑아 `*_calibration.xlsx`에 담는다. 판정기 값은 숨기고 haewon이 sentiment와 negative_type을 직접 적는다.
|
||||
2. 일치율을 계산한다. sentiment 일치 80% 이상이면 기준표를 확정한다. 미만이면 불일치 사례를 §2 규칙에 예시로 추가하고 다시 돌린다.
|
||||
3. 확정 후에는 같은 기준표·같은 모델로만 월간 실행한다. 모델을 바꾸면 보정을 다시 한다.
|
||||
|
||||
### 1차 보정 결과 (2026-09-09)
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 대상 | 뷰성형외과 240건 판정분에서 무작위 30건 |
|
||||
| haewon 라벨 | 29건 (G-08 openai 1건은 판단 보류로 공란) |
|
||||
| sentiment 일치 | **22/29 = 75%** (기준선 80% 미달) |
|
||||
| negative_type 일치 | **측정 불가** (haewon G열 미기입, negative 라벨 2건 포함) |
|
||||
|
||||
불일치 7건의 갈래는 셋이다. 판정기가 mixed를 남발한 것 3건(A-01·D4-01·D2-01), 평가 축이 있는 목록 포함과 강점 서술을 중립으로 낮춘 것 3건(A-02·C-12·C-08), 항목 나열의 수식어를 긍정으로 올린 것 1건(A-03).
|
||||
|
||||
### 이 보정의 한계
|
||||
|
||||
규칙 R1~R6은 위 30건의 불일치에서 뽑았다. 같은 30건으로 다시 재면 일치율이 오르는 것은 당연하므로, 재판정 후의 일치율을 기준표 확정 근거로 쓰지 않는다. **확정하려면 새로 뽑은 30건으로 2차 보정을 해야 한다.** 1차 보정에 쓴 30건은 2차 표본에서 제외한다.
|
||||
|
||||
negative_type은 라벨이 없어 이번에 재지 못했다. 2차 보정에서는 negative·mixed 라벨에 종류를 반드시 함께 적는다.
|
||||
|
||||
### 재판정 결과 (2026-09-09, R1~R5 적용)
|
||||
|
||||
240건을 gpt-4.1로 다시 판정했다. 실제 비용 $0.79(입력 324,627 · 출력 17,723 토큰).
|
||||
|
||||
| sentiment | v0.1 | v0.2 | 증감 |
|
||||
|---|---|---|---|
|
||||
| positive | 14 | 20 | +6 |
|
||||
| neutral | 57 | 58 | +1 |
|
||||
| mixed | 10 | **0** | -10 |
|
||||
| negative | 3 | 6 | +3 |
|
||||
| not_mentioned | 156 | 156 | 0 |
|
||||
|
||||
**감성 점수 53.6 → 58.3** (언급 84건, 변동 없음). 리포트 `Viewclinic_QB_Sentiment.md` 를 v0.2로 다시 만들었다.
|
||||
|
||||
haewon 라벨 29건 대비 일치율은 75% → 93%(27/29)다. 앞서 적은 대로 **이 값은 확정 근거가 아니다.** 규칙을 이 표본의 불일치에서 뽑았기 때문이다.
|
||||
|
||||
### R4-a·R6 시험 투입과 철회 (같은 날)
|
||||
|
||||
R4 과적용(C-12 perplexity)과 A-03을 고치려고 규칙 두 개를 더 넣고 240건을 다시 판정했다. 결과는 나빠졌다.
|
||||
|
||||
| 회차 | 규칙 | haewon 라벨 대비 (참고값) |
|
||||
|---|---|---|
|
||||
| v0.1 | 기본 정의만 | 75% (22/29) |
|
||||
| v0.2 | R1~R5 | 93% (27/29) |
|
||||
| v0.3 시험 | R1~R5 + R4-a + R6 | **89% (26/29)** |
|
||||
|
||||
- **R4-a**(목록에 있어도 결론에서 대상 병원이 빠지면 positive 아님)는 C-12 perplexity를 고쳤으나 **C-12 openai를 반대로 틀었다.**
|
||||
- **R6**(결론 없는 카탈로그는 neutral)은 목표한 A-03을 고치지 못했고 **A-02 perplexity를 새로 틀었다.**
|
||||
|
||||
규칙을 하나 더할 때마다 하나 고치고 하나 깨진다. n=29에서 75%·93%·89%는 표준오차(약 ±6%p) 안이다. **규칙은 R1~R5에서 멈춘다.** R4-a와 R6은 철회했고, 남은 불일치 2건(C-12 perplexity, A-03)은 사람 판단의 경계로 보고 2차 보정에서 새 표본으로 다시 본다.
|
||||
|
||||
시험분은 폐기했고, 최종 데이터는 v0.2 판정(R1~R5 프롬프트)에 §2-1 탐지기를 다시 적용해 만들었다. 탐지기는 결정론적 후처리라 API 재호출이 필요 없다. 이 회차의 총 API 비용은 $1.70이다(v0.2 $0.79 + R4-a·R6 시험 $0.91).
|
||||
|
||||
### 최종 수치 (v0.3)
|
||||
|
||||
| 항목 | v0.1 | v0.3 |
|
||||
|---|---|---|
|
||||
| 언급 | 84 | **83** |
|
||||
| 감성 점수 | 53.6 | **58.4** |
|
||||
| positive / neutral / mixed / negative | 14 / 57 / 10 / 3 | 20 / 57 / 0 / 6 |
|
||||
| 약한 언급 | 3(오탐 포함) | **1**(G-08 openai) |
|
||||
|
||||
약한 언급 1건을 빼면 언급 82건 · 감성 점수 57.9다. 리포트에 두 값을 함께 적었다.
|
||||
|
||||
**mixed가 240건 전체에서 0건이다.** haewon 라벨에도 mixed가 없었으므로 방향은 맞지만, R2가 과하게 좁혔을 수 있다. 2차 보정 표본에 mixed 후보가 하나도 없으면 판정값 자체를 없앨지 정한다.
|
||||
|
||||
## 6. haewon이 정할 것
|
||||
|
||||
- **부정의 범위**: "후기 광고 논란"처럼 사실이지만 병원에 불리한 언급을 negative로 볼지, 규제 사실 서술은 neutral로 볼지. 초안은 negative다.
|
||||
- **mixed 처리**: 점수에서 25점으로 셀지, 제외할지. 초안은 25점. 1차 보정에서 haewon 라벨에 mixed가 한 건도 없었다(29건 중 0건). R2로 mixed 조건을 좁혔으므로 재판정 후 mixed 건수를 보고 25점 유지 여부를 정한다.
|
||||
- **다른 병원 추천 문맥**: 대상 병원이 언급되지 않고 경쟁 병원만 추천된 답변을 별도 지표(경쟁 노출)로 셀지.
|
||||
- **R5와 R6의 경계** (v0.2 추가): 병원 홍보 문구가 인용된 답변에서 A-02는 positive, A-03은 neutral로 갈렸다. 질문 성격으로 가를지(평가 요청 대 사실 조회), 답변에 종합 서술이 있는지로 가를지 확정이 필요하다.
|
||||
- **약한 언급 처리**: §2-1 탐지기로 오탐을 걷어내니 진짜 약한 언급은 **1건**(G-08 openai)뿐이다. "View", "Wooa", "Wish PS" 가 병원 목록으로 나열된 영어권 커뮤니티 인용이다. 현재는 **언급으로 세고 표시**해 두었고, 리포트에 제외 점수(57.9)를 함께 적는다. 1건이라 어느 쪽으로 정해도 점수는 0.5점 움직인다. haewon 확정 필요.
|
||||
- **mixed 값 유지 여부**: R2 적용 후 240건 중 mixed가 0건이다. 판정값에서 mixed를 빼고 positive/neutral/negative 셋으로 갈지 정한다.
|
||||
- **R4-a·R6 재시도 여부**: 이번에 철회했다(§5). 2차 보정 표본에서 같은 유형이 반복되면 그때 다시 본다.
|
||||
57
docs/INFINITH_GA4_AI_Traffic_Integration.md
Normal file
57
docs/INFINITH_GA4_AI_Traffic_Integration.md
Normal file
@ -0,0 +1,57 @@
|
||||
# GA4 AI 검색 유입 연동 안내 v0.1
|
||||
|
||||
작성 2026-09-08 · 스크립트 `scripts/ga4_ai_traffic.py` · 분류 기준 `data/ai_channels.json` · 태그 `supporters/src/layouts/Base.astro`
|
||||
|
||||
## 1. 구조
|
||||
|
||||
| 대상 | 누가 계측하는가 | 필요한 것 |
|
||||
|---|---|---|
|
||||
| 서포터즈 사이트 | INFINITH가 직접. `site.json`의 `ga4MeasurementId`(G-XXXX)를 넣으면 빌드 시 태그가 들어간다 | INFINITH GA4 속성 1개(병원별 데이터 스트림) |
|
||||
| 병원 공식 사이트 | 병원이 이미 쓰는 GA4 속성에 INFINITH 서비스 계정을 뷰어로 추가 | 병원 GA4 관리자 권한자 1명, 5분 |
|
||||
|
||||
코드 설치는 없다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고 그건 병원 웹 담당자 일이다.
|
||||
|
||||
## 2. 연동 절차 (병원 사이트)
|
||||
|
||||
1. INFINITH: Google Cloud 프로젝트에서 Google Analytics Data API 사용 설정, 서비스 계정 생성, 키(JSON) 발급. `.env`에 `GA4_SERVICE_ACCOUNT_JSON=<경로>`. 계정은 haewon이 지정한다(이전 GCP 재인증 이슈 있음).
|
||||
2. 병원: GA4 관리 → 속성 액세스 관리 → 사용자 추가 → 서비스 계정 이메일, 역할 **뷰어**.
|
||||
3. 병원: 속성 ID(숫자 9자리) 전달.
|
||||
4. INFINITH: `python3 scripts/ga4_ai_traffic.py --property <ID> --days 90 --clinic <id> --out docs/reports/<id>/05_ai_traffic`.
|
||||
5. 월간 실행 → 리포트 카드와 병원 확인 패널에 붙인다(다음 단계).
|
||||
|
||||
API 없이 받을 때: 병원이 GA4 탐색에서 날짜·세션 소스·세션 매체·방문 페이지·세션수·주요 이벤트를 CSV로 내보내면 `--from-csv`로 같은 리포트를 만든다.
|
||||
|
||||
## 3. AI 채널 분류 규칙
|
||||
|
||||
`data/ai_channels.json` 한 곳에서 관리한다. 세션 출처 호스트(경계 일치)와 `utm_source`로 묶는다.
|
||||
|
||||
| 채널 | 측정 | 이유 |
|
||||
|---|---|---|
|
||||
| ChatGPT | 가능 | 링크에 utm_source=chatgpt.com |
|
||||
| Gemini, Claude | 가능 | 고유 리퍼러 |
|
||||
| Perplexity, Copilot, 기타 AI | 일부만 | 리퍼러를 자주 비움 → direct로 섞임 |
|
||||
| Google AI Overview · AI Mode | 불가 | google / organic과 구분되지 않음 |
|
||||
| 네이버 AI 브리핑 | 불가 | search.naver.com과 구분되지 않음 |
|
||||
|
||||
리포트마다 "측정 가능 범위" 절이 자동으로 들어간다. 불가 채널에는 숫자를 만들지 않는다.
|
||||
|
||||
## 4. 키 이벤트
|
||||
|
||||
서포터즈 사이트 태그가 보내는 이벤트. GA4에서 **주요 이벤트로 표시**해야 전환으로 집계된다.
|
||||
|
||||
| 이벤트 | 조건 |
|
||||
|---|---|
|
||||
| click_tel | `tel:` 링크 |
|
||||
| click_reservation | 헤더 상담 예약 버튼(`a.cta`) 또는 `data-ga="reservation"` |
|
||||
| click_clinic_site | `data-ga="clinic-site"` (병원 홈페이지 이동) |
|
||||
| click_map | `data-ga="map"` (지도·길찾기) |
|
||||
|
||||
## 5. 개인정보
|
||||
|
||||
GA4 태그는 쿠키를 쓴다. `anonymize_ip`는 켜 두었다. 쿠키 고지 배너를 넣을지, 쿠키 없는 대안(서버 로그, Vercel Web Analytics)으로 갈지는 haewon 결정 대기. 결정 전까지 서포터즈 미리보기(noindex)에는 측정 ID를 넣지 않는다.
|
||||
|
||||
## 6. 남은 일
|
||||
|
||||
- 리포트 카드(`build_discovery_report.ts`)에 AI 유입 요약 카드와 실측 언급률 동일 시간축 그래프.
|
||||
- 병원 확인 패널(`/supporters/:id`)에 "GA4 뷰어 추가" 안내와 속성 ID 입력란.
|
||||
- 월간 실행을 워커 또는 cron에.
|
||||
104
docs/NEXT_SESSION_2026-09-09.md
Normal file
104
docs/NEXT_SESSION_2026-09-09.md
Normal file
@ -0,0 +1,104 @@
|
||||
# 다음 세션 핸드오버 · 2026-09-09
|
||||
|
||||
**2026-09-09 진행분은 §2 각 항목의 상태 표시를 본다.** §2-1~4·6 완료, 남은 것은 §2-5(GA4 실연결)·§2-7 과 2차 보정이다.
|
||||
|
||||
작성 2026-09-08 · 작성자 Claude (haewon 세션 핸드오버). 자동 빌드 전체 상태는 `docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`(§1·§2·§3 갱신됨)를 본다. 이 문서는 09-08에 새로 시작한 일(감성 분석·GA4·가격)과 내일 첫 할 일만 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 세션 시작 시 붙여 넣을 프롬프트
|
||||
|
||||
```
|
||||
INFINITH 저장소. 자동 빌드 코드는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 감성 분석 산출물은 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH 에 있다.
|
||||
먼저 docs/NEXT_SESSION_2026-09-09.md 를 읽고 §2 순서대로 진행한다. 자동 빌드 잔여 과제는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md §2.
|
||||
원칙: 근거 없는 사실은 생성하지 않는다. 사람 게이트는 자동화하지 않는다. 잴 수 없는 것은 숫자를 만들지 않고 '측정 불가'로 적는다. 존댓말, 엠대시 금지, 은유 금지. 결정이 필요한 것은 §3을 보고 haewon 에게 묻는다.
|
||||
```
|
||||
|
||||
## 1. 09-08에 끝낸 것
|
||||
|
||||
| 일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 뉴스룸 워커 단계 + 뉴스 게이트 규칙 | Productize, 커밋 484601b·4e73798, 푸시·infinith-demo 배포 완료 | 뷰 회귀 1,105건(수작업 1,096), 원진 1,049건 반영·재배포 |
|
||||
| launchd 폴러 설치 | `~/Library/LaunchAgents/com.infinith.supporters-poll.plist`, 5분 간격, 로그 `~/supporters-builds/poll.log` | 큐가 비어 있어 실제 신청 1건 완주는 아직 못 봄 |
|
||||
| FrostAI 비교 + 가격 제안 v0.1 | 메인 체크아웃 `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html` | haewon 검토 대기 |
|
||||
| AI 답변 감성 판정 | 메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `scripts/sentiment_qb.py`, 결과 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md` | 뷰 240건 판정 완료(언급 84, 감성 53.6, 부정 15.5%, 규제 언급률 6.0%). **보정 라벨링 진행 중** |
|
||||
| GA4 AI 유입 연동 뼈대 | Productize `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `supporters/src/layouts/Base.astro` 태그, `docs/INFINITH_GA4_AI_Traffic_Integration.md` | 합성 데이터로 검증. 실제 속성 연결은 GCP 계정 결정 후 |
|
||||
| 리포트 "측정 가능 범위" 절 | Productize `scripts/build_discovery_report.ts` Integrity 절, 원진 리포트 v2.1 HTML·PDF | 감성·GA4 리포트에도 같은 절 자동 포함 |
|
||||
|
||||
**미커밋 상태** (haewon 지시 없이 커밋하지 않았다)
|
||||
- Productize: `data/ai_channels.json`, `scripts/ga4_ai_traffic.py`, `scripts/build_discovery_report.ts`, `supporters/src/layouts/Base.astro`, 템플릿 site.json 2곳(`ga4MeasurementId`), `docs/INFINITH_GA4_AI_Traffic_Integration.md`, `docs/reports/wonjin/01_readiness_report/*v2.1*`, 이 문서.
|
||||
- 메인 체크아웃: `scripts/sentiment_qb.py`, `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment*.{md,xlsx}`, `scripts/out/qb_sentiment_*.jsonl`, `docs/INFINITH_vs_FrostAI_*.html`. 메인 체크아웃에는 09-08 이전부터 있던 무관한 수정(plan 컴포넌트 등)도 섞여 있으니 감성 파일만 골라 커밋한다.
|
||||
|
||||
## 2. 내일 순서
|
||||
|
||||
> 진행 상태 (2026-09-09 갱신): 1 완료 · 2 완료 · 3 완료 · 4 완료 · 5 미착수(§3-2 대기) · 6 완료(푸시 전) · 7 미착수
|
||||
|
||||
|
||||
1. **보정 라벨링 마무리** (haewon). 파일 `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`, 시트 `라벨링` 30줄. D열 답변 전문을 읽고 F열(positive/neutral/negative/mixed/not_mentioned), 부정·혼합이면 G열 종류, 애매하면 H열 메모. 판정기 값은 숨김 시트에 있으니 보지 않는다.
|
||||
- 알려진 사례: **G-08(openai)** 은 영문 약칭 "View" 하나만 있음 → not_mentioned 로 볼지 haewon 판단. **G-04(openai)** 는 판정기 오판(뷰 언급 없음). **C-01(openai)** 은 "View Plastic Surgery" 영문 정식 명칭.
|
||||
2. **일치율 계산**: `python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx`. sentiment 일치 80% 이상이면 기준표 v0.1 확정. 미만이면 불일치 줄을 기준표 §2 규칙 예시로 추가하고 240건 재판정(`--out` 파일을 지우고 다시 실행, 약 $1).
|
||||
3. **기준표 확정 사항 반영**: 약한 언급(한글 상호 없이 영문 약칭만, 3건) 포함 여부, mixed 25점 유지 여부를 기준표 §6에 적고 리포트 재생성. 감성 결과를 `docs/reports/viewclinic/03_question_bank/` 엑셀·POC 부록에 넣을지 결정.
|
||||
4. **감성 판정을 월간 실측에 붙이기**: 질문 뱅크 러너 결과가 나오면 자동으로 `sentiment_qb.py` 를 이어 돌리는 한 줄(워커 별도 단계 또는 cron). 결과를 `supporter_builds.report.sentiment` 에 저장해 리포트 카드에 표시.
|
||||
5. **GA4 실연결**: §3-2 계정이 정해지면 서비스 계정 키 발급 → `.env` `GA4_SERVICE_ACCOUNT_JSON` → 서포터즈 사이트용 GA4 속성 생성 → 원진 `site.json` 에 `ga4MeasurementId` → 재빌드. 쿠키 배너 결정(§3-3) 전에는 미리보기에 ID를 넣지 않는다.
|
||||
6. **커밋**: 위 미커밋 목록을 두 체크아웃에서 각각 커밋(감성 파일은 메인 체크아웃에서 골라서). 푸시 후 `vercel --prod` 는 프론트 변경이 있을 때만.
|
||||
7. 시간이 남으면 v3 §2의 감사 재실행 단계(Phase 9), 원진 수동 실측(강남언니·바비톡·GBP·플레이스 NAP).
|
||||
8. (2026-09-09 추가) 질문 뱅크 러너 병원별 매개변수화. `run_question_bank_openai.py` 가 `question_bank_viewclinic.json` 과 출력 경로를 하드코딩해 뷰성형외과 전용이다. 월간 체인을 원진 등 다른 병원에 쓰려면 여기가 먼저다.
|
||||
|
||||
### 2026-09-09 결과 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 보정 라벨링 | haewon 29/30 완료. G-08(openai)만 공란 |
|
||||
| 1차 일치율 | sentiment 22/29 = **75%**, 기준선 80% 미달. negative_type 은 G열 미기입으로 **측정 불가** |
|
||||
| 기준표 | v0.1 → **v0.3**(메인 체크아웃 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md`). 규칙 R1~R5 추가 |
|
||||
| 철회한 규칙 | R4-a·R6 시험 투입 후 철회. 일치율이 93%에서 89%로 떨어졌다. n=29 에서 과적합 |
|
||||
| 언급 탐지기 | 결정론적 `mention_kind()` 신설. URL 안의 "view", "리뷰"의 "뷰" 오탐 제거, 한글 상호 공백 허용. 언급 84 → 83, 약한 언급 3 → 1 |
|
||||
| 감성 점수 | 53.6 → **58.4** (약한 언급 1건 제외 시 57.9). mixed 10 → 0 |
|
||||
| 월간 체인 | `scripts/monthly_ai_measure.py` 신설(§2-4). 러너 → 감성 → 리포트 → `supporter_builds.report.sentiment` |
|
||||
| API 비용 | 총 $1.70 (v0.2 재판정 $0.79 + R4-a·R6 시험 $0.91). 최종본은 재호출 없이 후처리로 만들었다 |
|
||||
| 커밋 | 메인 체크아웃 `feature/ai-sentiment-rubric` 2eb3d5c · Productize 8776a15(GA4)·501d600(PRD v0.2) |
|
||||
|
||||
**아직 확정이 아니다.** 93%는 같은 표본으로 규칙을 고쳐 나온 값이다. 확정하려면 새 30건으로 2차 보정을 해야 하고, 1차 표본 30건은 제외한다. 리포트와 요약 JSON 에 `confirmed: false` 로 적어 두었다.
|
||||
|
||||
**메인 체크아웃이 브랜치에 있다.** `feature/ai-sentiment-rubric` 이며 main 이 아니다. main 으로 합치려면 그 체크아웃에서 `git checkout main && git merge feature/ai-sentiment-rubric` 을 한다. 두 체크아웃 모두 **푸시하지 않았다.**
|
||||
|
||||
## 3. haewon 결정 대기
|
||||
|
||||
1. 감성 기준표(2026-09-09 갱신): mixed 는 R2 적용 후 240건 중 0건이라 판정값에서 뺄지 정한다. 약한 언급은 오탐을 걷어내니 **1건**(G-08 openai)뿐이며 현재는 세고 표시해 두었다. 부정의 범위(규제 사실 서술을 negative 로)와 경쟁 노출 지표는 그대로 미결. 남은 불일치 2건(C-12 perplexity, A-03 openai)은 2차 보정에서 새 표본으로 본다.
|
||||
- 2차 보정을 언제 돌릴지도 결정 대상이다. 새 30건 추출 → haewon 라벨 → `--agreement`. 1차 표본 30건은 제외한다.
|
||||
2. GA4 서비스 계정을 만들 Google Cloud 계정(이전 재인증 이슈 계정 회피 여부).
|
||||
3. 서포터즈 사이트 쿠키 고지 배너 vs 쿠키 없는 대안(서버 로그·Vercel Web Analytics).
|
||||
4. 가격 제안 v0.1(문서 §5의 5개 항목: 무료 진단 범위, 최소 계약 3개월, 도메인 귀속, Frost 우위 3종 대응, 가격 공개 여부).
|
||||
5. 뉴스룸 "언론 언급" 종류 노출 여부(v3 §3-6).
|
||||
6. 자동 빌드 잔여 결정은 v3 §3.
|
||||
|
||||
## 4. 함정 (09-08 추가)
|
||||
|
||||
- 프레임워크 Python 3.13은 인증서 저장소가 비어 `CERTIFICATE_VERIFY_FAILED` 가 난다. 새 스크립트는 certifi 컨텍스트를 쓴다(`sentiment_qb.py` 참고).
|
||||
- 판정기의 `mentioned` 는 영문 약칭에 관대하다. 한글 상호 정규식으로 `weak_mention` 을 따로 표시하고 보정 때 결정한다.
|
||||
- `sentiment_qb.py --summary` 로 보정 엑셀을 다시 만들면 **haewon 라벨이 지워진다**. 라벨링 시작 후에는 `--calibration` 을 빼고 리포트만 다시 만든다. (2026-09-09: `--calibration` 없이 돌리면 §7 생성에서 죽던 버그를 고쳤다. 이제 그냥 빼고 돌리면 된다)
|
||||
- (2026-09-09) 감성 판정을 다시 돌릴 때는 `--out` 파일을 **옮기거나 지워야** 한다. 이어쓰기가 있어 그냥 실행하면 0건 처리하고 끝난다. `monthly_ai_measure.py --rejudge` 가 이 처리를 대신한다.
|
||||
- (2026-09-09) 병원 언급 탐지는 판정기(LLM)에 맡기지 않는다. URL 안의 상호와 "리뷰"의 "뷰" 를 언급으로 세면 모든 비율의 분모가 틀어진다. `mention_kind()` 를 먼저 통과시킨다.
|
||||
- 백그라운드 파이썬 로그는 버퍼링 때문에 끝날 때까지 비어 보인다. 진행은 JSONL 줄 수로 본다.
|
||||
- JS 정규식 `\W`·`\w`·`\b` 는 한글을 문자로 보지 않는다(v3 §5). 워드프레스 게시판 메타 날짜는 게시판 생성일이다.
|
||||
|
||||
## 5. 자주 쓰는 명령
|
||||
|
||||
```
|
||||
# 월간 실측 한 번에 (메인 체크아웃, 2026-09-09 신설)
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 # 러너부터 전부
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변으로 감성만
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --rejudge # 기준표 고친 뒤 재판정
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
|
||||
# 감성 판정 개별 (메인 체크아웃)
|
||||
python3 scripts/sentiment_qb.py --in scripts/out/qb_openai_results.jsonl --out scripts/out/qb_sentiment_openai.jsonl
|
||||
python3 scripts/sentiment_qb.py --summary scripts/out/qb_sentiment_openai.jsonl scripts/out/qb_sentiment_perplexity.jsonl --report docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md # 라벨링 중에는 --calibration 금지
|
||||
python3 scripts/sentiment_qb.py --agreement docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx
|
||||
# GA4 (Productize)
|
||||
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo
|
||||
python3 scripts/ga4_ai_traffic.py --property <ID> --days 90 --clinic <id> --out docs/reports/<id>/05_ai_traffic
|
||||
# 진단 리포트 (Productize)
|
||||
npx esbuild scripts/build_discovery_report.ts --bundle --platform=node --format=esm --outfile=/tmp/report.mjs && node /tmp/report.mjs --id <id> --out <html> --status ~/supporters-builds/<id>/status.json --site ~/supporters-builds/<id>/site --briefs ~/supporters-builds/<id>/briefs.json
|
||||
# 폴러 상태
|
||||
launchctl print gui/$(id -u)/com.infinith.supporters-poll | head -12 ; tail -20 ~/supporters-builds/poll.log
|
||||
```
|
||||
61
docs/NEXT_SESSION_2026-09-11_ORACLE.md
Normal file
61
docs/NEXT_SESSION_2026-09-11_ORACLE.md
Normal file
@ -0,0 +1,61 @@
|
||||
# 다음 세션 핸드오버 · 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자 텍스트 영역은 규칙을 의료 맥락으로 좁혀야 한다.
|
||||
162
docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md
Normal file
162
docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md
Normal file
@ -0,0 +1,162 @@
|
||||
# 세션 핸드오버 · 2026-09-12(토) · INFINITH AI Discovery Product 빌드
|
||||
|
||||
작성 2026-09-11 · 갱신 2026-09-12(토) · 작성자 Claude (haewon 세션) · 상태: §1 확인 완료, §4-1 완료
|
||||
하위 문서: 서포터즈 사이트 빌드 절차 `~/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·크롤러 로그)" 파이프라인이다.
|
||||
코드 위치: 프런트·스크립트·문서는 메인 체크아웃 ~/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.
|
||||
```
|
||||
|
||||
## 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): 글·영상·뉴스룸·방문 안내(지도·길찾기)·회복 일정 플래너 `/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 계정 대기 |
|
||||
| 7 계약·가격 | 상담 기반 계약 | 가격 문서 `docs/INFINITH_가격정책_사업부보고서_v1.1.docx`(2026-04, 이전 제품 INSIGHT/INTELLIGENCE 티어), AI Discovery 가격 제안은 `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html` | 메인 | **AI Discovery 전용 티어 미확정** |
|
||||
|
||||
2026-09-10~11 추가: 회복 일정 플래너 `/plan`(설계 v0.2 §13, 관광공사 데이터 합침), 주변 안내 `/stay`(장소·축제·동선 지도·날씨, 플래너와 별개 페이지로 복원), 오시는 길 지도·길찾기, 전역 언어 전환·영어 홈(뷰), 모바일 헤더, 발행(색인) 단계. 전부 템플릿·워커에 통합됨.
|
||||
|
||||
## 2. 코드·브랜치·배포 지도
|
||||
|
||||
| 무엇 | 어디 | 브랜치 | 배포 |
|
||||
|---|---|---|---|
|
||||
| 제품 프런트(랜딩·리포트·확인 패널) | 메인 `src/` | main(프로덕션), feature/ai-sentiment-rubric(감성·플래너 작업), feature/discovery-url-cta(리드 CTA) | `infinith-demo.vercel.app` (main 에서 `vercel --prod`, Gitea 자동 트리거 없음) |
|
||||
| 채점·질문뱅크·감성·리포트 스크립트 | 메인 `scripts/`, `docs/reports/` | feature/ai-sentiment-rubric | 로컬 실행 |
|
||||
| 워커·템플릿·게이트·GA4 뼈대·v2 기준표 | Productize `workers/`, `supporters/`, `templates/`, `scripts/fetch_medical_tourism.py` | feature/supporters-autobuild | 병원별 `supporters-<clinic>` (워커가 강제 link) |
|
||||
| 뷰 수작업 샘플 | 메인 `supporters/` | feature/ai-sentiment-rubric | `view-supporters-sample` 수동 |
|
||||
| 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 는 같다.
|
||||
|
||||
## 3. 지금 되는 것 / 안 되는 것 (한 줄씩)
|
||||
|
||||
되는 것
|
||||
- 병원 이름·URL 로 채널 발견·근거 수집·자동 채점·리포트 HTML/PDF·덱(뷰·원진·오라클·태하 사례).
|
||||
- 워커 한 번으로 서포터즈 사이트 초안 발행(글 6~8편, 영상, 뉴스룸, 이미지 규칙, 지도, 플래너, 관광공사 데이터). 게이트 41건 테스트.
|
||||
- 병원 확인 패널 입력 → 수동 재빌드.
|
||||
- 질문뱅크 120문항 2엔진 실측, 감성 판정, Before/After 페이지(`scripts/build_before_after.py`).
|
||||
|
||||
안 되는 것
|
||||
- 랜딩 신청 → 큐 → 빌드 → 미리보기 링크 회신이 사람 손 없이 이어지지 않는다(리드 테이블과 큐가 분리, CTA 브랜치 미머지).
|
||||
- 진단 결과가 코드 파일(`discovery_<id>.ts`)이라 병원마다 프런트 배포가 필요하다. 런타임 데이터(Supabase 또는 JSON 스토리지)로 옮겨야 "URL 입력 → 리포트 링크"가 된다.
|
||||
- 측정 지표가 리포트 카드에 자동으로 들어가지 않는다(스크립트 산출물을 사람이 옮긴다).
|
||||
- 워커가 Mac 에 묶여 있다. 규칙표 잠정값·큐레이션 장소·의료진 후보·이미지·채널 12건은 사람 확인 대기.
|
||||
- 색인 작업의 사람 몫(네이버 등록·GSC 소유자·도메인·법무)이 체크리스트 파일로만 나오고, 담당자에게 알림·요청하는 UI 가 아직 없다(§4-9).
|
||||
- AI Discovery 가격·계약 조건 미확정. 랜딩 FAQ 의 해지·환불 항목 삭제 상태.
|
||||
|
||||
## 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 태그와 영문 법적 고지 처리를 정본으로 이식했다.
|
||||
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` 있는 글만 낸다는 규칙 유지.
|
||||
5. **측정 카드**: 질문뱅크·감성·GA4·크롤러 히트 결과를 `discovery_results` 에 붙이고 리포트 Integrity 절과 Before/After 에 자동 표시. 언급률은 신뢰구간과 함께.
|
||||
6. **운영 이관**: Mac launchd 폴러 → Trigger.dev(또는 동등)로. 키·비용 로그(`status.json` costUsd)·실패 알림.
|
||||
7. **가격·계약**: AI Discovery 티어(진단만 / 진단+서포터즈 / +측정·관리) 확정, 랜딩 FAQ·계약서·해지 조건. FrostAI 비교 문서의 제안을 출발점으로.
|
||||
8. **다음 병원**: 톡스앤필·차앤박(피부과), 레지스트리 73곳 중 회귀 병원. 한 화면 비교 산출물은 만들지 않는다.
|
||||
9. **발행(색인) 단계**: "서포터즈 사이트 완료"의 정의는 배포가 아니라 색인 작업과 사람 체크리스트까지 끝난 상태다(haewon 결정 2026-09-11). 워커 `publish` 단계(`supporters/scripts/publish_site.mjs`)가 자동으로 하는 것: 라이브 확인(홈·robots·sitemap·noindex 헤더), IndexNow 로 사이트맵 URL 전부 알림(빙·네이버 공용, 색인 허용 빌드일 때만), Google Search Console 사이트맵 제출과 URL 색인 상태 조회(서비스 계정 `GOOGLE_SERVICE_ACCOUNT_JSON` 있을 때), 검색엔진 소유 확인 메타 태그(site.json `googleSiteVerification`·`naverSiteVerification`), IndexNow 키 파일(`public/<key>.txt`) 자동 생성, `--indexable` 로 noindex 헤더 제거·승인 글만 발행, `--domain` 으로 정식 도메인 연결. 사람에게 남기는 것(`~/supporters-builds/<clinic>/publish-checklist.md`, `status.humanTasks`): 네이버 서치어드바이저 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유자 추가(첫 1회), 정식 도메인 결정, 색인 허용 전환 전 법무 3건·게시 승인, 병원 자산에서 백링크·sameAs, 네이버 블로그 재작성 트랙. 하지 않는 것: Google Indexing API(채용·라이브 방송 전용), 색인 보장 약속. 제품에서는 이 체크리스트를 확인 패널(`ClinicInputsPanel`) 또는 운영자 알림으로 사람에게 요청하게 만드는 것이 다음 일이다.
|
||||
|
||||
## 5. haewon 결정 대기
|
||||
|
||||
1. ~~**Product 1.0 범위**~~ **결정 (2026-09-12, haewon): (b) 진단 + 서포터즈 사이트 초안.** 랜딩 CTA 는 여기까지 약속한다. 측정·관리는 내부 참고로만 쓰고 약속하지 않는다. 남은 일은 §4-2 리드→큐 연결과 §4-3 진단 런타임화다.
|
||||
2. **가격 티어**(§4-7)와 계약 최소 기간·해지 조건(2026-04 문서의 P0 두 항목이 그대로 미결).
|
||||
3. ~~**뷰 샘플 통일**~~ **결정 (2026-09-12, haewon): 메인 `supporters/` 가 정본.** 템플릿은 `npm run export:template` 로 여기서 생성한다. 영어 홈 `/en` 은 템플릿에 추가했고 영문 사실이 없는 병원에서도 문장이 성립하도록 조각 단위로 조립한다(§5-2 판단 2). `view-supporters-sample` 배포 자리를 워커로 옮길지는 아직 안 정했다.
|
||||
4. **법무**: 외국인환자 유치업 등록 여부(플래너·숙소 링크), 의료광고 사전심의 대상 여부, 지원 관계 고지 문구(sponsorNotice). 이 세 가지는 색인 허용 전환의 전제.
|
||||
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 .` 로 깨지는 문제를 함께 고쳤다. 게이트는 두 가지 모두 오류로 잡지 못한다.
|
||||
|
||||
### 여기서 배운 규칙
|
||||
|
||||
- **템플릿에 넣기 전 빈 데이터로 빌드해 본다.** 한 병원의 데이터로만 확인하면 하드코딩과 데이터 참조가 구별되지 않는다. 화면이 같아 보이기 때문이다.
|
||||
- **게이트가 통과해도 화면은 깨질 수 있다.** 게이트는 의료광고 문구와 금칙어를 보지, 빈 값이 만든 비문을 보지 않는다. 배포 전 로컬 확인이 필요한 이유다.
|
||||
|
||||
## 6. 함정 (제품 관점)
|
||||
|
||||
- **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만.
|
||||
- **페이지를 덮어쓰지 않는다**: 플래너 통합 때 `/stay`(주변 안내)를 `/plan` 으로 리다이렉트해 세 사이트에서 주변 안내가 사라졌다(09-11 17시 복원). 새 기능이 기존 페이지와 '같은 주제'로 보여도 haewon 확인 전에는 기존 페이지를 지우거나 리다이렉트하지 않는다.
|
||||
- **세 브랜치 분산**: 같은 파일(`supporters/`)이 두 브랜치에서 다르게 진화했다. 머지 전에 "템플릿이 정본"을 정하고 뷰 샘플을 옮긴다.
|
||||
- **진단 결과가 코드**: `discovery_<id>.ts` 커밋 방식은 병원 수가 늘면 배포 병목이다(§4-3).
|
||||
- **게이트는 고객 화면 전체를 본다**: 운영 어휘("실측"·"API 집계"·"임베드")가 데이터 값을 타고 화면에 나가면 빌드가 막힌다. 운영 기록은 데이터에만.
|
||||
- **원문 문장은 걸러야 한다**: 병원 페이지 원문 32문장 중 14건이 홍보문·정형 고지였다. 워커 planner 단계 필터(CARE/NOT_CARE) 없이는 "병원 안내 기준" 배지가 의료광고 위반이 된다.
|
||||
- **헤드리스 크롬 모바일 캡처**: 창 최소 폭 때문에 400px 직접 캡처가 틀린다. 1280 창 안 390px iframe 하네스로.
|
||||
- **lint**: 메인 체크아웃은 한때 tsc 가 없어 `npm run lint` 가 가짜 통과였다. 실제 0 에러인지 `npx tsc --noEmit` 로 본다.
|
||||
- **엠대시·타 서비스 이름**: 커밋 메시지와 문서에서 두 번 잡혔다. 산출 전 엠대시 검색(`grep -cP "\x{2014}"`)과 타 서비스명 격리 grep(전역 CLAUDE.md 의 목록).
|
||||
|
||||
## 7. 파일 참조
|
||||
|
||||
- 진단: `src/data/aeoGeoRubricV2.ts`, `src/lib/discoveryScoreV2.ts`, `src/pages/DiscoveryReportPage.tsx`, `scripts/audit_aeo_geo.py`, `docs/AEO_GEO_RUBRIC_v2.md`
|
||||
- 랜딩·리드: `src/pages/DiscoveryLandingPage.tsx`, `src/components/discovery/`, `supabase/migrations/20260831_discovery_leads.sql`
|
||||
- 확인 패널·큐: `src/components/discovery/ClinicInputsPanel.tsx`, `src/pages/SupportersBuildPage.tsx`, `supabase/migrations/20260907_supporter_builds*.sql`
|
||||
- 워커·템플릿: 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`
|
||||
- 가격·경쟁: `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 인지 확인한다.
|
||||
2. **"완료"를 산출물이 아니라 고객 결과로 정의하면 자동·사람 경계가 드러난다.** 서포터즈 사이트 완료 = 색인 작업 + 사람 체크리스트로 바꾸자 IndexNow·GSC 는 자동, 네이버 등록·소유자 추가는 사람으로 갈렸고 워커 단계가 그 경계를 코드로 가졌다. 진단(사람 판정 4항목), 병원 확인(재빌드 트리거)도 같은 방식으로 "완료 정의 → 자동 부분 → 사람 요청"을 명시한다.
|
||||
3. **병원 원문은 인용 전에 걸러야 한다.** 원문 32문장 중 14건이 홍보문·정형 고지·표 조각이었다. 원문을 데이터로 쓰는 모든 경로(recoveryNotes, OCR, 뉴스 제목)에 화이트리스트 필터와 "제외된 문장 목록 눈으로 보기"를 둔다. 필터 없는 "병원 안내 기준" 배지는 의료광고 위반이다.
|
||||
4. **게이트는 데이터 값도 본다.** 좌표 출처의 "실측"이 화면에 새어 나가 빌드가 막혔다. 데이터 필드마다 고객 노출 여부를 정하고 운영 기록(source·coordSource·note)은 렌더하지 않는다.
|
||||
5. **규칙표를 먼저 쓰면 코드·테스트가 따라온다.** 설계 v0.2 §13 의 status(clinic/provisional)·출처 필수 규칙이 그대로 procedures.json 스키마와 게이트 테스트가 됐다. 새 기능은 데이터 규칙표부터 문서화한다.
|
||||
6. **관광공사 데이터는 공급이지 수요가 아니다.** 선호는 화면에서 직접 묻고, 큐레이션이 1순위, 외부 데이터는 보강(2순위)이라는 우선순위를 점수로 코드화했다. 외부 API 를 붙일 때마다 같은 질문(공급인가 수요인가, 누가 1순위인가)을 먼저 한다.
|
||||
7. **디자인 규칙은 텍스트가 아니라 리듬이다.** "흰 카드만 쌓인 페이지"는 홈·영상 페이지의 섹션 리듬(다크 히어로+글래스 통계 → 틴트 → 다크 → 라이트 → 틴트 → 다크)을 안 따라서 완성도가 낮아 보였다. 새 페이지는 기존 페이지 두 개의 구성을 먼저 분석하고 같은 리듬으로 짠다. 웜 그라디언트는 CTA 카드 한 장에만.
|
||||
8. **전역 CSS 와 컴포넌트 클래스는 접두어로 분리한다.** `.kpi` 충돌로 그라디언트가 되살아났다. 컴포넌트 클래스는 `plan-`·`vm-` 처럼 접두어를 쓰고, 새 클래스는 전역 CSS 에 grep 한다.
|
||||
9. **모바일 검증은 하네스로.** 헤드리스 크롬 창 최소 폭 때문에 400px 캡처가 틀렸고, 실제 문제(헤더 겹침)는 아이프레임 하네스에서만 보였다. 8페이지×3사이트 그리드 캡처를 배포 전 기본 절차로 둔다.
|
||||
10. **언어 전환은 전역 하나.** 페이지별 토글은 사용자 관점에서 틀렸다. 영어 홈이 없는 사이트는 영어 입구(/en/plan)를 정해야 하고, 템플릿에 영어 홈을 넣을지는 §5-3 결정에 달렸다.
|
||||
11. **기존 페이지는 haewon 확인 전에 지우거나 리다이렉트하지 않는다.** 새 기능이 기존 페이지와 같은 주제로 보여도 병존이 기본이다(주변 안내 `/stay` 와 회복 일정 `/plan`). **2026-09-12 재발.** 브랜치 통합 머지가 `/stay` 를 떨어뜨렸다. 페이지를 지우려는 의도가 없어도 머지 기준을 잘못 잡으면 같은 결과가 난다. 통합 뒤 페이지 목록을 이전 배포와 대조한다.
|
||||
12. **제품 병목은 세 가지로 좁혀졌다.** 진단 결과가 코드 파일, 리드→큐 미연결, 브랜치 3개 분산. 이 셋을 풀어야 "URL 입력 → 리포트 링크 → 서포터즈 미리보기"가 사람 손 없이 이어진다.
|
||||
97
docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md
Normal file
97
docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md
Normal file
@ -0,0 +1,97 @@
|
||||
# 다음 세션 핸드오버 · 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`
|
||||
@ -32,17 +32,18 @@ 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) | 라이브. 폴러는 미설치 |
|
||||
| 워커·큐·승인 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이 켜져 있어야 돈다 |
|
||||
| 입력 반영 | `apply_inputs.mjs` (검토·승인 → frontmatter, 자유 답변 → clinicAnswers 근거 [A]) | dry-run 검증 |
|
||||
| 미리보기 | supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex) | 배포됨 |
|
||||
|
||||
## 2. 남은 일 (권장 순서)
|
||||
|
||||
1. **뉴스룸 단계**: `supporters/scripts/build_news.py`(홈페이지 언론 게시판 + 네이버 뉴스 API, 부정 기사 제외)를 워커 단계로. 원진은 게시판 위치부터 확인. 병원 무관 기사 본문 관련성 필터(v2 §8 미반영) 포함.
|
||||
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건)을 뉴스룸에 보일지.
|
||||
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. **워커 상시 실행**: Mac launchd 폴러 설치(`workers/supporters-build/launchd/`) 또는 Trigger.dev 이관(가입·키만 haewon). 랜딩 URL 신청 → 큐 → 자동 빌드가 이어지려면 이게 필요하다.
|
||||
4. ~~워커 상시 실행~~ **완료(09-08)**: Mac launchd 폴러 설치(haewon 지시). 해제 `launchctl bootout gui/$(id -u)/com.infinith.supporters-poll`. Trigger.dev 이관은 월 수십 건이 되면. 실제 랜딩 신청 1건으로 큐 → 빌드 완주를 아직 못 봤다(큐가 비어 있었음). 첫 신청이 오면 `poll.log`를 본다.
|
||||
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 차단 대비)이 붙어야 좁혀진다.
|
||||
@ -52,9 +53,10 @@ INFINITH 저장소, 브랜치 feature/supporters-autobuild, 워크트리 ~/orca/
|
||||
|
||||
1. 원진 "확인 필요" 채널 12건 중 병원 것(영문 채널 Wonjin Plastic Surgery Korea, WONJIN_OFFICIAL 등)을 사이트에 연결할지.
|
||||
2. 지원 관계 고지 문구(site.sponsorNotice)를 켤지와 문안. 켜면 글 첫 부분에 노출되고 게이트가 필수 검사로 바뀐다(v2 §12-2).
|
||||
3. 워커 상시 실행 위치(Mac 폴러 설치 vs Trigger.dev 가입).
|
||||
3. ~~워커 상시 실행 위치~~ 결정됨(09-08): 파일럿은 Mac 폴러. Trigger.dev는 정착 단계에.
|
||||
4. 원진에 미리보기·리포트 송부 시점과 병원 입력 담당자.
|
||||
5. 경영진 미결 그대로: 도메인(별도 vs 서브도메인), POC 범위·가격, 서포터즈 실존 개인 모집.
|
||||
6. 뉴스룸 "언론 언급" 종류(mention)를 보일지. 병원이 알린 기사·원장 기고만 남기면 원진은 1,049건 중 535건이 된다. 보이면 관계사(WJ코스메틱)·투자·대학 기사가 섞인다. 기본값은 지금처럼 보이되 뒤로 정렬.
|
||||
|
||||
## 4. 이번 세션들(09-07 세션 3 ~ 09-08)에서 확정한 것
|
||||
|
||||
@ -69,6 +71,9 @@ 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. 자주 쓰는 명령
|
||||
@ -80,6 +85,8 @@ 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
|
||||
|
||||
298
docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html
Normal file
298
docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.2.html
Normal file
@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>INFINITH Global Medical Hub PRD v0.2</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--navy:#0A1128;--navy800:#1A2B5E;--near:#F4F6FB;--accent:#6C5CE7;--g1:#4F1DA1;--g2:#021341;--slate7:#334155;--slate6:#475569;--slate5:#64748B;--line:#E2E8F0;
|
||||
--crit-bg:#FFF0F0;--crit-tx:#7C3A4B;--crit-bd:#F5D5DC;--crit-dot:#D4889A;--warn-bg:#FFF6ED;--warn-tx:#7C5C3A;--warn-bd:#F5E0C5;--warn-dot:#D4A872;--good-bg:#F3F0FF;--good-tx:#4A3A7C;--good-bd:#D5CDF5;--good-dot:#9B8AD4;--info-bg:#EFF0FF;--info-tx:#3A3F7C;--info-bd:#C5CBF5;--info-dot:#7A84D4}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,Inter,-apple-system,sans-serif;color:var(--navy);background:#fff;line-height:1.65;font-size:15px;word-break:keep-all}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
section{padding:96px 24px}.wrap{max-width:1160px;margin:0 auto}
|
||||
.dark{background:var(--navy);color:#fff;position:relative}.dark::before{content:"";position:absolute;inset:0;background:radial-gradient(ellipse at top right,rgba(108,92,231,.15),transparent 60%);pointer-events:none}.dark .wrap{position:relative}
|
||||
.light{background:#fff}.near{background:var(--near)}
|
||||
.eyebrow{font-family:Inter,sans-serif;font-size:12px;font-weight:600;letter-spacing:.24em;text-transform:uppercase;color:var(--accent)}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:56px;line-height:1.05;margin:14px 0 10px;letter-spacing:-.02em}h2{font-size:38px;line-height:1.15;margin:10px 0 14px}h3{font-size:18px;font-weight:700;margin:0 0 8px}
|
||||
.dark h2{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.sub{font-size:17px;color:var(--slate6);max-width:820px}.dark .sub{color:#DDD6FE}
|
||||
p{margin:0 0 10px}.muted{color:var(--slate5);font-size:13.5px}.dark .muted{color:#C4B5FD}
|
||||
.grid{display:grid;gap:18px}.g2{grid-template-columns:repeat(2,1fr)}.g3{grid-template-columns:repeat(3,1fr)}.g4{grid-template-columns:repeat(4,1fr)}
|
||||
@media(max-width:900px){.g2,.g3,.g4{grid-template-columns:1fr}}
|
||||
.card{background:#fff;border:1px solid #F1F5F9;border-radius:18px;padding:22px 24px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy)}.card:hover{box-shadow:4px 6px 16px rgba(0,0,0,.09)}
|
||||
.card p{color:var(--slate7);font-size:14.5px}
|
||||
.grad{background:linear-gradient(90deg,var(--g1),var(--g2));color:#fff;border:0}.grad p,.grad .muted{color:#DDD6FE}.grad h3{color:#fff}
|
||||
.cta{background:linear-gradient(to right,#fff3eb,#e4cfff,#f5f9ff);border:0}
|
||||
.badge{display:inline-block;font-family:Inter,sans-serif;font-size:11.5px;font-weight:600;border:1px solid;border-radius:999px;padding:3px 10px;line-height:1.3}
|
||||
.b-crit{background:var(--crit-bg);color:var(--crit-tx);border-color:var(--crit-bd)}.b-warn{background:var(--warn-bg);color:var(--warn-tx);border-color:var(--warn-bd)}.b-good{background:var(--good-bg);color:var(--good-tx);border-color:var(--good-bd)}.b-info{background:var(--info-bg);color:var(--info-tx);border-color:var(--info-bd)}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:14px;overflow:hidden;font-size:13.5px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}th{background:var(--navy);color:#fff;text-align:left;padding:11px 13px;font-size:12.5px;font-weight:600}td{padding:11px 13px;border-bottom:1px solid #EEF2F7;vertical-align:top;color:var(--slate7)}tr:last-child td{border-bottom:0}td b{color:var(--navy)}
|
||||
.dark table{color:var(--navy)}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:#fff;border-radius:16px;padding:18px 20px;box-shadow:3px 4px 12px rgba(0,0,0,.06)}.kpi b{display:block;font-family:'Playfair Display',serif;font-size:34px;color:var(--accent);line-height:1}.kpi span{font-size:12.5px;color:var(--slate5)}
|
||||
@media(max-width:900px){.kpi{grid-template-columns:repeat(2,1fr)}}
|
||||
.flow{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;counter-reset:s}.flow .st{background:#fff;border-radius:14px;padding:14px;box-shadow:3px 4px 12px rgba(0,0,0,.06);color:var(--navy);font-size:13px}.flow .st::before{counter-increment:s;content:counter(s);display:inline-block;font-family:'Playfair Display',serif;font-weight:700;color:var(--accent);font-size:20px;margin-bottom:4px}
|
||||
@media(max-width:900px){.flow{grid-template-columns:repeat(2,1fr)}}
|
||||
ul{margin:6px 0 0 18px;padding:0}li{margin:4px 0;color:var(--slate7)}.dark li{color:#E9E5FF}
|
||||
.pill{display:inline-block;background:#F1F5F9;color:var(--slate6);border-radius:999px;padding:2px 9px;font-size:11.5px;font-family:Inter,sans-serif;font-weight:500;margin:0 4px 4px 0}
|
||||
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent)}
|
||||
.toc a{color:var(--accent);text-decoration:none}.toc li{color:var(--slate6)}
|
||||
footer{padding:36px 24px;font-size:12.5px;color:var(--slate5);border-top:1px solid var(--line);text-align:center}
|
||||
@media print{@page{size:A4;margin:11mm}section{padding:26px 6px;page-break-inside:auto}.pb{page-break-before:always}h1{font-size:40px}h2{font-size:28px}.card,tr{page-break-inside:avoid}.dark,.grad,.badge,.kpi .k,.cta,th{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
||||
</style></head><body>
|
||||
|
||||
<!-- 0. Cover -->
|
||||
<section class="dark" style="padding-top:120px;padding-bottom:88px"><div class="wrap">
|
||||
<div class="eyebrow">INFINITH · Product Requirements Document · v0.2 Draft</div>
|
||||
<h1 class="serif">Global Medical Hub</h1>
|
||||
<div style="font-size:24px;font-weight:700;margin-bottom:6px">외국인 환자가 AI에 물었을 때 인용되는, AIO2O 운영 중립 의료관광 허브</div>
|
||||
<p class="sub">한국 성형외과·피부과의 시술·의료진·회복·비용 항목을 다국어 원문으로 공급하고, 상담·예약과 회복기 여정(숙박·식사·관광·미용)을 한 곳에서 연결하는 플랫폼. 병원이 아니라 AIO2O가 소유·운영한다. 지금 완성된 AI Discovery 진단과 서포터즈 자동 빌드 파이프라인이 공급 쪽 절반을 이미 자동화했다.</p>
|
||||
<div class="kpi" style="margin-top:34px">
|
||||
<div class="k"><b>3</b><span>제품 층 (정보·발견 / 상담·예약 / 여정 연결)</span></div>
|
||||
<div class="k"><b>2</b><span>검증된 병원 (뷰성형외과 18/18 · 원진 6/6, 사람 개입 0)</span></div>
|
||||
<div class="k"><b>$0.6</b><span>병원 1곳 온보딩 실측 비용 (30분)</span></div>
|
||||
<div class="k"><b>8~12주</b><span>1단계 글로벌 허브 MVP</span></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:22px">작성 2026-09-08 · 작성 Claude (haewon 세션) · 검토 haewon · 결정 CEO · 배경: 2026-09-08 CEO·뷰성형외과 미팅 (원장의 의료관광 포털 제안) · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인 필요<br>v0.2 개정 2026-09-09 · 앵커의 병원 소개 역량 반영 (§04 앵커 계약 조항, §05 소개 구조 확인, §08 소개 규모·CEO 결정, §10 앵커 의존 리스크·미결 2건)</p>
|
||||
</div></section>
|
||||
|
||||
<!-- TOC -->
|
||||
<section class="light" style="padding:56px 24px"><div class="wrap toc">
|
||||
<div class="eyebrow">Contents</div>
|
||||
<ol style="columns:2;gap:40px;margin:10px 0 0 18px;font-size:14.5px">
|
||||
<li><a href="#s1">배경과 문제</a></li><li><a href="#s2">제품 정의와 범위</a></li><li><a href="#s3">사용자와 핵심 여정</a></li><li><a href="#s4">중립성과 IP: 운영 구조 비교</a></li><li><a href="#s5">AIO2O가 신청·등록·갖춰야 하는 것</a></li><li><a href="#s6">기술 스택과 기능: 완료 / 부분 / 신규</a></li><li><a href="#s7">아키텍처와 데이터 흐름</a></li><li><a href="#s8">로드맵</a></li><li><a href="#s9">KPI와 측정</a></li><li><a href="#s10">리스크와 미결</a></li><li><a href="#s11">부록: 근거 자료</a></li>
|
||||
</ol>
|
||||
</div></section>
|
||||
|
||||
<!-- 1. 배경 -->
|
||||
<section id="s1" class="near"><div class="wrap">
|
||||
<div class="eyebrow">01 · Why Now</div><h2 class="serif">Why Now</h2>
|
||||
<p class="sub">병원 홈페이지와 네이버 자산은 글로벌 AI 답변에 닿지 않는다. 그 공백을 원문 매체로 채우는 것이 INFINITH의 처방이고, 외국인 환자 시장은 인용할 원문이 가장 부족한 곳이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-crit">진단 근거</span><h3 style="margin-top:10px">병원은 읽히지만 인용되지 않는다</h3><p>뷰성형외과 AEO 42 / GEO 56, 원진성형외과 AEO 15 / GEO 45. 크롤러 접근은 열려 있으나 정의문·FAQ·표·의료진 스키마가 없다. 원진 영문 사이트는 hreflang조차 없다.</p></div>
|
||||
<div class="card"><span class="badge b-warn">시장 신호</span><h3 style="margin-top:10px">병원이 먼저 포털을 원한다</h3><p>2026-09-08 CEO 미팅에서 뷰성형외과 원장이 성형외과·피부과를 모은 글로벌 의료관광 포털을 제안했다. 반응은 좋았으나 "비슷한 제안이 많다"는 말도 들었다. 차별점은 실측 진단과 인용되는 원문, 그리고 자동 온보딩이다.</p></div>
|
||||
<div class="card"><span class="badge b-good">준비된 것</span><h3 style="margin-top:10px">공급 쪽은 이미 자동이다</h3><p>이름 하나로 채널 확인, 근거 수집, 팩트 시트, 의료진, 영상, 이미지(의료법 필터), 글 6편, 발행 게이트, 미리보기까지 30분에 $0.6. 두 병원에서 사람 개입 없이 완주했다.</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>우리가 채울 공백</h3><p style="color:var(--navy)">외국인이 ChatGPT·Perplexity·Gemini에 "best rhinoplasty clinic in Seoul for foreigners"라고 물었을 때 인용할 다국어 원문(시술 조건 표·회복 일정·의료진·안전 체계·비용 항목)이 오픈웹에 사실상 없다. 기존 앱(강남언니 글로벌·바비톡·크리에이트립)은 정보를 앱 안에 가두고, 병원 영문 사이트는 슬로건과 이미지다. 네이버 자산은 robots.txt로 AI 크롤러를 막아 기여도 0이다(2026-08-26 실측).</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 2. 제품 정의 -->
|
||||
<section id="s2" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">02 · Product Definition</div><h2 class="serif">What We Build</h2>
|
||||
<p class="sub">세 층으로 나눈다. 첫 층은 우리가 만들고, 둘째 층은 자격을 갖춰 만들며, 셋째 층은 만들지 않고 연결한다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1층 · 만든다</span><h3 style="margin-top:10px">정보·발견</h3><p>병원별 다국어 원문 페이지(시술 조건 표·회복 일정·의료진·안전·비용 항목·FAQ), 병원 공통 허브(시술별 비교 없는 목록·질문별 답), AI 답변엔진·검색 인용 구조(MedicalClinic·Physician·Article·FAQPage·hreflang). 지금 파이프라인 그대로.</p></div>
|
||||
<div class="card"><span class="badge b-warn">2층 · 자격 후 만든다</span><h3 style="margin-top:10px">상담·예약</h3><p>병원 선택 → 상담 신청 → 통역 포함 예약 확정 → 결제·정산. 대가를 받는 순간 외국인환자 유치업자 등록이 필요하다. 1단계에서는 상담 신청을 병원에 직접 넘기고 수수료를 받지 않는다.</p></div>
|
||||
<div class="card"><span class="badge b-info">3층 · 연결한다</span><h3 style="margin-top:10px">회복기 여정</h3><p>숙박·식사·관광·축제·미용·배달은 제휴 링크와 파트너 API로 연결한다. 직접 판매하면 여행업·통신판매업 등록이 따라온다. 배달은 국내 앱이 외부 API를 열지 않아 정보 안내 수준.</p></div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>제품이 아닌 것</h3><ul><li>병원 홈페이지를 대체하는 사이트. 홈페이지는 그대로 두고 sameAs로 같은 엔티티에 묶는다.</li><li>후기·전후 사진·병원 비교·가격 할인으로 설득하는 앱. 의료법 56조 2항이 막는 것은 처음부터 없다.</li><li>슈퍼앱. 예약·숙박·배달 시스템을 처음부터 짓지 않는다.</li><li>특정 병원의 사이트. 뷰성형외과는 앵커 파트너이지 소유자가 아니다.</li></ul></div>
|
||||
<div class="card"><h3>성공의 정의 (1단계)</h3><ul><li>병원 4곳 이상이 다국어 원문으로 발행되고 각 병원 개설자 승인이 데이터로 켜진다.</li><li>외국인 질문 뱅크(영·일·중 각 60문항) 실측에서 허브 인용률이 0에서 유의미하게 오른다. 소유 신호(서버 로그 AI 크롤러 히트, GA4 AI 유입)로 잰다.</li><li>상담 신청이 병원으로 전달되고 병원이 응답한 건수를 센다.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 3. 사용자 -->
|
||||
<section id="s3" class="light"><div class="wrap">
|
||||
<div class="eyebrow">03 · Users & Journeys</div><h2 class="serif">Who It Serves</h2>
|
||||
<div class="grid g4" style="margin-top:22px">
|
||||
<div class="card"><h3>외국인 환자</h3><p>일본·중국어권·동남아·영어권. AI와 검색에 질문 → 허브 원문 → 병원 페이지 → 상담 신청 → 방문·회복. 원하는 것은 조건(시간·마취·회복·비용 항목)과 안전, 그리고 의사소통.</p></div>
|
||||
<div class="card"><h3>병원 담당자</h3><p>온보딩 폼(v2 §6 9항목), 글별 검토자·검토일 입력, 상담 신청 응답. 기술 지식 없음. 지금 만든 `/supporters/:id` 패널이 시작점.</p></div>
|
||||
<div class="card"><h3>원장·개설자</h3><p>의학 검토, 게시 승인. 의료광고 주체. 승인 기록(승인자·직함·일시·글 버전)이 법적 근거가 된다.</p></div>
|
||||
<div class="card"><h3>AIO2O 운영자</h3><p>큐·워커 운영, 게이트 실패 처리, 이미지 육안 확인, "확인 필요" 채널 판정, 정정 접수. 비개발자가 운영할 수 있어야 한다.</p></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px"><h3>핵심 여정 (외국인 환자)</h3>
|
||||
<div class="flow" style="margin-top:10px">
|
||||
<div class="st">AI·검색에 질문. 허브의 질문형 글이 인용된다</div><div class="st">시술 조건 표·회복 일정·비용 항목을 언어별로 읽는다</div><div class="st">병원 페이지에서 의료진·안전 체계·확인일을 본다</div><div class="st">상담 신청(언어·희망 시술·일정). 1단계는 병원 직결</div><div class="st">예약 확정·통역·결제 (2단계, 유치업 등록 후)</div><div class="st">회복기 숙박·식사·관광 제휴 링크와 주의사항 안내 (3단계)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 4. 중립성과 IP -->
|
||||
<section id="s4" class="near"><div class="wrap">
|
||||
<div class="eyebrow">04 · Neutrality & IP</div><h2 class="serif">Ownership Structure</h2>
|
||||
<p class="sub">포털의 생명은 중립성이다. 한 병원의 계열 플랫폼에는 두 번째 병원이 들어오지 않는다. 파이프라인 IP는 AIO2O 사업 자체이므로 어떤 구조에서도 밖에 두지 않는다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">기준</th><th>뷰성형외과 자회사</th><th>뷰 · AIO2O 합작법인</th><th>AIO2O 단독 + 뷰 앵커 파트너 (추천)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>다른 병원 참여</b></td><td><span class="dot" style="background:var(--crit-dot)"></span>어렵다. 경쟁 병원 계열사에 의료진·비용 항목을 올릴 이유가 없다</td><td><span class="dot" style="background:var(--warn-dot)"></span>뷰 지분이 보이면 같은 경계. 지분이 작고 공개돼 있으면 완화</td><td><span class="dot" style="background:var(--good-dot)"></span>문제없다</td></tr>
|
||||
<tr><td><b>의료광고·고지</b></td><td>자회사 매체가 모병원을 노출하면 경제적 이해관계 고지가 페이지마다 붙고 추천·보증 심사지침 대상</td><td>같다. 뷰 관련 페이지에 지분 관계 고지</td><td>병원별 지원 관계 고지만</td></tr>
|
||||
<tr><td><b>유치업 등록</b></td><td>의료기관도 유치 등록은 가능하나 타 병원 환자 중개는 별도 법인이 깔끔</td><td>합작법인이 유치업자로 등록하면 자연스럽다</td><td>AIO2O 법인이 등록 (2단계 전)</td></tr>
|
||||
<tr><td><b>자본·앵커</b></td><td>병원이 자본을 댄다</td><td>자본·첫 병원·의료 자문을 같이 얻는다</td><td>자본은 AIO2O 몫. 앵커는 계약으로</td></tr>
|
||||
<tr><td><b>IP·속도</b></td><td>파이프라인이 병원 소유가 된다</td><td>IP를 JV에 넣으면 사업이 JV에 묶인다. 의사결정 둘</td><td>IP·속도 모두 AIO2O</td></tr>
|
||||
<tr><td><b>브랜드 리스크</b></td><td>뷰의 2026-07 공정위 시정명령(후기 뒷광고) 이력이 그대로 붙는다</td><td>같은 이력이 JV 브랜드에 묻는다</td><td>파트너 병원 중 하나의 이력</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card grad"><h3>추천 구조</h3><ul style="color:#E9E5FF"><li>플랫폼·파이프라인·게이트·데이터 모델 IP는 AIO2O 100%. 라이선스도 넘기지 않는다.</li><li>뷰성형외과는 앵커 파트너 계약: 첫 병원 무상 온보딩, 다국어 첫 발행, 병원 2~4곳 소개, 의료 자문. 대가는 소액 지분(10% 미만, 공개) 또는 소개 수수료.</li><li>예약 수수료를 받는 2단계에서 유치업 전용 법인이 필요하면 그 법인만 합작 검토. 자본금·배상보험을 나눠 지고 플랫폼 IP는 밖에 둔다.</li></ul></div>
|
||||
<div class="card"><h3>합작을 하게 된다면 계약 필수 조항</h3><ul><li>IP 귀속: 파이프라인·게이트·데이터 모델은 AIO2O 소유, JV는 사용권.</li><li>뷰 지분 상한과 중립성 조항: 노출 순서·비교 금지, 타 병원 정보 접근 제한.</li><li>의료광고 책임 분리: 각 병원 개설자가 광고 주체, JV는 매체·대행. 사전심의·유치 광고 규정 준수는 병원 책임.</li><li>공정위 이력에 대한 표명·보증. JV 이름에 병원명을 넣지 않는다.</li><li>데이터: 병원 자산 사용 허락 범위, 환자 데이터 처리 주체.</li></ul></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px;border-color:var(--info-bd)">
|
||||
<span class="badge b-info"><span class="dot" style="background:var(--info-dot)"></span>v0.2 추가 · 2026-09-09</span>
|
||||
<h3 style="margin-top:10px">앵커의 병원 소개 역량이 큰 경우</h3>
|
||||
<p><b>전달 사항(미확인).</b> 2026-09-09 haewon 전달. 뷰성형외과 원장이 다른 병원 네트워크·파트너십이 많아 병원을 유치할 수 있다고 밝혔다. 병원 명단·수·과목·지역·현재 관계 단계는 확인되지 않았다. 이 문서는 이를 사실이 아니라 확인 대상으로 둔다.</p>
|
||||
<p><b>구조 판단은 바뀌지 않는다.</b> 소개는 계약으로 확보할 수 있다. IP 귀속과 중립성 요건은 소개 규모와 무관하다. 위 비교표의 "다른 병원 참여" 행은 자회사·합작법인 구조에 대한 판단이며, 추천 구조는 이미 앵커가 병원을 소개하는 것을 전제로 한다(§07 입력 경로, §08 1단계). 소개 역량이 크다면 바뀌는 것은 구조가 아니라 <b>소개 목표 수치와 그 대가</b>다.</p>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div>
|
||||
<h3>앵커 계약에 넣을 조항 (신규)</h3>
|
||||
<ul>
|
||||
<li>소개 목표를 수치로: 1단계 소개 N곳, 계약 체결 M곳. 미달·초과 시 대가 조정.</li>
|
||||
<li>대가는 성과 연동: 계약 체결 병원 수에 따른 지분 베스팅(상한 10% 미만·공개 유지) 또는 병원당 소개 수수료.</li>
|
||||
<li>소개받은 병원은 AIO2O와 직접 계약한다. 앵커를 거치는 계약 구조를 두지 않는다.</li>
|
||||
<li>노출 우대 없음: 소개 병원에도 노출 순서 규칙과 비교·순위 금지를 동일 적용한다.</li>
|
||||
<li>앵커 이탈 시 병원 계약 존속: 앵커 관계 종료가 병원 계약의 해지 사유가 아님을 명시한다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>다음 미팅 확인 질문</h3>
|
||||
<ul>
|
||||
<li>병원 수·이름·과목·지역, 그리고 현재 관계 단계(지인·구두·계약).</li>
|
||||
<li>관계의 성격: 공동 마케팅인지, 환자 리퍼럴인지, 학회·공동구매인지. 성격에 따라 §05 확인 항목이 달라진다.</li>
|
||||
<li>각 병원이 AIO2O와 직접 계약하는 데 동의하는지.</li>
|
||||
<li>대가로 원하는 것: 지분·수수료·노출 우대 중 무엇인지. 노출 우대를 원하면 중립성 원칙과 충돌하므로 재협의가 필요하다.</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:8px">답변 전까지 §08 1단계의 소개 병원 수는 최소선 2~4곳을 유지한다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 5. 신청·등록 -->
|
||||
<section id="s5" class="dark pb"><div class="wrap">
|
||||
<div class="eyebrow">05 · Registrations & Compliance</div><h2 class="serif">What AIO2O Must Obtain</h2>
|
||||
<p class="sub">단계별로 필요한 등록·신고·계약·계정을 전부 적었다. "확인"은 요건 수치나 적용 여부를 담당 기관 또는 변호사에게 확인해야 하는 항목이다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:56px">단계</th><th style="width:230px">항목</th><th>근거·요건</th><th style="width:170px">담당·비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>사업자 업종 추가</b></td><td>정보서비스업(포털·데이터), 광고대행업, 소프트웨어 개발. 의료관광 유치업은 등록 후 추가.</td><td>세무 대리인</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 간 상호 소개 구조 확인</b></td><td>의료법 27조 3항(영리 목적 환자 소개·알선·유인 금지). 앵커·파트너 병원이 서로 환자를 소개하는 기존 관계를 허브에 그대로 옮길 때 적용 여부. 앵커에게 지급하는 병원 소개 대가가 이 조항 또는 유치업 등록 전 수수료 제한에 걸리는지 함께 본다. 관계의 성격(공동 마케팅·리퍼럴·학회)에 따라 판단이 갈린다.</td><td>확인: 변호사. 앵커 소개 명단 확보 후</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>병원 파트너 계약 (병원마다)</b></td><td>홈페이지·영상·이미지 자산 사용 허락 범위, 의료광고 주체는 개설자·AIO2O는 제작·운영 대행, 글별 의학 검토·게시 승인 의무와 주기, 지원 관계 고지 문구, 정정 절차, 환자 데이터 처리 역할. 뷰성형외과와의 앵커 조항(소개·자문·지분 또는 수수료)은 별도 부속.</td><td>변호사 검토. 표준 계약서 1종</td></tr>
|
||||
<tr><td><span class="badge b-good">1</span></td><td><b>의료광고 규정 준수 체계</b></td><td>의료법 56조 2항(치료경험담 2호, 비교 4호, 수술 장면 6호, 부작용 누락 7호, 과장 8호, 기사·전문가 형태 10호, 인증·보증 14호)을 발행 게이트와 이미지 규칙으로 차단. 경제적 이해관계 표시는 추천·보증 심사지침대로 글 첫 부분. 외국어 페이지도 동일 적용.</td><td>완료(코드). 외국어 게이트 어휘 추가 필요</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>의료광고 사전심의 (매체 기준)</b></td><td>의료법 57조. 자체 도메인 신규 매체는 전년도 일평균 이용자 10만 미만이라 의무 아님. 10만을 넘기면 대한의사협회 의료광고심의위원회 심의 대상. 네이버 블로그 재작성본은 게시물별 심의 대상이며 신청은 병원 명의.</td><td>이용자 수 모니터링. 확인: 외국어 매체 적용 여부</td></tr>
|
||||
<tr><td><span class="badge b-warn">1→2</span></td><td><b>외국인환자 유치 광고 규정</b></td><td>의료해외진출 및 외국인환자 유치 지원법. 유치업자·의료기관의 외국인환자 유치 광고는 국내 지정 장소(공항·항만·면세구역 등) 외 제한, 진료 비용 할인·과장 금지. 온라인 다국어 매체가 이 규정에서 어떻게 취급되는지 확인.</td><td>확인: 보건복지부·KHIDI</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>외국인환자 유치업자 등록</b></td><td>같은 법 6조. 상담·예약을 중개하고 대가를 받으면 필수. 요건(확인): 법인, 자본금 1억 원 이상, 의료사고 배상책임보험 또는 공제(보장 한도 기준 있음), 사무실. 시·도에 등록, 3년마다 갱신, 유치 실적 연 1회 보고. 미등록 유치·수수료는 형사 처벌 대상.</td><td>AIO2O 법인. 2단계 착수 전 3개월</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>통신판매업 신고 · 결제(PG)</b></td><td>예약금·상담비를 온라인으로 받으면 통신판매업 신고(구청). 해외 카드 결제 PG(Stripe·토스 해외결제) 계약, 외화 정산 계좌. 환불·취소 약관.</td><td>2단계</td></tr>
|
||||
<tr><td><span class="badge b-warn">2</span></td><td><b>개인정보·민감정보 처리</b></td><td>개인정보처리방침(한·영·일·중), 건강 정보는 민감정보라 별도 동의, 외국인 데이터의 국외 이전 동의(해외 결제·번역 API 사용 시), 병원과의 처리 위탁·제공 계약. GDPR·일본 개인정보보호법 적용 여부 확인(대상 국가별). 상담 신청 폼은 최소 수집.</td><td>변호사·개인정보 담당자 지정</td></tr>
|
||||
<tr><td><span class="badge b-info">3</span></td><td><b>여행업 등록 (직접 판매 시)</b></td><td>숙박·관광 상품을 직접 판매·알선하면 관광진흥법 여행업 등록. 제휴 링크(아고다·클룩·야놀자 어필리에이트)만 쓰면 불필요. 배달은 제휴 API 부재.</td><td>3단계에서 결정</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>상표·도메인</b></td><td>허브 브랜드 상표 출원(한국·주요 대상국), 국제 도메인, 언어별 서브패스(/en /ja /zh) 또는 서브도메인 결정(hreflang 설계에 영향).</td><td>브랜드 확정 후</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>API·플랫폼 계정</b></td><td>Google Cloud 프로젝트 쿼터(신청 중) → Places API(GBP 평점·리뷰 수·NAP 실측), YouTube Data API 쿼터 증량(기본 1만 유닛/일, 병원 10곳 이상 시 신청), OpenAI 조직 한도, Firecrawl·네이버 검색 API, 번역(DeepL 또는 GPT), Vercel Pro(팀·도메인), Supabase Pro, Trigger.dev(워커), Apify 미납 해소(인스타 수집).</td><td>AIO2O 계정 소유자 통일</td></tr>
|
||||
<tr><td><span class="badge b-info">1</span></td><td><b>보험·회계</b></td><td>매체 배상책임(명예훼손·저작권) 보험 검토, 병원 구독·수수료 세금계산서 체계, 외화 수익 회계.</td><td>확인</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 6. 기술 스택과 기능 -->
|
||||
<section id="s6" class="light pb"><div class="wrap">
|
||||
<div class="eyebrow">06 · Stack & Capabilities</div><h2 class="serif">Done, Partial, New</h2>
|
||||
<p class="sub">완료는 두 병원에서 검증된 것, 부분은 있으나 포털 규모로 바꿔야 하는 것, 신규는 없는 것. 근거 열은 저장소 파일이다.</p>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><h3>현재 스택</h3><p><span class="pill">React 19</span><span class="pill">Vite 6</span><span class="pill">Tailwind 4</span><span class="pill">React Router 7</span><span class="pill">Astro 7 (서포터즈)</span><span class="pill">Supabase (DB·Edge Functions)</span><span class="pill">Vercel</span><span class="pill">Node 22 워커</span><span class="pill">Python (채점기·이미지)</span><span class="pill">GPT-4.1</span><span class="pill">YouTube Data API</span><span class="pill">Firecrawl</span><span class="pill">네이버 검색 API</span></p></div>
|
||||
<div class="card"><h3>추가 스택 (1~2단계)</h3><p><span class="pill">번역: GPT-4.1 근거 프로토콜 재실행 + DeepL 검수</span><span class="pill">Trigger.dev (워커 상시)</span><span class="pill">Supabase Auth (병원·운영자 역할)</span><span class="pill">Stripe / 토스 해외결제</span><span class="pill">Google Places API</span><span class="pill">Resend·SES (알림 메일)</span><span class="pill">Plausible 또는 GA4 (AI 유입 채널그룹)</span><span class="pill">Vercel 로그 드레인 (AI 크롤러 계측)</span></p></div>
|
||||
<div class="card"><h3>원칙 (변하지 않는 것)</h3><ul><li>근거 없는 사실은 생성하지 않는다. "확인 대기".</li><li>사람 게이트(검토·승인·채널 확인·이미지 육안)는 자동화하지 않는다.</li><li>고객 화면에 운영자 설명 없음. 확정 디자인은 텍스트만.</li><li>부재는 증명한다(채널 탐색 다섯 갈래).</li></ul></div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:150px">영역</th><th>기능</th><th style="width:78px">상태</th><th style="width:260px">근거 파일 · 비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td rowspan="3"><b>진단 (AI Discovery)</b></td><td>기준표 v1.0 36항목 · v2.0 AEO/GEO 분리 채점, 미검증 분모 제외, 치명 게이트</td><td><span class="badge b-good">완료</span></td><td class="mono">src/data/aeoGeoRubric*.ts · src/lib/discoveryScore*.ts</td></tr>
|
||||
<tr><td>자동 채점기(auto 21~22항목) · 리포트 페이지 · 독립 HTML/PDF 리포트</td><td><span class="badge b-good">완료</span></td><td class="mono">scripts/audit_aeo_geo.py · /discovery/:id · scripts/build_discovery_report.ts</td></tr>
|
||||
<tr><td>외국인 질문 뱅크(영·일·중) · 다국어 답변엔진 실측 러너 · GBP·강남언니·플레이스 자동 실측(D1·D2·D4·D5)</td><td><span class="badge b-crit">신규</span></td><td>국문 120문항 러너 `scripts/run_question_bank_openai.py` 확장. Places API 키 필요</td></tr>
|
||||
<tr><td rowspan="4"><b>온보딩 파이프라인</b></td><td>채널 발견·엔티티 검증(5갈래, 근거 점수 3-state) · 근거 수집(223p) · 팩트 시트·의료진 초안</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/discover_channels.mjs · collect_evidence.mjs</td></tr>
|
||||
<tr><td>유튜브 집계(정보형 필터·쇼츠 판별) · 로고·파비콘 · 이미지(의료법 규칙·글자 이미지 검사)</td><td><span class="badge b-good">완료</span></td><td class="mono">collect_youtube.mjs · collect_images.mjs · img_process.py · docs/INFINITH_Supporters_Image_Rules.md</td></tr>
|
||||
<tr><td>첫 배치 기획(2층 구조) · GPT-4.1 글 생성 · 수치 대조 · 모델 근거 대조 · 수정 2회</td><td><span class="badge b-good">완료</span></td><td class="mono">default_briefs.mjs · generate_posts.mjs · gen/*</td></tr>
|
||||
<tr><td>뉴스룸 단계 · 감사 재실행(색인 허용 빌드 비교) · 유튜브 자막(IP 차단 대비 큐·프록시)</td><td><span class="badge b-warn">부분</span></td><td>뷰 수작업 스크립트 있음(`build_news.py`·`build_supporters_audit_xlsx.py`). 워커 단계로 미연결. 자막은 미구현</td></tr>
|
||||
<tr><td rowspan="2"><b>발행 게이트</b></td><td>error 11종 + warn 6종 + 비교 수치 · 승인 · 고지 위치 · 기자 화법 · 구조 지문, 픽스처 테스트 16/16</td><td><span class="badge b-good">완료</span></td><td class="mono">supporters/scripts/gate/rules.mjs · check.mjs</td></tr>
|
||||
<tr><td>외국어 금칙어·결론 단정·비교 표현 사전(영·일·중), 외국어 홈페이지 40자 중복 기준</td><td><span class="badge b-crit">신규</span></td><td>언어별 규칙 모듈. 원문 병원 페이지가 국문이면 번역 중복 검사 방식 결정 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>서포터즈 사이트 (병원 페이지)</b></td><td>Astro 템플릿(INFINITH 토큰) · 팩트 시트 단일 원본 · 신뢰 층(작성자·검토·승인·근거 유형·갱신·정정) · MedicalClinic·Physician·Article·FAQPage · noindex→색인 전환</td><td><span class="badge b-good">완료</span></td><td class="mono">templates/supporters-astro · supporters/src/lib.ts</td></tr>
|
||||
<tr><td>다국어(hreflang·언어별 라우팅·언어별 팩트 시트 표기·통화·날짜) · 언어별 상담 신청 폼</td><td><span class="badge b-crit">신규</span></td><td>같은 팩트 시트에서 언어별 렌더. 수치·시점은 언어 간 동일해야 함</td></tr>
|
||||
<tr><td>병원 N개를 한 도메인에서: 병원 서브패스 또는 서브도메인, 공통 홈·시술별 허브·질문별 답 페이지·병원 목록(비교·순위 없음)</td><td><span class="badge b-crit">신규</span></td><td>현재는 병원당 Vercel 프로젝트. 허브 도메인 설계와 canonical·sitemap 분리 필요</td></tr>
|
||||
<tr><td rowspan="3"><b>승인·운영 UI</b></td><td>병원 확인 항목 폼(9항목) · 글별 검토·승인 기록 · 발견 채널 확인 · 미리보기 · 입력 반영 재빌드</td><td><span class="badge b-good">완료</span></td><td class="mono">src/components/discovery/ClinicInputsPanel.tsx · apply_inputs.mjs</td></tr>
|
||||
<tr><td>계정·역할(병원 담당자·원장·운영자) · 로그인 · 병원별 권한 · 승인 서명 기록(승인자·직함·일시·글 버전 해시)</td><td><span class="badge b-crit">신규</span></td><td>지금은 익명 입력. Supabase Auth + RLS 병원 스코프</td></tr>
|
||||
<tr><td>운영자 콘솔: 게이트 실패 목록, 이미지 육안 확인 체크, 확인 필요 채널 판정, 정정 접수, 비용·쿼터 대시보드</td><td><span class="badge b-warn">부분</span></td><td>status.json·report.md로 존재. 화면 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>워커·인프라</b></td><td>워커 11단계 · 큐(supporter_builds) · 폴러 · 랜딩 URL 신청 → 큐 · Vercel 미리보기 배포</td><td><span class="badge b-good">완료</span></td><td class="mono">workers/supporters-build/run.mjs · poll.mjs</td></tr>
|
||||
<tr><td>상시 실행(Trigger.dev) · 입력 변경 시 자동 재빌드 트리거 · 실패 알림 · 병원 10곳 이상 동시 큐</td><td><span class="badge b-warn">부분</span></td><td>launchd plist 준비, 미설치. Trigger.dev 이관은 가입·키만 필요</td></tr>
|
||||
<tr><td rowspan="2"><b>상담·예약 (2단계)</b></td><td>상담 신청 폼(언어·시술·일정·연락처) → 병원 이메일·카카오 알림 · 응답 추적</td><td><span class="badge b-crit">신규</span></td><td>1단계는 병원 직결(수수료 없음)</td></tr>
|
||||
<tr><td>예약 확정·통역 배정·예약금 결제·취소 환불·정산 · 유치 실적 보고 데이터</td><td><span class="badge b-crit">신규</span></td><td>유치업 등록 후. Stripe·토스, 정산 테이블, 실적 보고 양식</td></tr>
|
||||
<tr><td><b>여정 연결 (3단계)</b></td><td>회복 일정 기반 숙박·식사·관광 추천 · 제휴 링크 트래킹 · 축제·행사 데이터 연동(한국관광공사 API)</td><td><span class="badge b-crit">신규</span></td><td>제휴 API·어필리에이트 계정. 직접 판매 없음</td></tr>
|
||||
<tr><td rowspan="2"><b>측정</b></td><td>서버 로그 AI 크롤러 히트 · GA4 AI 유입 채널그룹 · GSC AI Overviews · 기준선 스팟체크(질의 × 반복)</td><td><span class="badge b-crit">신규</span></td><td>E 카테고리 0/4. Vercel 로그 드레인 → Supabase, GA4 채널그룹 정의. 답변엔진 UI 자동 조회는 약관 위반이라 소유 신호만</td></tr>
|
||||
<tr><td>병원별 성과 리포트(인용·유입·상담 신청) 자동 생성</td><td><span class="badge b-warn">부분</span></td><td>리포트 빌더 있음. 측정 데이터가 없어 카드가 비어 있음</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 7. 아키텍처 -->
|
||||
<section id="s7" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">07 · Architecture</div><h2 class="serif">Data Flow</h2>
|
||||
<p class="sub">공급(병원)은 자동 온보딩 파이프라인, 수요(환자)는 AI·검색 인용, 그 사이를 승인 데이터가 잇는다.</p>
|
||||
<div class="flow" style="margin-top:22px">
|
||||
<div class="st"><b>입력</b><br>병원 이름 또는 URL (랜딩 신청·운영자·앵커 소개) → 큐</div>
|
||||
<div class="st"><b>발견·수집</b><br>채널 엔티티 검증, 근거 223p, 유튜브, 이미지(법 필터), 팩트 시트</div>
|
||||
<div class="st"><b>생성·게이트</b><br>기획 → GPT-4.1 → 수치·근거 대조 → 발행 게이트 → 국문 원문</div>
|
||||
<div class="st"><b>다국어</b><br>같은 팩트 시트·근거로 언어별 재생성(번역 아님) → 언어별 게이트 → hreflang</div>
|
||||
<div class="st"><b>승인</b><br>병원 폼 입력 → 검토·승인 기록 → 색인 허용 빌드에 승인 글만</div>
|
||||
<div class="st"><b>허브 배포·측정</b><br>허브 도메인(병원별 경로) → AI·검색 인용 → 로그·GA4·상담 신청 계측</div>
|
||||
</div>
|
||||
<div class="grid g2" style="margin-top:18px">
|
||||
<div class="card"><h3>데이터 모델 (핵심 테이블)</h3><ul><li><b>clinics</b>: id, 이름(다국어), url, 팩트 시트(JSON, checkedAt/nextCheck), surfaces(확인된 채널), 파트너 계약 상태.</li><li><b>supporter_builds</b>(있음): 빌드 실행·단계·미리보기 URL·리포트.</li><li><b>supporter_inputs</b>(있음): 병원 입력, 글별 검토·승인.</li><li><b>posts</b>: 언어별 글, 근거 유형, 검토·승인, 갱신 기록. 지금은 md frontmatter, 허브 규모에서는 DB로.</li><li><b>inquiries</b>(신규): 상담 신청, 언어, 병원 전달·응답 시각.</li><li><b>bookings·payments</b>(2단계), <b>affiliate_clicks</b>(3단계), <b>ai_crawler_hits·ai_referrals</b>(측정).</li></ul></div>
|
||||
<div class="card"><h3>다국어 설계 원칙</h3><ul><li>번역이 아니라 근거 프로토콜을 언어별로 다시 태운다. 수치·시점·의료진은 팩트 시트 한 곳에서 나온다.</li><li>언어별 게이트: 금칙어·결론 단정·비교 표현 사전을 언어마다 둔다. 부작용 고지·지원 관계 고지는 언어별 문안 고정.</li><li>hreflang 상호 연결, canonical 언어별, sitemap 언어별. 병원 홈페이지 영문 사이트가 있으면 sameAs로 연결.</li><li>통화·단위: 비용 항목만 쓰고 금액은 쓰지 않으므로 환율 문제는 2단계 결제에서만.</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 8. 로드맵 -->
|
||||
<section id="s8" class="near"><div class="wrap">
|
||||
<div class="eyebrow">08 · Roadmap</div><h2 class="serif">Three Phases</h2>
|
||||
<div class="grid g3" style="margin-top:22px">
|
||||
<div class="card"><span class="badge b-good">1단계 · 8~12주</span><h3 style="margin-top:10px">글로벌 허브 MVP</h3><ul><li>허브 브랜드·도메인·언어 라우팅 결정</li><li>다국어 생성(영·일·중) + 언어별 게이트</li><li>다병원 허브(공통 홈·시술 허브·병원 페이지·상담 신청 병원 직결)</li><li>뷰성형외과 + 앵커 소개 병원 온보딩(자동). 최소 2~4곳, 앵커 소개 명단 확인 후 재산정</li><li>외국인 질문 뱅크 실측(기준선)</li><li>측정 인프라: 로그 드레인·GA4 채널그룹</li><li>계정·역할·운영자 콘솔 최소판</li></ul><p class="muted">게이트: 병원 4곳 개설자 승인, 인용 기준선 확보</p></div>
|
||||
<div class="card"><span class="badge b-warn">2단계 · 3~6개월</span><h3 style="margin-top:10px">예약·수익화</h3><ul><li>유치업자 등록(법인·자본금·배상보험) 완료</li><li>예약 확정·통역·예약금 결제·환불·정산</li><li>병원 구독(온보딩·원문 공급·리포트) 요금제</li><li>뉴스룸·감사 재실행·자막 단계 워커 연결</li><li>Trigger.dev 상시 워커, 입력 변경 자동 재빌드</li><li>개인정보 국외 이전·민감정보 동의 체계</li></ul><p class="muted">게이트: 유치업 등록증, PG 계약, 상담→예약 전환 데이터</p></div>
|
||||
<div class="card"><span class="badge b-info">3단계 · 이후</span><h3 style="margin-top:10px">회복기 여정 연결</h3><ul><li>회복 일정 기반 숙박·식사·관광 추천(제휴)</li><li>축제·행사 데이터(관광공사 API)</li><li>미용 서비스 제휴, 배달은 안내</li><li>병원 10곳 이상, 피부과 확장</li><li>여행업 등록 여부 결정</li></ul><p class="muted">게이트: 제휴 계약, 3단계 수익 검증</p></div>
|
||||
</div>
|
||||
<div class="card cta" style="margin-top:18px"><h3>1단계 착수 전 CEO 결정 3가지</h3><p style="color:var(--navy)">(1) 뷰성형외과 앵커 계약 조건(무상 온보딩·소개·자문 대가로 소액 지분 또는 수수료). 소개 목표 수치, 성과 연동 여부, 소개 병원의 AIO2O 직접 계약 원칙, 노출 우대 배제를 함께 정한다(§04). (2) 유치업 등록을 AIO2O 법인으로 할지, 등록된 유치업자와 제휴할지. (3) 수익 순서: 병원 구독이 먼저인지 예약 수수료가 먼저인지. 이 셋이 정해지면 나머지는 지금 파이프라인 위에 붙는 개발이다.</p></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 9. KPI -->
|
||||
<section id="s9" class="light"><div class="wrap">
|
||||
<div class="eyebrow">09 · Metrics</div><h2 class="serif">How We Measure</h2>
|
||||
<p class="sub">답변엔진 UI 자동 조회는 약관 위반이라 주력 지표는 소유 신호다. 효과는 반복 측정과 신뢰구간으로만 보고한다.</p>
|
||||
<div style="overflow-x:auto;margin-top:22px"><table>
|
||||
<thead><tr><th style="width:170px">축</th><th>지표</th><th style="width:220px">소스</th><th style="width:150px">1단계 목표</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>공급</b></td><td>온보딩 병원 수, 언어별 발행 글 수, 개설자 승인 글 비율, 게이트 오류율, 편당 생성 비용</td><td>supporter_builds·inputs, 생성기 리포트</td><td>병원 4곳 · 승인 60% 이상</td></tr>
|
||||
<tr><td><b>인용(AEO/GEO)</b></td><td>외국인 질문 뱅크 인용률(언어별, 5회 반복), 병원 AEO/GEO 점수 변화, AI 크롤러 히트(IP 검증)</td><td>QB 러너, 채점기, 서버 로그</td><td>기준선 확보, 크롤러 히트 발생</td></tr>
|
||||
<tr><td><b>유입</b></td><td>AI 유입 세션(GA4 채널그룹), 검색 유입, 언어별 체류</td><td>GA4·GSC</td><td>월 AI 유입 세션 측정 시작</td></tr>
|
||||
<tr><td><b>전환</b></td><td>상담 신청 수, 병원 응답률·응답 시간, (2단계) 예약 확정·결제</td><td>inquiries·bookings</td><td>상담 신청 월 10건</td></tr>
|
||||
<tr><td><b>신뢰</b></td><td>정정 접수·처리 시간, 게이트 차단 건수, 확인 대기 항목 해소율</td><td>corrections, 게이트 로그</td><td>정정 72시간 내 처리</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
|
||||
<!-- 10. 리스크 -->
|
||||
<section id="s10" class="dark"><div class="wrap">
|
||||
<div class="eyebrow">10 · Risks & Open Questions</div><h2 class="serif">What Can Break</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>리스크와 대응</h3><ul><li><b>규제</b>: 유치업 미등록 상태의 수수료, 외국인 유치 광고 규정, 사전심의 매체 기준. 대응: 1단계 무수수료·병원 직결, 2단계 전 등록, 이용자 수 모니터링.</li><li><b>공급 정체</b>: 병원이 검토·승인을 안 한다. 대응: 미승인이어도 "확인 대기" 미리보기 발행, 검토 담당자·주기를 계약에.</li><li><b>중립성 의심</b>: 앵커 병원 편향. 대응: 지분 공개·고지, 노출 순서 규칙 공개, 비교·순위 없음.</li><li><b>생성 품질</b>: 근거 없는 수치·오역. 대응: 언어별 수치 대조·근거 대조, 사람 확인 플래그, 정정 페이지.</li><li><b>경쟁</b>: 크리에이트립·강남언니 글로벌이 AI 인용 구조를 갖추면 선점 기회가 사라진다. 대응: 속도. 온보딩 자동화가 유일한 시간 우위.</li><li><b>운영</b>: 비개발자 운영, 워커 상시 실행 미결정. 대응: Trigger.dev, 운영자 콘솔.</li><li><b>앵커 의존</b>: 공급이 앵커 개인의 관계에 붙는다. 소개 병원이 AIO2O와 직접 계약하지 않으면 앵커가 빠질 때 공급도 같이 빠진다. 대응: 병원별 직접 계약, 앵커 관계 종료가 해지 사유가 아님을 명시, 소개 경로와 계약 주체를 분리 기록.</li><li><b>브랜드</b>: 파트너 병원의 공정위·의료사고 이력. 대응: 계약 표명·보증, 뉴스룸 부정 기사 제외 규칙 유지, 허브 이름에 병원명 배제.</li></ul></div>
|
||||
<div class="card"><h3>미결 (결정 주체)</h3><ul><li>허브 브랜드·도메인·언어 라우팅 (CEO·haewon)</li><li>뷰 앵커 계약 조건, 합작 여부 (CEO)</li><li>앵커 소개 병원 명단·수·과목·지역·관계 단계 확인 (CEO)</li><li>소개 대가 구조: 성과 연동 지분 베스팅인지 병원당 수수료인지 (CEO)</li><li>유치업 등록 주체와 시점 (CEO·법무)</li><li>수익 모델 순서와 병원 구독 가격 (경영진)</li><li>대상 언어·국가 우선순위 (일본·중국어권·영어권) (CEO)</li><li>실존 서포터즈·편집 책임자 명의 (haewon)</li><li>워커 상시 실행 위치 (haewon)</li><li>기존 미결: 뷰 도메인, POC 8주 범위, 서포터즈 모집</li></ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- 11. 부록 -->
|
||||
<section id="s11" class="near"><div class="wrap">
|
||||
<div class="eyebrow">11 · Appendix</div><h2 class="serif">References</h2>
|
||||
<div class="grid g2" style="margin-top:22px">
|
||||
<div class="card"><h3>프로젝트 산출물</h3><ul>
|
||||
<li>진단 리포트: <span class="mono">docs/reports/viewclinic/01_readiness_report</span> · <span class="mono">docs/reports/wonjin/01_readiness_report</span> (HTML·PDF)</li>
|
||||
<li>미리보기: supporters-viewclinic.vercel.app · supporters-wonjin.vercel.app (noindex)</li>
|
||||
<li>병원 확인 항목 패널: infinith-demo.vercel.app/supporters/wonjin</li>
|
||||
<li>핸드오버: <span class="mono">docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md</span>, 규칙: v2 §3·§4·§6·§12</li>
|
||||
<li>회고: <span class="mono">docs/INFINITH_AI_Discovery_Retrospective_2026-09-08.md</span></li>
|
||||
<li>이미지 규칙: <span class="mono">docs/INFINITH_Supporters_Image_Rules.md</span></li>
|
||||
<li>워커: <span class="mono">workers/supporters-build/README.md</span></li>
|
||||
</ul></div>
|
||||
<div class="card"><h3>법령·기준 (원문 확인 필요)</h3><ul>
|
||||
<li>의료법 제56조(의료광고의 금지 등) 제2항, 제57조(의료광고의 심의), 시행령 제23조·제24조</li>
|
||||
<li>의료 해외진출 및 외국인환자 유치 지원법 제6조(유치업자 등록), 제15조(의료광고 특례·제한), 시행규칙(등록 요건·보험)</li>
|
||||
<li>표시·광고의 공정화에 관한 법률, 추천·보증 등에 관한 표시·광고 심사지침(2024-12 개정)</li>
|
||||
<li>개인정보 보호법(민감정보·국외 이전), 정보통신망법</li>
|
||||
<li>전자상거래법(통신판매업 신고), 관광진흥법(여행업)</li>
|
||||
<li>공정거래위원회 2026-07-12 성형외과 후기 뒷광고 시정명령(뷰성형외과 포함 3곳)</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · o2oteam@o2o.kr · PRD v0.1 초안 2026-09-08 · 법 해석은 자문이 아니며 계약·등록 전 변호사 확인이 필요합니다.</footer>
|
||||
</body></html>
|
||||
@ -8,7 +8,8 @@ docs/reports/
|
||||
│ ├── 01_readiness_report/ AI Answer Readiness 진단 덱 (v1.0 정본 · v2_표준캔버스 · v1.1 정정판) + 빌더
|
||||
│ ├── 02_poc_proposal/ POC 제안서 덱 (정본 · v3 · v3_표준캔버스) + 빌더
|
||||
│ ├── 03_question_bank/ 질문 뱅크 120문항 · ChatGPT/Perplexity 실측 결과 · TOP50 엑셀
|
||||
│ └── 04_supporters/ 서포터즈 GEO 개선 계획 · 2단계 콘텐츠 기획 · 서포터즈 vs 공식 AEO 감사 엑셀
|
||||
│ ├── 04_supporters/ 서포터즈 GEO 개선 계획 · 2단계 콘텐츠 기획 · 서포터즈 vs 공식 AEO 감사 엑셀
|
||||
│ └── 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 감사
|
||||
└── landing/ AI Discovery 랜딩 시안 (픽셀 정답 HTML) · 스크린샷 · 리포트 페이지 HTML 내보내기 스크립트
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
# AI 답변 감성 판정 결과 · 뷰성형외과
|
||||
|
||||
판정 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) ÷ 언급된 답변 수. 규제 언급률과 추천율은 언급된 답변 기준.
|
||||
|
||||
## 1. 엔진별
|
||||
|
||||
| 엔진 | 질문군 | 답변 | 언급 | 감성 점수 | 긍정 | 중립 | 혼합 | 부정 | 부정 비율 | 규제 언급률 | 추천율 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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% |
|
||||
| perplexity | 전체 | 120 | 39 | **51.3** | 4 | 32 | 0 | 3 | 7.7% | 2.6% | 10.3% |
|
||||
| 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% |
|
||||
|
||||
전체 합산: 언급 83/240 · 감성 점수 **58.4** · 부정 비율 7.2% · 규제 언급률 4.8% · 추천율 24.1%
|
||||
|
||||
약한 언급 1건(G-08(openai))은 정식 상호 없이 영문 약칭만 나온 답변이다. 이를 빼면 언급 82건 · 감성 점수 **57.9**. 포함 여부는 기준표 §6 미결이다.
|
||||
|
||||
## 2. 측면 태그 (언급된 답변)
|
||||
|
||||
- procedure: 39건
|
||||
- access: 39건
|
||||
- reviews: 33건
|
||||
- doctors: 29건
|
||||
- safety: 28건
|
||||
- price: 11건
|
||||
- media: 8건
|
||||
- regulatory: 5건
|
||||
|
||||
## 3. 부정·혼합 판정 전건 (6건)
|
||||
|
||||
| id | 엔진 | 판정 | 종류 | 질문 | 근거 문장 |
|
||||
|---|---|---|---|---|---|
|
||||
| A-01 | openai | negative | regulatory | 뷰성형외과 어때? 믿을만한 병원이야? | 그러나 2026년 7월 공정거래위원회의 제재 사례는, 병원 마케팅 방식에 대한 경계가 필요하다는 점을 보여줍니다. |
|
||||
| C-11 | openai | negative | regulatory | 후기 제일 많은 강남 성형외과 어디야? | 예컨대 ‘뷰성형외과’, '에이비성형외과의원', '디에이성형외과'는 후기를 조건으로 수술비 일부를 할인해 주고 광고처럼 이용한 사례로 지적된 바 있습니다 ([v.daum.net](https://v.daum.net/v/ |
|
||||
| G-02 | openai | negative | regulatory | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | 뷰성형외과가 수술비 할인 등 대가를 제공하고, 이를 '자발적인 후기'로 위장하여 광고한 혐의로 공정거래위원회의 시정명령을 받았습니다. |
|
||||
| A-12 | perplexity | negative | regulatory | 뷰성형외과 단점이나 주의할 점 있어? | 공정거래위원회가 뷰성형외과를 포함한 강남 성형외과들의 *수술비 할인 등 대가를 받고 작성한 후기를 자발적 후기처럼 보이게 한 행위*를 제재했습니다. |
|
||||
| D2-01 | perplexity | negative | reputation | 뷰성형외과 눈성형 어때? | 다만 제가 확인한 자료만으로는 **수술 결과의 일관된 객관성**까지 판단하긴 어렵고, 커뮤니티에는 상담 경험에 대한 **부정적 의견도 일부** 보입니다.[6] |
|
||||
| G-02 | perplexity | negative | reputation | 뷰성형외과 부작용 후기나 안 좋은 얘기 있어? | 네. 검색 결과만 보면 **뷰성형외과에 대한 안 좋은 후기나 부작용 언급은 있습니다**. |
|
||||
|
||||
## 4. 논브랜드 질문에서 긍정 언급 (15건, 가장 가치 있는 신호)
|
||||
|
||||
| id | 엔진 | 추천 | 질문 | 근거 문장 |
|
||||
|---|---|---|---|---|
|
||||
| C-01 | openai | recommended | 강남 성형외과 추천해줘 | The Door Plastic Surgery, View Plastic Surgery 등도 평점 4.8로 높은 평가받고 있습니다 ([gangnambeautyguide.com](https://gangnambeautygu |
|
||||
| C-02 | openai | recommended | 강남에서 가슴성형 잘하는 병원 어디야? | 뷰성형외과 - 협진 체계, 안전 장비, 1:1 진료 및 사후관리 강조 |
|
||||
| C-04 | openai | recommended | 코성형 잘하는 강남 병원 추천해줘 | 뷰성형외과 강남 | 협진 시스템, 맞춤 설계, 대학병원급 안전 시스템 |
|
||||
| C-06 | openai | recommended | 강남 성형외과 톱5 알려줘 | - 평점: 4.8 / 리뷰수: 약 9,167건 ([gangnambeautyguide.com](https://gangnambeautyguide.com/en/clinics/?utm_source=openai))
|
||||
- 강 |
|
||||
| C-08 | openai | recommended | 마취과 전문의가 상주하는 강남 성형외과 어디야? | - 안면윤곽, 눈·코·가슴 성형 등을 전문으로 하며, **마취과 전문의가 365일 상주**하고 있다는 점을 강조합니다 ([blog.viewclinic.com](https://blog.viewclinic.com/?ut |
|
||||
| C-12 | openai | recommended | 강남언니에서 평점 높은 성형외과 알려줘 | **팝성형외과**, **티에스성형외과**, **기린성형외과**, **제이필의원**, **뷰성형외과**, **뭐 등 다양한 병원들이 높은 평점을 보이고 있습니다. |
|
||||
| D1-01 | openai | recommended | 뷰성형외과 가슴성형 잘해? | 뷰성형외과는 가슴성형 분야에서 경험, 전문성, 안전성, 사후관리 체계를 갖춘 병원으로 평가됩니다. 많은 환자들이 만족스럽다는 후기도 많으며, 개인의 체형·희망·상태에 맞춘 맞춤 상담과 전문적인 진료 흐름이 강점입니다 |
|
||||
| D2-01 | openai | recommended | 뷰성형외과 눈성형 어때? | 장점:
|
||||
- 높은 평점(4.7)과 JCI 인증, 다국어 지원으로 의료 신뢰도 높음 ([getcarekorea.com](https://getcarekorea.com/ko/hospitals/view-plastic-surg |
|
||||
| D4-01 | openai | recommended | 뷰성형외과 안면윤곽 잘하는 곳 맞아? | 전반적으로, 뷰성형외과는 안면윤곽 수술에 있어 안전성과 전문성을 갖춘 병원으로 평가받고 있습니다. 특히 수술 기법과 시스템, 다수의 수상 경력과 방송 노출 경력 등이 신뢰감을 줍니다. |
|
||||
| E-09 | openai | recommended | 뷰성형외과 안전 시스템 어때? | 이처럼 뷰성형외과는 **환자 안전을 위한 다층적인 시스템과 기준**을 두고 있습니다. |
|
||||
| G-01 | openai | recommended | 뷰성형외과 실제 후기 어때? | 뷰성형외과는 **높은 만족도와 전문성을 갖춘 대형 병원**으로 평가되며, 특히 자연스럽고 정밀한 결과를 원하는 환자들에게 긍정적인 선택이 될 수 있습니다. 다만, 상담 경험은 대기 시간과 상담 시간의 한계가 있을 수 |
|
||||
| G-07 | openai | recommended | 뷰성형외과는 뭘로 유명한 병원이야? | 뷰성형외과는 특히 **가슴성형 분야의 압도적인 시술량**, **안전에 대한 철저한 시스템**, **원스톱 프리미엄 케어**, 그리고 **외국인 환자를 위한 의료관광 서비스**로 높은 평판을 자랑합니다. |
|
||||
| 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] |
|
||||
|
||||
## 5. 언급 판정 불일치 (판정기 vs 추출기) 1건
|
||||
|
||||
- C-14 openai: 판정기 False / 추출기 True ·
|
||||
|
||||
## 6. 측정 가능 범위
|
||||
|
||||
- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar).
|
||||
- 재지 않는 것: 소비자 화면(chatgpt.com 등)의 실제 답변. 약관상 자동 조회를 하지 않으므로 API 답변과 화면 답변은 다를 수 있다.
|
||||
- 재지 않는 것: Google AI Overview·네이버 AI 브리핑·Gemini 의 어조. 다음 회차에 엔진을 추가할 때 같은 기준표로 잰다.
|
||||
- 한계: 판정기는 언어 모델이다. 보정(§7) 전 점수는 참고값이고, 보정 후에도 문항 수(언급 84건)가 적어 ±10점은 우연 범위로 본다.
|
||||
|
||||
## 7. 보정
|
||||
|
||||
기준표 `v0.3` 로 판정했다. 1차 보정(2026-09-09)에서 haewon 라벨 29건 대비 v0.1 일치율은 75%로 기준선 80%에 미달했고, 불일치 7건에서 규칙 R1~R5를 뽑아 판정기에 반영한 것이 이 회차다.
|
||||
|
||||
같은 30건으로 다시 잰 일치율은 규칙을 그 표본에 맞춰 고친 결과이므로 확정 근거가 아니다. 기준표 확정은 새로 뽑은 30건의 2차 보정으로 한다. 자세한 이력과 한계는 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.2.md` §5에 있다.
|
||||
Binary file not shown.
135
docs/reports/viewclinic/05_global_en/QB_en_openai_results.md
Normal file
135
docs/reports/viewclinic/05_global_en/QB_en_openai_results.md
Normal file
@ -0,0 +1,135 @@
|
||||
# VIEW Plastic Surgery EN Question Bank results: openai
|
||||
|
||||
Measured 2026-09-10 / 60 questions
|
||||
|
||||
| # | Question | VIEW mentioned | Rank | Sentiment | Clinics (in order) | Cited domains |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A-01 | Is VIEW Plastic Surgery in Seoul legit? Is it a trustworthy clinic for foreigners? | O | 1 | positive | VIEW Plastic Surgery | forum.purseblog.com, gangnamclinicindex.com, medtraveling.com, realself.com, reddit.com, seoulclinics.io |
|
||||
| A-02 | Tell me about VIEW Plastic Surgery in Gangnam, Korea. What are they known for? | O | 1 | positive | VIEW Plastic Surgery | english.visitkorea.or.kr, medicaltour.gangnam.go.kr, realself.com, reddit.com, viewplasticsurgery.com, youtube.com |
|
||||
| A-03 | What procedures does VIEW Plastic Surgery Korea specialize in? | O | 1 | positive | VIEW Plastic Surgery | english.visitkorea.or.kr, kmhglobal.com, viewplasticsurgery.com |
|
||||
| A-04 | Who is Dr. Choi Soon Woo of VIEW Plastic Surgery? Is he board certified? | O | 1 | positive | VIEW Plastic Surgery | khunkim.com, viewplasticsurgery.com |
|
||||
| A-05 | How many surgeons does VIEW Plastic Surgery have and are they all board-certified plastic surgeons? | O | 1 | positive | VIEW Plastic Surgery | assets.website-files.com, khunkim.com, reddit.com, viewplasticsurgery.com |
|
||||
| A-06 | Does VIEW Plastic Surgery have English-speaking staff or interpreters? | O | 1 | positive | VIEW Plastic Surgery | assets.website-files.com, docentpro.com, forum.purseblog.com, khunkim.com, viewplasticsurgery.com |
|
||||
| A-07 | How long has VIEW Plastic Surgery been operating and how big is the clinic? | O | 1 | neutral | VIEW Plastic Surgery | assets.website-files.com, english.visitkorea.or.kr, experts-beauty.com, icloudhospital.com, inflo.kr, viewplasticsurgery.com |
|
||||
| A-08 | VIEW Plastic Surgery Korea reviews from international patients: what do foreigners say? | O | 1 | positive | VIEW Plastic Surgery, Girin Plastic Surgery, AB Plastic Surgery | inkoreanow.com, kclinichelp.com, patients.community, realself.com, reddit.com, viewplasticsurgery.com |
|
||||
| B-01 | Where is VIEW Plastic Surgery located and how do I get there from Incheon Airport? | O | 1 | - | VIEW Plastic Surgery | english.seoul.go.kr, incheon.go.kr, ivisitkorea.com, jusofind.kr, origin.viewplasticsurgery.com, reddit.com |
|
||||
| B-02 | Does VIEW Plastic Surgery offer airport pickup for international patients? | O | 1 | positive | VIEW Plastic Surgery | viewplasticsurgery.com |
|
||||
| B-03 | What are VIEW Plastic Surgery's opening hours and can I book a consultation online from abroad? | O | 1 | positive | VIEW Plastic Surgery | jp.viewplasticsurgery.com, reddit.com, stmstyle.com, viewplasticsurgery.com |
|
||||
| B-04 | Can I contact VIEW Plastic Surgery via WhatsApp or LINE for a consultation? | O | 1 | - | VIEW Plastic Surgery | 10mag.com, reddit.com, viewplasticsurgery.com, x.com |
|
||||
| B-05 | Does VIEW Plastic Surgery provide accommodation or hotel partnerships for foreign patients? | O | 1 | positive | VIEW Plastic Surgery | medicaltour.gangnam.go.kr, reddit.com, viewplasticsurgery.com |
|
||||
| B-06 | How long should I stay in Seoul after plastic surgery before flying home? | X | - | - | (none) | |
|
||||
| B-07 | Do I need a medical visa for plastic surgery in Korea as a tourist? | X | - | - | (none) | |
|
||||
| B-08 | Which Gangnam plastic surgery clinics are closest to Sinnonhyeon Station? | O | 6 | neutral | JUST Plastic Surgery Clinic, 1 mm Plastic Surgery Clinic, 345 Plastic Surgery Clinic, Thank You Plastic Surgery Clinic, BETWEEN Plastic Surgery, VIEW Plastic Surgery, Noonopi Plastic Surgery, IK Plastic Surgery | en.ikplastic.com, eng.btwps.com, medi-spec.com, noonopiclinic.com, reddit.com, rightme.kr |
|
||||
| C-01 | Best plastic surgery clinic in Seoul for foreigners | X | - | - | JK Plastic Surgery Center, ID Hospital, Banobagi Plastic Surgery, SAERO Plastic Surgery, AB Plastic Surgery, NANA Plastic Surgery Hospital, SNUPS Clinic, JW Plastic Surgery Center | clinicseoul.net, english.visitkorea.or.kr, kclinichelp.com, koreaexperience.com, reddit.com, seoulmedicalinsider.com |
|
||||
| C-02 | Best breast augmentation clinic in Korea for international patients | X | - | - | SAERO Plastic Surgery, JW Plastic Surgery Center, The Plus Plastic Surgery, AB Plastic Surgery, UNE Plastic Surgery, Global Plastic Surgery, Opera Plastic Surgery, JK Plastic Surgery Centre | clinicrankingkorea.com, kclinichelp.com, medicaldepartures.com, medicaltravelkorea.com, my1health.com, realself.com |
|
||||
| C-03 | Top facial contouring and V-line surgery clinics in Gangnam | O | 3 | positive | JK Plastic Surgery, Banobagi Plastic Surgery, VIEW Plastic Surgery, BRAUN Plastic Surgery, GIRIN Plastic Surgery, 345 Plastic Surgery, Ipche Plastic Surgery, Made Young Plastic Surgery Clinic | gangnambeautyguide.com, gangnamdrk.com, medtraveling.com, misooda.com, plasticsurgeryko.com, topclinicseoul.com |
|
||||
| C-04 | Best rhinoplasty surgeon in Seoul for foreigners | O | 7 | positive | AB Plastic Surgery, ONYX Plastic Surgery, WOOA Plastic Surgery & Skin Clinic, Banobagi Plastic Surgery, Girin Plastic Surgery, Onlif Plastic Surgery, Sketch Plastic Surgery, VIEW Plastic Surgery | deesseclinic.com, drsamrizk.com, gangnambeautyguide.com, gangnamwomenshealth.com, kclinichelp.com, koreabeautyclinicrating.com |
|
||||
| C-05 | Best double jaw surgery clinic in Korea | X | - | - | TJ Plastic Surgery, Sinsang Plastic and Reconstructive Surgery, Brown Eye Medical Clinic, Hannaeve Plastic Surgery, SoonPlus Plastic Surgery, Severance Hospital, Seoul National University Hospital (SNUH), Asan Medical Center | gangnam.dentist, jawsurgerykorea.com, koreaclinicguide.com, plasticsurgeryko.com, us-uk.bookimed.com |
|
||||
| C-06 | Top 5 plastic surgery clinics in Gangnam, Seoul | O | 5 | positive | 4Ever Plastic Surgery, DA Plastic Surgery, ARC Plastic Surgery, Noselab Clinic, VIEW Plastic Surgery, JW Plastic Surgery, ID Hospital, Lienjang Plastic Surgery | gangnambeautyguide.com, gangnamclinicindex.com, plasticsurgeryko.com |
|
||||
| C-07 | Compare VIEW Plastic Surgery vs ID Hospital vs Banobagi for facial contouring | O | 1 | positive | VIEW Plastic Surgery, ID Hospital, Banobagi | |
|
||||
| C-08 | Which Korean plastic surgery clinics use Motiva implants and have the most experience with them? | O | 2 | positive | U&U Plastic Surgery Clinic, VIEW Plastic Surgery, Ryan Plastic Surgery, V Clinic Gangnam, ARC Plastic Surgery Clinic, Verygood Plastic Surgery, NANA Plastic Surgery, Golden Diamant Plastic Surgery | en.nanahospital.com, goldendiamant.com, goodbyeacup.com, realself.com, reddit.com, unupskorea.com |
|
||||
| C-09 | Safest plastic surgery clinic in Korea with anesthesiologists on staff | X | - | - | AB Plastic Surgery Clinic, JK Plastic Surgery Center, Hannaeve Plastic Surgery Clinic, Faceline Plastic Surgery, Braun Plastic Surgery, Respect Plastic Surgery | braunps.com, hannaeveglobal.com, healthdailysouthkorea.com, icloudhospital.com, jkplastic.com, medicalkorea.or.kr |
|
||||
| C-10 | Which Seoul plastic surgery clinics are officially recognized for medical tourism by the Korean government? | O | 4 | positive | JK Plastic Surgery Center, HER SHE (HERSHE) Plastic Surgery, Hyundai Aesthetics Plastic Surgery, VIEW Plastic Surgery, HERSHE | data.edmgr.kr, english.visitkorea.or.kr, medical.visitseoul.net, medicalkorea.or.kr, medicaltour.gangnam.go.kr, news.seoul.go.kr |
|
||||
| C-11 | Best clinic in Korea for double eyelid surgery revision for foreigners | X | - | - | 1MM Plastic Surgery, Etonne Plastic Surgery, Wanna Plastic Surgery, Gangnam Seoyeon Clinic, Symphony Plastic Surgery, Link Plastic Surgery, Motive Plastic Surgery, Ahn Sung Min Plastic Surgery | bookkoreamedical.com, gangnamobgyn.com, gangnamwomenshealth.com, kclinichelp.com, plasticsurgeryko.com, reddit.com |
|
||||
| C-12 | Large plastic surgery hospitals in Seoul with in-house recovery wards | X | - | - | Banobagi Plastic Surgery Clinic, ID Hospital, JW Plastic Surgery, Dream Medical Group, Grand Plastic Surgery | |
|
||||
| D-01 | Motiva breast augmentation in Korea: what should I know before booking? | X | - | - | (none) | |
|
||||
| D-02 | What is Motiva Preserve and which clinics in Korea offer it? | X | - | - | U&U Plastic Surgery Clinic, Ryan Plastic Surgery, Golden Diamant Plastic Surgery, Wooa Medical | goldendiamant.com, goodbyeacup.com, imagepreserve.kr, motiva.health, unupskorea.com, wooamedical.com |
|
||||
| D-03 | How long is recovery after breast augmentation in Korea before I can fly? | X | - | - | (none) | |
|
||||
| D-04 | What is the difference between V-line surgery and square jaw reduction? | X | - | - | (none) | |
|
||||
| D-05 | Double jaw surgery in Korea: recovery timeline and risks for international patients | X | - | - | (none) | |
|
||||
| D-06 | Incisional vs non-incisional double eyelid surgery in Korea: which is better? | X | - | - | (none) | |
|
||||
| D-07 | Rhinoplasty in Korea with rib cartilage vs silicone: pros and cons | X | - | - | (none) | |
|
||||
| D-08 | How is revision rhinoplasty done in Korea and how long do I need to stay? | X | - | - | (none) | |
|
||||
| D-09 | Facelift in Korea for a 50-year-old: what techniques are used? | X | - | - | (none) | |
|
||||
| D-10 | Is liposuction in Korea safe and how many days of downtime should I expect? | X | - | - | (none) | |
|
||||
| D-11 | What does VIEW Plastic Surgery's breast surgery process look like from consultation to aftercare? | O | 1 | positive | VIEW Plastic Surgery | id.viewplasticsurgery.com, viewplasticsurgery.com |
|
||||
| D-12 | Does VIEW Plastic Surgery do a breast ultrasound or examination before augmentation? | O | 1 | positive | VIEW Plastic Surgery | origin.viewplasticsurgery.com |
|
||||
| D-13 | Subbrow lift vs upper blepharoplasty: which should I get in Korea? | X | - | - | (none) | |
|
||||
| D-14 | Mini lift vs full facelift in Korea: cost and recovery | O | 7 | - | SeoulFaceliftClinic, Gangnam.Health, medisou, seoulclinics, Korea Surgeon Hub, Arke Clinic, VIEW Plastic Surgery, arkeplasticsurgery | arkeplasticsurgery.com, gangnam.health, koreaplasticsurgery.org, medisou.com, misooda.com, seoulclinics.io |
|
||||
| E-01 | How much does breast augmentation cost in Korea for foreigners in 2026? | X | - | - | MISOODA, Medisou, Clinicsoncall, ClinicSeoul.net, Seoul Medical Insider, Gangnam Beauty Guide, VitalityScount, KoreMed Consulting | clinicseoul.net, clinicsoncall.com, gangnambeautyguide.com, koremedconsulting.com, medisou.com, misooda.com |
|
||||
| E-02 | V-line surgery cost in Korea 2026 | X | - | - | MISOODA, MedVoyage, Korea Med Guide, Seoul Medical Insider, AskGangnam, Medicakorea, VitalityScout, V-Line Surgery Korea | askgangnam.com, koreamedguide.com, medicakorea.blogspot.com, medvoyage.app, misooda.com, seoulmedicalinsider.com |
|
||||
| E-03 | How much does double jaw surgery cost in Seoul? | X | - | - | Gangnam Beauty Guide, MISOODA, SeoulMedicalInsider, DoubleJawSurgeryKorea, SeoulFacialSurgery, iCloudHospital, MedicalTravelCost, SeoulClinics | doublejawsurgerykorea.com, gangnambeautyguide.com, koreacliniccost.com, medicaltravelcost.com, medvoyage.app, misooda.com |
|
||||
| E-04 | Is plastic surgery in Korea cheaper than in the US? How much can I save? | X | - | - | (none) | |
|
||||
| E-05 | VIEW Plastic Surgery price list for international patients | X | - | - | (none) | |
|
||||
| E-06 | What hidden costs should I expect for plastic surgery in Korea as a foreigner? | X | - | - | (none) | |
|
||||
| E-07 | Can foreigners get a tax refund on plastic surgery in Korea? | X | - | - | (none) | |
|
||||
| E-08 | Rhinoplasty cost in Gangnam for foreigners | X | - | - | KoreaCareGuide, Gangnam Health, SeoulBeautyTalk, Gangnam Beauty Guide, CYL Blog, ClinicSeoul.net, PlacidWay, SeoulPlasticSurgeon.com | clinicseoul.net, cyl.co.kr, gangnam.health, gangnam.placidway.com, gangnambeauty.guide, gangnambeautyguide.com |
|
||||
| F-01 | Is plastic surgery in Korea safe for foreigners? What safety standards should I look for? | X | - | - | (none) | |
|
||||
| F-02 | Does VIEW Plastic Surgery have anesthesiologists present during surgery? | O | 1 | positive | VIEW Plastic Surgery | plasticsurgery.org, reddit.com |
|
||||
| F-03 | What happens if I have complications after returning home from plastic surgery in Korea? | X | - | - | (none) | |
|
||||
| F-04 | How do I verify a Korean plastic surgeon is board certified? | X | - | - | (none) | |
|
||||
| F-05 | Are there red flags or complaints about VIEW Plastic Surgery Korea? | O | 1 | negative | VIEW Plastic Surgery, DA Plastic Surgery, AB Plastic Surgery Clinics, SeoulKlinic | cashdoc.me, cerd.com, clinics.seoulklinic.com, clinicseoul.net, cyl.co.kr, korea.verabeauty.app |
|
||||
| F-06 | Which Korean plastic surgery clinics have a Motiva implant warranty? | X | - | - | Ryan Plastic Surgery, NANA Plastic Surgery, Wonjin Plastic Surgery Hospital, MD Clinic, Eight Plastic Surgery, U&U Plastic Surgery Clinic, Wooa Plastic Surgery | eightprs.co.kr, en.nanahospital.com, goodbyeacup.com, mdclinic.co.kr, motiva.health, motivakorea.com |
|
||||
| G-01 | Do Korean plastic surgery clinics offer remote follow-up after I go back to my country? | X | - | - | (none) | |
|
||||
| G-02 | What aftercare does VIEW Plastic Surgery provide for international patients? | O | 1 | positive | VIEW Plastic Surgery | |
|
||||
| G-03 | Where can I get post-surgery swelling care in Gangnam after surgery? | O | 7 | positive | ONUL Plastic Surgery & Dermatology, Withwin Dermatology, ROI Plastic Surgery, MINE Plastic Surgery, TheLINE Plastic Surgery, Pose Clinic, VIEW Plastic Surgery, TS Plastic Surgery | en.onulps.com, global.poseclinic.co.kr, hoev.net, mineclinic.com, reddit.com, roisurgery.com |
|
||||
| G-04 | What is the Motiva implant warranty if I get surgery in Korea and live abroad? | X | - | - | Motiva Korea, breastsurgerykorea.com | breastsurgerykorea.com, motiva.health, motivakorea.com, mtoz.motivaimplants.co.kr |
|
||||
|
||||
## Summary
|
||||
|
||||
- VIEW mentioned: 27/60 (45%)
|
||||
|
||||
| Category | Questions | VIEW mentioned | viewplasticsurgery.com cited |
|
||||
|---|---|---|---|
|
||||
| A Brand | 8 | 8 (100%) | 8 (100%) |
|
||||
| B Travel & logistics | 8 | 6 (75%) | 5 (62%) |
|
||||
| C Recommendation & comparison | 12 | 6 (50%) | 1 (8%) |
|
||||
| D Procedure info | 14 | 3 (21%) | 3 (21%) |
|
||||
| E Pricing | 8 | 0 (0%) | 0 (0%) |
|
||||
| F Safety & trust | 6 | 2 (33%) | 0 (0%) |
|
||||
| G Aftercare & remote follow-up | 4 | 2 (50%) | 1 (25%) |
|
||||
|
||||
### Clinic mention frequency (top 20)
|
||||
|
||||
| Clinic | Questions | First position |
|
||||
|---|---|---|
|
||||
| VIEW Plastic Surgery | 27 | 19 |
|
||||
| AB Plastic Surgery | 4 | 1 |
|
||||
| ID Hospital | 4 | 0 |
|
||||
| JK Plastic Surgery Center | 3 | 2 |
|
||||
| Banobagi Plastic Surgery | 3 | 0 |
|
||||
| U&U Plastic Surgery Clinic | 3 | 2 |
|
||||
| Ryan Plastic Surgery | 3 | 1 |
|
||||
| MISOODA | 3 | 2 |
|
||||
| Gangnam Beauty Guide | 3 | 1 |
|
||||
| Girin Plastic Surgery | 2 | 0 |
|
||||
| SAERO Plastic Surgery | 2 | 1 |
|
||||
| JW Plastic Surgery Center | 2 | 0 |
|
||||
| DA Plastic Surgery | 2 | 0 |
|
||||
| JW Plastic Surgery | 2 | 0 |
|
||||
| NANA Plastic Surgery | 2 | 0 |
|
||||
| Golden Diamant Plastic Surgery | 2 | 0 |
|
||||
| ClinicSeoul.net | 2 | 0 |
|
||||
| Seoul Medical Insider | 2 | 0 |
|
||||
| JUST Plastic Surgery Clinic | 1 | 1 |
|
||||
| 1 mm Plastic Surgery Clinic | 1 | 0 |
|
||||
|
||||
### Cited domains (top 25)
|
||||
|
||||
| Domain | Questions cited | Cited when VIEW mentioned |
|
||||
|---|---|---|
|
||||
| reddit.com | 16 | 13 |
|
||||
| viewplasticsurgery.com | 16 | 16 |
|
||||
| gangnambeautyguide.com | 6 | 3 |
|
||||
| misooda.com | 6 | 2 |
|
||||
| realself.com | 5 | 4 |
|
||||
| english.visitkorea.or.kr | 5 | 4 |
|
||||
| kclinichelp.com | 5 | 2 |
|
||||
| clinicseoul.net | 4 | 1 |
|
||||
| seoulmedicalinsider.com | 4 | 0 |
|
||||
| us-uk.bookimed.com | 4 | 0 |
|
||||
| plasticsurgeryko.com | 4 | 2 |
|
||||
| seoulclinics.io | 3 | 2 |
|
||||
| medicaltour.gangnam.go.kr | 3 | 3 |
|
||||
| khunkim.com | 3 | 3 |
|
||||
| assets.website-files.com | 3 | 3 |
|
||||
| goodbyeacup.com | 3 | 1 |
|
||||
| unupskorea.com | 3 | 1 |
|
||||
| motiva.health | 3 | 0 |
|
||||
| forum.purseblog.com | 2 | 2 |
|
||||
| gangnamclinicindex.com | 2 | 2 |
|
||||
| medtraveling.com | 2 | 2 |
|
||||
| icloudhospital.com | 2 | 1 |
|
||||
| origin.viewplasticsurgery.com | 2 | 2 |
|
||||
| seoulplasticsurgeon.com | 2 | 0 |
|
||||
| wuneprs.com | 2 | 0 |
|
||||
736
docs/reports/viewclinic/05_global_en/QB_en_openai_summary.json
Normal file
736
docs/reports/viewclinic/05_global_en/QB_en_openai_summary.json
Normal file
@ -0,0 +1,736 @@
|
||||
{
|
||||
"per_cat": {
|
||||
"A": [
|
||||
8,
|
||||
8,
|
||||
8
|
||||
],
|
||||
"B": [
|
||||
8,
|
||||
6,
|
||||
5
|
||||
],
|
||||
"C": [
|
||||
12,
|
||||
6,
|
||||
1
|
||||
],
|
||||
"D": [
|
||||
14,
|
||||
3,
|
||||
3
|
||||
],
|
||||
"E": [
|
||||
8,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"F": [
|
||||
6,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"G": [
|
||||
4,
|
||||
2,
|
||||
1
|
||||
]
|
||||
},
|
||||
"freq": {
|
||||
"VIEW Plastic Surgery": [
|
||||
27,
|
||||
19
|
||||
],
|
||||
"Girin Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"AB Plastic Surgery": [
|
||||
4,
|
||||
1
|
||||
],
|
||||
"JUST Plastic Surgery Clinic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"1 mm Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"345 Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Thank You Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"BETWEEN Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Noonopi Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"IK Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JK Plastic Surgery Center": [
|
||||
3,
|
||||
2
|
||||
],
|
||||
"ID Hospital": [
|
||||
4,
|
||||
0
|
||||
],
|
||||
"Banobagi Plastic Surgery": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"SAERO Plastic Surgery": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"NANA Plastic Surgery Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SNUPS Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JW Plastic Surgery Center": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"The Plus Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"UNE Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Global Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Opera Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JK Plastic Surgery Centre": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JK Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"BRAUN Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"GIRIN Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"345 Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Ipche Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Made Young Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ONYX Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"WOOA Plastic Surgery & Skin Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Onlif Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Sketch Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"TJ Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Sinsang Plastic and Reconstructive Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Brown Eye Medical Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Hannaeve Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SoonPlus Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Severance Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Seoul National University Hospital (SNUH)": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Asan Medical Center": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"4Ever Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"DA Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"ARC Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Noselab Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JW Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"Lienjang Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Banobagi": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"U&U Plastic Surgery Clinic": [
|
||||
3,
|
||||
2
|
||||
],
|
||||
"Ryan Plastic Surgery": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"V Clinic Gangnam": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ARC Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Verygood Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"NANA Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"Golden Diamant Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"AB Plastic Surgery Clinic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Hannaeve Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Faceline Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Braun Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Respect Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"HER SHE (HERSHE) Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Hyundai Aesthetics Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"HERSHE": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"1MM Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Etonne Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Wanna Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Gangnam Seoyeon Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Symphony Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Link Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Motive Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Ahn Sung Min Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Banobagi Plastic Surgery Clinic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Dream Medical Group": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Grand Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Wooa Medical": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulFaceliftClinic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Gangnam.Health": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"medisou": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"seoulclinics": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Korea Surgeon Hub": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Arke Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"arkeplasticsurgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MISOODA": [
|
||||
3,
|
||||
2
|
||||
],
|
||||
"Medisou": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Clinicsoncall": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ClinicSeoul.net": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"Seoul Medical Insider": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"Gangnam Beauty Guide": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"VitalityScount": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"KoreMed Consulting": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MedVoyage": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Korea Med Guide": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"AskGangnam": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Medicakorea": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"VitalityScout": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"V-Line Surgery Korea": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulMedicalInsider": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"DoubleJawSurgeryKorea": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulFacialSurgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"iCloudHospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MedicalTravelCost": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulClinics": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"KoreaCareGuide": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Gangnam Health": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulBeautyTalk": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"CYL Blog": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"PlacidWay": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulPlasticSurgeon.com": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"AB Plastic Surgery Clinics": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"SeoulKlinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Wonjin Plastic Surgery Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MD Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Eight Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Wooa Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ONUL Plastic Surgery & Dermatology": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Withwin Dermatology": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ROI Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MINE Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"TheLINE Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Pose Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"TS Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Motiva Korea": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"breastsurgerykorea.com": [
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
"dom_freq": {
|
||||
"forum.purseblog.com": 2,
|
||||
"gangnamclinicindex.com": 2,
|
||||
"medtraveling.com": 2,
|
||||
"realself.com": 5,
|
||||
"reddit.com": 16,
|
||||
"seoulclinics.io": 3,
|
||||
"viewplasticsurgery.com": 16,
|
||||
"english.visitkorea.or.kr": 5,
|
||||
"medicaltour.gangnam.go.kr": 3,
|
||||
"youtube.com": 1,
|
||||
"kmhglobal.com": 1,
|
||||
"khunkim.com": 3,
|
||||
"assets.website-files.com": 3,
|
||||
"docentpro.com": 1,
|
||||
"experts-beauty.com": 1,
|
||||
"icloudhospital.com": 2,
|
||||
"inflo.kr": 1,
|
||||
"inkoreanow.com": 1,
|
||||
"kclinichelp.com": 5,
|
||||
"patients.community": 1,
|
||||
"english.seoul.go.kr": 1,
|
||||
"incheon.go.kr": 1,
|
||||
"ivisitkorea.com": 1,
|
||||
"jusofind.kr": 1,
|
||||
"origin.viewplasticsurgery.com": 2,
|
||||
"rome2rio.com": 1,
|
||||
"jp.viewplasticsurgery.com": 1,
|
||||
"stmstyle.com": 1,
|
||||
"10mag.com": 1,
|
||||
"x.com": 1,
|
||||
"en.ikplastic.com": 1,
|
||||
"eng.btwps.com": 1,
|
||||
"medi-spec.com": 1,
|
||||
"noonopiclinic.com": 1,
|
||||
"rightme.kr": 1,
|
||||
"clinicseoul.net": 4,
|
||||
"koreaexperience.com": 1,
|
||||
"seoulmedicalinsider.com": 4,
|
||||
"seoulplasticsurgeon.com": 2,
|
||||
"us-uk.bookimed.com": 4,
|
||||
"wellmap.co": 1,
|
||||
"clinicrankingkorea.com": 1,
|
||||
"medicaldepartures.com": 1,
|
||||
"medicaltravelkorea.com": 1,
|
||||
"my1health.com": 1,
|
||||
"wuneprs.com": 2,
|
||||
"gangnambeautyguide.com": 6,
|
||||
"gangnamdrk.com": 1,
|
||||
"misooda.com": 6,
|
||||
"plasticsurgeryko.com": 4,
|
||||
"topclinicseoul.com": 1,
|
||||
"deesseclinic.com": 1,
|
||||
"drsamrizk.com": 1,
|
||||
"gangnamwomenshealth.com": 2,
|
||||
"koreabeautyclinicrating.com": 1,
|
||||
"medicaltourismchat.com": 1,
|
||||
"seoulvera.com": 2,
|
||||
"gangnam.dentist": 1,
|
||||
"jawsurgerykorea.com": 1,
|
||||
"koreaclinicguide.com": 1,
|
||||
"en.nanahospital.com": 2,
|
||||
"goldendiamant.com": 2,
|
||||
"goodbyeacup.com": 3,
|
||||
"unupskorea.com": 3,
|
||||
"vclinicglobal.kr": 1,
|
||||
"verygoodaesthetics.com": 1,
|
||||
"braunps.com": 1,
|
||||
"hannaeveglobal.com": 1,
|
||||
"healthdailysouthkorea.com": 1,
|
||||
"jkplastic.com": 1,
|
||||
"medicalkorea.or.kr": 2,
|
||||
"respectps.clinic": 1,
|
||||
"data.edmgr.kr": 1,
|
||||
"medical.visitseoul.net": 1,
|
||||
"news.seoul.go.kr": 1,
|
||||
"bookkoreamedical.com": 1,
|
||||
"gangnamobgyn.com": 1,
|
||||
"imagepreserve.kr": 1,
|
||||
"motiva.health": 3,
|
||||
"wooamedical.com": 1,
|
||||
"id.viewplasticsurgery.com": 1,
|
||||
"arkeplasticsurgery.com": 1,
|
||||
"gangnam.health": 2,
|
||||
"koreaplasticsurgery.org": 1,
|
||||
"medisou.com": 2,
|
||||
"seoulfaceliftclinic.com": 1,
|
||||
"clinicsoncall.com": 1,
|
||||
"koremedconsulting.com": 1,
|
||||
"vitalityscout.com": 2,
|
||||
"askgangnam.com": 1,
|
||||
"koreamedguide.com": 1,
|
||||
"medicakorea.blogspot.com": 1,
|
||||
"medvoyage.app": 2,
|
||||
"vlinesurgerykorea.com": 1,
|
||||
"doublejawsurgerykorea.com": 1,
|
||||
"koreacliniccost.com": 1,
|
||||
"medicaltravelcost.com": 1,
|
||||
"mowatool.com": 1,
|
||||
"seoulfacialsurgery.com": 1,
|
||||
"cyl.co.kr": 2,
|
||||
"gangnam.placidway.com": 1,
|
||||
"gangnambeauty.guide": 1,
|
||||
"koreacareguide.com": 1,
|
||||
"seoulbeautytalk.com": 2,
|
||||
"plasticsurgery.org": 1,
|
||||
"cashdoc.me": 1,
|
||||
"cerd.com": 1,
|
||||
"clinics.seoulklinic.com": 1,
|
||||
"korea.verabeauty.app": 1,
|
||||
"eightprs.co.kr": 1,
|
||||
"mdclinic.co.kr": 1,
|
||||
"motivakorea.com": 2,
|
||||
"wonjinbeauty.com": 1,
|
||||
"en.onulps.com": 1,
|
||||
"global.poseclinic.co.kr": 1,
|
||||
"hoev.net": 1,
|
||||
"mineclinic.com": 1,
|
||||
"roisurgery.com": 1,
|
||||
"thelineclinic.com": 1,
|
||||
"tsplasticsurgery.com": 1,
|
||||
"widwinskin.com": 1,
|
||||
"breastsurgerykorea.com": 1,
|
||||
"mtoz.motivaimplants.co.kr": 1
|
||||
},
|
||||
"view_dom": {
|
||||
"forum.purseblog.com": 2,
|
||||
"gangnamclinicindex.com": 2,
|
||||
"medtraveling.com": 2,
|
||||
"realself.com": 4,
|
||||
"reddit.com": 13,
|
||||
"seoulclinics.io": 2,
|
||||
"viewplasticsurgery.com": 16,
|
||||
"english.visitkorea.or.kr": 4,
|
||||
"medicaltour.gangnam.go.kr": 3,
|
||||
"youtube.com": 1,
|
||||
"kmhglobal.com": 1,
|
||||
"khunkim.com": 3,
|
||||
"assets.website-files.com": 3,
|
||||
"docentpro.com": 1,
|
||||
"experts-beauty.com": 1,
|
||||
"icloudhospital.com": 1,
|
||||
"inflo.kr": 1,
|
||||
"inkoreanow.com": 1,
|
||||
"kclinichelp.com": 2,
|
||||
"patients.community": 1,
|
||||
"english.seoul.go.kr": 1,
|
||||
"incheon.go.kr": 1,
|
||||
"ivisitkorea.com": 1,
|
||||
"jusofind.kr": 1,
|
||||
"origin.viewplasticsurgery.com": 2,
|
||||
"rome2rio.com": 1,
|
||||
"jp.viewplasticsurgery.com": 1,
|
||||
"stmstyle.com": 1,
|
||||
"10mag.com": 1,
|
||||
"x.com": 1,
|
||||
"en.ikplastic.com": 1,
|
||||
"eng.btwps.com": 1,
|
||||
"medi-spec.com": 1,
|
||||
"noonopiclinic.com": 1,
|
||||
"rightme.kr": 1,
|
||||
"gangnambeautyguide.com": 3,
|
||||
"gangnamdrk.com": 1,
|
||||
"misooda.com": 2,
|
||||
"plasticsurgeryko.com": 2,
|
||||
"topclinicseoul.com": 1,
|
||||
"deesseclinic.com": 1,
|
||||
"drsamrizk.com": 1,
|
||||
"gangnamwomenshealth.com": 1,
|
||||
"koreabeautyclinicrating.com": 1,
|
||||
"medicaltourismchat.com": 1,
|
||||
"seoulvera.com": 1,
|
||||
"en.nanahospital.com": 1,
|
||||
"goldendiamant.com": 1,
|
||||
"goodbyeacup.com": 1,
|
||||
"unupskorea.com": 1,
|
||||
"vclinicglobal.kr": 1,
|
||||
"verygoodaesthetics.com": 1,
|
||||
"data.edmgr.kr": 1,
|
||||
"medical.visitseoul.net": 1,
|
||||
"medicalkorea.or.kr": 1,
|
||||
"news.seoul.go.kr": 1,
|
||||
"id.viewplasticsurgery.com": 1,
|
||||
"arkeplasticsurgery.com": 1,
|
||||
"gangnam.health": 1,
|
||||
"koreaplasticsurgery.org": 1,
|
||||
"medisou.com": 1,
|
||||
"seoulfaceliftclinic.com": 1,
|
||||
"plasticsurgery.org": 1,
|
||||
"cashdoc.me": 1,
|
||||
"cerd.com": 1,
|
||||
"clinics.seoulklinic.com": 1,
|
||||
"clinicseoul.net": 1,
|
||||
"cyl.co.kr": 1,
|
||||
"korea.verabeauty.app": 1,
|
||||
"seoulbeautytalk.com": 1,
|
||||
"en.onulps.com": 1,
|
||||
"global.poseclinic.co.kr": 1,
|
||||
"hoev.net": 1,
|
||||
"mineclinic.com": 1,
|
||||
"roisurgery.com": 1,
|
||||
"thelineclinic.com": 1,
|
||||
"tsplasticsurgery.com": 1,
|
||||
"widwinskin.com": 1
|
||||
},
|
||||
"n": 60,
|
||||
"mentioned": 27
|
||||
}
|
||||
135
docs/reports/viewclinic/05_global_en/QB_en_perplexity_results.md
Normal file
135
docs/reports/viewclinic/05_global_en/QB_en_perplexity_results.md
Normal file
@ -0,0 +1,135 @@
|
||||
# VIEW Plastic Surgery EN Question Bank results: perplexity
|
||||
|
||||
Measured 2026-09-10 / 60 questions
|
||||
|
||||
| # | Question | VIEW mentioned | Rank | Sentiment | Clinics (in order) | Cited domains |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A-01 | Is VIEW Plastic Surgery in Seoul legit? Is it a trustworthy clinic for foreigners? | O | 1 | neutral | VIEW Plastic Surgery | clinicsoncall.com, curemeabroad.com, forum.purseblog.com, khunkim.com, koreaplasticsurgery.tistory.com, medtraveling.com |
|
||||
| A-02 | Tell me about VIEW Plastic Surgery in Gangnam, Korea. What are they known for? | O | 1 | positive | VIEW Plastic Surgery | 10mag.com, clinicsoncall.com, curemeabroad.com, english.visitkorea.or.kr, id.viewplasticsurgery.com, khunkim.com |
|
||||
| A-03 | What procedures does VIEW Plastic Surgery Korea specialize in? | O | 1 | - | VIEW Plastic Surgery | clinicsoncall.com, curemeabroad.com, english.visitkorea.or.kr, experts-medical.com, forum.purseblog.com, khunkim.com |
|
||||
| A-04 | Who is Dr. Choi Soon Woo of VIEW Plastic Surgery? Is he board certified? | O | 1 | positive | VIEW Plastic Surgery | cashdoc.me, clinicsoncall.com, id.viewplasticsurgery.com, khunkim.com, olden.viewplasticsurgery.com, us-uk.bookimed.com |
|
||||
| A-05 | How many surgeons does VIEW Plastic Surgery have and are they all board-certified plastic surgeons? | O | 1 | neutral | VIEW Plastic Surgery | academic.oup.com, clinicsoncall.com, ibisworld.com, khunkim.com, kmhglobal.com, madisonps.com |
|
||||
| A-06 | Does VIEW Plastic Surgery have English-speaking staff or interpreters? | O | 1 | positive | VIEW Plastic Surgery | clinicsoncall.com, curemeabroad.com, en.uniclinics.com, experts-medical.com, khunkim.com, linkedin.com |
|
||||
| A-07 | How long has VIEW Plastic Surgery been operating and how big is the clinic? | O | 1 | positive | VIEW Plastic Surgery | aeysurgery.com, curemeabroad.com, elclinics.com, english.visitkorea.or.kr, hospitals-travel.ru, id.viewplasticsurgery.com |
|
||||
| A-08 | VIEW Plastic Surgery Korea reviews from international patients: what do foreigners say? | O | - | - | (none) | abplasticsurgerykorea.com, aljazeera.com, clinicreviewkorea.com, clinicsoncall.com, community.icloudhospital.com, linkpskorea.com |
|
||||
| B-01 | Where is VIEW Plastic Surgery located and how do I get there from Incheon Airport? | O | 1 | - | VIEW Plastic Surgery | 10mag.com, clinicsoncall.com, curemeabroad.com, en.uniclinics.com, english.visitkorea.or.kr, id.viewplasticsurgery.com |
|
||||
| B-02 | Does VIEW Plastic Surgery offer airport pickup for international patients? | O | 1 | positive | VIEW Plastic Surgery | abplasticsurgerykorea.com, curemeabroad.com, en.uniclinics.com, forum.purseblog.com, medicalkorea.or.kr, medtour.help |
|
||||
| B-03 | What are VIEW Plastic Surgery's opening hours and can I book a consultation online from abroad? | O | 1 | - | VIEW Plastic Surgery | 152harleystreet.com, english.visitkorea.or.kr, healthgrades.com, id.viewplasticsurgery.com, khunkim.com, kmhglobal.com |
|
||||
| B-04 | Can I contact VIEW Plastic Surgery via WhatsApp or LINE for a consultation? | O | 1 | - | VIEW Plastic Surgery | experts-medical.com, facebook.com, instagram.com, medicalavenuekorea.com, reddit.com, viewplasticsurgery.com |
|
||||
| B-05 | Does VIEW Plastic Surgery provide accommodation or hotel partnerships for foreign patients? | O | 1 | positive | VIEW Plastic Surgery | abplasticsurgerykorea.com, clinicsoncall.com, curemeabroad.com, en.uniclinics.com, medicaltour.gangnam.go.kr, reddit.com |
|
||||
| B-06 | How long should I stay in Seoul after plastic surgery before flying home? | X | - | - | (none) | ava-ps.com, beautybutlerthailand.com, globalbeautyspot.com, hugo-ps-global.com, jivaka.kr, jwbeauty.net |
|
||||
| B-07 | Do I need a medical visa for plastic surgery in Korea as a tourist? | X | - | - | (none) | 365plasticsurgerykorea.com, better.medicaltourism.com, clinicseoul.net, clinicsoncall.com, cyl.co.kr, djmeditour.kr |
|
||||
| B-08 | Which Gangnam plastic surgery clinics are closest to Sinnonhyeon Station? | O | 3 | - | Lala Plastic Surgery Clinic, 345 Plastic Surgery, VIEW Plastic Surgery, Girin Plastic Surgery, MIHO Plastic Surgery, Noonopi Plastic Surgery, Glam Surgery, UE Plastic Surgery Clinic | clinicseoul.net, en.345-ps.com, en.nanahospital.com, gangnam.health, gangnamdrk.com, korea-clinics.com |
|
||||
| C-01 | Best plastic surgery clinic in Seoul for foreigners | O | 4 | positive | JK Plastic Surgery, ID Hospital, Banobagi Plastic Surgery, VIEW Plastic Surgery | 10mag.com, abplasticsurgerykorea.com, english.visitkorea.or.kr, expatguidekorea.com, gangnamwomenshealth.com, girinpsen.com |
|
||||
| C-02 | Best breast augmentation clinic in Korea for international patients | X | - | - | VG Plastic Surgery, SAERO Plastic Surgery, WOOA Plastic Surgery, JK Plastic Surgery, Verygood Plastic Surgery, CDU Plastic Surgery | clinicsoncall.com, experts-beauty.com, gangnamwomenshealth.com, jkplastic.com, koreaclinicguide.com, medicaldepartures.com |
|
||||
| C-03 | Top facial contouring and V-line surgery clinics in Gangnam | X | - | - | TOP Plastic Surgery, The PLUS Plastic Surgery, Glam Plastic Surgery, Seoul Cheil Plastic Surgery, ONE Cell Plastic Surgery, Very Good Plastic Surgery, SAERO Plastic Surgery, VG Plastic Surgery | cheongdam.skin, facialcontouringkorea.com, gangnam.health, gangnambeautyguide.com, gangnamdrk.com, gangnamwomenshealth.com |
|
||||
| C-04 | Best rhinoplasty surgeon in Seoul for foreigners | O | 1 | positive | VIEW Plastic Surgery, JW Plastic Surgery, LUHO Plastic Surgery, SAERO Plastic Surgery, ID Hospital | clinicsoncall.com, experts-beauty.com, experts-medical.com, gangnamrhinoplasty.com, gangnamwomenshealth.com, kowonplasticsurgery.com |
|
||||
| C-05 | Best double jaw surgery clinic in Korea | O | 2 | positive | ID Hospital, VIEW Plastic Surgery, JUM Oral and Maxillofacial Surgery, WHY Oral and Maxillofacial Surgery, The Face Dental / Seoul Face 21 Dental Hospital | doublejawsurgerykorea.com, en.whydental.co.kr, eng.idhospital.com, gangnam.dental, idhospital.com, koreaclinicguide.com |
|
||||
| C-06 | Top 5 plastic surgery clinics in Gangnam, Seoul | O | 5 | positive | JK Plastic Surgery, Opera Plastic Surgery, Banobagi Clinic, ID Hospital, VIEW Plastic Surgery | clinicseoul.net, en-ggps.com, en.chiups.com, experts-beauty.com, gangnambeautyguide.com, gangnamdrk.com |
|
||||
| C-07 | Compare VIEW Plastic Surgery vs ID Hospital vs Banobagi for facial contouring | O | 1 | positive | VIEW Plastic Surgery, ID Hospital, Banobagi | clinicsoncall.com, drspiegel.com, english.visitkorea.or.kr, id.viewplasticsurgery.com, khunkim.com, viewplasticsurgery.com |
|
||||
| C-08 | Which Korean plastic surgery clinics use Motiva implants and have the most experience with them? | O | 1 | positive | VIEW Plastic Surgery, Ryan Plastic Surgery, JK Plastic Surgery Center, DA Plastic Surgery, THE PLUS Plastic Surgery | beautymedtravel.com, daprseng.com, girinpsen.com, goodbyeacup.com, idhospital.com, jkplastic.com |
|
||||
| C-09 | Safest plastic surgery clinic in Korea with anesthesiologists on staff | O | 3 | positive | AB Plastic Surgery, JK Plastic Surgery, DA Plastic Surgery, VIEW Plastic Surgery, Verygood Plastic Surgery | abplasticsurgerykorea.com, daprseng.com, english.visitkorea.or.kr, experts-beauty.com, girinpsen.com, icloudhospital.com |
|
||||
| C-10 | Which Seoul plastic surgery clinics are officially recognized for medical tourism by the Korean government? | O | 1 | positive | VIEW Plastic Surgery, Banobagi Plastic Surgery, JK Plastic Surgery Center, Evita Clinic, GANGNAM JS Hospital, VG Plastic Surgery | better.medicaltourism.com, english.visitkorea.or.kr, evitaclinic.com, gangnamwomenshealth.com, koreaexperience.com, kr-webmagazine.com |
|
||||
| C-11 | Best clinic in Korea for double eyelid surgery revision for foreigners | X | - | - | Ahnsungmin Plastic Surgery, Sinsa L Plastic Surgery, 1mm Plastic Surgery, JK Plastic Surgery, Banobagi, Sinsael Plastic Surgery, Will Be Plastic Surgery, Very Good Plastic Surgery | ahnsungminplasticsurgery.com, clinicseoul.net, eng.banobagi.com, gangnamobgyn.com, lowerblepharoplastykorea.com, my1health.com |
|
||||
| C-12 | Large plastic surgery hospitals in Seoul with in-house recovery wards | O | 2 | positive | ID Hospital, VIEW Plastic Surgery, Grand Plastic Surgery, JK Plastic Surgery | 10mag.com, cmcseoul.or.kr, en.nanahospital.com, english.visitkorea.or.kr, experts-beauty.com, experts-medical.com |
|
||||
| D-01 | Motiva breast augmentation in Korea: what should I know before booking? | X | - | - | (none) | abplasticsurgerykorea.com, daprseng.com, girinpsen.com, graceclinic.co.kr, idhospital.com, jkplastic.com |
|
||||
| D-02 | What is Motiva Preserve and which clinics in Korea offer it? | X | - | - | (none) | agulloplasticsurgery.com, bodybyravi.com, bostoncenterforplasticsurgery.com, caloaesthetics.com, clnq.com, dallasplasticsurgery.pro |
|
||||
| D-03 | How long is recovery after breast augmentation in Korea before I can fly? | X | - | - | (none) | afittingexperience.com, gangnambreastreductionsurgery.com, globalbeautyspot.com, hayatmed.com, korea-clinics.com, koreaclinicguide.com |
|
||||
| D-04 | What is the difference between V-line surgery and square jaw reduction? | X | - | - | (none) | abplasticsurgerykorea.com, april31.com, cinderellaplasticsurgery.com, cyl.co.kr, deschamps-braly.com, docfinderkorea.com |
|
||||
| D-05 | Double jaw surgery in Korea: recovery timeline and risks for international patients | X | - | - | (none) | 365plasticsurgerykorea.com, advancedreconstruction.com, daprseng.com, docfinderkorea.com, eng.banobagi.com, eng.idhospital.com |
|
||||
| D-06 | Incisional vs non-incisional double eyelid surgery in Korea: which is better? | X | - | - | (none) | abplasticsurgerykorea.com, allaboutmei.com, clinicseoul.net, docfinderkorea.com, en.marbleps.com, eng.banobagi.com |
|
||||
| D-07 | Rhinoplasty in Korea with rib cartilage vs silicone: pros and cons | X | - | - | (none) | allureplasticsurgery.sg, answerplasticsurgery.com, asiancosmeticsurgery.com, braunps.com, clinicseoul.net, cyl.co.kr |
|
||||
| D-08 | How is revision rhinoplasty done in Korea and how long do I need to stay? | X | - | - | (none) | clinicsoncall.com, editionps.com, en.nanahospital.com, eng.banobagi.com, gangnamplastic.surgery, inkoreanow.com |
|
||||
| D-09 | Facelift in Korea for a 50-year-old: what techniques are used? | X | - | - | (none) | clinicseoul.net, docfinderkorea.com, en.nanahospital.com, gangnammedicalconcierge.com, gangnammedspa.com, gangnamskin.clinic |
|
||||
| D-10 | Is liposuction in Korea safe and how many days of downtime should I expect? | X | - | - | (none) | canvassresearch.com, clinicsoncall.com, diteplasticsurgery.com, evitaclinic.com, gangnamskin.clinic, jivaka.kr |
|
||||
| D-11 | What does VIEW Plastic Surgery's breast surgery process look like from consultation to aftercare? | O | 1 | positive | VIEW Plastic Surgery | kmhglobal.com, mayoclinic.org, medicalavenuekorea.com, plasticsurgery.org, viewplasticsurgery.com |
|
||||
| D-12 | Does VIEW Plastic Surgery do a breast ultrasound or examination before augmentation? | O | 1 | positive | VIEW Plastic Surgery | associationofbreastsurgery.org.uk, clinicsoncall.com, khunkim.com, mja.com.au, myguideseoul.com, plasticsurgery.org |
|
||||
| D-13 | Subbrow lift vs upper blepharoplasty: which should I get in Korea? | X | - | - | (none) | andps-global.co.kr, cdubeauty.co.kr, docfinderkorea.com, e-aaps.org, gangnamplastic.surgery, korea-plastic-surgery.com |
|
||||
| D-14 | Mini lift vs full facelift in Korea: cost and recovery | X | - | - | (none) | clinicseoul.net, cyl.co.kr, faceliftingseoul.com, gangnamdermatologyclinic.com, gangnamfaceliftsurgery.com, gangnammedicalconcierge.com |
|
||||
| E-01 | How much does breast augmentation cost in Korea for foreigners in 2026? | X | - | - | (none) | abplasticsurgerykorea.com, breastimplantkorea.com, clinicseoul.net, clinicsoncall.com, docfinderkorea.com, koreaclinicguide.com |
|
||||
| E-02 | V-line surgery cost in Korea 2026 | X | - | - | (none) | abplasticsurgerykorea.com, askgangnam.com, clinicseoul.net, docfinderkorea.com, dojagi-clinic.com, jivaka.kr |
|
||||
| E-03 | How much does double jaw surgery cost in Seoul? | X | - | - | ID Hospital | clinicseoul.net, dentistgangnam.com, docfinderkorea.com, eng.idhospital.com, icloudhospital.com, koreaclinicguide.com |
|
||||
| E-04 | Is plastic surgery in Korea cheaper than in the US? How much can I save? | X | - | - | (none) | aristocratps.com, clinicseoul.net, clinicsoncall.com, gangnamplastic.surgery, go.medicalavenuekorea.com, guide.kmedtour.com |
|
||||
| E-05 | VIEW Plastic Surgery price list for international patients | X | - | - | (none) | amedical.az, clinicadelrey.com, clinicseoul.net, clinicsoncall.com, deepplane.com, europesurgery.co.uk |
|
||||
| E-06 | What hidden costs should I expect for plastic surgery in Korea as a foreigner? | X | - | - | (none) | clinicseoul.net, clinicsoncall.com, koreaexperience.com, koreaplasticsurgerycost.com, my1health.com, reddit.com |
|
||||
| E-07 | Can foreigners get a tax refund on plastic surgery in Korea? | X | - | - | (none) | beautipin.com, blog.yeoshin.co.kr, eng.dsmc.or.kr:49910, english.visitkorea.or.kr, evitaclinic.com, forenaclinic.com |
|
||||
| E-08 | Rhinoplasty cost in Gangnam for foreigners | X | - | - | (none) | clinicseoul.net, gangnam.health, gangnambeautyguide.com, gangnamplastic.surgery, gangnamplasticsurgery.clinic, gangnamrhinoplasty.com |
|
||||
| F-01 | Is plastic surgery in Korea safe for foreigners? What safety standards should I look for? | X | - | - | (none) | abplasticsurgerykorea.com, aljazeera.com, canvassresearch.com, clinicseoul.net, clinicsoncall.com, gangnamplastics.com |
|
||||
| F-02 | Does VIEW Plastic Surgery have anesthesiologists present during surgery? | O | 1 | positive | VIEW Plastic Surgery | blog.viewclinic.com, clinicsoncall.com, id.viewplasticsurgery.com, khunkim.com, linkedin.com, plasticsurgery.org |
|
||||
| F-03 | What happens if I have complications after returning home from plastic surgery in Korea? | X | - | - | (none) | askgangnam.com, botgig.org, clinicsoncall.com, kca.go.kr, kj.nomardy.com, koreaclinicguide.com |
|
||||
| F-04 | How do I verify a Korean plastic surgeon is board certified? | X | - | - | (none) | abplasticsurgery.org, absurgery.org, akmclinic.com, cyl.co.kr, go.medicalavenuekorea.com, guide.kmedtour.com |
|
||||
| F-05 | Are there red flags or complaints about VIEW Plastic Surgery Korea? | O | 1 | negative | (none) | canvassresearch.com, clinicsoncall.com, cyl.co.kr, gangnamclinicindex.com, gangnamskinclinicreberry.com, jwbeauty.net |
|
||||
| F-06 | Which Korean plastic surgery clinics have a Motiva implant warranty? | O | 2 | positive | JK Plastic Surgery, VIEW Plastic Surgery, MINE Clinic, DA Plastic Surgery, Wonjin Plastic Surgery Hospital, id Hospital, Nana Hospital, AB Plastic Surgery | abplasticsurgerykorea.com, breastsurgerykorea.com, daprseng.com, drkim.com, en.nanahospital.com, goodbyeacup.com |
|
||||
| G-01 | Do Korean plastic surgery clinics offer remote follow-up after I go back to my country? | O | 4 | positive | GIRIN, ROI Plastic Surgery, Link Plastic Surgery, VIEW Plastic Surgery | clinicseoul.net, clinicsoncall.com, community.icloudhospital.com, curemeabroad.com, gangnam.health, girinpsen.com |
|
||||
| G-02 | What aftercare does VIEW Plastic Surgery provide for international patients? | O | 1 | positive | VIEW Plastic Surgery | clinicsoncall.com, koreaplasticsurgery.tistory.com, medicaltour.gangnam.go.kr, medicaltourism.com, medicaltravelkorea.com, pearlwellnesscenter.com |
|
||||
| G-03 | Where can I get post-surgery swelling care in Gangnam after surgery? | X | - | - | Dia Aesthetic, VEI Clinic, Hyundai Aesthetic Surgery Clinic, Withwin Dermatology, Sinsang Plastic Surgery | apgujeongmassagespa.com, blog.yeoshin.co.kr, daprseng.com, eng.banobagi.com, gangnamdermatologycenter.com, gangnamfootmassage.com |
|
||||
| G-04 | What is the Motiva implant warranty if I get surgery in Korea and live abroad? | X | - | - | (none) | carelyclinic.com, fortesmd.com, leelamundramd.com, motiva.health, motivaimplantatum.hu, motivausa.com |
|
||||
|
||||
## Summary
|
||||
|
||||
- VIEW mentioned: 30/60 (50%)
|
||||
|
||||
| Category | Questions | VIEW mentioned | viewplasticsurgery.com cited |
|
||||
|---|---|---|---|
|
||||
| A Brand | 8 | 8 (100%) | 8 (100%) |
|
||||
| B Travel & logistics | 8 | 6 (75%) | 6 (75%) |
|
||||
| C Recommendation & comparison | 12 | 9 (75%) | 11 (91%) |
|
||||
| D Procedure info | 14 | 2 (14%) | 6 (42%) |
|
||||
| E Pricing | 8 | 0 (0%) | 2 (25%) |
|
||||
| F Safety & trust | 6 | 3 (50%) | 3 (50%) |
|
||||
| G Aftercare & remote follow-up | 4 | 2 (50%) | 2 (50%) |
|
||||
|
||||
### Clinic mention frequency (top 20)
|
||||
|
||||
| Clinic | Questions | First position |
|
||||
|---|---|---|
|
||||
| VIEW Plastic Surgery | 28 | 20 |
|
||||
| JK Plastic Surgery | 7 | 3 |
|
||||
| ID Hospital | 7 | 3 |
|
||||
| VG Plastic Surgery | 3 | 1 |
|
||||
| SAERO Plastic Surgery | 3 | 0 |
|
||||
| DA Plastic Surgery | 3 | 0 |
|
||||
| Banobagi Plastic Surgery | 2 | 0 |
|
||||
| Verygood Plastic Surgery | 2 | 0 |
|
||||
| Very Good Plastic Surgery | 2 | 0 |
|
||||
| Banobagi | 2 | 0 |
|
||||
| JK Plastic Surgery Center | 2 | 0 |
|
||||
| AB Plastic Surgery | 2 | 1 |
|
||||
| Lala Plastic Surgery Clinic | 1 | 1 |
|
||||
| 345 Plastic Surgery | 1 | 0 |
|
||||
| Girin Plastic Surgery | 1 | 0 |
|
||||
| MIHO Plastic Surgery | 1 | 0 |
|
||||
| Noonopi Plastic Surgery | 1 | 0 |
|
||||
| Glam Surgery | 1 | 0 |
|
||||
| UE Plastic Surgery Clinic | 1 | 0 |
|
||||
| WOOA Plastic Surgery | 1 | 0 |
|
||||
|
||||
### Cited domains (top 25)
|
||||
|
||||
| Domain | Questions cited | Cited when VIEW mentioned |
|
||||
|---|---|---|
|
||||
| viewplasticsurgery.com | 38 | 30 |
|
||||
| reddit.com | 33 | 14 |
|
||||
| us-uk.bookimed.com | 28 | 15 |
|
||||
| clinicsoncall.com | 26 | 16 |
|
||||
| koreaclinicguide.com | 20 | 6 |
|
||||
| clinicseoul.net | 17 | 3 |
|
||||
| unni.app | 14 | 13 |
|
||||
| placidway.com | 14 | 4 |
|
||||
| khunkim.com | 13 | 11 |
|
||||
| vippskorea.com | 13 | 7 |
|
||||
| english.visitkorea.or.kr | 12 | 10 |
|
||||
| abplasticsurgerykorea.com | 12 | 6 |
|
||||
| realself.com | 11 | 6 |
|
||||
| koreaexperience.com | 10 | 4 |
|
||||
| curemeabroad.com | 9 | 9 |
|
||||
| linkpskorea.com | 9 | 2 |
|
||||
| my1health.com | 9 | 5 |
|
||||
| seoulmedicalinsider.com | 9 | 2 |
|
||||
| medicaltourismco.com | 9 | 4 |
|
||||
| seoulcosmeticsurgery.com | 8 | 1 |
|
||||
| seoulguidemedical.com | 8 | 1 |
|
||||
| jkplastic.com | 8 | 3 |
|
||||
| docfinderkorea.com | 8 | 0 |
|
||||
| id.viewplasticsurgery.com | 7 | 7 |
|
||||
| medicalavenuekorea.com | 7 | 6 |
|
||||
@ -0,0 +1,715 @@
|
||||
{
|
||||
"per_cat": {
|
||||
"A": [
|
||||
8,
|
||||
8,
|
||||
8
|
||||
],
|
||||
"B": [
|
||||
8,
|
||||
6,
|
||||
6
|
||||
],
|
||||
"C": [
|
||||
12,
|
||||
9,
|
||||
11
|
||||
],
|
||||
"D": [
|
||||
14,
|
||||
2,
|
||||
6
|
||||
],
|
||||
"E": [
|
||||
8,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"F": [
|
||||
6,
|
||||
3,
|
||||
3
|
||||
],
|
||||
"G": [
|
||||
4,
|
||||
2,
|
||||
2
|
||||
]
|
||||
},
|
||||
"freq": {
|
||||
"VIEW Plastic Surgery": [
|
||||
28,
|
||||
20
|
||||
],
|
||||
"Lala Plastic Surgery Clinic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"345 Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Girin Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MIHO Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Noonopi Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Glam Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"UE Plastic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JK Plastic Surgery": [
|
||||
7,
|
||||
3
|
||||
],
|
||||
"ID Hospital": [
|
||||
7,
|
||||
3
|
||||
],
|
||||
"Banobagi Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"VG Plastic Surgery": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"SAERO Plastic Surgery": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"WOOA Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Verygood Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"CDU Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"TOP Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"The PLUS Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Glam Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Seoul Cheil Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"ONE Cell Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Very Good Plastic Surgery": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"JW Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"LUHO Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JUM Oral and Maxillofacial Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"WHY Oral and Maxillofacial Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"The Face Dental / Seoul Face 21 Dental Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Opera Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Banobagi Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Banobagi": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"Ryan Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"JK Plastic Surgery Center": [
|
||||
2,
|
||||
0
|
||||
],
|
||||
"DA Plastic Surgery": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"THE PLUS Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"AB Plastic Surgery": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"Evita Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"GANGNAM JS Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Ahnsungmin Plastic Surgery": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"Sinsa L Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"1mm Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Sinsael Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Will Be Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Grand Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"MINE Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Wonjin Plastic Surgery Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"id Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Nana Hospital": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"GIRIN": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"ROI Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Link Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Dia Aesthetic": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"VEI Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Hyundai Aesthetic Surgery Clinic": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Withwin Dermatology": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"Sinsang Plastic Surgery": [
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
"dom_freq": {
|
||||
"clinicsoncall.com": 26,
|
||||
"curemeabroad.com": 9,
|
||||
"forum.purseblog.com": 3,
|
||||
"khunkim.com": 13,
|
||||
"koreaplasticsurgery.tistory.com": 2,
|
||||
"medtraveling.com": 4,
|
||||
"realself.com": 11,
|
||||
"reddit.com": 33,
|
||||
"viewplasticsurgery.com": 38,
|
||||
"10mag.com": 4,
|
||||
"english.visitkorea.or.kr": 12,
|
||||
"id.viewplasticsurgery.com": 7,
|
||||
"kosmedi.co.kr": 5,
|
||||
"medicalavenuekorea.com": 7,
|
||||
"unni.app": 14,
|
||||
"vn.viewplasticsurgery.com": 4,
|
||||
"experts-medical.com": 5,
|
||||
"kmhglobal.com": 4,
|
||||
"koreaclinicguide.com": 20,
|
||||
"medtour.help": 2,
|
||||
"myguideseoul.com": 6,
|
||||
"placidway.com": 14,
|
||||
"cashdoc.me": 1,
|
||||
"olden.viewplasticsurgery.com": 1,
|
||||
"us-uk.bookimed.com": 28,
|
||||
"academic.oup.com": 1,
|
||||
"ibisworld.com": 1,
|
||||
"madisonps.com": 1,
|
||||
"pmc.ncbi.nlm.nih.gov": 5,
|
||||
"theviewhospital.com": 3,
|
||||
"en.uniclinics.com": 4,
|
||||
"linkedin.com": 3,
|
||||
"medicaltour.gangnam.go.kr": 5,
|
||||
"aeysurgery.com": 1,
|
||||
"elclinics.com": 1,
|
||||
"hospitals-travel.ru": 1,
|
||||
"medica-tour.com": 1,
|
||||
"vinmec.com": 1,
|
||||
"abplasticsurgerykorea.com": 12,
|
||||
"aljazeera.com": 2,
|
||||
"clinicreviewkorea.com": 1,
|
||||
"community.icloudhospital.com": 2,
|
||||
"linkpskorea.com": 9,
|
||||
"mediglobus.com": 1,
|
||||
"seoulcosmeticsurgery.com": 8,
|
||||
"vippskorea.com": 13,
|
||||
"whatclinic.com": 4,
|
||||
"medicalkorea.or.kr": 2,
|
||||
"my1health.com": 9,
|
||||
"secondmin.co.kr": 2,
|
||||
"seoulmedicalinsider.com": 9,
|
||||
"152harleystreet.com": 1,
|
||||
"healthgrades.com": 1,
|
||||
"pittsburghplasticsurgery.com": 1,
|
||||
"uamshealth.com": 1,
|
||||
"uvmhealth.org": 1,
|
||||
"viewskinclinic.com": 1,
|
||||
"facebook.com": 1,
|
||||
"instagram.com": 1,
|
||||
"x.com": 1,
|
||||
"youtube.com": 1,
|
||||
"ava-ps.com": 1,
|
||||
"beautybutlerthailand.com": 1,
|
||||
"globalbeautyspot.com": 2,
|
||||
"hugo-ps-global.com": 1,
|
||||
"jivaka.kr": 4,
|
||||
"jwbeauty.net": 3,
|
||||
"k-medlinker.com": 3,
|
||||
"kbeauty.today79.com": 1,
|
||||
"kittoplasticsurgery.com": 1,
|
||||
"koreaexperience.com": 10,
|
||||
"seoulbeautytalk.com": 6,
|
||||
"seouleyeplastics.com": 4,
|
||||
"soonplusplasticsurgery.com": 3,
|
||||
"365plasticsurgerykorea.com": 2,
|
||||
"better.medicaltourism.com": 2,
|
||||
"clinicseoul.net": 17,
|
||||
"cyl.co.kr": 6,
|
||||
"djmeditour.kr": 1,
|
||||
"gangnamplastic.surgery": 5,
|
||||
"seoulguidemedical.com": 8,
|
||||
"thetreatmentregistry.com": 1,
|
||||
"en.345-ps.com": 1,
|
||||
"en.nanahospital.com": 5,
|
||||
"gangnam.health": 4,
|
||||
"gangnamdrk.com": 3,
|
||||
"korea-clinics.com": 3,
|
||||
"koreacareguide.com": 1,
|
||||
"noonopiclinic.com": 5,
|
||||
"triple.global": 1,
|
||||
"expatguidekorea.com": 1,
|
||||
"gangnamwomenshealth.com": 5,
|
||||
"girinpsen.com": 6,
|
||||
"medicaltourismco.com": 9,
|
||||
"medicaltravelkorea.com": 6,
|
||||
"experts-beauty.com": 5,
|
||||
"jkplastic.com": 8,
|
||||
"medicaldepartures.com": 3,
|
||||
"theplusps.co": 7,
|
||||
"wooaeng.com": 1,
|
||||
"cheongdam.skin": 1,
|
||||
"facialcontouringkorea.com": 2,
|
||||
"gangnambeautyguide.com": 4,
|
||||
"medicalguide.co.kr": 1,
|
||||
"seouldoctorfillers.com": 1,
|
||||
"seoulskin.clinic": 1,
|
||||
"tatlerasia.com": 1,
|
||||
"gangnamrhinoplasty.com": 2,
|
||||
"kowonplasticsurgery.com": 2,
|
||||
"rhinoplastyclinicseoul.com": 1,
|
||||
"doublejawsurgerykorea.com": 1,
|
||||
"en.whydental.co.kr": 1,
|
||||
"eng.idhospital.com": 4,
|
||||
"gangnam.dental": 1,
|
||||
"idhospital.com": 7,
|
||||
"plasticsurgeryko.com": 1,
|
||||
"thebestoms.com": 1,
|
||||
"wonjinbeauty.com": 4,
|
||||
"en-ggps.com": 1,
|
||||
"en.chiups.com": 1,
|
||||
"gangnamplasticsurgery.clinic": 2,
|
||||
"seoulcp.com": 1,
|
||||
"drspiegel.com": 2,
|
||||
"beautymedtravel.com": 1,
|
||||
"daprseng.com": 6,
|
||||
"goodbyeacup.com": 2,
|
||||
"jy-clinic.com": 2,
|
||||
"mineclinic.com": 4,
|
||||
"motiva.health": 4,
|
||||
"seojinps.co": 2,
|
||||
"icloudhospital.com": 2,
|
||||
"kculture.com": 1,
|
||||
"natlawreview.com": 1,
|
||||
"thelineclinic.com": 1,
|
||||
"woori-ps.com": 1,
|
||||
"evitaclinic.com": 3,
|
||||
"kr-webmagazine.com": 1,
|
||||
"visitgangnam.net": 1,
|
||||
"ahnsungminplasticsurgery.com": 1,
|
||||
"eng.banobagi.com": 5,
|
||||
"gangnamobgyn.com": 1,
|
||||
"lowerblepharoplastykorea.com": 1,
|
||||
"seoulobgyn.com": 1,
|
||||
"seoulplasticsurgeon.com": 2,
|
||||
"cmcseoul.or.kr": 1,
|
||||
"medigence.com": 2,
|
||||
"graceclinic.co.kr": 1,
|
||||
"motivaimplants.co.kr": 1,
|
||||
"mydockorea.com": 3,
|
||||
"sketchplasticsurgery.com": 1,
|
||||
"agulloplasticsurgery.com": 1,
|
||||
"bodybyravi.com": 1,
|
||||
"bostoncenterforplasticsurgery.com": 1,
|
||||
"caloaesthetics.com": 1,
|
||||
"clnq.com": 1,
|
||||
"dallasplasticsurgery.pro": 1,
|
||||
"drchasan.com": 1,
|
||||
"foizahmed.co.uk": 1,
|
||||
"forbes.com": 1,
|
||||
"kapadiaplasticsurgery.com": 1,
|
||||
"klinikavisnjar.si": 1,
|
||||
"metropolitanplasticsurgery.com": 1,
|
||||
"newvuemedical.com": 1,
|
||||
"plasticsurgeryhub.com": 1,
|
||||
"afittingexperience.com": 1,
|
||||
"gangnambreastreductionsurgery.com": 1,
|
||||
"hayatmed.com": 1,
|
||||
"partingtonps.com": 1,
|
||||
"patients.community": 1,
|
||||
"wuneprs.com": 2,
|
||||
"april31.com": 1,
|
||||
"cinderellaplasticsurgery.com": 1,
|
||||
"deschamps-braly.com": 1,
|
||||
"docfinderkorea.com": 8,
|
||||
"dr-mfo.com": 1,
|
||||
"faceplusplasticsurgery.com": 1,
|
||||
"facepluspsi.com": 1,
|
||||
"healthline.com": 1,
|
||||
"seoulguidemedical.id": 1,
|
||||
"thefacedental.com": 1,
|
||||
"thevirginiainstitute.com": 1,
|
||||
"umiclinickorea.com": 1,
|
||||
"waveplasticsurgery.com": 1,
|
||||
"advancedreconstruction.com": 1,
|
||||
"oregonoralsurgery.com": 1,
|
||||
"shinmedical.com": 1,
|
||||
"allaboutmei.com": 1,
|
||||
"en.marbleps.com": 1,
|
||||
"ifaas.co": 1,
|
||||
"made-young.co": 1,
|
||||
"allureplasticsurgery.sg": 1,
|
||||
"answerplasticsurgery.com": 1,
|
||||
"asiancosmeticsurgery.com": 1,
|
||||
"braunps.com": 1,
|
||||
"drrahulgogoi.com": 1,
|
||||
"koreanplasticsurgery.info": 2,
|
||||
"rhinoplastytoronto.ca": 1,
|
||||
"editionps.com": 1,
|
||||
"inkoreanow.com": 3,
|
||||
"gangnammedicalconcierge.com": 2,
|
||||
"gangnammedspa.com": 1,
|
||||
"gangnamskin.clinic": 2,
|
||||
"koreafacelift.com": 3,
|
||||
"koreaplastic.surgery": 2,
|
||||
"medicalkoreaservice.com": 2,
|
||||
"rnwoodclinickorea.com": 2,
|
||||
"seoulclinics.io": 6,
|
||||
"canvassresearch.com": 3,
|
||||
"diteplasticsurgery.com": 1,
|
||||
"meclinic.net": 1,
|
||||
"news.sbs.co.kr": 2,
|
||||
"seoulliposuctionclinic.com": 1,
|
||||
"mayoclinic.org": 1,
|
||||
"plasticsurgery.org": 3,
|
||||
"associationofbreastsurgery.org.uk": 1,
|
||||
"mja.com.au": 1,
|
||||
"pubmed.ncbi.nlm.nih.gov": 1,
|
||||
"southernplasticsurgery.com": 1,
|
||||
"andps-global.co.kr": 1,
|
||||
"cdubeauty.co.kr": 1,
|
||||
"e-aaps.org": 1,
|
||||
"korea-plastic-surgery.com": 1,
|
||||
"faceliftingseoul.com": 1,
|
||||
"gangnamdermatologyclinic.com": 1,
|
||||
"gangnamfaceliftsurgery.com": 1,
|
||||
"seoulfaceliftclinic.com": 1,
|
||||
"sinsangps.com": 2,
|
||||
"tj-plasticsurgery.com": 1,
|
||||
"yujinplasticsurgery.com": 1,
|
||||
"breastimplantkorea.com": 1,
|
||||
"koreamedguide.com": 1,
|
||||
"misooda.com": 4,
|
||||
"seoulmedicalguide.com": 1,
|
||||
"askgangnam.com": 2,
|
||||
"dojagi-clinic.com": 1,
|
||||
"myseoulclinic.com": 1,
|
||||
"dentistgangnam.com": 1,
|
||||
"seoul.clinic": 1,
|
||||
"seoul.dentist": 1,
|
||||
"seoulfacialsurgery.com": 1,
|
||||
"aristocratps.com": 1,
|
||||
"go.medicalavenuekorea.com": 2,
|
||||
"guide.kmedtour.com": 2,
|
||||
"koreaplus-lifes.com": 1,
|
||||
"lydianclinic.com": 1,
|
||||
"amedical.az": 1,
|
||||
"clinicadelrey.com": 1,
|
||||
"deepplane.com": 1,
|
||||
"europesurgery.co.uk": 1,
|
||||
"flymedi.com": 1,
|
||||
"flytocure.com": 1,
|
||||
"health-tourism.com": 1,
|
||||
"koreaplasticsurgerycost.com": 2,
|
||||
"le-medica.com": 1,
|
||||
"medicaltourism.com": 2,
|
||||
"surgery.wustl.edu": 1,
|
||||
"vitalityscout.com": 2,
|
||||
"beautipin.com": 1,
|
||||
"blog.yeoshin.co.kr": 2,
|
||||
"eng.dsmc.or.kr:49910": 1,
|
||||
"forenaclinic.com": 1,
|
||||
"independent.co.uk": 1,
|
||||
"koreaherald.com": 1,
|
||||
"koreatimes.co.kr": 1,
|
||||
"myeongdong.clinic": 1,
|
||||
"vatupdate.com": 1,
|
||||
"stdclinicseoul.com": 1,
|
||||
"gangnamplastics.com": 1,
|
||||
"medinkorea.com": 1,
|
||||
"plasticsurgeryguidekorea.com": 1,
|
||||
"ranker.kr": 1,
|
||||
"reuters.com": 2,
|
||||
"blog.viewclinic.com": 1,
|
||||
"botgig.org": 1,
|
||||
"kca.go.kr": 1,
|
||||
"kj.nomardy.com": 1,
|
||||
"mk.co.kr": 1,
|
||||
"scmp.com": 1,
|
||||
"straitstimes.com": 1,
|
||||
"abplasticsurgery.org": 1,
|
||||
"absurgery.org": 1,
|
||||
"akmclinic.com": 1,
|
||||
"labelleviecosmetic.com": 1,
|
||||
"lupielclinic.com": 1,
|
||||
"plasticsurgery.or.kr": 1,
|
||||
"theaestheticsociety.org": 1,
|
||||
"willplasticsugery.com": 1,
|
||||
"gangnamclinicindex.com": 1,
|
||||
"gangnamskinclinicreberry.com": 1,
|
||||
"breastsurgerykorea.com": 1,
|
||||
"drkim.com": 1,
|
||||
"motivausa.com": 2,
|
||||
"tcplasticsurgery.com": 2,
|
||||
"mediqueen.net": 1,
|
||||
"roisurgery.com": 1,
|
||||
"pearlwellnesscenter.com": 1,
|
||||
"apgujeongmassagespa.com": 1,
|
||||
"gangnamdermatologycenter.com": 1,
|
||||
"gangnamfootmassage.com": 1,
|
||||
"hyundaisurgery.com": 1,
|
||||
"medicinenet.com": 1,
|
||||
"okayps.co": 1,
|
||||
"seoulantiagingclinic.com": 1,
|
||||
"widwinskin.com": 1,
|
||||
"carelyclinic.com": 1,
|
||||
"fortesmd.com": 1,
|
||||
"leelamundramd.com": 1,
|
||||
"motivaimplantatum.hu": 1,
|
||||
"support.motiva.health": 1
|
||||
},
|
||||
"view_dom": {
|
||||
"clinicsoncall.com": 16,
|
||||
"curemeabroad.com": 9,
|
||||
"forum.purseblog.com": 3,
|
||||
"khunkim.com": 11,
|
||||
"koreaplasticsurgery.tistory.com": 2,
|
||||
"medtraveling.com": 4,
|
||||
"realself.com": 6,
|
||||
"reddit.com": 14,
|
||||
"viewplasticsurgery.com": 30,
|
||||
"10mag.com": 4,
|
||||
"english.visitkorea.or.kr": 10,
|
||||
"id.viewplasticsurgery.com": 7,
|
||||
"kosmedi.co.kr": 4,
|
||||
"medicalavenuekorea.com": 6,
|
||||
"unni.app": 13,
|
||||
"vn.viewplasticsurgery.com": 4,
|
||||
"experts-medical.com": 5,
|
||||
"kmhglobal.com": 4,
|
||||
"koreaclinicguide.com": 6,
|
||||
"medtour.help": 2,
|
||||
"myguideseoul.com": 6,
|
||||
"placidway.com": 4,
|
||||
"cashdoc.me": 1,
|
||||
"olden.viewplasticsurgery.com": 1,
|
||||
"us-uk.bookimed.com": 15,
|
||||
"academic.oup.com": 1,
|
||||
"ibisworld.com": 1,
|
||||
"madisonps.com": 1,
|
||||
"pmc.ncbi.nlm.nih.gov": 2,
|
||||
"theviewhospital.com": 3,
|
||||
"en.uniclinics.com": 4,
|
||||
"linkedin.com": 3,
|
||||
"medicaltour.gangnam.go.kr": 5,
|
||||
"aeysurgery.com": 1,
|
||||
"elclinics.com": 1,
|
||||
"hospitals-travel.ru": 1,
|
||||
"medica-tour.com": 1,
|
||||
"vinmec.com": 1,
|
||||
"abplasticsurgerykorea.com": 6,
|
||||
"aljazeera.com": 1,
|
||||
"clinicreviewkorea.com": 1,
|
||||
"community.icloudhospital.com": 2,
|
||||
"linkpskorea.com": 2,
|
||||
"mediglobus.com": 1,
|
||||
"seoulcosmeticsurgery.com": 1,
|
||||
"vippskorea.com": 7,
|
||||
"whatclinic.com": 3,
|
||||
"medicalkorea.or.kr": 1,
|
||||
"my1health.com": 5,
|
||||
"secondmin.co.kr": 2,
|
||||
"seoulmedicalinsider.com": 2,
|
||||
"152harleystreet.com": 1,
|
||||
"healthgrades.com": 1,
|
||||
"pittsburghplasticsurgery.com": 1,
|
||||
"uamshealth.com": 1,
|
||||
"uvmhealth.org": 1,
|
||||
"viewskinclinic.com": 1,
|
||||
"facebook.com": 1,
|
||||
"instagram.com": 1,
|
||||
"x.com": 1,
|
||||
"youtube.com": 1,
|
||||
"clinicseoul.net": 3,
|
||||
"en.345-ps.com": 1,
|
||||
"en.nanahospital.com": 3,
|
||||
"gangnam.health": 2,
|
||||
"gangnamdrk.com": 2,
|
||||
"korea-clinics.com": 1,
|
||||
"koreacareguide.com": 1,
|
||||
"noonopiclinic.com": 1,
|
||||
"triple.global": 1,
|
||||
"expatguidekorea.com": 1,
|
||||
"gangnamwomenshealth.com": 3,
|
||||
"girinpsen.com": 4,
|
||||
"koreaexperience.com": 4,
|
||||
"medicaltourismco.com": 4,
|
||||
"medicaltravelkorea.com": 4,
|
||||
"experts-beauty.com": 4,
|
||||
"gangnamrhinoplasty.com": 1,
|
||||
"kowonplasticsurgery.com": 1,
|
||||
"rhinoplastyclinicseoul.com": 1,
|
||||
"theplusps.co": 2,
|
||||
"doublejawsurgerykorea.com": 1,
|
||||
"en.whydental.co.kr": 1,
|
||||
"eng.idhospital.com": 1,
|
||||
"gangnam.dental": 1,
|
||||
"idhospital.com": 4,
|
||||
"plasticsurgeryko.com": 1,
|
||||
"thebestoms.com": 1,
|
||||
"wonjinbeauty.com": 2,
|
||||
"en-ggps.com": 1,
|
||||
"en.chiups.com": 1,
|
||||
"gangnambeautyguide.com": 1,
|
||||
"gangnamplasticsurgery.clinic": 1,
|
||||
"seoulcp.com": 1,
|
||||
"drspiegel.com": 1,
|
||||
"beautymedtravel.com": 1,
|
||||
"daprseng.com": 3,
|
||||
"goodbyeacup.com": 2,
|
||||
"jkplastic.com": 3,
|
||||
"jy-clinic.com": 1,
|
||||
"mineclinic.com": 2,
|
||||
"motiva.health": 2,
|
||||
"seojinps.co": 1,
|
||||
"icloudhospital.com": 1,
|
||||
"kculture.com": 1,
|
||||
"natlawreview.com": 1,
|
||||
"seoulbeautytalk.com": 2,
|
||||
"thelineclinic.com": 1,
|
||||
"woori-ps.com": 1,
|
||||
"better.medicaltourism.com": 1,
|
||||
"evitaclinic.com": 1,
|
||||
"kr-webmagazine.com": 1,
|
||||
"seoulguidemedical.com": 1,
|
||||
"visitgangnam.net": 1,
|
||||
"cmcseoul.or.kr": 1,
|
||||
"medicaldepartures.com": 2,
|
||||
"medigence.com": 1,
|
||||
"mayoclinic.org": 1,
|
||||
"plasticsurgery.org": 3,
|
||||
"associationofbreastsurgery.org.uk": 1,
|
||||
"mja.com.au": 1,
|
||||
"pubmed.ncbi.nlm.nih.gov": 1,
|
||||
"southernplasticsurgery.com": 1,
|
||||
"blog.viewclinic.com": 1,
|
||||
"canvassresearch.com": 1,
|
||||
"cyl.co.kr": 1,
|
||||
"gangnamclinicindex.com": 1,
|
||||
"gangnamskinclinicreberry.com": 1,
|
||||
"jwbeauty.net": 1,
|
||||
"news.sbs.co.kr": 1,
|
||||
"breastsurgerykorea.com": 1,
|
||||
"drkim.com": 1,
|
||||
"motivausa.com": 1,
|
||||
"tcplasticsurgery.com": 1,
|
||||
"mediqueen.net": 1,
|
||||
"rnwoodclinickorea.com": 1,
|
||||
"roisurgery.com": 1,
|
||||
"medicaltourism.com": 1,
|
||||
"pearlwellnesscenter.com": 1
|
||||
},
|
||||
"n": 60,
|
||||
"mentioned": 30
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
192
docs/reports/viewclinic/05_global_en/audit_en_auto.json
Normal file
192
docs/reports/viewclinic/05_global_en/audit_en_auto.json
Normal file
@ -0,0 +1,192 @@
|
||||
{
|
||||
"_meta": {
|
||||
"sampleUrls": [
|
||||
"https://www.viewplasticsurgery.com",
|
||||
"https://www.viewplasticsurgery.com/breast/breast-augmentation-motiva.html"
|
||||
],
|
||||
"sampleWarning": "표본 URL 2개 (<5). 판정은 홈·대표 시술 페이지 기준이며 사이트 전체로 일반화하지 말 것. 시술 상세 3개·오시는길·블로그를 curl+파서로 추가 확인할 것 (2026-08-28 뷰성형외과 오판 3건의 원인)"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"criterionId": "A1",
|
||||
"level": 3,
|
||||
"evidence": "12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200",
|
||||
"source": "robots.txt: User-agent: * / Disallow: /wp-admin/"
|
||||
},
|
||||
{
|
||||
"criterionId": "A2",
|
||||
"level": 2,
|
||||
"evidence": "CDN 사용(Apache/2.4.68 (Debian)), AI UA 차단 없음(전부 200)"
|
||||
},
|
||||
{
|
||||
"criterionId": "A3",
|
||||
"level": 3,
|
||||
"evidence": "서버 렌더링. JS 미실행 HTML에서 본문 텍스트 79,646자 추출"
|
||||
},
|
||||
{
|
||||
"criterionId": "A4",
|
||||
"level": 3,
|
||||
"evidence": "http://viewplasticsurgery.com → 301 → https://www.viewplasticsurgery.com/ 정본 수렴 (4변형 모두)"
|
||||
},
|
||||
{
|
||||
"criterionId": "A5",
|
||||
"level": 3,
|
||||
"evidence": "robots.txt에 sitemap 선언, 200 응답. URL 482개, lastmod 최신 2026-09-09"
|
||||
},
|
||||
{
|
||||
"criterionId": "A6",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)"
|
||||
},
|
||||
{
|
||||
"criterionId": "A7",
|
||||
"level": 2,
|
||||
"evidence": "TTFB 0.870s / 초기 HTML 587KB. 시술 페이지 212KB (TTFB 양호 / HTML 비대)"
|
||||
},
|
||||
{
|
||||
"criterionId": "B1",
|
||||
"level": 3,
|
||||
"evidence": "JSON-LD 1블록 전부 유효 + @graph 연결"
|
||||
},
|
||||
{
|
||||
"criterionId": "B2",
|
||||
"level": 0,
|
||||
"evidence": "업종 엔티티 스키마 없음 (발견 타입: CollectionPage, Person, WebSite)"
|
||||
},
|
||||
{
|
||||
"criterionId": "B3",
|
||||
"level": 0,
|
||||
"evidence": "Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재"
|
||||
},
|
||||
{
|
||||
"criterionId": "B4",
|
||||
"level": "unverified",
|
||||
"evidence": "주소 자동 추출 실패 (수동 확인 필요). 전화 4종: 02-539-1132, 02-2138-1305, 023480-3573"
|
||||
},
|
||||
{
|
||||
"criterionId": "B5",
|
||||
"level": 0,
|
||||
"evidence": "Organization.sameAs: [] (빈 배열). 푸터/본문에 SNS 링크 2종(www.instagram.com, www.tiktok.com) — 스키마에 미연결"
|
||||
},
|
||||
{
|
||||
"criterionId": "B6",
|
||||
"level": 2,
|
||||
"evidence": "rel=canonical self-referencing 존재"
|
||||
},
|
||||
{
|
||||
"criterionId": "B7",
|
||||
"level": 3,
|
||||
"evidence": "hreflang 10개 언어 연결: ar, en-us, es, id-id, ja-jp, mn-mn, ru-ru, th-th, vi-vn, zh-cn"
|
||||
},
|
||||
{
|
||||
"criterionId": "C1",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 메인 첫 200자: \"Best Plastic Surgery in Korea for Foreigners | VIEW Seoul Gangnam korea plastic surgery Introduction…\" / 시술 페이지 첫 200자: \"Motiva Breast Augmentation Korea | KOL Clinic | VIEW Plastic Surgery korea plast…\" (첫 200자 내 상호 없음)"
|
||||
},
|
||||
{
|
||||
"criterionId": "C2",
|
||||
"level": 2,
|
||||
"evidence": "FAQ 섹션 존재: \"VIEW Breast Surgery FAQ\" 외 0개. FAQPage 스키마 없음"
|
||||
},
|
||||
{
|
||||
"criterionId": "C3",
|
||||
"level": 2,
|
||||
"evidence": "H1 1개: \"korea plastic surgery\". H2 중복 0건 또는 라벨형"
|
||||
},
|
||||
{
|
||||
"criterionId": "C4",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 시술 페이지 수치 밀도: 가격표기 0회, 시간 0회, 일/주 0회, % 2회, 가격/비용/회복 단어 0회 — 표기 맥락(마케팅 문구 vs 실제 표) 확인 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "C5",
|
||||
"level": 3,
|
||||
"evidence": "HTML 표 10개"
|
||||
},
|
||||
{
|
||||
"criterionId": "C6",
|
||||
"level": 2,
|
||||
"evidence": "최신 갱신 신호 2026-09-09 (sitemap lastmod 2026-09-09). dateModified 마크업 없음"
|
||||
},
|
||||
{
|
||||
"criterionId": "C7",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 전문가 언급 0회, 권위 키워드 13회 (KOL) — 자격·경력이 텍스트인지 이미지인지 확인 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "C8",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "D1",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] 업종 버티컬(병의원: 강남언니 / 법률: 로톡) 프로필 수동 실측 필요 — 후기 수·의료진·이벤트·갱신일"
|
||||
},
|
||||
{
|
||||
"criterionId": "D2",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "D3",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 검색 그라운딩 미실시. 사이트에서 발견된 외부 채널: www.tiktok.com(5), www.instagram.com(4), naver.me(2) — naver.com 제외 오픈웹 언급(티스토리·언론)은 검색으로 별도 집계 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "D4",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "D5",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 사이트에 플레이스/지도 링크 발견: https://naver.me/x9BxGXkK — 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인"
|
||||
},
|
||||
{
|
||||
"criterionId": "D6",
|
||||
"level": 0,
|
||||
"evidence": "Wikidata에서 \"VIEW Plastic Surgery (EN)\" 검색 결과 없음"
|
||||
},
|
||||
{
|
||||
"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회 반복은 별도 단계)"
|
||||
},
|
||||
{
|
||||
"criterionId": "F1",
|
||||
"level": 3,
|
||||
"evidence": "메인 페이지 이미지 500개 중 0개(0%) alt 누락 또는 빈 값"
|
||||
},
|
||||
{
|
||||
"criterionId": "F2",
|
||||
"level": "unverified",
|
||||
"evidence": "meta generator: WordPress 7.1 — 출시일 미상, 수동 판정 필요"
|
||||
},
|
||||
{
|
||||
"criterionId": "F3",
|
||||
"level": 2,
|
||||
"evidence": "초기 HTML 587KB (300~800KB)"
|
||||
},
|
||||
{
|
||||
"criterionId": "F4",
|
||||
"level": 2,
|
||||
"evidence": "viewport meta 존재, 반응형. CWV 미측정"
|
||||
}
|
||||
]
|
||||
}
|
||||
50
docs/reports/viewclinic/05_global_en/audit_en_overrides.json
Normal file
50
docs/reports/viewclinic/05_global_en/audit_en_overrides.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"C1": {
|
||||
"level": 1,
|
||||
"evidence": "정의문이 메타 설명에만 있다. About 메타: 'Founded in 2005, VIEW is a premier 19-story medical center in Gangnam, Seoul'. 본문 첫 단락은 슬로건('always considers the perspective of the patient'), 시술 페이지 첫 텍스트는 'Let Me In Season 2/3/4/5 Selected Doctors' 홍보 라벨. 홈 첫 200자는 검색창·메뉴"
|
||||
},
|
||||
"C4": {
|
||||
"level": 1,
|
||||
"evidence": "시술 페이지(가슴 Motiva) 본문에 가격·수술시간·회복기간 0회. 수치는 Motiva 보증(재수술 지원 300만원/개) 뿐. 블로그 글에는 USD 가격표(V-line $7,000–12,000, 표 3행)와 체류 기간(10–14일)이 있으나 시술 페이지와 연결되지 않음"
|
||||
},
|
||||
"C7": {
|
||||
"level": 3,
|
||||
"evidence": "의료진 상세 페이지에 학위(서울대 의학박사)·전문의 자격·학회 회원·논문 목록이 텍스트. 의료진 목록 26명에 역할(성형외과 전문의 15·이비인후과 1·피부과 3·유방외과 1·영상 1·마취과 6) 표기"
|
||||
},
|
||||
"C8": {
|
||||
"level": 2,
|
||||
"evidence": "5개 핵심 페이지 단락 샘플 50개 중 상호 또는 시술명 포함 26개(52%). 상호 'VIEW' 포함은 7개(14%). FAQ 답변은 'Motiva Preserve is…'처럼 시술명만 있고 상호가 없다"
|
||||
},
|
||||
"B4": {
|
||||
"level": 2,
|
||||
"evidence": "전 페이지 푸터 NAP 동일: 107 Bongeunsa-ro, Gangnam-gu / +82-2-539-1177 / eng_viewps@viewclinic.com / 진료시간. 구주소(201-14 Nonhyeon-dong) 병기. 개인정보 페이지에 다른 번호(02-2138-1305, 02-3480-3573) 노출"
|
||||
},
|
||||
"B7": {
|
||||
"level": 2,
|
||||
"evidence": "10개 언어 hreflang 양방향 선언. 그러나 th·jp·vn·ru·id·mn·es·ar 8개 서브도메인은 viewplasticsurgery.com/{lang}/ 로 301. hreflang 대상 URL이 최종 URL이 아니다. cn 페이지는 en-US 대신 en으로 선언"
|
||||
},
|
||||
"D2": {
|
||||
"level": 2,
|
||||
"evidence": "Google 비즈니스 프로필 4.9점·리뷰 901건, 소유자 게시 활성, 웹사이트가 영문 사이트로 연결(2026-08-31 실측 인용). 전화번호 미등록. 2026-09-10 재실측은 Places API 키 부재로 미실시"
|
||||
},
|
||||
"D4": {
|
||||
"level": 2,
|
||||
"evidence": "영문 사이트·GBP 주소 일치(107 Bongeunsa-ro). GBP 전화 없음. 강남언니는 국문 표면이라 외국인 질의 근거로는 쓰이지 않음"
|
||||
},
|
||||
"E4": {
|
||||
"level": 2,
|
||||
"evidence": "2026-09-10 외국인 관점 영문 60문항 × 2엔진(Perplexity sonar, OpenAI gpt-4o web_search 뉴욕 위치) 1회 실측. 5회 반복은 미실시"
|
||||
},
|
||||
"F2": {
|
||||
"level": 2,
|
||||
"evidence": "WordPress 7.1(2026 최신 계열). meta generator로 버전 노출"
|
||||
},
|
||||
"D1": {
|
||||
"level": 2,
|
||||
"evidence": "외국인 대상 리뷰·중개 플랫폼 기준. Bookimed(us-uk.bookimed.com) 프로필과 RealSelf 평점(제3자 인용 4.8/5)이 AI 답변의 인용 출처로 확인됨. 리뷰 수·갱신일·소유자 응답은 직접 실측하지 못함. 강남언니(국문)는 외국인 질의 60문항 어디에도 인용되지 않음"
|
||||
},
|
||||
"D3": {
|
||||
"level": 2,
|
||||
"evidence": "뷰를 언급한 AI 답변의 인용처가 유치업체·에이전시 블로그(khunkim.com·clinicsoncall.com·curemeabroad.com·kmhglobal.com), 정부 표면(english.visitkorea.or.kr·medicaltour.gangnam.go.kr), 커뮤니티(reddit.com·forum.purseblog.com), LinkedIn에 분산. 영어권 주요 언론 기사와 Wikipedia 항목은 없음"
|
||||
}
|
||||
}
|
||||
500
docs/reports/viewclinic/05_global_en/audit_en_scored.json
Normal file
500
docs/reports/viewclinic/05_global_en/audit_en_scored.json
Normal file
@ -0,0 +1,500 @@
|
||||
{
|
||||
"label": "VIEW EN",
|
||||
"v1": {
|
||||
"score": 64,
|
||||
"grade": "B",
|
||||
"earned": 55.7,
|
||||
"possible": 87.0,
|
||||
"verified": 31,
|
||||
"total": 36,
|
||||
"categories": {
|
||||
"access": 92,
|
||||
"entity": 38,
|
||||
"content": 63,
|
||||
"surface": 63,
|
||||
"measure": 67,
|
||||
"hygiene": 80
|
||||
}
|
||||
},
|
||||
"aeo": {
|
||||
"score": 61,
|
||||
"raw": 61,
|
||||
"grade": "B",
|
||||
"earned": 55.0,
|
||||
"possible": 90.0,
|
||||
"items": [
|
||||
[
|
||||
"AEO1",
|
||||
"질문 수요 충족",
|
||||
67
|
||||
],
|
||||
[
|
||||
"AEO2",
|
||||
"답변의 직접성",
|
||||
33
|
||||
],
|
||||
[
|
||||
"AEO3",
|
||||
"답변 단위의 자기완결성",
|
||||
67
|
||||
],
|
||||
[
|
||||
"AEO4",
|
||||
"의사결정에 필요한 정보",
|
||||
60
|
||||
],
|
||||
[
|
||||
"AEO5",
|
||||
"정보 구조와 가독성",
|
||||
67
|
||||
],
|
||||
[
|
||||
"AEO6",
|
||||
"다음 행동의 명확성",
|
||||
null
|
||||
],
|
||||
[
|
||||
"AEO7",
|
||||
"핵심 답변의 텍스트 추출",
|
||||
100
|
||||
],
|
||||
[
|
||||
"AEO8",
|
||||
"사이트 내 답변 발견",
|
||||
87
|
||||
]
|
||||
]
|
||||
},
|
||||
"geo": {
|
||||
"score": 70,
|
||||
"raw": 70,
|
||||
"grade": "B",
|
||||
"earned": 42.0,
|
||||
"possible": 60.0,
|
||||
"items": [
|
||||
[
|
||||
"GEO1",
|
||||
"주장과 근거의 일치",
|
||||
null
|
||||
],
|
||||
[
|
||||
"GEO2",
|
||||
"출처의 적합성과 추적성",
|
||||
100
|
||||
],
|
||||
[
|
||||
"GEO3",
|
||||
"전문성과 책임 주체",
|
||||
60
|
||||
],
|
||||
[
|
||||
"GEO4",
|
||||
"병원 엔티티와 사실의 정합성",
|
||||
42
|
||||
],
|
||||
[
|
||||
"GEO5",
|
||||
"사실의 최신성과 정정",
|
||||
67
|
||||
],
|
||||
[
|
||||
"GEO6",
|
||||
"원천 대비 추가 가치",
|
||||
null
|
||||
],
|
||||
[
|
||||
"GEO7",
|
||||
"발행 관계와 투명성",
|
||||
null
|
||||
],
|
||||
[
|
||||
"GEO8",
|
||||
"인용 식별자와 메타데이터 정합성",
|
||||
100
|
||||
]
|
||||
]
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "G1",
|
||||
"passed": true,
|
||||
"failedBy": [],
|
||||
"cap": 40,
|
||||
"axes": [
|
||||
"aeo",
|
||||
"geo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G2",
|
||||
"passed": true,
|
||||
"failedBy": [],
|
||||
"cap": 40,
|
||||
"axes": [
|
||||
"aeo",
|
||||
"geo"
|
||||
]
|
||||
}
|
||||
],
|
||||
"aeo_h": {
|
||||
"score": 65,
|
||||
"grade": "B",
|
||||
"earned": 65.0,
|
||||
"possible": 100,
|
||||
"items": [
|
||||
[
|
||||
"AEO1",
|
||||
"질문 수요 충족",
|
||||
20,
|
||||
13.3,
|
||||
"derived",
|
||||
[
|
||||
"C2=2"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO2",
|
||||
"답변의 직접성",
|
||||
20,
|
||||
6.7,
|
||||
"derived",
|
||||
[
|
||||
"C1=1"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO3",
|
||||
"답변 단위의 자기완결성",
|
||||
15,
|
||||
10.0,
|
||||
"derived",
|
||||
[
|
||||
"C8=2"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO4",
|
||||
"의사결정에 필요한 정보",
|
||||
15,
|
||||
9.0,
|
||||
"derived",
|
||||
[
|
||||
"C4=1",
|
||||
"C5=3"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO5",
|
||||
"정보 구조와 가독성",
|
||||
10,
|
||||
6.7,
|
||||
"derived",
|
||||
[
|
||||
"C3=2"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO6",
|
||||
"다음 행동의 명확성",
|
||||
10,
|
||||
10.0,
|
||||
"judged",
|
||||
[]
|
||||
],
|
||||
[
|
||||
"AEO7",
|
||||
"핵심 답변의 텍스트 추출",
|
||||
5,
|
||||
5.0,
|
||||
"derived",
|
||||
[
|
||||
"A3=3",
|
||||
"F1=3"
|
||||
]
|
||||
],
|
||||
[
|
||||
"AEO8",
|
||||
"사이트 내 답변 발견",
|
||||
5,
|
||||
4.3,
|
||||
"derived",
|
||||
[
|
||||
"A5=3",
|
||||
"B6=2"
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
"geo_h": {
|
||||
"score": 72,
|
||||
"grade": "B",
|
||||
"earned": 72.0,
|
||||
"possible": 100,
|
||||
"items": [
|
||||
[
|
||||
"GEO1",
|
||||
"주장과 근거의 일치",
|
||||
20,
|
||||
13.3,
|
||||
"judged",
|
||||
[]
|
||||
],
|
||||
[
|
||||
"GEO2",
|
||||
"출처의 적합성과 추적성",
|
||||
15,
|
||||
15.0,
|
||||
"derived",
|
||||
[
|
||||
"A4=3"
|
||||
]
|
||||
],
|
||||
[
|
||||
"GEO3",
|
||||
"전문성과 책임 주체",
|
||||
15,
|
||||
9.0,
|
||||
"derived",
|
||||
[
|
||||
"C7=3",
|
||||
"B3=0"
|
||||
]
|
||||
],
|
||||
[
|
||||
"GEO4",
|
||||
"병원 엔티티와 사실의 정합성",
|
||||
15,
|
||||
6.3,
|
||||
"derived",
|
||||
[
|
||||
"B2=0",
|
||||
"B4=2",
|
||||
"B5=0",
|
||||
"D1=2",
|
||||
"D2=2",
|
||||
"D4=2",
|
||||
"D6=0"
|
||||
]
|
||||
],
|
||||
[
|
||||
"GEO5",
|
||||
"사실의 최신성과 정정",
|
||||
10,
|
||||
6.7,
|
||||
"derived",
|
||||
[
|
||||
"C6=2"
|
||||
]
|
||||
],
|
||||
[
|
||||
"GEO6",
|
||||
"원천 대비 추가 가치",
|
||||
10,
|
||||
10.0,
|
||||
"judged",
|
||||
[]
|
||||
],
|
||||
[
|
||||
"GEO7",
|
||||
"발행 관계와 투명성",
|
||||
10,
|
||||
6.7,
|
||||
"judged",
|
||||
[]
|
||||
],
|
||||
[
|
||||
"GEO8",
|
||||
"인용 식별자와 메타데이터 정합성",
|
||||
5,
|
||||
5.0,
|
||||
"derived",
|
||||
[
|
||||
"B1=3"
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
"results": {
|
||||
"A1": {
|
||||
"criterionId": "A1",
|
||||
"level": 3,
|
||||
"evidence": "12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200",
|
||||
"source": "robots.txt: User-agent: * / Disallow: /wp-admin/"
|
||||
},
|
||||
"A2": {
|
||||
"criterionId": "A2",
|
||||
"level": 2,
|
||||
"evidence": "CDN 사용(Apache/2.4.68 (Debian)), AI UA 차단 없음(전부 200)"
|
||||
},
|
||||
"A3": {
|
||||
"criterionId": "A3",
|
||||
"level": 3,
|
||||
"evidence": "서버 렌더링. JS 미실행 HTML에서 본문 텍스트 79,646자 추출"
|
||||
},
|
||||
"A4": {
|
||||
"criterionId": "A4",
|
||||
"level": 3,
|
||||
"evidence": "http://viewplasticsurgery.com → 301 → https://www.viewplasticsurgery.com/ 정본 수렴 (4변형 모두)"
|
||||
},
|
||||
"A5": {
|
||||
"criterionId": "A5",
|
||||
"level": 3,
|
||||
"evidence": "robots.txt에 sitemap 선언, 200 응답. URL 482개, lastmod 최신 2026-09-09"
|
||||
},
|
||||
"A6": {
|
||||
"criterionId": "A6",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)"
|
||||
},
|
||||
"A7": {
|
||||
"criterionId": "A7",
|
||||
"level": 2,
|
||||
"evidence": "TTFB 0.870s / 초기 HTML 587KB. 시술 페이지 212KB (TTFB 양호 / HTML 비대)"
|
||||
},
|
||||
"B1": {
|
||||
"criterionId": "B1",
|
||||
"level": 3,
|
||||
"evidence": "JSON-LD 1블록 전부 유효 + @graph 연결"
|
||||
},
|
||||
"B2": {
|
||||
"criterionId": "B2",
|
||||
"level": 0,
|
||||
"evidence": "업종 엔티티 스키마 없음 (발견 타입: CollectionPage, Person, WebSite)"
|
||||
},
|
||||
"B3": {
|
||||
"criterionId": "B3",
|
||||
"level": 0,
|
||||
"evidence": "Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재"
|
||||
},
|
||||
"B4": {
|
||||
"criterionId": "B4",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 전 페이지 푸터 NAP 동일: 107 Bongeunsa-ro, Gangnam-gu / +82-2-539-1177 / eng_viewps@viewclinic.com / 진료시간. 구주소(201-14 Nonhyeon-dong) 병기. 개인정보 페이지에 다른 번호(02-2138-1305, 02-3480-3573) 노출"
|
||||
},
|
||||
"B5": {
|
||||
"criterionId": "B5",
|
||||
"level": 0,
|
||||
"evidence": "Organization.sameAs: [] (빈 배열). 푸터/본문에 SNS 링크 2종(www.instagram.com, www.tiktok.com) — 스키마에 미연결"
|
||||
},
|
||||
"B6": {
|
||||
"criterionId": "B6",
|
||||
"level": 2,
|
||||
"evidence": "rel=canonical self-referencing 존재"
|
||||
},
|
||||
"B7": {
|
||||
"criterionId": "B7",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 10개 언어 hreflang 양방향 선언. 그러나 th·jp·vn·ru·id·mn·es·ar 8개 서브도메인은 viewplasticsurgery.com/{lang}/ 로 301. hreflang 대상 URL이 최종 URL이 아니다. cn 페이지는 en-US 대신 en으로 선언"
|
||||
},
|
||||
"C1": {
|
||||
"criterionId": "C1",
|
||||
"level": 1,
|
||||
"evidence": "[사람 보정] 정의문이 메타 설명에만 있다. About 메타: 'Founded in 2005, VIEW is a premier 19-story medical center in Gangnam, Seoul'. 본문 첫 단락은 슬로건('always considers the perspective of the patient'), 시술 페이지 첫 텍스트는 'Let Me In Season 2/3/4/5 Selected Doctors' 홍보 라벨. 홈 첫 200자는 검색창·메뉴"
|
||||
},
|
||||
"C2": {
|
||||
"criterionId": "C2",
|
||||
"level": 2,
|
||||
"evidence": "FAQ 섹션 존재: \"VIEW Breast Surgery FAQ\" 외 0개. FAQPage 스키마 없음"
|
||||
},
|
||||
"C3": {
|
||||
"criterionId": "C3",
|
||||
"level": 2,
|
||||
"evidence": "H1 1개: \"korea plastic surgery\". H2 중복 0건 또는 라벨형"
|
||||
},
|
||||
"C4": {
|
||||
"criterionId": "C4",
|
||||
"level": 1,
|
||||
"evidence": "[사람 보정] 시술 페이지(가슴 Motiva) 본문에 가격·수술시간·회복기간 0회. 수치는 Motiva 보증(재수술 지원 300만원/개) 뿐. 블로그 글에는 USD 가격표(V-line $7,000–12,000, 표 3행)와 체류 기간(10–14일)이 있으나 시술 페이지와 연결되지 않음"
|
||||
},
|
||||
"C5": {
|
||||
"criterionId": "C5",
|
||||
"level": 3,
|
||||
"evidence": "HTML 표 10개"
|
||||
},
|
||||
"C6": {
|
||||
"criterionId": "C6",
|
||||
"level": 2,
|
||||
"evidence": "최신 갱신 신호 2026-09-09 (sitemap lastmod 2026-09-09). dateModified 마크업 없음"
|
||||
},
|
||||
"C7": {
|
||||
"criterionId": "C7",
|
||||
"level": 3,
|
||||
"evidence": "[사람 보정] 의료진 상세 페이지에 학위(서울대 의학박사)·전문의 자격·학회 회원·논문 목록이 텍스트. 의료진 목록 26명에 역할(성형외과 전문의 15·이비인후과 1·피부과 3·유방외과 1·영상 1·마취과 6) 표기"
|
||||
},
|
||||
"C8": {
|
||||
"criterionId": "C8",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 5개 핵심 페이지 단락 샘플 50개 중 상호 또는 시술명 포함 26개(52%). 상호 'VIEW' 포함은 7개(14%). FAQ 답변은 'Motiva Preserve is…'처럼 시술명만 있고 상호가 없다"
|
||||
},
|
||||
"D1": {
|
||||
"criterionId": "D1",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 외국인 대상 리뷰·중개 플랫폼 기준. Bookimed(us-uk.bookimed.com) 프로필과 RealSelf 평점(제3자 인용 4.8/5)이 AI 답변의 인용 출처로 확인됨. 리뷰 수·갱신일·소유자 응답은 직접 실측하지 못함. 강남언니(국문)는 외국인 질의 60문항 어디에도 인용되지 않음"
|
||||
},
|
||||
"D2": {
|
||||
"criterionId": "D2",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] Google 비즈니스 프로필 4.9점·리뷰 901건, 소유자 게시 활성, 웹사이트가 영문 사이트로 연결(2026-08-31 실측 인용). 전화번호 미등록. 2026-09-10 재실측은 Places API 키 부재로 미실시"
|
||||
},
|
||||
"D3": {
|
||||
"criterionId": "D3",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 뷰를 언급한 AI 답변의 인용처가 유치업체·에이전시 블로그(khunkim.com·clinicsoncall.com·curemeabroad.com·kmhglobal.com), 정부 표면(english.visitkorea.or.kr·medicaltour.gangnam.go.kr), 커뮤니티(reddit.com·forum.purseblog.com), LinkedIn에 분산. 영어권 주요 언론 기사와 Wikipedia 항목은 없음"
|
||||
},
|
||||
"D4": {
|
||||
"criterionId": "D4",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 영문 사이트·GBP 주소 일치(107 Bongeunsa-ro). GBP 전화 없음. 강남언니는 국문 표면이라 외국인 질의 근거로는 쓰이지 않음"
|
||||
},
|
||||
"D5": {
|
||||
"criterionId": "D5",
|
||||
"level": "unverified",
|
||||
"evidence": "[semi·사람 판정 필요] 사이트에 플레이스/지도 링크 발견: https://naver.me/x9BxGXkK — 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인"
|
||||
},
|
||||
"D6": {
|
||||
"criterionId": "D6",
|
||||
"level": 0,
|
||||
"evidence": "Wikidata에서 \"VIEW Plastic Surgery (EN)\" 검색 결과 없음"
|
||||
},
|
||||
"E1": {
|
||||
"criterionId": "E1",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)"
|
||||
},
|
||||
"E2": {
|
||||
"criterionId": "E2",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)"
|
||||
},
|
||||
"E3": {
|
||||
"criterionId": "E3",
|
||||
"level": "unverified",
|
||||
"evidence": "[manual] GSC 접근 필요 (AI Overviews 노출)"
|
||||
},
|
||||
"E4": {
|
||||
"criterionId": "E4",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] 2026-09-10 외국인 관점 영문 60문항 × 2엔진(Perplexity sonar, OpenAI gpt-4o web_search 뉴욕 위치) 1회 실측. 5회 반복은 미실시"
|
||||
},
|
||||
"F1": {
|
||||
"criterionId": "F1",
|
||||
"level": 3,
|
||||
"evidence": "메인 페이지 이미지 500개 중 0개(0%) alt 누락 또는 빈 값"
|
||||
},
|
||||
"F2": {
|
||||
"criterionId": "F2",
|
||||
"level": 2,
|
||||
"evidence": "[사람 보정] WordPress 7.1(2026 최신 계열). meta generator로 버전 노출"
|
||||
},
|
||||
"F3": {
|
||||
"criterionId": "F3",
|
||||
"level": 2,
|
||||
"evidence": "초기 HTML 587KB (300~800KB)"
|
||||
},
|
||||
"F4": {
|
||||
"criterionId": "F4",
|
||||
"level": 2,
|
||||
"evidence": "viewport meta 존재, 반응형. CWV 미측정"
|
||||
}
|
||||
}
|
||||
}
|
||||
489
docs/reports/viewclinic/05_global_en/build_global_en_report.py
Normal file
489
docs/reports/viewclinic/05_global_en/build_global_en_report.py
Normal file
@ -0,0 +1,489 @@
|
||||
#!/usr/bin/env python3
|
||||
"""뷰성형외과 영문 사이트 · 외국인 관점 AEO/GEO 진단 보고서 빌더.
|
||||
|
||||
입력 (같은 폴더):
|
||||
audit_en_scored.json score_en.py 결과 (v1.0 36항목 + v2.0 AEO/GEO)
|
||||
question_bank_en.json 외국인 관점 영문 60문항
|
||||
qb_en_perplexity_results.jsonl Perplexity sonar 실측
|
||||
qb_en_openai_results.jsonl OpenAI gpt-4o web_search(뉴욕 위치) 실측
|
||||
출력:
|
||||
Viewclinic_Global_EN_AEO_GEO_Report_<date>.html / .pdf (Playwright chromium)
|
||||
|
||||
사용: python3 build_global_en_report.py
|
||||
"""
|
||||
import json, os, re, sys, html, datetime as dt
|
||||
from collections import Counter, defaultdict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATE = "2026-09-10"
|
||||
OUT_BASE = os.path.join(HERE, f"Viewclinic_Global_EN_AEO_GEO_Report_{DATE}")
|
||||
SITE = "www.viewplasticsurgery.com"
|
||||
|
||||
KR_BASE = {"v1": 54, "aeo": 42, "geo": 56} # 국문 사이트 2026-09-07 재채점 (AEO_GEO_RUBRIC_v2.md)
|
||||
|
||||
CATS = {"A": "브랜드 직접 질문", "B": "이동·체류·운영", "C": "추천·비교", "D": "시술 정보",
|
||||
"E": "가격", "F": "안전·신뢰", "G": "사후관리·원격 팔로업"}
|
||||
CAT_EN = {"A": "Brand", "B": "Travel & logistics", "C": "Recommendation & comparison", "D": "Procedure info",
|
||||
"E": "Pricing", "F": "Safety & trust", "G": "Aftercare & remote follow-up"}
|
||||
STAGE = {"aware": "인지", "explore": "탐색", "compare": "비교", "decision": "결정"}
|
||||
|
||||
V1_NAMES = {
|
||||
"A1":"검색용 AI 봇 허용","A2":"CDN/WAF AI 차단 없음","A3":"서버 렌더링 본문","A4":"HTTPS·리다이렉트 정합","A5":"sitemap.xml 제공","A6":"Bing 색인 (ChatGPT 검색 경로)","A7":"응답 속도·크롤 예산",
|
||||
"B1":"JSON-LD 문법 유효성","B2":"업종 엔티티 스키마 (MedicalClinic)","B3":"전문가 스키마 (Physician)","B4":"NAP 사이트 내 일관성","B5":"sameAs 연결","B6":"canonical·중복 제거","B7":"다국어 hreflang",
|
||||
"C1":"정의문형 첫 문단","C2":"FAQ 섹션","C3":"제목 계층 의미성","C4":"구체 수치 (가격·시간·회복)","C5":"표·비교 구조","C6":"신선도 신호","C7":"전문가 E-E-A-T (의료진)","C8":"패시지 자기완결성",
|
||||
"D1":"업종 버티컬 프로필 (외국인: 국제 리뷰 플랫폼)","D2":"Google 비즈니스 프로필","D3":"오픈웹 언급 (해외 커뮤니티·언론)","D4":"표면 간 NAP 완전 일치","D5":"네이버 플레이스 (AI 브리핑 전용)","D6":"위키데이터/위키백과 엔티티",
|
||||
"E1":"AI 크롤러 로그 (IP 검증)","E2":"GA4 AI 유입 채널","E3":"GSC AI Overviews 노출","E4":"기준선 스팟체크",
|
||||
"F1":"이미지 대체텍스트","F2":"CMS 보안·최신성","F3":"페이지 무게","F4":"모바일 대응"}
|
||||
V1_CAT = {"A":"A · Access & Crawlability","B":"B · Entity & Structured Data","C":"C · Content & Answerability","D":"D · Surfaces & Citations","E":"E · Measurement","F":"F · Technical Hygiene"}
|
||||
|
||||
V2_DESC = {
|
||||
"AEO1":"질문 뱅크 대비 답이 있는 문항 비율. 자동 신호는 FAQ 섹션·FAQPage 스키마",
|
||||
"AEO2":"핵심 페이지 첫 200자에 [상호+지역+전문분야] 정의문 또는 직접 답 존재",
|
||||
"AEO3":"핵심 페이지 단락 샘플 중 상호·시술명이 포함된 비율",
|
||||
"AEO4":"시술 페이지의 가격·시간·회복 수치 밀도와 표·비교 구조",
|
||||
"AEO5":"H1 1개·서술형, H2 논리 계층",
|
||||
"AEO6":"예약·전화·위치가 본문 텍스트로 연결되는지 (사람 판정)",
|
||||
"AEO7":"JS 미실행 HTML 본문 비율 + 이미지 alt",
|
||||
"AEO8":"sitemap 선언·lastmod + canonical 정합",
|
||||
"GEO1":"주장 문장에 출처·조건·시점이 붙은 비율 (사람 판정)",
|
||||
"GEO2":"http/www 변형의 301 정본 수렴, URL 안정성",
|
||||
"GEO3":"전문가 자격·경력 텍스트 + Physician 스키마",
|
||||
"GEO4":"업종 스키마·사이트 내 NAP·sameAs·버티컬·GBP·표면 간 NAP·Wikidata",
|
||||
"GEO5":"dateModified·sitemap lastmod·본문 갱신일",
|
||||
"GEO6":"타 사이트에 없는 병원 고유 정보 유형 수 (사람 판정)",
|
||||
"GEO7":"운영 주체·연락처·이해관계 고지, 정보와 광고의 구분 (사람 판정)",
|
||||
"GEO8":"ld+json 전 블록 파싱 + 화면과 메타 일치"}
|
||||
|
||||
V2_HUMAN_EVIDENCE = {
|
||||
"AEO6": "전 페이지 푸터에 전화 +82-2-539-1177 · 이메일 eng_viewps@viewclinic.com · 주소 · 진료시간이 텍스트로 반복. Quick Consultation 폼에 WhatsApp · LINE · KakaoTalk · Viber 선택지와 국가번호 입력. Google Map · Naver Map 링크. 전화·예약·방문 3경로 충족",
|
||||
"GEO1": "'No.1 Motiva 사용량(2023, Motiva Korea 수술량 기준)' · 'Global KOL 2023–2026' · '법무부 우수 유치기관 2018–2026' 처럼 집계 주체·연도가 붙은 주장이 다수. 그러나 페이지 제목의 'Best Plastic Surgery in Korea' · 'No.1 Trusted Clinic'은 근거 없음. 가슴 페이지 메타 설명은 'No.1 Motiva usage in 2013', 본문은 2023으로 불일치. 안전 페이지의 '마취과 전문의 상시'는 인원 수 없음(의료진 페이지에는 6명 명시)",
|
||||
"GEO6": "컨시어지 10항목(10개 언어 통역 무료 · 공항 픽업 · 원내 숙박 · 도보 10분 제휴 호텔 · T-money 제공 · 귀국 후 원격 경과 관찰), 층별 안내(B1 검진센터~15F 치과), 공항 리무진 노선·정류장 번호, 블로그 USD 가격표, FAQ 402건, Motiva 보증 조건(5년 재수술 지원 300만원/개, 10년 교체). 원천 유형 5개 이상",
|
||||
"GEO7": "푸터에 상호 · 대표(Choi Soon Woo) · 사업자번호 220-08-86777 표기. 전 페이지에 부작용 고지문. 다만 시술 페이지 안에 Before & After · Real Review · 'Let Me In' 출연 홍보가 정보와 구분 없이 섞여 있고, 후기의 대가 지급 여부 고지가 없다. 국문 사이트 후기 광고는 2026-07-12 공정위 시정명령을 받았고 영문 Real Review 섹션도 같은 방식이다"}
|
||||
|
||||
# ─────────────────────────── 데이터 로드 ───────────────────────────
|
||||
def load_jsonl(p):
|
||||
rows = {}
|
||||
if os.path.exists(p):
|
||||
for line in open(p, encoding="utf-8"):
|
||||
if line.strip():
|
||||
r = json.loads(line); rows[r["id"]] = r
|
||||
return rows
|
||||
|
||||
score = json.load(open(os.path.join(HERE, "audit_en_scored.json"), encoding="utf-8"))
|
||||
qs = json.load(open(os.path.join(HERE, "question_bank_en.json"), encoding="utf-8"))
|
||||
PP = load_jsonl(os.path.join(HERE, "qb_en_perplexity_results.jsonl"))
|
||||
OA = load_jsonl(os.path.join(HERE, "qb_en_openai_results.jsonl"))
|
||||
ENGINES = [("perplexity", "Perplexity", PP), ("openai", "OpenAI 웹검색", OA)]
|
||||
|
||||
def dom(u):
|
||||
d = urlparse(u).netloc.lower()
|
||||
return d[4:] if d.startswith("www.") else d
|
||||
|
||||
OWN = ("viewplasticsurgery.com", "viewclinic.com")
|
||||
def own_cited(r):
|
||||
return any(any(o in dom(u) for o in OWN) for u in r.get("urls", []))
|
||||
|
||||
# 집계
|
||||
stats = {}
|
||||
for key, label, rows in ENGINES:
|
||||
st = {"n": 0, "mentioned": 0, "own": 0, "first": 0, "sent": Counter(), "cat": defaultdict(lambda: [0, 0, 0, 0]),
|
||||
"clinics": Counter(), "clinic_first": Counter(), "domains": Counter(), "domains_view": Counter(), "own_urls": Counter()}
|
||||
for q in qs:
|
||||
r = rows.get(q["id"])
|
||||
if not r: continue
|
||||
st["n"] += 1
|
||||
c = st["cat"][q["cat"]]; c[0] += 1
|
||||
vm = bool(r.get("view_mentioned"))
|
||||
if vm:
|
||||
st["mentioned"] += 1; c[1] += 1
|
||||
st["sent"][r.get("view_sentiment") or "neutral"] += 1
|
||||
if r.get("view_rank") == 1: st["first"] += 1; c[3] += 1
|
||||
if own_cited(r): st["own"] += 1; c[2] += 1
|
||||
for i, cl in enumerate([x for x in r.get("clinics", []) if isinstance(x, str)][:8]):
|
||||
st["clinics"][cl] += 1
|
||||
if i == 0: st["clinic_first"][cl] += 1
|
||||
ds = {dom(u) for u in r.get("urls", [])}
|
||||
for d in ds:
|
||||
st["domains"][d] += 1
|
||||
if vm: st["domains_view"][d] += 1
|
||||
for u in r.get("urls", []):
|
||||
if any(o in dom(u) for o in OWN):
|
||||
path = urlparse(u).path
|
||||
st["own_urls"][path] += 1
|
||||
stats[key] = st
|
||||
|
||||
def pct(a, b): return f"{a*100//b}%" if b else "-"
|
||||
|
||||
# ─────────────────────────── HTML 도우미 ───────────────────────────
|
||||
def esc(s): return html.escape(str(s).replace("—", ", ").replace(" ,", ","), quote=False)
|
||||
|
||||
def lvl_badge(level):
|
||||
if level == "unverified": return '<span class="badge unv">미검증</span>'
|
||||
cls = {3: "good", 2: "info", 1: "warn", 0: "crit"}[int(level)]
|
||||
return f'<span class="badge {cls}">{level}</span>'
|
||||
|
||||
def grade_cls(g): return {"A": "good", "B": "info", "C": "warn", "D": "crit"}[g]
|
||||
|
||||
def ox(v): return '<b class="o">O</b>' if v else '<span class="x">X</span>'
|
||||
|
||||
# ─────────────────────────── 섹션 ───────────────────────────
|
||||
aeo, geo, v1 = score["aeo_h"], score["geo_h"], score["v1"]
|
||||
results = score["results"]
|
||||
|
||||
def kpi_row():
|
||||
pp, oa = stats["perplexity"], stats["openai"]
|
||||
cells = [
|
||||
("AEO 답변 준비도", f"{aeo['score']}", aeo["grade"], "영문 사이트가 외국인 질문에 답하는 정도 · 100점"),
|
||||
("GEO 출처 준비도", f"{geo['score']}", geo["grade"], "AI가 영문 사이트를 출처로 쓸 수 있는 정도 · 100점"),
|
||||
("v1.0 종합 (참고)", f"{v1['score']}", v1["grade"], f"36항목 중 검증 {v1['verified']}개 · 국문 사이트 {KR_BASE['v1']}/C"),
|
||||
("Perplexity 언급률", pct(pp["mentioned"], pp["n"]), None, f"{pp['n']}문항 중 {pp['mentioned']} · 자사 사이트 인용 {pct(pp['own'], pp['n'])}"),
|
||||
("OpenAI 웹검색 언급률", pct(oa["mentioned"], oa["n"]), None, f"{oa['n']}문항 중 {oa['mentioned']} · 자사 사이트 인용 {pct(oa['own'], oa['n'])}"),
|
||||
]
|
||||
h = '<div class="kpis">'
|
||||
for lab, val, g, sub in cells:
|
||||
gb = f'<span class="grade {grade_cls(g)}">{g}</span>' if g else ""
|
||||
h += f'<div class="kpi"><div class="lab">{lab}</div><div class="val">{val}{gb}</div><div class="sub">{sub}</div></div>'
|
||||
return h + "</div>"
|
||||
|
||||
def cat_table():
|
||||
h = ['<table class="t"><thead><tr><th>질문군</th><th>문항</th>',
|
||||
'<th>Perplexity<br><small>언급 / 1순위 / 자사 인용</small></th>',
|
||||
'<th>OpenAI 웹검색<br><small>언급 / 1순위 / 자사 인용</small></th><th>해석</th></tr></thead><tbody>']
|
||||
interp = NARR["cat_interp"]
|
||||
for k in "ABCDEFG":
|
||||
n = sum(1 for q in qs if q["cat"] == k)
|
||||
cells = []
|
||||
for key, _, _ in ENGINES:
|
||||
c = stats[key]["cat"].get(k, [0, 0, 0, 0])
|
||||
cells.append(f"<td class='num'>{pct(c[1], c[0])} / {pct(c[3], c[0])} / {pct(c[2], c[0])}</td>")
|
||||
h.append(f"<tr><td><b>{k}</b> {CATS[k]}<span class='cat'>{CAT_EN[k]}</span></td><td class='num'>{n}</td>{''.join(cells)}<td>{esc(interp.get(k, ''))}</td></tr>")
|
||||
tot = []
|
||||
for key, _, _ in ENGINES:
|
||||
st = stats[key]; tot.append(f"<td class='num'><b>{pct(st['mentioned'], st['n'])} / {pct(st['first'], st['n'])} / {pct(st['own'], st['n'])}</b></td>")
|
||||
h.append(f"<tr class='sum'><td><b>합계</b></td><td class='num'>{len(qs)}</td>{''.join(tot)}<td></td></tr></tbody></table>")
|
||||
return "".join(h)
|
||||
|
||||
def clinic_table():
|
||||
merged = Counter(); first = Counter()
|
||||
for key, _, _ in ENGINES:
|
||||
merged.update(stats[key]["clinics"]); first.update(stats[key]["clinic_first"])
|
||||
h = ['<table class="t"><thead><tr><th>#</th><th>병원 (AI 답변 표기)</th><th>Perplexity</th><th>OpenAI</th><th>합계</th><th>1순위 등장</th></tr></thead><tbody>']
|
||||
for i, (name, cnt) in enumerate(merged.most_common(12), 1):
|
||||
cls = " class='hl'" if name == "VIEW Plastic Surgery" else ""
|
||||
h.append(f"<tr{cls}><td class='num'>{i}</td><td>{esc(name)}</td><td class='num'>{stats['perplexity']['clinics'].get(name,0)}</td>"
|
||||
f"<td class='num'>{stats['openai']['clinics'].get(name,0)}</td><td class='num'><b>{cnt}</b></td><td class='num'>{first.get(name,0)}</td></tr>")
|
||||
h.append("</tbody></table>")
|
||||
return "".join(h)
|
||||
|
||||
def domain_table():
|
||||
merged = Counter(); view = Counter()
|
||||
for key, _, _ in ENGINES:
|
||||
merged.update(stats[key]["domains"]); view.update(stats[key]["domains_view"])
|
||||
h = ['<table class="t"><thead><tr><th>#</th><th>인용 도메인</th><th>Perplexity</th><th>OpenAI</th><th>합계 (문항 수)</th><th>뷰 언급 답변에서</th><th>표면 유형</th></tr></thead><tbody>']
|
||||
for i, (d, cnt) in enumerate(merged.most_common(18), 1):
|
||||
cls = " class='hl'" if any(o in d for o in OWN) else ""
|
||||
h.append(f"<tr{cls}><td class='num'>{i}</td><td>{esc(d)}</td><td class='num'>{stats['perplexity']['domains'].get(d,0)}</td>"
|
||||
f"<td class='num'>{stats['openai']['domains'].get(d,0)}</td><td class='num'><b>{cnt}</b></td><td class='num'>{view.get(d,0)}</td><td>{esc(surface_type(d))}</td></tr>")
|
||||
h.append("</tbody></table>")
|
||||
return "".join(h)
|
||||
|
||||
def surface_type(d):
|
||||
if any(o in d for o in OWN): return "자사 사이트"
|
||||
if "reddit" in d or "quora" in d: return "커뮤니티"
|
||||
if "realself" in d or "whatclinic" in d or "bookimed" in d or "medigo" in d or "trustpilot" in d or "gangnamunni" in d or "yelp" in d: return "리뷰·중개 플랫폼"
|
||||
if "youtube" in d or "instagram" in d or "tiktok" in d or "facebook" in d: return "소셜"
|
||||
if "korea" in d and ("herald" in d or "times" in d or "joongang" in d or "yonhap" in d): return "언론"
|
||||
if d.endswith(".go.kr") or "visitkorea" in d or "medicalkorea" in d or "seoul" in d: return "정부·관광"
|
||||
if d.endswith(".com") or d.endswith(".net") or d.endswith(".kr") or d.endswith(".org"): return "타 병원·에이전시·블로그"
|
||||
return ""
|
||||
|
||||
def own_url_table():
|
||||
merged = Counter()
|
||||
for key, _, _ in ENGINES: merged.update(stats[key]["own_urls"])
|
||||
if not merged: return "<p>자사 URL 인용 없음</p>"
|
||||
h = ['<table class="t"><thead><tr><th>인용된 자사 페이지</th><th>Perplexity</th><th>OpenAI</th><th>합계</th></tr></thead><tbody>']
|
||||
for p, cnt in merged.most_common(12):
|
||||
h.append(f"<tr><td class='mono'>{esc(p)}</td><td class='num'>{stats['perplexity']['own_urls'].get(p,0)}</td><td class='num'>{stats['openai']['own_urls'].get(p,0)}</td><td class='num'><b>{cnt}</b></td></tr>")
|
||||
return "".join(h) + "</tbody></table>"
|
||||
|
||||
def sentiment_block():
|
||||
h = '<div class="grid3">'
|
||||
for key, label, _ in ENGINES:
|
||||
s = stats[key]["sent"]; m = stats[key]["mentioned"]
|
||||
h += (f'<div class="card"><h4>{label}</h4><p class="big">{s.get("positive",0)} <small>긍정</small> · {s.get("neutral",0)} <small>중립</small> · {s.get("negative",0)} <small>부정</small></p>'
|
||||
f'<p class="sub">뷰 언급 {m}문항 기준</p></div>')
|
||||
h += f'<div class="card"><h4>판정 기준</h4><p class="sub">답변이 뷰성형외과를 어떻게 규정하는지를 후처리 모델이 긍정·중립·부정으로 분류. 감성 기준표 v0.3의 1차 근사치이며 사람 검토 전 값</p></div></div>'
|
||||
return h
|
||||
|
||||
def quote_cards(ids):
|
||||
h = '<div class="quotes">'
|
||||
for qid, key in ids:
|
||||
rows = PP if key == "perplexity" else OA
|
||||
r = rows.get(qid)
|
||||
if not r: continue
|
||||
q = next(x for x in qs if x["id"] == qid)
|
||||
ans = re.sub(r"\[\d+\]", "", r.get("answer", "")).strip()
|
||||
ans = re.sub(r"\s+", " ", ans).replace("—", ", ").replace(" ,", ",")[:420]
|
||||
eng = "Perplexity" if key == "perplexity" else "OpenAI 웹검색"
|
||||
doms = sorted({dom(u) for u in r.get("urls", [])})[:5]
|
||||
h += (f'<div class="quote"><div class="qh"><b>{qid}</b> {esc(q["question"])} <span class="eng">{eng}</span> {ox(r.get("view_mentioned"))}</div>'
|
||||
f'<p>{esc(ans)}…</p><div class="cite">인용: {esc(", ".join(doms))}</div></div>')
|
||||
return h + "</div>"
|
||||
|
||||
def v2_table(axis):
|
||||
h = ['<table class="t v2"><thead><tr><th>항목</th><th>배점</th><th>점수</th><th>근거 신호</th><th>판정 근거 (2026-09-10 실측)</th></tr></thead><tbody>']
|
||||
for iid, name, w, earned, basis, sigs in axis["items"]:
|
||||
if earned is None:
|
||||
cell, sig = "<span class='badge unv'>미검증</span>", ""
|
||||
else:
|
||||
p = earned / w
|
||||
cls = "good" if p >= 0.8 else "info" if p >= 0.6 else "warn" if p >= 0.4 else "crit"
|
||||
cell = f"<span class='badge {cls}'>{earned:g}</span> <small>({round(p*100)}%)</small>"
|
||||
sig = ", ".join(sigs) if sigs else "사람 판정"
|
||||
ev = V2_HUMAN_EVIDENCE.get(iid) or NARR["v2_evidence"].get(iid, "")
|
||||
h.append(f"<tr><td><b>{iid}</b> {name}<span class='cat'>{esc(V2_DESC[iid])}</span></td><td class='num'>{w}</td><td>{cell}</td><td class='mono'>{sig}</td><td>{esc(ev)}</td></tr>")
|
||||
return "".join(h) + "</tbody></table>"
|
||||
|
||||
def v1_table():
|
||||
h = []
|
||||
for c in "ABCDEF":
|
||||
h.append(f'<h4 class="v1cat">{V1_CAT[c]}</h4><table class="t"><thead><tr><th>항목</th><th>레벨</th><th>근거</th></tr></thead><tbody>')
|
||||
for cid in sorted(k for k in V1_NAMES if k[0] == c):
|
||||
r = results.get(cid, {})
|
||||
lv = r.get("level", "unverified")
|
||||
ev = r.get("evidence", "")
|
||||
h.append(f"<tr><td><b>{cid}</b> {V1_NAMES[cid]}</td><td>{lvl_badge(lv)}</td><td>{esc(ev)}</td></tr>")
|
||||
h.append("</tbody></table>")
|
||||
return "".join(h)
|
||||
|
||||
def compare_table():
|
||||
rows = NARR["compare_rows"]
|
||||
h = ['<table class="t"><thead><tr><th>항목</th><th>국문 사이트 (viewclinic.com)</th><th>영문 사이트 (viewplasticsurgery.com)</th><th>차이</th></tr></thead><tbody>']
|
||||
for a, b, c, d in rows:
|
||||
h.append(f"<tr><td><b>{esc(a)}</b></td><td>{esc(b)}</td><td>{esc(c)}</td><td>{esc(d)}</td></tr>")
|
||||
return "".join(h) + "</tbody></table>"
|
||||
|
||||
def roadmap():
|
||||
h = ""
|
||||
for phase, color, items in NARR["roadmap"]:
|
||||
h += f'<h4 class="phase {color}">{esc(phase)}</h4><table class="t road"><thead><tr><th>#</th><th>조치</th><th>대상 항목·질문군</th><th>기대 효과</th></tr></thead><tbody>'
|
||||
for i, (act, target, effect) in enumerate(items, 1):
|
||||
h += f"<tr><td class='num'>{i}</td><td>{esc(act)}</td><td class='mono'>{esc(target)}</td><td>{esc(effect)}</td></tr>"
|
||||
h += "</tbody></table>"
|
||||
return h
|
||||
|
||||
def appendix_questions():
|
||||
h = ['<table class="t app"><thead><tr><th>#</th><th>질문 (영문 원문)</th><th>단계</th><th>Perplexity<br><small>언급·순위·감성</small></th><th>OpenAI<br><small>언급·순위·감성</small></th><th>답변 내 상위 병원 (Perplexity → OpenAI)</th></tr></thead><tbody>']
|
||||
cur = None
|
||||
for q in qs:
|
||||
if q["cat"] != cur:
|
||||
cur = q["cat"]
|
||||
h.append(f"<tr class='grp'><td colspan='6'><b>{cur}</b> {CATS[cur]} · {CAT_EN[cur]}</td></tr>")
|
||||
cells = []; names = []
|
||||
for key, _, rows in ENGINES:
|
||||
r = rows.get(q["id"])
|
||||
if not r: cells.append("<td>-</td>"); names.append("-"); continue
|
||||
s = {"positive": "긍정", "neutral": "중립", "negative": "부정"}.get(r.get("view_sentiment") or "", "")
|
||||
cells.append(f"<td>{ox(r.get('view_mentioned'))} {r.get('view_rank') or ''} <small>{s}</small></td>")
|
||||
cl = [x for x in r.get("clinics", []) if isinstance(x, str) and x != "VIEW Plastic Surgery"][:3]
|
||||
names.append(", ".join(cl) if cl else "(뷰 외 없음)")
|
||||
h.append(f"<tr><td class='mono'>{q['id']}</td><td>{esc(q['question'])}</td><td>{STAGE[q['stage']]}</td>{''.join(cells)}<td><small>{esc(names[0])} → {esc(names[1])}</small></td></tr>")
|
||||
return "".join(h) + "</tbody></table>"
|
||||
|
||||
# ─────────────────────────── 서술 (실측 후 작성) ───────────────────────────
|
||||
NARR = json.load(open(os.path.join(HERE, "narrative_ko.json"), encoding="utf-8"))
|
||||
|
||||
# ─────────────────────────── 렌더 ───────────────────────────
|
||||
CSS = """
|
||||
:root{--ink:#0A1128;--navy:#021341;--violet:#4F1DA1;--accent:#6C5CE7;--indigo:#1D0024;--light:#F7F8FC;--line:#E4E6F0;--slate6:#475569;--slate5:#64748B;
|
||||
--good-bg:#ECFDF5;--good-tx:#047857;--good-bd:#A7F3D0;--info-bg:#EEF2FF;--info-tx:#3730A3;--info-bd:#C7D2FE;--warn-bg:#FFFBEB;--warn-tx:#B45309;--warn-bd:#FDE68A;--crit-bg:#FEF2F2;--crit-tx:#B91C1C;--crit-bd:#FECACA}
|
||||
*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
body{margin:0;font-family:"Pretendard Variable",Pretendard,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;color:var(--indigo);background:#fff;font-size:15px;line-height:1.65;word-break:keep-all}
|
||||
.page{max-width:1080px;margin:0 auto;padding:0 40px 60px}
|
||||
.serif{font-family:"Playfair Display",Georgia,serif}
|
||||
.cover{background:linear-gradient(135deg,var(--ink) 0%,var(--navy) 55%,#2A1466 100%);color:#fff;padding:64px 56px 52px;margin:0 -40px 36px;position:relative;overflow:hidden}
|
||||
.cover:after{content:"";position:absolute;right:-120px;top:-120px;width:420px;height:420px;border-radius:50%;background:radial-gradient(circle,rgba(108,92,231,.45),transparent 70%)}
|
||||
.eyebrow{font-family:Inter,Pretendard,sans-serif;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:#C4B5FD;font-weight:600}
|
||||
.cover h1{font-size:38px;line-height:1.25;margin:14px 0 8px;font-weight:800}
|
||||
.cover .sub{font-size:19px;color:#DDD6FE;margin:0 0 22px}
|
||||
.cover .meta{font-size:14px;color:#C4B5FD;display:flex;gap:22px;flex-wrap:wrap}
|
||||
.cover .meta b{color:#fff;font-weight:600}
|
||||
h2{font-family:"Playfair Display",Georgia,serif;font-size:28px;color:var(--indigo);margin:44px 0 6px;font-weight:700;display:flex;align-items:center;gap:12px}
|
||||
h2 .n{font-size:14px;font-family:Inter,Pretendard,sans-serif;letter-spacing:.15em;color:var(--accent);font-weight:700}
|
||||
h2 + .lead{font-size:17px;color:var(--violet);margin:0 0 18px;font-weight:500}
|
||||
h3{font-size:20px;margin:28px 0 10px;color:var(--indigo)}
|
||||
h4{font-size:16px;margin:18px 0 8px;color:var(--indigo)}
|
||||
p{margin:0 0 12px}
|
||||
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:8px 0 22px}
|
||||
.kpi{background:var(--light);border:1px solid var(--line);border-radius:12px;padding:16px 16px 14px}
|
||||
.kpi .lab{font-size:13px;font-weight:600;color:var(--slate6)}
|
||||
.kpi .val{font-family:"Playfair Display",Georgia,serif;font-size:40px;line-height:1.1;margin:6px 0 4px;color:var(--indigo);display:flex;align-items:baseline;gap:8px}
|
||||
.kpi .sub{font-size:12.5px;color:var(--slate6);line-height:1.45}
|
||||
.grade{font-family:Inter,Pretendard,sans-serif;font-size:15px;font-weight:800;border-radius:999px;padding:2px 10px}
|
||||
.good{background:var(--good-bg);color:var(--good-tx);border:1px solid var(--good-bd)}.info{background:var(--info-bg);color:var(--info-tx);border:1px solid var(--info-bd)}
|
||||
.warn{background:var(--warn-bg);color:var(--warn-tx);border:1px solid var(--warn-bd)}.crit{background:var(--crit-bg);color:var(--crit-tx);border:1px solid var(--crit-bd)}
|
||||
.unv{background:#F1F5F9;color:var(--slate5);border:1px solid var(--line)}
|
||||
.badge{display:inline-block;font-family:Inter,Pretendard,sans-serif;font-size:15px;font-weight:800;border-radius:8px;padding:1px 10px;min-width:34px;text-align:center}
|
||||
.badge.unv{font-size:12px;font-weight:600}
|
||||
table.t{width:100%;border-collapse:collapse;font-size:14px;margin:8px 0 18px;page-break-inside:auto}
|
||||
table.t th{background:var(--light);text-align:left;font-weight:700;padding:9px 10px;border-bottom:2px solid var(--line);font-size:13.5px;color:var(--indigo);vertical-align:bottom}
|
||||
table.t th small{font-weight:500;color:var(--slate6)}
|
||||
table.t td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
table.t tr{page-break-inside:avoid}
|
||||
table.t td.num{text-align:center;white-space:nowrap}
|
||||
table.t .mono{font-family:Inter,Menlo,monospace;font-size:12.5px;color:var(--slate6)}
|
||||
table.t tr.hl td{background:#F3F0FF;font-weight:600}
|
||||
table.t tr.sum td{background:var(--light)}
|
||||
table.t tr.grp td{background:#EEF2FF;color:var(--violet);font-size:14px}
|
||||
td .cat{display:block;font-size:12.5px;color:var(--slate5);font-weight:400;line-height:1.4;margin-top:2px}
|
||||
.o{color:var(--good-tx);font-weight:800}.x{color:var(--slate5)}
|
||||
.findings{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:6px 0 10px}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px 18px;page-break-inside:avoid}
|
||||
.card h4{margin:0 0 6px;font-size:16px}
|
||||
.card p{font-size:14.5px;margin:0}
|
||||
.card .big{font-size:26px;font-family:"Playfair Display",Georgia,serif;margin:2px 0}
|
||||
.card .big small{font-size:13px;font-family:Pretendard,sans-serif;color:var(--slate6)}
|
||||
.card .sub{font-size:13px;color:var(--slate6);margin-top:4px}
|
||||
.card.dark{background:var(--navy);color:#fff;border:0}.card.dark h4{color:#C4B5FD}.card.dark p{color:#fff}
|
||||
.num-badge{display:inline-block;width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;font-weight:800;text-align:center;line-height:26px;font-size:13px;margin-right:8px;font-family:Inter,Pretendard,sans-serif}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:6px 0 14px}
|
||||
.prio{font-size:19px;font-weight:700;color:var(--indigo);margin:14px 0 8px}
|
||||
ol.actions{padding-left:0;list-style:none;margin:0}
|
||||
ol.actions li{padding:10px 0;border-bottom:1px solid var(--line);font-size:15px;display:flex;gap:10px;align-items:flex-start}
|
||||
.quotes{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:6px 0 16px}
|
||||
.quote{background:var(--light);border:1px solid var(--line);border-radius:12px;padding:14px 16px;page-break-inside:avoid}
|
||||
.quote .qh{font-size:14px;margin-bottom:6px;color:var(--indigo)}
|
||||
.quote .eng{display:inline-block;font-size:11.5px;background:#fff;border:1px solid var(--line);border-radius:999px;padding:0 8px;margin-left:6px;color:var(--slate6);font-family:Inter,Pretendard,sans-serif}
|
||||
.quote p{font-size:13.5px;color:var(--slate6);margin:0 0 6px;line-height:1.55}
|
||||
.quote .cite{font-size:12px;color:var(--slate5);font-family:Inter,Pretendard,sans-serif}
|
||||
h4.v1cat{font-family:"Playfair Display",Georgia,serif;font-size:20px;margin:22px 0 6px;color:var(--indigo)}
|
||||
h4.phase{font-size:17px;padding:8px 14px;border-radius:8px;margin:22px 0 6px}
|
||||
h4.phase.crit{background:var(--crit-bg);color:var(--crit-tx)}h4.phase.warn{background:var(--warn-bg);color:var(--warn-tx)}h4.phase.info{background:var(--info-bg);color:var(--info-tx)}
|
||||
.note{background:var(--light);border-left:4px solid var(--accent);padding:12px 16px;border-radius:0 10px 10px 0;font-size:14px;margin:12px 0 18px}
|
||||
.note b{color:var(--violet)}
|
||||
.footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--line);font-size:12.5px;color:var(--slate5)}
|
||||
.pb{page-break-before:always}
|
||||
@page{size:A4;margin:12mm 11mm 14mm}
|
||||
@media print{body{font-size:13.5px}.page{max-width:none;padding:0}.cover{margin:0 0 24px;padding:44px 40px 36px}.cover h1{font-size:32px}h2{font-size:24px;margin-top:26px}
|
||||
.kpis{grid-template-columns:repeat(5,1fr);gap:8px}.kpi .val{font-size:30px}table.t{font-size:12px}table.t th{font-size:12px}
|
||||
.findings,.quotes{grid-template-columns:1fr 1fr}.grid3{grid-template-columns:repeat(3,1fr)}}
|
||||
"""
|
||||
|
||||
def render():
|
||||
pp, oa = stats["perplexity"], stats["openai"]
|
||||
findings = "".join(f'<div class="card"><h4><span class="num-badge">{i}</span>{esc(t)}</h4><p>{esc(b)}</p></div>' for i, (t, b) in enumerate(NARR["findings"], 1))
|
||||
actions = "".join(f'<li><span class="num-badge">{i}</span><span>{esc(a)}</span></li>' for i, a in enumerate(NARR["top_actions"], 1))
|
||||
method_rows = "".join(f"<tr><td><b>{esc(a)}</b></td><td>{esc(b)}</td></tr>" for a, b in NARR["method"])
|
||||
limits = "".join(f"<li>{esc(x)}</li>" for x in NARR["limits"])
|
||||
aux_rows = "".join(f"<tr><td><b>{esc(a)}</b></td><td>{lvl_badge(l)}</td><td>{esc(b)}</td></tr>" for a, l, b in NARR["aux"])
|
||||
kpi_rows = "".join(f"<tr><td><b>{esc(a)}</b></td><td>{esc(b)}</td><td>{esc(c)}</td><td>{esc(d)}</td></tr>" for a, b, c, d in NARR["kpi_plan"])
|
||||
|
||||
body = f"""
|
||||
<div class="page">
|
||||
<div class="cover">
|
||||
<div class="eyebrow">INFINITH AI Discovery · Global Patient Edition</div>
|
||||
<h1>뷰성형외과 영문 사이트<br>AEO · GEO 진단 보고서</h1>
|
||||
<p class="sub">외국인 환자 관점 질문 60문항 실측과 답변·출처 준비도 채점</p>
|
||||
<div class="meta"><span>진단일 <b>{DATE}</b></span><span>대상 <b>{SITE}</b> (en-US · x-default)</span><span>기준표 <b>AEO/GEO v2.0</b> (v1.0 36항목 병행)</span><span>답변엔진 <b>Perplexity sonar · OpenAI gpt-4o web_search (뉴욕)</b></span><span>문의 <b>o2oteam@o2o.kr</b></span></div>
|
||||
</div>
|
||||
|
||||
<h2><span class="n">01</span>요약 판정</h2>
|
||||
<p class="lead">{esc(NARR["lead"])}</p>
|
||||
{kpi_row()}
|
||||
<div class="findings">{findings}</div>
|
||||
<div class="prio">먼저 할 일 5가지</div>
|
||||
<ol class="actions">{actions}</ol>
|
||||
|
||||
<h2 class="pb"><span class="n">02</span>진단 범위와 방법</h2>
|
||||
<p class="lead">국문 사이트 진단(2026-08-31 · 09-07)과 같은 기준표를 영문 사이트와 외국인 질문에 적용했습니다.</p>
|
||||
<table class="t"><tbody>{method_rows}</tbody></table>
|
||||
<h4>이번 측정의 한계</h4><ul>{limits}</ul>
|
||||
|
||||
<h2 class="pb"><span class="n">03</span>외국인 질의 실측 결과</h2>
|
||||
<p class="lead">{esc(NARR["qb_lead"])}</p>
|
||||
<h3>3.1 질문군별 언급률</h3>
|
||||
<p>셀 값은 순서대로 뷰성형외과 언급 비율, 답변에서 첫 번째로 등장한 비율, 답변이 viewplasticsurgery.com 페이지를 인용한 비율입니다.</p>
|
||||
{cat_table()}
|
||||
<h3>3.2 답변에 등장한 병원</h3>
|
||||
<p>{esc(NARR["clinic_text"])}</p>
|
||||
{clinic_table()}
|
||||
<h3>3.3 AI가 근거로 쓴 표면</h3>
|
||||
<p>{esc(NARR["domain_text"])}</p>
|
||||
{domain_table()}
|
||||
<h4>인용된 자사 페이지</h4>
|
||||
{own_url_table()}
|
||||
<h3>3.4 답변 감성</h3>
|
||||
{sentiment_block()}
|
||||
<h3>3.5 답변 사례</h3>
|
||||
{quote_cards(NARR["quote_ids"])}
|
||||
|
||||
<h2 class="pb"><span class="n">04</span>AEO 답변 준비도 <span class="grade {grade_cls(aeo['grade'])}" style="font-size:18px">{aeo['score']} · {aeo['grade']}</span></h2>
|
||||
<p class="lead">고객의 질문에 얼마나 명확하고 충분하게 답하는가. 8항목 100점.</p>
|
||||
{v2_table(aeo)}
|
||||
<div class="note">{esc(NARR["aeo_note"])}</div>
|
||||
|
||||
<h2 class="pb"><span class="n">05</span>GEO 출처 준비도 <span class="grade {grade_cls(geo['grade'])}" style="font-size:18px">{geo['score']} · {geo['grade']}</span></h2>
|
||||
<p class="lead">그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가. 8항목 100점.</p>
|
||||
{v2_table(geo)}
|
||||
<div class="note">{esc(NARR["geo_note"])}</div>
|
||||
|
||||
<h2 class="pb"><span class="n">06</span>게이트와 부가 지표</h2>
|
||||
<p class="lead">게이트 G1(AI 크롤러 접근) · G2(본문 렌더링) 모두 통과. 상한 적용 없음.</p>
|
||||
<table class="t"><thead><tr><th>지표</th><th>레벨</th><th>실측</th></tr></thead><tbody>{aux_rows}</tbody></table>
|
||||
|
||||
<h2 class="pb"><span class="n">07</span>국문 사이트와의 비교</h2>
|
||||
<p class="lead">{esc(NARR["compare_lead"])}</p>
|
||||
<div class="kpis" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="kpi"><div class="lab">v1.0 종합</div><div class="val">{KR_BASE['v1']} <small style="font-size:18px;color:#64748B">→</small> {v1['score']}</div><div class="sub">국문 C → 영문 {v1['grade']}</div></div>
|
||||
<div class="kpi"><div class="lab">AEO 답변 준비도</div><div class="val">{KR_BASE['aeo']} <small style="font-size:18px;color:#64748B">→</small> {aeo['score']}</div><div class="sub">국문 C → 영문 {aeo['grade']}</div></div>
|
||||
<div class="kpi"><div class="lab">GEO 출처 준비도</div><div class="val">{KR_BASE['geo']} <small style="font-size:18px;color:#64748B">→</small> {geo['score']}</div><div class="sub">국문 C → 영문 {geo['grade']}</div></div>
|
||||
</div>
|
||||
{compare_table()}
|
||||
|
||||
<h2 class="pb"><span class="n">08</span>개선 로드맵</h2>
|
||||
<p class="lead">{esc(NARR["roadmap_lead"])}</p>
|
||||
{roadmap()}
|
||||
|
||||
<h2><span class="n">09</span>측정 계획과 KPI</h2>
|
||||
<p class="lead">개선 전후를 같은 60문항 · 같은 두 엔진으로 월 1회 재실측합니다.</p>
|
||||
<table class="t"><thead><tr><th>지표</th><th>현재 (2026-09-10)</th><th>90일 목표</th><th>측정 방법</th></tr></thead><tbody>{kpi_rows}</tbody></table>
|
||||
|
||||
<h2 class="pb"><span class="n">A</span>부록 A · 60문항 실측표</h2>
|
||||
<p class="lead">O = 뷰성형외과 언급, 숫자 = 답변 내 등장 순위. 감성은 후처리 모델 1차 분류.</p>
|
||||
{appendix_questions()}
|
||||
|
||||
<h2 class="pb"><span class="n">B</span>부록 B · v1.0 36항목 판정</h2>
|
||||
<p class="lead">자동 채점 20항목 + 사람 보정 10항목 + 미검증 6항목. 미검증은 분모에서 제외합니다.</p>
|
||||
{v1_table()}
|
||||
|
||||
<div class="footer">INFINITH AI Discovery · 뷰성형외과 영문 사이트 진단 · {DATE} · 이 점수는 INFINITH 제품용 진단 기준이며 검색엔진의 공식 점수나 인용 확률이 아닙니다 · 문의 o2oteam@o2o.kr</div>
|
||||
</div>"""
|
||||
head = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>뷰성형외과 영문 사이트 AEO · GEO 진단 보고서 {DATE}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Playfair+Display:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">
|
||||
<style>{CSS}</style></head><body>"""
|
||||
return head + body + "</body></html>"
|
||||
|
||||
|
||||
def to_pdf(html_path, pdf_path):
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
pg = b.new_page()
|
||||
pg.goto("file://" + html_path, wait_until="networkidle")
|
||||
pg.wait_for_timeout(1200)
|
||||
pg.emulate_media(media="print")
|
||||
pg.pdf(path=pdf_path, format="A4", print_background=True, prefer_css_page_size=True)
|
||||
b.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_html = OUT_BASE + ".html"
|
||||
open(out_html, "w", encoding="utf-8").write(render())
|
||||
print("HTML:", out_html)
|
||||
if "--no-pdf" not in sys.argv:
|
||||
to_pdf(out_html, OUT_BASE + ".pdf")
|
||||
print("PDF:", OUT_BASE + ".pdf")
|
||||
421
docs/reports/viewclinic/05_global_en/narrative_ko.json
Normal file
421
docs/reports/viewclinic/05_global_en/narrative_ko.json
Normal file
@ -0,0 +1,421 @@
|
||||
{
|
||||
"lead": "영문 사이트는 국문 사이트보다 답변·출처 준비도가 모두 높습니다(AEO 65 · GEO 72, 둘 다 B). 외국인이 병원 이름을 알고 묻는 질문에는 두 엔진 모두 100% 답하지만, 병원을 모르는 상태의 시술·가격 질문에서는 언급률이 0~21%에 그칩니다. 원인은 콘텐츠 부족이 아니라 인용된 문단에 상호가 없어서입니다.",
|
||||
"findings": [
|
||||
[
|
||||
"이름을 알면 100%, 모르면 0~21%",
|
||||
"브랜드(A)·이동·체류(B) 질문군은 두 엔진 모두 뷰성형외과를 1순위로 답하고 viewplasticsurgery.com을 직접 인용합니다. 반면 시술 정보(D)는 Perplexity 14% · OpenAI 21%, 가격(E)은 두 엔진 모두 0%입니다. 외국인 환자 여정에서 가장 긴 구간이 비어 있습니다."
|
||||
],
|
||||
[
|
||||
"인용은 되는데 이름이 안 나오는 문항 8개",
|
||||
"Perplexity는 가격 질문(E-01 가슴성형 비용, E-02 V-line 비용)과 시술 질문(D-01·D-04·D-05·D-10)에서 뷰 블로그·시술 페이지를 출처로 썼지만 답변에 'VIEW'가 한 번도 등장하지 않았습니다. 인용된 문단이 'The cost of V-line surgery in Seoul ranges…'처럼 상호 없이 쓰여 있어 AI가 수치만 가져가고 병원 이름은 남기지 않습니다."
|
||||
],
|
||||
[
|
||||
"추천 질문의 경쟁 구도: JK · ID · Banobagi",
|
||||
"'Best plastic surgery clinic in Seoul for foreigners' 류 추천 질문(C)에서 뷰는 Perplexity 9/12, OpenAI 6/12에 등장했지만 1순위는 드뭅니다. JK Plastic Surgery(KAHF 인증 4회 연속), ID Hospital, Banobagi가 상위를 차지하고, 그 근거는 유치 에이전시·중개 플랫폼의 영문 가이드 글입니다."
|
||||
],
|
||||
[
|
||||
"AI가 근거로 쓰는 표면은 Reddit · Bookimed · 에이전시 가이드",
|
||||
"두 엔진 합산 인용 도메인 1위는 viewplasticsurgery.com(54문항), 2위는 reddit.com(49문항)입니다. 그다음이 us-uk.bookimed.com · clinicsoncall.com · koreaclinicguide.com · clinicseoul.net · unni.app 같은 중개·가이드 표면입니다. 강남언니 국문 페이지는 60문항 어디에도 인용되지 않았습니다."
|
||||
],
|
||||
[
|
||||
"엔티티 스키마 0, hreflang은 리다이렉트 URL",
|
||||
"홈 JSON-LD가 병원을 Person 타입으로 선언하고 MedicalClinic·Physician 스키마와 sameAs가 없습니다(GEO4 6.3/15). 10개 언어 hreflang은 갖췄지만 8개 언어 서브도메인이 경로 URL로 301 하고, origin.·olden. 서브도메인이 전체 사이트를 색인 가능 상태로 서빙해 AI 인용 URL이 분산됩니다."
|
||||
],
|
||||
[
|
||||
"부정 답변 1건: 공정위 후기 광고 시정명령",
|
||||
"'Are there red flags about VIEW Plastic Surgery'(F-05) 질문에 Perplexity는 국문 사이트 후기 광고에 대한 2026-07 공정위 시정명령을 첫 문장으로 답했습니다. 영문 Real Review 섹션도 같은 구조(대가 고지 없음)라 GEO7 발행 투명성 항목의 감점 요인입니다."
|
||||
]
|
||||
],
|
||||
"top_actions": [
|
||||
"시술 페이지·블로그 가격 가이드의 첫 문단과 표 캡션에 'At VIEW Plastic Surgery in Gangnam, Seoul' 형태로 상호·지역을 넣습니다. 인용은 되는데 이름이 안 나오는 8문항이 바로 대상입니다.",
|
||||
"MedicalClinic + Physician JSON-LD를 추가하고 Person 타입을 Organization/MedicalClinic으로 바꾸며 sameAs에 Instagram·TikTok·YouTube·Google 프로필·Bookimed·RealSelf·unni.app을 연결합니다.",
|
||||
"시술 페이지 상단에 결정 정보 블록(USD 가격대 · 수술 시간 · 입원 · 권장 체류일 · 귀국 가능일)을 표로 두고 블로그 가격 가이드와 상호 링크합니다. 가격(E)·시술(D) 질문군 0~21%의 직접 대응입니다.",
|
||||
"FAQ 402문항에 FAQPage 스키마를 붙이고 카테고리별 URL로 나눕니다. 전 페이지 동일 H1('korea plastic surgery')을 페이지별 서술형 H1으로 교체합니다.",
|
||||
"origin.·olden. 서브도메인을 www로 301 하고 언어 서브도메인 hreflang을 최종 URL로 통일합니다. 후기·전후 섹션에 대가 제공 여부와 개인차 고지를 넣어 정보 페이지와 분리합니다."
|
||||
],
|
||||
"method": [
|
||||
[
|
||||
"진단 대상",
|
||||
"https://www.viewplasticsurgery.com (en-US, x-default). 홈 · About · 의료진 · 컨시어지 · 오시는길 · 안전 · 수상 · FAQ · 가슴(Motiva) · 코 · 윤곽 · 블로그 목록 · 블로그 글 1건 등 13개 페이지를 JS 미실행 HTML로 수집"
|
||||
],
|
||||
[
|
||||
"자동 채점",
|
||||
"scripts/audit_aeo_geo.py (기준표 v1.0 36항목 중 자동 20항목). AI 봇 12종 UA 접근, sitemap, JSON-LD, hreflang, H1/H2, FAQ, 표, alt, TTFB 실측"
|
||||
],
|
||||
[
|
||||
"사람 판정",
|
||||
"C1 정의문 · C4 수치 · C7 전문성 · C8 자기완결성 · B4/B7 · D1~D4 · E4 · F2 (10항목) + v2.0 사람 판정 4항목(AEO6 · GEO1 · GEO6 · GEO7). 근거는 각 표에 기재"
|
||||
],
|
||||
[
|
||||
"질문 뱅크",
|
||||
"외국인 환자 관점 영문 60문항 · 7군 (브랜드 8 · 이동·체류 8 · 추천·비교 12 · 시술 14 · 가격 8 · 안전·신뢰 6 · 사후관리 4). 국문 120문항 뱅크와 같은 퍼널 구조"
|
||||
],
|
||||
[
|
||||
"답변엔진",
|
||||
"Perplexity API sonar (자체 웹검색) · OpenAI Responses API gpt-4o + web_search (사용자 위치 미국 뉴욕). 소비자 ChatGPT UI 자동 조회는 약관 위반이라 쓰지 않음. 각 문항 1회 질의, 2026-09-10"
|
||||
],
|
||||
[
|
||||
"후처리",
|
||||
"답변에서 병원명 최대 8개를 등장 순서로 추출(gpt-4o-mini). VIEW Plastic Surgery · View Clinic · VIEW Seoul 등 표기 변형을 같은 병원으로 정규화. 감성은 긍정·중립·부정 1차 분류"
|
||||
],
|
||||
[
|
||||
"점수 산식",
|
||||
"v2.0: 항목 점수 = 파생 신호(v1.0 레벨 0~3) 가중 평균 × 배점, 사람 판정 우선, 미검증은 분모 제외. 게이트 실패 시 상한 40. 등급 A 81+ · B 61–80 · C 41–60 · D 40 이하"
|
||||
]
|
||||
],
|
||||
"limits": [
|
||||
"질의는 문항당 1회입니다. 답변엔진은 같은 질문에도 매번 다른 병원을 고를 수 있어 5회 반복 평균이 정식 기준선입니다.",
|
||||
"OpenAI gpt-4o는 60문항 중 23문항에서 웹검색을 실행하지 않고 모델 기억으로 답했습니다(시술·가격 일반 질문 대부분). 이 문항의 미언급은 사이트 문제가 아니라 모델 기억에 뷰가 없다는 뜻입니다.",
|
||||
"Google 비즈니스 프로필은 2026-08-31 실측값(4.9점 · 리뷰 901건)을 인용했습니다. 이번에는 Places API 키가 없어 재실측하지 못했습니다.",
|
||||
"Bing 색인(A6), 서버 로그(E1), GA4(E2), GSC(E3)는 소유자 계정이 필요해 미검증입니다.",
|
||||
"감성 분류는 후처리 모델의 1차 판정이며 감성 기준표 v0.3의 사람 검토 전 값입니다."
|
||||
],
|
||||
"qb_lead": "Perplexity는 60문항 중 30문항(50%), OpenAI 웹검색은 27문항(45%)에서 뷰성형외과를 언급했습니다. 국문 120문항 실측(2026-09-01, Perplexity 32% · OpenAI 37%)보다 높지만, 상승분은 전부 병원 이름이 들어간 질문에서 나왔습니다.",
|
||||
"cat_interp": {
|
||||
"A": "두 엔진 모두 전 문항 1순위. 자사 사이트·에이전시 블로그·정부 의료관광 페이지가 근거",
|
||||
"B": "공항 픽업·숙박·WhatsApp 상담은 컨시어지 페이지가 직접 인용됨. 미언급 2문항은 비자·체류기간 일반 질문",
|
||||
"C": "Perplexity는 9/12 언급하나 1순위는 4건. OpenAI는 절반. JK·ID·Banobagi가 상위, 근거는 제3자 가이드 글",
|
||||
"D": "병원 이름이 들어간 D-11·D-12만 언급. 나머지 12문항은 뷰 페이지가 인용돼도 이름이 안 나옴",
|
||||
"E": "두 엔진 모두 0. Perplexity는 뷰 블로그 가격 가이드를 2문항에서 인용했으나 상호 없이 수치만 사용. OpenAI는 가격 질문 대부분을 검색 없이 답변",
|
||||
"F": "마취과 전문의(F-02)·보증(F-06)은 언급. 안전 일반론(F-01·F-03·F-04)은 미언급. F-05는 공정위 시정명령이 첫 문장",
|
||||
"G": "병원 이름이 있는 G-02·G-04만 언급. 원격 팔로업 일반 질문(G-01)에서 Perplexity 4순위"
|
||||
},
|
||||
"clinic_text": "두 엔진을 합쳐 뷰성형외과는 57문항에서 언급됐고 그중 40문항이 1순위입니다. 그러나 1순위는 대부분 브랜드·체류 질문입니다. 병원을 고르는 추천·비교 질문에서는 JK Plastic Surgery, ID Hospital, Banobagi, AB Plastic Surgery, SAERO가 반복 등장합니다.",
|
||||
"domain_text": "인용 도메인은 세 층으로 나뉩니다. 자사 사이트(viewplasticsurgery.com 54문항), 커뮤니티(reddit.com 49문항), 그리고 중개·가이드 표면(Bookimed · clinicsoncall · koreaclinicguide · clinicseoul.net · unni.app · khunkim.com)입니다. 강남언니 국문 페이지와 네이버는 한 번도 인용되지 않았습니다. 외국인 대상 GEO는 이 세 층에 원문을 두는 일입니다.",
|
||||
"quote_ids": [
|
||||
[
|
||||
"E-02",
|
||||
"perplexity"
|
||||
],
|
||||
[
|
||||
"C-01",
|
||||
"perplexity"
|
||||
],
|
||||
[
|
||||
"C-02",
|
||||
"openai"
|
||||
],
|
||||
[
|
||||
"F-05",
|
||||
"perplexity"
|
||||
],
|
||||
[
|
||||
"D-11",
|
||||
"openai"
|
||||
],
|
||||
[
|
||||
"E-05",
|
||||
"openai"
|
||||
]
|
||||
],
|
||||
"aeo_note": "AEO 65점의 감점은 AEO2 답변의 직접성(6.7/20)과 AEO4 의사결정 정보(9/15)에 집중됩니다. 정의문이 메타 설명에만 있고 본문은 슬로건으로 시작하며, 시술 페이지에 가격·시간·회복 수치가 없습니다. 이 두 항목이 그대로 시술(D)·가격(E) 질문군의 0~21%로 나타납니다.",
|
||||
"geo_note": "GEO 72점의 감점은 GEO4 엔티티 정합성(6.3/15)과 GEO3 전문성 스키마(9/15)입니다. 의료진 자격·논문은 텍스트로 충분하지만 Physician 스키마가 없고, 병원 자체가 Person 타입으로 선언돼 있습니다. GEO7 투명성(6.7/10)은 후기·전후 섹션의 대가 고지 부재가 원인이며 F-05 답변에서 실제 감점으로 확인됩니다.",
|
||||
"v2_evidence": {
|
||||
"AEO1": "FAQ 페이지에 402문항이 서버 렌더링(카테고리 11개). 시술 페이지 하단에도 FAQ 5문항. FAQPage 스키마는 블로그 글에만 있고 FAQ 페이지에는 없음. 질문 뱅크 대비 A·B군은 사이트가 직접 답하고, D·E군 답은 블로그에 있으나 상호가 없음",
|
||||
"AEO2": "정의문이 About 메타 설명에만 있음('Founded in 2005, VIEW is a premier 19-story medical center in Gangnam, Seoul'). 본문 첫 단락은 슬로건, 시술 페이지 첫 텍스트는 'Let Me In Season 2/3/4/5 Selected Doctors' 홍보 라벨, 홈 첫 200자는 검색창·메뉴",
|
||||
"AEO3": "5개 핵심 페이지 단락 샘플 50개 중 상호 또는 시술명 포함 26개(52%). 상호 'VIEW' 포함은 7개(14%). FAQ 답변과 블로그 가격 문단은 시술명만 있고 상호가 없어 인용돼도 병원 이름이 남지 않음(실측 8문항)",
|
||||
"AEO4": "시술 페이지 본문에 가격·수술시간·회복기간 0회. 수치는 Motiva 보증(재수술 지원 300만원/개)뿐. HTML 표는 10개 존재. 블로그에는 USD 가격표(V-line $7,000–12,000)와 체류 권장일(10–14일)이 있으나 시술 페이지와 연결되지 않음",
|
||||
"AEO5": "전 페이지 H1이 동일하게 'korea plastic surgery'(키워드 라벨). 페이지 고유 제목은 H2·H3. H2 'What would you like to know today?'(검색창)가 모든 페이지에 반복",
|
||||
"AEO7": "JS 미실행 HTML 본문 79,646자 서버 렌더링. 메인 이미지 500개 alt 누락 0%",
|
||||
"AEO8": "sitemap index 10개 · URL 482개 · lastmod 2026-09-09. canonical self 지정. 다만 origin.·olden. 서브도메인이 전체 사이트를 200·index로 서빙(canonical은 www 지정)해 AI 답변이 origin.·olden. URL을 인용함"
|
||||
},
|
||||
"aux": [
|
||||
[
|
||||
"A6 Bing 색인 (ChatGPT 검색 경로)",
|
||||
"unverified",
|
||||
"Bing 검색 결과 페이지가 스크립트 렌더링이라 curl로 확인 불가. Bing Webmaster Tools 등록 여부는 소유자 확인 필요"
|
||||
],
|
||||
[
|
||||
"A7 응답 속도·크롤 예산",
|
||||
2,
|
||||
"TTFB 0.87s · 초기 HTML 587KB · 시술 페이지 212KB. 속도 양호, HTML 비대"
|
||||
],
|
||||
[
|
||||
"B7 다국어 hreflang",
|
||||
2,
|
||||
"10개 언어(en-US · zh-CN · th · ja · vi · ru · id · mn · es · ar) 양방향 선언. th·jp·vn·ru·id·mn·es·ar 8개 서브도메인은 viewplasticsurgery.com/{lang}/ 로 301. cn은 en-US 대신 en 선언"
|
||||
],
|
||||
[
|
||||
"D3 오픈웹 언급 (해외)",
|
||||
2,
|
||||
"에이전시 블로그(khunkim · clinicsoncall · curemeabroad · kmhglobal), 정부 표면(visitkorea · 강남구 의료관광), Reddit · Purseblog 포럼, LinkedIn에 분산. 영어권 주요 언론 기사·Wikipedia 없음"
|
||||
],
|
||||
[
|
||||
"D5 네이버 플레이스",
|
||||
"unverified",
|
||||
"외국인 질의 60문항에서 네이버 표면 인용 0건. 외국인 관점에서는 채점 대상 아님"
|
||||
],
|
||||
[
|
||||
"E1 AI 크롤러 로그",
|
||||
"unverified",
|
||||
"서버 로그 접근 필요. Perplexity가 60문항 중 38문항에서 자사 URL을 인용했으므로 PerplexityBot 히트는 존재할 것으로 추정"
|
||||
],
|
||||
[
|
||||
"E2 GA4 AI 유입 채널",
|
||||
"unverified",
|
||||
"GA4 접근 필요. chatgpt.com · perplexity.ai · copilot 리퍼러 채널 그룹 설정 여부 미확인"
|
||||
],
|
||||
[
|
||||
"E3 GSC AI Overviews",
|
||||
"unverified",
|
||||
"GSC 접근 필요"
|
||||
],
|
||||
[
|
||||
"E4 기준선 스팟체크",
|
||||
2,
|
||||
"60문항 × 2엔진 × 1회 실측 완료(이 보고서). 5회 반복 미실시"
|
||||
],
|
||||
[
|
||||
"F2 CMS 보안·최신성",
|
||||
2,
|
||||
"WordPress 7.1(최신 계열). meta generator로 버전 노출"
|
||||
],
|
||||
[
|
||||
"F3 페이지 무게",
|
||||
2,
|
||||
"초기 HTML 587KB(300~800KB 구간)"
|
||||
],
|
||||
[
|
||||
"F4 모바일 대응",
|
||||
2,
|
||||
"viewport 존재 · 반응형. Core Web Vitals 미측정"
|
||||
]
|
||||
],
|
||||
"compare_lead": "같은 병원의 두 사이트를 같은 기준표로 채점했습니다. 영문 사이트가 세 점수 모두 한 등급 높고, 질문 뱅크 언급률도 13~18%p 높습니다. 차이는 콘텐츠 양(FAQ 402건 · 블로그 주 1회 이상 발행 · 의료진 논문 목록)에서 나왔고, 남은 감점 원인(정의문 · 수치 · 스키마 · 상호 없는 문단)은 두 사이트가 같습니다.",
|
||||
"compare_rows": [
|
||||
[
|
||||
"v1.0 종합",
|
||||
"54 / C (검증 32/36)",
|
||||
"64 / B (검증 31/36)",
|
||||
"+10"
|
||||
],
|
||||
[
|
||||
"AEO 답변 준비도",
|
||||
"42 / C",
|
||||
"65 / B",
|
||||
"+23"
|
||||
],
|
||||
[
|
||||
"GEO 출처 준비도",
|
||||
"56 / C",
|
||||
"72 / B",
|
||||
"+16"
|
||||
],
|
||||
[
|
||||
"질문 뱅크 언급률",
|
||||
"120문항 · Perplexity 32% · OpenAI 37% (09-01)",
|
||||
"60문항 · Perplexity 50% · OpenAI 45% (09-10)",
|
||||
"+18p / +8p"
|
||||
],
|
||||
[
|
||||
"C1 정의문",
|
||||
"없음 (0)",
|
||||
"메타 설명에만 (1)",
|
||||
"본문 정의문은 둘 다 없음"
|
||||
],
|
||||
[
|
||||
"C2 FAQ",
|
||||
"재검증 후 2",
|
||||
"FAQ 페이지 402문항 · 시술별 5문항 (2)",
|
||||
"영문이 양은 많으나 FAQPage 스키마는 블로그에만"
|
||||
],
|
||||
[
|
||||
"C3 H1",
|
||||
"'VIEW SELFIE' 등 영문 라벨 (0)",
|
||||
"전 페이지 동일 'korea plastic surgery' (2)",
|
||||
"둘 다 페이지별 서술형 H1 없음"
|
||||
],
|
||||
[
|
||||
"C4 · C5 수치와 표",
|
||||
"C4=2 · C5=0",
|
||||
"C4=1 · C5=3",
|
||||
"영문은 수치가 블로그에, 표는 시술 페이지에 따로 있음"
|
||||
],
|
||||
[
|
||||
"C6 신선도",
|
||||
"블로그 2024-05-31 이후 정지 (재검증 후 3)",
|
||||
"블로그 2026-09-09 발행 · sitemap lastmod 당일 (2)",
|
||||
"영문은 갱신 활발, dateModified 마크업은 둘 다 없음"
|
||||
],
|
||||
[
|
||||
"C7 전문성",
|
||||
"2",
|
||||
"학위 · 전문의 · 학회 · 논문 목록 텍스트 (3)",
|
||||
"영문 의료진 상세가 더 충실"
|
||||
],
|
||||
[
|
||||
"B2 · B3 스키마",
|
||||
"B2=1 · B3=0",
|
||||
"B2=0 · B3=0 (Person 타입 오선언)",
|
||||
"둘 다 MedicalClinic · Physician 없음"
|
||||
],
|
||||
[
|
||||
"B7 hreflang",
|
||||
"없음",
|
||||
"10개 언어 (2)",
|
||||
"영문만 다국어 연결, 대상 URL 정리 필요"
|
||||
],
|
||||
[
|
||||
"GEO7 투명성",
|
||||
"3.3 / 10 (공정위 시정명령)",
|
||||
"6.7 / 10",
|
||||
"영문 Real Review도 대가 고지 없음, F-05 답변에 노출"
|
||||
]
|
||||
],
|
||||
"roadmap_lead": "우선순위는 실측에서 확인된 감점 순서입니다. 즉시 조치는 개발 작업 없이 텍스트·스키마 수정으로 끝나고, 30일 조치는 시술 페이지 콘텐츠 블록, 90일 조치는 외부 표면과 측정 인프라입니다.",
|
||||
"roadmap": [
|
||||
[
|
||||
"즉시 (0–2주) · 텍스트와 스키마",
|
||||
"crit",
|
||||
[
|
||||
[
|
||||
"시술 페이지·블로그 가격 가이드의 첫 문단과 표 캡션에 'At VIEW Plastic Surgery in Gangnam, Seoul, …' 형태로 상호·지역 삽입. FAQ 답변 첫 문장에도 'At VIEW' 추가",
|
||||
"AEO3 · C8 / D·E군 8문항",
|
||||
"인용은 되는데 이름이 안 나오는 8문항이 언급으로 전환. 답변 단위 자기완결성 52% → 80%"
|
||||
],
|
||||
[
|
||||
"JSON-LD: Person → MedicalClinic(+Organization)으로 교체, name · address · telephone · openingHours · medicalSpecialty · availableLanguage(10개) 기재. 의료진 26명 Physician 스키마(자격 · 소속 · sameAs)",
|
||||
"GEO3 · GEO4 / B2 · B3 · B5",
|
||||
"엔티티 정합성 6.3 → 12/15. 의료진 질문(A-04 · A-05 · F-02) 근거가 스키마로 확정"
|
||||
],
|
||||
[
|
||||
"sameAs 채우기: Instagram · TikTok · YouTube · Facebook · Google 비즈니스 프로필 · Bookimed · RealSelf · unni.app · LinkedIn",
|
||||
"GEO4 / B5",
|
||||
"AI가 표면 간 동일 병원임을 확인. Reddit · Bookimed 인용이 뷰 엔티티로 연결"
|
||||
],
|
||||
[
|
||||
"FAQ 페이지 402문항에 FAQPage 스키마 부여, 카테고리별 URL(/faq/breast/ 등 11개)로 분할",
|
||||
"AEO1 / C2",
|
||||
"질문 수요 충족 13.3 → 20/20. 시술 질문(D) 인용 페이지 확보"
|
||||
],
|
||||
[
|
||||
"H1을 페이지별 서술형으로 교체('Motiva Breast Augmentation in Seoul at VIEW Plastic Surgery'). 검색창 H2 제거",
|
||||
"AEO5 / C3",
|
||||
"제목 계층 6.7 → 10/10"
|
||||
],
|
||||
[
|
||||
"origin.·olden. 서브도메인 → www 301. 언어 서브도메인 8개의 hreflang을 최종 URL(/th/ 등)로 통일, cn hreflang en-US로 정정",
|
||||
"AEO8 · B7",
|
||||
"인용 URL 분산 해소, 다국어 신호 정합"
|
||||
],
|
||||
[
|
||||
"메타 설명 오류 정정: 가슴 페이지 'No.1 Motiva usage in 2013' → 2023. 제목의 'Best' · 'No.1 Trusted' 표현은 근거 있는 표현으로 교체",
|
||||
"GEO1",
|
||||
"주장·근거 일치 13.3 → 16/20"
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
"30일 · 시술 페이지 결정 정보",
|
||||
"warn",
|
||||
[
|
||||
[
|
||||
"시술 페이지 상단에 정의문 + 결정 정보 블록: USD 가격대 · 수술 시간 · 마취 · 입원 · 권장 체류일 · 귀국 가능일 · 실밥 제거일을 표로. 부위별 8개 시술군 우선",
|
||||
"AEO2 · AEO4 / C1 · C4 / D·E군",
|
||||
"시술(D) 14~21% · 가격(E) 0% 구간의 직접 대응. 답변 직접성 6.7 → 16/20"
|
||||
],
|
||||
[
|
||||
"블로그 가격 가이드(가슴 · V-line · 코 · 양악 · 눈)와 시술 페이지 상호 링크. 표는 시술 페이지에 요약 이식",
|
||||
"AEO4 / C4 · C5",
|
||||
"의사결정 정보 9 → 13/15"
|
||||
],
|
||||
[
|
||||
"'귀국 후 합병증 대응 · 원격 팔로업' 전용 페이지: 절차 · 연락 채널 · 응답 시간 · 현지 협력 · Motiva 보증 청구 방법",
|
||||
"F-03 · G-01 · G-04",
|
||||
"안전(F) · 사후관리(G) 질문군에서 인용 원문 확보"
|
||||
],
|
||||
[
|
||||
"수상·인증 페이지에 주최 · 연도 · 원문 링크 명시(법무부 우수 유치기관 2018–2026, Motiva KOL, 서울시 표창)",
|
||||
"GEO1 / C-10",
|
||||
"정부 인증 질문(C-10)에서 1순위 유지, 주장 근거 일치 향상"
|
||||
],
|
||||
[
|
||||
"후기 · 전후 · 'Let Me In' 홍보를 정보 페이지와 분리. 후기에 대가 제공 여부 · 개인차 고지 첫 부분에 표기",
|
||||
"GEO7",
|
||||
"F-05 부정 답변의 맥락 제공. 발행 투명성 6.7 → 10/10"
|
||||
],
|
||||
[
|
||||
"dateModified · author · reviewedBy를 시술 페이지와 블로그 글에 마크업. 의료진 검토 표기",
|
||||
"GEO5 · GEO8",
|
||||
"최신성 6.7 → 10/10"
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
"90일 · 외부 표면과 측정",
|
||||
"info",
|
||||
[
|
||||
[
|
||||
"Google 비즈니스 프로필: 전화번호 등록, 영문 리뷰 응답, 컨시어지 서비스 속성 기재",
|
||||
"D2 · D4",
|
||||
"위치·연락처 질문(B) 오답 위험 제거"
|
||||
],
|
||||
[
|
||||
"Bookimed · RealSelf · unni.app 프로필 갱신(의료진 · 시술 · 가격대 · 언어). Reddit r/PlasticSurgery · r/korea 질문에 공식 계정으로 사실 답변",
|
||||
"D1 · D3",
|
||||
"reddit.com 49문항 · Bookimed 32문항 인용 표면에 뷰 원문 배치. 추천 질문(C) 1순위 비율 상승"
|
||||
],
|
||||
[
|
||||
"Bing Webmaster Tools 등록 + IndexNow. GSC · GA4 AI 유입 채널 그룹 설정, 서버 로그 AI 크롤러 IP 검증",
|
||||
"A6 · E1 · E2 · E3",
|
||||
"ChatGPT 검색 경로 확보, 소유 신호로 월간 측정"
|
||||
],
|
||||
[
|
||||
"60문항 × 2엔진 × 5회 반복 월간 재실측. 질문 뱅크에 일본어 · 중국어 · 태국어 각 60문항 추가(hreflang 언어 순)",
|
||||
"E4",
|
||||
"개선 전후 비교 기준선. 언어별 언급률 관리"
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
"kpi_plan": [
|
||||
[
|
||||
"60문항 언급률",
|
||||
"Perplexity 50% · OpenAI 45%",
|
||||
"Perplexity 65% · OpenAI 60%",
|
||||
"월 1회 60문항 × 2엔진 × 5회 반복"
|
||||
],
|
||||
[
|
||||
"시술(D) · 가격(E) 언급률",
|
||||
"D 14~21% · E 0%",
|
||||
"D 40% · E 30%",
|
||||
"같은 실측의 질문군별 집계"
|
||||
],
|
||||
[
|
||||
"인용됐지만 미언급 문항",
|
||||
"Perplexity 8문항",
|
||||
"0문항",
|
||||
"자사 URL 인용 여부와 상호 등장 여부 교차 집계"
|
||||
],
|
||||
[
|
||||
"자사 사이트 인용률",
|
||||
"Perplexity 63% · OpenAI 30%",
|
||||
"Perplexity 75% · OpenAI 45%",
|
||||
"인용 URL 도메인 집계"
|
||||
],
|
||||
[
|
||||
"추천 질문(C) 1순위",
|
||||
"Perplexity 4/12 · OpenAI 1/12",
|
||||
"각 6/12",
|
||||
"질문군 C 순위 집계"
|
||||
],
|
||||
[
|
||||
"AEO · GEO 점수",
|
||||
"65 · 72 (B · B)",
|
||||
"80 · 85 (A 진입)",
|
||||
"기준표 v2.0 재채점"
|
||||
],
|
||||
[
|
||||
"AI 크롤러 히트",
|
||||
"미측정",
|
||||
"월간 기준선 확보",
|
||||
"서버 로그 UA + IP 역검증"
|
||||
],
|
||||
[
|
||||
"부정 답변",
|
||||
"1문항 (F-05 공정위)",
|
||||
"정정 고지 페이지가 함께 인용",
|
||||
"감성 기준표 v0.3 사람 검토"
|
||||
]
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
62
docs/reports/viewclinic/05_global_en/question_bank_en.json
Normal file
62
docs/reports/viewclinic/05_global_en/question_bank_en.json
Normal file
@ -0,0 +1,62 @@
|
||||
[
|
||||
{"id":"A-01","cat":"A","question":"Is VIEW Plastic Surgery in Seoul legit? Is it a trustworthy clinic for foreigners?","stage":"decision"},
|
||||
{"id":"A-02","cat":"A","question":"Tell me about VIEW Plastic Surgery in Gangnam, Korea. What are they known for?","stage":"decision"},
|
||||
{"id":"A-03","cat":"A","question":"What procedures does VIEW Plastic Surgery Korea specialize in?","stage":"decision"},
|
||||
{"id":"A-04","cat":"A","question":"Who is Dr. Choi Soon Woo of VIEW Plastic Surgery? Is he board certified?","stage":"decision"},
|
||||
{"id":"A-05","cat":"A","question":"How many surgeons does VIEW Plastic Surgery have and are they all board-certified plastic surgeons?","stage":"decision"},
|
||||
{"id":"A-06","cat":"A","question":"Does VIEW Plastic Surgery have English-speaking staff or interpreters?","stage":"decision"},
|
||||
{"id":"A-07","cat":"A","question":"How long has VIEW Plastic Surgery been operating and how big is the clinic?","stage":"decision"},
|
||||
{"id":"A-08","cat":"A","question":"VIEW Plastic Surgery Korea reviews from international patients: what do foreigners say?","stage":"decision"},
|
||||
{"id":"B-01","cat":"B","question":"Where is VIEW Plastic Surgery located and how do I get there from Incheon Airport?","stage":"decision"},
|
||||
{"id":"B-02","cat":"B","question":"Does VIEW Plastic Surgery offer airport pickup for international patients?","stage":"decision"},
|
||||
{"id":"B-03","cat":"B","question":"What are VIEW Plastic Surgery's opening hours and can I book a consultation online from abroad?","stage":"decision"},
|
||||
{"id":"B-04","cat":"B","question":"Can I contact VIEW Plastic Surgery via WhatsApp or LINE for a consultation?","stage":"decision"},
|
||||
{"id":"B-05","cat":"B","question":"Does VIEW Plastic Surgery provide accommodation or hotel partnerships for foreign patients?","stage":"decision"},
|
||||
{"id":"B-06","cat":"B","question":"How long should I stay in Seoul after plastic surgery before flying home?","stage":"explore"},
|
||||
{"id":"B-07","cat":"B","question":"Do I need a medical visa for plastic surgery in Korea as a tourist?","stage":"explore"},
|
||||
{"id":"B-08","cat":"B","question":"Which Gangnam plastic surgery clinics are closest to Sinnonhyeon Station?","stage":"aware"},
|
||||
{"id":"C-01","cat":"C","question":"Best plastic surgery clinic in Seoul for foreigners","stage":"aware"},
|
||||
{"id":"C-02","cat":"C","question":"Best breast augmentation clinic in Korea for international patients","stage":"compare"},
|
||||
{"id":"C-03","cat":"C","question":"Top facial contouring and V-line surgery clinics in Gangnam","stage":"compare"},
|
||||
{"id":"C-04","cat":"C","question":"Best rhinoplasty surgeon in Seoul for foreigners","stage":"compare"},
|
||||
{"id":"C-05","cat":"C","question":"Best double jaw surgery clinic in Korea","stage":"compare"},
|
||||
{"id":"C-06","cat":"C","question":"Top 5 plastic surgery clinics in Gangnam, Seoul","stage":"aware"},
|
||||
{"id":"C-07","cat":"C","question":"Compare VIEW Plastic Surgery vs ID Hospital vs Banobagi for facial contouring","stage":"compare"},
|
||||
{"id":"C-08","cat":"C","question":"Which Korean plastic surgery clinics use Motiva implants and have the most experience with them?","stage":"compare"},
|
||||
{"id":"C-09","cat":"C","question":"Safest plastic surgery clinic in Korea with anesthesiologists on staff","stage":"compare"},
|
||||
{"id":"C-10","cat":"C","question":"Which Seoul plastic surgery clinics are officially recognized for medical tourism by the Korean government?","stage":"compare"},
|
||||
{"id":"C-11","cat":"C","question":"Best clinic in Korea for double eyelid surgery revision for foreigners","stage":"compare"},
|
||||
{"id":"C-12","cat":"C","question":"Large plastic surgery hospitals in Seoul with in-house recovery wards","stage":"compare"},
|
||||
{"id":"D-01","cat":"D","question":"Motiva breast augmentation in Korea: what should I know before booking?","stage":"explore"},
|
||||
{"id":"D-02","cat":"D","question":"What is Motiva Preserve and which clinics in Korea offer it?","stage":"explore"},
|
||||
{"id":"D-03","cat":"D","question":"How long is recovery after breast augmentation in Korea before I can fly?","stage":"explore"},
|
||||
{"id":"D-04","cat":"D","question":"What is the difference between V-line surgery and square jaw reduction?","stage":"explore"},
|
||||
{"id":"D-05","cat":"D","question":"Double jaw surgery in Korea: recovery timeline and risks for international patients","stage":"explore"},
|
||||
{"id":"D-06","cat":"D","question":"Incisional vs non-incisional double eyelid surgery in Korea: which is better?","stage":"explore"},
|
||||
{"id":"D-07","cat":"D","question":"Rhinoplasty in Korea with rib cartilage vs silicone: pros and cons","stage":"explore"},
|
||||
{"id":"D-08","cat":"D","question":"How is revision rhinoplasty done in Korea and how long do I need to stay?","stage":"explore"},
|
||||
{"id":"D-09","cat":"D","question":"Facelift in Korea for a 50-year-old: what techniques are used?","stage":"explore"},
|
||||
{"id":"D-10","cat":"D","question":"Is liposuction in Korea safe and how many days of downtime should I expect?","stage":"explore"},
|
||||
{"id":"D-11","cat":"D","question":"What does VIEW Plastic Surgery's breast surgery process look like from consultation to aftercare?","stage":"decision"},
|
||||
{"id":"D-12","cat":"D","question":"Does VIEW Plastic Surgery do a breast ultrasound or examination before augmentation?","stage":"decision"},
|
||||
{"id":"D-13","cat":"D","question":"Subbrow lift vs upper blepharoplasty: which should I get in Korea?","stage":"explore"},
|
||||
{"id":"D-14","cat":"D","question":"Mini lift vs full facelift in Korea: cost and recovery","stage":"explore"},
|
||||
{"id":"E-01","cat":"E","question":"How much does breast augmentation cost in Korea for foreigners in 2026?","stage":"explore"},
|
||||
{"id":"E-02","cat":"E","question":"V-line surgery cost in Korea 2026","stage":"explore"},
|
||||
{"id":"E-03","cat":"E","question":"How much does double jaw surgery cost in Seoul?","stage":"explore"},
|
||||
{"id":"E-04","cat":"E","question":"Is plastic surgery in Korea cheaper than in the US? How much can I save?","stage":"aware"},
|
||||
{"id":"E-05","cat":"E","question":"VIEW Plastic Surgery price list for international patients","stage":"decision"},
|
||||
{"id":"E-06","cat":"E","question":"What hidden costs should I expect for plastic surgery in Korea as a foreigner?","stage":"explore"},
|
||||
{"id":"E-07","cat":"E","question":"Can foreigners get a tax refund on plastic surgery in Korea?","stage":"explore"},
|
||||
{"id":"E-08","cat":"E","question":"Rhinoplasty cost in Gangnam for foreigners","stage":"explore"},
|
||||
{"id":"F-01","cat":"F","question":"Is plastic surgery in Korea safe for foreigners? What safety standards should I look for?","stage":"aware"},
|
||||
{"id":"F-02","cat":"F","question":"Does VIEW Plastic Surgery have anesthesiologists present during surgery?","stage":"decision"},
|
||||
{"id":"F-03","cat":"F","question":"What happens if I have complications after returning home from plastic surgery in Korea?","stage":"explore"},
|
||||
{"id":"F-04","cat":"F","question":"How do I verify a Korean plastic surgeon is board certified?","stage":"explore"},
|
||||
{"id":"F-05","cat":"F","question":"Are there red flags or complaints about VIEW Plastic Surgery Korea?","stage":"decision"},
|
||||
{"id":"F-06","cat":"F","question":"Which Korean plastic surgery clinics have a Motiva implant warranty?","stage":"compare"},
|
||||
{"id":"G-01","cat":"G","question":"Do Korean plastic surgery clinics offer remote follow-up after I go back to my country?","stage":"explore"},
|
||||
{"id":"G-02","cat":"G","question":"What aftercare does VIEW Plastic Surgery provide for international patients?","stage":"decision"},
|
||||
{"id":"G-03","cat":"G","question":"Where can I get post-surgery swelling care in Gangnam after surgery?","stage":"explore"},
|
||||
{"id":"G-04","cat":"G","question":"What is the Motiva implant warranty if I get surgery in Korea and live abroad?","stage":"explore"}
|
||||
]
|
||||
39
docs/reports/viewclinic/05_global_en/score_en.py
Normal file
39
docs/reports/viewclinic/05_global_en/score_en.py
Normal file
@ -0,0 +1,39 @@
|
||||
import json,sys,os
|
||||
sys.path.insert(0,'/Users/haewonkam/Claude/Agentic Marketing/INFINITH/scripts')
|
||||
import score_discovery_json as sd
|
||||
S=os.path.dirname(os.path.abspath(__file__))
|
||||
d=json.load(open(f'{S}/audit_en_auto.json'))
|
||||
results={r['criterionId']:dict(r) for r in d['results']}
|
||||
for cid,o in json.load(open(f'{S}/audit_en_overrides.json')).items():
|
||||
results.setdefault(cid,{'criterionId':cid}); results[cid]['level']=o['level']; results[cid]['evidence']='[사람 보정] '+o['evidence']
|
||||
extra=json.load(open(sys.argv[1])) if len(sys.argv)>1 else {}
|
||||
for cid,o in extra.items():
|
||||
results.setdefault(cid,{'criterionId':cid}); results[cid]['level']=o['level']; results[cid]['evidence']='[사람 보정] '+o['evidence']
|
||||
out=sd.score(results,'VIEW EN')
|
||||
# v2 human items
|
||||
human={'AEO6':(3,''),'GEO1':(2,''),'GEO6':(3,''),'GEO7':(2,'')}
|
||||
items,gates=sd.load_rubric_v2()
|
||||
res={}
|
||||
for axis in ('aeo','geo'):
|
||||
ea=pa=0; det=[]
|
||||
for it in items:
|
||||
if it['axis']!=axis: continue
|
||||
if it['id'] in human:
|
||||
pct=human[it['id']][0]/3; basis='judged'
|
||||
else:
|
||||
sig=[(c,w,results[c]['level']) for c,w in it['derivedFrom'] if c in results and results[c]['level']!='unverified']
|
||||
if not sig: det.append((it['id'],it['name'],it['weight'],None,'unverified',[])); continue
|
||||
ws=sum(w for _,w,_ in sig); pct=sum(w*int(l)/3 for _,w,l in sig)/ws; basis='derived'
|
||||
sigs=[f"{c}={l}" for c,w,l in sig]
|
||||
ea+=it['weight']*pct; pa+=it['weight']
|
||||
det.append((it['id'],it['name'],it['weight'],round(it['weight']*pct,1),basis,sigs if basis=='derived' else []))
|
||||
raw=round(ea/pa*100)
|
||||
res[axis]={'score':raw,'grade':sd.grade(raw),'earned':round(ea,1),'possible':pa,'items':det}
|
||||
out['aeo_h']=res['aeo']; out['geo_h']=res['geo']
|
||||
out['results']=results
|
||||
print(f"v1 {out['v1']['score']}/{out['v1']['grade']} ({out['v1']['earned']}/{out['v1']['possible']}, verified {out['v1']['verified']}/36) cats {out['v1']['categories']}")
|
||||
for ax in ('aeo','geo'):
|
||||
r=res[ax]; print(f"{ax.upper()} {r['score']}/{r['grade']} ({r['earned']}/{r['possible']})")
|
||||
for x in r['items']: print(' ',x)
|
||||
print('gates',out['gates'])
|
||||
json.dump(out,open(f'{S}/audit_en_scored.json','w'),ensure_ascii=False,indent=1)
|
||||
@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>원진성형외과 AI Answer Readiness Report v2.0</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
:root{--ink:#0A1128;--navy:#021341;--violet:#4F1DA1;--accent:#6C5CE7;--light:#F7F8FC;--slate:#4A5268;--muted:#7A8399;--line:#E3E7F2}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,-apple-system,sans-serif;color:var(--ink);background:#fff;word-break:keep-all;line-height:1.62;font-size:14px}
|
||||
.serif{font-family:'Playfair Display',serif}
|
||||
.page{padding:48px 56px;max-width:1080px;margin:0 auto}
|
||||
.dark{background:var(--ink);color:#fff}.dark .sub,.dark .note{color:#B9C0D4}.light{background:var(--light)}
|
||||
.eyebrow{font-size:12px;font-weight:700;letter-spacing:.24em;color:var(--accent);text-transform:uppercase}.dark .eyebrow{color:#C4B5FD}
|
||||
h1{font-size:44px;line-height:1.1;margin:10px 0 6px;letter-spacing:-.02em}h2{font-size:30px;margin:8px 0 12px;line-height:1.2}h3{font-size:17px;margin:0 0 6px}
|
||||
.accent{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.lede{font-size:16px;color:var(--slate);max-width:760px}.dark .lede{color:#D6DBEA}
|
||||
.sub{font-size:12.5px;color:var(--muted)}.note{font-size:12.5px;color:var(--muted);margin-top:8px}
|
||||
.badge{display:inline-block;border:1px solid;border-radius:999px;padding:3px 12px;font-size:13px;font-weight:700}
|
||||
.pill{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700}.pill.gray{background:#EEF0F6;color:#6B7280}
|
||||
.grid{display:grid;gap:16px}.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:repeat(3,1fr)}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}.dark .card{color:var(--ink)}
|
||||
.rings{display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap}.ring{display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--line)}.tbl th{background:#F1F3FA;text-align:left;padding:9px 11px;font-size:12px;color:var(--slate);border-bottom:1px solid var(--line)}.tbl td{padding:9px 11px;border-bottom:1px solid #EEF0F6;vertical-align:top}.tbl tr:last-child td{border-bottom:0}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700}
|
||||
.lv{display:inline-block;min-width:26px;text-align:center;border-radius:6px;padding:1px 6px;font-weight:700;font-size:12px}.lv3{background:#E9F7EF;color:#2E7D4F}.lv2{background:#EEF3FF;color:#2F4FBF}.lv1{background:#FFF4E5;color:#B26A00}.lv0{background:#FDECEF;color:#B84C5F}.lvu{background:#EEF0F6;color:#6B7280}
|
||||
.find{border-left:5px solid;padding-left:16px}.find .tag{font-size:11.5px;font-weight:700;letter-spacing:.08em}
|
||||
.act .pri{font-family:'Playfair Display',serif;font-size:22px;font-weight:700;color:var(--accent)}.act .eff{float:right}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:14px 16px}.kpi b{font-family:'Playfair Display',serif;font-size:30px;color:#AF90FF;display:block;line-height:1}.kpi span{font-size:12.5px;color:#C7CDE0}
|
||||
a{color:var(--accent)}ul{margin:6px 0 0 18px;padding:0}li{margin:3px 0}
|
||||
footer{padding:28px 56px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}
|
||||
@media print{@page{size:A4;margin:12mm}body{font-size:12.5px}.page{padding:22px 10px;max-width:none}.pb{page-break-before:always}h1{font-size:34px}h2{font-size:24px}.dark{-webkit-print-color-adjust:exact;print-color-adjust:exact}.card,.tbl,.lv,.badge,.pill,.kpi .k{-webkit-print-color-adjust:exact;print-color-adjust:exact}tr,.card{page-break-inside:avoid}}
|
||||
</style></head><body>
|
||||
|
||||
<section class="dark"><div class="page" style="padding-top:64px;padding-bottom:56px">
|
||||
<div class="eyebrow">INFINITH AI Discovery · AEO / GEO Audit v2.0</div>
|
||||
<h1 class="serif"><span class="accent">AI Answer Readiness</span></h1>
|
||||
<div style="font-size:26px;font-weight:700;margin:4px 0 2px">원진성형외과</div>
|
||||
<div class="sub" style="color:#C4B5FD">성형외과 (강남역) · <a href="https://www.k-wonjin.co.kr" style="color:#C4B5FD">https://www.k-wonjin.co.kr</a></div>
|
||||
<p class="lede" style="margin-top:18px">AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.</p>
|
||||
<div style="display:flex;gap:32px;align-items:center;flex-wrap:wrap;margin-top:26px">
|
||||
<div class="card" style="display:flex;gap:26px;padding:22px 30px"><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="277.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">15</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FDECEF;color:#B84C5F;border-color:#F7C3CD">AEO 등급 D</span></div><div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="52" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="52" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="179.7" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">45</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="background:#FFF4E5;color:#B26A00;border-color:#FFD9A8">GEO 등급 C</span></div></div>
|
||||
<div class="kpi" style="flex:1;min-width:420px">
|
||||
<div class="k"><b>25</b><span>실측 항목 / 36</span></div>
|
||||
<div class="k"><b>49</b><span>v1.0 종합 (C)</span></div>
|
||||
<div class="k"><b>5</b><span>핵심 발견</span></div>
|
||||
<div class="k"><b>6</b><span>실행 항목</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:22px">실측일 2026-09-08 · 리포트 작성 2026-09-08 · 미검증 11개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="light pb"><div class="page">
|
||||
<div class="eyebrow">Findings</div><h2>AI가 지금 원진성형외과를 어떻게 읽는지 정리했습니다</h2>
|
||||
<div class="grid g2" style="margin-top:14px">
|
||||
<div class="card find" style="border-left-color:#9B8AD4"><div class="tag" style="color:#9B8AD4">강점</div><h3>크롤러 접근은 열려 있습니다. 서버 렌더링이라 AI가 본문을 읽습니다</h3><p style="margin:0;color:var(--slate)">robots.txt가 AI 검색봇 12종을 모두 허용하고, IIS가 본문을 서버에서 렌더링해 JS 없이도 14,079자를 읽을 수 있습니다. TTFB 0.09초, 초기 HTML 153KB로 가볍습니다. 다만 www 유무에 따라 최종 URL이 갈라지고(정본 미수렴), sitemap이 robots.txt에 선언돼 있지 않습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>연결은 됐지만 병원 자체가 스키마로 정의돼 있지 않습니다</h3><p style="margin:0;color:var(--slate)">Organization.sameAs 10개로 SNS·지도·버티컬을 잘 묶었고 주소 표기도 한 가지입니다. 그러나 MedicalClinic·Physician 스키마가 없어 주소·전화·진료시간·의료진 17명이 구조화돼 있지 않고, 전화번호는 6종이 섞여 있습니다. 영문 사이트(wonjinbeauty.com)는 hreflang으로 연결되지 않았습니다.</p></div><div class="card find" style="border-left-color:#D4889A"><div class="tag" style="color:#D4889A">핵심 과제</div><h3>인용할 문장이 부족합니다. H1도 표도 정의문도 없습니다</h3><p style="margin:0;color:var(--slate)">메인·시술 페이지에 H1이 없고 HTML 표가 없으며 병원 정의문도 없습니다. 눈매교정(CFS)·트임 페이지에는 수술 시간·마취·실밥·일상 복귀 수치가 텍스트로 있어 인용 구조의 출발점이 될 수 있지만, 쌍꺼풀·눈매교정 나머지 페이지는 수치가 없습니다. 최신 갱신 신호가 2025-02-18(sitemap)로 18개월 전입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>유튜브는 강합니다. 736편·누적 3,113만 회</h3><p style="margin:0;color:var(--slate)">설명형 롱폼(눈성형 관리법·중년 눈성형 비교·지방흡입 QnA 등)이 있어 글의 근거 영상으로 쓸 수 있습니다. 다만 상위 조회 영상의 다수가 브랜드 필름·연예인·전후 형식이라 인용용 정보형 영상은 10편 안팎입니다. 이 채널이 원진의 가장 큰 오픈웹 자산입니다.</p></div><div class="card find" style="border-left-color:#D4A872"><div class="tag" style="color:#D4A872">보완</div><h3>이미지 alt 62% 누락, 의료진 경력은 텍스트지만 구조화는 안 됨</h3><p style="margin:0;color:var(--slate)">메인 이미지 45개 중 28개가 alt 없이 실려 AI가 이미지 내용을 읽지 못합니다. 의료진 17명의 경력은 텍스트로 있어(원장당 12~23줄) Physician 스키마만 붙이면 바로 엔티티가 됩니다. 강남언니·GBP·네이버 플레이스는 이번 자동 채점 범위 밖이라 수동 실측이 남아 있습니다.</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb"><div class="page">
|
||||
<div class="eyebrow">AEO · Answer Readiness</div><h2>답변 준비도 8개 항목</h2>
|
||||
<p class="lede">질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">AEO1</td><td><b>질문 수요 충족</b><div class="sub">사람들이 실제로 묻는 질문에 답하는 페이지가 있는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C2 L0</td></tr><tr><td class="mono">AEO2</td><td><b>답변의 직접성</b><div class="sub">페이지 첫 단락에 답이 먼저 나오는가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C1 L0</td></tr><tr><td class="mono">AEO3</td><td><b>답변 단위의 자기완결성</b><div class="sub">한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub">C8 미검증</td></tr><tr><td class="mono">AEO4</td><td><b>의사결정에 필요한 정보</b><div class="sub">가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C4 L2 · C5 L0</td></tr><tr><td class="mono">AEO5</td><td><b>정보 구조와 가독성</b><div class="sub">제목이 주제를 서술하고 계층이 논리적인가</div></td><td><b>0</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C3 L0</td></tr><tr><td class="mono">AEO6</td><td><b>다음 행동의 명확성</b><div class="sub">답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">AEO7</td><td><b>핵심 답변의 텍스트 추출</b><div class="sub">답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가</div></td><td><b>60</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A3 L3 · F1 L0</td></tr><tr><td class="mono">AEO8</td><td><b>사이트 내 답변 발견</b><div class="sub">크롤러가 답이 있는 페이지를 찾아갈 수 있는가</div></td><td><b>47</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A5 L1 · B6 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<div class="eyebrow" style="margin-top:34px">GEO · Source Readiness</div><h2>출처 준비도 8개 항목</h2>
|
||||
<p class="lede">주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가.</p>
|
||||
|
||||
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
|
||||
<tr><td class="mono">GEO1</td><td><b>주장과 근거의 일치</b><div class="sub">수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO2</td><td><b>출처의 적합성과 추적성</b><div class="sub">인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">A4 L1</td></tr><tr><td class="mono">GEO3</td><td><b>전문성과 책임 주체</b><div class="sub">누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가</div></td><td><b>40</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C7 L2 · B3 L0</td></tr><tr><td class="mono">GEO4</td><td><b>병원 엔티티와 사실의 정합성</b><div class="sub">이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가</div></td><td><b>63</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B2 L1 · B4 L3 · B5 L3 · D1 미검증 · D2 미검증 · D4 미검증 · D5 미검증 · D6 L0</td></tr><tr><td class="mono">GEO5</td><td><b>사실의 최신성과 정정</b><div class="sub">언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가</div></td><td><b>33</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">C6 L1</td></tr><tr><td class="mono">GEO6</td><td><b>원천 대비 추가 가치</b><div class="sub">다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO7</td><td><b>발행 관계와 투명성</b><div class="sub">누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가</div></td><td><span class="pill gray">미검증</span></td><td>미검증</td><td class="sub"></td></tr><tr><td class="mono">GEO8</td><td><b>인용 식별자와 메타데이터 정합성</b><div class="sub">기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가</div></td><td><b>67</b><span class="sub"> / 100</span></td><td>실측 파생</td><td class="sub">B1 L2</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Category Detail</div><h2>카테고리별 실측 36개 항목</h2>
|
||||
<div class="grid g3" style="margin-top:12px">
|
||||
<div class="card"><div class="sub">A · Access & Crawlability</div><h3 style="margin-top:2px">AI 크롤러 접근성</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">80</span><span class="sub"> / 100 · 실측 6/7</span></div></div><div class="card"><div class="sub">B · Entity & Structured Data</div><h3 style="margin-top:2px">엔티티 식별·구조화</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">50</span><span class="sub"> / 100 · 실측 7/7</span></div></div><div class="card"><div class="sub">C · Answer-Ready Content</div><h3 style="margin-top:2px">답변 인용 가능 콘텐츠</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">25</span><span class="sub"> / 100 · 실측 7/8</span></div></div><div class="card"><div class="sub">D · Off-site Surfaces</div><h3 style="margin-top:2px">AI 인용 표면 확보</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">53</span><span class="sub"> / 100 · 실측 2/6</span></div></div><div class="card"><div class="sub">E · Measurement</div><h3 style="margin-top:2px">AI 가시성 측정 인프라</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">0</span><span class="sub"> / 100 · 실측 0/4</span></div></div><div class="card"><div class="sub">F · Technical Hygiene</div><h3 style="margin-top:2px">기술 위생</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">42</span><span class="sub"> / 100 · 실측 3/4</span></div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="margin-top:22px;font-size:18px">A · AI 크롤러 접근성 <span class="sub">AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A1</td><td><b>검색용 AI 봇 허용</b><div class="sub">배점 5</div></td><td><span class="lv lv3">L3</span><div class="sub">검색봇 전부 허용 + 200 응답</div></td><td class="sub" style="color:var(--slate)">12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200</td></tr><tr><td class="mono">A2</td><td><b>CDN/WAF AI 차단 없음</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">CDN 차단 없음 확인</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음</td></tr><tr><td class="mono">A3</td><td><b>서버 렌더링 본문</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">80% 이상 (SSR/정적)</div></td><td class="sub" style="color:var(--slate)">서버 렌더링. JS 미실행 HTML에서 본문 텍스트 14,079자 추출</td></tr><tr><td class="mono">A4</td><td><b>HTTPS·리다이렉트 정합</b><div class="sub">배점 2</div></td><td><span class="lv lv1">L1</span><div class="sub">변형 간 200 중복</div></td><td class="sub" style="color:var(--slate)">변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://k-wonjin.co.kr', 'https://www.k-wonjin.co.kr']</td></tr><tr><td class="mono">A5</td><td><b>sitemap.xml 제공</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">있으나 robots 미선언</div></td><td class="sub" style="color:var(--slate)">/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 132개</td></tr><tr><td class="mono">A6</td><td><b>Bing 색인 (ChatGPT 검색 경로)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)</td></tr><tr><td class="mono">A7</td><td><b>응답 속도·크롤 예산</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">TTFB < 0.5s, HTML < 300KB</div></td><td class="sub" style="color:var(--slate)">TTFB 0.094s / 초기 HTML 153KB. 시술 페이지 145KB. 모두 기준 이내</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">B · 엔티티 식별·구조화 <span class="sub">AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">B1</td><td><b>JSON-LD 문법 유효성</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">전부 유효</div></td><td class="sub" style="color:var(--slate)">JSON-LD 4블록 전부 유효 (@graph 미사용)</td></tr><tr><td class="mono">B2</td><td><b>업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)</b><div class="sub">배점 5</div></td><td><span class="lv lv1">L1</span><div class="sub">Organization만</div></td><td class="sub" style="color:var(--slate)">ItemList + Organization만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음</td></tr><tr><td class="mono">B3</td><td><b>전문가 스키마 (Physician / Attorney)</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재</td></tr><tr><td class="mono">B4</td><td><b>NAP 사이트 내 일관성</b><div class="sub">배점 3</div></td><td><span class="lv lv3">L3</span><div class="sub">단일 표기</div></td><td class="sub" style="color:var(--slate)">주소 단일 표기: "서울시 서초구 강남대로 419 파고다타워 12". 전화 6종: 02-3477-3300, 023480-3573, 02392-0330</td></tr><tr><td class="mono">B5</td><td><b>sameAs 연결</b><div class="sub">배점 2</div></td><td><span class="lv lv3">L3</span><div class="sub">5개 이상 (SNS + 버티컬 + 지도)</div></td><td class="sub" style="color:var(--slate)">sameAs 10개 (SNS·버티컬·지도 연결)</td></tr><tr><td class="mono">B6</td><td><b>canonical·중복 제거</b><div class="sub">배점 2</div></td><td><span class="lv lv2">L2</span><div class="sub">있음</div></td><td class="sub" style="color:var(--slate)">rel=canonical self-referencing 존재</td></tr><tr><td class="mono">B7</td><td><b>다국어 hreflang</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">다국어 페이지 있으나 미연결</div></td><td class="sub" style="color:var(--slate)">다국어 페이지 흔적(https://www.wonjinbeauty.com/en/) 있으나 hreflang 없음. 미연결</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">C · 답변 인용 가능 콘텐츠 <span class="sub">패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">C1</td><td><b>정의문형 첫 문단</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음 (슬로건·이미지만)</div></td><td class="sub" style="color:var(--slate)">홈 본문 1,999자에 "원진성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 소개 문구(25년 헤리티지·강남역)는 메인 비주얼 이미지 alt 에만 있고, 소개 페이지(/hospitalinfo/about) 첫 단락도 슬로건형</td></tr><tr><td class="mono">C2</td><td><b>FAQ 섹션</b><div class="sub">배점 4</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·시술 페이지)</td></tr><tr><td class="mono">C3</td><td><b>제목 계층 의미성</b><div class="sub">배점 3</div></td><td><span class="lv lv0">L0</span><div class="sub">H1 없음 또는 5개 이상</div></td><td class="sub" style="color:var(--slate)">메인 H1 0개: 없음. 시술 페이지 H1 없음</td></tr><tr><td class="mono">C4</td><td><b>구체 수치 (가격·시간·회복)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">시술별 일부 수치</div></td><td class="sub" style="color:var(--slate)">눈매교정(CFS) 페이지에 수술 시간 2시간·단기 수면 또는 부분 마취·실밥 제거 7일·일상생활 3일 텍스트 존재. 트임(오픈아이) 40분·5~7일. 쌍꺼풀·눈매교정(글램아이·더블유착) 페이지는 수치 없음. 가격 표기 0회(비급여 안내 페이지는 별도)</td></tr><tr><td class="mono">C5</td><td><b>표·비교 구조</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">HTML <table> 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)</td></tr><tr><td class="mono">C6</td><td><b>신선도 신호</b><div class="sub">배점 3</div></td><td><span class="lv lv1">L1</span><div class="sub">1년 이상</div></td><td class="sub" style="color:var(--slate)">최신 갱신 신호 2025-02-18 (sitemap lastmod 2025-02-18). 18개월 전. dateModified 마크업 없음</td></tr><tr><td class="mono">C7</td><td><b>전문가 E-E-A-T (의료진 / 변호사)</b><div class="sub">배점 3</div></td><td><span class="lv lv2">L2</span><div class="sub">자격·경력 텍스트</div></td><td class="sub" style="color:var(--slate)">의료진 상세 페이지 17명의 직함·전문의 여부·경력(원장당 12~23줄)이 HTML 텍스트로 존재. 학술 활동 페이지 3,760자. 단 Physician 스키마·저자 표기는 없어 구조화되지 않음</td></tr><tr><td class="mono">C8</td><td><b>패시지 자기완결성</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">D · AI 인용 표면 확보 <span class="sub">실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">D1</td><td><b>업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)</b><div class="sub">배점 6</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">강남언니 프로필은 공개 페이지 수동 실측 필요 (후기 수·의료진·이벤트·갱신일). 홈페이지 sameAs 에 버티컬 링크 포함 여부는 확인됨(10개 중)</td></tr><tr><td class="mono">D2</td><td><b>Google 비즈니스 프로필</b><div class="sub">배점 4</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요</td></tr><tr><td class="mono">D3</td><td><b>오픈웹 언급 (티스토리·유튜브·언론)</b><div class="sub">배점 4</div></td><td><span class="lv lv2">L2</span><div class="sub">유튜브 + 언론/티스토리</div></td><td class="sub" style="color:var(--slate)">자체 YouTube 채널 @wjwonjin 영상 736편·구독 1.5만·누적 조회 3,113만(2026-09-08 API 집계). 인스타그램 3계정·페이스북·네이버 블로그(popokpop) 존재. 채널 sameAs 10개로 홈페이지와 연결됨. 단 홈페이지 내 외부 링크는 JS 렌더라 HTML에서 미노출</td></tr><tr><td class="mono">D4</td><td><b>표면 간 NAP 완전 일치</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요</td></tr><tr><td class="mono">D5</td><td><b>네이버 플레이스 (AI 브리핑 전용)</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[semi·사람 판정 필요] 사이트에 네이버 플레이스 링크 없음. 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인</td></tr><tr><td class="mono">D6</td><td><b>위키데이터/위키백과 엔티티</b><div class="sub">배점 1</div></td><td><span class="lv lv0">L0</span><div class="sub">없음</div></td><td class="sub" style="color:var(--slate)">Wikidata에서 "원진성형외과" 검색 결과 없음</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">E · AI 가시성 측정 인프라 <span class="sub">개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">E1</td><td><b>AI 크롤러 로그 (IP 검증)</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)</td></tr><tr><td class="mono">E2</td><td><b>GA4 AI 유입 채널</b><div class="sub">배점 3</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)</td></tr><tr><td class="mono">E3</td><td><b>GSC AI Overviews 노출</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] GSC 접근 필요 (AI Overviews 노출)</td></tr><tr><td class="mono">E4</td><td><b>기준선 스팟체크</b><div class="sub">배점 2</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)</td></tr>
|
||||
</tbody></table><h3 style="margin-top:22px;font-size:18px">F · 기술 위생 <span class="sub">이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.</span></h3>
|
||||
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
|
||||
<tr><td class="mono">F1</td><td><b>이미지 대체텍스트</b><div class="sub">배점 2</div></td><td><span class="lv lv0">L0</span><div class="sub">50% 이상 누락</div></td><td class="sub" style="color:var(--slate)">메인 페이지 이미지 45개 중 28개(62%) alt 누락 또는 빈 값</td></tr><tr><td class="mono">F2</td><td><b>CMS 보안·최신성</b><div class="sub">배점 1</div></td><td><span class="lv lvu">미검증</span><div class="sub">미검증</div></td><td class="sub" style="color:var(--slate)">Server: Microsoft-IIS/10.0, ASP.NET 계열 자체 제작 추정(meta generator 없음). CMS 버전·보안 패치 여부 판정 불가</td></tr><tr><td class="mono">F3</td><td><b>페이지 무게</b><div class="sub">배점 1</div></td><td><span class="lv lv3">L3</span><div class="sub">< 300KB</div></td><td class="sub" style="color:var(--slate)">초기 HTML 153KB (300KB 미만)</td></tr><tr><td class="mono">F4</td><td><b>모바일 대응</b><div class="sub">배점 1</div></td><td><span class="lv lv2">L2</span><div class="sub">반응형</div></td><td class="sub" style="color:var(--slate)">viewport meta 존재, 반응형. CWV 미측정</td></tr>
|
||||
</tbody></table>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Action Plan</div><h2>지금 고칠 것과 4주 안에 만들 것을 나눴습니다</h2>
|
||||
<h3 style="margin-top:20px">P0 · 지금 고칠 것 (1주 안)</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">1일</span><div class="pri">P0</div><h3>정본 URL 수렴 + sitemap 선언</h3><p style="margin:0 0 8px;color:var(--slate)">k-wonjin.co.kr → www.k-wonjin.co.kr 로 301 통일, robots.txt 에 Sitemap: 선언, sitemap lastmod 갱신. 전화번호를 대표번호 1개로 통일.</p><div class="sub">연결 항목 <span class="mono">A4</span> <span class="mono">A5</span> <span class="mono">B4</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P0</div><h3>MedicalClinic + Physician 스키마</h3><p style="margin:0 0 8px;color:var(--slate)">Organization 을 MedicalClinic 으로 승격(address·telephone·openingHoursSpecification·medicalSpecialty). 의료진 17명 Physician 스키마(이름·직함·전문의·프로필 URL). 영문 사이트와 hreflang 상호 연결.</p><div class="sub">연결 항목 <span class="mono">B2</span> <span class="mono">B3</span> <span class="mono">B7</span></div></div></div><h3 style="margin-top:20px">P1 · 4주 안에 만들 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>시술 페이지 인용 구조: H1·표·FAQ</h3><p style="margin:0 0 8px;color:var(--slate)">시술 페이지마다 H1 1개, 수술 시간·마취·실밥·일상 복귀를 HTML 표로, FAQ 5문항 + FAQPage 스키마. CFS·트임 페이지의 수치 형식을 다른 눈·코·윤곽 페이지로 확산. 메인에 병원 정의문 한 단락.</p><div class="sub">연결 항목 <span class="mono">C1</span> <span class="mono">C2</span> <span class="mono">C3</span> <span class="mono">C4</span> <span class="mono">C5</span></div></div><div class="card act"><span class="pill gray eff">1주</span><div class="pri">P1</div><h3>이미지 alt 일괄 정비 + 갱신 신호</h3><p style="margin:0 0 8px;color:var(--slate)">메인 28개 이미지 alt 작성(무엇을 찍은 사진인지 사실만). 페이지에 dateModified 마크업, sitemap lastmod 를 실제 수정일로.</p><div class="sub">연결 항목 <span class="mono">F1</span> <span class="mono">C6</span></div></div><div class="card act"><span class="pill gray eff">2~4주</span><div class="pri">P1</div><h3>서포터즈 사이트로 오픈웹 원문 공급</h3><p style="margin:0 0 8px;color:var(--slate)">자동 빌드 미리보기(supporters-wonjin.vercel.app)의 글 6편을 병원이 검토·승인하면 색인 허용 도메인으로 발행. 유튜브 설명 영상 10편을 글에 연결. 이후 질문 뱅크 기준으로 편집 캘린더 운영.</p><div class="sub">연결 항목 <span class="mono">C2</span> <span class="mono">C4</span> <span class="mono">C5</span> <span class="mono">D3</span></div></div></div><h3 style="margin-top:20px">P2 · 이어서 할 것</h3><div class="grid g2"><div class="card act"><span class="pill gray eff">지속</span><div class="pri">P2</div><h3>표면 실측과 측정 인프라</h3><p style="margin:0 0 8px;color:var(--slate)">강남언니·GBP·네이버 플레이스 NAP 대조표 작성, GA4 AI 유입 채널그룹, GSC AI Overviews 확인, 서버 로그의 AI 크롤러 히트 계측. Bing Webmaster 등록.</p><div class="sub">연결 항목 <span class="mono">D1</span> <span class="mono">D2</span> <span class="mono">D4</span> <span class="mono">D5</span> <span class="mono">E1</span> <span class="mono">E2</span> <span class="mono">E3</span> <span class="mono">A6</span></div></div></div>
|
||||
</div></section>
|
||||
|
||||
<section class="pb dark"><div class="page">
|
||||
<div class="eyebrow">Supporters Preview</div><h2>홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다</h2>
|
||||
<p class="lede">병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.</p>
|
||||
<div class="kpi" style="margin-top:18px">
|
||||
<div class="k"><b>6</b><span>글 (게이트 통과)</span></div>
|
||||
<div class="k"><b>736</b><span>유튜브 영상 집계</span></div>
|
||||
<div class="k"><b>66</b><span>사용 가능 이미지 (전후·사례 제외)</span></div>
|
||||
<div class="k"><b>17</b><span>의료진 프로필</span></div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:14px">미리보기 <a href="https://supporters-wonjin.vercel.app" style="color:#C4B5FD">https://supporters-wonjin.vercel.app</a> · 병원 확인 항목 입력 <a href="https://infinith-demo.vercel.app/supporters/wonjin" style="color:#C4B5FD">infinith-demo.vercel.app/supporters/wonjin</a></p>
|
||||
</div></section>
|
||||
<section><div class="page">
|
||||
<h3 style="font-size:18px">자동 생성한 글 6편</h3>
|
||||
<table class="tbl"><thead><tr><th style="width:54px">분류</th><th>제목</th><th style="width:70px">근거 영상</th><th style="width:86px">병원 확인 대기</th><th style="width:90px">상태</th></tr></thead><tbody>
|
||||
<tr><td class="mono">A</td><td><b>원진성형외과는 어떤 병원인가요? 진료 분야·의료진 구성·위치</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/what-is-clinic">https://supporters-wonjin.vercel.app/posts/what-is-clinic</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">B</td><td><b>원진성형외과 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/visit-guide">https://supporters-wonjin.vercel.app/posts/visit-guide</a></div></td><td>0편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 윤곽은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-facial">https://supporters-wonjin.vercel.app/posts/procedure-facial</a></div></td><td>0편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 눈성형은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-eye">https://supporters-wonjin.vercel.app/posts/procedure-eye</a></div></td><td>3편</td><td>5건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">D</td><td><b>원진성형외과 레이저안티에이징은 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/procedure-skin">https://supporters-wonjin.vercel.app/posts/procedure-skin</a></div></td><td>0편</td><td>6건</td><td><span class="pill gray">의학 검토 대기</span></td></tr><tr><td class="mono">C</td><td><b>상담에서 꼭 물어봐야 할 질문은 무엇인가요?</b><div class="sub"><a href="https://supporters-wonjin.vercel.app/posts/consult-questions">https://supporters-wonjin.vercel.app/posts/consult-questions</a></div></td><td>3편</td><td>3건</td><td><span class="pill gray">의학 검토 대기</span></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">발견한 채널과 확인 결과</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:110px">플랫폼</th><th>확인됨</th><th>확인 필요</th></tr></thead><tbody>
|
||||
<tr><td><b>유튜브</b></td><td><a href="https://www.youtube.com/channel/UCNBaQBCok5xIViCnrGdisoQ">WJ원진성형외과</a> <span class="sub">(구독 28,300)</span><br><a href="https://www.youtube.com/@wjwonjin">WJ원진성형외과</a> <span class="sub">(구독 14,700)</span></td><td class="sub"><a href="https://www.youtube.com/channel/UCNmTm4UubrscPczrAfGhUvQ" style="color:var(--muted)">Wonjin Plastic Surgery Korea</a><br><a href="https://www.youtube.com/channel/UCk2UbMID_NjYZe7-WBLQXRw" style="color:var(--muted)">유세윤의 광고100 X 원진성형외과</a><br><a href="https://www.youtube.com/channel/UCEC21_7WvRx8cWlje4sLVlw" style="color:var(--muted)">WONJINPS</a><br><a href="https://www.youtube.com/channel/UCJuM0rYynWd9xAXmOf-dl9A" style="color:var(--muted)">WONJIN_OFFICIAL - KOREA No.1 PLASTIC SURGERY</a><br><a href="https://www.youtube.com/channel/UChnJVIjdmUeZkA40cRFMzFA" style="color:var(--muted)">원트성형외과</a></td></tr><tr><td><b>인스타그램</b></td><td><a href="https://www.instagram.com/wonjin_official/">Instagram</a><br><a href="https://www.instagram.com/wonjin_ps/">Instagram</a><br><a href="https://www.instagram.com/wj_cosmetic/">Instagram</a></td><td class="sub"><a href="https://www.instagram.com/wj_plasticsurgery/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/hamdust_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/bjwnjn/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin_sunnywon/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinire/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wxxnzin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wj_1j_/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjin/" style="color:var(--muted)">Instagram</a><br><a href="https://www.instagram.com/wonjinps/" style="color:var(--muted)">Instagram</a></td></tr><tr><td><b>페이스북</b></td><td><a href="https://www.facebook.com/KwonjinPS">KwonjinPS</a></td><td class="sub"></td></tr><tr><td><b>네이버 블로그</b></td><td><a href="https://blog.naver.com/popokpop">popokpop</a></td><td class="sub"><a href="https://blog.naver.com/naramalssami1" style="color:var(--muted)">naramalssami1</a><br><a href="https://blog.naver.com/qeqe0420" style="color:var(--muted)">qeqe0420</a><br><a href="https://blog.naver.com/vpepwxormtfo" style="color:var(--muted)">vpepwxormtfo</a></td></tr><tr><td><b>틱톡</b></td><td><a href="https://www.tiktok.com/@wonjin_official">wonjin_official</a></td><td class="sub"></td></tr><tr><td><b>카카오톡 채널</b></td><td><a href="https://pf.kakao.com/_bxflzE">원진성형외과의원</a></td><td class="sub"></td></tr><tr><td><b>강남언니</b></td><td><a href="https://www.gangnamunni.com/hospitals/2500">원진성형외과의원 | 강남언니 - 내 선택에 확신을</a></td><td class="sub"></td></tr><tr><td><b>네이버 플레이스</b></td><td><span class="sub">찾지 못함</span></td><td class="sub"></td></tr><tr><td><b>바비톡</b></td><td><a href="https://babitalk.com/hospitals/3143">원진성형외과의원 | 후기∙가격∙이벤트 정보 - 바비톡</a></td><td class="sub"></td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="font-size:18px;margin-top:26px">병원이 확인해 주실 것 9가지</h3>
|
||||
<p class="sub" style="color:var(--slate)">홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.</p>
|
||||
<div class="grid g3" style="margin-top:10px"><div class="card" style="padding:14px 16px"><span class="mono">1</span> <b>작성자 실명·직함·공개 프로필</b></div><div class="card" style="padding:14px 16px"><span class="mono">2</span> <b>글별 의학 검토 원장과 검토일</b></div><div class="card" style="padding:14px 16px"><span class="mono">3</span> <b>지원 관계 문구</b></div><div class="card" style="padding:14px 16px"><span class="mono">4</span> <b>견적 기본 포함 항목·결제 방식</b></div><div class="card" style="padding:14px 16px"><span class="mono">5</span> <b>회복 일정표의 병원 확정값</b></div><div class="card" style="padding:14px 16px"><span class="mono">6</span> <b>재수술 정책</b></div><div class="card" style="padding:14px 16px"><span class="mono">7</span> <b>페이지 간 표기 불일치의 정답</b></div><div class="card" style="padding:14px 16px"><span class="mono">8</span> <b>진료 분야별 담당 원장</b></div><div class="card" style="padding:14px 16px"><span class="mono">9</span> <b>정식 도메인·색인 허용 시점·홈페이지 링크 위치</b></div></div>
|
||||
<p class="note">싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<section class="pb light"><div class="page">
|
||||
<div class="eyebrow">Integrity</div><h2>근거 없는 것은 약속하지 않습니다</h2>
|
||||
<table class="tbl" style="margin-top:12px"><thead><tr><th style="width:30%">하지 않는 것</th><th>이유</th></tr></thead><tbody>
|
||||
<tr><td><b>llms.txt 제작</b></td><td style="color:var(--slate)">Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.</td></tr><tr><td><b>"스키마 넣으면 AI가 인용합니다" 약속</b></td><td style="color:var(--slate)">인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.</td></tr><tr><td><b>단발 조회로 순위·노출 보고</b></td><td style="color:var(--slate)">LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.</td></tr><tr><td><b>답변엔진 UI 자동 조회</b></td><td style="color:var(--slate)">OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.</td></tr><tr><td><b>네이버 블로그 증량</b></td><td style="color:var(--slate)">blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)"><li>실측 대상: 메인 페이지 + /contents/eye/cfs 2개 URL, JS 미실행 HTML 기준</li><li>크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청</li><li>semi/manual 항목은 자동 수집 증거만 첨부하고 미검증 처리, 분모 제외 (사람 보정 후 확정)</li><li>답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)</li></ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
<tr><td><b>ChatGPT 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">링크에 utm_source=chatgpt.com 을 붙여 보내므로 리퍼러가 비어도 잡힌다.</td></tr><tr><td><b>Perplexity 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러를 자주 비운다. 잡히는 건수는 실제 유입의 일부다. 실측 언급률과 함께 읽는다.</td></tr><tr><td><b>Gemini 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">gemini.google.com 리퍼러로 분리된다.</td></tr><tr><td><b>Copilot 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">Bing 검색과 리퍼러가 섞이는 경우가 있다.</td></tr><tr><td><b>Claude 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">가능</span></td><td class="sub">claude.ai 리퍼러.</td></tr><tr><td><b>Google AI Overview · AI Mode 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">구글 자연검색(google / organic)과 같은 리퍼러라 GA4로는 구분되지 않는다. 타사 도구의 'AI Overview 유입'도 추정치다. 가시성은 답변엔진 실측으로만 잰다.</td></tr><tr><td><b>네이버 AI 브리핑 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">불가</span></td><td class="sub">네이버 검색 리퍼러(search.naver.com)와 구분되지 않는다. 네이버 서치어드바이저 유입 키워드로 간접 확인만 가능.</td></tr><tr><td><b>기타 AI 검색 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">일부만</span></td><td class="sub">리퍼러가 있을 때만 잡힌다.</td></tr>
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 36개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) 8개와 출처 준비도(GEO) 8개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
<footer>INFINITH · AI오투오 · 문의 o2oteam@o2o.kr · 이 리포트는 공개 페이지 실측에 근거하며, 오너 계정(GA4·GSC·서버 로그)이 필요한 항목은 미검증으로 두었습니다. 법률 자문이 아닙니다.</footer>
|
||||
</body></html>
|
||||
Binary file not shown.
158
public/reports/viewclinic/global-en-2026-09-10.html
Normal file
158
public/reports/viewclinic/global-en-2026-09-10.html
Normal file
File diff suppressed because one or more lines are too long
BIN
public/reports/viewclinic/global-en-2026-09-10.pdf
Normal file
BIN
public/reports/viewclinic/global-en-2026-09-10.pdf
Normal file
Binary file not shown.
@ -9,6 +9,7 @@
|
||||
* 화법: 고객용. 판단 제목은 "~합니다", 목차성 제목은 명사구. 엠대시 없음. 숫자는 표와 배지에만.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import AI_CHANNELS from '../data/ai_channels.json';
|
||||
import { DISCOVERY_RESULTS } from '../src/data/discoveryResults';
|
||||
import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric';
|
||||
import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2';
|
||||
@ -184,6 +185,15 @@ ${status ? `<section class="pb dark"><div class="page">
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
|
||||
<ul style="color:var(--slate)">${result.conditions.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>
|
||||
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
|
||||
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
|
||||
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
|
||||
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
|
||||
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
|
||||
${(AI_CHANNELS as any).channels.map((c: any) => `<tr><td><b>${esc(c.label)} 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">${c.measurable === 'yes' ? '가능' : c.measurable === 'partial' ? '일부만' : '불가'}</span></td><td class="sub">${esc(c.note)}</td></tr>`).join('')}
|
||||
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
|
||||
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
|
||||
</tbody></table>
|
||||
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
|
||||
<p style="color:var(--slate);margin:0">기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
|
||||
</div></section>
|
||||
|
||||
585
scripts/fetch_medical_tourism.py
Normal file
585
scripts/fetch_medical_tourism.py
Normal file
@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env python3
|
||||
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어). 병원별로 돈다.
|
||||
|
||||
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
|
||||
거리와 함께 붙여 프론트가 쓸 JSON 으로 낸다. 영문(EngService2)과 국문(KorService2)을
|
||||
한 번에 만들 수 있다.
|
||||
|
||||
왜 사전 수집인가
|
||||
영문 서비스는 도심 커버리지가 얇고 관광지 항목의 대부분이 의료기관이라 필터링이 많이
|
||||
필요하다. 그 처리를 수집 시점에 끝내고 프론트는 정제된 결과만 그린다. 미팅 데모 중
|
||||
외부 API 상태에 화면이 종속되지도 않는다.
|
||||
|
||||
무엇을 만들지 않는가
|
||||
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 가 주지 않으므로 비워 두고,
|
||||
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
|
||||
판정하지 않는다. 좌표도 지어내지 않는다. 주소를 좌표로 바꾸지 못하면 종료 코드 1 로 멈춘다.
|
||||
|
||||
기준 좌표를 정하는 순서
|
||||
1. --lat --lng 를 직접 주면 그대로 쓴다 (coordSource: 명령 인자).
|
||||
2. --address 와 --name 을 주면 네이버 지역검색(NAVER_CLIENT_ID/SECRET)으로 상호를 찾고,
|
||||
결과의 도로명 주소가 준 주소의 도로명·번지와 같을 때만 그 좌표를 쓴다.
|
||||
네이버가 못 찾으면 TourAPI 키워드 검색(국문·영문)으로 한 번 더 찾는다.
|
||||
3. 아무 인자도 없으면 .env 의 TOUR_API_ORIGIN_LAT/LNG (초기 샘플 사이트용).
|
||||
어느 경우든 meta.origin.coordSource 에 출처와 날짜를 적는다.
|
||||
|
||||
출력은 서포터즈 사이트(Astro)의 데이터다. 화면은 회복 일정 플래너 src/components/Planner.astro(/plan 국문, /en/plan 영문)이며
|
||||
src/lib/tour.ts 가 이 JSON 을 추천 후보·숙소·축제로 바꾼다. 외국인 환자와 보호자가 보는 곳이다.
|
||||
|
||||
# 워커가 부르는 형태 (팩트 시트 주소 기준, 영문·국문 동시)
|
||||
python3 scripts/fetch_medical_tourism.py --clinic oracle --name "오라클피부과" \
|
||||
--address "서울 강남구 선릉로 612 한일빌딩" --label "오라클피부과 (선릉로 612)" \
|
||||
--out-en ~/supporters-builds/oracle/tourism/medicalTourism.json \
|
||||
--out-ko ~/supporters-builds/oracle/tourism/medicalTourismKo.json
|
||||
# 좌표를 직접 줄 때
|
||||
python3 scripts/fetch_medical_tourism.py --lat 37.5049823 --lng 127.0254182 --label "..." --out-en a.json --out-ko b.json
|
||||
# 이전 방식 (.env 좌표, 언어 하나)
|
||||
python3 scripts/fetch_medical_tourism.py --lang ko --out supporters/src/data/medicalTourismKo.json
|
||||
"""
|
||||
import argparse, io, json, math, os, re, ssl, sys, time, urllib.parse, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 영문 관광지(76)의 대부분은 관광지가 아니라 의료관광 등록업소다. 반경 5km 100건 중 92건이
|
||||
# 분류코드 A02020500 = "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치 에이전시가
|
||||
# 여기 들어 있다. 제목 키워드로는 "하이안과", "닥파인더코리아" 같은 것을 놓치므로 코드로 거른다.
|
||||
CAT_MEDICAL_TOURISM = "A02020500"
|
||||
# 스파·찜질방은 수술 후 회복 여정에 맞으므로 관광이 아니라 별도 범주로 뺀다.
|
||||
CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
|
||||
# 다국어 ContentTypeId. 국문과 코드가 다르다.
|
||||
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
|
||||
SEASON = {3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer",
|
||||
9: "autumn", 10: "autumn", 11: "autumn", 12: "winter", 1: "winter", 2: "winter"}
|
||||
|
||||
# ContentTypeId 는 다국어와 국문이 다르다. 같은 코드로 부르면 엉뚱한 것이 온다.
|
||||
TYPES_EN = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
"79": ("shopping", "10000"),
|
||||
"80": ("stay", "5000"),
|
||||
"82": ("restaurant", "5000"),
|
||||
}
|
||||
TYPES_KO = {
|
||||
"12": ("attraction", "10000"),
|
||||
"14": ("culture", "10000"),
|
||||
"38": ("shopping", "10000"),
|
||||
"32": ("stay", "5000"),
|
||||
"39": ("restaurant", "5000"),
|
||||
}
|
||||
# 언어별 타입 표는 collect() 가 lang 에 맞게 고른다.
|
||||
|
||||
|
||||
def ssl_ctx():
|
||||
try:
|
||||
import certifi; return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def load_env(path):
|
||||
""".env 파일을 읽고, 같은 이름의 환경변수가 있으면 그 값을 우선한다.
|
||||
워커(run.mjs)나 클라우드 실행은 파일 없이 환경변수만 줄 수 있다."""
|
||||
d = {}
|
||||
if os.path.exists(path):
|
||||
for line in io.open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1); d[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for k, v in os.environ.items():
|
||||
if k.startswith(("TOUR_API_", "NAVER_CLIENT_")) and v:
|
||||
d[k] = v
|
||||
return d
|
||||
|
||||
|
||||
def haversine_km(lat1, lng1, lat2, lng2):
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def call(env, op, extra, rows=100, retries=3, page=1):
|
||||
base, svc, key = env["TOUR_API_BASE"], env["TOUR_API_SERVICE"], env["TOUR_API_KEY"]
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": str(page), **extra}
|
||||
url = f"{base}/{svc}/{op}?serviceKey={key}&" + urllib.parse.urlencode(p)
|
||||
for i in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"):
|
||||
raise RuntimeError(f"XML 응답(인증·한도 확인): {body[:160]}")
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000":
|
||||
raise RuntimeError(f"{j['header'].get('resultCode')} {j['header'].get('resultMsg')}")
|
||||
b = j.get("body", {}); items = b.get("items")
|
||||
return b.get("totalCount", 0), ([] if not items or items == "" else items["item"])
|
||||
except Exception as e:
|
||||
if i == retries - 1: raise
|
||||
time.sleep(1.5 * (i + 1))
|
||||
|
||||
|
||||
def call_all(env, op, extra, max_pages=6, rows=100):
|
||||
"""전 페이지를 모은다. 관광지는 거리순 상위 100건이 거의 다 의료관광업소라 한 페이지로는 부족하다."""
|
||||
got, total = [], None
|
||||
for page in range(1, max_pages + 1):
|
||||
total, items = call(env, op, extra, rows=rows, page=page)
|
||||
got += items
|
||||
if not items or len(got) >= (total or 0): break
|
||||
time.sleep(0.2)
|
||||
return total, got
|
||||
|
||||
|
||||
def walk_min(m):
|
||||
"""도보 분. 80m/분(4.8km/h) 기준. 참고 사이트의 표기를 역산해 맞춘 값이다
|
||||
(135m→2분, 248m→3분, 297m→4분, 566m→7분)."""
|
||||
return max(1, round(m / 80))
|
||||
|
||||
|
||||
def drive_min(km):
|
||||
"""차 이동 분. 직선거리 km x 1.2. 역시 역산값이다(25km→30분, 33km→40분)."""
|
||||
return max(1, round(km * 1.2))
|
||||
|
||||
|
||||
def travel(m):
|
||||
"""이동 수단과 소요 시간.
|
||||
2km 까지는 걸을 수 있는 거리로 본다(약 25분). 그 위는 차·지하철이다.
|
||||
짧은 거리의 차 시간은 주차와 신호 때문에 의미가 없으므로 최소 5분으로 둔다."""
|
||||
if m is None: return None
|
||||
if m <= 2000: return {"mode": "walk", "minutes": walk_min(m), "label": f"{walk_min(m)} min walk"}
|
||||
mins = max(5, drive_min(m / 1000))
|
||||
return {"mode": "drive", "minutes": mins, "label": f"{mins} min by car"}
|
||||
|
||||
|
||||
def fetch_overview(env, content_id, cache):
|
||||
"""detailCommon2 의 overview(설명문)와 homepage. locationBasedList2 는 주지 않는다.
|
||||
설명문이 없으면 카드가 이름과 주소뿐이라 사람이 고를 수 없다."""
|
||||
if content_id in cache: return cache[content_id]
|
||||
try:
|
||||
_, rows = call(env, "detailCommon2", {"contentId": content_id}, rows=1)
|
||||
except Exception:
|
||||
cache[content_id] = ("", ""); return cache[content_id]
|
||||
if not rows: cache[content_id] = ("", ""); return cache[content_id]
|
||||
r = rows[0]
|
||||
ov = re.sub(r"<[^>]+>", " ", r.get("overview") or "")
|
||||
ov = re.sub(r"\s+", " ", ov).strip()
|
||||
hp = re.sub(r"<[^>]+>", " ", r.get("homepage") or "").strip()
|
||||
hp = (re.search(r"https?://\S+", hp) or [None])[0] if hp else ""
|
||||
cache[content_id] = (ov, hp or "")
|
||||
return cache[content_id]
|
||||
|
||||
|
||||
def fetch_image(env, content_id, cache):
|
||||
"""locationBasedList2 의 firstimage 가 비어 있을 때 detailImage2 로 한 번 더 찾는다.
|
||||
이미지 전용 오퍼레이션이라 목록 API 에 없는 사진이 여기 있다(도산공원 7장, 몽마르뜨공원 5장).
|
||||
실측: 사진 없던 38건 중 15건을 여기서 살렸다. 나머지 23건은 TourAPI 에 사진이 없다."""
|
||||
key = ("img", content_id)
|
||||
if key in cache: return cache[key]
|
||||
try:
|
||||
_, rows = call(env, "detailImage2", {"contentId": content_id, "imageYN": "Y"}, rows=5)
|
||||
except Exception:
|
||||
cache[key] = ""; return ""
|
||||
url = ""
|
||||
for r in rows or []:
|
||||
u = (r.get("originimgurl") or r.get("smallimageurl") or "").strip()
|
||||
if u: url = u.replace("http://", "https://", 1); break
|
||||
cache[key] = url
|
||||
return url
|
||||
|
||||
|
||||
def call_kor(env, op, extra, rows=10):
|
||||
"""국문 서비스(KorService2). 영문 항목에 사진이 없어도 국문 항목에는 있는 경우가 있다.
|
||||
같은 한국관광공사 사진이라 출처가 달라지지 않는다."""
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": "1", **extra}
|
||||
url = f"{env['TOUR_API_BASE']}/KorService2/{op}?serviceKey={env['TOUR_API_KEY']}&" + urllib.parse.urlencode(p)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=25, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"): return []
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000": return []
|
||||
it = j["body"].get("items")
|
||||
return [] if not it or it == "" else it["item"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def korean_name(title):
|
||||
m = re.search(r"\(([^)]+)\)\s*$", title or "")
|
||||
return (m.group(1) if m else (title or "")).strip()
|
||||
|
||||
|
||||
def fetch_image_kor(env, p):
|
||||
"""국문 서비스에서 같은 이름의 항목을 찾아 사진을 가져온다. 실측 23건 중 10건이 여기서 나왔다."""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not re.search(r"[가-힣]", ko): return ""
|
||||
rows = []
|
||||
if p.get("lat"):
|
||||
rows = call_kor(env, "locationBasedList2",
|
||||
{"mapX": str(p["lng"]), "mapY": str(p["lat"]), "radius": "300", "keyword": ko})
|
||||
if not rows: rows = call_kor(env, "searchKeyword2", {"keyword": ko})
|
||||
key = ko.replace(" ", "")
|
||||
for r in rows:
|
||||
if key in (r.get("title") or "").replace(" ", ""):
|
||||
u = (r.get("firstimage") or r.get("firstimage2") or "").strip()
|
||||
if u: return u.replace("http://", "https://", 1)
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_image_og(url):
|
||||
"""업체 공식 홈페이지의 대표 이미지(og:image). 업체가 스스로 올린 사진이라 목록에 쓰기에 무리가 없다.
|
||||
검색 결과 이미지를 긁어 쓰면 식당·블로그·언론사의 저작물이라 환자용 페이지에 올릴 수 없다."""
|
||||
if not url or not url.startswith("http"): return ""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=12, context=ssl_ctx()) as r:
|
||||
head = r.read(120000).decode("utf-8", "replace")
|
||||
base = r.geturl()
|
||||
except Exception:
|
||||
return ""
|
||||
m = re.search(r'<meta[^>]+(?:property|name)=["\']og:image["\'][^>]*content=["\']([^"\']+)', head, re.I) \
|
||||
or re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\']og:image', head, re.I)
|
||||
if not m: return ""
|
||||
u = m.group(1).strip()
|
||||
if u.startswith("//"): u = "https:" + u
|
||||
elif u.startswith("/"):
|
||||
from urllib.parse import urlparse
|
||||
pr = urlparse(base); u = f"{pr.scheme}://{pr.netloc}{u}"
|
||||
return u if u.startswith("https://") else ""
|
||||
|
||||
|
||||
def usable_image(url):
|
||||
"""실제로 이미지가 오는지 열어 본다. og:image 가 이미지가 아니거나(빈 content-type)
|
||||
인스타 CDN 처럼 핫링크를 403 으로 막는 경우가 있어 화면에서 빈 칸이 된다."""
|
||||
if not url: return False
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=10, context=ssl_ctx()) as r:
|
||||
return r.status == 200 and (r.headers.get("content-type") or "").startswith("image")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_image_naver(env, p):
|
||||
"""마지막 폴백. Naver 이미지 검색 API.
|
||||
주의: 결과는 제3자 저작물이다. 내부 시연 목적으로만 쓰고, 공개 배포 전에는
|
||||
imageSource 가 'naver' 인 항목을 걷어내거나 라이선스가 있는 사진으로 교체한다.
|
||||
구글 HTML 크롤링 대신 이걸 쓰는 이유는 공식 API 라 차단되지 않고 결과가 일정해서다."""
|
||||
cid, sec = env.get("NAVER_CLIENT_ID"), env.get("NAVER_CLIENT_SECRET")
|
||||
if not (cid and sec): return ""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not ko: return ""
|
||||
# 지역을 붙이면 정확도가 오르지만 결과가 아예 없는 경우가 있다. 넓혀 가며 시도한다.
|
||||
plain = re.sub(r"\s*\(.*\)\s*", "", p.get("title") or "").strip()
|
||||
for q in (f"{ko} 서울", ko, plain):
|
||||
if not q: continue
|
||||
qs = urllib.parse.urlencode({"query": q, "display": "3", "sort": "sim"})
|
||||
req = urllib.request.Request("https://openapi.naver.com/v1/search/image.json?" + qs,
|
||||
headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ssl_ctx()) as r:
|
||||
items = json.loads(r.read().decode()).get("items", [])
|
||||
except Exception:
|
||||
continue
|
||||
for it in items:
|
||||
u = (it.get("link") or "").strip()
|
||||
if u.startswith("https://"): return u
|
||||
return ""
|
||||
|
||||
|
||||
def bucket(it):
|
||||
"""분류코드로 범주를 정한다. None 이면 버린다."""
|
||||
cat3 = (it.get("cat3") or "").strip()
|
||||
if cat3 == CAT_MEDICAL_TOURISM: return None
|
||||
if cat3 in CAT_WELLNESS: return "wellness"
|
||||
return "keep"
|
||||
|
||||
|
||||
def clean(it, lat, lng):
|
||||
"""TourAPI 원본에서 화면이 쓸 필드만 남긴다. mapx=경도, mapy=위도 (이름과 반대라 자주 틀린다)."""
|
||||
try:
|
||||
ilng, ilat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
ilng = ilat = 0.0
|
||||
dist = it.get("dist")
|
||||
return {
|
||||
"id": it.get("contentid"),
|
||||
"title": (it.get("title") or "").strip(),
|
||||
"address": (it.get("addr1") or "").strip(),
|
||||
# TourAPI 는 이미지 URL 을 http 로 준다. https 사이트에서 혼합 콘텐츠로 차단되므로 올린다.
|
||||
# tong.visitkorea.or.kr 은 https 로도 같은 파일을 준다(실측 확인).
|
||||
"image": (lambda u: u.replace("http://", "https://", 1) if u else None)(
|
||||
it.get("firstimage") or it.get("firstimage2") or None),
|
||||
"imageSource": "tourapi" if (it.get("firstimage") or it.get("firstimage2")) else "",
|
||||
"tel": (it.get("tel") or "").strip() or None,
|
||||
"lat": ilat or None, "lng": ilng or None,
|
||||
"distanceM": round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None),
|
||||
"travel": travel(round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None)),
|
||||
# detailCommon2 로 따로 채운다(locationBasedList2 는 설명문을 주지 않는다).
|
||||
"overview": "", "homepage": "",
|
||||
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
|
||||
"rating": None, "reviewCount": None, "openingHours": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------- 기준 좌표 ----------
|
||||
|
||||
def road_key(address):
|
||||
"""주소에서 도로명과 건물번호를 뽑는다. "서울 강남구 선릉로 612 한일빌딩" → ("선릉로", "612").
|
||||
비교에만 쓴다. 없으면 (None, None)."""
|
||||
m = re.search(r"([가-힣A-Za-z0-9]*?(?:대로|로|길))\s*(\d+(?:-\d+)?)(?!\s*길)", address or "")
|
||||
return (m.group(1), m.group(2)) if m else (None, None)
|
||||
|
||||
|
||||
def addr_match(want, got):
|
||||
"""검색 결과 주소가 준 주소와 같은 도로명·번지인지. 도로명과 번지 사이 공백은 무시하고,
|
||||
번지 바로 뒤에 숫자나 '-' 가 붙으면(612 와 6120, 612-1) 다른 곳으로 본다.
|
||||
"봉은사로 107 1, 3-5층" 처럼 공백 뒤에 층이 오는 것은 같은 곳이다."""
|
||||
road, num = road_key(want)
|
||||
if not road: return False
|
||||
return re.search(re.escape(road) + r"\s*" + re.escape(num) + r"(?![\d-])", got or "") is not None
|
||||
|
||||
|
||||
def strip_tags(s):
|
||||
return re.sub(r"<[^>]+>", "", s or "").strip()
|
||||
|
||||
|
||||
def geocode_naver(env, name, address):
|
||||
"""네이버 지역검색으로 상호를 찾아 좌표를 얻는다. mapx/mapy 는 WGS84 경위도 x 10^7 이다.
|
||||
주소만으로는 결과가 없다(실측). 상호를 넣고, 결과의 도로명 주소가 준 주소와 같을 때만 받는다."""
|
||||
cid, sec = env.get("NAVER_CLIENT_ID"), env.get("NAVER_CLIENT_SECRET")
|
||||
if not (cid and sec): return None, "NAVER_CLIENT_ID/SECRET 없음"
|
||||
road, num = road_key(address)
|
||||
queries = []
|
||||
if road: queries.append(f"{name} {road} {num}")
|
||||
queries += [f"{name} {address}", name]
|
||||
tried = []
|
||||
for q in queries:
|
||||
qs = urllib.parse.urlencode({"query": q, "display": "5", "sort": "random"})
|
||||
req = urllib.request.Request("https://openapi.naver.com/v1/search/local.json?" + qs,
|
||||
headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ssl_ctx()) as r:
|
||||
items = json.loads(r.read().decode()).get("items", [])
|
||||
except Exception as e:
|
||||
tried.append(f"'{q}': 요청 실패 {e}"); continue
|
||||
for it in items:
|
||||
got = it.get("roadAddress") or it.get("address") or ""
|
||||
if not addr_match(address, got): continue
|
||||
try:
|
||||
lng, lat = int(it["mapx"]) / 1e7, int(it["mapy"]) / 1e7
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if not (33 <= lat <= 39 and 124 <= lng <= 132): continue
|
||||
return {"lat": lat, "lng": lng, "matched": strip_tags(it.get("title")), "matchedAddress": got,
|
||||
"coordSource": f"네이버 지역검색 '{strip_tags(it.get('title'))}' ({got}) {time.strftime('%Y-%m-%d')}"}, ""
|
||||
tried.append(f"'{q}': {len(items)}건, 주소 일치 없음")
|
||||
return None, "; ".join(tried)
|
||||
|
||||
|
||||
def geocode_tourapi(env, name, address):
|
||||
"""TourAPI 키워드 검색(국문·영문)으로 병원 항목을 찾는다. 등재된 병원만 나오므로 보조 수단이다."""
|
||||
tried = []
|
||||
for svc in ("KorService2", "EngService2"):
|
||||
e = dict(env, TOUR_API_SERVICE=svc)
|
||||
try:
|
||||
_, rows = call(e, "searchKeyword2", {"keyword": name}, rows=20, retries=1)
|
||||
except Exception as ex:
|
||||
tried.append(f"{svc}: 요청 실패 {ex}"); continue
|
||||
for it in rows:
|
||||
got = it.get("addr1") or ""
|
||||
if not addr_match(address, got): continue
|
||||
try:
|
||||
lng, lat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (33 <= lat <= 39 and 124 <= lng <= 132): continue
|
||||
return {"lat": lat, "lng": lng, "matched": (it.get("title") or "").strip(), "matchedAddress": got,
|
||||
"coordSource": f"한국관광공사 TourAPI {svc} contentid {it.get('contentid')} ({got}) {time.strftime('%Y-%m-%d')}"}, ""
|
||||
tried.append(f"{svc}: {len(rows)}건, 주소 일치 없음")
|
||||
return None, "; ".join(tried)
|
||||
|
||||
|
||||
def resolve_origin(env, a):
|
||||
"""기준 좌표와 출처. 못 정하면 (None, 이유)."""
|
||||
if a.lat is not None or a.lng is not None:
|
||||
if a.lat is None or a.lng is None: return None, "--lat 와 --lng 는 함께 줘야 합니다"
|
||||
return {"lat": a.lat, "lng": a.lng, "address": a.address or "",
|
||||
"coordSource": f"명령 인자 --lat/--lng {time.strftime('%Y-%m-%d')}"}, ""
|
||||
if a.address:
|
||||
if not a.name: return None, "--address 에는 --name(검색할 상호)이 필요합니다. 주소만으로는 지역검색 결과가 없습니다"
|
||||
road, num = road_key(a.address)
|
||||
if not road: return None, f"주소에서 도로명·번지를 찾지 못했습니다: {a.address}"
|
||||
found, why1 = geocode_naver(env, a.name, a.address)
|
||||
if found: return dict(found, address=a.address), ""
|
||||
found, why2 = geocode_tourapi(env, a.name, a.address)
|
||||
if found: return dict(found, address=a.address), ""
|
||||
return None, f"주소를 좌표로 바꾸지 못했습니다. 네이버 지역검색: {why1} / TourAPI: {why2}"
|
||||
lat, lng = env.get("TOUR_API_ORIGIN_LAT"), env.get("TOUR_API_ORIGIN_LNG")
|
||||
if not (lat and lng): return None, "기준 좌표가 없습니다. --address --name 또는 --lat --lng 를 주거나 .env 의 TOUR_API_ORIGIN_LAT/LNG 를 채우세요"
|
||||
return {"lat": float(lat), "lng": float(lng), "address": "",
|
||||
"coordSource": f".env TOUR_API_ORIGIN_LAT/LNG {time.strftime('%Y-%m-%d')}"}, ""
|
||||
|
||||
|
||||
# ---------- 수집 ----------
|
||||
|
||||
def collect(env, lang, origin, a):
|
||||
"""한 언어의 데이터를 모아 dict 로 돌려준다. 파일은 쓰지 않는다."""
|
||||
types = TYPES_KO if lang == "ko" else TYPES_EN
|
||||
env = dict(env, TOUR_API_SERVICE="KorService2" if lang == "ko" else "EngService2")
|
||||
lat, lng = origin["lat"], origin["lng"]
|
||||
print(f"\n[{lang}] {env['TOUR_API_SERVICE']} · 기준 {lat}, {lng}")
|
||||
|
||||
out = {"places": {}, "festivals": [], "filters": {}, "meta": {}}
|
||||
filtered_log = {}
|
||||
|
||||
wellness = []
|
||||
for ct, (name, radius) in types.items():
|
||||
total, rows = call_all(env, "locationBasedList2",
|
||||
{"mapX": str(lng), "mapY": str(lat), "radius": radius,
|
||||
"contentTypeId": ct, "arrange": "E"})
|
||||
kept, dropped = [], []
|
||||
for it in rows:
|
||||
b = bucket(it)
|
||||
if b is None:
|
||||
dropped.append((it.get("title") or "").strip()); continue
|
||||
(wellness if b == "wellness" else kept).append(clean(it, lat, lng))
|
||||
kept.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"][name] = kept[:a.per_type]
|
||||
filtered_log[name] = {"apiTotal": total, "fetched": len(rows), "radiusM": int(radius),
|
||||
"droppedMedicalTourism": len(dropped), "kept": len(kept),
|
||||
"shown": len(out["places"][name]), "droppedSample": dropped[:5]}
|
||||
print(f" {name:<11} r={radius:>5}m · API {total:>4} · 받음 {len(rows):>3} · 의료관광업소 제외 {len(dropped):>3} · 담음 {len(out['places'][name])}")
|
||||
wellness.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"]["wellness"] = wellness[:a.per_type]
|
||||
filtered_log["wellness"] = {"note": "관광지에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
|
||||
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
|
||||
|
||||
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
|
||||
today = time.strftime("%Y%m%d")
|
||||
total, rows = call(env, "searchFestival2", {"eventStartDate": today, "arrange": "A"}, rows=300)
|
||||
near, nocoord = [], 0
|
||||
for it in rows:
|
||||
c = clean(it, lat, lng)
|
||||
if c["lat"] is None: nocoord += 1; continue
|
||||
km = haversine_km(lat, lng, c["lat"], c["lng"])
|
||||
if km > a.festival_km: continue
|
||||
sd = it.get("eventstartdate") or ""
|
||||
c.update(startDate=sd, endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1), month=(int(sd[4:6]) if len(sd) == 8 else None),
|
||||
season=SEASON.get(int(sd[4:6]), "") if len(sd) == 8 else "")
|
||||
near.append(c)
|
||||
near.sort(key=lambda x: x["startDate"] or "")
|
||||
out["festivals"] = near[:a.per_type]
|
||||
print(f" festival API {total:>4}건 · 받음 {len(rows):>3} · 좌표없음 {nocoord} · {a.festival_km:.0f}km 이내 {len(near)}")
|
||||
|
||||
# 설명문은 항목마다 detailCommon2 를 한 번씩 부른다. 카드에 이름과 주소만 있으면 고를 수 없다.
|
||||
cache: dict = {}
|
||||
targets = [p for v in out["places"].values() for p in v] + out["festivals"]
|
||||
print(f" 설명문 수집 {len(targets)}건", end="", flush=True)
|
||||
for i, p in enumerate(targets):
|
||||
if not p.get("id"): continue
|
||||
ov, hp = fetch_overview(env, p["id"], cache)
|
||||
p["overview"] = ov[:600]
|
||||
p["homepage"] = hp
|
||||
if not p.get("image"):
|
||||
# 1) 같은 언어 detailImage2 → 2) 국문 서비스 → 3) 업체 홈페이지 og:image → 4) Naver 이미지 검색
|
||||
src = ""
|
||||
img = fetch_image(env, p["id"], cache)
|
||||
if img: src = "tourapi"
|
||||
if not img and lang == "en":
|
||||
img = fetch_image_kor(env, p); src = "tourapi-kor" if img else src
|
||||
if not img:
|
||||
cand = fetch_image_og(p.get("homepage") or "")
|
||||
if cand and usable_image(cand): img, src = cand, "homepage"
|
||||
if not img and not a.no_naver_images:
|
||||
img = fetch_image_naver(env, p); src = "naver" if img else src
|
||||
p["image"] = img or None
|
||||
p["imageSource"] = src
|
||||
if i % 20 == 0: print(".", end="", flush=True)
|
||||
time.sleep(0.12)
|
||||
got = sum(1 for p in targets if p["overview"])
|
||||
pic = sum(1 for p in targets if p.get("image"))
|
||||
print(f" 완료 · 설명문 {got}/{len(targets)} · 사진 {pic}/{len(targets)}")
|
||||
|
||||
# 도보 시간 구간별 건수. 화면의 필터 칩이 이 수를 쓴다.
|
||||
allp = [p for v in out["places"].values() for p in v]
|
||||
# 누적 기준이다. "15분 이내"는 5분 이내도 포함한다. 사람이 고르는 방식에 맞춘다.
|
||||
def walk_upto(mins):
|
||||
return sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"
|
||||
and p["travel"]["minutes"] <= mins)
|
||||
out["filters"] = {
|
||||
"total": len(allp),
|
||||
"walk5": walk_upto(5),
|
||||
"walk15": walk_upto(15),
|
||||
"walkAll": sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"),
|
||||
"drive": sum(1 for p in allp if not p.get("travel") or p["travel"]["mode"] != "walk"),
|
||||
}
|
||||
|
||||
out["meta"] = {
|
||||
"lang": lang,
|
||||
"clinic": a.clinic or "",
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"origin": {"lat": lat, "lng": lng, "label": a.label or origin.get("matched") or origin.get("address") or "",
|
||||
"address": origin.get("address", ""), "coordSource": origin["coordSource"]},
|
||||
"radiusByType": {n: int(r) for n, r in types.values()}, "festivalRadiusKm": a.festival_km,
|
||||
"counts": filtered_log,
|
||||
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
|
||||
"limits": [
|
||||
"영문 서비스는 도심 숙박·음식점 커버리지가 국문보다 얇다.",
|
||||
"관광지 항목 중 분류코드 A02020500 \"Medical Tourism Sites\"(병원·유치업체)는 제외했다.",
|
||||
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
|
||||
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
|
||||
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
|
||||
],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="병원 주변 관광 데이터 수집 (TourAPI)")
|
||||
ap.add_argument("--clinic", default="", help="병원 id (meta.clinic)")
|
||||
ap.add_argument("--name", default="", help="지역검색에 쓸 상호. --address 와 함께 준다")
|
||||
ap.add_argument("--address", default="", help="병원 주소. 네이버 지역검색으로 좌표를 찾는다")
|
||||
ap.add_argument("--lat", type=float, default=None); ap.add_argument("--lng", type=float, default=None)
|
||||
ap.add_argument("--label", default="", help="화면에 보일 기준점 이름 (meta.origin.label)")
|
||||
ap.add_argument("--out-en", default="", help="영문 JSON 경로 (절대 또는 현재 폴더 기준)")
|
||||
ap.add_argument("--out-ko", default="", help="국문 JSON 경로")
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--no-naver-images", action="store_true", help="네이버 이미지 검색 폴백을 끈다 (공개 배포용)")
|
||||
ap.add_argument("--lang", choices=["en", "ko"], default="en", help="(이전 방식) --out 하나만 낼 때의 언어")
|
||||
ap.add_argument("--out", default="", help="(이전 방식) 저장소 루트 기준 경로")
|
||||
a = ap.parse_args()
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
for k in ("TOUR_API_KEY", "TOUR_API_BASE"):
|
||||
if not env.get(k): sys.exit(f"{k} 가 비어 있습니다 (.env 또는 환경변수)")
|
||||
|
||||
origin, why = resolve_origin(env, a)
|
||||
if not origin:
|
||||
print(f"기준 좌표 실패: {why}", file=sys.stderr); sys.exit(1)
|
||||
print(f"기준 좌표 {origin['lat']}, {origin['lng']} · {origin['coordSource']}")
|
||||
|
||||
jobs = []
|
||||
if a.out_en: jobs.append(("en", os.path.abspath(a.out_en)))
|
||||
if a.out_ko: jobs.append(("ko", os.path.abspath(a.out_ko)))
|
||||
if not jobs:
|
||||
rel = a.out or ("supporters/src/data/medicalTourism.json" if a.lang == "en" else "supporters/src/data/medicalTourismKo.json")
|
||||
jobs.append((a.lang, os.path.join(ROOT, rel)))
|
||||
|
||||
for lang, path in jobs:
|
||||
out = collect(env, lang, origin, a)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
io.open(path, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
print(f"→ {path} · 장소 {out['filters']['total']} · 축제 {len(out['festivals'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
149
scripts/ga4_ai_traffic.py
Normal file
149
scripts/ga4_ai_traffic.py
Normal file
@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GA4 → AI 검색 유입 리포트. GA4 Data API(runReport)로 세션 출처·랜딩 페이지·키 이벤트를 받아 data/ai_channels.json 기준으로 AI 채널을 분류한다.
|
||||
|
||||
연동 방식(B2B): 병원(또는 서포터즈 사이트)의 GA4 속성에 우리 서비스 계정 이메일을 '뷰어'로 추가하면 끝. 코드 설치 없음.
|
||||
1) Google Cloud 프로젝트에서 Google Analytics Data API 켜기 → 서비스 계정 키(JSON) 발급
|
||||
2) GA4 관리 > 속성 액세스 관리 > 서비스 계정 이메일을 뷰어로 추가
|
||||
3) .env: GA4_SERVICE_ACCOUNT_JSON=<키 파일 경로> (속성 ID는 --property 인자)
|
||||
|
||||
사용:
|
||||
python3 scripts/ga4_ai_traffic.py --property 123456789 --days 90 --clinic wonjin --out docs/reports/wonjin/05_ai_traffic
|
||||
python3 scripts/ga4_ai_traffic.py --from-csv <GA4 탐색 내보내기.csv> --clinic wonjin --out ... # API 없이 (열: date, sessionSource, sessionMedium, landingPage, sessions, keyEvents)
|
||||
python3 scripts/ga4_ai_traffic.py --mock --clinic demo --out /tmp/ga4demo # 합성 데이터로 파이프라인 검증
|
||||
|
||||
산출: <out>/ai_traffic.json (일별·채널별·랜딩별 집계) + <out>/AI_Traffic_Report.md ("측정 가능 범위" 표 포함)
|
||||
원칙: GA4 로 잴 수 없는 것(AI Overview·네이버 AI 브리핑·리퍼러 없는 유입·노출만 된 경우)은 숫자 없이 '측정 불가'로 적는다. 추정치를 만들지 않는다.
|
||||
"""
|
||||
import argparse, csv, datetime as dt, json, os, random, sys, collections
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CHANNELS = json.load(open(os.path.join(ROOT, "data", "ai_channels.json"), encoding="utf-8"))
|
||||
|
||||
def load_env():
|
||||
p = os.path.join(ROOT, ".env")
|
||||
if not os.path.exists(p): return
|
||||
for line in open(p, encoding="utf-8"):
|
||||
if "=" in line and not line.strip().startswith("#"):
|
||||
k, v = line.split("=", 1); os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
|
||||
# ---------- 분류 ----------
|
||||
def classify(source: str, medium: str = "", utm_source: str = "") -> str | None:
|
||||
"""세션 출처를 AI 채널 id 로. 아니면 None. 부분 문자열이 아니라 호스트 경계로 본다."""
|
||||
s = (source or "").lower().strip(); u = (utm_source or source or "").lower().strip()
|
||||
for ch in CHANNELS["channels"]:
|
||||
for host in ch["sources"]:
|
||||
h = host.lower()
|
||||
if s == h or s.endswith("." + h) or s.startswith(h + "/") or s == h.replace("www.", ""): return ch["id"]
|
||||
for us in ch["utm_sources"]:
|
||||
if u == us.lower(): return ch["id"]
|
||||
return None
|
||||
|
||||
LABEL = {c["id"]: c["label"] for c in CHANNELS["channels"]}
|
||||
MEASURABLE = {c["id"]: c["measurable"] for c in CHANNELS["channels"]}
|
||||
|
||||
# ---------- 데이터 소스 ----------
|
||||
def rows_from_api(property_id: str, days: int):
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
except ImportError:
|
||||
sys.exit("google-auth 가 필요합니다: pip install google-auth requests")
|
||||
load_env()
|
||||
key_path = os.environ.get("GA4_SERVICE_ACCOUNT_JSON")
|
||||
if not key_path or not os.path.exists(key_path): sys.exit("GA4_SERVICE_ACCOUNT_JSON(서비스 계정 키 파일 경로) 필요 (.env)")
|
||||
creds = service_account.Credentials.from_service_account_file(key_path, scopes=["https://www.googleapis.com/auth/analytics.readonly"])
|
||||
sess = AuthorizedSession(creds)
|
||||
body = {"dateRanges": [{"startDate": f"{days}daysAgo", "endDate": "yesterday"}],
|
||||
"dimensions": [{"name": "date"}, {"name": "sessionSource"}, {"name": "sessionMedium"}, {"name": "landingPagePlusQueryString"}],
|
||||
"metrics": [{"name": "sessions"}, {"name": "keyEvents"}, {"name": "engagedSessions"}],
|
||||
"limit": 100000}
|
||||
r = sess.post(f"https://analyticsdata.googleapis.com/v1beta/properties/{property_id}:runReport", json=body, timeout=60)
|
||||
if r.status_code != 200: sys.exit(f"GA4 Data API {r.status_code}: {r.text[:300]}")
|
||||
j = r.json()
|
||||
for row in j.get("rows", []):
|
||||
d = [x["value"] for x in row["dimensionValues"]]; m = [x["value"] for x in row["metricValues"]]
|
||||
yield {"date": f"{d[0][:4]}-{d[0][4:6]}-{d[0][6:]}", "source": d[1], "medium": d[2], "landing": d[3].split("?")[0], "utm_source": _utm(d[3]),
|
||||
"sessions": int(float(m[0])), "key_events": int(float(m[1])), "engaged": int(float(m[2]))}
|
||||
|
||||
def _utm(landing_with_qs: str) -> str:
|
||||
if "utm_source=" not in landing_with_qs: return ""
|
||||
return landing_with_qs.split("utm_source=")[1].split("&")[0]
|
||||
|
||||
def rows_from_csv(path: str):
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
for r in csv.DictReader(f):
|
||||
g = lambda *ks: next((r[k] for k in ks if k in r and r[k] not in (None, "")), "")
|
||||
yield {"date": g("date", "날짜"), "source": g("sessionSource", "세션 소스", "source"), "medium": g("sessionMedium", "세션 매체", "medium"),
|
||||
"landing": g("landingPage", "landingPagePlusQueryString", "방문 페이지", "landing").split("?")[0], "utm_source": _utm(g("landingPagePlusQueryString", "landing")),
|
||||
"sessions": int(float(g("sessions", "세션수", "세션") or 0)), "key_events": int(float(g("keyEvents", "주요 이벤트", "conversions") or 0)), "engaged": int(float(g("engagedSessions", "참여 세션수") or 0))}
|
||||
|
||||
def rows_mock(days: int = 60):
|
||||
random.seed(7); today = dt.date.today()
|
||||
srcs = [("google", "organic", 40), ("(direct)", "(none)", 18), ("chatgpt.com", "referral", 6), ("perplexity.ai", "referral", 2), ("gemini.google.com", "referral", 1), ("search.naver.com", "organic", 25), ("instagram.com", "referral", 4)]
|
||||
pages = ["/", "/posts/consult-questions", "/posts/procedure-eye", "/visit", "/videos", "/newsroom"]
|
||||
for i in range(days):
|
||||
d = (today - dt.timedelta(days=days - i)).isoformat()
|
||||
for s, m, w in srcs:
|
||||
for p in random.sample(pages, 3):
|
||||
n = max(0, int(random.gauss(w / 3, w / 6)))
|
||||
if n: yield {"date": d, "source": s, "medium": m, "landing": p, "utm_source": "chatgpt.com" if s == "chatgpt.com" else "", "sessions": n, "key_events": sum(1 for _ in range(n) if random.random() < 0.06), "engaged": int(n * 0.6)}
|
||||
|
||||
# ---------- 집계 ----------
|
||||
def aggregate(rows):
|
||||
daily = collections.defaultdict(lambda: collections.Counter()); by_ch = collections.Counter(); ke_ch = collections.Counter()
|
||||
landing = collections.defaultdict(lambda: collections.Counter()); total = collections.Counter(); direct = 0; weeks = collections.defaultdict(lambda: collections.Counter())
|
||||
for r in rows:
|
||||
total["sessions"] += r["sessions"]; total["key_events"] += r["key_events"]
|
||||
ch = classify(r["source"], r["medium"], r.get("utm_source", ""))
|
||||
if r["source"] in ("(direct)", "direct"): direct += r["sessions"]
|
||||
if not ch: continue
|
||||
daily[r["date"]][ch] += r["sessions"]; by_ch[ch] += r["sessions"]; ke_ch[ch] += r["key_events"]
|
||||
landing[r["landing"]][ch] += r["sessions"]; landing[r["landing"]]["_ke"] += r["key_events"]
|
||||
y, w, _ = dt.date.fromisoformat(r["date"]).isocalendar(); weeks[f"{y}-W{w:02d}"][ch] += r["sessions"]
|
||||
ai_total = sum(by_ch.values())
|
||||
return {"total_sessions": total["sessions"], "total_key_events": total["key_events"], "direct_sessions": direct,
|
||||
"ai_sessions": ai_total, "ai_share_pct": round(ai_total / total["sessions"] * 100, 2) if total["sessions"] else 0,
|
||||
"by_channel": [{"id": c, "label": LABEL[c], "measurable": MEASURABLE[c], "sessions": n, "key_events": ke_ch[c], "cvr_pct": round(ke_ch[c] / n * 100, 1) if n else 0} for c, n in by_ch.most_common()],
|
||||
"weekly": {w: dict(v) for w, v in sorted(weeks.items())}, "daily": {d: dict(v) for d, v in sorted(daily.items())},
|
||||
"landing": sorted([{"page": p, "ai_sessions": sum(n for k, n in v.items() if k != "_ke"), "key_events": v["_ke"], "by": {k: n for k, n in v.items() if k != "_ke"}} for p, v in landing.items()], key=lambda x: -x["ai_sessions"])[:20]}
|
||||
|
||||
# ---------- 리포트 ----------
|
||||
def scope_table_md():
|
||||
lines = ["| 채널 | GA4로 측정 | 비고 |", "|---|---|---|"]
|
||||
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
|
||||
for c in CHANNELS["channels"]: lines.append(f"| {c['label']} | **{M[c['measurable']]}** | {c['note']} |")
|
||||
lines += ["", "GA4로 잴 수 없는 것:", ""] + [f"- {x}" for x in CHANNELS["not_measurable_by_ga4"]]
|
||||
return "\n".join(lines)
|
||||
|
||||
def report_md(agg, clinic, source_desc, days):
|
||||
L = [f"# AI 검색 유입 리포트 · {clinic}", "", f"기간 최근 {days}일 · 데이터 {source_desc} · 분류 기준 `data/ai_channels.json` v{CHANNELS['version']} · 작성 {dt.date.today()}", "",
|
||||
"## 1. 요약", "", "| 지표 | 값 |", "|---|---|",
|
||||
f"| 전체 세션 | {agg['total_sessions']:,} |", f"| AI 검색 유입 세션(측정 가능분) | {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) |",
|
||||
f"| AI 유입의 키 이벤트(전화 탭·예약·홈페이지 이동) | {sum(c['key_events'] for c in agg['by_channel']):,} |", f"| direct 세션(리퍼러 없음, AI 유입 일부가 여기 섞임) | {agg['direct_sessions']:,} |", "",
|
||||
"## 2. 채널별", "", "| 채널 | 세션 | 키 이벤트 | 전환율 | 측정 |", "|---|---|---|---|---|"]
|
||||
M = {"yes": "가능", "partial": "일부만", "no": "불가"}
|
||||
for c in agg["by_channel"]: L.append(f"| {c['label']} | {c['sessions']:,} | {c['key_events']} | {c['cvr_pct']}% | {M[c['measurable']]} |")
|
||||
for c in CHANNELS["channels"]:
|
||||
if c["measurable"] == "no": L.append(f"| {c['label']} | 측정 불가 | | | 불가 |")
|
||||
L += ["", "## 3. AI 유입 랜딩 페이지 (상위)", "", "| 페이지 | AI 세션 | 키 이벤트 | 채널 |", "|---|---|---|---|"]
|
||||
for p in agg["landing"][:12]: L.append(f"| {p['page']} | {p['ai_sessions']} | {p['key_events']} | {', '.join(f'{LABEL[k]} {n}' for k, n in sorted(p['by'].items(), key=lambda x: -x[1]))} |")
|
||||
L += ["", "## 4. 주별 추이", "", "| 주 | " + " | ".join(LABEL[c["id"]] for c in agg["by_channel"]) + " | 합 |", "|---|" + "---|" * (len(agg["by_channel"]) + 1)]
|
||||
for w, v in agg["weekly"].items(): L.append(f"| {w} | " + " | ".join(str(v.get(c["id"], 0)) for c in agg["by_channel"]) + f" | {sum(v.values())} |")
|
||||
L += ["", "## 5. 측정 가능 범위", "", "이 리포트의 숫자는 GA4가 리퍼러나 utm으로 구분할 수 있는 유입만 셉니다. 아래 표의 '일부만'과 '불가'는 실제보다 적게 잡히거나 전혀 잡히지 않는 채널입니다. AI 답변에 병원이 얼마나 나오는지(노출)는 이 리포트가 아니라 답변엔진 실측(질문 뱅크)으로 잽니다. 두 숫자를 같은 시간축에 놓고 읽어야 합니다.", "", scope_table_md(), "",
|
||||
"## 6. 연동 방식", "", "GA4 속성 액세스 관리에서 INFINITH 서비스 계정 이메일을 '뷰어'로 추가하면 매일 자동으로 갱신됩니다. 코드 설치는 없습니다. 병원 사이트에 GA4가 없으면 태그 설치가 먼저이고, 서포터즈 사이트는 INFINITH가 직접 계측합니다."]
|
||||
return "\n".join(L) + "\n"
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--property"); ap.add_argument("--days", type=int, default=90); ap.add_argument("--from-csv"); ap.add_argument("--mock", action="store_true")
|
||||
ap.add_argument("--clinic", required=True); ap.add_argument("--out", required=True)
|
||||
a = ap.parse_args()
|
||||
if a.mock: rows, desc = list(rows_mock(a.days if a.days < 90 else 60)), "합성 데이터(파이프라인 검증용)"
|
||||
elif a.from_csv: rows, desc = list(rows_from_csv(a.from_csv)), f"GA4 내보내기 {os.path.basename(a.from_csv)}"
|
||||
elif a.property: rows, desc = list(rows_from_api(a.property, a.days)), f"GA4 Data API 속성 {a.property}"
|
||||
else: sys.exit("--property 또는 --from-csv 또는 --mock 중 하나")
|
||||
agg = aggregate(rows)
|
||||
os.makedirs(a.out, exist_ok=True)
|
||||
json.dump({"clinic": a.clinic, "source": desc, "days": a.days, "generatedAt": dt.date.today().isoformat(), "channels_version": CHANNELS["version"], **agg}, open(os.path.join(a.out, "ai_traffic.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
open(os.path.join(a.out, "AI_Traffic_Report.md"), "w", encoding="utf-8").write(report_md(agg, a.clinic, desc, a.days))
|
||||
print(f"세션 {agg['total_sessions']:,} · AI 유입 {agg['ai_sessions']:,} ({agg['ai_share_pct']}%) · 채널 {', '.join(f'{c['label']} {c['sessions']}' for c in agg['by_channel'])} → {a.out}")
|
||||
109
scripts/monthly_ai_measure.py
Normal file
109
scripts/monthly_ai_measure.py
Normal file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""월간 AI 실측 체인: 질문 뱅크 러너 → 감성 판정 → 리포트 → supporter_builds.report.sentiment.
|
||||
|
||||
핸드오버 §2-4 의 "한 줄". 지금까지 사람이 네 개 명령을 순서대로 치던 것을 하나로 묶는다.
|
||||
사람 게이트는 자동화하지 않는다. 보정(라벨링)은 여기 들어가지 않으며, 확정 여부는
|
||||
calibration.confirmed 플래그로 리포트 카드에 그대로 전달한다.
|
||||
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변 재사용, 감성만
|
||||
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
|
||||
|
||||
한계: 질문 뱅크 러너(run_question_bank_openai.py)는 아직 뷰성형외과 전용이다
|
||||
(scripts/question_bank_viewclinic.json 하드코딩). 다른 병원으로 넓히려면 러너를
|
||||
먼저 병원별로 매개변수화해야 한다.
|
||||
"""
|
||||
import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error, importlib.util
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(ROOT, "scripts", "out")
|
||||
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)
|
||||
|
||||
|
||||
def sh(argv):
|
||||
print(" $ " + " ".join(argv), flush=True)
|
||||
r = subprocess.run(argv, cwd=ROOT)
|
||||
if r.returncode != 0: sys.exit(f"실패: {' '.join(argv)}")
|
||||
|
||||
|
||||
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)")
|
||||
H = {"apikey": key, "authorization": f"Bearer {key}", "content-type": "application/json"}
|
||||
ep = f"{url}/rest/v1/supporter_builds?id=eq.{build_id}"
|
||||
req = urllib.request.Request(ep + "&select=report", headers=H)
|
||||
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
|
||||
rows = json.loads(r.read().decode())
|
||||
if not rows: sys.exit(f"supporter_builds 에 {build_id} 없음")
|
||||
report = rows[0].get("report") or {}
|
||||
report["sentiment"] = sentiment
|
||||
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 갱신 (HTTP {r.status})")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--clinic", default="뷰성형외과")
|
||||
ap.add_argument("--alias", default="뷰성형외과의원,뷰 성형외과,View Plastic Surgery,viewclinic")
|
||||
ap.add_argument("--report", default="docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md")
|
||||
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="기존 감성 판정을 지우고 다시 판정한다(기준표를 고친 뒤)")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not a.skip_run:
|
||||
print("[1/3] 질문 뱅크 실측")
|
||||
for e in ENGINES:
|
||||
sh([sys.executable, "scripts/run_question_bank_openai.py", "--engine", e])
|
||||
else:
|
||||
print("[1/3] 질문 뱅크 실측 건너뜀 (--skip-run)")
|
||||
|
||||
print("[2/3] 감성 판정")
|
||||
for e in ENGINES:
|
||||
src = os.path.join(OUT, f"qb_{e}_results.jsonl")
|
||||
dst = os.path.join(OUT, f"qb_sentiment_{e}.jsonl")
|
||||
if not os.path.exists(src): sys.exit(f"답변 파일 없음: {src}")
|
||||
if a.rejudge and os.path.exists(dst): os.remove(dst)
|
||||
sh([sys.executable, "scripts/sentiment_qb.py", "--in", src, "--out", dst,
|
||||
"--clinic", a.clinic, "--alias", a.alias])
|
||||
|
||||
print("[3/3] 리포트")
|
||||
outs = [os.path.join(OUT, f"qb_sentiment_{e}.jsonl") for e in ENGINES]
|
||||
sh([sys.executable, "scripts/sentiment_qb.py", "--summary", *outs,
|
||||
"--report", a.report, "--clinic", a.clinic])
|
||||
|
||||
rows = [json.loads(l) for p in outs for l in open(p, encoding="utf-8") if l.strip()]
|
||||
W = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
|
||||
strong = [r for r in rows if r["sentiment"] != "not_mentioned" and not r.get("weak_mention")]
|
||||
s = sqb.score(rows)
|
||||
sentiment = {
|
||||
"rubric": sqb.RUBRIC_VERSION, "model": rows[0].get("model"),
|
||||
"measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"answers": s["answers"], "mentioned": s["mentioned"], "score": s["score"],
|
||||
"scoreStrongOnly": round(sum(W[r["sentiment"]] for r in strong) / len(strong), 1) if strong else None,
|
||||
"weakMentions": sum(1 for r in rows if r.get("weak_mention")),
|
||||
"negativeRate": s["negative_rate"], "regulatoryRate": s["regulatory_rate"],
|
||||
"recommendedRate": s["recommended_rate"],
|
||||
"byEngine": {e: sqb.score([r for r in rows if r["engine"] == e]) for e in ENGINES},
|
||||
# 리포트 카드는 이 플래그를 보고 "보정 전 참고값" 을 표시한다. 2차 보정 전에는 확정이 아니다.
|
||||
"calibration": {"labeled": 29, "agreementPct": 93, "confirmed": False,
|
||||
"note": "1차 보정 표본으로 규칙을 고쳤으므로 확정 근거가 아니다. 새 30건 2차 보정 필요."},
|
||||
}
|
||||
p = os.path.join(OUT, "sentiment_summary.json")
|
||||
open(p, "w", encoding="utf-8").write(json.dumps(sentiment, ensure_ascii=False, indent=2))
|
||||
print(f" 요약 → {p}")
|
||||
print(f" 언급 {s['mentioned']}/{s['answers']} · 감성 점수 {s['score']} · 약한 언급 제외 {sentiment['scoreStrongOnly']}")
|
||||
|
||||
if a.build_id: patch_supabase(a.build_id, sentiment)
|
||||
else: print(" --build-id 없음. Supabase 반영은 건너뛴다.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
scripts/out/qb_sentiment_openai.jsonl
Normal file
120
scripts/out/qb_sentiment_openai.jsonl
Normal file
@ -0,0 +1,120 @@
|
||||
{"id": "A-01", "engine": "openai", "question": "뷰성형외과 어때? 믿을만한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "regulatory", "aspects": ["reviews", "regulatory", "procedure", "safety", "doctors", "access"], "evidence": "그러나 2026년 7월 공정거래위원회의 제재 사례는, 병원 마케팅 방식에 대한 경계가 필요하다는 점을 보여줍니다.", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:34:55", "mention_kind": "strong"}
|
||||
{"id": "A-02", "engine": "openai", "question": "뷰성형외과는 어떤 병원이야? 소개해줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["procedure", "safety", "doctors"], "evidence": "환자의 안전과 만족을 최우선으로 하며, 최신 기술과 장비를 사용하여 서비스를 제공한다고 광고합니다.", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-09T10:34:56", "mention_kind": "strong"}
|
||||
{"id": "A-03", "engine": "openai", "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-09T10:34:59", "mention_kind": "strong"}
|
||||
{"id": "A-04", "engine": "openai", "question": "뷰성형외과 최순우 원장 어떤 의사야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "reviews", "media"], "evidence": "특히 협진 시스템, 정밀 검사, 안전 중심의 수술 시스템을 갖춘 병원을 운영하며, 의료관광 분야에서도 활동하는 등 의료계 내 다양한 영역에서 신뢰를 쌓아왔습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:00", "mention_kind": "strong"}
|
||||
{"id": "A-05", "engine": "openai", "question": "뷰성형외과 의료진 몇 명이야? 다 전문의야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["doctors", "procedure", "safety"], "evidence": "의료진 수: **약 22명**\n- 이 중 성형외과 전문의 비율: **대부분이 성형외과 전문의**\n- 그 외: **이비인후과 전문의** (코 성형 담당), **마취통증의학과 전문의** (안전한 수술을 위한 상주 인력) 포함", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:02", "mention_kind": "strong"}
|
||||
{"id": "A-06", "engine": "openai", "question": "뷰성형외과가 렛미인 나온 병원 맞아?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["media"], "evidence": "즉, 뷰성형외과는 “렛미인” 프로그램에 **닥터스 참여 병원**으로 실제 출연한 경력이 있는 병원입니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:04", "mention_kind": "strong"}
|
||||
{"id": "A-07", "engine": "openai", "question": "뷰성형외과 언제 생긴 병원이야? 오래됐어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["regulatory", "safety"], "evidence": "정리하면, 뷰성형외과는 2005년에 문을 연 이후, 현재(2026년 기준) 약 21~22년 정도 운영되어 온 비교적 오랜 역사를 가진 병원입니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:05", "mention_kind": "strong"}
|
||||
{"id": "A-08", "engine": "openai", "question": "뷰성형외과 건물이 통째로 병원이라던데 맞아?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access", "procedure"], "evidence": "뷰성형외과는 성형외과 진료만을 위한 **'19층 규모의 ALL‑IN‑ONE 스마트 빌딩'**으로 운영되고 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:07", "mention_kind": "strong"}
|
||||
{"id": "A-09", "engine": "openai", "question": "뷰성형외과 강남언니 평점 몇 점이야?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews"], "evidence": "강남언니 내 병원 추천 목록에서는 ‘뷰성형외과’ 가 9.5점 평점을 기록 중이며, 약 17,099건의 평가가 있는 것으로 나타났습니다", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:09", "mention_kind": "strong"}
|
||||
{"id": "A-10", "engine": "openai", "question": "뷰성형외과 유튜브 채널 있어?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["media"], "evidence": "뷰성형외과(‘VIEW Plastic Surgery’)는 유튜브에서 공식 채널을 운영하고 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:10", "mention_kind": "strong"}
|
||||
{"id": "A-11", "engine": "openai", "question": "뷰성형외과랑 다른 강남 대형 성형외과랑 뭐가 달라?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["doctors", "safety", "reviews", "procedure", "access"], "evidence": "뷰성형외과는 다른 강남의 대형 성형외과와 비교해 몇 가지 차별점이 있을 수 있습니다. 하지만 정확한 차별점을 알기 위해서는 최신 정보를 확인하는 것이 중요합니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:12", "mention_kind": "strong"}
|
||||
{"id": "A-12", "engine": "openai", "question": "뷰성형외과 단점이나 주의할 점 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "뷰성형외과에 대한 구체적인 단점이나 주의할 점에 관해서는 인터넷 검색이나 직접적인 이용 후기를 참조하는 것이 가장 좋습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:13", "mention_kind": "strong"}
|
||||
{"id": "B-01", "engine": "openai", "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-09T10:35:15", "mention_kind": "strong"}
|
||||
{"id": "B-02", "engine": "openai", "question": "뷰성형외과 신논현역에서 얼마나 걸려?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "신논현역에서 뷰성형외과까지의 거리는 걸어서 약 5분에서 10분 정도 소요될 수 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:16", "mention_kind": "strong"}
|
||||
{"id": "B-03", "engine": "openai", "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-09T10:35:18", "mention_kind": "strong"}
|
||||
{"id": "B-04", "engine": "openai", "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-09T10:35:19", "mention_kind": "strong"}
|
||||
{"id": "B-05", "engine": "openai", "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-09T10:35:21", "mention_kind": "strong"}
|
||||
{"id": "B-06", "engine": "openai", "question": "뷰성형외과 주차 되나?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과(강남구 봉은사로 107, 신논현역 인근)는 **병원 건물 내부에 전용 주차시설이 있는 것으로 보이지 않습니다**.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:23", "mention_kind": "strong"}
|
||||
{"id": "B-07", "engine": "openai", "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-09T10:35:24", "mention_kind": "strong"}
|
||||
{"id": "B-08", "engine": "openai", "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-09T10:35:26", "mention_kind": "strong"}
|
||||
{"id": "B-09", "engine": "openai", "question": "뷰성형외과 상담받으러 가면 몇 층으로 가?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "즉, 처음 뷰성형외과 상담을 받으러 가시면 **1층에서 접수 → (지하 촬영) → 4층으로 올라가 상담 대기 및 상담**의 순서로 이동하게 됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:27", "mention_kind": "strong"}
|
||||
{"id": "B-10", "engine": "openai", "question": "신논현역 3번 출구 근처 성형외과 뭐 있어?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access", "procedure"], "evidence": "- **VIEW Plastic Surgery (뷰 성형외과)**\n - 위치: 신논현역 3번 출구에서 약 50m 거리, 정말 가까움 ([viewplasticsurgery.com](https://www.viewplasticsurgery.com/introduction/direction/?utm_source=openai)).\n - 진료 시간: 평일 10:00–19:00, 금요일은 21:00까지, 토요일 10:00–17:00 ([viewplasticsurgery.com](https://www.viewplasticsurgery.com/introduction/direction/?utm_source=openai)).\n - 특징: 19층 규모의 병원 형태로, 전문성 높은 팀을 구성하여 다부위 수술 및 복합 시술에 적합", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:30", "mention_kind": "strong"}
|
||||
{"id": "C-01", "engine": "openai", "question": "강남 성형외과 추천해줘", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["reviews"], "evidence": "The Door Plastic Surgery, View Plastic Surgery 등도 평점 4.8로 높은 평가받고 있습니다 ([gangnambeautyguide.com](https://gangnambeautyguide.com/en/clinics/?utm_source=openai)).", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:31", "mention_kind": "strong"}
|
||||
{"id": "C-02", "engine": "openai", "question": "강남에서 가슴성형 잘하는 병원 어디야?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["safety", "doctors", "procedure", "access", "reviews"], "evidence": "뷰성형외과 - 협진 체계, 안전 장비, 1:1 진료 및 사후관리 강조", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:33", "mention_kind": "strong"}
|
||||
{"id": "C-03", "engine": "openai", "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-09T10:35:34", "mention_kind": "none"}
|
||||
{"id": "C-04", "engine": "openai", "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"], "evidence": "뷰성형외과 강남 | 협진 시스템, 맞춤 설계, 대학병원급 안전 시스템", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:36", "mention_kind": "strong"}
|
||||
{"id": "C-05", "engine": "openai", "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-09T10:35:37", "mention_kind": "none"}
|
||||
{"id": "C-06", "engine": "openai", "question": "강남 성형외과 톱5 알려줘", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["reviews", "procedure"], "evidence": "- 평점: 4.8 / 리뷰수: 약 9,167건 ([gangnambeautyguide.com](https://gangnambeautyguide.com/en/clinics/?utm_source=openai)) \n- 강점: 유방성형 및 안면 윤곽 전문, 넓은 리뷰 기반", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:39", "mention_kind": "strong"}
|
||||
{"id": "C-07", "engine": "openai", "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-09T10:35:41", "mention_kind": "none"}
|
||||
{"id": "C-08", "engine": "openai", "question": "마취과 전문의가 상주하는 강남 성형외과 어디야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure"], "evidence": "- 안면윤곽, 눈·코·가슴 성형 등을 전문으로 하며, **마취과 전문의가 365일 상주**하고 있다는 점을 강조합니다 ([blog.viewclinic.com](https://blog.viewclinic.com/?utm_source=openai)).\n- 3D-CT 등 정밀 골격 분석 장비와 체계적 사후 관리 체계를 갖추고 있는 병원입니다 ([blog.viewclinic.com](https://blog.viewclinic.com/?utm_source=openai)).", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:42", "mention_kind": "strong"}
|
||||
{"id": "C-09", "engine": "openai", "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-09T10:35:44", "mention_kind": "none"}
|
||||
{"id": "C-10", "engine": "openai", "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-09T10:35:45", "mention_kind": "none"}
|
||||
{"id": "C-11", "engine": "openai", "question": "후기 제일 많은 강남 성형외과 어디야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "regulatory", "aspects": ["regulatory", "reviews", "price"], "evidence": "예컨대 ‘뷰성형외과’, '에이비성형외과의원', '디에이성형외과'는 후기를 조건으로 수술비 일부를 할인해 주고 광고처럼 이용한 사례로 지적된 바 있습니다 ([v.daum.net](https://v.daum.net/v/QaV6Ond8j0?utm_source=openai)).", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:46", "mention_kind": "strong"}
|
||||
{"id": "C-12", "engine": "openai", "question": "강남언니에서 평점 높은 성형외과 알려줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["reviews"], "evidence": "**팝성형외과**, **티에스성형외과**, **기린성형외과**, **제이필의원**, **뷰성형외과**, **뭐 등 다양한 병원들이 높은 평점을 보이고 있습니다.", "recommendation": "recommended", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-09T10:35:48", "mention_kind": "strong"}
|
||||
{"id": "C-13", "engine": "openai", "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-09T10:35:49", "mention_kind": "none"}
|
||||
{"id": "C-14", "engine": "openai", "question": "리프팅 잘하는 강남 병원 추천해줘", "prior": "✕", "extractor_mentioned": true, "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-09T10:35:51", "mention_kind": "none"}
|
||||
{"id": "C-15", "engine": "openai", "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-09T10:35:52", "mention_kind": "none"}
|
||||
{"id": "D1-01", "engine": "openai", "question": "뷰성형외과 가슴성형 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "price", "reviews", "procedure"], "evidence": "뷰성형외과는 가슴성형 분야에서 경험, 전문성, 안전성, 사후관리 체계를 갖춘 병원으로 평가됩니다. 많은 환자들이 만족스럽다는 후기도 많으며, 개인의 체형·희망·상태에 맞춘 맞춤 상담과 전문적인 진료 흐름이 강점입니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:54", "mention_kind": "strong"}
|
||||
{"id": "D1-02", "engine": "openai", "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-09T10:35:55", "mention_kind": "none"}
|
||||
{"id": "D1-03", "engine": "openai", "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-09T10:35:56", "mention_kind": "none"}
|
||||
{"id": "D1-04", "engine": "openai", "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-09T10:35:57", "mention_kind": "none"}
|
||||
{"id": "D1-05", "engine": "openai", "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-09T10:35:59", "mention_kind": "none"}
|
||||
{"id": "D1-06", "engine": "openai", "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-09T10:36:00", "mention_kind": "none"}
|
||||
{"id": "D1-07", "engine": "openai", "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-09T10:36:01", "mention_kind": "none"}
|
||||
{"id": "D1-08", "engine": "openai", "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-09T10:36:02", "mention_kind": "none"}
|
||||
{"id": "D1-09", "engine": "openai", "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-09T10:36:04", "mention_kind": "none"}
|
||||
{"id": "D1-10", "engine": "openai", "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-09T10:36:05", "mention_kind": "none"}
|
||||
{"id": "D1-11", "engine": "openai", "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-09T10:36:06", "mention_kind": "none"}
|
||||
{"id": "D1-12", "engine": "openai", "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-09T10:36:08", "mention_kind": "none"}
|
||||
{"id": "D2-01", "engine": "openai", "question": "뷰성형외과 눈성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "price", "reviews", "procedure", "access"], "evidence": "장점:\n- 높은 평점(4.7)과 JCI 인증, 다국어 지원으로 의료 신뢰도 높음 ([getcarekorea.com](https://getcarekorea.com/ko/hospitals/view-plastic-surgery?utm_source=openai))\n- 눈 성형 종류 다양하고 비교적 합리적인 가격대 ([beauty6.tistory.com](https://beauty6.tistory.com/184?utm_source=openai))\n- 실제 환자들이 자연스러운 결과, 빠른 회복, 친절한 간호/진료 서비스에 만족 ([cashdoc.me](https://cashdoc.me/community/plasticsurgery/89688246?utm_source=openai))", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:10", "mention_kind": "strong"}
|
||||
{"id": "D2-02", "engine": "openai", "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-09T10:36:14", "mention_kind": "none"}
|
||||
{"id": "D2-03", "engine": "openai", "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-09T10:36:15", "mention_kind": "none"}
|
||||
{"id": "D2-04", "engine": "openai", "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-09T10:36:16", "mention_kind": "none"}
|
||||
{"id": "D2-05", "engine": "openai", "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-09T10:36:17", "mention_kind": "none"}
|
||||
{"id": "D2-06", "engine": "openai", "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-09T10:36:19", "mention_kind": "none"}
|
||||
{"id": "D2-07", "engine": "openai", "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-09T10:36:20", "mention_kind": "none"}
|
||||
{"id": "D2-08", "engine": "openai", "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-09T10:36:23", "mention_kind": "none"}
|
||||
{"id": "D2-09", "engine": "openai", "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-09T10:36:24", "mention_kind": "none"}
|
||||
{"id": "D3-01", "engine": "openai", "question": "뷰성형외과 코성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews", "doctors", "procedure"], "evidence": "뷰성형외과의 코성형에 대한 평가는 일반적으로 병원의 평판, 의사들의 경험, 수술 후 환자들의 후기 등을 바탕으로 이루어집니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:25", "mention_kind": "strong"}
|
||||
{"id": "D3-02", "engine": "openai", "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-09T10:36:26", "mention_kind": "none"}
|
||||
{"id": "D3-03", "engine": "openai", "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-09T10:36:28", "mention_kind": "none"}
|
||||
{"id": "D3-04", "engine": "openai", "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-09T10:36:29", "mention_kind": "none"}
|
||||
{"id": "D3-05", "engine": "openai", "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-09T10:36:30", "mention_kind": "none"}
|
||||
{"id": "D3-06", "engine": "openai", "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-09T10:36:32", "mention_kind": "none"}
|
||||
{"id": "D3-07", "engine": "openai", "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-09T10:36:33", "mention_kind": "none"}
|
||||
{"id": "D3-08", "engine": "openai", "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-09T10:36:34", "mention_kind": "none"}
|
||||
{"id": "D3-09", "engine": "openai", "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-09T10:36:35", "mention_kind": "none"}
|
||||
{"id": "D4-01", "engine": "openai", "question": "뷰성형외과 안면윤곽 잘하는 곳 맞아?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "reviews", "procedure", "media"], "evidence": "전반적으로, 뷰성형외과는 안면윤곽 수술에 있어 안전성과 전문성을 갖춘 병원으로 평가받고 있습니다. 특히 수술 기법과 시스템, 다수의 수상 경력과 방송 노출 경력 등이 신뢰감을 줍니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:37", "mention_kind": "strong"}
|
||||
{"id": "D4-02", "engine": "openai", "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-09T10:36:39", "mention_kind": "none"}
|
||||
{"id": "D4-03", "engine": "openai", "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-09T10:36:40", "mention_kind": "none"}
|
||||
{"id": "D4-04", "engine": "openai", "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-09T10:36:41", "mention_kind": "none"}
|
||||
{"id": "D4-05", "engine": "openai", "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-09T10:36:43", "mention_kind": "none"}
|
||||
{"id": "D4-06", "engine": "openai", "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-09T10:36:44", "mention_kind": "none"}
|
||||
{"id": "D4-07", "engine": "openai", "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-09T10:36:45", "mention_kind": "none"}
|
||||
{"id": "D4-08", "engine": "openai", "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-09T10:36:46", "mention_kind": "none"}
|
||||
{"id": "D4-09", "engine": "openai", "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-09T10:36:48", "mention_kind": "none"}
|
||||
{"id": "D5-01", "engine": "openai", "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-09T10:36:49", "mention_kind": "none"}
|
||||
{"id": "D5-02", "engine": "openai", "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-09T10:36:51", "mention_kind": "none"}
|
||||
{"id": "D5-03", "engine": "openai", "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-09T10:36:52", "mention_kind": "none"}
|
||||
{"id": "D5-04", "engine": "openai", "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-09T10:36:53", "mention_kind": "none"}
|
||||
{"id": "D5-05", "engine": "openai", "question": "뷰성형외과에서 지방성형도 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "뷰성형외과에서 지방 성형을 하는지 확인하려면, 해당 병원의 웹사이트를 방문하거나 직접 문의하는 것이 가장 정확합니다.", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:54", "mention_kind": "strong"}
|
||||
{"id": "D5-06", "engine": "openai", "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-09T10:36:56", "mention_kind": "none"}
|
||||
{"id": "D6-01", "engine": "openai", "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-09T10:36:58", "mention_kind": "none"}
|
||||
{"id": "D6-02", "engine": "openai", "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-09T10:37:00", "mention_kind": "none"}
|
||||
{"id": "D6-03", "engine": "openai", "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-09T10:37:01", "mention_kind": "none"}
|
||||
{"id": "D6-04", "engine": "openai", "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-09T10:37:02", "mention_kind": "none"}
|
||||
{"id": "D6-05", "engine": "openai", "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-09T10:37:03", "mention_kind": "none"}
|
||||
{"id": "D6-06", "engine": "openai", "question": "뷰성형외과에서 리프팅도 받을 수 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure"], "evidence": "따라서 뷰성형외과에서는 리프팅 시술이 **다양한 방식으로 제공**되고 있음을 확인하실 수 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:05", "mention_kind": "strong"}
|
||||
{"id": "D6-07", "engine": "openai", "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-09T10:37:06", "mention_kind": "none"}
|
||||
{"id": "D7-01", "engine": "openai", "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-09T10:37:07", "mention_kind": "none"}
|
||||
{"id": "D7-02", "engine": "openai", "question": "뷰성형외과 재수술 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "뷰성형외과의 재수술에 대한 평판이나 성공 사례를 확인하려면, 실제 환자들의 후기를 참고하거나 관련 커뮤니티에서 정보를 수집하는 것이 좋습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:09", "mention_kind": "strong"}
|
||||
{"id": "D7-03", "engine": "openai", "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-09T10:37:10", "mention_kind": "none"}
|
||||
{"id": "D7-04", "engine": "openai", "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-09T10:37:11", "mention_kind": "none"}
|
||||
{"id": "D7-05", "engine": "openai", "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-09T10:37:13", "mention_kind": "none"}
|
||||
{"id": "D7-06", "engine": "openai", "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-09T10:37:15", "mention_kind": "none"}
|
||||
{"id": "E-01", "engine": "openai", "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-09T10:37:16", "mention_kind": "none"}
|
||||
{"id": "E-02", "engine": "openai", "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-09T10:37:18", "mention_kind": "none"}
|
||||
{"id": "E-03", "engine": "openai", "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-09T10:37:19", "mention_kind": "none"}
|
||||
{"id": "E-04", "engine": "openai", "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-09T10:37:21", "mention_kind": "none"}
|
||||
{"id": "E-05", "engine": "openai", "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-09T10:37:22", "mention_kind": "none"}
|
||||
{"id": "E-06", "engine": "openai", "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-09T10:37:23", "mention_kind": "none"}
|
||||
{"id": "E-07", "engine": "openai", "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-09T10:37:24", "mention_kind": "none"}
|
||||
{"id": "E-08", "engine": "openai", "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-09T10:37:26", "mention_kind": "none"}
|
||||
{"id": "E-09", "engine": "openai", "question": "뷰성형외과 안전 시스템 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["safety", "doctors", "procedure", "access", "reviews"], "evidence": "이처럼 뷰성형외과는 **환자 안전을 위한 다층적인 시스템과 기준**을 두고 있습니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:27", "mention_kind": "strong"}
|
||||
{"id": "F-01", "engine": "openai", "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-09T10:37:28", "mention_kind": "none"}
|
||||
{"id": "F-02", "engine": "openai", "question": "뷰성형외과 가슴성형 얼마야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["price", "procedure"], "evidence": "뷰성형외과에서 시행하는 가슴성형 수술의 비용은 여러 요인에 따라 매우 다양한데요. 사용되는 보형물 브랜드, 1차 또는 재수술 여부, 수술 난이도, 마취 시스템, 병원의 안전 인프라 및 사후관리 포함 여부 등에 따라 크게 달라집니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:30", "mention_kind": "strong"}
|
||||
{"id": "F-03", "engine": "openai", "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-09T10:37:31", "mention_kind": "none"}
|
||||
{"id": "F-04", "engine": "openai", "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-09T10:37:32", "mention_kind": "none"}
|
||||
{"id": "F-05", "engine": "openai", "question": "뷰성형외과 지금 하는 이벤트 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["price", "procedure"], "evidence": "뷰성형외과에 대한 현재 진행 중인 이벤트를 확인한 결과, 최신 프로모션 정보를 찾기 위해 주요 정보 플랫폼을 조회했습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:33", "mention_kind": "strong"}
|
||||
{"id": "F-06", "engine": "openai", "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-09T10:37:34", "mention_kind": "none"}
|
||||
{"id": "F-07", "engine": "openai", "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-09T10:37:36", "mention_kind": "none"}
|
||||
{"id": "F-08", "engine": "openai", "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-09T10:37:37", "mention_kind": "none"}
|
||||
{"id": "G-01", "engine": "openai", "question": "뷰성형외과 실제 후기 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "price", "reviews", "procedure"], "evidence": "뷰성형외과는 **높은 만족도와 전문성을 갖춘 대형 병원**으로 평가되며, 특히 자연스럽고 정밀한 결과를 원하는 환자들에게 긍정적인 선택이 될 수 있습니다. 다만, 상담 경험은 대기 시간과 상담 시간의 한계가 있을 수 있으니 이에 대한 충분한 대비가 필요합니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:39", "mention_kind": "strong"}
|
||||
{"id": "G-02", "engine": "openai", "question": "뷰성형외과 부작용 후기나 안 좋은 얘기 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "regulatory", "aspects": ["regulatory", "reviews"], "evidence": "뷰성형외과가 수술비 할인 등 대가를 제공하고, 이를 '자발적인 후기'로 위장하여 광고한 혐의로 공정거래위원회의 시정명령을 받았습니다.", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:40", "mention_kind": "strong"}
|
||||
{"id": "G-03", "engine": "openai", "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-09T10:37:42", "mention_kind": "none"}
|
||||
{"id": "G-04", "engine": "openai", "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-09T10:37:43", "mention_kind": "none"}
|
||||
{"id": "G-05", "engine": "openai", "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-09T10:37:44", "mention_kind": "none"}
|
||||
{"id": "G-06", "engine": "openai", "question": "뷰성형외과 인스타그램 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "요약하면, 예, 뷰성형외과는 공식 인스타그램 계정을 운영 중이며, 해당 계정은 Linktree를 통해 연결되어 있는 것으로 확인됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:50", "mention_kind": "strong"}
|
||||
{"id": "G-07", "engine": "openai", "question": "뷰성형외과는 뭘로 유명한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "access", "media", "reviews"], "evidence": "뷰성형외과는 특히 **가슴성형 분야의 압도적인 시술량**, **안전에 대한 철저한 시스템**, **원스톱 프리미엄 케어**, 그리고 **외국인 환자를 위한 의료관광 서비스**로 높은 평판을 자랑합니다.", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:51", "mention_kind": "strong"}
|
||||
{"id": "G-08", "engine": "openai", "question": "가슴성형 후기 많은 병원 어디야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["reviews"], "evidence": "또한, “View”, “Wooa”, “Wish PS” 등도 리뷰량과 자체 B&A(전후 사진)가 많아 실제 경험 공유가 활발한 곳으로 언급됩니다 ([reddit.com](https://www.reddit.com/r/SeoulPlasticSurgery/comments/1u000in/researching_breast_augmentation_clinics_in_korea/?utm_source=openai)).", "recommendation": "recommended", "confidence": 0.95, "weak_mention": true, "ts": "2026-09-09T10:37:53", "mention_kind": "weak"}
|
||||
120
scripts/out/qb_sentiment_perplexity.jsonl
Normal file
120
scripts/out/qb_sentiment_perplexity.jsonl
Normal file
@ -0,0 +1,120 @@
|
||||
{"id": "A-01", "engine": "perplexity", "question": "뷰성형외과 어때? 믿을만한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews", "access", "procedure", "doctors", "safety"], "evidence": "그래서 현실적으로는 **“후기 기반 평판은 좋은 편이지만, 광고성/이벤트성 후기의 영향도 있을 수 있어 추가 확인이 필요”**라고 보는 게 안전합니다.[4][7][8][14][15]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:26", "mention_kind": "strong"}
|
||||
{"id": "A-02", "engine": "perplexity", "question": "뷰성형외과는 어떤 병원이야? 소개해줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "access", "doctors", "safety"], "evidence": "뷰성형외과는 **서울 강남구 봉은사로 107(논현동)**에 있는 성형외과로, **2005년 개원**한 뒤 가슴성형, 안면윤곽, 양악수술, 눈·코성형, 지방흡입, 안티에이징 등 여러 분야를 다루는 **종합 성형외과**로 소개됩니다.[1][2][11]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:27", "mention_kind": "strong"}
|
||||
{"id": "A-03", "engine": "perplexity", "question": "뷰성형외과는 무슨 수술을 주로 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure"], "evidence": "뷰성형외과는 **가슴성형**을 특히 강조하지만, 그 외에도 **코성형, 눈성형, 안면윤곽, 양악수술, 지방흡입/지방성형, 안티에이징·리프팅**을 주로 하는 병원입니다.[1][3][6][11]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:29", "mention_kind": "strong"}
|
||||
{"id": "A-04", "engine": "perplexity", "question": "뷰성형외과 최순우 원장 어떤 의사야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "procedure", "safety", "media"], "evidence": "즉, 한마디로 정리하면 **서울대 출신의 성형외과 전문의이자 뷰성형외과 대표원장**이며, 병원 측 자료 기준으로는 특히 **가슴성형** 쪽 경력이 많이 강조되는 의사입니다.[1][2][6][7]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:31", "mention_kind": "strong"}
|
||||
{"id": "A-05", "engine": "perplexity", "question": "뷰성형외과 의료진 몇 명이야? 다 전문의야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["doctors"], "evidence": "뷰성형외과의원은 **의사 23명**으로 신고되어 있고, 그중 **전문의는 15명**으로 보입니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:32", "mention_kind": "strong"}
|
||||
{"id": "A-06", "engine": "perplexity", "question": "뷰성형외과가 렛미인 나온 병원 맞아?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["media", "doctors"], "evidence": "네, **뷰성형외과는 렛미인에 나온 병원**이 맞습니다. 여러 자료에서 뷰성형외과가 **‘렛미인 닥터스 선정 병원’으로 시즌 2~5에 참여**했다고 소개하고 있습니다.[1][2][4]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:34", "mention_kind": "strong"}
|
||||
{"id": "A-07", "engine": "perplexity", "question": "뷰성형외과 언제 생긴 병원이야? 오래됐어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과는 2005년에 개원한 병원이라, 현재 기준으로 꽤 오래된 편입니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:35", "mention_kind": "strong"}
|
||||
{"id": "A-08", "engine": "perplexity", "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-09T10:35:36", "mention_kind": "strong"}
|
||||
{"id": "A-09", "engine": "perplexity", "question": "뷰성형외과 강남언니 평점 몇 점이야?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews"], "evidence": "강남언니의 뷰성형외과의원 페이지에 평점이 **9.5**로 표시되어 있습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:38", "mention_kind": "strong"}
|
||||
{"id": "A-10", "engine": "perplexity", "question": "뷰성형외과 유튜브 채널 있어?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["media"], "evidence": "네, **있습니다**. 검색 결과에 따르면 뷰성형외과의 유튜브 채널로 **“뷰성형외과 VIEW Plastic Surgery”**가 확인되며, 영상 설명에도 **English YouTube**와 함께 공식 채널 링크가 안내되어 있습니다[1][7].", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:39", "mention_kind": "strong"}
|
||||
{"id": "A-11", "engine": "perplexity", "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": "안전 중심 운영: 대학병원급 안전 장비와 시설, 응급상황 대비 시스템, 검사·수술·회복을 한 곳에서 진행하는 원스톱 구조를 내세웁니다.[5][8][9]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:41", "mention_kind": "strong"}
|
||||
{"id": "A-12", "engine": "perplexity", "question": "뷰성형외과 단점이나 주의할 점 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "regulatory", "aspects": ["reviews", "price", "doctors", "safety", "regulatory", "procedure", "access"], "evidence": "공정거래위원회가 뷰성형외과를 포함한 강남 성형외과들의 *수술비 할인 등 대가를 받고 작성한 후기를 자발적 후기처럼 보이게 한 행위*를 제재했습니다.", "recommendation": "cautioned", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:43", "mention_kind": "strong"}
|
||||
{"id": "B-01", "engine": "perplexity", "question": "뷰성형외과 주소 어디야?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "**뷰성형외과 주소**는 서울 **강남구 봉은사로 107**입니다.[2][3][4]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:44", "mention_kind": "strong"}
|
||||
{"id": "B-02", "engine": "perplexity", "question": "뷰성형외과 신논현역에서 얼마나 걸려?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "신논현역 3번 출구에서 뷰성형외과까지는 도보로 약 1분~3분 정도로 안내됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:46", "mention_kind": "strong"}
|
||||
{"id": "B-03", "engine": "perplexity", "question": "뷰성형외과 전화번호 알려줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "뷰성형외과 전화번호는 02-539-1177입니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:47", "mention_kind": "strong"}
|
||||
{"id": "B-04", "engine": "perplexity", "question": "뷰성형외과 진료시간 어떻게 돼?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과 진료시간은 **평일 10:00~19:00**, **금요일 10:00~21:00**, **토요일 10:00~17:00**이고, **일요일·공휴일은 휴진**입니다.[1][6][8]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:48", "mention_kind": "strong"}
|
||||
{"id": "B-05", "engine": "perplexity", "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-09T10:35:50", "mention_kind": "strong"}
|
||||
{"id": "B-06", "engine": "perplexity", "question": "뷰성형외과 주차 되나?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access", "price"], "evidence": "네, **주차 가능합니다**. 뷰성형외과는 **주차 가능**으로 안내되어 있고, **35대 동시 주차**가 가능한 주차공간이 있다고 안내합니다.[1][3][5]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:51", "mention_kind": "strong"}
|
||||
{"id": "B-07", "engine": "perplexity", "question": "뷰성형외과 상담 예약 어떻게 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과 상담 예약은 **전화(02-539-1177)** 로 문의하거나, **네이버 예약**에서 진행할 수 있습니다.[2][3]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:53", "mention_kind": "strong"}
|
||||
{"id": "B-08", "engine": "perplexity", "question": "뷰성형외과 카톡으로 상담돼?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "뷰성형외과는 카카오톡 문의를 안내하고 있으며, 검색 결과에 따르면 **@뷰성형외과의원**(플러스친구 ID) 또는 **뷰성형외과**로 카톡 문의가 가능합니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:54", "mention_kind": "strong"}
|
||||
{"id": "B-09", "engine": "perplexity", "question": "뷰성형외과 상담받으러 가면 몇 층으로 가?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["access"], "evidence": "상담은 4층으로 가면 됩니다. 뷰성형외과 안내에 따르면 처음 방문하는 고객은 1층 안내데스크를 이용하고, 상담실은 4층입니다.[2]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:56", "mention_kind": "strong"}
|
||||
{"id": "B-10", "engine": "perplexity", "question": "신논현역 3번 출구 근처 성형외과 뭐 있어?", "prior": "◯", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "access"], "evidence": "뷰성형외과의원: 신논현역 3번 출구 인근으로 안내되며, 가슴성형 중심 병원으로 표시됩니다.[9]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:35:57", "mention_kind": "strong"}
|
||||
{"id": "C-01", "engine": "perplexity", "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-09T10:35:58", "mention_kind": "none"}
|
||||
{"id": "C-02", "engine": "perplexity", "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-09T10:36:00", "mention_kind": "none"}
|
||||
{"id": "C-03", "engine": "perplexity", "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-09T10:36:01", "mention_kind": "none"}
|
||||
{"id": "C-04", "engine": "perplexity", "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-09T10:36:02", "mention_kind": "none"}
|
||||
{"id": "C-05", "engine": "perplexity", "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-09T10:36:03", "mention_kind": "none"}
|
||||
{"id": "C-06", "engine": "perplexity", "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-09T10:36:05", "mention_kind": "none"}
|
||||
{"id": "C-07", "engine": "perplexity", "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-09T10:36:06", "mention_kind": "none"}
|
||||
{"id": "C-08", "engine": "perplexity", "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-09T10:36:07", "mention_kind": "none"}
|
||||
{"id": "C-09", "engine": "perplexity", "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-09T10:36:09", "mention_kind": "none"}
|
||||
{"id": "C-10", "engine": "perplexity", "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-09T10:36:10", "mention_kind": "none"}
|
||||
{"id": "C-11", "engine": "perplexity", "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-09T10:36:12", "mention_kind": "none"}
|
||||
{"id": "C-12", "engine": "perplexity", "question": "강남언니에서 평점 높은 성형외과 알려줘", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["reviews"], "evidence": "**뷰성형외과의원** — **9.5점**, 후기 수는 검색 결과에 일부만 보입니다.[3]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:13", "mention_kind": "strong"}
|
||||
{"id": "C-13", "engine": "perplexity", "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-09T10:36:15", "mention_kind": "none"}
|
||||
{"id": "C-14", "engine": "perplexity", "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-09T10:36:16", "mention_kind": "none"}
|
||||
{"id": "C-15", "engine": "perplexity", "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-09T10:36:17", "mention_kind": "none"}
|
||||
{"id": "D1-01", "engine": "perplexity", "question": "뷰성형외과 가슴성형 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "reviews", "safety", "doctors"], "evidence": "가슴성형 병원 선택은 **의사 경력, 재수술 비율, 보형물 선택, 부작용 대응, 실제 전후 사례**를 함께 봐야 합니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:36:19", "mention_kind": "strong"}
|
||||
{"id": "D1-02", "engine": "perplexity", "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-09T10:36:20", "mention_kind": "none"}
|
||||
{"id": "D1-03", "engine": "perplexity", "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-09T10:36:22", "mention_kind": "none"}
|
||||
{"id": "D1-04", "engine": "perplexity", "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-09T10:36:23", "mention_kind": "none"}
|
||||
{"id": "D1-05", "engine": "perplexity", "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-09T10:36:24", "mention_kind": "none"}
|
||||
{"id": "D1-06", "engine": "perplexity", "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-09T10:36:25", "mention_kind": "none"}
|
||||
{"id": "D1-07", "engine": "perplexity", "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-09T10:36:26", "mention_kind": "none"}
|
||||
{"id": "D1-08", "engine": "perplexity", "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-09T10:36:29", "mention_kind": "none"}
|
||||
{"id": "D1-09", "engine": "perplexity", "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-09T10:36:30", "mention_kind": "none"}
|
||||
{"id": "D1-10", "engine": "perplexity", "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-09T10:36:32", "mention_kind": "none"}
|
||||
{"id": "D1-11", "engine": "perplexity", "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-09T10:36:33", "mention_kind": "none"}
|
||||
{"id": "D1-12", "engine": "perplexity", "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-09T10:36:35", "mention_kind": "none"}
|
||||
{"id": "D2-01", "engine": "perplexity", "question": "뷰성형외과 눈성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "reputation", "aspects": ["reviews"], "evidence": "다만 제가 확인한 자료만으로는 **수술 결과의 일관된 객관성**까지 판단하긴 어렵고, 커뮤니티에는 상담 경험에 대한 **부정적 의견도 일부** 보입니다.[6]", "recommendation": "cautioned", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-09T10:36:36", "mention_kind": "strong"}
|
||||
{"id": "D2-02", "engine": "perplexity", "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-09T10:36:38", "mention_kind": "none"}
|
||||
{"id": "D2-03", "engine": "perplexity", "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-09T10:36:40", "mention_kind": "none"}
|
||||
{"id": "D2-04", "engine": "perplexity", "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-09T10:36:41", "mention_kind": "none"}
|
||||
{"id": "D2-05", "engine": "perplexity", "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-09T10:36:42", "mention_kind": "none"}
|
||||
{"id": "D2-06", "engine": "perplexity", "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-09T10:36:43", "mention_kind": "none"}
|
||||
{"id": "D2-07", "engine": "perplexity", "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-09T10:36:45", "mention_kind": "none"}
|
||||
{"id": "D2-08", "engine": "perplexity", "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-09T10:36:46", "mention_kind": "none"}
|
||||
{"id": "D2-09", "engine": "perplexity", "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-09T10:36:47", "mention_kind": "none"}
|
||||
{"id": "D3-01", "engine": "perplexity", "question": "뷰성형외과 코성형 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews", "price", "procedure", "doctors", "safety"], "evidence": "평가가 많은 편이고, 후기 평점도 높은 편이라 “코성형 상담 후보”로는 많이들 보는 병원입니다.[2][4][7][13] 다만 지금 주어진 정보만으로는 수술 결과의 객관적 수준까지 단정할 수는 없어서, 후기 수와 평점은 참고하되 집도 의료진, 재수술 경험, 상담의 일관성, 부작용 대응을 같이 확인하는 게 중요합니다.[2][4][7]", "recommendation": "listed", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-09T10:36:49", "mention_kind": "strong"}
|
||||
{"id": "D3-02", "engine": "perplexity", "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-09T10:36:51", "mention_kind": "none"}
|
||||
{"id": "D3-03", "engine": "perplexity", "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-09T10:36:52", "mention_kind": "none"}
|
||||
{"id": "D3-04", "engine": "perplexity", "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-09T10:36:53", "mention_kind": "none"}
|
||||
{"id": "D3-05", "engine": "perplexity", "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-09T10:36:54", "mention_kind": "none"}
|
||||
{"id": "D3-06", "engine": "perplexity", "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-09T10:36:55", "mention_kind": "none"}
|
||||
{"id": "D3-07", "engine": "perplexity", "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-09T10:36:57", "mention_kind": "none"}
|
||||
{"id": "D3-08", "engine": "perplexity", "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-09T10:36:58", "mention_kind": "none"}
|
||||
{"id": "D3-09", "engine": "perplexity", "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-09T10:36:59", "mention_kind": "none"}
|
||||
{"id": "D4-01", "engine": "perplexity", "question": "뷰성형외과 안면윤곽 잘하는 곳 맞아?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "reviews", "doctors", "safety", "access"], "evidence": "다만, “잘하는 곳인지”는 객관적으로 단정하기 어렵습니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:01", "mention_kind": "strong"}
|
||||
{"id": "D4-02", "engine": "perplexity", "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-09T10:37:02", "mention_kind": "none"}
|
||||
{"id": "D4-03", "engine": "perplexity", "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-09T10:37:03", "mention_kind": "none"}
|
||||
{"id": "D4-04", "engine": "perplexity", "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-09T10:37:05", "mention_kind": "none"}
|
||||
{"id": "D4-05", "engine": "perplexity", "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-09T10:37:07", "mention_kind": "none"}
|
||||
{"id": "D4-06", "engine": "perplexity", "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-09T10:37:08", "mention_kind": "none"}
|
||||
{"id": "D4-07", "engine": "perplexity", "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-09T10:37:09", "mention_kind": "none"}
|
||||
{"id": "D4-08", "engine": "perplexity", "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-09T10:37:11", "mention_kind": "none"}
|
||||
{"id": "D4-09", "engine": "perplexity", "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-09T10:37:12", "mention_kind": "none"}
|
||||
{"id": "D5-01", "engine": "perplexity", "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-09T10:37:14", "mention_kind": "none"}
|
||||
{"id": "D5-02", "engine": "perplexity", "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-09T10:37:15", "mention_kind": "none"}
|
||||
{"id": "D5-03", "engine": "perplexity", "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-09T10:37:16", "mention_kind": "none"}
|
||||
{"id": "D5-04", "engine": "perplexity", "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-09T10:37:18", "mention_kind": "none"}
|
||||
{"id": "D5-05", "engine": "perplexity", "question": "뷰성형외과에서 지방성형도 해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure"], "evidence": "네, **뷰성형외과는 지방성형을 하는 것으로 보입니다**. 검색 결과에 따르면 **지방흡입**, **지방이식**, 그리고 **눈밑지방재배치** 같은 지방 관련 진료 항목이 확인됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:19", "mention_kind": "strong"}
|
||||
{"id": "D5-06", "engine": "perplexity", "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-09T10:37:20", "mention_kind": "none"}
|
||||
{"id": "D6-01", "engine": "perplexity", "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-09T10:37:22", "mention_kind": "none"}
|
||||
{"id": "D6-02", "engine": "perplexity", "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-09T10:37:23", "mention_kind": "none"}
|
||||
{"id": "D6-03", "engine": "perplexity", "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-09T10:37:24", "mention_kind": "none"}
|
||||
{"id": "D6-04", "engine": "perplexity", "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-09T10:37:27", "mention_kind": "none"}
|
||||
{"id": "D6-05", "engine": "perplexity", "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-09T10:37:28", "mention_kind": "none"}
|
||||
{"id": "D6-06", "engine": "perplexity", "question": "뷰성형외과에서 리프팅도 받을 수 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure"], "evidence": "뷰성형외과는 진료 항목에 **리프팅**이 포함되어 있고, 실제로 **실리프팅**, **레이저 리프팅**, **티타늄 리프팅**, **써마지/울쎄라 계열 리프팅** 관련 안내와 이벤트가 확인됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:29", "mention_kind": "strong"}
|
||||
{"id": "D6-07", "engine": "perplexity", "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-09T10:37:31", "mention_kind": "none"}
|
||||
{"id": "D7-01", "engine": "perplexity", "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-09T10:37:32", "mention_kind": "none"}
|
||||
{"id": "D7-02", "engine": "perplexity", "question": "뷰성형외과 재수술 잘해?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "doctors", "reviews", "safety", "access"], "evidence": "뷰성형외과는 재수술 관련 안내와 사례 콘텐츠를 적극적으로 내고 있고, 눈·코 재수술 상담을 다루는 정보가 확인됩니다.", "recommendation": "listed", "confidence": 0.98, "weak_mention": false, "ts": "2026-09-09T10:37:33", "mention_kind": "strong"}
|
||||
{"id": "D7-03", "engine": "perplexity", "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-09T10:37:35", "mention_kind": "none"}
|
||||
{"id": "D7-04", "engine": "perplexity", "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-09T10:37:36", "mention_kind": "none"}
|
||||
{"id": "D7-05", "engine": "perplexity", "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-09T10:37:37", "mention_kind": "none"}
|
||||
{"id": "D7-06", "engine": "perplexity", "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-09T10:37:38", "mention_kind": "none"}
|
||||
{"id": "E-01", "engine": "perplexity", "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-09T10:37:40", "mention_kind": "none"}
|
||||
{"id": "E-02", "engine": "perplexity", "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-09T10:37:41", "mention_kind": "none"}
|
||||
{"id": "E-03", "engine": "perplexity", "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-09T10:37:42", "mention_kind": "none"}
|
||||
{"id": "E-04", "engine": "perplexity", "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-09T10:37:43", "mention_kind": "none"}
|
||||
{"id": "E-05", "engine": "perplexity", "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-09T10:37:45", "mention_kind": "none"}
|
||||
{"id": "E-06", "engine": "perplexity", "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-09T10:37:46", "mention_kind": "none"}
|
||||
{"id": "E-07", "engine": "perplexity", "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-09T10:37:48", "mention_kind": "none"}
|
||||
{"id": "E-08", "engine": "perplexity", "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-09T10:37:49", "mention_kind": "none"}
|
||||
{"id": "E-09", "engine": "perplexity", "question": "뷰성형외과 안전 시스템 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["safety", "doctors", "procedure"], "evidence": "공개 정보만 놓고 보면 **안전 시스템을 강조하는 병원**으로 볼 수 있지만, 이것이 곧바로 개별 환자에게 충분한 안전을 보장한다는 뜻은 아닙니다.[1][2][7]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:50", "mention_kind": "strong"}
|
||||
{"id": "F-01", "engine": "perplexity", "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-09T10:37:52", "mention_kind": "none"}
|
||||
{"id": "F-02", "engine": "perplexity", "question": "뷰성형외과 가슴성형 얼마야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["price"], "evidence": "뷰성형외과 **가슴성형 비용은 이벤트 기준으로 약 240만~1,155만원 정도**로 확인됩니다.[2][3][4][6][11][12]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:53", "mention_kind": "strong"}
|
||||
{"id": "F-03", "engine": "perplexity", "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-09T10:37:54", "mention_kind": "none"}
|
||||
{"id": "F-04", "engine": "perplexity", "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-09T10:37:57", "mention_kind": "none"}
|
||||
{"id": "F-05", "engine": "perplexity", "question": "뷰성형외과 지금 하는 이벤트 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["price", "procedure"], "evidence": "네. 현재 검색 결과상 **뷰성형외과 이벤트가 여러 개** 확인됩니다.", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:37:59", "mention_kind": "strong"}
|
||||
{"id": "F-06", "engine": "perplexity", "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-09T10:38:00", "mention_kind": "none"}
|
||||
{"id": "F-07", "engine": "perplexity", "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-09T10:38:01", "mention_kind": "none"}
|
||||
{"id": "F-08", "engine": "perplexity", "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-09T10:38:03", "mention_kind": "none"}
|
||||
{"id": "G-01", "engine": "perplexity", "question": "뷰성형외과 실제 후기 어때?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews", "access"], "evidence": "뷰성형외과는 **후기 수가 많은 편**이고, 공개된 후기들에서는 **친절함·상담 설명·시설 만족도**가 자주 언급됩니다. 다만 일부 후기에서는 **엘리베이터가 느리다** 같은 병원 이용 편의에 대한 불만도 보입니다.", "recommendation": "listed", "confidence": 0.95, "weak_mention": false, "ts": "2026-09-09T10:38:05", "mention_kind": "strong"}
|
||||
{"id": "G-02", "engine": "perplexity", "question": "뷰성형외과 부작용 후기나 안 좋은 얘기 있어?", "prior": "✕", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "negative", "negative_type": "reputation", "aspects": ["reviews", "safety"], "evidence": "네. 검색 결과만 보면 **뷰성형외과에 대한 안 좋은 후기나 부작용 언급은 있습니다**.", "recommendation": "cautioned", "confidence": 0.98, "weak_mention": false, "ts": "2026-09-09T10:38:06", "mention_kind": "strong"}
|
||||
{"id": "G-03", "engine": "perplexity", "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-09T10:38:08", "mention_kind": "none"}
|
||||
{"id": "G-04", "engine": "perplexity", "question": "뷰성형외과 수술 전후 사진 어디서 봐?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["procedure", "reviews", "access"], "evidence": "뷰성형외과 **수술 전후 사진**은 보통 병원 **공식 홈페이지의 Before & After 페이지**에서 볼 수 있습니다.[1][2] 병원 공식 인스타그램/유튜브/페이스북에도 일부 사례가 올라와 있습니다.[4][7][16]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:38:09", "mention_kind": "strong"}
|
||||
{"id": "G-05", "engine": "perplexity", "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-09T10:38:10", "mention_kind": "none"}
|
||||
{"id": "G-06", "engine": "perplexity", "question": "뷰성형외과 인스타그램 있어?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": [], "evidence": "네. 뷰성형외과 인스타그램은 **@viewplastic**으로 확인됩니다.[6][9][10]", "recommendation": "not_applicable", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:38:12", "mention_kind": "strong"}
|
||||
{"id": "G-07", "engine": "perplexity", "question": "뷰성형외과는 뭘로 유명한 병원이야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "positive", "negative_type": "none", "aspects": ["doctors", "safety", "procedure", "reviews"], "evidence": "뷰성형외과는 가슴성형, 안면윤곽, 양악수술, 눈·코성형 등 여러 성형 분야를 고르게 하는 곳으로 알려져 있고, 특히 안전 중심 운영과 무사고 기록을 강조해 유명합니다.[1][11][13]", "recommendation": "recommended", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:38:13", "mention_kind": "strong"}
|
||||
{"id": "G-08", "engine": "perplexity", "question": "가슴성형 후기 많은 병원 어디야?", "prior": "△", "extractor_mentioned": true, "model": "gpt-4.1", "rubric": "v0.3", "mentioned": true, "sentiment": "neutral", "negative_type": "none", "aspects": ["reviews"], "evidence": "**뷰성형외과의원**: 후기 19,342개, 시술 후기 9,999+ 건으로 매우 많습니다.[3]", "recommendation": "listed", "confidence": 1.0, "weak_mention": false, "ts": "2026-09-09T10:38:15", "mention_kind": "strong"}
|
||||
176
scripts/run_question_bank_en.py
Normal file
176
scripts/run_question_bank_en.py
Normal file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""외국인 관점 영문 Question Bank 답변엔진 실측 러너 (OpenAI / Perplexity).
|
||||
|
||||
run_question_bank_openai.py의 영문·해외 위치 변형. 차이점:
|
||||
- 질문 파일·출력 폴더를 인자로 받는다 (기본: docs/reports/viewclinic/05_global_en)
|
||||
- OpenAI web_search user_location을 --country/--city로 지정 (기본 US / New York)
|
||||
- 병원명 추출 프롬프트가 영문 표기 변형(VIEW Plastic Surgery, View Clinic, VIEW Seoul)을 같은 병원으로 본다
|
||||
- 인용 URL을 도메인별로 집계해 "AI가 어떤 표면을 근거로 쓰는가"를 기록한다
|
||||
|
||||
사용:
|
||||
python3 scripts/run_question_bank_en.py --engine perplexity
|
||||
python3 scripts/run_question_bank_en.py --engine openai --country US --city "New York"
|
||||
python3 scripts/run_question_bank_en.py --engine openai --summarize
|
||||
"""
|
||||
import argparse, json, os, re, sys, time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import run_question_bank_openai as base # noqa: E402
|
||||
|
||||
ROOT = base.ROOT
|
||||
DEFAULT_DIR = os.path.join(ROOT, "docs", "reports", "viewclinic", "05_global_en")
|
||||
|
||||
EXTRACT_PROMPT = """Below is an answer from an AI search engine. Extract the names of plastic surgery clinics / hospitals mentioned, in order of appearance, up to 8.
|
||||
- Clinic names only (exclude platforms, apps, media, agencies: e.g. RealSelf, Reddit, Gangnam Unni, Bookimed, Whatclinic are NOT clinics)
|
||||
- "VIEW Plastic Surgery" also appears as View Clinic, VIEW Clinic Korea, VIEW Seoul, VIEW Plastic Surgery Clinic, 뷰성형외과. Treat all as the same clinic and normalize to "VIEW Plastic Surgery".
|
||||
- Output JSON only: {"clinics": ["Clinic 1", ...], "view_mentioned": true/false, "view_rank": position or null, "view_sentiment": "positive"|"neutral"|"negative"|null}
|
||||
- view_sentiment: how the answer characterizes VIEW (null if not mentioned)
|
||||
|
||||
Answer:
|
||||
"""
|
||||
|
||||
|
||||
class OpenAIEngineLoc(base.OpenAIEngine):
|
||||
def __init__(self, model, search_tool, country, city, tz):
|
||||
super().__init__(model, search_tool)
|
||||
self.loc = {"type": "approximate", "country": country, "city": city, "timezone": tz}
|
||||
|
||||
def ask(self, question):
|
||||
tool = {"type": self.search_tool, "user_location": self.loc}
|
||||
try:
|
||||
resp = base.post_retry("https://api.openai.com/v1/responses",
|
||||
{"model": self.model, "tools": [tool], "input": question},
|
||||
{"Authorization": f"Bearer {self.key}"})
|
||||
except RuntimeError as e:
|
||||
if self.search_tool == "web_search" and "web_search" in str(e):
|
||||
self.search_tool = "web_search_preview"
|
||||
return self.ask(question)
|
||||
raise
|
||||
return base.openai_answer_text(resp)
|
||||
|
||||
|
||||
def extract_clinics(answer):
|
||||
okey = os.environ.get("OPENAI_API_KEY")
|
||||
if not okey:
|
||||
sys.exit("OPENAI_API_KEY needed for extraction")
|
||||
resp = base.post_retry("https://api.openai.com/v1/responses",
|
||||
{"model": "gpt-4o-mini", "input": EXTRACT_PROMPT + answer[:9000]},
|
||||
{"Authorization": f"Bearer {okey}"})
|
||||
text, _ = base.openai_answer_text(resp)
|
||||
m = re.search(r"\{.*\}", text or "", re.S)
|
||||
try:
|
||||
return json.loads(m.group(0)) if m else {"clinics": [], "view_mentioned": False, "view_rank": None, "view_sentiment": None}
|
||||
except json.JSONDecodeError:
|
||||
return {"clinics": [], "view_mentioned": False, "view_rank": None, "view_sentiment": None, "parse_error": text[:200]}
|
||||
|
||||
|
||||
def domain(u):
|
||||
try:
|
||||
d = urlparse(u).netloc.lower()
|
||||
return d[4:] if d.startswith("www.") else d
|
||||
except Exception:
|
||||
return u
|
||||
|
||||
|
||||
def out_path(d, engine):
|
||||
return os.path.join(d, f"qb_en_{engine}_results.jsonl")
|
||||
|
||||
|
||||
def load_done(d, engine):
|
||||
done = {}
|
||||
p = out_path(d, engine)
|
||||
if os.path.exists(p):
|
||||
for line in open(p):
|
||||
if line.strip():
|
||||
row = json.loads(line); done[row["id"]] = row
|
||||
return done
|
||||
|
||||
|
||||
def summarize(d, engine, qfile):
|
||||
done = load_done(d, engine)
|
||||
qs = json.load(open(qfile))
|
||||
cats = {"A": "Brand", "B": "Travel & logistics", "C": "Recommendation & comparison", "D": "Procedure info",
|
||||
"E": "Pricing", "F": "Safety & trust", "G": "Aftercare & remote follow-up"}
|
||||
freq, dom_freq, view_dom = {}, {}, {}
|
||||
per_cat = {}
|
||||
lines = [f"# VIEW Plastic Surgery EN Question Bank results: {engine}", "",
|
||||
f"Measured {time.strftime('%Y-%m-%d')} / {len(done)} questions", "",
|
||||
"| # | Question | VIEW mentioned | Rank | Sentiment | Clinics (in order) | Cited domains |", "|---|---|---|---|---|---|---|"]
|
||||
for q in qs:
|
||||
r = done.get(q["id"])
|
||||
if not r: continue
|
||||
c = per_cat.setdefault(q["cat"], [0, 0, 0]); c[0] += 1
|
||||
clinics = [x for x in r.get("clinics", []) if isinstance(x, str)][:8]
|
||||
for i, x in enumerate(clinics):
|
||||
freq.setdefault(x, [0, 0]); freq[x][0] += 1
|
||||
if i == 0: freq[x][1] += 1
|
||||
doms = sorted({domain(u) for u in r.get("urls", [])})
|
||||
for dm in doms:
|
||||
dom_freq[dm] = dom_freq.get(dm, 0) + 1
|
||||
vm = bool(r.get("view_mentioned"))
|
||||
if vm:
|
||||
c[1] += 1
|
||||
for dm in doms: view_dom[dm] = view_dom.get(dm, 0) + 1
|
||||
if any("viewplasticsurgery.com" in u or "viewclinic" in u for u in r.get("urls", [])): c[2] += 1
|
||||
lines.append(f"| {q['id']} | {q['question']} | {'O' if vm else 'X'} | {r.get('view_rank') or '-'} | {r.get('view_sentiment') or '-'} | "
|
||||
f"{', '.join(clinics) if clinics else '(none)'} | {', '.join(doms[:6])} |")
|
||||
mentioned = sum(v[1] for v in per_cat.values())
|
||||
lines += ["", "## Summary", "", f"- VIEW mentioned: {mentioned}/{len(done)} ({mentioned*100//max(len(done),1)}%)", "",
|
||||
"| Category | Questions | VIEW mentioned | viewplasticsurgery.com cited |", "|---|---|---|---|"]
|
||||
for k in sorted(per_cat):
|
||||
n, m, cited = per_cat[k]
|
||||
lines.append(f"| {k} {cats.get(k,'')} | {n} | {m} ({m*100//n}%) | {cited} ({cited*100//n}%) |")
|
||||
lines += ["", "### Clinic mention frequency (top 20)", "", "| Clinic | Questions | First position |", "|---|---|---|"]
|
||||
for name, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0])[:20]:
|
||||
lines.append(f"| {name} | {cnt} | {first} |")
|
||||
lines += ["", "### Cited domains (top 25)", "", "| Domain | Questions cited | Cited when VIEW mentioned |", "|---|---|---|"]
|
||||
for dm, cnt in sorted(dom_freq.items(), key=lambda x: -x[1])[:25]:
|
||||
lines.append(f"| {dm} | {cnt} | {view_dom.get(dm, 0)} |")
|
||||
out_md = os.path.join(d, f"QB_en_{engine}_results.md")
|
||||
open(out_md, "w").write("\n".join(lines) + "\n")
|
||||
json.dump({"per_cat": per_cat, "freq": freq, "dom_freq": dom_freq, "view_dom": view_dom, "n": len(done), "mentioned": mentioned},
|
||||
open(os.path.join(d, f"QB_en_{engine}_summary.json"), "w"), ensure_ascii=False, indent=1)
|
||||
print(f"saved: {out_md}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--engine", choices=["openai", "perplexity"], default="perplexity")
|
||||
ap.add_argument("--model", default=None)
|
||||
ap.add_argument("--search-tool", default="web_search")
|
||||
ap.add_argument("--country", default="US"); ap.add_argument("--city", default="New York"); ap.add_argument("--tz", default="America/New_York")
|
||||
ap.add_argument("--dir", default=DEFAULT_DIR)
|
||||
ap.add_argument("--questions", default=os.path.join(DEFAULT_DIR, "question_bank_en.json"))
|
||||
ap.add_argument("--limit", type=int, default=0); ap.add_argument("--sleep", type=float, default=1.0)
|
||||
ap.add_argument("--summarize", action="store_true")
|
||||
a = ap.parse_args()
|
||||
base.load_dotenv()
|
||||
if a.summarize:
|
||||
summarize(a.dir, a.engine, a.questions); return
|
||||
eng = OpenAIEngineLoc(a.model or "gpt-4o", a.search_tool, a.country, a.city, a.tz) if a.engine == "openai" \
|
||||
else base.PerplexityEngine(a.model or "sonar")
|
||||
os.makedirs(a.dir, exist_ok=True)
|
||||
qs = json.load(open(a.questions)); done = load_done(a.dir, a.engine)
|
||||
todo = [q for q in qs if q["id"] not in done]
|
||||
if a.limit: todo = todo[:a.limit]
|
||||
print(f"{eng.name}: {len(todo)} to run ({len(done)} done)", flush=True)
|
||||
with open(out_path(a.dir, a.engine), "a") as f:
|
||||
for i, q in enumerate(todo, 1):
|
||||
t0 = time.time()
|
||||
try:
|
||||
answer, urls = eng.ask(q["question"])
|
||||
ext = extract_clinics(answer)
|
||||
except Exception as e:
|
||||
print(f" [{i}/{len(todo)}] {q['id']} ERROR {e}", flush=True); time.sleep(5); continue
|
||||
row = {"id": q["id"], "cat": q["cat"], "question": q["question"], "engine": eng.name,
|
||||
"answer": answer, "urls": urls, "measured_at": time.strftime("%Y-%m-%dT%H:%M:%S"), **ext}
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n"); f.flush()
|
||||
print(f" [{i}/{len(todo)}] {q['id']} view={'O' if ext.get('view_mentioned') else 'X'} rank={ext.get('view_rank')} "
|
||||
f"clinics={ext.get('clinics', [])[:4]} urls={len(urls)} ({time.time()-t0:.0f}s)", flush=True)
|
||||
time.sleep(a.sleep)
|
||||
summarize(a.dir, a.engine, a.questions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
291
scripts/sentiment_qb.py
Normal file
291
scripts/sentiment_qb.py
Normal file
@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AI 답변 감성 판정기. 질문 뱅크 실측 결과(JSONL, 답변 원문 포함)를 읽어 대상 병원 언급의 어조를 판정한다.
|
||||
기준표: docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md · 판정 모델: OpenAI gpt-4.1 (JSON 출력)
|
||||
|
||||
사용:
|
||||
python3 scripts/sentiment_qb.py --clinic 뷰성형외과 --alias "뷰성형외과의원,뷰 성형외과,View Plastic Surgery" \
|
||||
--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 docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx
|
||||
|
||||
- 재개 가능: out 파일에 이미 있는 id는 건너뛴다.
|
||||
- 비용: 답변당 1회 호출, 240건 약 $1.5.
|
||||
- 소비자 UI 자동 조회는 하지 않는다(약관). 공식 API 답변 원문만 판정한다.
|
||||
"""
|
||||
import argparse, json, os, random, re, ssl, sys, time, urllib.request, urllib.error, collections
|
||||
|
||||
def ssl_context():
|
||||
# 프레임워크 Python 은 인증서 저장소가 비어 CERTIFICATE_VERIFY_FAILED 가 난다. certifi 가 있으면 그것을, 없으면 시스템 기본을 쓴다.
|
||||
try:
|
||||
import certifi; return ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
return ssl.create_default_context()
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def load_env():
|
||||
for d in (ROOT, os.path.join(ROOT, "..")):
|
||||
p = os.path.join(d, ".env")
|
||||
if not os.path.exists(p): continue
|
||||
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("'"))
|
||||
|
||||
SENTIMENTS = ["positive", "neutral", "negative", "mixed", "not_mentioned"]
|
||||
NEG_TYPES = ["none", "regulatory", "safety", "service", "price", "reputation", "other"]
|
||||
# 기준표 버전. 판정 결과의 추적용. docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.2.md 와 같이 움직인다.
|
||||
# v0.2: 1차 보정(29건, 일치 75%) 불일치에서 R1~R5 추가.
|
||||
# v0.3: 규칙은 R1~R5에서 멈춘다. R4-a·R6을 시험 투입했으나 haewon 라벨 대비 일치율이 93%에서 89%로
|
||||
# 떨어져(하나 고치면 하나 깨짐) 되돌렸다. n=29에서는 과적합이다. 기준표 §5 참조.
|
||||
# v0.3의 실제 변경은 결정론적 언급 탐지기(mention_kind)뿐이다. URL 안의 상호와 '리뷰'의 '뷰' 오탐을 없앤다.
|
||||
RUBRIC_VERSION = "v0.3"
|
||||
|
||||
URL_RE = re.compile(r"https?://\S+|\([a-z0-9.-]+\.[a-z]{2,}[^)]*\)", re.I)
|
||||
|
||||
def strip_urls(text):
|
||||
"""URL·인용 링크를 지운다. news.nate.com/view/... 의 'view' 를 병원 언급으로 세지 않기 위해서다."""
|
||||
return URL_RE.sub(" ", text or "")
|
||||
|
||||
def _flex(name):
|
||||
"""문자 사이 공백을 허용하는 패턴. '뷰성형외과' 가 '뷰 성형외과' 로 쓰여도 잡는다."""
|
||||
return r"\s*".join(re.escape(c) for c in name if not c.isspace())
|
||||
|
||||
def mention_kind(answer, clinic, aliases, weak_tokens):
|
||||
"""strong 정식 상호 / weak 약칭만 / none 언급 없음.
|
||||
URL 을 먼저 지우고, 한글 약칭은 '리뷰' 의 '뷰' 처럼 다른 낱말의 일부인 경우를 뺀다."""
|
||||
a = strip_urls(answer)
|
||||
for n in [clinic] + list(aliases):
|
||||
if n and re.search(_flex(n), a, re.I): return "strong"
|
||||
for t in weak_tokens:
|
||||
pat = (r"(?<![가-힣A-Za-z])" + re.escape(t) + r"(?![가-힣])") if re.match(r"[가-힣]", t) else r"\b" + re.escape(t) + r"\b"
|
||||
if re.search(pat, a, re.I): return "weak"
|
||||
return "none"
|
||||
|
||||
ASPECTS = ["doctors", "safety", "price", "reviews", "regulatory", "procedure", "access", "media"]
|
||||
RECS = ["recommended", "listed", "cautioned", "not_applicable"]
|
||||
|
||||
def rubric_prompt(clinic, aliases):
|
||||
names = ", ".join([clinic] + aliases)
|
||||
return f"""당신은 AI 답변엔진의 답변에서 특정 병원이 어떤 어조로 언급됐는지 판정하는 심사자입니다.
|
||||
대상 병원: {clinic} (별칭: {names}). 다른 병원에 대한 평가는 세지 않습니다. 대상 병원 문장만 봅니다.
|
||||
|
||||
판정 규칙
|
||||
- mentioned: 대상 병원(별칭 포함)이 답변에 나오면 true.
|
||||
- sentiment: positive(강점 서술·추천 근거) / neutral(사실 나열·목록 포함·안내) / negative(부정 사실·병원 고유 문제 지목·주의 권고) / mixed(긍정과 부정이 함께) / not_mentioned.
|
||||
"확인이 필요하다", "직접 상담하라" 같은 일반 주의 문구는 neutral입니다. 병원 고유의 문제를 지목할 때만 negative입니다.
|
||||
|
||||
우선순위 규칙 (기준표 v0.2, 1차 보정에서 도출. 위 정의보다 우선합니다)
|
||||
- R1. 결론 단락이 개별 항목보다 우선합니다. 답변에 종합 평가·결론 단락이 있으면 그 단락의 어조로 판정합니다. 본문에 나열된 개별 항목의 어조로 판정하지 않습니다.
|
||||
예: 본문에 높은 평점이 있어도 결론이 "평판은 좋은 편이지만 추가 확인이 필요"면 neutral입니다.
|
||||
예: 본문에 후기 지적 한 줄이 있어도 종합 평이 "안전성과 전문성을 갖춘 병원으로 평가받고 있습니다"면 positive입니다.
|
||||
예: 결론이 "객관성까지 판단하긴 어렵고 부정적 의견도 일부 보입니다"면 negative입니다.
|
||||
- R2. mixed는 긍정과 부정이 대등하게 병존할 때만 씁니다. 부정 성분이 문장 하나의 부수 언급이면 mixed로 세지 않습니다. 결론이 어느 쪽으로도 기울지 않을 때만 mixed입니다.
|
||||
- R3. "후기 수가 많다", "인지도가 있다"는 규모 서술이며 긍정 평가가 아닙니다. 이 서술만 있고 병원 고유의 문제 지목이 함께 있으면 mixed가 아니라 negative입니다.
|
||||
- R4. 평가 축이 있는 목록에 포함되면 positive입니다. 질문이 조건이나 우수성을 묻고(평점이 높은 곳, 마취과 전문의가 상주하는 곳, 잘하는 곳) 그 목록에 대상 병원이 들어가면 positive입니다. 조건 없는 단순 나열(위치·진료과목 안내, 지역 병원 열거)만 neutral입니다.
|
||||
- R5. 강점·역량 서술은 출처가 병원 홍보라도 positive로 셉니다. 답변이 "안전과 만족을 최우선", "최신 기술과 장비", "마취과 전문의 365일 상주"처럼 강점을 서술하면, 그것이 병원 광고를 인용한 것이라도 어조는 positive입니다.
|
||||
- negative_type: none / regulatory(공정위·시정명령·의료법 위반·광고 제재·소송) / safety(부작용·사고·안전) / service(응대·불친절) / price(가격 불만) / reputation(후기·평판 논란) / other.
|
||||
- aspects: {", ".join(ASPECTS)} 중 해당하는 것 전부.
|
||||
- evidence: 판정 근거가 된 답변 원문 문장 1개를 그대로 인용. 요약하지 않습니다. 언급이 없으면 빈 문자열.
|
||||
- recommendation: recommended(추천했다) / listed(나열만) / cautioned(주의를 붙였다) / not_applicable.
|
||||
- confidence: 0~1.
|
||||
|
||||
JSON만 출력합니다. 키: mentioned, sentiment, negative_type, aspects, evidence, recommendation, confidence."""
|
||||
|
||||
def judge(key, model, system, question, answer, retries=4):
|
||||
body = {"model": model, "temperature": 0,
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"[질문]\n{question}\n\n[답변]\n{answer[:6000]}"}]}
|
||||
req = urllib.request.Request("https://api.openai.com/v1/chat/completions", data=json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
||||
for i in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90, context=ssl_context()) as r:
|
||||
j = json.load(r)
|
||||
out = json.loads(j["choices"][0]["message"]["content"])
|
||||
usage = j.get("usage", {})
|
||||
return out, usage
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (429, 500, 502, 503) and i < retries - 1:
|
||||
time.sleep(2 * (i + 1)); continue
|
||||
raise RuntimeError(f"HTTP {e.code}: {e.read()[:200]}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
if i < retries - 1: continue
|
||||
raise RuntimeError(f"파싱 실패: {e}")
|
||||
|
||||
def normalize(o, mentioned_hint):
|
||||
s = o.get("sentiment") if o.get("sentiment") in SENTIMENTS else "neutral"
|
||||
m = bool(o.get("mentioned", mentioned_hint))
|
||||
if not m: s = "not_mentioned"
|
||||
return {"mentioned": m, "sentiment": s,
|
||||
"negative_type": o.get("negative_type") if o.get("negative_type") in NEG_TYPES else ("none" if s not in ("negative", "mixed") else "other"),
|
||||
"aspects": [a for a in (o.get("aspects") or []) if a in ASPECTS],
|
||||
"evidence": str(o.get("evidence") or "")[:400],
|
||||
"recommendation": o.get("recommendation") if o.get("recommendation") in RECS else ("not_applicable" if not m else "listed"),
|
||||
"confidence": float(o.get("confidence") or 0)}
|
||||
|
||||
def run(args):
|
||||
load_env()
|
||||
key = os.environ.get("OPENAI_API_KEY") or sys.exit("OPENAI_API_KEY 필요 (.env)")
|
||||
aliases = [a.strip() for a in (args.alias or "").split(",") if a.strip()]
|
||||
weak_tokens = [w.strip() for w in (args.weak or "").split(",") if w.strip()]
|
||||
system = rubric_prompt(args.clinic, aliases)
|
||||
rows = [json.loads(l) for l in open(args.inp, encoding="utf-8") if l.strip()]
|
||||
done = {}
|
||||
if os.path.exists(args.out):
|
||||
for l in open(args.out, encoding="utf-8"):
|
||||
if l.strip(): r = json.loads(l); done[r["id"]] = r
|
||||
print(f"입력 {len(rows)}건 · 이미 판정 {len(done)}건 · 모델 {args.model}")
|
||||
tokens = collections.Counter()
|
||||
with open(args.out, "a", encoding="utf-8") as f:
|
||||
for i, r in enumerate(rows):
|
||||
if r["id"] in done: continue
|
||||
kind = mention_kind(r.get("answer", ""), args.clinic, aliases, weak_tokens)
|
||||
hint = kind != "none"
|
||||
out, usage = judge(key, args.model, system, r["question"], r.get("answer", ""))
|
||||
n = normalize(out, hint)
|
||||
# 탐지기가 none 이면 판정기 판단보다 우선한다. 상호가 아예 없는 답변을 언급으로 셀 수 없다.
|
||||
if kind == "none" and n["mentioned"]:
|
||||
n = normalize({"mentioned": False}, False)
|
||||
# 약한 언급: 정식 상호 없이 영문 약칭만 나온 경우. 점수에는 넣되 표시하고 기준표 §6에서 포함 여부를 정한다
|
||||
n["weak_mention"] = kind == "weak" and bool(n["mentioned"])
|
||||
tokens["in"] += usage.get("prompt_tokens", 0); tokens["out"] += usage.get("completion_tokens", 0)
|
||||
row = {"id": r["id"], "engine": r.get("engine"), "question": r["question"], "prior": r.get("prior"),
|
||||
"extractor_mentioned": hint, "model": args.model, "rubric": RUBRIC_VERSION, **n, "ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n"); f.flush()
|
||||
print(f" {r['id']} {n['sentiment']:<13} {n['negative_type']:<10} {n['recommendation']:<14} {n['evidence'][:50]}")
|
||||
time.sleep(0.3)
|
||||
# gpt-4.1 단가 $2/M in · $8/M out
|
||||
cost = tokens["in"] / 1e6 * 2 + tokens["out"] / 1e6 * 8
|
||||
print(f"완료 → {args.out} · 토큰 in {tokens['in']} out {tokens['out']} · 약 ${cost:.2f}")
|
||||
|
||||
def score(rs):
|
||||
m = [r for r in rs if r["mentioned"]]
|
||||
if not m: return None
|
||||
w = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
|
||||
c = collections.Counter(r["sentiment"] for r in m)
|
||||
return {"answers": len(rs), "mentioned": len(m),
|
||||
"score": round(sum(w.get(r["sentiment"], 50) for r in m) / len(m), 1),
|
||||
"positive": c["positive"], "neutral": c["neutral"], "mixed": c["mixed"], "negative": c["negative"],
|
||||
"negative_rate": round((c["negative"] + c["mixed"]) / len(m) * 100, 1),
|
||||
"regulatory_rate": round(sum(1 for r in m if r["negative_type"] == "regulatory") / len(m) * 100, 1),
|
||||
"recommended_rate": round(sum(1 for r in m if r["recommendation"] == "recommended") / len(m) * 100, 1),
|
||||
"aspects": collections.Counter(a for r in m for a in r["aspects"]).most_common(8)}
|
||||
|
||||
def summary(args):
|
||||
allrows = []
|
||||
for p in args.summary:
|
||||
allrows += [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]
|
||||
is_brand = lambda r: str(r["id"]).split("-")[0] in ("A", "B") # 질문 뱅크 A·B = 브랜드 질문
|
||||
lines = [f"# AI 답변 감성 판정 결과 · {args.clinic}", "",
|
||||
f"판정 {time.strftime('%Y-%m-%d')} · 기준표 {RUBRIC_VERSION}(`docs/AI_ANSWER_SENTIMENT_RUBRIC_{RUBRIC_VERSION}.md`) · 모델 {allrows[0]['model'] if allrows else ''} · 답변 {len(allrows)}건", "",
|
||||
"감성 점수 = (positive×100 + neutral×50 + mixed×25 + negative×0) ÷ 언급된 답변 수. 규제 언급률과 추천율은 언급된 답변 기준.", "",
|
||||
"## 1. 엔진별", "", "| 엔진 | 질문군 | 답변 | 언급 | 감성 점수 | 긍정 | 중립 | 혼합 | 부정 | 부정 비율 | 규제 언급률 | 추천율 |", "|---|---|---|---|---|---|---|---|---|---|---|---|"]
|
||||
engines = sorted({r["engine"] for r in allrows})
|
||||
for e in engines:
|
||||
for label, flt in (("전체", lambda r: True), ("브랜드(A·B)", is_brand), ("논브랜드", lambda r: not is_brand(r))):
|
||||
s = score([r for r in allrows if r["engine"] == e and flt(r)])
|
||||
if s: lines.append(f"| {e} | {label} | {s['answers']} | {s['mentioned']} | **{s['score']}** | {s['positive']} | {s['neutral']} | {s['mixed']} | {s['negative']} | {s['negative_rate']}% | {s['regulatory_rate']}% | {s['recommended_rate']}% |")
|
||||
s = score(allrows)
|
||||
if s: lines += ["", f"전체 합산: 언급 {s['mentioned']}/{s['answers']} · 감성 점수 **{s['score']}** · 부정 비율 {s['negative_rate']}% · 규제 언급률 {s['regulatory_rate']}% · 추천율 {s['recommended_rate']}%"]
|
||||
# 약한 언급(정식 상호 없이 영문 약칭만)은 포함 여부가 미결이므로 제외한 점수를 함께 낸다. 기준표 §6.
|
||||
weak = [r for r in allrows if r.get("weak_mention")]
|
||||
if weak:
|
||||
W = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
|
||||
strong = [r for r in allrows if r["sentiment"] != "not_mentioned" and not r.get("weak_mention")]
|
||||
ids = ", ".join(f"{r['id']}({r['engine']})" for r in weak)
|
||||
lines += ["", f"약한 언급 {len(weak)}건({ids})은 정식 상호 없이 영문 약칭만 나온 답변이다. 이를 빼면 언급 {len(strong)}건 · 감성 점수 **{round(sum(W[r['sentiment']] for r in strong) / len(strong), 1)}**. 포함 여부는 기준표 §6 미결이다."]
|
||||
lines += ["", "## 2. 측면 태그 (언급된 답변)", ""]
|
||||
if s: lines += [f"- {a}: {n}건" for a, n in s["aspects"]]
|
||||
negs = [r for r in allrows if r["sentiment"] in ("negative", "mixed")]
|
||||
lines += ["", f"## 3. 부정·혼합 판정 전건 ({len(negs)}건)", "", "| id | 엔진 | 판정 | 종류 | 질문 | 근거 문장 |", "|---|---|---|---|---|---|"]
|
||||
for r in negs: lines.append(f"| {r['id']} | {r['engine']} | {r['sentiment']} | {r['negative_type']} | {r['question'][:40]} | {r['evidence'][:120]} |")
|
||||
pos = [r for r in allrows if r["sentiment"] == "positive" and not is_brand(r)]
|
||||
lines += ["", f"## 4. 논브랜드 질문에서 긍정 언급 ({len(pos)}건, 가장 가치 있는 신호)", "", "| id | 엔진 | 추천 | 질문 | 근거 문장 |", "|---|---|---|---|---|"]
|
||||
for r in pos: lines.append(f"| {r['id']} | {r['engine']} | {r['recommendation']} | {r['question'][:40]} | {r['evidence'][:120]} |")
|
||||
dis = [r for r in allrows if r["mentioned"] != r["extractor_mentioned"]]
|
||||
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).",
|
||||
"- 재지 않는 것: 소비자 화면(chatgpt.com 등)의 실제 답변. 약관상 자동 조회를 하지 않으므로 API 답변과 화면 답변은 다를 수 있다.",
|
||||
"- 재지 않는 것: Google AI Overview·네이버 AI 브리핑·Gemini 의 어조. 다음 회차에 엔진을 추가할 때 같은 기준표로 잰다.",
|
||||
"- 한계: 판정기는 언어 모델이다. 보정(§7) 전 점수는 참고값이고, 보정 후에도 문항 수(언급 84건)가 적어 ±10점은 우연 범위로 본다."]
|
||||
# --calibration 없이 리포트만 다시 만드는 경로가 정상 사용이다(haewon 라벨을 지우지 않기 위해).
|
||||
# 그때는 새 보정 표본을 만들지 않았으므로 1차 보정 결과를 그대로 적는다.
|
||||
if args.calibration:
|
||||
lines += ["", "## 7. 보정", "", f"무작위 30건을 `{os.path.basename(args.calibration)}`에 담았다(판정기 값은 숨김 시트). haewon이 sentiment·negative_type을 적으면 `--agreement` 로 일치율을 낸다. 80% 이상이면 기준표 확정."]
|
||||
else:
|
||||
lines += ["", "## 7. 보정", "",
|
||||
f"기준표 `{RUBRIC_VERSION}` 로 판정했다. 1차 보정(2026-09-09)에서 haewon 라벨 29건 대비 v0.1 일치율은 75%로 기준선 80%에 미달했고, 불일치 7건에서 규칙 R1~R5를 뽑아 판정기에 반영한 것이 이 회차다.",
|
||||
"",
|
||||
"같은 30건으로 다시 잰 일치율은 규칙을 그 표본에 맞춰 고친 결과이므로 확정 근거가 아니다. 기준표 확정은 새로 뽑은 30건의 2차 보정으로 한다. 자세한 이력과 한계는 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.2.md` §5에 있다."]
|
||||
os.makedirs(os.path.dirname(args.report), exist_ok=True)
|
||||
open(args.report, "w", encoding="utf-8").write("\n".join(lines) + "\n")
|
||||
print(f"리포트 → {args.report}")
|
||||
if args.calibration: write_calibration(allrows, args.calibration)
|
||||
|
||||
def write_calibration(rows, path):
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
except ImportError:
|
||||
sys.exit("openpyxl 필요: pip install openpyxl")
|
||||
random.seed(20260908)
|
||||
m = [r for r in rows if r["mentioned"]]
|
||||
sample = random.sample(m, min(30, len(m)))
|
||||
wb = Workbook(); ws = wb.active; ws.title = "라벨링"
|
||||
# 답변 전문을 붙인다(근거 문장만 보고 판정하면 안 된다). 원본 QB 결과에서 id+engine 으로 찾는다.
|
||||
full = {}
|
||||
for eng in ("openai", "perplexity"):
|
||||
fp = os.path.join(ROOT, "scripts", "out", f"qb_{eng}_results.jsonl")
|
||||
if os.path.exists(fp):
|
||||
for l in open(fp, encoding="utf-8"):
|
||||
if l.strip(): q = json.loads(l); full[(q["id"], eng)] = q.get("answer", "")
|
||||
head = ["id", "엔진", "질문", "답변 전문", "판정기가 인용한 근거 문장", "haewon sentiment\n(positive / neutral / negative / mixed / not_mentioned)", "haewon negative_type\n(none / regulatory / safety / service / price / reputation / other)", "메모"]
|
||||
ws.append(head)
|
||||
for c in ws[1]: c.font = Font(bold=True, color="FFFFFF"); c.fill = PatternFill("solid", fgColor="0A1128"); c.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
ws.row_dimensions[1].height = 48
|
||||
for r in sample: ws.append([r["id"], r["engine"], r["question"], full.get((r["id"], r["engine"]), ""), r["evidence"], "", "", ""])
|
||||
for col, w in zip("ABCDEFGH", (8, 11, 34, 90, 46, 22, 24, 24)): ws.column_dimensions[col].width = w
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
dv1 = DataValidation(type="list", formula1='"positive,neutral,negative,mixed,not_mentioned"', allow_blank=True); dv2 = DataValidation(type="list", formula1='"none,regulatory,safety,service,price,reputation,other"', allow_blank=True)
|
||||
ws.add_data_validation(dv1); ws.add_data_validation(dv2); dv1.add(f"F2:F{len(sample)+1}"); dv2.add(f"G2:G{len(sample)+1}")
|
||||
ws.freeze_panes = "D2"
|
||||
guide = wb.create_sheet("기준 요약")
|
||||
for row in [["값", "언제"], ["positive", "병원의 강점을 서술하거나 추천 근거로 든다"], ["neutral", "사실 나열, 목록 포함, 위치·진료과목 안내. '확인이 필요하다', '직접 상담하라' 같은 일반 주의 문구도 neutral"], ["negative", "부정 사실을 서술하거나 병원 고유의 문제를 지목해 주의를 권한다(공정위, 부작용 사례, 후기 논란)"], ["mixed", "긍정과 부정이 한 답변에 함께 있다"], ["", ""], ["negative_type", "부정·혼합일 때만. regulatory 공정위·시정명령·의료법 / safety 부작용·사고 / service 응대 / price 가격 불만 / reputation 후기·평판 논란 / other. 긍정·중립이면 none"], ["", ""], ["주의", "다른 병원에 대한 평가는 세지 않는다. 뷰성형외과 문장만 본다. 답변 전문(D열)을 읽고 판단한다. 근거 문장(E열)은 참고만"]]: guide.append(row)
|
||||
guide.column_dimensions["A"].width = 16; guide.column_dimensions["B"].width = 110
|
||||
for row in guide.iter_rows():
|
||||
for c in row: c.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
for c in row: c.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
hid = wb.create_sheet("판정기(숨김)"); hid.sheet_state = "hidden"
|
||||
hid.append(["id", "engine", "sentiment", "negative_type", "recommendation", "confidence"])
|
||||
for r in sample: hid.append([r["id"], r["engine"], r["sentiment"], r["negative_type"], r["recommendation"], r["confidence"]])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True); wb.save(path)
|
||||
print(f"보정 샘플 {len(sample)}건 → {path}")
|
||||
|
||||
def agreement(args):
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(args.agreement)
|
||||
human = {(r[0], r[1]): (str(r[5] or "").strip().lower(), str(r[6] or "").strip().lower()) for r in wb["라벨링"].iter_rows(min_row=2, values_only=True) if r[0]}
|
||||
model = {(r[0], r[1]): (r[2], r[3]) for r in wb["판정기(숨김)"].iter_rows(min_row=2, values_only=True) if r[0]}
|
||||
labeled = [k for k, v in human.items() if v[0]]
|
||||
if not labeled: sys.exit("haewon 라벨이 비어 있습니다")
|
||||
s_ok = sum(1 for k in labeled if human[k][0] == model[k][0]); n_ok = sum(1 for k in labeled if human[k][1] and human[k][1] == model[k][1])
|
||||
n_lab = sum(1 for k in labeled if human[k][1])
|
||||
print(f"라벨 {len(labeled)}건 · sentiment 일치 {s_ok}/{len(labeled)} = {s_ok*100//len(labeled)}% · negative_type 일치 {n_ok}/{n_lab}")
|
||||
for k in labeled:
|
||||
if human[k][0] != model[k][0]: print(f" 불일치 {k[0]} {k[1]}: haewon {human[k][0]} / 판정기 {model[k][0]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--clinic", default="뷰성형외과"); ap.add_argument("--alias", default="뷰성형외과의원,뷰 성형외과,View Plastic Surgery,viewclinic")
|
||||
ap.add_argument("--weak", default="View,뷰", help="정식 상호 없이 이것만 나오면 약한 언급으로 표시한다")
|
||||
ap.add_argument("--in", dest="inp"); ap.add_argument("--out"); ap.add_argument("--model", default="gpt-4.1")
|
||||
ap.add_argument("--summary", nargs="*"); ap.add_argument("--report"); ap.add_argument("--calibration"); ap.add_argument("--agreement")
|
||||
a = ap.parse_args()
|
||||
if a.agreement: agreement(a)
|
||||
elif a.summary: summary(a)
|
||||
elif a.inp and a.out: run(a)
|
||||
else: ap.print_help()
|
||||
@ -40,6 +40,7 @@ const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{
|
||||
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
|
||||
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'diet_guide', title: '5b. 수술 전후 식이 가이드', hint: '의료관광 연결의 음식점 추천에 그대로 실립니다. 개별 식당의 적합 여부는 저희가 판정하지 않고, 여기 적힌 분류만 표시합니다.', inputs: [{ name: 'recommended', label: '권장 음식 분류 (회복 단계별)', type: 'textarea' }, { name: 'avoid', label: '피해야 할 음식 분류', type: 'textarea' }, { name: 'stageDays', label: '각 단계가 수술 후 며칠인지' }] },
|
||||
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
|
||||
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },
|
||||
|
||||
@ -324,3 +324,76 @@ export function PrismFilled({ size = 20, className = '' }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 의료관광 연결 (숙박·식사·웰니스·관광·축제) ── */
|
||||
|
||||
export function BedFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="2" y="10" width="20" height="8" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="6" width="16" height="5" rx="2" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="7.5" cy="9" r="2" fill="currentColor" />
|
||||
<rect x="2" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
<rect x="19" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForkFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 4v6a2 2 0 0 0 2 2v8h1.5v-8a2 2 0 0 0 2-2V4H12v5h-1V4H9.5v5h-1V4H8z" fill="currentColor" />
|
||||
<path d="M17 4c-1.4 1-2 2.8-2 4.5 0 1.4.6 2.4 1.5 2.9V20H18V4h-1z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 5c2.2 1.8 3.4 4 3.4 6.2 0 1.3-.4 2.4-1.1 3.2C13.4 13.2 12.7 11.4 12 9c-.7 2.4-1.4 4.2-2.3 5.4-.7-.8-1.1-1.9-1.1-3.2C8.6 9 9.8 6.8 12 5z" fill="currentColor" />
|
||||
<ellipse cx="12" cy="17.5" rx="5.5" ry="1.6" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="9" r="3.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5V7z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11.2" y="7" width="1.6" height="3" rx="0.8" fill="currentColor" />
|
||||
<rect x="11.2" y="12" width="1.6" height="5" rx="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BagFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M4 8h16l-1.2 12H5.2L4 8z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8.5 9V7a3.5 3.5 0 1 1 7 0v2h-2V7a1.5 1.5 0 1 0-3 0v2h-2z" fill="currentColor" />
|
||||
<rect x="4" y="7" width="16" height="2.4" rx="1.2" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TheaterFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="4" width="18" height="14" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<path d="M7 9.5h10v2H7zM7 13h6v2H7z" fill="currentColor" />
|
||||
<rect x="8" y="18" width="8" height="2" rx="1" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
6
supporters/.gitignore
vendored
6
supporters/.gitignore
vendored
@ -26,3 +26,9 @@ pnpm-debug.log*
|
||||
|
||||
# 글 생성기 산출물 (리포트는 docs 로 옮겨 커밋)
|
||||
generated/
|
||||
|
||||
# Vision OCR 빌드 산출물 (ocr_evidence.mjs 가 swiftc 로 만든다)
|
||||
scripts/ocr/vision_ocr
|
||||
|
||||
# 시술 후 관리 안내 번역 캐시 (collect_recovery.mjs --translate)
|
||||
src/data/recoveryNotes.cache.json
|
||||
|
||||
8
supporters/DEPLOY_NOTE.md
Normal file
8
supporters/DEPLOY_NOTE.md
Normal file
@ -0,0 +1,8 @@
|
||||
# 배포 안내 (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,15 +5,18 @@
|
||||
```
|
||||
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 (화면에는 안 나옴)
|
||||
@ -52,3 +55,44 @@ node scripts/generate_posts.mjs --clinic viewclinic --from-golden # 정
|
||||
- 자막 없는 영상의 발언을 만들지 않는다. 영상 근거는 `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 코드를 검사한다.
|
||||
|
||||
45
supporters/briefs/oracle/clinic.json
Normal file
45
supporters/briefs/oracle/clinic.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"_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": "서울 강남 청담"
|
||||
},
|
||||
"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": "본사 건물 외관. 출처: 오라클메디컬그룹 홈페이지"
|
||||
}
|
||||
},
|
||||
"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 (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외."
|
||||
}
|
||||
BIN
supporters/briefs/oracle/hq-building.jpg
Normal file
BIN
supporters/briefs/oracle/hq-building.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
supporters/briefs/oracle/logo-horizontal-cropped.png
Normal file
BIN
supporters/briefs/oracle/logo-horizontal-cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
supporters/briefs/oracle/logo-horizontal.png
Normal file
BIN
supporters/briefs/oracle/logo-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
supporters/briefs/oracle/logo-oracle.png
Normal file
BIN
supporters/briefs/oracle/logo-oracle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
supporters/briefs/oracle/logo.gif
Normal file
BIN
supporters/briefs/oracle/logo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
661
supporters/briefs/oracle/planner.places.json
Normal file
661
supporters/briefs/oracle/planner.places.json
Normal file
@ -0,0 +1,661 @@
|
||||
{
|
||||
"_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"
|
||||
}
|
||||
]
|
||||
}
|
||||
47
supporters/briefs/viewclinic/planner.places.json
Normal file
47
supporters/briefs/viewclinic/planner.places.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"_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 }
|
||||
]
|
||||
}
|
||||
729
supporters/briefs/wonjin/planner.places.json
Normal file
729
supporters/briefs/wonjin/planner.places.json
Normal file
@ -0,0 +1,729 @@
|
||||
{
|
||||
"_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
|
||||
}
|
||||
]
|
||||
}
|
||||
94
supporters/scripts/build_planner_data.mjs
Normal file
94
supporters/scripts/build_planner_data.mjs
Normal file
@ -0,0 +1,94 @@
|
||||
// 회복 일정 플래너 병원별 데이터 생성 (워커 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,6 +9,7 @@ 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/');
|
||||
@ -35,6 +36,12 @@ function walk(dir, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function withoutRecoveryQuote(html) {
|
||||
const root = parseHtml(html);
|
||||
root.querySelectorAll('#recovery-quote').forEach((el) => el.remove());
|
||||
return root.toString();
|
||||
}
|
||||
|
||||
const findings = []; // { where, level, code, msg }
|
||||
const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }));
|
||||
|
||||
@ -47,6 +54,8 @@ 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
|
||||
@ -64,7 +73,9 @@ 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));
|
||||
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
|
||||
// 홈페이지 40자 연속 일치(§3-11)는 글이 홈페이지 문장을 베끼는 것을 막는 규칙이다. /stay 의 "시술 후 관리 안내"(#recovery-quote)는
|
||||
// 출처 링크·확인일을 붙인 병원 원문 인용이라 설계상 일치하므로 그 블록만 빼고 본다. 금칙어·운영자 어휘 검사는 인용 블록에도 그대로 적용한다.
|
||||
add(rel, R.checkHomeDuplicate(/id="recovery-quote"/.test(html) ? R.stripHtml(withoutRecoveryQuote(html)) : text, HOME_TEXT));
|
||||
add(rel, R.checkOperatorVocab(text, rel));
|
||||
add(rel, R.checkHeadingEndings(html));
|
||||
// §12-2 (1) 글 첫 부분 지원 고지. site.sponsorNotice 가 켜져 있으면 error, 아니면 warn
|
||||
@ -100,6 +111,8 @@ 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,15 +1,22 @@
|
||||
// 근거 수집기 (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 후보 (사람 확인 전 "확인 대기")
|
||||
// evidence/<clinic>/facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기"). 지점 목록 페이지가 있으면 branches 배열
|
||||
// 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';
|
||||
@ -17,52 +24,102 @@ 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://...>'); process.exit(2); }
|
||||
if (!CLINIC || !START) { console.error('사용법: --clinic <id> --url <https://...> [--extra-url <url,url>]'); 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 경로와 앵커 텍스트 둘 다 본다.
|
||||
// 시술 어휘. URL 경로에 유형이 없는 사이트(index.php?doc=N)는 앵커·제목이 시술명 그대로라서 이 사전으로 잡는다.
|
||||
// 성형외과·피부과 공통. 병원 상호에 흔한 "성형외과"·"피부과" 자체는 넣지 않는다(제목 접미어 오분류 방지).
|
||||
const PROCEDURE_VOCAB = /토닝|기미|색소|잡티|주근깨|검버섯|리프팅|울쎄라|써마지|인모드|슈링크|리쥬란|스킨부스터|물광|보톡스|톡신|필러|여드름|흉터|모공|제모|다한증|홍조|주름|탈모|모발이식|비만|지방분해|지방흡입|스케일링|필링|미백|레이저|사마귀|무좀|피부염|아토피|점\s*제거|문신|타투|눈성형|코성형|쌍꺼풀|매몰|절개|트임|눈매교정|안검|눈재수술|코재수술|콧볼|매부리|양악|윤곽|사각턱|광대|무턱|턱끝|가슴확대|가슴축소|가슴재수술|힙업|리프트|줄기세포|재생|주사|시술|수술|성형술/;
|
||||
// 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다. branches 는 지점 목록·지점별 페이지(네트워크 병원).
|
||||
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/i],
|
||||
['doctor', /\bdoctors?\b|의료진|\bstaff\b|physician|원장\s*(?:소개|단)/i],
|
||||
['branches', /\bbranch|\bnetwork\b|지점|네트워크\s*병원|(?:^|\s)[가-힣A-Za-z0-9]{1,10}점$/i],
|
||||
['direction', /\bdirections?\d*\b|\blocation\b|오시는\s*길|찾아오|\bcontact\b|\bway-?to/i],
|
||||
['facilities', /facilit|\btour\b|둘러보|시설/i],
|
||||
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting/i],
|
||||
['facilities', /facilit|\btour\b|둘러보|시설|병원\s*전경/i],
|
||||
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting|인사말|소개$/i],
|
||||
['reservation', /\breserv|\bcounsel|예약|상담/i],
|
||||
['pricing', /\bprice|\bcost\b|비급여|수가|비용/i],
|
||||
['procedure', PROCEDURE_VOCAB],
|
||||
];
|
||||
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 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 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, ' '));
|
||||
for (const [t, re] of TYPES) if (segs.some((x) => re.test(x)) || re.test(path)) return t;
|
||||
// 경로에는 시술 어휘를 적용하지 않는다(경로 "/surgery/" 같은 일반어가 전부 procedure 로 잡히는 것을 막는다)
|
||||
for (const [t, re] of TYPES) if (t !== 'procedure' && (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 body = await res.text();
|
||||
return { status: res.status, finalUrl: res.url, body, contentType: res.headers.get('content-type') ?? '' };
|
||||
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 };
|
||||
} catch (e) { if (i === tries) return { status: 0, finalUrl: url, body: '', error: e.message }; await sleep(800 * (i + 1)); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- robots ----------
|
||||
// ---------- robots (origin 별 캐시) ----------
|
||||
const robotsCache = new Map();
|
||||
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) {
|
||||
@ -73,30 +130,56 @@ async function loadRobots(origin) {
|
||||
else if (star && /^disallow:/i.test(l)) { const p = l.replace(/^disallow:\s*/i, '').trim(); if (p) dis.push(p); }
|
||||
}
|
||||
}
|
||||
return { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
|
||||
const out = { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
|
||||
robotsCache.set(origin, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 링크 정규화 ----------
|
||||
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 = ''; u.search = '';
|
||||
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);
|
||||
// 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다.
|
||||
// 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다.
|
||||
let s = u.toString();
|
||||
if (u.pathname !== '/' && s.endsWith('/')) s = s.slice(0, -1);
|
||||
if (u.pathname !== '/' && !u.search && s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s;
|
||||
} catch { return null; }
|
||||
}
|
||||
const sameHost = (u, origin) => new URL(u).host.replace(/^www\./, '') === new URL(origin).host.replace(/^www\./, '');
|
||||
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 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()));
|
||||
@ -108,7 +191,9 @@ 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 rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text))).filter((r) => r.some(Boolean));
|
||||
// 중첩 레이아웃 표는 같은 글이 행마다 되풀이된다. 인접한 같은 셀과 같은 행은 한 번만
|
||||
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; });
|
||||
if (rows.length) items.push({ kind: 'table', rows });
|
||||
return;
|
||||
}
|
||||
@ -138,8 +223,12 @@ 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))];
|
||||
return { title, metaDesc, mainSelector, items, images, links, anchors };
|
||||
// <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 };
|
||||
}
|
||||
// 사실 후보용 전문(메뉴·푸터 포함). body 가 없는 옛 HTML 은 root 전체
|
||||
const fullText = (html) => { const r = parse(html); return clean((r.querySelector('body') ?? r).text ?? '').slice(0, 200000); };
|
||||
|
||||
/** 항목을 헤딩 기준 섹션으로 묶는다 */
|
||||
function sectionize(items) {
|
||||
@ -155,17 +244,39 @@ 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(/(?<!\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),
|
||||
phone: pick(new RegExp(`(?<!\\d)(${PHONE})(?!\\d)`, 'g')),
|
||||
address: pick(ADDRESS),
|
||||
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*\d{1,2}[:시]\d{0,2})/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),
|
||||
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) {
|
||||
@ -175,23 +286,53 @@ 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 ? '이름 미확인 · 확인 대기' : creds.length < 2 ? (page.flags.textInImages ? '약력 이미지 글자 · 확인 대기' : '약력 본문 없음 · 확인 대기') : 'ok';
|
||||
const status = !name ? (page.flags.textInImages ? '이름 미확인(이미지 글자) · 확인 대기' : '이름 미확인 · 확인 대기') : 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 });
|
||||
const robots = await loadRobots(origin);
|
||||
console.log(`[1/4] 시작 ${START} (robots Disallow ${robots.disallow.length}개)`);
|
||||
console.log(`[1/4] 시작 ${START}`);
|
||||
|
||||
// 링크 발견: 홈 + 사이트맵
|
||||
const home = await fetchText(START);
|
||||
// 시작 URL 해석: 껍데기면 리다이렉트 대상들을 받아 같은 호스트 링크가 가장 많은 페이지를 홈으로, 나머지는 씨앗 페이지로
|
||||
let home = await fetchText(START);
|
||||
if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); }
|
||||
const homeEx = extract(home.body, home.finalUrl);
|
||||
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})`);
|
||||
}
|
||||
|
||||
// 링크 발견: 홈 + 씨앗 + 사이트맵
|
||||
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 queue = new Map(); // url → { anchor, fromHome }
|
||||
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; };
|
||||
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; } };
|
||||
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;
|
||||
@ -203,22 +344,33 @@ 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; };
|
||||
// 유형이 잡힌 페이지 먼저(의료진 상세는 40명까지), 그다음 후보는 홈 메뉴에서 연결된 것 → 얕은 경로 순
|
||||
const vk = (x) => Math.min(x.variant, 1);
|
||||
const isDoctorDetail = (x) => x.type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(x.url).pathname);
|
||||
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}`);
|
||||
// 버킷 순서: 유형 확정(시술 제외) → 의료진 상세(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 pages = [];
|
||||
const slugOf = (u) => (new URL(u).pathname.replace(/^\/|\/$/g, '').replace(/[^a-zA-Z0-9가-힣]+/g, '-') || 'home');
|
||||
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'); };
|
||||
async function collect(t) {
|
||||
let r = await fetchText(t.url);
|
||||
if (r.status === 404) { // 슬래시 유무 반대 형태 재시도
|
||||
@ -231,9 +383,10 @@ 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');
|
||||
if (type === 'candidate') type = PROCEDURE_HINT.test(bodyText) ? 'procedure' : 'other';
|
||||
// 경로·앵커로 못 정한 페이지: 제목·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 === '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, 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: {} });
|
||||
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: {} });
|
||||
}
|
||||
{
|
||||
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); } });
|
||||
@ -242,12 +395,28 @@ 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, 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: {} });
|
||||
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: {} });
|
||||
}
|
||||
console.log(`\n[3/4] 페이지 ${pages.length}개 수집. 반복 줄 제거`);
|
||||
// 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})` : ''}. 반복 줄 제거`);
|
||||
|
||||
// 반복 줄(boilerplate) 제거: 페이지의 30% 이상(최소 3)에 같은 줄이 있으면 메뉴·푸터로 본다
|
||||
const ok = pages.filter((p) => p.status === 200 && !p.error);
|
||||
const ok = pages.filter((p) => p.status === 200 && !p.error && p.type !== 'duplicate');
|
||||
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));
|
||||
@ -259,7 +428,8 @@ 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;
|
||||
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 };
|
||||
// 이미지 글자: 본문 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 };
|
||||
}
|
||||
|
||||
// 저장
|
||||
@ -268,20 +438,30 @@ 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, 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, 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) });
|
||||
}
|
||||
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, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1));
|
||||
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));
|
||||
|
||||
// 의료진
|
||||
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));
|
||||
// 의료진: 상세 페이지가 있으면 상세, 없으면 목록 페이지(이미지 글자면 이름 미확인으로 남긴다)
|
||||
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 factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type));
|
||||
const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type) || p.extra); // --extra-url 로 지정한 페이지는 사람이 고른 원천이라 항상 포함
|
||||
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); } }
|
||||
writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기".', candidates: facts }, null, 1));
|
||||
// 지점 후보: 지점 목록·지점별 페이지의 "지점명 주소 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));
|
||||
|
||||
// 40자 중복 검사 기준 텍스트
|
||||
writeFileSync(join(OUT, 'home_text.txt'), ok.map((p) => p.text.replace(/\s+/g, '')).join('\n'));
|
||||
@ -291,7 +471,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], origin)) 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])) 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); }
|
||||
@ -299,5 +479,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}명) · 반복 줄 ${boiler.size}개 제거 · home_text ${ok.reduce((n, p) => n + p.chars, 0).toLocaleString()}자`);
|
||||
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()}자`);
|
||||
const bad = index.filter((x) => x.status !== 200); if (bad.length) console.log(` 실패 ${bad.length}: ${bad.map((b) => `${b.status} ${b.url}`).join(', ')}`);
|
||||
|
||||
225
supporters/scripts/collect_news.mjs
Normal file
225
supporters/scripts/collect_news.mjs
Normal file
@ -0,0 +1,225 @@
|
||||
// 뉴스룸 수집기 (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}`);
|
||||
249
supporters/scripts/collect_recovery.mjs
Normal file
249
supporters/scripts/collect_recovery.mjs
Normal file
@ -0,0 +1,249 @@
|
||||
// 병원 홈페이지 시술 페이지 원문에서 "시술 후 관리" 문장을 골라 /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}`);
|
||||
@ -1,11 +1,15 @@
|
||||
// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11).
|
||||
// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11, 피부과 PRD §7·§8).
|
||||
// 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다.
|
||||
// 병원마다 페이지 구성이 다르므로(뷰: 주의사항·애프터케어·안전 페이지 있음 / 원진: 시술 페이지·소개·예약만 텍스트) 두 층으로 고른다.
|
||||
// 1층: 전용 페이지(precautions·aftercare·safety·direction)가 있을 때의 문항
|
||||
// 2층: 어느 병원에나 있는 근거(시술 상세·병원 소개·의료진·예약)로 만드는 문항
|
||||
// 업종(industry)은 plastic(성형외과) / derm(피부과) 두 가지다.
|
||||
// plastic: 시술 영역을 URL 세그먼트(/contents/facial/...)로 묶는다. 세그먼트가 일반어(new·doc·index.php·숫자)면 제목·헤딩·본문 키워드로 정한다.
|
||||
// derm : URL 과 무관하게 제목·헤딩·본문 키워드로 피부과 8영역(질문 뱅크 D1~D8)에 배정한다. 시술 글은 조건 표 8행을 요구한다.
|
||||
// --industry 를 주지 않으면 병원 이름과 시술 페이지 제목의 어휘 비율로 감지하고, 판단이 안 서면 plastic 이다.
|
||||
// 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다.
|
||||
//
|
||||
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6]
|
||||
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6] [--industry derm|plastic] [--areas N]
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
@ -18,6 +22,8 @@ if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic
|
||||
const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
|
||||
const fact = JSON.parse(readFileSync(join(SITE, 'src/data/factSheet.json'), 'utf8'));
|
||||
const authors = JSON.parse(readFileSync(join(SITE, 'src/data/authors.json'), 'utf8'));
|
||||
const factsDraftPath = join(EV, 'facts.draft.json');
|
||||
const factsDraft = existsSync(factsDraftPath) ? JSON.parse(readFileSync(factsDraftPath, 'utf8')) : {};
|
||||
const name = fact.shortName || fact.name || clinic;
|
||||
// 조사: 받침 있으면 은/을, 없으면 는/를
|
||||
const hasBatchim = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 !== 0 : false; };
|
||||
@ -32,43 +38,135 @@ const urls = (...types) => [...new Set(types.flatMap((t) => byType(t).slice(0, 3
|
||||
const physicianIds = Object.keys(authors.physicians ?? {});
|
||||
const rep = physicianIds.find((id) => /대표/.test(authors.physicians[id].title ?? '')) ?? physicianIds[0];
|
||||
const anesth = physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep;
|
||||
const safeDecode = (u) => { try { return decodeURIComponent(u); } catch { return u; } };
|
||||
|
||||
// 시술 페이지를 영역(URL 의 시술 세그먼트)별로 묶는다. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast
|
||||
// ---------- 업종 사전 ----------
|
||||
// 피부과 8영역. 질문 뱅크 D1~D8 과 1:1 (PRD §6·§7). kw 는 제목·헤딩·본문에서 찾는 낱말(소문자 비교).
|
||||
export const DERM_AREAS = [
|
||||
{ key: 'pigment', label: '색소·토닝', qb: 'D1', kw: ['색소', '토닝', '기미', '잡티', '주근깨', '흑자', '검버섯', '점빼기', '점 제거', '미백', '피코', '레이저토닝', '오타모반', '색소침착', '멜라닌', '화이트닝', 'ipl', '브이빔', '주근깨'] },
|
||||
{ key: 'lifting', label: '리프팅·탄력', qb: 'D2', kw: ['리프팅', '탄력', '처짐', '울쎄라', '써마지', '인모드', '슈링크', '실리프팅', '고주파', '하이푸', 'hifu', '튠페이스', '올리지오', '덴서티', '잔주름', '안티에이징', '탄력저하', '볼처짐', '이중턱'] },
|
||||
{ key: 'booster', label: '스킨부스터·재생', qb: 'D3', kw: ['스킨부스터', '리쥬란', '쥬베룩', '엑소좀', '물광', '연어주사', 'pdrn', '재생', '수분', '피부결', '콜라겐', '샤넬주사', '힐러', '스킨보톡스', '더마샤인', '피부재생', '진피', '리쥬엔'] },
|
||||
{ key: 'toxin-filler', label: '톡신·필러', qb: 'D4', kw: ['보톡스', '톡신', '보툴리눔', '필러', '주름', '사각턱', '턱끝', '팔자', '이마', '미간', '눈가', '입술', '코필러', '볼륨', '승모근', '히알루론산', '윤곽주사', '지방분해주사'] },
|
||||
{ key: 'acne', label: '여드름·흉터·모공', qb: 'D5', kw: ['여드름', '흉터', '모공', '피지', '블랙헤드', '압출', '스케일링', '필링', '아그네스', '프락셀', '피코프락셀', '패인 흉터', '튼살', '켈로이드', '수술흉터', 'aha', 'pha', '좁쌀'] },
|
||||
{ key: 'hair-body', label: '제모·바디', qb: 'D6', kw: ['제모', '레이저제모', '바디', '체형', '셀룰라이트', '겨드랑이', '비키니', '종아리', '쿨스컬프팅', '지방 감소', '몸매', '팔뚝', '복부', '허벅지', '뱃살'] },
|
||||
{ key: 'disease-hair', label: '피부질환·모발', qb: 'D7', kw: ['홍조', '안면홍조', '혈관', '실핏줄', '다한증', '액취증', '아토피', '건선', '두드러기', '사마귀', '티눈', '무좀', '습진', '탈모', '두피', '모발', '백반증', '피부염', '알레르기', '한관종', '비립종', '쥐젖', '지루', '피부암', '대상포진', '흉터 없는 점', '피부질환'] },
|
||||
{ key: 'plan', label: '계획·주기·조합', qb: 'D8', kw: ['시술 주기', '권장 주기', '회차', '병행', '조합', '시술 순서', '시술 간격', '유지 관리', '프로그램', '패키지', '캘린더', '연간', '시술 계획', '같이 받', '함께 받', '결혼 전', '행사 전'] },
|
||||
];
|
||||
// 성형외과 폴백 사전. URL 세그먼트가 일반어(new·doc·숫자)라 영역을 못 정할 때만 쓴다. 뷰·원진처럼 세그먼트가 있는 사이트는 이 사전을 타지 않는다.
|
||||
export const PLASTIC_AREAS = [
|
||||
{ key: 'eye', label: '눈성형', kw: ['눈성형', '쌍꺼풀', '안검', '눈매', '트임', '눈밑', '상안검', '하안검'] },
|
||||
{ key: 'nose', label: '코성형', kw: ['코성형', '코끝', '콧대', '비중격', '매부리', '코 재수술', '휜코', '들창코'] },
|
||||
{ key: 'facial', label: '안면윤곽', kw: ['윤곽', '광대', '사각턱', '턱끝', '양악', '안면', 'v라인'] },
|
||||
{ key: 'breast', label: '가슴성형', kw: ['가슴', '보형물', '유방', '모티바', '프리저베', '유두', '부유방'] },
|
||||
{ key: 'lifting', label: '리프팅', kw: ['리프팅', '거상', '주름', '탄력', '안티에이징', '실리프팅'] },
|
||||
{ key: 'body', label: '바디', kw: ['지방흡입', '지방이식', '복부', '허벅지', '팔뚝', '바디', '체형'] },
|
||||
{ key: 'skin', label: '피부', kw: ['피부', '레이저', '보톡스', '필러', '스킨', '토닝', '여드름'] },
|
||||
{ key: 'hair', label: '모발이식', kw: ['모발', '이식', '탈모', '헤어라인'] },
|
||||
];
|
||||
// 업종 감지용 어휘. 시술 페이지 제목·URL 에서 센다.
|
||||
const DERM_VOCAB = /피부과|토닝|울쎄라|써마지|인모드|슈링크|리쥬란|스킨부스터|제모|여드름|모공|기미|잡티|탈모|다한증|홍조|레이저|필링|보톡스|톡신|필러|물광|색소|흉터|주름|탄력|사마귀|아토피|액취증|건선|피부질환|점빼기|검버섯|주근깨/i;
|
||||
const PLASTIC_VOCAB = /성형(?!외과)|수술|절개|매몰|보형물|지방이식|지방흡입|양악|윤곽|거상|재수술|안검|쌍꺼풀|콧대|비중격|가슴|코끝|눈매|트임/;
|
||||
// 시술 페이지로 볼 제목·URL 낱말 (업종별)
|
||||
const PROC_WORD = /(성형|수술|술|리프팅|보톡스|필러|이식|교정|축소|확대|재배치|절개|매몰|거상)/;
|
||||
const procPages = byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(decodeURIComponent(p.url)));
|
||||
const areaOf = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); return (i >= 0 ? segs[i + 1] : segs[0]) ?? 'etc'; };
|
||||
const DERM_PROC_WORD = new RegExp(`${DERM_VOCAB.source}|시술|치료|주사|관리|클리닉|피부`, 'i');
|
||||
// URL 세그먼트가 이런 값이면 영역 이름으로 쓰지 않는다 (오라클: /new/doc/index.php?doc=N)
|
||||
const GENERIC_SEG = /^(new|old|doc|docs|page|pages|view|sub|content|contents|html|php|asp|jsp|index(\.\w+)?|m|mobile|ko|kr|en|web|www|\d+|[a-z]\d+|.*\.(php|asp|aspx|jsp|html?))$/i;
|
||||
|
||||
function detectIndustry() {
|
||||
const forced = opt('industry');
|
||||
if (forced) { if (!['derm', 'plastic'].includes(forced)) { console.error(`--industry 는 derm 또는 plastic (받은 값: ${forced})`); process.exit(2); } return { industry: forced, how: '인자' }; }
|
||||
// 병원 이름에 업종이 있으면 그것으로. "피부과성형외과"처럼 둘 다 있으면 먼저 나온 쪽(등록 진료과목 순서로 본다)
|
||||
const nm = `${fact.name ?? ''} ${fact.shortName ?? ''} ${index.pages.find((p) => p.type === 'home')?.title ?? ''}`;
|
||||
const iDerm = nm.indexOf('피부과'), iPlastic = nm.indexOf('성형외과');
|
||||
if (iDerm >= 0 && (iPlastic < 0 || iDerm < iPlastic)) return { industry: 'derm', how: '병원 이름' };
|
||||
if (iPlastic >= 0) return { industry: 'plastic', how: '병원 이름' };
|
||||
// 이름에 없으면 시술 페이지 제목·URL 어휘를 센다. 본문이 이미지라 글자 수가 적은 페이지도 제목은 있으므로 status 200 전부를 본다
|
||||
const titles = index.pages.filter((p) => p.status === 200 && ['procedure', 'other', 'candidate'].includes(p.type)).map((p) => `${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`);
|
||||
const derm = titles.filter((t) => DERM_VOCAB.test(t)).length, plastic = titles.filter((t) => PLASTIC_VOCAB.test(t)).length;
|
||||
if (derm >= 3 && derm > plastic) return { industry: 'derm', how: `시술 페이지 어휘 (피부과 ${derm} > 성형외과 ${plastic})` };
|
||||
return { industry: 'plastic', how: `기본값 (피부과 어휘 ${derm}, 성형외과 어휘 ${plastic})` };
|
||||
}
|
||||
const { industry, how: industryHow } = detectIndustry();
|
||||
const isDerm = industry === 'derm';
|
||||
const op = isDerm ? '시술' : '수술';
|
||||
const areasMax = Number(opt('areas', '0')) || (isDerm ? 8 : 3);
|
||||
|
||||
// ---------- 시술 페이지 → 영역 ----------
|
||||
const pageCache = new Map();
|
||||
const loadPage = (p) => { if (!p.file) return null; if (!pageCache.has(p.file)) { const f = join(EV, p.file); pageCache.set(p.file, existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null); } return pageCache.get(p.file); };
|
||||
/** 제목(5점)·헤딩(2점)·본문(등장 횟수 × 0.5, 최대 2.5점) 키워드 점수로 사전의 영역 하나를 고른다. 2점 미만이면 null. */
|
||||
function classifyByKeywords(p, dict) {
|
||||
const page = loadPage(p);
|
||||
const title = `${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`.toLowerCase();
|
||||
const heads = (page?.items ?? []).filter((i) => i.kind === 'h').map((i) => i.text).join(' ').toLowerCase();
|
||||
const body = String(page?.text ?? '').toLowerCase().slice(0, 6000);
|
||||
let best = null;
|
||||
for (const a of dict) {
|
||||
let score = 0;
|
||||
for (const k of a.kw) {
|
||||
const kk = k.toLowerCase();
|
||||
if (title.includes(kk)) score += 5;
|
||||
if (heads.includes(kk)) score += 2;
|
||||
const n = body.split(kk).length - 1; if (n) score += Math.min(n, 5) * 0.5;
|
||||
}
|
||||
if (score > (best?.score ?? 0)) best = { area: a, score };
|
||||
}
|
||||
return best && best.score >= 2 ? best.area : null;
|
||||
}
|
||||
// URL 의 시술 세그먼트. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast. 세그먼트가 없으면 'etc'(기존 동작), 일반어·숫자·파일명이면 null(키워드 폴백).
|
||||
const areaOfUrl = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); const seg = (i >= 0 ? segs[i + 1] : segs[0]); if (seg === undefined) return 'etc'; return GENERIC_SEG.test(seg) ? null : seg; };
|
||||
|
||||
// 시술 후보 페이지. 피부과는 procedure 외에 other·candidate 중 피부과 어휘가 있는 페이지도 넣는다(수집기 분류가 doc=N 페이지를 other 로 둘 수 있다).
|
||||
const procPages = isDerm
|
||||
? uniq(pages.filter((p) => ['procedure', 'other', 'candidate'].includes(p.type))).filter((p) => DERM_PROC_WORD.test(`${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`))
|
||||
: byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(safeDecode(p.url)));
|
||||
const areas = new Map();
|
||||
for (const p of procPages) { const a = areaOf(p.url); if (!areas.has(a)) areas.set(a, []); areas.get(a).push(p); }
|
||||
const topAreas = [...areas.entries()].map(([area, list]) => ({ area, list: list.sort((a, b) => b.chars - a.chars), chars: list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, 3);
|
||||
const areaLabel = (a) => (a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area;
|
||||
for (const p of procPages) {
|
||||
let key, meta = null;
|
||||
if (isDerm) { const a = classifyByKeywords(p, DERM_AREAS); if (!a) continue; key = a.key; meta = a; }
|
||||
else { key = areaOfUrl(p.url); if (!key) { const a = classifyByKeywords(p, PLASTIC_AREAS); key = a?.key ?? 'etc'; meta = a; } }
|
||||
if (!areas.has(key)) areas.set(key, { area: key, list: [], meta });
|
||||
areas.get(key).list.push(p);
|
||||
}
|
||||
// 근거 충분성: 영역의 페이지 글자 합이 1,500자 이상이어야 글 하나를 기획한다
|
||||
const topAreas = [...areas.values()].map((a) => ({ ...a, list: a.list.sort((x, y) => y.chars - x.chars), chars: a.list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, areasMax);
|
||||
const areaLabel = (a) => a.meta?.label ?? ((a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area);
|
||||
|
||||
const AVOID = ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담'];
|
||||
const DERM_AVOID = [...AVOID, '이벤트가·할인·% 표기', '정품 정량 인증 같은 인증 표현', '영구·완치·재발 없음 같은 효과 보장', '병원 페이지에 없는 장비·제제 이름', '허가 범위 밖 효능', '전후 사진·비포애프터를 보라는 유도'];
|
||||
const CONDITION_ROWS = '시술시간·마취·통증 정도·회복(다운타임)·유지기간·권장 회차·권장 주기·시술자';
|
||||
const candidates = [];
|
||||
const add = (c) => candidates.push(c);
|
||||
|
||||
// ---------- 1층: 전용 페이지가 있을 때 ----------
|
||||
if (byType('precautions').length) add({
|
||||
id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'],
|
||||
title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?',
|
||||
intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
|
||||
title: `${op} 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?`,
|
||||
intent: isDerm
|
||||
? '시술 전(약·화장·자외선·시술 간격)과 후(세안·자외선 차단·사우나·화장·운동)의 시점을 시술 공통 항목 중심으로 표. 시술별로 다른 값은 시술명을 명시. 부작용(화상·색소침착·멍·부기·감염)은 페이지에 있는 항목만. 개인차 조건 필수.'
|
||||
: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
|
||||
pages: urls('precautions', 'aftercare'), reviewer: rep,
|
||||
});
|
||||
if (byType('safety').length) add({
|
||||
id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'],
|
||||
title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`,
|
||||
intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
|
||||
intent: isDerm
|
||||
? '안전 페이지가 공개한 항목(시술자 정책·장비 허가·응급 장비·부작용 대응 절차·정품 정량 서약)을 항목 표로. 정품 정량은 병원이 밝힌 사실로만 쓰고 인증 표현으로 넓히지 않는다. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.'
|
||||
: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
|
||||
pages: urls('safety', 'checkup'), reviewer: anesth,
|
||||
});
|
||||
if (byType('aftercare').length) add({
|
||||
id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'],
|
||||
title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?',
|
||||
intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".',
|
||||
title: `${op} 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?`,
|
||||
intent: `애프터케어 페이지의 ${isDerm ? '시술별' : '부위별'} 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".`,
|
||||
pages: urls('aftercare', 'checkup'), reviewer: rep,
|
||||
});
|
||||
if (byType('safety').length || byType('aftercare').length) add({
|
||||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||||
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
|
||||
intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.',
|
||||
intent: isDerm
|
||||
? '안전·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 시술자(의사 직접인지)·장비 이름과 허가·권장 회차와 주기·부작용 대응·비급여 고지 위치를 반드시 포함. 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.'
|
||||
: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.',
|
||||
pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep,
|
||||
});
|
||||
|
||||
@ -76,7 +174,9 @@ if (byType('safety').length || byType('aftercare').length) add({
|
||||
if (byType('about').length || byType('doctor').length) add({
|
||||
id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'],
|
||||
title: `${name}${eun(name)} 어떤 병원인가요? 진료 분야·의료진 구성·위치`,
|
||||
intent: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.',
|
||||
intent: isDerm
|
||||
? '병원 소개·진료 분야·의료진 수·지점 수·본사(운영법인)·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장, 다지점이면 지점 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.'
|
||||
: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.',
|
||||
pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep,
|
||||
});
|
||||
if (byType('direction').length || byType('reservation').length) add({
|
||||
@ -85,7 +185,26 @@ if (byType('direction').length || byType('reservation').length) add({
|
||||
intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길·예약 페이지만 근거로. 확인되지 않은 시간·주차는 "전화 확인".',
|
||||
pages: urls('direction', 'reservation', 'about'), reviewer: null,
|
||||
});
|
||||
for (const a of topAreas) add({
|
||||
// 지점 선택 가이드 (피부과·다지점). 지점 유형 페이지가 있거나 팩트 시트·facts.draft 에 branches 가 있을 때만.
|
||||
const branchPages = uniq([...byType('branches'), ...byType('branch')]);
|
||||
const factBranches = Array.isArray(fact.branches) ? fact.branches : [];
|
||||
const draftBranches = Array.isArray(factsDraft.branches) ? factsDraft.branches : Array.isArray(factsDraft.candidates?.branches) ? factsDraft.candidates.branches : [];
|
||||
if (isDerm && (branchPages.length || factBranches.length > 1 || draftBranches.length > 1)) {
|
||||
const srcUrls = [...factBranches, ...draftBranches].map((b) => b?.source ?? b?.src ?? b?.url).filter((u) => typeof u === 'string' && /^https?:/.test(u));
|
||||
add({
|
||||
id: 'branch-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-02', 'B-04'],
|
||||
title: `${name} 지점은 어떻게 고르나요? 지점별 진료시간·담당 원장·예약 경로`,
|
||||
intent: '팩트 시트 branches 와 지점 페이지가 공개한 지점명·주소·진료시간·담당 원장·예약 경로를 지점 × 항목 표로. 없는 칸은 "확인 대기". 지점 간 우열·추천 서술 없음. 팩트 시트 밖의 지점을 만들지 않는다. 글 하단 "검토 지점"은 사람이 정한다.',
|
||||
pages: [...new Set([...branchPages.slice(0, 6).map((p) => p.url), ...srcUrls, ...urls('direction', 'about')])].slice(0, 8), reviewer: null,
|
||||
});
|
||||
}
|
||||
for (const a of topAreas) add(isDerm ? {
|
||||
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: [`${a.meta.qb}-01`],
|
||||
title: `${name} ${areaLabel(a)} 시술은 무엇이 있고 시술시간·통증·회복·유지기간·권장 주기는 어떻게 되나요?`,
|
||||
intent: `${areaLabel(a)} 시술 페이지들이 공개한 시술 종류·대상·장비(페이지에 적힌 이름만)를 정리하고, 시술마다 조건 표(${CONDITION_ROWS}) 8행을 만든다. 페이지에 없는 칸은 "확인"으로 두고 표 아래에 개인차와 병원 확인 대기를 쓴다. 시술자는 페이지에 "의사 직접"·"의사 감독" 표기가 없으면 "확인 대기". 부작용은 페이지에 있는 항목만 화상·색소침착·멍·부기·감염 같은 구체 어휘로. 효과 서술·전후 사진 언급·금액은 옮기지 않는다.`,
|
||||
mustCover: [`시술 조건 표 8행(${CONDITION_ROWS})`, '시술 후 관리와 부작용(페이지에 있는 항목만)', '상담에서 확인할 것 3개(시술자·허가·회차 포함)'],
|
||||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, area: a,
|
||||
} : {
|
||||
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'],
|
||||
title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`,
|
||||
intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`,
|
||||
@ -94,7 +213,9 @@ for (const a of topAreas) add({
|
||||
if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({
|
||||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||||
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
|
||||
intent: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 질문마다 왜 묻는지 한 줄. 답을 적는 칸.',
|
||||
intent: isDerm
|
||||
? '시술 페이지가 공개한 시술·장비·회차 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 시술자(의사 직접인지)·장비 이름과 허가·권장 회차와 주기·부작용 대응·비급여 고지 위치를 반드시 포함. 질문마다 왜 묻는지 한 줄. 답을 적는 칸.'
|
||||
: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 질문마다 왜 묻는지 한 줄. 답을 적는 칸.',
|
||||
pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep,
|
||||
});
|
||||
|
||||
@ -104,12 +225,12 @@ const videosPath = join(SITE, 'src/data/videos.json');
|
||||
const vdata = existsSync(videosPath) ? JSON.parse(readFileSync(videosPath, 'utf8')) : null;
|
||||
// 후보 = 정보형(info) 롱폼(쇼츠 아님, 90초 이상, 제목에 #shorts 없음)만. collect_youtube 가 붙인 info 판정을 그대로 쓴다.
|
||||
const longs = (vdata?.topLong ?? []).concat((vdata?.all ?? []).filter((v) => !v.isShort && v.duration >= 90 && v.info !== false && !/#\s?shorts/i.test(v.title))).filter((v, i, a) => a.findIndex((x) => x.id === v.id) === i);
|
||||
// 병원 소개·방문 안내는 영상을 붙이지 않는다(주제와 맞는 영상이 없고 아무 영상이나 붙으면 오해를 준다)
|
||||
// 병원 소개·방문 안내·지점 안내는 영상을 붙이지 않는다(주제와 맞는 영상이 없고 아무 영상이나 붙으면 오해를 준다)
|
||||
const KW = {
|
||||
'what-is-clinic': [], 'visit-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진'],
|
||||
'precautions-guide': ['주의사항', '수술 후', '관리', '실밥', '부기', '붓기'], 'aftercare-program': ['관리', '애프터', '회복', '수술 후'], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
|
||||
'what-is-clinic': [], 'visit-guide': [], 'branch-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진', '시술자', '정품'],
|
||||
'precautions-guide': ['주의사항', `${op} 후`, '관리', '실밥', '부기', '붓기', '자외선'], 'aftercare-program': ['관리', '애프터', '회복', `${op} 후`], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
|
||||
};
|
||||
const areaKw = (a) => { const t = areaLabel(a); return [t, ...t.split(/[·,/\s]+/).filter((w) => w.length >= 2), a.area]; };
|
||||
const areaKw = (a) => { if (a.meta) return [a.meta.label, ...a.meta.kw.slice(0, 8)]; const t = areaLabel(a); return [t, ...t.split(/[·,/\s]+/).filter((w) => w.length >= 2), a.area]; };
|
||||
function matchVideos(c) {
|
||||
if (!longs.length) return [];
|
||||
const kws = (KW[c.id] ?? []).concat(c.area ? areaKw(c.area) : []).filter(Boolean);
|
||||
@ -123,20 +244,20 @@ const norm = (u) => String(u).replace(/\/$/, '').toLowerCase();
|
||||
function matchImages(c, pg) {
|
||||
const pages = new Set(pg.map(norm));
|
||||
let pool = c.id === 'what-is-clinic' ? imgs.filter((i) => i.category === 'clinic') : imgs.filter((i) => ['procedure', 'equipment'].includes(i.category) && pages.has(norm(i.page.url)));
|
||||
if (c.id === 'visit-guide' || c.id === 'consult-questions') pool = imgs.filter((i) => i.category === 'clinic').slice(0, 2);
|
||||
if (['visit-guide', 'consult-questions', 'branch-guide'].includes(c.id)) pool = imgs.filter((i) => i.category === 'clinic').slice(0, 2);
|
||||
const pick = pool.slice(0, 4).map((i) => ({ src: i.src, alt: i.alt, caption: i.caption }));
|
||||
return { hero: pick[0], gallery: pick.slice(1, 4) };
|
||||
}
|
||||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => {
|
||||
const { hero, gallery } = matchImages(c, pg);
|
||||
return {
|
||||
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
|
||||
...c, industry, avoid: isDerm ? DERM_AVOID : AVOID, reviewerCandidate: reviewer ?? undefined,
|
||||
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
|
||||
videos: matchVideos({ ...c, area }), ...(hero ? { hero, thumbnail: hero.src } : {}), gallery,
|
||||
};
|
||||
});
|
||||
|
||||
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
|
||||
const out = { clinic, industry, industryHow, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
|
||||
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));
|
||||
writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n');
|
||||
console.log(`brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`);
|
||||
console.log(`[${industry} · ${industryHow}] brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`);
|
||||
|
||||
@ -15,6 +15,7 @@ const TPL = join(SRC, 'scripts', 'template');
|
||||
const EXCLUDE = [
|
||||
'node_modules', 'dist', '.astro', '.vercel', 'docs',
|
||||
'src/content/posts', 'public/img', 'scripts/home_text.txt', 'scripts/gate/layout-baseline.json', 'scripts/template', 'README.md',
|
||||
'src/data/recoveryNotes.cache.json', // 시술 후 관리 안내 번역 캐시(병원별 산출물)
|
||||
];
|
||||
const excluded = (abs) => {
|
||||
const rel = relative(SRC, abs).split(sep).join('/');
|
||||
@ -44,9 +45,17 @@ pkg.name = 'supporters-template';
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
// 남은 병원 고유 문자열 검사
|
||||
const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam/;
|
||||
// 상호뿐 아니라 지명·건물 같은 병원 고유 사실도 잡는다. 상호만 찾으면
|
||||
// "Sinnonhyeon Station Exit 3" 처럼 지명으로 적힌 사실이 그대로 템플릿에 남는다.
|
||||
const CLINIC_RE = /뷰성형외과|viewclinic|ViewclinicKR|안성민|o2oteam|View Plastic|Sinnonhyeon|Bongeunsa|Gimpo|신논현|봉은사|19-storey/;
|
||||
const leaks = [];
|
||||
const walk = (d) => { for (const f of readdirSync(d, { withFileTypes: true })) { const p = join(d, f.name); if (f.isDirectory()) { if (f.name !== 'node_modules') walk(p); } else if (/\.(astro|ts|mjs|json|css)$/.test(f.name) && !p.endsWith('export_template.mjs') && !p.includes('/scripts/gate/fixtures/') && CLINIC_RE.test(readFileSync(p, 'utf8'))) leaks.push(relative(DEST, p)); } };
|
||||
walk(DEST);
|
||||
if (leaks.length) { console.error('병원 고유 문자열이 남은 파일:\n ' + leaks.join('\n ')); process.exit(1); }
|
||||
// 문장을 그리는 코드에 병원 고유 사실이 박히면 다른 병원 화면에 틀린 말이 나가므로 내보내기를 멈춘다.
|
||||
// 데이터(src/data)는 워커가 병원마다 갈아끼우고 빌드 도구(scripts)는 고객이 보지 않으므로 알림만 한다.
|
||||
const RENDER_RE = /^src\/(pages|layouts|components|lib)\//;
|
||||
const blocking = leaks.filter((f) => RENDER_RE.test(f));
|
||||
const notes = leaks.filter((f) => !RENDER_RE.test(f));
|
||||
if (notes.length) console.log('참고 · 병원 이름이 남은 파일(고객 화면 아님):\n ' + notes.join('\n '));
|
||||
if (blocking.length) { console.error('고객 화면 코드에 병원 고유 사실이 남았습니다. 데이터에서 읽도록 고치세요:\n ' + blocking.join('\n ')); process.exit(1); }
|
||||
console.log(`템플릿 내보내기 완료: ${DEST}${existsSync(join(DEST, 'node_modules')) ? '' : ' (npm ci 필요)'}`);
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
<!-- expect: BANNED_PHRASE -->
|
||||
<body><main><h1>사례</h1><p>비포애프터 사진은 상담실에서 볼 수 있습니다.</p></main></body>
|
||||
2
supporters/scripts/gate/fixtures/derm-cert-ok.html
Normal file
2
supporters/scripts/gate/fixtures/derm-cert-ok.html
Normal file
@ -0,0 +1,2 @@
|
||||
<!-- expect: (none) -->
|
||||
<body><main><h1>정품 정량 서약</h1><p>병원은 홈페이지에서 정품·정량 사용을 밝힙니다. 이 매체는 전후 사진을 싣지 않습니다. 수술 전후 1:1 모니터링을 안내합니다.</p></main></body>
|
||||
2
supporters/scripts/gate/fixtures/derm-cert-phrase.html
Normal file
2
supporters/scripts/gate/fixtures/derm-cert-phrase.html
Normal file
@ -0,0 +1,2 @@
|
||||
<!-- expect: BANNED_PHRASE -->
|
||||
<body><main><h1>정품 정량</h1><p>이 병원은 정품 정량 인증을 받은 병원입니다. 전후 사진을 꼭 확인하세요.</p></main></body>
|
||||
12
supporters/scripts/gate/fixtures/device-claim-approval.md
Normal file
12
supporters/scripts/gate/fixtures/device-claim-approval.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [DEVICE_CLAIM]
|
||||
---
|
||||
## 장비
|
||||
|
||||
이 병원의 리프팅 장비는 식약처 허가를 받은 장비라고 홈페이지에 적혀 있습니다. 상담에서 확인하세요.
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술자 | 의사 직접 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/device-claim-guarantee.md
Normal file
12
supporters/scripts/gate/fixtures/device-claim-guarantee.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [DEVICE_CLAIM]
|
||||
---
|
||||
## 효과
|
||||
|
||||
레이저 제모는 5회면 영구 제모가 됩니다. 여드름은 치료 후 재발이 없습니다. 상담에서 확인하세요.
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술자 | 의사 직접 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
15
supporters/scripts/gate/fixtures/device-claim-ok.md
Normal file
15
supporters/scripts/gate/fixtures/device-claim-ok.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
expect: []
|
||||
sources:
|
||||
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07", type: clinic }
|
||||
- { label: "식약처 의료기기 허가 정보", url: "https://emedi.mfds.go.kr/", accessed: "2026-09-07", type: regulation }
|
||||
---
|
||||
## 장비와 효과
|
||||
|
||||
홈페이지는 이 장비가 식약처 허가를 받았다고 씁니다. 허가는 해당 제품과 적응증에 한정되며 위험이 없다는 뜻은 아닙니다. 레이저 제모가 영구 제모라는 뜻은 아니며, 유지기간은 개인차가 있습니다. 반영구 화장은 별도 시술입니다.
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술자 | 의사 감독 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
14
supporters/scripts/gate/fixtures/practitioner-missing.md
Normal file
14
supporters/scripts/gate/fixtures/practitioner-missing.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
expect: []
|
||||
expectWarn: [PRACTITIONER_MISSING]
|
||||
industry: derm
|
||||
category: D
|
||||
---
|
||||
## 시술 조건
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술시간 | 확인 |
|
||||
| 마취 | 확인 |
|
||||
|
||||
개인차가 있습니다. 상담에서 확인하세요.
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
expect: []
|
||||
expectNoWarn: [PRACTITIONER_MISSING]
|
||||
industry: plastic
|
||||
category: D
|
||||
---
|
||||
## 수술 조건
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 수술 시간 | 확인 |
|
||||
|
||||
개인차가 있습니다. 상담에서 확인하세요.
|
||||
14
supporters/scripts/gate/fixtures/practitioner-present.md
Normal file
14
supporters/scripts/gate/fixtures/practitioner-present.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
expect: []
|
||||
expectNoWarn: [PRACTITIONER_MISSING]
|
||||
industry: derm
|
||||
category: D
|
||||
---
|
||||
## 시술 조건
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술시간 | 확인 |
|
||||
| 시술자 | 확인 대기 |
|
||||
|
||||
개인차가 있습니다. 상담에서 확인하세요.
|
||||
12
supporters/scripts/gate/fixtures/price-mention.md
Normal file
12
supporters/scripts/gate/fixtures/price-mention.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [PRICE_MENTION]
|
||||
---
|
||||
## 시술 비용
|
||||
|
||||
울쎄라 300샷은 이벤트가 990,000원이고 정가 대비 30% 할인입니다. 상담에서 확인하세요.
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술자 | 확인 대기 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/price-notice-link.md
Normal file
12
supporters/scripts/gate/fixtures/price-notice-link.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: []
|
||||
---
|
||||
## 시술 비용 구성
|
||||
|
||||
가격은 샷 수와 회차, 패키지 조건으로 구성됩니다. 금액은 병원의 비급여 진료비용 고지 페이지(https://example.com/notice)에서 확인하세요.
|
||||
|
||||
| 항목 | 병원 안내 |
|
||||
|---|---|
|
||||
| 시술자 | 확인 대기 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
expect: [REGULATION_SOURCE_MISSING]
|
||||
expect: [REGULATION_SOURCE_MISSING, DEVICE_CLAIM]
|
||||
---
|
||||
## 승인
|
||||
|
||||
|
||||
@ -15,6 +15,9 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.lang-switch",
|
||||
"a.cta",
|
||||
"main",
|
||||
"section.hero-light",
|
||||
@ -255,6 +258,7 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
@ -283,6 +287,9 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.lang-switch",
|
||||
"a.cta",
|
||||
"div.backbar-wrap",
|
||||
"div.backbar.wrap-wide",
|
||||
@ -480,6 +487,7 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
@ -508,6 +516,9 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.lang-switch",
|
||||
"a.cta",
|
||||
"div.backbar-wrap",
|
||||
"div.backbar.wrap-wide",
|
||||
@ -731,6 +742,7 @@
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
|
||||
130
supporters/scripts/gate/plan.test.mjs
Normal file
130
supporters/scripts/gate/plan.test.mjs
Normal file
@ -0,0 +1,130 @@
|
||||
// 회복기 스케줄러 계산 로직 검증. node --experimental-strip-types scripts/gate/plan.test.mjs
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildPlan, addDays, decodeHash, encodeHash, bookingUrl, googleFlightsUrl, buildIcs } from '../../src/lib/plan.ts';
|
||||
import { tourToPlaces, festivalsDuring, levelByDistance, bookingName } from '../../src/lib/tour.ts';
|
||||
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const P = JSON.parse(readFileSync(here('../../src/data/procedures.json'), 'utf8'));
|
||||
const PL = JSON.parse(readFileSync(here('../../src/data/places.json'), 'utf8'));
|
||||
const TKO = JSON.parse(readFileSync(here('../../src/data/medicalTourismKo.json'), 'utf8'));
|
||||
const byId = (id) => P.procedures.find((x) => x.id === id);
|
||||
const nat = (code) => P.nationalities.list.find((x) => x.code === code);
|
||||
|
||||
let n = 0;
|
||||
const ok = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
|
||||
|
||||
ok('addDays: 월 경계', () => { assert.equal(addDays('2026-10-30', 3), '2026-11-02'); assert.equal(addDays('2026-11-01', -2), '2026-10-30'); });
|
||||
|
||||
ok('가슴: 입국일·실밥·출국·체류', () => {
|
||||
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['shopping'] }, PL.places);
|
||||
assert.equal(plan.arriveBy, '2026-11-01');
|
||||
assert.equal(plan.stitchDate, '2026-11-10');
|
||||
assert.equal(plan.earliestDeparture, '2026-11-13');
|
||||
assert.equal(plan.stayNights, 12);
|
||||
assert.equal(plan.days[0].isArrival, true);
|
||||
assert.equal(plan.days.at(-1).isDeparture, true);
|
||||
});
|
||||
|
||||
ok('장거리 국적은 준비일 +1', () => {
|
||||
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('US'), prefs: [] }, PL.places);
|
||||
assert.equal(plan.arriveBy, '2026-10-31');
|
||||
assert.equal(plan.preOpDays, 3);
|
||||
});
|
||||
|
||||
ok('윤곽: 유동식 단계에는 죽·스무디만 추천', () => {
|
||||
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['food', 'shopping'] }, PL.places);
|
||||
const day5 = plan.days.find((d) => d.offset === 5);
|
||||
assert.equal(day5.level, 1);
|
||||
assert.equal(day5.diet, 'liquid');
|
||||
for (const pick of day5.picks) {
|
||||
assert.ok(pick.minLevel <= 1, `${pick.id} 등급 초과`);
|
||||
if (pick.category === 'food') assert.ok(pick.dietOk.includes('liquid'), `${pick.id} 유동식 불가`);
|
||||
}
|
||||
assert.equal(plan.stitchDate, null); // 녹는 실
|
||||
});
|
||||
|
||||
ok('수술 당일·입원일·L0 은 추천 없음', () => {
|
||||
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
|
||||
const d0 = plan.days.find((d) => d.offset === 0), d1 = plan.days.find((d) => d.offset === 1);
|
||||
assert.deepEqual(d0.picks, []); assert.equal(d0.isSurgery, true);
|
||||
assert.equal(d1.level, 0); assert.deepEqual(d1.picks, []);
|
||||
});
|
||||
|
||||
ok('호텔은 일자 추천에 나오지 않음', () => {
|
||||
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: ['care'] }, PL.places);
|
||||
for (const d of plan.days) for (const p of d.picks) assert.notEqual(p.category, 'hotel');
|
||||
});
|
||||
|
||||
ok('연속 이틀 같은 곳 반복 억제', () => {
|
||||
const plan = buildPlan({ procedure: byId('eye'), surgeryDate: '2026-11-03', nationality: null, prefs: ['sightseeing'] }, PL.places);
|
||||
const out = plan.days.filter((d) => d.picks.length && d.offset > 0);
|
||||
let repeats = 0;
|
||||
for (let i = 1; i < out.length; i++) for (const p of out[i].picks) if (out[i - 1].picks.some((q) => q.id === p.id)) repeats++;
|
||||
assert.ok(repeats <= out.length, `연속 반복 ${repeats}`);
|
||||
});
|
||||
|
||||
ok('해시 왕복', () => {
|
||||
const s = { p: 'nose', d: '2026-12-01', l: 'en', n: 'US', pref: ['kpop', 'food'] };
|
||||
assert.deepEqual(decodeHash('#' + encodeHash(s)), s);
|
||||
assert.equal(decodeHash('#d=notadate').d, undefined);
|
||||
});
|
||||
|
||||
ok('예약 링크 형식', () => {
|
||||
const u = new URL(bookingUrl('Hotel Cappuccino Seoul', '2026-11-01', '2026-11-13', 'en'));
|
||||
assert.equal(u.hostname, 'www.booking.com');
|
||||
assert.equal(u.searchParams.get('checkin'), '2026-11-01');
|
||||
assert.ok(googleFlightsUrl('2026-11-01', '2026-11-13', 'en').startsWith('https://www.google.com/travel/flights'));
|
||||
});
|
||||
|
||||
ok('ICS 에 일정 4개', () => {
|
||||
const plan = buildPlan({ procedure: byId('nose'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
|
||||
const ics = buildIcs(plan, { arrive: 'Arrive', surgery: 'Surgery', stitch: 'Stitch', departure: 'Depart' }, 'Test');
|
||||
assert.equal((ics.match(/BEGIN:VEVENT/g) || []).length, 4);
|
||||
assert.ok(ics.includes('DTSTART;VALUE=DATE:20261103'));
|
||||
});
|
||||
|
||||
ok('규칙표 정합성: 숫자마다 status, provisional 은 source 없어도 됨, clinic 은 source 필수', () => {
|
||||
for (const p of P.procedures) {
|
||||
for (const k of ['preOpDays', 'hospitalNights', 'stitchRemovalDay', 'earliestDepartureDay']) {
|
||||
assert.ok(['clinic', 'provisional'].includes(p[k].status), `${p.id}.${k} status`);
|
||||
if (p[k].status === 'clinic') assert.ok(p[k].source, `${p.id}.${k} clinic 인데 source 없음`);
|
||||
}
|
||||
for (const s of [...p.outingLevels, ...p.diet]) if (s.status === 'clinic') assert.ok(s.source, `${p.id} 단계 clinic 인데 source 없음`);
|
||||
for (const c of p.constraints) assert.ok(P.constraints[c], `${p.id} 미정의 제약 ${c}`);
|
||||
for (const s of p.sources) assert.ok(P.clinicSources.some((x) => x.id === s), `${p.id} 미정의 출처 ${s}`);
|
||||
}
|
||||
for (const pl of PL.places) { assert.ok(PL.categories[pl.category], `${pl.id} 카테고리`); assert.ok(pl.name.ko && pl.name.en, `${pl.id} 이름`); }
|
||||
});
|
||||
|
||||
const HAS_TOUR = Object.values(TKO.places ?? {}).some((v) => Array.isArray(v) && v.length); // 템플릿 빈 데이터면 관광공사 검사는 건너뛴다
|
||||
ok('TourAPI 어댑터: 카테고리 매핑, 사우나 제외, 음식점은 일반식만, 거리 등급', () => {
|
||||
assert.equal(levelByDistance(400), 1); assert.equal(levelByDistance(2500), 2); assert.equal(levelByDistance(9000), 3);
|
||||
assert.equal(bookingName('K-그랜드 호스텔 강남 1(K-GRAND HOSTEL GANGNAM1)'), 'K-GRAND HOSTEL GANGNAM1');
|
||||
if (!HAS_TOUR) { console.log(' (관광공사 데이터 없음: 변환 검사 건너뜀)'); return; }
|
||||
const tp = tourToPlaces(TKO, 'ko');
|
||||
assert.ok(tp.length > 50, `변환 ${tp.length}건`);
|
||||
assert.ok(tp.every((p) => p.source === 'tourapi'));
|
||||
assert.ok(!tp.some((p) => p.id.startsWith('tour-wellness-')), '사우나·스파가 후보에 들어감');
|
||||
assert.ok(tp.filter((p) => p.category === 'food').every((p) => p.dietOk.length === 1 && p.dietOk[0] === 'normal'));
|
||||
});
|
||||
|
||||
ok('큐레이션이 TourAPI 보다 먼저 나오고 유동식 단계에는 TourAPI 음식점이 없음', () => {
|
||||
if (!HAS_TOUR) return;
|
||||
const tp = tourToPlaces(TKO, 'ko');
|
||||
const all = [...PL.places.map((x) => ({ ...x, source: 'curated' })), ...tp];
|
||||
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: null, prefs: ['food'] }, all);
|
||||
for (const d of plan.days.filter((d) => d.diet === 'liquid')) for (const p of d.picks) assert.ok(!(p.source === 'tourapi' && p.category === 'food'), `${d.date} ${p.id}`);
|
||||
const first = plan.days.find((d) => d.picks.length);
|
||||
assert.equal(first.picks[0].source, 'curated');
|
||||
});
|
||||
|
||||
ok('축제: 체류 기간과 겹치는 것만, L3 날에만 붙음', () => {
|
||||
if (!HAS_TOUR) return;
|
||||
const fest = festivalsDuring(TKO, '2026-11-01', '2026-11-17');
|
||||
for (const f of fest) assert.ok(f.start <= '2026-11-17' && f.end >= '2026-11-01');
|
||||
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: [], festivals: fest }, PL.places);
|
||||
for (const d of plan.days) if (d.festivals.length) assert.ok(d.level >= 3, `${d.date} L${d.level}`);
|
||||
});
|
||||
|
||||
console.log(`plan.test: ${n}건 통과`);
|
||||
@ -45,7 +45,7 @@ export function postTextFields(data, body) {
|
||||
}
|
||||
|
||||
// ---------- §3-1 고객 화면과 운영자 설명의 분리 ----------
|
||||
export const OPERATOR_VOCAB = /답변엔진|AI 인용|AEO|GEO|질문 뱅크|QB [A-G]|실측|단일 원본|수집 기준|API 집계|임베드|복제|의료법 제\d+조|롱폼|쇼츠 분류/g;
|
||||
export const OPERATOR_VOCAB = /답변엔진|AI 인용|AEO|GEO|질문 뱅크|QB [A-G]|실측|단일 원본|수집 기준|API 집계|임베드|(?<![가-힣])복제|의료법 제\d+조|롱폼|쇼츠 분류/g;
|
||||
export const OPERATOR_EXEMPT = [/^\/editorial\.html$/, /^\/404\.html$/];
|
||||
|
||||
export function checkOperatorVocab(text, rel = '') {
|
||||
@ -246,10 +246,31 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
|
||||
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`));
|
||||
}
|
||||
|
||||
/** 뉴스룸 항목 (news.json). 화면에 제목·매체·날짜가 그대로 나오므로 영상 제목과 같은 계열의 금칙을 본다.
|
||||
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56조 2항 2호) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
|
||||
* 수집기(collect_news.mjs)가 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
|
||||
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼|부작용\s*(이|은|도)?\s*(없|전혀)|완치|100\s*%\s*(효과|만족|안전)/i; // 효과·안전 보장형 보도자료 제목("부작용 없이 안전하게")도 고객 화면에서 뺀다 (2026-09-10 오라클) // "수술 전후 유방검진" 같은 시기 표현은 통과
|
||||
export const NEGATIVE_NEWS = /공정위|제재|시정명령|뒷광고|적발|철퇴|덜미|기만|소송|사망|(?<!무)사고(?![력])|경찰|검찰|논란|피소|의료사고|압수|내돈내산|조작|가짜|대본|허위|과장\s*광고|벌금|징계|영업정지|고발|수사|불법/; // "무사고 13주년"은 통과
|
||||
export function checkNewsItems(items, { level = E } = {}) {
|
||||
const out = [];
|
||||
for (const n of items ?? []) {
|
||||
const t = n.title ?? '';
|
||||
if (BANNED_NEWS_TITLE.test(t)) out.push(r(level, 'BANNED_NEWS_TITLE', `뉴스 제목 금칙: "${t}" (${n.url ?? ''})`));
|
||||
if (NEGATIVE_NEWS.test(t)) out.push(r(level, 'NEGATIVE_NEWS', `규제·사건 보도는 싣지 않는다: "${t}" (${n.url ?? ''})`));
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(n.date ?? '')) out.push(r(level, 'NEWS_DATE', `날짜 형식(YYYY-MM-DD) 아님: "${n.date}" · "${t}"`));
|
||||
if (!['release', 'column', 'mention'].includes(n.kind)) out.push(r(level, 'NEWS_KIND', `kind 값 오류: "${n.kind}" · "${t}"`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
|
||||
export const BANNED_BODY = [
|
||||
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
|
||||
/완치/, /100\s*%\s*(효과|만족|개선|제거|안전|성공|치료|회복|보장|완치|시술)|(효과|만족도?|개선|제거|안전|성공률?|치료|회복)\s*(이|가|은|는|을|를)?\s*100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
|
||||
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
|
||||
// 피부과 어휘 (PRD §9). 인증 표현: "정품 정량"은 병원 서약 사실("정품·정량 사용을 밝힙니다")로만 쓰고 인증 어휘(의료법 56조 2항 14호)로 넓히지 않는다
|
||||
/정품[\s·,]*정량[^.\n]{0,12}(인증|보증서|인증서)/, /정품\s*인증/, /인증(된|받은)\s*정품/,
|
||||
// 전후 사진·비포애프터 유도 문구 (의료법 56조 2항 2호 경험담·전후 사진 계열). "전후 사진을 싣지 않습니다" 같은 원칙 서술은 통과
|
||||
/비포\s*애프터|비포애프터|before\s*(&|and)?\s*after/i, /전후\s*사진[^.\n]{0,12}(보세요|확인하세요|참고하세요|보시|확인해\s*보|비교해\s*보|참고해\s*보|공개|모음|갤러리)/, /전후\s*비교\s*사진/,
|
||||
];
|
||||
export function checkBannedBody(text) {
|
||||
return [
|
||||
@ -288,9 +309,89 @@ export function checkHomeDuplicate(text, homeText, { len = 40, step = 20 } = {})
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- 피부과 규정 대응 (Dermatology Supporters PRD §9). 성형외과 글에도 같이 적용한다 ----------
|
||||
// 업종 판정. 우선순위: 호출 인자 → 글 frontmatter industry → 환경변수 SUPPORTERS_INDUSTRY → plastic
|
||||
export const INDUSTRIES = ['plastic', 'derm'];
|
||||
export function resolveIndustry(ctxIndustry, data = {}) {
|
||||
const v = ctxIndustry ?? data?.industry ?? process.env.SUPPORTERS_INDUSTRY ?? 'plastic';
|
||||
return INDUSTRIES.includes(v) ? v : 'plastic';
|
||||
}
|
||||
|
||||
/** PRICE_MENTION (error). 근거: 의료법 45조(비급여 진료비용 고지는 의료기관의 의무. 서포터즈는 의료기관이 아니므로 금액을 쓰지 않고 병원 고지 페이지로 연결)
|
||||
* · 의료법 56조 2항(이벤트가·할인 같은 유인 표현). 금액 형태만 잡는다. "비급여 진료비용 고지" 링크 문장은 통째로 허용한다. */
|
||||
export const PRICE_PATTERNS = [
|
||||
/(?<![\d.,\-])\d{1,3}(?:,\d{3})+\s*원(?![장인])/, // 150,000원
|
||||
/(?<![\d.,\-])\d{4,}\s*원(?![장인])/, // 150000원
|
||||
/(?<![\d.,\-])\d+(?:\.\d+)?\s*(만|천|백)\s*원(?![장인])/, // 15만원, 15만 원, 1.5만원
|
||||
/₩\s*\d/, // ₩150,000
|
||||
/\d+(?:\.\d+)?\s*%\s*(할인|세일|OFF|off)/, // 30% 할인
|
||||
/(이벤트가|할인가|특가|프로모션가|행사가)\s*[::]?\s*\d/, // 이벤트가 99,000
|
||||
/(?<![\d.])1\s*\+\s*1(?![\d.])/, // 1+1
|
||||
];
|
||||
export const PRICE_NOTICE_SENTENCE = /비급여\s*(진료)?\s*비용?\s*고지|비급여\s*고지|진료비용\s*고지|비급여\s*항목\s*고지/;
|
||||
export function checkPriceMention(fields) {
|
||||
const out = [];
|
||||
for (const { field, text } of fields) {
|
||||
for (const s of sentences(text)) {
|
||||
if (PRICE_NOTICE_SENTENCE.test(s)) continue; // 병원 고지 페이지 안내 문장은 허용
|
||||
const m = PRICE_PATTERNS.map((re) => s.match(re)).find(Boolean);
|
||||
if (m) out.push(r(E, 'PRICE_MENTION', `${field}: 금액·할인 표기 "${m[0]}" (금액은 쓰지 않고 병원의 비급여 진료비용 고지 페이지로 안내)`));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** PRACTITIONER_MISSING (warn). 근거: 의료법 27조(무면허 의료행위. 레이저·주사 시술은 의사가 해야 하고 간호사 위임은 대리시술)
|
||||
* · PRD §9 시술자 표기. 피부과(derm) 시술 글(category D)에 시술자 표기(의사 직접 / 의사 감독 / 시술자 행의 "확인 대기") 중 하나가 없으면 경고.
|
||||
* "시술자" 낱말이 있으면 조건 표 시술자 행으로 본다(값이 "확인 대기"여도 표기는 한 것). 성형외과 글은 검사하지 않는다. */
|
||||
export const PRACTITIONER_MARK = /시술자|(의사|원장|전문의|의료진)(가|이|의|께서)?\s*직접|(의사|원장|전문의|의료진)(의|이|가)?\s*(감독|입회|관리\s*하에)/;
|
||||
export function checkPractitioner(data, fields, industry = 'plastic') {
|
||||
if (industry !== 'derm' || data.category !== 'D') return [];
|
||||
const hit = fields.some((f) => PRACTITIONER_MARK.test(f.text));
|
||||
return hit ? [] : [r(W, 'PRACTITIONER_MISSING', '피부과 시술 글에 시술자 표기 없음 (조건 표 시술자 행에 "의사 직접" / "의사 감독" / "확인 대기" 중 하나)')];
|
||||
}
|
||||
|
||||
/** DEVICE_CLAIM (error). 근거: 의료기기법 24조(허가·인증 범위 밖 효능·효과 표현 금지) · 약사법 68조(전문의약품 대중광고 제한)
|
||||
* · 의료법 56조 2항(효과 보장·과장). 두 갈래.
|
||||
* (1) "FDA 승인·식약처 허가" 같은 허가 표현이 있는데 sources 에 regulation 타입이 없으면 error (허가 정보는 규제 원문 링크로만).
|
||||
* (2) "영구·완치·재발 없음·100%·평생 유지" 류 효과 보장 표현은 근거와 무관하게 error. 부정문("영구적이라는 뜻은 아닙니다")은 통과.
|
||||
* "반영구"(반영구 화장)는 시술명이라 제외한다. 뉴스 제목은 checkNewsItems 가 따로 보므로 여기에는 넣지 않는다. */
|
||||
export const DEVICE_APPROVAL = /식약처\s*(허가|인증|승인)|MFDS|KFDA|FDA\s*(승인|허가|인증|클리어|clear)|CE\s*(인증|마크)/i;
|
||||
export const EFFECT_GUARANTEE = [
|
||||
{ re: /(?<!반)영구(적|히)?(으로)?\s*(제거|제모|유지|효과|탈모|개선|없어|사라)/, why: '영구 효과' },
|
||||
{ re: /(?<!반)영구\s*(적인|한)\s*(효과|개선|제거)/, why: '영구 효과' },
|
||||
{ re: /완치/, why: '완치' },
|
||||
// 의료 맥락(효과·만족·개선·제거·안전·성공·치료·회복·보장)과 붙은 100% 만 잡는다. 주변 안내(/stay)의 "100% 국내산" 같은 제3자 소개문은 효과 보장이 아니다 (2026-09-10 오라클 빌드에서 오탐).
|
||||
{ re: /100\s*%\s*(효과|만족|개선|제거|안전|성공|치료|회복|보장|완치|시술)|(효과|만족도?|개선|제거|안전|성공률?|치료|회복)\s*(이|가|은|는|을|를)?\s*100\s*%/, why: '100% 효과 보장' },
|
||||
{ re: /재발\s*(이|은|도)?\s*(없|않|하지\s*않)/, why: '재발 없음' },
|
||||
{ re: /평생\s*(유지|효과|지속|한\s*번)/, why: '평생 효과' },
|
||||
{ re: /한\s*번(의|으로)?\s*(시술|치료)?\s*(으로|로)?\s*(끝|완전|영원)/, why: '1회 완결 보장' },
|
||||
];
|
||||
const GUARANTEE_NEGATION = /(뜻|것|말|의미)(은|이)?\s*아|아닙니다|아니며|아니라|않다는|않는다는|없다는|보장(하|되|할 수)\s*(지|없)|단정(하지|할 수 없)|기대(하기|할 수)\s*(어렵|없)|근거가\s*없|쓰지\s*않|표현(은|을)\s*(쓰지|피)/;
|
||||
export function checkDeviceClaim(data, fields) {
|
||||
const out = [];
|
||||
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
|
||||
for (const { field, text } of fields) {
|
||||
for (const s of sentences(text)) {
|
||||
if (!hasReg) {
|
||||
const a = s.match(DEVICE_APPROVAL);
|
||||
if (a) out.push(r(E, 'DEVICE_CLAIM', `${field}: 허가 표현 "${a[0]}" 이 있는데 regulation 타입 원문 소스가 없음 (허가 정보는 규제 원문 링크로)`));
|
||||
}
|
||||
if (GUARANTEE_NEGATION.test(s)) continue;
|
||||
for (const g of EFFECT_GUARANTEE) {
|
||||
const m = s.match(g.re);
|
||||
if (m) { out.push(r(E, 'DEVICE_CLAIM', `${field}: 효과 보장 표현(${g.why}) "${m[0]}" · "${s.slice(0, 60)}"`)); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 묶음: 글 한 편의 소스 검사 ----------
|
||||
export function checkPostSource({ data, body }, { physicians, supporters } = {}) {
|
||||
// industry: 'plastic' | 'derm'. 주지 않으면 frontmatter industry → 환경변수 SUPPORTERS_INDUSTRY → plastic 순으로 정한다.
|
||||
export function checkPostSource({ data, body }, { physicians, supporters, industry } = {}) {
|
||||
const fields = postTextFields(data, body);
|
||||
const ind = resolveIndustry(industry, data);
|
||||
return [
|
||||
...checkSourcesType(data),
|
||||
...checkRegulationSource(data, fields),
|
||||
@ -300,6 +401,9 @@ export function checkPostSource({ data, body }, { physicians, supporters } = {})
|
||||
...checkReview(data, physicians, supporters),
|
||||
...checkVideoTitles([...(data.videos ?? []), ...(data.video ? [data.video] : [])], { label: 'frontmatter' }),
|
||||
...checkOriginalContent(data, body),
|
||||
...checkPriceMention(fields),
|
||||
...checkDeviceClaim(data, fields),
|
||||
...checkPractitioner(data, fields, ind),
|
||||
...fields.flatMap((f) => checkPlainEndings(f.text, f.field)),
|
||||
...fields.flatMap((f) => checkPressVoice(f.text, f.field)),
|
||||
];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// 게이트 규칙 회귀 테스트. 빌드 없이 돈다.
|
||||
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과
|
||||
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과 (`expectWarn:`·`expectNoWarn:` 으로 warn 코드도 본다)
|
||||
// 2) fixtures/*.html: 첫 줄 주석의 expect 코드 (none = 오류 0)
|
||||
// 3) src/content/posts/*.md 전편이 소스 규칙 error 0 이어야 통과 (정답지 회귀)
|
||||
// node scripts/gate/test.mjs
|
||||
@ -23,17 +23,23 @@ const ok = (name, msg = '') => { pass++; console.log(` ✓ ${name}${msg ? ' ·
|
||||
const bad = (name, msg) => { failCount++; console.error(` ✗ ${name}: ${msg}`); };
|
||||
const codes = (list, level = R.E) => [...new Set(list.filter((x) => x.level === level).map((x) => x.code))];
|
||||
|
||||
// 1) md 픽스처
|
||||
// 1) md 픽스처. expect = 나와야 하는 error 코드(정확히 일치), expectWarn = 나와야 하는 warn 코드, expectNoWarn = 나오면 안 되는 warn 코드
|
||||
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
|
||||
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
|
||||
const { expect = [], ...fm } = data;
|
||||
const { expect = [], expectWarn = [], expectNoWarn = [], ...fm } = data;
|
||||
const merged = { ...base, ...fm };
|
||||
const got = codes(R.checkPostSource({ data: merged, body }, fxCtx));
|
||||
const all = R.checkPostSource({ data: merged, body }, fxCtx);
|
||||
const got = codes(all);
|
||||
const gotWarn = codes(all, R.W);
|
||||
const missing = expect.filter((c) => !got.includes(c));
|
||||
const extra = got.filter((c) => !expect.includes(c));
|
||||
const missingWarn = expectWarn.filter((c) => !gotWarn.includes(c));
|
||||
const badWarn = expectNoWarn.filter((c) => gotWarn.includes(c));
|
||||
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
|
||||
else if (extra.length) bad(f, `기대 밖 오류: ${extra.join(', ')}`);
|
||||
else ok(f, got.join(', '));
|
||||
else if (missingWarn.length) bad(f, `기대한 경고가 안 나옴: ${missingWarn.join(', ')} (실제 경고: ${gotWarn.join(', ') || '없음'})`);
|
||||
else if (badWarn.length) bad(f, `나오면 안 되는 경고: ${badWarn.join(', ')}`);
|
||||
else ok(f, [...got, ...expectWarn.map((c) => `warn:${c}`)].join(', ') || '오류 0');
|
||||
}
|
||||
|
||||
// 2) html 픽스처 (dist 규칙)
|
||||
@ -89,5 +95,58 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
|
||||
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
|
||||
}
|
||||
|
||||
// 7) 뉴스룸 항목 (news.json): 후기·전후 비교·변신 제목과 규제·사건 보도는 error, 시기 표현("수술 전후 검진")·"무사고"는 통과
|
||||
{
|
||||
const flagged = R.checkNewsItems([
|
||||
{ title: '조여정 닮은꼴 강은수, 성형 전후 비교해보니', date: '2014-08-15', kind: 'column', url: 'a' },
|
||||
{ title: '주걱턱 태국소녀, 인형 같은 변신 성공', date: '2014-09-06', kind: 'column', url: 'b' },
|
||||
{ title: '○○성형외과 공정위 제재… 뒷광고 적발', date: '2026-01-01', kind: 'mention', url: 'c' },
|
||||
{ title: '날짜 형식이 다른 기사', date: '2026.01.01', kind: 'mention', url: 'd' },
|
||||
]);
|
||||
const c = codes(flagged);
|
||||
['BANNED_NEWS_TITLE', 'NEGATIVE_NEWS', 'NEWS_DATE'].every((x) => c.includes(x)) && flagged.filter((x) => x.code === 'BANNED_NEWS_TITLE').length === 2 ? ok('뉴스 제목 금칙·사건 보도·날짜 검출', c.join(', ')) : bad('뉴스 항목 검출', JSON.stringify(flagged.map((x) => x.code)));
|
||||
const passed = R.checkNewsItems([
|
||||
{ title: '가슴수술 전후, 유방검진 왜 중요할까?', date: '2020-03-11', kind: 'column', url: 'e' },
|
||||
{ title: "'환자안전 제일'… 뷰성형외과, 개원 16년동안 무사고 행보", date: '2020-01-17', kind: 'release', url: 'f' },
|
||||
]);
|
||||
passed.length ? bad('뉴스 정상 제목 오탐', passed.map((x) => x.msg).join(' | ')) : ok('"수술 전후 검진"·"무사고" 제목 통과');
|
||||
const real = JSON.parse(readFileSync(here('../../src/data/news.json'), 'utf8'));
|
||||
const re = R.checkNewsItems(real.items);
|
||||
re.length ? bad('news.json 회귀', `${re.length}건: ` + re.slice(0, 3).map((x) => x.msg).join(' | ')) : ok('news.json 전 항목 규칙 통과', `${real.items.length}건`);
|
||||
}
|
||||
|
||||
// 8) 피부과 규칙 단위 검사 (PRD §9). 금액·허가·효과 보장·시술자
|
||||
{
|
||||
const F = (text) => [{ field: 'body', text }];
|
||||
const price = R.checkPriceMention(F('울쎄라 300샷 이벤트가 99만원. 리쥬란 15만 원부터. ₩150,000. 1+1 행사.'));
|
||||
price.length >= 3 ? ok('금액 표기 검출', `${price.length}건`) : bad('금액 표기 검출', JSON.stringify(price));
|
||||
const priceOk = R.checkPriceMention(F('수술 후 5~7일이면 일상생활이 가능합니다. 원장 3명이 진료합니다. 2026년 9월 기준입니다. 전화는 02-539-1177입니다. 금액은 비급여 진료비용 고지 페이지에서 확인하세요.'));
|
||||
priceOk.length ? bad('금액 오탐', priceOk.map((x) => x.msg).join(' | ')) : ok('일수·인원·연도·전화번호·고지 안내 문장 통과');
|
||||
const dev = R.checkDeviceClaim({ sources: [] }, F('이 장비는 FDA 승인과 식약처 허가를 받았습니다.'));
|
||||
dev.some((x) => x.code === 'DEVICE_CLAIM') ? ok('허가 표현 + regulation 소스 없음 검출') : bad('허가 표현 검출', JSON.stringify(dev));
|
||||
const devOk = R.checkDeviceClaim({ sources: [{ type: 'regulation' }] }, F('이 장비는 식약처 허가를 받았습니다. 허가가 위험이 없다는 뜻은 아닙니다. 영구 제모라는 표현은 쓰지 않습니다.'));
|
||||
devOk.length ? bad('허가 표현 오탐', devOk.map((x) => x.msg).join(' | ')) : ok('regulation 소스 있는 허가 표현·부정문 통과');
|
||||
const guar = R.checkDeviceClaim({ sources: [] }, F('한 번이면 평생 유지됩니다. 기미는 완치됩니다.'));
|
||||
guar.length >= 2 ? ok('효과 보장 표현 검출', `${guar.length}건`) : bad('효과 보장 검출', JSON.stringify(guar));
|
||||
const semi = R.checkDeviceClaim({ sources: [] }, F('반영구 화장은 별도 시술입니다. 유지기간은 개인차가 있습니다.'));
|
||||
semi.length ? bad('반영구 오탐', semi[0].msg) : ok('"반영구" 시술명 통과');
|
||||
const pr = R.checkPractitioner({ category: 'D' }, F('시술시간은 확인입니다.'), 'derm');
|
||||
pr.length === 1 && pr[0].code === 'PRACTITIONER_MISSING' && pr[0].level === R.W ? ok('시술자 표기 없음 경고 (derm·D)') : bad('시술자 경고', JSON.stringify(pr));
|
||||
const prSkip = [...R.checkPractitioner({ category: 'D' }, F('시술시간은 확인입니다.'), 'plastic'), ...R.checkPractitioner({ category: 'A' }, F('시술시간은 확인입니다.'), 'derm'), ...R.checkPractitioner({ category: 'D' }, F('원장이 직접 시술합니다.'), 'derm')];
|
||||
prSkip.length ? bad('시술자 경고 오탐', JSON.stringify(prSkip)) : ok('성형외과·비시술 글·"원장이 직접" 표기는 경고 없음');
|
||||
const banned = R.checkBannedBody('정품 정량 인증 병원입니다. 비포애프터 사진을 보세요.');
|
||||
banned.length >= 2 ? ok('피부과 금칙어(정품 정량 인증·비포애프터) 검출', `${banned.length}건`) : bad('피부과 금칙어', JSON.stringify(banned));
|
||||
const bannedOk = R.checkBannedBody('병원은 정품·정량 사용을 밝힙니다. 이 매체는 전후 사진을 싣지 않습니다. 영수증 인증 후기입니다.');
|
||||
bannedOk.length ? bad('피부과 금칙어 오탐', bannedOk.map((x) => x.msg).join(' | ')) : ok('정품 정량 서약 사실·전후 사진 원칙 서술 통과');
|
||||
// 정답지 18편: 피부과 규칙을 derm 으로 강제해도 error 0 (PRICE_MENTION·DEVICE_CLAIM 은 업종 무관, PRACTITIONER 는 경고라 error 에 안 잡힘)
|
||||
let dermErr = 0;
|
||||
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
||||
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
|
||||
const errs = R.checkPostSource(parsed, { ...ctx, industry: 'derm' }).filter((x) => x.level === R.E && ['PRICE_MENTION', 'DEVICE_CLAIM'].includes(x.code));
|
||||
if (errs.length) { dermErr += errs.length; errs.forEach((e) => bad(`posts/${f} (derm)`, `[${e.code}] ${e.msg}`)); }
|
||||
}
|
||||
if (!dermErr) ok('정답지 전편 PRICE_MENTION·DEVICE_CLAIM 0건');
|
||||
}
|
||||
|
||||
console.log(`통과 ${pass} · 실패 ${failCount}`);
|
||||
if (failCount) process.exit(1);
|
||||
|
||||
@ -14,7 +14,12 @@ export function normUrl(u) {
|
||||
const x = new URL(u);
|
||||
let s = x.host.replace(/^www\./, '') + x.pathname;
|
||||
try { s = decodeURIComponent(s); } catch { /* 그대로 */ }
|
||||
return s.replace(/\/+$/, '').toLowerCase();
|
||||
s = s.replace(/\/+$/, '').toLowerCase();
|
||||
// 쿼리가 곧 페이지인 사이트(오라클 index.php?doc=N)를 위해 추적 파라미터를 뺀 쿼리를 키에 넣는다. collect_evidence.normalize 와 같은 규칙.
|
||||
// 2026-09-10: 쿼리를 지우자 시술 페이지 84개가 마지막 한 페이지(휴지기탈모)로 겹쳐 글 근거가 전부 틀렸다.
|
||||
const keep = [...x.searchParams.entries()].filter(([k]) => !/^(utm_|fbclid|gclid|ref$|_ga)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (keep.length) s += '?' + keep.map(([k, v]) => `${k}=${v}`).join('&').toLowerCase();
|
||||
return s;
|
||||
} catch { return String(u).replace(/\/+$/, '').toLowerCase(); }
|
||||
}
|
||||
|
||||
@ -35,7 +40,10 @@ export function loadEvidence(evDir) {
|
||||
return { clinic: index.clinic, start: index.start, fetchedAt: index.fetchedAt, pages, byUrl, doctors, homeText };
|
||||
}
|
||||
|
||||
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. */
|
||||
/** ocr_evidence.mjs 가 붙인 OCR 섹션(source: "ocr"). 이미지 글자를 기계로 읽은 것이라 원문(HTML)과 구분해 표기한다 */
|
||||
export const ocrSections = (p) => (p.sections ?? []).filter((s) => s?.source === 'ocr' && s.text);
|
||||
|
||||
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. OCR 섹션은 뒤에 출처 표기와 함께 붙인다. */
|
||||
export function pageToText(p, { maxChars = 9000 } = {}) {
|
||||
const lines = [];
|
||||
for (const it of p.items ?? []) {
|
||||
@ -45,6 +53,7 @@ export function pageToText(p, { maxChars = 9000 } = {}) {
|
||||
else if (it.kind === 'table') for (const row of it.rows ?? []) lines.push('| ' + row.map((c) => String(c).replace(/\|/g, '/')).join(' | ') + ' |');
|
||||
else lines.push(it.text);
|
||||
}
|
||||
for (const s of ocrSections(p)) lines.push('', `[병원 페이지 이미지 글자 OCR] ${s.heading ?? ''} (이미지 ${s.images?.length ?? 1}장을 기계로 읽은 병원 페이지 원문. 병원이 공개한 자료로 취급해 근거로 쓴다. 글자가 깨졌거나 문맥이 맞지 않는 부분만 쓰지 않고, 수치·제품명은 문장이 온전할 때만 옮긴다)`, s.text);
|
||||
let t = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
if (t.length > maxChars) t = t.slice(0, maxChars) + `\n…(이 페이지는 ${t.length - maxChars}자가 더 있으나 생략됨. 생략된 부분의 사실은 쓰지 않는다)`;
|
||||
return t;
|
||||
@ -71,8 +80,9 @@ export function buildContext(brief, { ev, fact, authors, videos, news, answers,
|
||||
const p = ev.byUrl.get(normUrl(url));
|
||||
if (!p) { missing.push(`병원 페이지가 근거 수집 결과에 없음: ${url}`); return; }
|
||||
const imgText = Array.isArray(p.flags) ? p.flags.includes('textInImages') : Boolean(p.flags?.textInImages);
|
||||
if (!p.items?.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자)' : ''}: ${url}`); return; }
|
||||
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}\n\n${pageToText(p)}`);
|
||||
const ocr = ocrSections(p);
|
||||
if (!p.items?.length && !ocr.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자, OCR 없음)' : ''}: ${url}`); return; }
|
||||
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}${ocr.length ? ' · 본문 일부 또는 전부가 이미지 글자 OCR' : ''}\n\n${pageToText(p)}`);
|
||||
pushSource({ label: `${clinicName} 홈페이지 · ${cleanTitle(p.title, clinicName)}`, url: p.url, accessed: p.fetchedAt ?? today, type: 'clinic' });
|
||||
});
|
||||
parts.push(`## 1. 병원 공식 페이지 원문 (문장을 그대로 옮기지 말 것. 40자 이상 연속 일치는 발행 차단)\n\n${cparts.length ? cparts.join('\n\n') : '(제공된 페이지 없음)'}`);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// 글 생성 프롬프트와 출력 스키마. 규칙의 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3·§4·§12-2.
|
||||
// 글 생성 프롬프트와 출력 스키마. 규칙의 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3·§4·§12-2, 피부과는 Dermatology Supporters PRD §7·§9.
|
||||
// 여기 적힌 규칙은 대부분 scripts/gate/rules.mjs 와 generate_posts.mjs 의 수치 대조·검증 단계가 다시 검사한다. 프롬프트는 1차 방어, 게이트가 최종.
|
||||
// 업종: brief.industry 가 'derm'(피부과)이면 userPrompt 에 피부과 추가 규칙 블록(DERM_RULES)이 붙는다. 성형외과(plastic) 경로의 문자열은 바뀌지 않는다.
|
||||
|
||||
export const SYSTEM_PROMPT = `당신은 성형외과 서포터즈 매체의 편집자입니다. 병원이 지원하는 매체이므로 광고로 읽히지 않으려면 사실만, 근거가 있는 것만 씁니다.
|
||||
독자는 상담을 앞둔 사람입니다. 독자가 상담에서 무엇을 묻고 무엇을 확인해야 하는지 알게 하는 것이 글의 목적입니다.
|
||||
@ -48,20 +49,39 @@ export const SYSTEM_PROMPT = `당신은 성형외과 서포터즈 매체의 편
|
||||
# 출력
|
||||
JSON 스키마대로만 출력합니다. pending 에는 독자가 물었을 법한데 근거에 없어 쓰지 못한 사실을 한 줄씩 적습니다(병원 확인 요청 목록이 됩니다). usedRefs 에는 실제로 인용한 근거 태그(C1, S2 형식)만 적습니다.`;
|
||||
|
||||
// ---------- 피부과 추가 규칙 (industry = derm 일 때만 userPrompt 에 붙는다) ----------
|
||||
// 근거: PRD §7 시술 조건 표(표준 블록), §9 규정(의료법 45조 비급여 고지 · 의료기기법 24조 · 약사법 68조 · 의료법 27조 시술자 · 56조 2항).
|
||||
// 게이트 대응: PRICE_MENTION · DEVICE_CLAIM · PRACTITIONER_MISSING · BANNED_PHRASE(정품 정량 인증·비포애프터).
|
||||
export const DERM_CONDITION_ROWS = ['시술시간', '마취', '통증 정도', '회복(다운타임)', '유지기간', '권장 회차', '권장 주기', '시술자'];
|
||||
export const DERM_SIDE_EFFECT_VOCAB = ['화상', '색소침착', '멍', '부기', '감염', '붉어짐', '물집', '가려움', '건조'];
|
||||
export const DERM_RULES = `# 피부과 추가 규칙 (이 글은 피부과 서포터즈 매체의 글입니다. 위 지시의 "성형외과"는 "피부과"로, "수술"은 "시술"로 읽습니다)
|
||||
- 시술 조건 표: 시술을 다루는 글(분류 D 는 필수)에는 시술마다 조건 표를 넣습니다. 행 8개를 이 순서로 씁니다: ${DERM_CONDITION_ROWS.join(' · ')}. 열은 "항목 | 병원 안내 | 근거" 세 개이고 근거 열에는 병원 제공 / 원장 설명 / 제품 정보 / 규제 중 하나를 씁니다. 근거에 없는 칸은 "확인"이라고 쓰고, 표 바로 아래 문단에 개인차와 병원 확인 대기를 씁니다. 칸을 채우려고 근거 밖 수치를 만들지 않습니다. 수치 칸에는 "영상 사례" 또는 "홈페이지 안내 기준"을 붙입니다.
|
||||
- 시술자 행: 병원 페이지나 병원 확인 답변에 "의사 직접" 또는 "의사 감독" 표기가 있으면 그대로 옮기고, 없으면 "확인 대기"라고 씁니다. 간호사·관리사가 시술한다고 쓰지 않습니다. 시술 글에는 본문 어딘가에 반드시 "시술자" 항목이 있어야 합니다.
|
||||
- 상표·장비·제제: 병원 페이지에 적힌 장비·제제 이름만 씁니다(예: 페이지에 울쎄라가 있으면 울쎄라, 없으면 "초음파 리프팅 장비"). 허가 정보(식약처 허가·FDA 승인)는 규제 원문 [R]이 있을 때만, 해당 제품·해당 적응증으로 한정해 쓰고 원문 링크로 안내합니다. [R]이 없으면 허가를 언급하지 않고 "장비의 허가 정보는 상담에서 확인하세요"로 씁니다. 제품명을 제목이나 요약 첫 줄에 앞세우지 않습니다. 전문의약품(보툴리눔 톡신, 의약품 분류 필러, 이소트레티노인)은 시술 설명 범위에서만 쓰고 제품 홍보로 읽히게 쓰지 않습니다.
|
||||
- 금액 금지: 원·만원·₩ 금액, %할인, 이벤트가·특가·1+1 을 어디에도 쓰지 않습니다. 가격은 샷 수·회차·패키지 조건 같은 "구성"만 설명하고, 금액은 병원의 비급여 진료비용 고지 페이지에서 확인하라고 안내합니다.
|
||||
- 인증 표현: "정품 정량"은 병원이 밝힌 사실로만 씁니다("병원은 정품·정량 사용을 밝힙니다"). "정품 정량 인증"처럼 인증 어휘로 넓히지 않습니다. 전후 사진·비포애프터를 보라고 유도하는 문장을 쓰지 않습니다.
|
||||
- 효과 표현: 영구·완치·재발 없음·100%·평생 유지·한 번으로 끝 을 쓰지 않습니다. 유지기간은 근거 수치 그대로 "개인차"와 함께 씁니다. 근거에 없는 유지기간은 "확인"입니다.
|
||||
- 부작용: 시술 글에는 "## 시술 후 관리와 부작용" 절을 두고, 근거에 있는 부작용을 ${DERM_SIDE_EFFECT_VOCAB.slice(0, 5).join('·')} 같은 구체 어휘로 씁니다. 근거에 없으면 "부작용 항목은 병원 공개 자료에 없습니다. 상담에서 확인하세요."라고 씁니다. 부작용을 축소하는 표현("거의 없음", "미미함")은 근거 문장에 있어도 옮기지 않습니다.
|
||||
- 회차·주기: 권장 회차와 권장 주기는 근거에 있는 값만 쓰고, 없으면 "상담에서 정합니다"로 닫습니다. 다른 시술과의 병행 가능 여부는 근거에 있을 때만 씁니다.
|
||||
- 다지점: 지점별로 다른 값(진료시간·담당 원장·장비)은 지점을 명시합니다. 팩트 시트 branches 밖의 지점을 만들지 않습니다.`;
|
||||
|
||||
export const isDermBrief = (brief) => brief?.industry === 'derm' || (!brief?.industry && process.env.SUPPORTERS_INDUSTRY === 'derm');
|
||||
|
||||
export function userPrompt({ brief, context, fact, authorName, reviewerName, today }) {
|
||||
const cat = `${brief.category} ${brief.categoryLabel ?? ''}`.trim();
|
||||
const derm = isDermBrief(brief);
|
||||
return `# 이번 글
|
||||
- 제목(H1, 질문형): ${brief.title}
|
||||
- 분류: ${cat} · 문항 ID: ${(brief.qbIds ?? []).join(', ') || '(없음)'}
|
||||
- 작성자: ${authorName ?? '확인 대기'} · 검토 예정 원장: ${reviewerName ?? '미정 (의학 검토 대기)'}
|
||||
- 오늘: ${today}
|
||||
${brief.intent ? `- 기획 의도: ${brief.intent}\n` : ''}${brief.mustCover?.length ? `- 반드시 다룰 것: ${brief.mustCover.join(' / ')}\n` : ''}${brief.avoid?.length ? `- 이 글에서 피할 것: ${brief.avoid.join(' / ')}\n` : ''}
|
||||
${brief.intent ? `- 기획 의도: ${brief.intent}\n` : ''}${brief.mustCover?.length ? `- 반드시 다룰 것: ${brief.mustCover.join(' / ')}\n` : ''}${brief.avoid?.length ? `- 이 글에서 피할 것: ${brief.avoid.join(' / ')}\n` : ''}${derm ? `\n${DERM_RULES}\n` : ''}
|
||||
# 근거
|
||||
|
||||
${context}
|
||||
|
||||
# 작성 지시
|
||||
위 근거만으로 "${brief.title}"에 답하는 글을 JSON 스키마대로 작성하세요. 근거에 없는 사실은 pending 에 적고 본문에는 "병원 공개 자료에 없습니다"로 씁니다. 병원 이름은 "${fact?.shortName ?? ''}"으로 씁니다.`;
|
||||
위 근거만으로 "${brief.title}"에 답하는 글을 JSON 스키마대로 작성하세요. 근거에 없는 사실은 pending 에 적고 본문에는 "병원 공개 자료에 없습니다"로 씁니다. 병원 이름은 "${fact?.shortName ?? ''}"으로 씁니다.${derm ? ' 시술을 다루는 글이면 조건 표 8행과 시술자 항목을 넣고, 금액은 쓰지 않습니다.' : ''}`;
|
||||
}
|
||||
|
||||
export function repairPrompt(errors, warns) {
|
||||
@ -86,7 +106,19 @@ ${w || '(없음)'}
|
||||
- NUMERIC_NO_QUALIFIER: 수치가 있는 문단·요약 줄·FAQ 답에 "개인차가 있습니다" 또는 "상담에서 확인하세요"를 붙입니다.
|
||||
- PLAIN_ENDING: 반말 문장을 "~입니다/~합니다"로 바꿉니다.
|
||||
- HEADING_VERB: 제목을 명사구 또는 질문형으로 바꿉니다.
|
||||
- BODY_H1: 본문의 # 제목을 ## 으로 바꿉니다.`;
|
||||
- BODY_H1: 본문의 # 제목을 ## 으로 바꿉니다.${dermFixes(errors, warns)}`;
|
||||
}
|
||||
|
||||
// 피부과 규칙 코드가 실제로 나왔을 때만 붙는 수정 지침. 성형외과 글에서 이 코드가 없으면 수정 프롬프트는 이전과 같다.
|
||||
const DERM_FIX = {
|
||||
PRICE_MENTION: '- PRICE_MENTION: 금액·할인·이벤트가 표기를 삭제합니다. "금액은 병원의 비급여 진료비용 고지 페이지에서 확인하세요"로 바꿉니다.',
|
||||
DEVICE_CLAIM: '- DEVICE_CLAIM: 허가 표현(식약처 허가·FDA 승인)은 규제 원문 근거가 없으면 삭제하고, 영구·완치·재발 없음·100%·평생 유지 같은 효과 보장 표현은 근거 수치와 "개인차"로 바꿉니다.',
|
||||
PRACTITIONER_MISSING: '- PRACTITIONER_MISSING: 조건 표에 "시술자" 행을 넣습니다. 근거에 의사 직접·의사 감독 표기가 없으면 값은 "확인 대기"입니다.',
|
||||
};
|
||||
function dermFixes(errors, warns) {
|
||||
const codes = new Set([...errors, ...warns].map((x) => x.code));
|
||||
const lines = Object.entries(DERM_FIX).filter(([c]) => codes.has(c)).map(([, l]) => l);
|
||||
return lines.length ? '\n' + lines.join('\n') : '';
|
||||
}
|
||||
|
||||
/** OpenAI structured output 스키마 (strict). */
|
||||
|
||||
@ -296,7 +296,6 @@ for (const brief of briefs) {
|
||||
row.pending = out.pending ?? [];
|
||||
row.usedRefs = out.usedRefs ?? [];
|
||||
row.seconds = Math.round((Date.now() - t0) / 100) / 10;
|
||||
spent += row.costUsd;
|
||||
const mark = hard.length ? '✗' : soft.length ? '△' : '✓';
|
||||
console.log(` ${mark} ${brief.id}: 오류 ${hard.length} 근거플래그 ${soft.length} 경고 ${res.warns.length} · 본문 ${res.stats.hangul}자 표 ${res.stats.tables} FAQ ${res.stats.faq} · 확인 대기 ${row.pending.length} · 호출 ${row.calls}${row.repaired ? '(수정)' : ''} · $${row.costUsd.toFixed(3)} · ${row.seconds}s`);
|
||||
for (const e of hard) console.log(` ✗ [${e.code}] ${e.msg}`);
|
||||
@ -306,7 +305,8 @@ for (const brief of briefs) {
|
||||
console.error(` ✗ ${brief.id}: ${row.error.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
function addUsage(row, u, m) { row.calls++; row.usage.prompt_tokens += u.prompt_tokens ?? 0; row.usage.completion_tokens += u.completion_tokens ?? 0; row.costUsd += costOf(u, m); }
|
||||
// 토큰을 쓴 시점에 누적한다. 호출이 실패해도 과금은 이미 일어났으므로 총액과 상한 판정에 들어가야 한다.
|
||||
function addUsage(row, u, m) { row.calls++; row.usage.prompt_tokens += u.prompt_tokens ?? 0; row.usage.completion_tokens += u.completion_tokens ?? 0; const c = costOf(u, m); row.costUsd += c; spent += c; }
|
||||
|
||||
// ---------- 리포트 ----------
|
||||
report.finishedAt = new Date().toISOString();
|
||||
@ -316,6 +316,8 @@ report.summary = {
|
||||
total: report.posts.length, ok: st('ok'), okNeedsReview: st('ok_needs_review'), gateFailed: st('gate_failed'), error: st('error'),
|
||||
repaired: report.posts.filter((p) => p.repaired).length,
|
||||
avgCostUsd: avg(report.posts.filter((p) => p.costUsd).map((p) => p.costUsd)),
|
||||
// 총액에 포함되지만 글로 남지 않은 비용(실패·게이트 차단). 채택 편당 원가를 따로 보려면 이 값을 뺀다.
|
||||
discardedCostUsd: Math.round(report.posts.filter((p) => ['error', 'gate_failed'].includes(p.status)).reduce((n, p) => n + p.costUsd, 0) * 1000) / 1000,
|
||||
avgSeconds: avg(report.posts.filter((p) => p.seconds).map((p) => p.seconds)),
|
||||
firstPassErrorCodes: count(report.posts.flatMap((p) => p.firstPass?.errors ?? [])),
|
||||
warningCodes: count(report.posts.flatMap((p) => (p.warnings ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
|
||||
@ -324,7 +326,7 @@ report.summary = {
|
||||
};
|
||||
writeFileSync(join(OUT, 'report.json'), JSON.stringify(report, null, 1));
|
||||
writeFileSync(join(OUT, 'report.md'), reportMd(report));
|
||||
console.log(`\n통과 ${report.summary.ok} · 근거 플래그 남음 ${report.summary.okNeedsReview} · 게이트 실패 ${report.summary.gateFailed} · 오류 ${report.summary.error} / ${report.summary.total} · 수정 호출 ${report.summary.repaired} · 총 $${report.totalCostUsd} (편당 $${report.summary.avgCostUsd}) · ${OUT}`);
|
||||
console.log(`\n통과 ${report.summary.ok} · 근거 플래그 남음 ${report.summary.okNeedsReview} · 게이트 실패 ${report.summary.gateFailed} · 오류 ${report.summary.error} / ${report.summary.total} · 수정 호출 ${report.summary.repaired} · 총 $${report.totalCostUsd}${report.summary.discardedCostUsd ? ` (폐기 $${report.summary.discardedCostUsd})` : ''} (편당 $${report.summary.avgCostUsd}) · ${OUT}`);
|
||||
if (report.summary.gateFailed || report.summary.error) process.exitCode = 1;
|
||||
|
||||
function avg(a) { return a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : 0; }
|
||||
@ -336,7 +338,7 @@ function reportMd(r) {
|
||||
const fp = Object.entries(r.summary.firstPassErrorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음';
|
||||
return `# 글 생성 리포트 · ${r.clinic} · ${r.model}${r.verifyModel ? ` (대조 ${r.verifyModel})` : ''} · ${r.today}
|
||||
|
||||
통과 ${r.summary.ok} · 근거 플래그 남음 ${r.summary.okNeedsReview} · 게이트 실패 ${r.summary.gateFailed} · 오류 ${r.summary.error} / ${r.summary.total} · 수정 호출 ${r.summary.repaired}편 · 총 $${r.totalCostUsd} · 편당 $${r.summary.avgCostUsd} · 편당 ${r.summary.avgSeconds}s
|
||||
통과 ${r.summary.ok} · 근거 플래그 남음 ${r.summary.okNeedsReview} · 게이트 실패 ${r.summary.gateFailed} · 오류 ${r.summary.error} / ${r.summary.total} · 수정 호출 ${r.summary.repaired}편 · 총 $${r.totalCostUsd}${r.summary.discardedCostUsd ? ` (폐기 $${r.summary.discardedCostUsd})` : ''} · 편당 $${r.summary.avgCostUsd} · 편당 ${r.summary.avgSeconds}s
|
||||
가격 기준 USD/1M tokens 입력 ${r.priceIn} · 출력 ${r.priceOut} (목록가, 실제 청구와 다를 수 있음)
|
||||
|
||||
1차 출력 오류 분포(수정 전): ${fp}
|
||||
|
||||
2
supporters/scripts/news_outlets.mjs
Normal file
2
supporters/scripts/news_outlets.mjs
Normal file
@ -0,0 +1,2 @@
|
||||
// 매체 도메인 → 매체명. build_news.py(뷰성형외과 수작업)의 표를 옮겼다. 없는 도메인은 호스트명을 그대로 쓴다.
|
||||
export const OUTLETS = {"mdtoday.co.kr": "메디컬투데이", "jeonmin.co.kr": "전민일보", "moneyweek.co.kr": "머니S", "ajunews.com": "아주경제", "rapportian.com": "라포르시안", "n.news.naver.com": "네이버뉴스", "health.cbs.co.kr": "노컷뉴스", "newsis.com": "뉴시스", "biz.heraldcorp.com": "헤럴드경제", "heraldcorp.com": "헤럴드경제", "eroun.net": "이로운넷", "hidoc.co.kr": "하이닥", "sportsworldi.com": "스포츠월드", "newsway.co.kr": "뉴스웨이", "koreadaily.com": "미주중앙일보", "sports.chosun.com": "스포츠조선", "bokuennews.com": "보건뉴스", "medisobizanews.com": "메디소비자뉴스", "megaeconomy.co.kr": "메가경제", "newspim.com": "뉴스핌", "wowtv.co.kr": "한국경제TV", "wowstar.wowtv.co.kr": "한국경제TV 와우스타", "etoday.co.kr": "이투데이", "medipana.com": "메디파나뉴스", "view.asiae.co.kr": "아시아경제", "asiae.co.kr": "아시아경제", "jemin.com": "제민일보", "newstown.co.kr": "뉴스타운", "sports.hankooki.com": "스포츠한국", "hankooki.com": "한국일보", "psychiatricnews.net": "정신의학신문", "mt.co.kr": "머니투데이", "siminilbo.co.kr": "시민일보", "hemophilia.co.kr": "헤모필리아라이프", "beopbo.com": "법보신문", "stardailynews.co.kr": "스타데일리뉴스", "thefirstmedia.net": "더퍼스트미디어", "edu.donga.com": "에듀동아", "ikoreanspirit.com": "코리안스피릿", "biotimes.co.kr": "바이오타임즈", "gosiweek.com": "고시위크", "livesnews.com": "라이브뉴스", "vegannews.co.kr": "비건뉴스", "sisunnews.co.kr": "시선뉴스", "dhnews.co.kr": "대학저널", "yna.co.kr": "연합뉴스", "news.kbs.co.kr": "KBS", "news.sbs.co.kr": "SBS", "biz.sbs.co.kr": "SBS Biz", "imnews.imbc.com": "MBC", "donga.com": "동아일보", "hani.co.kr": "한겨레", "khan.co.kr": "경향신문", "chosun.com": "조선일보", "biz.chosun.com": "조선비즈", "hankookilbo.com": "한국일보", "mbn.mk.co.kr": "MBN", "mk.co.kr": "매일경제", "yonhapnewstv.co.kr": "연합뉴스TV", "news.tvchosun.com": "TV조선", "sisajournal.com": "시사저널", "kizmom.com": "키즈맘", "idsn.co.kr": "아이디에스엔", "e-science.co.kr": "이사이언스", "asiatoday.co.kr": "아시아투데이", "newsworks.co.kr": "뉴스웍스", "gukjenews.com": "국제뉴스", "cstimes.com": "컨슈머타임스", "cnbnews.com": "CNB뉴스", "enewstoday.co.kr": "이뉴스투데이", "fntoday.co.kr": "파이낸스투데이", "seoul.co.kr": "서울신문", "queen.co.kr": "퀸", "medicalworldnews.co.kr": "메디컬월드뉴스", "thereport.co.kr": "더리포트", "getnews.co.kr": "게잇뉴스", "newslock.co.kr": "뉴스락", "dailypop.kr": "데일리팝", "greened.kr": "녹색경제신문", "public25.com": "퍼블릭뉴스", "news.einfomax.co.kr": "연합인포맥스", "safetimes.co.kr": "세이프타임즈", "pennmike.com": "펜앤드마이크", "health.chosun.com": "헬스조선", "kormedi.com": "코메디닷컴", "sportsseoul.com": "스포츠서울", "segye.com": "세계일보", "sedaily.com": "서울경제", "fnnews.com": "파이낸셜뉴스", "ytn.co.kr": "YTN", "news1.kr": "뉴스1", "newsen.com": "뉴스엔", "osen.co.kr": "OSEN", "xportsnews.com": "엑스포츠뉴스", "tvdaily.co.kr": "티브이데일리", "topstarnews.net": "톱스타뉴스", "hankyung.com": "한국경제", "dailian.co.kr": "데일리안", "kukinews.com": "쿠키뉴스", "joongang.co.kr": "중앙일보", "dt.co.kr": "디지털타임스", "kyeonggi.com": "경기일보", "incheonilbo.com": "인천일보", "ggilbo.com": "금강일보", "kwnews.co.kr": "강원일보", "kmib.co.kr": "국민일보", "munhwa.com": "문화일보", "edaily.co.kr": "이데일리", "nocutnews.co.kr": "노컷뉴스", "news.mt.co.kr": "머니투데이", "newsprime.co.kr": "프라임경제", "ilyo.co.kr": "일요신문", "sports.donga.com": "스포츠동아", "medicaltimes.com": "메디칼타임즈", "docdocdoc.co.kr": "청년의사", "whosaeng.com": "후생신보", "bosa.co.kr": "의학신문", "dailymedi.com": "데일리메디", "akomnews.com": "한의신문", "joynews24.com": "조이뉴스24", "inews24.com": "아이뉴스24", "zdnet.co.kr": "지디넷코리아", "ceoscoredaily.com": "CEO스코어데일리", "viva100.com": "브릿지경제", "g-enews.com": "글로벌이코노믹", "thebell.co.kr": "더벨", "businesspost.co.kr": "비즈니스포스트", "ohmynews.com": "오마이뉴스", "pressian.com": "프레시안", "mediatoday.co.kr": "미디어오늘", "nate.com": "네이트뉴스", "news.nate.com": "네이트뉴스", "daum.net": "다음뉴스", "v.daum.net": "다음뉴스", "news.zum.com": "줌뉴스"};
|
||||
129
supporters/scripts/ocr/vision_ocr.swift
Normal file
129
supporters/scripts/ocr/vision_ocr.swift
Normal file
@ -0,0 +1,129 @@
|
||||
// macOS Vision 프레임워크(VNRecognizeTextRequest)로 이미지 글자를 읽는 단일 파일 도구.
|
||||
// tesseract·pyobjc 가 없는 환경에서 결정론적 OCR 을 쓰기 위한 것. 유료 API 를 쓰지 않는다.
|
||||
//
|
||||
// swiftc -O -o vision_ocr vision_ocr.swift (ocr_evidence.mjs 가 소스가 바뀌면 다시 빌드한다)
|
||||
// ./vision_ocr [--min-width 400] [--langs ko-KR,en-US] [--tile-height 2000] [--upscale-below 1400] <image> [<image> ...]
|
||||
//
|
||||
// 세로로 긴 이미지(의료진 소개 794x12240 같은 스크롤 이미지)는 Vision 이 통째로 축소해 한 줄도 못 읽는다.
|
||||
// --tile-height 픽셀 단위 띠로 잘라(겹침 10%) 띠마다 인식하고, 겹침 구간의 줄은 한 번만 남긴다.
|
||||
// 폭이 --upscale-below 미만이면 2배로 키워 인식한다(작은 글자 대응). box 좌표는 원본 기준 0~1 정규화.
|
||||
//
|
||||
// 출력: 이미지 한 장에 JSON 한 줄(stdout)
|
||||
// {"path":"...","width":W,"height":H,"lines":[{"text":"...","confidence":0.93,"box":[x,y,w,h]}],"engine":"apple-vision"}
|
||||
// 폭이 --min-width 미만이면 {"path":"...","width":W,"height":H,"skipped":"narrow"}. 읽기 실패는 {"path":"...","error":"..."}.
|
||||
// 줄 순서는 위에서 아래, 같은 줄 안에서는 왼쪽에서 오른쪽(Vision 의 정규화 좌표 y 는 아래가 0 이라 뒤집는다).
|
||||
// 글자는 Vision 이 준 것만 내보낸다. 후처리(병합·교정)는 하지 않는다. 그것은 호출 측(ocr_evidence.mjs) 몫이다.
|
||||
import Foundation
|
||||
import Vision
|
||||
import ImageIO
|
||||
import CoreGraphics
|
||||
|
||||
var minWidth = 0
|
||||
var tileHeight = 2000
|
||||
var upscaleBelow = 1400
|
||||
var langs = ["ko-KR", "en-US"]
|
||||
var paths: [String] = []
|
||||
var it = CommandLine.arguments.dropFirst().makeIterator()
|
||||
while let a = it.next() {
|
||||
switch a {
|
||||
case "--min-width": if let v = it.next(), let n = Int(v) { minWidth = n }
|
||||
case "--langs": if let v = it.next() { langs = v.split(separator: ",").map { String($0) } }
|
||||
case "--tile-height": if let v = it.next(), let n = Int(v) { tileHeight = max(n, 400) }
|
||||
case "--upscale-below": if let v = it.next(), let n = Int(v) { upscaleBelow = n }
|
||||
default: paths.append(a)
|
||||
}
|
||||
}
|
||||
if paths.isEmpty {
|
||||
FileHandle.standardError.write("사용법: vision_ocr [--min-width N] [--langs ko-KR,en-US] <image> ...\n".data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func jsonLine(_ obj: [String: Any]) {
|
||||
if let d = try? JSONSerialization.data(withJSONObject: obj, options: [.withoutEscapingSlashes]), let s = String(data: d, encoding: .utf8) {
|
||||
print(s)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func loadImage(_ path: String) -> CGImage? {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||
return CGImageSourceCreateImageAtIndex(src, 0, nil)
|
||||
}
|
||||
|
||||
struct Line { var text: String; var confidence: Double; var x: Double; var y: Double; var w: Double; var h: Double } // 픽셀 좌표(원본 기준)
|
||||
|
||||
// 이미지 한 장(또는 띠 하나)을 인식해 원본 픽셀 좌표의 줄 목록으로. scale 은 확대 배율
|
||||
func recognize(_ img: CGImage, offsetY: Int, scale: Int) throws -> [Line] {
|
||||
var target = img
|
||||
if scale > 1 {
|
||||
let w = img.width * scale, h = img.height * scale
|
||||
if let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) {
|
||||
ctx.interpolationQuality = .high
|
||||
ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))
|
||||
if let up = ctx.makeImage() { target = up }
|
||||
}
|
||||
}
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.recognitionLanguages = langs
|
||||
request.usesLanguageCorrection = true
|
||||
let handler = VNImageRequestHandler(cgImage: target, options: [:])
|
||||
try handler.perform([request])
|
||||
let obs = (request.results ?? []) as [VNRecognizedTextObservation]
|
||||
let W = Double(img.width), H = Double(img.height)
|
||||
var out: [Line] = []
|
||||
for o in obs {
|
||||
guard let top = o.topCandidates(1).first else { continue }
|
||||
let bb = o.boundingBox // 정규화, 원점 왼쪽 아래
|
||||
out.append(Line(text: top.string, confidence: Double(top.confidence), x: bb.minX * W, y: (1 - bb.maxY) * H + Double(offsetY), w: bb.width * W, h: bb.height * H))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for path in paths {
|
||||
guard let img = loadImage(path) else { jsonLine(["path": path, "error": "이미지를 열 수 없음"]); continue }
|
||||
let w = img.width, h = img.height
|
||||
if w < minWidth { jsonLine(["path": path, "width": w, "height": h, "skipped": "narrow"]); continue }
|
||||
let scale = w < upscaleBelow ? 2 : 1
|
||||
var lines: [Line] = []
|
||||
var tiles = 0
|
||||
do {
|
||||
if h <= tileHeight {
|
||||
lines = try recognize(img, offsetY: 0, scale: scale); tiles = 1
|
||||
} else {
|
||||
let overlap = tileHeight / 10
|
||||
var top = 0
|
||||
while top < h {
|
||||
let th = min(tileHeight, h - top)
|
||||
guard let tile = img.cropping(to: CGRect(x: 0, y: top, width: w, height: th)) else { break }
|
||||
let got = try recognize(tile, offsetY: top, scale: scale)
|
||||
// 겹침 구간은 한가운데서 나눠 앞 띠는 그 앞까지, 뒷 띠는 그 뒤부터 받는다(같은 줄이 두 띠에 들어가지 않게).
|
||||
// 띠 가장자리에 걸려 반만 읽힌 줄은 중심이 경계 밖이라 버려지고, 다른 띠에서 온전히 읽힌 것만 남는다. 첫 띠는 처음부터, 마지막 띠는 끝까지
|
||||
let isLast = top + th >= h
|
||||
let lo = top == 0 ? -1.0 : Double(top + overlap / 2)
|
||||
let hi = isLast ? Double.infinity : Double(top + th - overlap / 2)
|
||||
for l in got { let cy = l.y + l.h / 2; if cy >= lo && cy < hi { lines.append(l) } }
|
||||
tiles += 1
|
||||
if top + th >= h { break }
|
||||
top += tileHeight - overlap
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
jsonLine(["path": path, "width": w, "height": h, "error": "인식 실패: \(error.localizedDescription)"]); continue
|
||||
}
|
||||
// 위→아래, 같은 줄 안에서는 왼→오른쪽
|
||||
lines.sort { a, b in
|
||||
let ay = a.y + a.h / 2, by = b.y + b.h / 2
|
||||
if abs(ay - by) > max(a.h, b.h) * 0.6 { return ay < by }
|
||||
return a.x < b.x
|
||||
}
|
||||
let W = Double(w), H = Double(h)
|
||||
let r3 = { (v: Double) -> Double in (v * 1000).rounded() / 1000 }
|
||||
let out: [[String: Any]] = lines.map { l in [
|
||||
"text": l.text,
|
||||
"confidence": r3(l.confidence),
|
||||
"box": [r3(l.x / W), r3(l.y / H), r3(l.w / W), r3(l.h / H)],
|
||||
] }
|
||||
jsonLine(["path": path, "width": w, "height": h, "tiles": tiles, "scale": scale, "lines": out, "engine": "apple-vision"])
|
||||
}
|
||||
241
supporters/scripts/ocr_evidence.mjs
Normal file
241
supporters/scripts/ocr_evidence.mjs
Normal file
@ -0,0 +1,241 @@
|
||||
// 근거 OCR 단계. collect_evidence.mjs 가 남긴 evidence/<clinic>/ 에서 글자가 이미지에 들어 있는 페이지(flags.textInImages 또는 본문 100자 미만)의
|
||||
// 콘텐츠 이미지를 내려받아 macOS Vision(scripts/ocr/vision_ocr.swift)으로 읽고, 결과를 페이지 JSON·index.json·home_text·doctors.json 에 되돌려 넣는다.
|
||||
// 시술 설명을 통짜 JPG 로 넣는 사이트(오라클피부과: 시술 84페이지 중 83개가 이미지 글자)를 근거로 쓰기 위한 것이다.
|
||||
//
|
||||
// node scripts/ocr_evidence.mjs --clinic <id> --evidence <evidence/<id>> [--min-width 400] [--max-images 200] [--min-conf 0.5] [--force]
|
||||
//
|
||||
// 원칙
|
||||
// - 유료 API 를 쓰지 않는다. Vision 이 준 글자만 쓰고 교정·보완하지 않는다(없는 글자를 만들지 않는다).
|
||||
// - 신뢰도 0.5 미만 줄은 버린다. 줄 병합은 문장 부호 기준으로만 한다: 앞 줄이 . ? ! 로 끝나지 않으면 다음 줄을 같은 문장으로 이어 붙인다.
|
||||
// 세로 간격이 줄 높이의 1.6배를 넘으면 문단을 나눈다(나누기만 하고 붙이지는 않는다).
|
||||
// - 원문(HTML) 텍스트와 OCR 텍스트를 구분한다: 페이지 JSON 에 ocr[]·sections[{source:"ocr"}]·flags.ocr=true·textOriginal 을 남긴다.
|
||||
// - 다시 돌려도 결과가 같다(멱등): 이미 받은 이미지는 다시 받지 않고, 텍스트는 textOriginal 에서 다시 만든다. --force 면 OCR 도 다시 한다.
|
||||
//
|
||||
// 산출물(기존 파일을 갱신)
|
||||
// evidence/<clinic>/ocr/<slug>/<이미지> 내려받은 원본 이미지
|
||||
// evidence/<clinic>/ocr/results.jsonl 이미지별 Vision 원출력(줄·신뢰도·좌표)
|
||||
// evidence/<clinic>/pages/<slug>.json ocr[], sections 에 OCR 섹션 추가, text·chars 갱신, flags.ocr
|
||||
// evidence/<clinic>/index.json chars·flags 갱신, ocr 요약
|
||||
// evidence/<clinic>/home_text.txt 원문 + OCR (40자 연속 일치 게이트 기준. OCR 글자도 병원 문장이므로 합친다)
|
||||
// evidence/<clinic>/home_text.ocr.txt OCR 만 따로
|
||||
// evidence/<clinic>/doctors.json 의료진 페이지 OCR 에서 읽은 이름·직함·약력 후보 ("OCR · 확인 대기")
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
// ---------- 인자 ----------
|
||||
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; };
|
||||
const CLINIC = arg('--clinic'); const EV = arg('--evidence');
|
||||
if (!CLINIC || !EV || !existsSync(join(EV, 'index.json'))) { console.error('사용법: --clinic <id> --evidence <evidence/<id>> (index.json 있는 폴더)'); process.exit(2); }
|
||||
const MIN_WIDTH = Number(arg('--min-width', 400)); const MAX_IMAGES = Number(arg('--max-images', 200)); const MIN_CONF = Number(arg('--min-conf', 0.5));
|
||||
const FORCE = process.argv.includes('--force');
|
||||
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)';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const OCR_DIR = join(EV, 'ocr'); mkdirSync(OCR_DIR, { recursive: true });
|
||||
|
||||
// ---------- Vision 도구 빌드 ----------
|
||||
const SWIFT_SRC = fileURLToPath(new URL('./ocr/vision_ocr.swift', import.meta.url));
|
||||
const BIN = join(dirname(SWIFT_SRC), 'vision_ocr');
|
||||
function ensureBinary() {
|
||||
const fresh = existsSync(BIN) && statSync(BIN).mtimeMs >= statSync(SWIFT_SRC).mtimeMs;
|
||||
if (fresh) return;
|
||||
console.log(` swiftc 빌드 ${basename(SWIFT_SRC)} → ${basename(BIN)}`);
|
||||
const r = spawnSync('swiftc', ['-O', '-o', BIN, SWIFT_SRC], { encoding: 'utf8' });
|
||||
if (r.status !== 0) { console.error(r.stderr || r.stdout); console.error('vision_ocr 빌드 실패. Xcode Command Line Tools(swiftc) 가 필요하다'); process.exit(1); }
|
||||
}
|
||||
|
||||
// ---------- 대상 페이지·이미지 ----------
|
||||
// 로고·버튼·아이콘·배너·전후사진·장비 썸네일은 이름으로 뺀다. 폭은 Vision 도구가 --min-width 로 거른다
|
||||
const NOT_CONTENT = /icon|logo|btn|button|arrow|blank|spacer|sns|share|banner|bnr|top_ban|_ban\d|main_bf|convenient|bg_|_bg\b|\/bg\.|dot\.|bullet|quick|visual|slide|roll|thumb|gallery\/|laiser_table|_next\/image|\.bmp$|\.gif$|\.svg$|before|after/i;
|
||||
const TYPE_PRIO = { 'doctor-detail': 0, doctor: 1, procedure: 2, precautions: 3, aftercare: 3, safety: 3, checkup: 3, about: 4, pricing: 4, facilities: 5, direction: 5, reservation: 6, branches: 6, home: 7, other: 8 };
|
||||
const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
|
||||
const loadPage = (f) => JSON.parse(readFileSync(join(EV, f), 'utf8'));
|
||||
const savePage = (f, p) => writeFileSync(join(EV, f), JSON.stringify(p, null, 1));
|
||||
const flagOn = (p, k) => (Array.isArray(p.flags) ? p.flags.includes(k) : Boolean(p.flags?.[k]));
|
||||
|
||||
const candidates = index.pages
|
||||
.filter((p) => p.status === 200 && p.type !== 'duplicate' && p.file)
|
||||
.map((p) => ({ ix: p, page: loadPage(p.file) }))
|
||||
.filter(({ page }) => flagOn(page, 'textInImages') || (page.flags?.charsOriginal ?? page.chars ?? 0) < 100)
|
||||
.sort((a, b) => (TYPE_PRIO[a.ix.type] ?? 9) - (TYPE_PRIO[b.ix.type] ?? 9));
|
||||
console.log(`[1/4] ${CLINIC}: 이미지 글자 페이지 ${candidates.length}개 (전체 ${index.pages.length})`);
|
||||
|
||||
// 페이지별 콘텐츠 이미지 → 내려받기 목록. 같은 이미지가 여러 페이지에 있으면 한 번만 받는다
|
||||
const jobs = []; const seenSrc = new Map();
|
||||
for (const c of candidates) {
|
||||
const imgs = (c.page.images ?? []).filter((im) => im.src && !NOT_CONTENT.test(im.src));
|
||||
for (const im of imgs) {
|
||||
if (jobs.length >= MAX_IMAGES) break;
|
||||
const key = im.src.replace(/^https?:\/\//, '');
|
||||
if (seenSrc.has(key)) { seenSrc.get(key).pages.push(c); continue; }
|
||||
const name = decodeURIComponent(basename(new URL(im.src).pathname)).replace(/[^a-zA-Z0-9가-힣._-]+/g, '_') || 'image';
|
||||
const file = join(OCR_DIR, c.page.slug, name);
|
||||
const job = { src: im.src, alt: im.alt ?? '', file, pages: [c] };
|
||||
seenSrc.set(key, job); jobs.push(job);
|
||||
}
|
||||
}
|
||||
console.log(` 콘텐츠 이미지 후보 ${jobs.length}개 (--max-images ${MAX_IMAGES}, 이름으로 제외 ${candidates.reduce((n, c) => n + (c.page.images ?? []).filter((im) => NOT_CONTENT.test(im.src ?? '')).length, 0)}개)`);
|
||||
|
||||
// ---------- 내려받기 ----------
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function download(job) {
|
||||
if (existsSync(job.file) && statSync(job.file).size > 0) { job.downloaded = 'cached'; return; }
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
const res = await fetch(job.src, { headers: { 'user-agent': UA, referer: job.pages[0].page.url }, signal: AbortSignal.timeout(30000) });
|
||||
if (res.status !== 200) { job.error = `HTTP ${res.status}`; return; }
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
if (buf.length < 1000) { job.error = `파일이 너무 작음 (${buf.length}B)`; return; }
|
||||
mkdirSync(dirname(job.file), { recursive: true }); writeFileSync(job.file, buf); job.downloaded = 'new'; return;
|
||||
} catch (e) { job.error = e.message; await sleep(600 * (i + 1)); }
|
||||
}
|
||||
}
|
||||
{
|
||||
let i = 0; const workers = Array.from({ length: 3 }, async () => { while (i < jobs.length) { const j = jobs[i++]; await download(j); process.stdout.write(`\r 내려받기 ${i}/${jobs.length}`); } });
|
||||
await Promise.all(workers);
|
||||
}
|
||||
const fetched = jobs.filter((j) => !j.error);
|
||||
console.log(`\n[2/4] 내려받음 ${fetched.length}개 (새로 ${fetched.filter((j) => j.downloaded === 'new').length} · 캐시 ${fetched.filter((j) => j.downloaded === 'cached').length} · 실패 ${jobs.length - fetched.length})`);
|
||||
|
||||
// ---------- OCR ----------
|
||||
ensureBinary();
|
||||
const RESULTS = join(OCR_DIR, 'results.jsonl');
|
||||
const prior = new Map(); // file → 결과 (멱등: 이미 읽은 이미지는 다시 읽지 않는다)
|
||||
if (!FORCE && existsSync(RESULTS)) for (const l of readFileSync(RESULTS, 'utf8').split('\n').filter(Boolean)) { try { const o = JSON.parse(l); if (o.path && !o.error) prior.set(o.path, o); } catch { /* 깨진 줄 무시 */ } }
|
||||
const todo = fetched.filter((j) => !prior.has(j.file));
|
||||
const results = new Map(prior);
|
||||
if (todo.length) {
|
||||
console.log(` Vision OCR ${todo.length}개 (캐시 ${fetched.length - todo.length}개)`);
|
||||
for (let i = 0; i < todo.length; i += 25) { // 한 프로세스에 25장씩. 실패해도 나머지는 진행
|
||||
const batch = todo.slice(i, i + 25);
|
||||
const r = spawnSync(BIN, ['--min-width', String(MIN_WIDTH), '--langs', 'ko-KR,en-US', ...batch.map((j) => j.file)], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 });
|
||||
if (r.status !== 0 && !r.stdout) { console.error(` vision_ocr 실패: ${r.stderr?.slice(0, 500)}`); for (const j of batch) results.set(j.file, { path: j.file, error: `vision_ocr 종료 코드 ${r.status}` }); continue; }
|
||||
for (const l of r.stdout.split('\n').filter(Boolean)) { try { const o = JSON.parse(l); results.set(o.path, o); } catch { /* 무시 */ } }
|
||||
process.stdout.write(`\r OCR ${Math.min(i + 25, todo.length)}/${todo.length}`);
|
||||
}
|
||||
console.log('');
|
||||
writeFileSync(RESULTS, [...results.values()].map((o) => JSON.stringify(o)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
// ---------- 줄 → 문단 ----------
|
||||
const ENDS = /[.?!。…]["')\]]?$/;
|
||||
/** 신뢰도로 거르고, 문장 부호 기준으로 줄을 잇는다. 세로 간격이 크면 문단을 나눈다 */
|
||||
function linesToText(lines) {
|
||||
const kept = lines.filter((l) => l.confidence >= MIN_CONF && l.text.trim());
|
||||
const paras = []; let cur = null; let prev = null;
|
||||
for (const l of kept) {
|
||||
const t = l.text.trim();
|
||||
const gapBreak = prev && l.box && prev.box && (l.box[1] - (prev.box[1] + prev.box[3])) > Math.max(prev.box[3], l.box[3]) * 1.6;
|
||||
if (cur && !gapBreak && !ENDS.test(cur)) cur = `${cur} ${t}`;
|
||||
else { if (cur) paras.push(cur); cur = t; }
|
||||
prev = l;
|
||||
}
|
||||
if (cur) paras.push(cur);
|
||||
return { text: paras.join('\n'), kept: kept.length, dropped: lines.length - kept.length };
|
||||
}
|
||||
|
||||
// ---------- 페이지에 반영 ----------
|
||||
let ocrImages = 0, ocrPages = 0, ocrChars = 0; const failed = []; const perPage = [];
|
||||
for (const c of candidates) {
|
||||
const page = c.page;
|
||||
const myJobs = jobs.filter((j) => j.pages.includes(c));
|
||||
const entries = [];
|
||||
for (const j of myJobs) {
|
||||
const r = results.get(j.file);
|
||||
if (j.error) { failed.push({ page: page.url, src: j.src, reason: j.error }); continue; }
|
||||
if (!r) { failed.push({ page: page.url, src: j.src, reason: 'OCR 결과 없음' }); continue; }
|
||||
if (r.error) { failed.push({ page: page.url, src: j.src, reason: r.error }); continue; }
|
||||
if (r.skipped) continue; // 폭 부족
|
||||
const { text, kept, dropped } = linesToText(r.lines ?? []);
|
||||
if (!text) continue;
|
||||
const conf = kept ? Math.round((r.lines.filter((l) => l.confidence >= MIN_CONF).reduce((s, l) => s + l.confidence, 0) / kept) * 1000) / 1000 : 0;
|
||||
entries.push({ src: j.src, alt: j.alt, file: j.file.replace(EV + '/', ''), width: r.width, height: r.height, tiles: r.tiles ?? 1, lines: r.lines.filter((l) => l.confidence >= MIN_CONF).map((l) => l.text), droppedLines: dropped, confidence: conf, text, engine: r.engine ?? 'apple-vision', at: today });
|
||||
}
|
||||
// 원문 텍스트는 textOriginal 로 보존(재실행 시 여기서 다시 만든다)
|
||||
page.textOriginal ??= page.text ?? '';
|
||||
page.flags ??= {};
|
||||
page.flags.charsOriginal ??= page.textOriginal.replace(/\s+/g, '').length;
|
||||
page.sections = (page.sections ?? []).filter((s) => s.source !== 'ocr');
|
||||
page.ocr = entries;
|
||||
const ocrText = entries.map((e) => e.text).join('\n');
|
||||
if (entries.length) {
|
||||
page.sections.push({ heading: page.title || page.h1 || page.slug, level: 2, source: 'ocr', images: entries.map((e) => e.src), text: ocrText, paragraphs: ocrText.split('\n'), lists: [], tables: [] });
|
||||
page.text = [page.textOriginal, ocrText].filter(Boolean).join('\n');
|
||||
page.flags.ocr = true; page.flags.ocrImages = entries.length; page.flags.ocrChars = ocrText.replace(/\s+/g, '').length;
|
||||
ocrImages += entries.length; ocrPages++; ocrChars += page.flags.ocrChars;
|
||||
} else { page.text = page.textOriginal; delete page.flags.ocr; delete page.flags.ocrImages; delete page.flags.ocrChars; }
|
||||
page.chars = page.text.replace(/\s+/g, '').length;
|
||||
savePage(c.ix.file, page);
|
||||
c.ix.chars = page.chars;
|
||||
c.ix.flags = Object.entries(page.flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
perPage.push({ url: page.url, type: page.type, title: page.title, chars: page.chars, ocrChars: page.flags.ocrChars ?? 0, images: entries.length });
|
||||
}
|
||||
index.byType = index.pages.reduce((m, x) => ((m[x.type] = (m[x.type] ?? 0) + 1), m), {});
|
||||
index.ocr = { at: today, engine: 'apple-vision', pages: ocrPages, images: ocrImages, chars: ocrChars, minWidth: MIN_WIDTH, minConf: MIN_CONF, failed: failed.length };
|
||||
writeFileSync(join(EV, 'index.json'), JSON.stringify(index, null, 1));
|
||||
console.log(`[3/4] 페이지 ${ocrPages}개에 OCR 반영 (이미지 ${ocrImages}장 · OCR 글자 ${ocrChars.toLocaleString()}자 · 실패 ${failed.length})`);
|
||||
|
||||
// ---------- home_text ----------
|
||||
// 40자 연속 일치 게이트는 병원 문장의 그대로 옮김을 막는 것이다. OCR 글자도 병원이 쓴 문장이므로 home_text.txt 에 합친다.
|
||||
// OCR 오독으로 원문과 몇 글자 다를 수 있어 게이트가 그만큼 느슨해질 뿐, 잘못 막는 쪽으로는 가지 않는다. OCR 만 따로 home_text.ocr.txt 에도 남긴다.
|
||||
{
|
||||
const orig = []; const ocr = [];
|
||||
for (const ix of index.pages) {
|
||||
if (ix.status !== 200 || ix.type === 'duplicate' || !ix.file) continue;
|
||||
const p = loadPage(ix.file);
|
||||
const o = (p.textOriginal ?? p.text ?? '').replace(/\s+/g, ''); if (o) orig.push(o);
|
||||
const t = (p.ocr ?? []).map((e) => e.text).join('\n').replace(/\s+/g, ''); if (t) ocr.push(t);
|
||||
}
|
||||
writeFileSync(join(EV, 'home_text.txt'), [...orig, ...ocr].join('\n'));
|
||||
writeFileSync(join(EV, 'home_text.ocr.txt'), ocr.join('\n'));
|
||||
}
|
||||
|
||||
// ---------- 의료진 ----------
|
||||
// 의료진 페이지 OCR 줄에서 "이름 원장" 을 찾고, 다음 이름·지점 제목까지의 줄을 약력 후보로. 지점("X점")·지역("X지역")·진료과 제목은 소속으로 붙인다.
|
||||
const NAME_RE = /^[\s|1lI]*([가-힣]{2,4})\s*(대표\s*)?원장(?:님)?\s*$/;
|
||||
const BRANCH_RE = /^[\s|]*((?:[가-힣]{1,4}\s)?[가-힣A-Za-z0-9]{1,10}점)\s*$/;
|
||||
const REGION_RE = /^[\s|]*([가-힣]{2,6}지역)\s*$/;
|
||||
const DEPT_RE = /^[\s|]*(피부과|성형외과|치과|내과|비만\s*[•·]?\s*체형|피부\s*관리)\s*$/;
|
||||
const SPECIALTY_RE = /(성형외과|이비인후과|마취통증의학과|피부과|영상의학과|외과|치과|내과|가정의학과|산부인과)\s*전문의/;
|
||||
function parseDoctorsFromOcr(page) {
|
||||
const out = [];
|
||||
for (const e of page.ocr ?? []) {
|
||||
let region = null, branch = null, dept = null, cur = null;
|
||||
for (const raw of e.lines) {
|
||||
const t = raw.trim();
|
||||
let m;
|
||||
if ((m = t.match(REGION_RE))) { region = m[1]; continue; }
|
||||
if ((m = t.match(BRANCH_RE))) { branch = m[1]; cur = null; continue; }
|
||||
if ((m = t.match(DEPT_RE))) { dept = m[1].replace(/\s+/g, ' '); continue; }
|
||||
if ((m = t.match(NAME_RE))) { cur = { name: m[1], isRep: Boolean(m[2]), region, branch, dept, credentials: [], image: e.src }; out.push(cur); continue; }
|
||||
if (cur && t.length >= 4 && t.length <= 60) cur.credentials.push(t);
|
||||
}
|
||||
}
|
||||
return out.map((d, i) => {
|
||||
const specialty = d.credentials.map((c) => c.match(SPECIALTY_RE)?.[0]).find(Boolean) ?? null;
|
||||
return { id: `ocr-${i + 1}-${d.name}`, url: page.url, name: d.name, title: [d.isRep ? '대표원장' : '원장', specialty].filter(Boolean).join(' · '), region: d.region, branch: d.branch, dept: d.dept, credentials: d.credentials.filter((c) => c !== specialty), image: d.image, source: 'ocr', status: 'OCR · 확인 대기' };
|
||||
});
|
||||
}
|
||||
{
|
||||
const path = join(EV, 'doctors.json');
|
||||
const doc = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { clinic: CLINIC, fetchedAt: today, doctors: [] };
|
||||
const doctorPages = candidates.filter((c) => ['doctor', 'doctor-detail'].includes(c.page.type) && c.page.ocr?.length);
|
||||
const found = doctorPages.flatMap((c) => parseDoctorsFromOcr(c.page));
|
||||
if (found.length) {
|
||||
// 상세 페이지에서 이미 이름이 읽힌 의사는 유지하고 OCR 약력만 붙인다. 이름 없이 남아 있던 항목(이미지 글자)은 OCR 결과로 바꾼다
|
||||
// 지난 실행의 OCR 항목(source=ocr)은 버리고 다시 만든다(멱등)
|
||||
const kept = (doc.doctors ?? []).filter((d) => d.name && d.source !== 'ocr').map((d) => { const o = found.find((f) => f.name === d.name); return o ? { ...d, credentialsOcr: o.credentials, ocrImage: o.image, status: d.status === 'ok' ? d.status : 'OCR 약력 · 확인 대기' } : d; });
|
||||
const add = found.filter((f) => !kept.some((d) => d.name === f.name));
|
||||
doc.doctors = [...kept, ...add];
|
||||
doc.ocr = { at: today, pages: doctorPages.map((c) => c.page.url), found: found.length, note: 'source=ocr 항목은 의료진 이미지에서 Vision 으로 읽은 이름·약력 후보. 오독 가능. authors.json 에 옮기기 전에 원본 이미지와 대조한다.' };
|
||||
writeFileSync(path, JSON.stringify(doc, null, 1));
|
||||
console.log(` 의료진 OCR 후보 ${found.length}명 (${[...new Set(found.map((f) => f.branch).filter(Boolean))].length}개 지점) → doctors.json ${doc.doctors.length}명`);
|
||||
} else console.log(' 의료진 OCR 후보 없음');
|
||||
}
|
||||
|
||||
// ---------- 보고 ----------
|
||||
const dist = perPage.filter((p) => p.images);
|
||||
console.log(`[4/4] 저장 ${EV}`);
|
||||
console.log(` OCR 이미지 ${ocrImages}장 · 페이지 ${ocrPages}개 · 200자 이상 ${dist.filter((p) => p.ocrChars >= 200).length}개 · 1,000자 이상 ${dist.filter((p) => p.ocrChars >= 1000).length}개 · home_text ${index.pages.reduce((n, p) => n + (p.chars ?? 0), 0).toLocaleString()}자 (OCR ${ocrChars.toLocaleString()}자)`);
|
||||
if (failed.length) console.log(` 실패 ${failed.length}: ${failed.slice(0, 10).map((f) => `${f.src} (${f.reason})`).join(', ')}${failed.length > 10 ? ' …' : ''}`);
|
||||
127
supporters/scripts/publish_site.mjs
Normal file
127
supporters/scripts/publish_site.mjs
Normal file
@ -0,0 +1,127 @@
|
||||
// 배포 후 색인 작업 (워커 publish 단계). "서포터즈 사이트 완료"는 배포가 아니라 이 단계와 사람 체크리스트까지 끝난 상태다 (haewon 결정 2026-09-11).
|
||||
// node scripts/publish_site.mjs --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]
|
||||
//
|
||||
// 자동으로 하는 것
|
||||
// 1. 라이브 확인: 홈·robots.txt·sitemap.xml 응답, X-Robots-Tag(noindex) 여부, 사이트맵 URL 수
|
||||
// 2. IndexNow(빙·네이버 공용): 색인 허용 빌드일 때만 사이트맵의 URL 전부를 api.indexnow.org 에 알린다. 키 파일은 워커 data 단계가 public/<key>.txt 로 넣는다
|
||||
// 3. Google Search Console: GOOGLE_SERVICE_ACCOUNT_JSON(경로 또는 JSON) 이 있으면 사이트맵 제출 + 최대 20 URL 색인 상태 조회(URL Inspection API).
|
||||
// 서비스 계정이 속성 소유자로 등록돼 있지 않으면 403 → 사람 체크리스트로 넘긴다
|
||||
// 사람에게 남기는 것 (<work>/publish-checklist.md, status.humanTasks)
|
||||
// 네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유 확인(첫 1회), 도메인 연결, 색인 허용 전환 전 법무·승인 확인
|
||||
// 하지 않는 것: Google Indexing API(채용·라이브 방송 전용, 일반 페이지에 쓰면 가이드라인 위반), 색인 보장 약속.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createSign } from 'node:crypto';
|
||||
|
||||
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 clinic = opt('clinic'); const SITE = opt('site'); const URLBASE = (opt('url') || '').replace(/\/$/, '');
|
||||
if (!clinic || !SITE || !URLBASE) { console.error('사용법: --clinic <id> --site <siteDir> --url https://<live> [--work <dir>] [--indexable]'); process.exit(2); }
|
||||
const WORK = resolve(opt('work', join(SITE, '..')));
|
||||
const site = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'site.json'), 'utf8'));
|
||||
const fact = JSON.parse(readFileSync(join(SITE, 'src', 'data', 'factSheet.json'), 'utf8'));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const report = { clinic, url: URLBASE, checkedAt: new Date().toISOString(), live: {}, indexable: false, indexnow: null, google: null, naver: null, humanTasks: [] };
|
||||
const task = (id, title, why, how) => report.humanTasks.push({ id, title, why, how });
|
||||
|
||||
async function head(url) {
|
||||
try { const r = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'infinith-supporters-publish/1.0' } }); const text = r.ok ? await r.text() : ''; return { ok: r.ok, status: r.status, robotsTag: r.headers.get('x-robots-tag') || '', text }; }
|
||||
catch (e) { return { ok: false, status: 0, robotsTag: '', text: '', error: String(e.message ?? e) }; }
|
||||
}
|
||||
|
||||
// ---------- 1. 라이브 확인 ----------
|
||||
const home = await head(`${URLBASE}/`);
|
||||
const robots = await head(`${URLBASE}/robots.txt`);
|
||||
const sitemap = await head(`${URLBASE}/sitemap.xml`);
|
||||
const urls = [...(sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g))].map((m) => m[1].trim());
|
||||
const noindexHeader = /noindex/i.test(home.robotsTag);
|
||||
const robotsBlocksAll = /Disallow:\s*\/\s*$/m.test(robots.text) && !/Allow:\s*\//.test(robots.text);
|
||||
report.live = { home: home.status, robots: robots.status, sitemap: sitemap.status, sitemapUrls: urls.length, noindexHeader, robotsBlocksAll };
|
||||
report.indexable = flag('indexable') || (home.ok && !noindexHeader && !robotsBlocksAll);
|
||||
if (!home.ok) task('live', '라이브 응답 확인', `홈이 ${home.status} 로 응답했습니다.`, `${URLBASE}/ 를 열어 배포 상태를 확인`);
|
||||
|
||||
// ---------- 2. IndexNow (빙·네이버) ----------
|
||||
if (report.indexable && urls.length) {
|
||||
const key = site.indexNowKey;
|
||||
if (!key) { report.indexnow = { skipped: 'site.json 에 indexNowKey 없음 (워커 data 단계가 만든다)' }; }
|
||||
else {
|
||||
const keyUrl = `${URLBASE}/${key}.txt`;
|
||||
const kf = await head(keyUrl);
|
||||
if (!kf.ok || kf.text.trim() !== key) { report.indexnow = { skipped: `키 파일 확인 실패 ${keyUrl} (${kf.status})` }; }
|
||||
else {
|
||||
try {
|
||||
const host = new URL(URLBASE).host;
|
||||
const r = await fetch('https://api.indexnow.org/indexnow', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls.slice(0, 10000) }) });
|
||||
report.indexnow = { status: r.status, submitted: Math.min(urls.length, 10000), engines: 'Bing·Naver(IndexNow 참여)' };
|
||||
} catch (e) { report.indexnow = { error: String(e.message ?? e) }; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
report.indexnow = { skipped: report.indexable ? '사이트맵 URL 없음' : '색인 허용 빌드가 아님(noindex). 샘플 단계에서는 알리지 않는다' };
|
||||
}
|
||||
|
||||
// ---------- 3. Google Search Console ----------
|
||||
async function googleToken(sa) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const unsigned = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/webmasters', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`;
|
||||
const sig = createSign('RSA-SHA256').update(unsigned).sign(sa.private_key, 'base64url');
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${unsigned}.${sig}` });
|
||||
if (!r.ok) throw new Error(`토큰 실패 ${r.status}`);
|
||||
return (await r.json()).access_token;
|
||||
}
|
||||
const saRaw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || '';
|
||||
if (!report.indexable) {
|
||||
report.google = { skipped: '색인 허용 빌드가 아님' };
|
||||
} else if (!saRaw) {
|
||||
report.google = { skipped: 'GOOGLE_SERVICE_ACCOUNT_JSON 없음' };
|
||||
task('gsc-owner', 'Google Search Console 속성 등록·소유 확인 (첫 1회)', '서비스 계정이 없거나 속성 소유자로 등록되지 않아 사이트맵 제출과 색인 상태 조회를 자동으로 할 수 없습니다.', `1) Search Console 에 ${URLBASE} 속성 추가 2) 소유 확인: site.json googleSiteVerification 에 메타 태그 값(content)을 넣고 재빌드 3) 서비스 계정 이메일을 속성 소유자로 추가 4) Productize .env 에 GOOGLE_SERVICE_ACCOUNT_JSON 경로`);
|
||||
} else {
|
||||
try {
|
||||
const sa = JSON.parse(existsSync(saRaw) ? readFileSync(saRaw, 'utf8') : saRaw);
|
||||
const token = await googleToken(sa);
|
||||
const siteUrl = `${URLBASE}/`;
|
||||
const smUrl = `${URLBASE}/sitemap.xml`;
|
||||
const put = await fetch(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(smUrl)}`, { method: 'PUT', headers: { authorization: `Bearer ${token}` } });
|
||||
const g = { sitemapSubmit: put.status, inspected: [] };
|
||||
if (put.status === 403 || put.status === 404) {
|
||||
task('gsc-owner', 'Google Search Console 소유자 추가', `사이트맵 제출이 ${put.status} 로 거절됐습니다. 서비스 계정(${sa.client_email})이 속성 소유자가 아닙니다.`, `Search Console → 설정 → 사용자 및 권한 → ${sa.client_email} 을 소유자로 추가`);
|
||||
} else {
|
||||
for (const u of urls.slice(0, 20)) {
|
||||
const r = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ inspectionUrl: u, siteUrl }) });
|
||||
const j = r.ok ? await r.json() : null;
|
||||
g.inspected.push({ url: u, verdict: j?.inspectionResult?.indexStatusResult?.verdict ?? `HTTP ${r.status}`, coverage: j?.inspectionResult?.indexStatusResult?.coverageState ?? '' });
|
||||
}
|
||||
g.indexed = g.inspected.filter((x) => x.verdict === 'PASS').length;
|
||||
}
|
||||
report.google = g;
|
||||
} catch (e) { report.google = { error: String(e.message ?? e) }; task('gsc-error', 'Google Search Console 연동 오류 확인', String(e.message ?? e), 'GOOGLE_SERVICE_ACCOUNT_JSON 값과 서비스 계정 권한 확인'); }
|
||||
}
|
||||
|
||||
// ---------- 4. 네이버 서치어드바이저 (공개 API 없음 → 사람) ----------
|
||||
if (report.indexable) {
|
||||
report.naver = { via: 'IndexNow 로 URL 알림' + (report.indexnow?.status ? ` (${report.indexnow.status})` : ''), verificationMeta: Boolean(site.naverSiteVerification) };
|
||||
task('naver-register', '네이버 서치어드바이저 사이트 등록·소유 확인·사이트맵 제출 (첫 1회)', '네이버는 사이트 등록과 사이트맵·RSS 제출에 공개 API 가 없습니다. IndexNow 로 URL 은 알렸지만 등록이 돼 있어야 수집 현황을 볼 수 있습니다.',
|
||||
`1) searchadvisor.naver.com 에 ${URLBASE} 등록 2) 소유 확인: HTML 태그 방식의 content 값을 site.json naverSiteVerification 에 넣고 재빌드 3) 요청 → 사이트맵 제출 ${URLBASE}/sitemap.xml 4) 요청 → 웹 페이지 수집(하루 50건)에 홈·주요 글 URL`);
|
||||
task('naver-track', '네이버 블로그 재작성 트랙 시작', '네이버 블로그·플레이스·카페는 AI 크롤러를 막고 있어 네이버 노출은 별도 트랙입니다(설계 v0.1 §8).', '허브 발행 24시간 뒤 요약 재작성본(60% 이하 분량)에 허브 링크. 병원 명의 여부는 §7 사전심의 확인 뒤');
|
||||
task('backlinks', '병원 자산에서 서포터즈로 링크·sameAs 연결', '새 도메인은 권위가 0 이라 색인이 몇 주 걸릴 수 있습니다. 병원 홈페이지·유튜브 채널 설명·강남언니·구글 비즈니스 프로필에서 연결하면 빨라집니다.', '병원 담당자에게 링크 추가 요청. 서포터즈 JSON-LD 의 sameAs 는 이미 병원 자산을 가리킨다');
|
||||
} else {
|
||||
report.naver = { skipped: '색인 허용 빌드가 아님' };
|
||||
task('go-indexable', '색인 허용 전환 결정', '지금은 샘플(noindex) 상태입니다. 전환 전에 법무 3건(외국인환자 유치업, 의료광고 사전심의, 지원 관계 고지)과 글 게시 승인(approvedAt)이 끝나야 합니다.', `결정되면 워커를 --indexable --deploy 로 재실행. 도메인이 정해졌으면 --domain <도메인> 을 함께`);
|
||||
}
|
||||
const isVercelDefault = /^https:\/\/[a-z0-9-]+\.vercel\.app$/.test(URLBASE);
|
||||
if (isVercelDefault) {
|
||||
task('domain', '정식 도메인 연결', '지금 주소는 Vercel 기본 도메인입니다. 정식 도메인(별도 도메인 vs 병원 서브도메인, 설계 v0.1 §3)이 정해져야 색인 작업이 의미가 있습니다.', '도메인 결정 → 워커 --domain <도메인> (Vercel 프로젝트에 도메인 추가) → DNS 설정 → 재실행');
|
||||
}
|
||||
|
||||
// ---------- 5. 체크리스트 ----------
|
||||
mkdirSync(WORK, { recursive: true });
|
||||
const md = [`# 발행 체크리스트 · ${fact.shortName || clinic} · ${today}`, '', `라이브 ${URLBASE} · 홈 ${home.status} · robots ${robots.status} · sitemap ${sitemap.status} (URL ${urls.length}) · 색인 허용 ${report.indexable ? '예' : '아니오(noindex)'}`, '',
|
||||
`## 자동으로 한 것`, `- IndexNow: ${report.indexnow?.status ? `제출 ${report.indexnow.submitted}건, 응답 ${report.indexnow.status}` : (report.indexnow?.skipped || report.indexnow?.error || '없음')}`,
|
||||
`- Google Search Console: ${report.google?.sitemapSubmit ? `사이트맵 제출 ${report.google.sitemapSubmit}, 색인 확인 ${report.google.indexed ?? 0}/${report.google.inspected?.length ?? 0}` : (report.google?.skipped || report.google?.error || '없음')}`, '',
|
||||
`## 사람이 할 것 (이것까지 끝나야 완료)`, ...report.humanTasks.map((t, i) => `${i + 1}. **${t.title}**\n - 왜: ${t.why}\n - 어떻게: ${t.how}`), ''].join('\n');
|
||||
writeFileSync(join(WORK, 'publish-checklist.md'), md);
|
||||
writeFileSync(join(WORK, 'publish-report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(md);
|
||||
console.log(`__PUBLISH_JSON__${JSON.stringify({ live: report.live, indexable: report.indexable, indexnow: report.indexnow, google: report.google && { sitemapSubmit: report.google.sitemapSubmit, indexed: report.google.indexed, skipped: report.google.skipped, error: report.google.error }, humanTasks: report.humanTasks.map((t) => t.title) })}`);
|
||||
@ -6,7 +6,15 @@
|
||||
"areaLabel": "",
|
||||
"founded": "",
|
||||
"representative": "",
|
||||
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" },
|
||||
"address": {
|
||||
"full": "",
|
||||
"street": "",
|
||||
"locality": "",
|
||||
"region": "",
|
||||
"postalNote": "",
|
||||
"navigation": "",
|
||||
"source": ""
|
||||
},
|
||||
"phone": "",
|
||||
"fax": "",
|
||||
"kakao": "",
|
||||
@ -18,15 +26,28 @@
|
||||
"transitShort": "",
|
||||
"transitNote": "",
|
||||
"parking": "",
|
||||
"hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] },
|
||||
"building": { "summary": "", "floors": [], "source": "" },
|
||||
"hours": {
|
||||
"source": "",
|
||||
"sourceLabel": "",
|
||||
"rows": [],
|
||||
"schema": []
|
||||
},
|
||||
"building": {
|
||||
"summary": "",
|
||||
"floors": [],
|
||||
"source": ""
|
||||
},
|
||||
"departments": [],
|
||||
"specialties": [],
|
||||
"safety": { "items": [], "source": "" },
|
||||
"safety": {
|
||||
"items": [],
|
||||
"source": ""
|
||||
},
|
||||
"recognitions": [],
|
||||
"surfaces": {},
|
||||
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
|
||||
"businessNo": "",
|
||||
"checkedAt": "",
|
||||
"nextCheck": ""
|
||||
"nextCheck": "",
|
||||
"geo": null
|
||||
}
|
||||
|
||||
25
supporters/scripts/template/data/factSheetEn.json
Normal file
25
supporters/scripts/template/data/factSheetEn.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"_comment": "영어 페이지용 병원 사실. 값의 원본은 factSheet.json 이고 여기에는 영어 표기만 둔다. 비어 있는 값은 영어 화면에서 해당 조각을 렌더하지 않는다. 지어내지 않는다.",
|
||||
"name": "",
|
||||
"shortName": "",
|
||||
"kind": "",
|
||||
"areaLabel": "",
|
||||
"address": {
|
||||
"full": "",
|
||||
"mapQuery": ""
|
||||
},
|
||||
"transit": "",
|
||||
"airport": "",
|
||||
"airportShort": "",
|
||||
"parking": "",
|
||||
"hoursDays": {
|
||||
"월·화·수·목": "",
|
||||
"금": "",
|
||||
"토": "",
|
||||
"일·공휴일": ""
|
||||
},
|
||||
"hoursClosed": "",
|
||||
"building": "",
|
||||
"arrival": "",
|
||||
"specialties": []
|
||||
}
|
||||
36
supporters/scripts/template/data/medicalTourism.json
Normal file
36
supporters/scripts/template/data/medicalTourism.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"_comment": "주변 안내 데이터(빈 값). 워커의 tourism 단계(scripts/fetch_medical_tourism.py)가 팩트 시트 주소로 채운다. origin lat 0 이면 /stay·/en/stay 는 '확인 대기'로 그린다.",
|
||||
"places": {
|
||||
"stay": [],
|
||||
"restaurant": [],
|
||||
"wellness": [],
|
||||
"attraction": [],
|
||||
"culture": [],
|
||||
"shopping": []
|
||||
},
|
||||
"festivals": [],
|
||||
"filters": {
|
||||
"total": 0,
|
||||
"walk5": 0,
|
||||
"walk15": 0,
|
||||
"walkAll": 0,
|
||||
"drive": 0
|
||||
},
|
||||
"meta": {
|
||||
"source": "",
|
||||
"fetchedAt": "",
|
||||
"origin": {
|
||||
"lat": 0,
|
||||
"lng": 0,
|
||||
"label": "",
|
||||
"address": "",
|
||||
"coordSource": ""
|
||||
},
|
||||
"radiusByType": {},
|
||||
"festivalRadiusKm": 0,
|
||||
"counts": {},
|
||||
"limits": [],
|
||||
"lang": "en",
|
||||
"clinic": ""
|
||||
}
|
||||
}
|
||||
36
supporters/scripts/template/data/medicalTourismKo.json
Normal file
36
supporters/scripts/template/data/medicalTourismKo.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"_comment": "주변 안내 데이터(빈 값). 워커의 tourism 단계(scripts/fetch_medical_tourism.py)가 팩트 시트 주소로 채운다. origin lat 0 이면 /stay·/en/stay 는 '확인 대기'로 그린다.",
|
||||
"places": {
|
||||
"stay": [],
|
||||
"restaurant": [],
|
||||
"wellness": [],
|
||||
"attraction": [],
|
||||
"culture": [],
|
||||
"shopping": []
|
||||
},
|
||||
"festivals": [],
|
||||
"filters": {
|
||||
"total": 0,
|
||||
"walk5": 0,
|
||||
"walk15": 0,
|
||||
"walkAll": 0,
|
||||
"drive": 0
|
||||
},
|
||||
"meta": {
|
||||
"lang": "ko",
|
||||
"source": "",
|
||||
"fetchedAt": "",
|
||||
"origin": {
|
||||
"lat": 0,
|
||||
"lng": 0,
|
||||
"label": "",
|
||||
"address": "",
|
||||
"coordSource": ""
|
||||
},
|
||||
"radiusByType": {},
|
||||
"festivalRadiusKm": 0,
|
||||
"counts": {},
|
||||
"limits": [],
|
||||
"clinic": ""
|
||||
}
|
||||
}
|
||||
207
supporters/scripts/template/data/places.json
Normal file
207
supporters/scripts/template/data/places.json
Normal file
@ -0,0 +1,207 @@
|
||||
{
|
||||
"_comment": "범용 큐레이션 기본표. 워커 planner 단계가 {CLINIC_KO}/{CLINIC_EN}/{AREA_KO}/{AREA_EN} 치환자를 팩트 시트 값으로 바꾸고 origin 을 관광공사 수집 기준 좌표로 채운다. 병원별 큐레이션(briefs/<clinic>/planner.places.json)이 있으면 이 표 대신 그것을 쓴다. 숙소는 관광공사 데이터가 맡는다.",
|
||||
"origin": {
|
||||
"label": {
|
||||
"ko": "{CLINIC_KO}",
|
||||
"en": "{CLINIC_EN}"
|
||||
},
|
||||
"lat": 0,
|
||||
"lng": 0
|
||||
},
|
||||
"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": "{AREA_KO} 주변 약국",
|
||||
"en": "Pharmacies near {AREA_EN}"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.2,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "처방약과 냉찜질 팩을 구할 수 있습니다.",
|
||||
"en": "Prescriptions and cold packs."
|
||||
},
|
||||
"mapQuery": "약국 {AREA_KO}",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "convenience",
|
||||
"name": {
|
||||
"ko": "편의점 ({AREA_KO} 일대)",
|
||||
"en": "Convenience stores ({AREA_EN})"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.1,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "생수, 죽, 빨대, 얼음을 24시간 살 수 있습니다.",
|
||||
"en": "Water, porridge, straws and ice, 24 hours."
|
||||
},
|
||||
"mapQuery": "편의점 {AREA_KO}",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "porridge",
|
||||
"name": {
|
||||
"ko": "죽 전문점 (본죽 등 체인)",
|
||||
"en": "Korean porridge (Bonjuk and similar chains)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "유동식·부드러운 식사 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.",
|
||||
"en": "Porridge for the liquid and soft-food stages, by delivery or takeout."
|
||||
},
|
||||
"mapQuery": "본죽 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "smoothie",
|
||||
"name": {
|
||||
"ko": "스무디·주스 카페 ({AREA_KO})",
|
||||
"en": "Smoothie and juice cafes ({AREA_EN})"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.",
|
||||
"en": "Straw-friendly options. Choose nothing hot."
|
||||
},
|
||||
"mapQuery": "스무디 카페 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "soup",
|
||||
"name": {
|
||||
"ko": "설렁탕·곰탕 ({AREA_KO} 일대)",
|
||||
"en": "Beef bone soup ({AREA_EN})"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "국물 위주라 부드러운 음식 단계부터 가능합니다.",
|
||||
"en": "Broth-based, fine from the soft-food stage."
|
||||
},
|
||||
"mapQuery": "설렁탕 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clinic-followup",
|
||||
"name": {
|
||||
"ko": "{CLINIC_KO} 경과 확인",
|
||||
"en": "{CLINIC_EN} follow-up"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.05,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "경과 확인과 관리는 병원 안내에 따라 예약합니다.",
|
||||
"en": "Follow-up checks and care, booked per the clinic's guidance."
|
||||
},
|
||||
"mapQuery": "{CLINIC_KO}",
|
||||
"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": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.",
|
||||
"en": "Skip sauna and massage until your doctor allows. Lounge and room service are fine."
|
||||
},
|
||||
"mapQuery": null,
|
||||
"verify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
961
supporters/scripts/template/data/procedures.json
Normal file
961
supporters/scripts/template/data/procedures.json
Normal file
@ -0,0 +1,961 @@
|
||||
{
|
||||
"_comment": "성형외과 기본 규칙표 (뷰성형외과 샘플에서 정리). 워커 planner 단계(scripts/build_planner_data.mjs)가 병원 시술 페이지 원문(recoveryNotes.json)을 matchKeywords 로 시술에 붙여 병원별 procedures.json 을 만든다. clinic 출처가 있는 값은 뷰성형외과 공개 안내이므로 다른 병원에서는 provisional 로 내려간다.",
|
||||
"clinicSources": [],
|
||||
"levels": [
|
||||
{
|
||||
"level": 0,
|
||||
"label": {
|
||||
"ko": "실내 안정",
|
||||
"en": "Rest indoors"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "병실이나 호텔에서 쉬는 날입니다. 필요한 것은 배달과 룸서비스로 해결합니다.",
|
||||
"en": "Rest at the hospital or hotel. Use delivery and room service."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 1,
|
||||
"label": {
|
||||
"ko": "근거리 외출",
|
||||
"en": "Short walks"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "호텔 반경 500m 안에서 짧게 다녀옵니다. 도보 10분 이내.",
|
||||
"en": "Short trips within 500 m of your hotel. Up to a 10-minute walk."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"label": {
|
||||
"ko": "반나절 외출",
|
||||
"en": "Half-day outing"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "택시로 반경 3km 안 쇼핑·식사·가벼운 관광이 가능합니다.",
|
||||
"en": "Shopping, dining and light sightseeing within 3 km by taxi."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"label": {
|
||||
"ko": "자유 외출",
|
||||
"en": "Full day out"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "하루 단위 외출이 가능합니다. 시내 관광, 행사, 인파가 있는 곳도 담당 원장 안내 안에서 가능합니다.",
|
||||
"en": "Full-day outings. City sightseeing and events, within your surgeon's guidance."
|
||||
}
|
||||
}
|
||||
],
|
||||
"diets": {
|
||||
"liquid": {
|
||||
"label": {
|
||||
"ko": "유동식",
|
||||
"en": "Liquid diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기 없는 유동식만 드십니다.",
|
||||
"en": "Liquids only, no solids."
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"label": {
|
||||
"ko": "부드러운 음식",
|
||||
"en": "Soft foods"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기가 있어도 부드러운 음식까지 가능합니다.",
|
||||
"en": "Soft foods are fine. Avoid anything chewy or hard."
|
||||
}
|
||||
},
|
||||
"mild": {
|
||||
"label": {
|
||||
"ko": "자극 없는 일반식",
|
||||
"en": "Regular, non-spicy"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "맵고 짜거나 딱딱한 음식은 피합니다.",
|
||||
"en": "Regular meals. Avoid spicy, salty or hard foods."
|
||||
}
|
||||
},
|
||||
"normal": {
|
||||
"label": {
|
||||
"ko": "일반식",
|
||||
"en": "Regular diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "식사 제한이 없습니다.",
|
||||
"en": "No dietary restrictions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"no-heavy-lifting": {
|
||||
"ko": "무거운 짐과 팔을 많이 쓰는 동작을 피합니다. 쇼핑은 배송 서비스를 이용하세요.",
|
||||
"en": "Avoid heavy bags and arm-heavy movement. Use delivery for shopping."
|
||||
},
|
||||
"sports-bra": {
|
||||
"ko": "스포츠브라를 1~3개월 유지합니다.",
|
||||
"en": "Wear a sports bra for 1 to 3 months."
|
||||
},
|
||||
"no-alcohol-30d": {
|
||||
"ko": "음주와 흡연은 최소 1개월 이후입니다.",
|
||||
"en": "No alcohol or smoking for at least 1 month."
|
||||
},
|
||||
"no-sauna-60d": {
|
||||
"ko": "사우나, 찜질방, 수영, 과한 운동은 약 2개월 후입니다.",
|
||||
"en": "No sauna, spa, swimming or intense exercise for about 2 months."
|
||||
},
|
||||
"compression-band": {
|
||||
"ko": "압박밴드를 2~4주 착용합니다. 취침 시에는 하지 않습니다.",
|
||||
"en": "Wear the compression band for 2 to 4 weeks, not while sleeping."
|
||||
},
|
||||
"sleep-elevated": {
|
||||
"ko": "머리를 심장보다 높게 하고 정면 자세로 잡니다. 1주 후부터 옆으로 누워도 됩니다.",
|
||||
"en": "Sleep with your head above heart level, facing up. Side sleeping from week 2."
|
||||
},
|
||||
"saline-gargle": {
|
||||
"ko": "약 1개월간 생리식염수 가글을 꾸준히 합니다.",
|
||||
"en": "Rinse with saline regularly for about 1 month."
|
||||
},
|
||||
"no-hard-food-30d": {
|
||||
"ko": "질기고 딱딱한 음식은 한 달간 피합니다.",
|
||||
"en": "Avoid chewy or hard food for 1 month."
|
||||
},
|
||||
"no-sun": {
|
||||
"ko": "직사광선을 피합니다. 모자와 선글라스를 챙기세요.",
|
||||
"en": "Avoid direct sun. Bring a hat and sunglasses."
|
||||
},
|
||||
"mask": {
|
||||
"ko": "외출 시 마스크를 쓰면 부기와 멍이 덜 보입니다.",
|
||||
"en": "A mask helps cover swelling and bruising when out."
|
||||
},
|
||||
"no-glasses": {
|
||||
"ko": "안경 착용을 피합니다. 콘택트렌즈나 안경 없이 다닐 계획을 세우세요.",
|
||||
"en": "Avoid glasses resting on your nose. Plan for contacts or going without."
|
||||
},
|
||||
"no-lens": {
|
||||
"ko": "콘택트렌즈는 담당 원장이 허용할 때까지 쓰지 않습니다.",
|
||||
"en": "No contact lenses until your surgeon allows."
|
||||
},
|
||||
"cold-compress": {
|
||||
"ko": "초기에는 냉찜질, 이후 온찜질을 합니다.",
|
||||
"en": "Cold compresses at first, then warm."
|
||||
},
|
||||
"no-nose-blow": {
|
||||
"ko": "코를 세게 풀거나 코 부위에 압력을 주지 않습니다.",
|
||||
"en": "Do not blow your nose or press on the nose area."
|
||||
},
|
||||
"no-massage": {
|
||||
"ko": "수술 부위 마사지와 강한 압박을 피합니다.",
|
||||
"en": "No massage or firm pressure on the surgical area."
|
||||
},
|
||||
"no-hat-pressure": {
|
||||
"ko": "이식 부위를 누르는 모자나 헬멧을 피합니다.",
|
||||
"en": "Avoid hats or helmets that press on the graft area."
|
||||
},
|
||||
"no-scratch": {
|
||||
"ko": "가려워도 긁지 않습니다.",
|
||||
"en": "Do not scratch, even if itchy."
|
||||
},
|
||||
"driving": {
|
||||
"ko": "수술 당일은 운전할 수 없습니다. 보호자 동반을 권합니다.",
|
||||
"en": "No driving on surgery day. A companion is recommended."
|
||||
}
|
||||
},
|
||||
"procedures": [
|
||||
{
|
||||
"id": "breast",
|
||||
"label": {
|
||||
"ko": "가슴성형",
|
||||
"en": "Breast Augmentation"
|
||||
},
|
||||
"group": {
|
||||
"ko": "가슴",
|
||||
"en": "Breast"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "검진과 상담 1일, 여유 1일",
|
||||
"en": "1 day for tests and consultation, 1 buffer day"
|
||||
}
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"range": [
|
||||
5,
|
||||
10
|
||||
],
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "실밥이 있으면 약 1주에 제거. 피부 접착제를 쓴 경우 실밥 없음",
|
||||
"en": "Stitches, if any, removed at about 1 week. No stitches if skin adhesive was used"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부기는 2~3일째 가장 심합니다",
|
||||
"en": "Swelling peaks on days 2 to 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "3일 이후 붕대를 뗍니다",
|
||||
"en": "Bandage removed after day 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"no-heavy-lifting",
|
||||
"sports-bra",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "가슴|유방|보형물|모티바"
|
||||
},
|
||||
{
|
||||
"id": "contour",
|
||||
"label": {
|
||||
"ko": "안면윤곽·양악",
|
||||
"en": "Facial Contouring / Jaw Surgery"
|
||||
},
|
||||
"group": {
|
||||
"ko": "얼굴뼈",
|
||||
"en": "Facial bone"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "3D-CT 촬영과 검진, 상담",
|
||||
"en": "3D-CT, tests and consultation"
|
||||
}
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "입안 실은 녹는 실이라 보통 한 달 후 저절로 없어집니다. 별도 제거 방문 없음",
|
||||
"en": "Intraoral stitches dissolve on their own in about a month. No removal visit"
|
||||
},
|
||||
"display": {
|
||||
"ko": "제거 방문 없음",
|
||||
"en": "No removal visit"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 14,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부기는 3~4일까지 늘다가 가라앉습니다",
|
||||
"en": "Swelling increases until day 3 to 4, then subsides"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 1,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "5~7일 후 일상생활이 가능합니다",
|
||||
"en": "Daily activities possible after day 5 to 7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 10,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 21,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "liquid",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"type": "soft",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"type": "mild",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 30,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"compression-band",
|
||||
"sleep-elevated",
|
||||
"saline-gargle",
|
||||
"no-hard-food-30d",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d",
|
||||
"no-massage"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "윤곽|양악|사각턱|광대|턱끝|무턱|주걱턱"
|
||||
},
|
||||
{
|
||||
"id": "eye",
|
||||
"label": {
|
||||
"ko": "눈성형",
|
||||
"en": "Eyelid Surgery"
|
||||
},
|
||||
"group": {
|
||||
"ko": "눈",
|
||||
"en": "Eyes"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 6,
|
||||
"range": [
|
||||
5,
|
||||
7
|
||||
],
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 10,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"cold-compress",
|
||||
"no-sun",
|
||||
"no-lens",
|
||||
"no-scratch",
|
||||
"no-alcohol-30d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "눈|쌍꺼풀|매몰|절개|트임|눈매"
|
||||
},
|
||||
{
|
||||
"id": "nose",
|
||||
"label": {
|
||||
"ko": "코성형",
|
||||
"en": "Rhinoplasty"
|
||||
},
|
||||
"group": {
|
||||
"ko": "코",
|
||||
"en": "Nose"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"range": [
|
||||
5,
|
||||
7
|
||||
],
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부목과 실밥 제거",
|
||||
"en": "Splint and stitch removal"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "mild",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"mask",
|
||||
"no-glasses",
|
||||
"no-nose-blow",
|
||||
"no-sun",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "코|비중격|콧"
|
||||
},
|
||||
{
|
||||
"id": "fat",
|
||||
"label": {
|
||||
"ko": "지방성형",
|
||||
"en": "Liposuction / Fat Grafting"
|
||||
},
|
||||
"group": {
|
||||
"ko": "지방",
|
||||
"en": "Fat"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"compression-band",
|
||||
"no-massage",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "지방|흡입|바디"
|
||||
},
|
||||
{
|
||||
"id": "lifting",
|
||||
"label": {
|
||||
"ko": "안티에이징·리프팅",
|
||||
"en": "Anti-aging / Face Lift"
|
||||
},
|
||||
"group": {
|
||||
"ko": "리프팅",
|
||||
"en": "Lifting"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "soft",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"mask",
|
||||
"no-massage",
|
||||
"no-sun",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "리프팅|거상|주름|안티에이징"
|
||||
},
|
||||
{
|
||||
"id": "skin",
|
||||
"label": {
|
||||
"ko": "피부과 시술",
|
||||
"en": "Dermatology Treatment"
|
||||
},
|
||||
"group": {
|
||||
"ko": "피부",
|
||||
"en": "Skin"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "실밥 없음",
|
||||
"en": "No stitches"
|
||||
},
|
||||
"display": {
|
||||
"ko": "제거 방문 없음",
|
||||
"en": "No removal visit"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "피부|레이저|보톡스|필러|토닝|색소"
|
||||
},
|
||||
{
|
||||
"id": "hair",
|
||||
"label": {
|
||||
"ko": "모발이식",
|
||||
"en": "Hair Transplant"
|
||||
},
|
||||
"group": {
|
||||
"ko": "모발",
|
||||
"en": "Hair"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "채취 방식에 따라 다릅니다",
|
||||
"en": "Depends on the harvesting method"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 3,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-hat-pressure",
|
||||
"no-scratch",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "모발|탈모"
|
||||
}
|
||||
],
|
||||
"nationalities": {
|
||||
"_comment": "무비자 체류 한도(일). 2026-09 기준 확인 필요. null 은 사전 비자 필요. 화면은 항상 대한민국 비자포털 링크와 함께 보여준다.",
|
||||
"portal": "https://www.visa.go.kr",
|
||||
"list": [
|
||||
{
|
||||
"code": "US",
|
||||
"label": {
|
||||
"ko": "미국",
|
||||
"en": "United States"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CA",
|
||||
"label": {
|
||||
"ko": "캐나다",
|
||||
"en": "Canada"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "GB",
|
||||
"label": {
|
||||
"ko": "영국",
|
||||
"en": "United Kingdom"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "AU",
|
||||
"label": {
|
||||
"ko": "호주",
|
||||
"en": "Australia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "JP",
|
||||
"label": {
|
||||
"ko": "일본",
|
||||
"en": "Japan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TW",
|
||||
"label": {
|
||||
"ko": "대만",
|
||||
"en": "Taiwan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "HK",
|
||||
"label": {
|
||||
"ko": "홍콩",
|
||||
"en": "Hong Kong"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "SG",
|
||||
"label": {
|
||||
"ko": "싱가포르",
|
||||
"en": "Singapore"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TH",
|
||||
"label": {
|
||||
"ko": "태국",
|
||||
"en": "Thailand"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MY",
|
||||
"label": {
|
||||
"ko": "말레이시아",
|
||||
"en": "Malaysia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "AE",
|
||||
"label": {
|
||||
"ko": "아랍에미리트",
|
||||
"en": "United Arab Emirates"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "RU",
|
||||
"label": {
|
||||
"ko": "러시아",
|
||||
"en": "Russia"
|
||||
},
|
||||
"visaFreeDays": 60,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "KZ",
|
||||
"label": {
|
||||
"ko": "카자흐스탄",
|
||||
"en": "Kazakhstan"
|
||||
},
|
||||
"visaFreeDays": 30,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CN",
|
||||
"label": {
|
||||
"ko": "중국",
|
||||
"en": "China"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "VN",
|
||||
"label": {
|
||||
"ko": "베트남",
|
||||
"en": "Vietnam"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "ID",
|
||||
"label": {
|
||||
"ko": "인도네시아",
|
||||
"en": "Indonesia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "PH",
|
||||
"label": {
|
||||
"ko": "필리핀",
|
||||
"en": "Philippines"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MN",
|
||||
"label": {
|
||||
"ko": "몽골",
|
||||
"en": "Mongolia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "OTHER",
|
||||
"label": {
|
||||
"ko": "그 외",
|
||||
"en": "Other"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
supporters/scripts/template/data/recoveryNotes.json
Normal file
8
supporters/scripts/template/data/recoveryNotes.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"_comment": "시술 후 관리 안내(빈 값). 워커의 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 채운다. items 가 비어 있으면 /stay·/en/stay 는 이 블록을 그리지 않는다.",
|
||||
"clinic": "",
|
||||
"generatedAt": "",
|
||||
"sourceNote": "병원 홈페이지 시술 페이지 원문",
|
||||
"items": [],
|
||||
"counts": {}
|
||||
}
|
||||
@ -21,5 +21,12 @@
|
||||
"visitReads": [],
|
||||
"newsNote": "",
|
||||
"newsOutletsExample": "",
|
||||
"sponsorNotice": ""
|
||||
"sponsorNotice": "",
|
||||
"ga4MeasurementId": "",
|
||||
"_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 키 이벤트 이름은 data/ai_channels.json conversion_events 와 같다.",
|
||||
"heroImageAltEn": "",
|
||||
"indexNowKey": "",
|
||||
"googleSiteVerification": "",
|
||||
"naverSiteVerification": "",
|
||||
"_comment_publish": "indexNowKey 는 워커 data 단계가 자동 생성(public/<key>.txt). googleSiteVerification·naverSiteVerification 은 각 콘솔의 HTML 태그 content 값을 사람이 넣는다(briefs/<clinic>/clinic.json site 로 전달 가능). 발행 단계는 scripts/publish_site.mjs."
|
||||
}
|
||||
|
||||
207
supporters/scripts/template/planner/places.base.json
Normal file
207
supporters/scripts/template/planner/places.base.json
Normal file
@ -0,0 +1,207 @@
|
||||
{
|
||||
"_comment": "범용 큐레이션 기본표. 워커 planner 단계가 {CLINIC_KO}/{CLINIC_EN}/{AREA_KO}/{AREA_EN} 치환자를 팩트 시트 값으로 바꾸고 origin 을 관광공사 수집 기준 좌표로 채운다. 병원별 큐레이션(briefs/<clinic>/planner.places.json)이 있으면 이 표 대신 그것을 쓴다. 숙소는 관광공사 데이터가 맡는다.",
|
||||
"origin": {
|
||||
"label": {
|
||||
"ko": "{CLINIC_KO}",
|
||||
"en": "{CLINIC_EN}"
|
||||
},
|
||||
"lat": 0,
|
||||
"lng": 0
|
||||
},
|
||||
"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": "{AREA_KO} 주변 약국",
|
||||
"en": "Pharmacies near {AREA_EN}"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.2,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "처방약과 냉찜질 팩을 구할 수 있습니다.",
|
||||
"en": "Prescriptions and cold packs."
|
||||
},
|
||||
"mapQuery": "약국 {AREA_KO}",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "convenience",
|
||||
"name": {
|
||||
"ko": "편의점 ({AREA_KO} 일대)",
|
||||
"en": "Convenience stores ({AREA_EN})"
|
||||
},
|
||||
"category": "essentials",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.1,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "생수, 죽, 빨대, 얼음을 24시간 살 수 있습니다.",
|
||||
"en": "Water, porridge, straws and ice, 24 hours."
|
||||
},
|
||||
"mapQuery": "편의점 {AREA_KO}",
|
||||
"verify": true
|
||||
},
|
||||
{
|
||||
"id": "porridge",
|
||||
"name": {
|
||||
"ko": "죽 전문점 (본죽 등 체인)",
|
||||
"en": "Korean porridge (Bonjuk and similar chains)"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.5,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "유동식·부드러운 식사 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.",
|
||||
"en": "Porridge for the liquid and soft-food stages, by delivery or takeout."
|
||||
},
|
||||
"mapQuery": "본죽 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "smoothie",
|
||||
"name": {
|
||||
"ko": "스무디·주스 카페 ({AREA_KO})",
|
||||
"en": "Smoothie and juice cafes ({AREA_EN})"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.4,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.",
|
||||
"en": "Straw-friendly options. Choose nothing hot."
|
||||
},
|
||||
"mapQuery": "스무디 카페 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"liquid",
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "soup",
|
||||
"name": {
|
||||
"ko": "설렁탕·곰탕 ({AREA_KO} 일대)",
|
||||
"en": "Beef bone soup ({AREA_EN})"
|
||||
},
|
||||
"category": "food",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.6,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "국물 위주라 부드러운 음식 단계부터 가능합니다.",
|
||||
"en": "Broth-based, fine from the soft-food stage."
|
||||
},
|
||||
"mapQuery": "설렁탕 {AREA_KO}",
|
||||
"verify": true,
|
||||
"dietOk": [
|
||||
"soft",
|
||||
"mild",
|
||||
"normal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clinic-followup",
|
||||
"name": {
|
||||
"ko": "{CLINIC_KO} 경과 확인",
|
||||
"en": "{CLINIC_EN} follow-up"
|
||||
},
|
||||
"category": "care",
|
||||
"minLevel": 1,
|
||||
"distanceKm": 0.05,
|
||||
"indoor": true,
|
||||
"area": {
|
||||
"ko": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "경과 확인과 관리는 병원 안내에 따라 예약합니다.",
|
||||
"en": "Follow-up checks and care, booked per the clinic's guidance."
|
||||
},
|
||||
"mapQuery": "{CLINIC_KO}",
|
||||
"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": "{AREA_KO}",
|
||||
"en": "{AREA_EN}"
|
||||
},
|
||||
"note": {
|
||||
"ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.",
|
||||
"en": "Skip sauna and massage until your doctor allows. Lounge and room service are fine."
|
||||
},
|
||||
"mapQuery": null,
|
||||
"verify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
842
supporters/scripts/template/planner/procedures.derm.json
Normal file
842
supporters/scripts/template/planner/procedures.derm.json
Normal file
@ -0,0 +1,842 @@
|
||||
{
|
||||
"_comment": "피부과 기본 규칙표 (오라클피부과 샘플에서 정리). 워커 planner 단계가 병원 시술 페이지 원문(recoveryNotes.json)을 matchKeywords 로 시술에 붙여 병원별 procedures.json 을 만든다. 값은 전부 provisional(병원 확인 전).",
|
||||
"clinicSources": [],
|
||||
"levels": [
|
||||
{
|
||||
"level": 0,
|
||||
"label": {
|
||||
"ko": "실내 안정",
|
||||
"en": "Rest indoors"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "병실이나 호텔에서 쉬는 날입니다. 필요한 것은 배달과 룸서비스로 해결합니다.",
|
||||
"en": "Rest at the hospital or hotel. Use delivery and room service."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 1,
|
||||
"label": {
|
||||
"ko": "근거리 외출",
|
||||
"en": "Short walks"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "호텔 반경 500m 안에서 짧게 다녀옵니다. 도보 10분 이내.",
|
||||
"en": "Short trips within 500 m of your hotel. Up to a 10-minute walk."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"label": {
|
||||
"ko": "반나절 외출",
|
||||
"en": "Half-day outing"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "택시로 반경 3km 안 쇼핑·식사·가벼운 관광이 가능합니다.",
|
||||
"en": "Shopping, dining and light sightseeing within 3 km by taxi."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"label": {
|
||||
"ko": "자유 외출",
|
||||
"en": "Full day out"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "하루 단위 외출이 가능합니다. 시내 관광, 행사, 인파가 있는 곳도 담당 원장 안내 안에서 가능합니다.",
|
||||
"en": "Full-day outings. City sightseeing and events, within your surgeon's guidance."
|
||||
}
|
||||
}
|
||||
],
|
||||
"diets": {
|
||||
"liquid": {
|
||||
"label": {
|
||||
"ko": "유동식",
|
||||
"en": "Liquid diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기 없는 유동식만 드십니다.",
|
||||
"en": "Liquids only, no solids."
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"label": {
|
||||
"ko": "부드러운 음식",
|
||||
"en": "Soft foods"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기가 있어도 부드러운 음식까지 가능합니다.",
|
||||
"en": "Soft foods are fine. Avoid anything chewy or hard."
|
||||
}
|
||||
},
|
||||
"mild": {
|
||||
"label": {
|
||||
"ko": "자극 없는 일반식",
|
||||
"en": "Regular, non-spicy"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "맵고 짜거나 딱딱한 음식은 피합니다.",
|
||||
"en": "Regular meals. Avoid spicy, salty or hard foods."
|
||||
}
|
||||
},
|
||||
"normal": {
|
||||
"label": {
|
||||
"ko": "일반식",
|
||||
"en": "Regular diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "식사 제한이 없습니다.",
|
||||
"en": "No dietary restrictions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"no-sun": {
|
||||
"ko": "직사광선을 피하고 자외선차단제를 바릅니다. 모자와 선글라스를 챙기세요.",
|
||||
"en": "Avoid direct sun and use sunscreen. Bring a hat and sunglasses."
|
||||
},
|
||||
"no-makeup-1d": {
|
||||
"ko": "화장은 담당 원장 안내에 따라 다음 날부터 합니다.",
|
||||
"en": "Makeup from the next day, per your doctor's guidance."
|
||||
},
|
||||
"no-makeup-3d": {
|
||||
"ko": "화장은 딱지가 자리 잡을 때까지 며칠 미룹니다. 시점은 담당 원장에게 확인하세요.",
|
||||
"en": "Hold off makeup for a few days until scabs settle. Confirm timing with your doctor."
|
||||
},
|
||||
"no-massage-1d": {
|
||||
"ko": "시술 부위를 문지르거나 누르지 않습니다.",
|
||||
"en": "Do not rub or press the treated area."
|
||||
},
|
||||
"no-massage-2w": {
|
||||
"ko": "시술 부위 마사지와 강한 압박은 약 2주 피합니다.",
|
||||
"en": "Avoid massage or firm pressure on the treated area for about 2 weeks."
|
||||
},
|
||||
"no-massage-4w": {
|
||||
"ko": "얼굴 마사지와 강한 압박은 약 4주 피합니다.",
|
||||
"en": "Avoid facial massage or firm pressure for about 4 weeks."
|
||||
},
|
||||
"no-lie-4h": {
|
||||
"ko": "시술 후 몇 시간은 눕지 않고 고개를 세웁니다.",
|
||||
"en": "Stay upright for a few hours after the procedure."
|
||||
},
|
||||
"no-sauna-3d": {
|
||||
"ko": "사우나, 찜질방, 격한 운동은 약 3일 피합니다.",
|
||||
"en": "No sauna, spa or intense exercise for about 3 days."
|
||||
},
|
||||
"no-sauna-7d": {
|
||||
"ko": "사우나, 찜질방, 수영은 약 1주 피합니다.",
|
||||
"en": "No sauna, spa or swimming for about 1 week."
|
||||
},
|
||||
"no-sauna-2w": {
|
||||
"ko": "사우나, 찜질방, 격한 운동은 약 2주 피합니다.",
|
||||
"en": "No sauna, spa or intense exercise for about 2 weeks."
|
||||
},
|
||||
"no-scrub": {
|
||||
"ko": "각질 제거와 스크럽은 담당 원장이 허용할 때까지 하지 않습니다.",
|
||||
"en": "No exfoliation or scrubs until your doctor allows."
|
||||
},
|
||||
"no-scratch": {
|
||||
"ko": "가려워도 긁지 않고 딱지를 떼지 않습니다.",
|
||||
"en": "Do not scratch or pick at scabs."
|
||||
},
|
||||
"no-wide-mouth": {
|
||||
"ko": "입을 크게 벌리는 동작과 딱딱한 음식은 약 2주 피합니다.",
|
||||
"en": "Avoid opening the mouth wide and hard foods for about 2 weeks."
|
||||
},
|
||||
"sleep-elevated": {
|
||||
"ko": "머리를 심장보다 높게 하고 정면 자세로 잡니다.",
|
||||
"en": "Sleep with your head above heart level, facing up."
|
||||
}
|
||||
},
|
||||
"procedures": [
|
||||
{
|
||||
"id": "skinbooster",
|
||||
"label": {
|
||||
"ko": "물광주사·스킨부스터",
|
||||
"en": "Skin Booster Injection"
|
||||
},
|
||||
"group": {
|
||||
"ko": "주사",
|
||||
"en": "Injection"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "실밥 없음",
|
||||
"en": "No stitches"
|
||||
},
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 3,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 4,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-makeup-1d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "물광|스킨부스터|리쥬란|쥬베룩|부스터"
|
||||
},
|
||||
{
|
||||
"id": "filler",
|
||||
"label": {
|
||||
"ko": "필러",
|
||||
"en": "Filler"
|
||||
},
|
||||
"group": {
|
||||
"ko": "주사",
|
||||
"en": "Injection"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 3,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-massage-2w",
|
||||
"no-sauna-3d",
|
||||
"no-sun"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "필러"
|
||||
},
|
||||
{
|
||||
"id": "botox",
|
||||
"label": {
|
||||
"ko": "보톡스",
|
||||
"en": "Botulinum Toxin"
|
||||
},
|
||||
"group": {
|
||||
"ko": "주사",
|
||||
"en": "Injection"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-lie-4h",
|
||||
"no-massage-1d",
|
||||
"no-sauna-3d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "보톡스|톡신"
|
||||
},
|
||||
{
|
||||
"id": "toning",
|
||||
"label": {
|
||||
"ko": "레이저 토닝·색소",
|
||||
"en": "Laser Toning / Pigment"
|
||||
},
|
||||
"group": {
|
||||
"ko": "레이저",
|
||||
"en": "Laser"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-scrub",
|
||||
"no-sauna-3d",
|
||||
"no-makeup-1d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "토닝|색소|기미|잡티|레이저"
|
||||
},
|
||||
{
|
||||
"id": "lifting",
|
||||
"label": {
|
||||
"ko": "에너지 리프팅(고주파·초음파)",
|
||||
"en": "Energy Lifting (RF / Ultrasound)"
|
||||
},
|
||||
"group": {
|
||||
"ko": "리프팅",
|
||||
"en": "Lifting"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sauna-3d",
|
||||
"no-sun",
|
||||
"no-massage-2w"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "리프팅|울쎄라|써마지|인모드|슈링크|고주파|초음파|탄력"
|
||||
},
|
||||
{
|
||||
"id": "thread",
|
||||
"label": {
|
||||
"ko": "실리프팅",
|
||||
"en": "Thread Lift"
|
||||
},
|
||||
"group": {
|
||||
"ko": "리프팅",
|
||||
"en": "Lifting"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "제거 방문 없음",
|
||||
"en": "No removal visit"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 10,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "soft",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-wide-mouth",
|
||||
"no-massage-4w",
|
||||
"sleep-elevated",
|
||||
"no-sauna-2w"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "실리프팅|실 리프팅|민트"
|
||||
},
|
||||
{
|
||||
"id": "acne",
|
||||
"label": {
|
||||
"ko": "여드름·흉터 레이저",
|
||||
"en": "Acne / Scar Laser"
|
||||
},
|
||||
"group": {
|
||||
"ko": "레이저",
|
||||
"en": "Laser"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 5,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-makeup-3d",
|
||||
"no-scratch",
|
||||
"no-sauna-7d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "여드름|흉터|프락셀|모공"
|
||||
},
|
||||
{
|
||||
"id": "body",
|
||||
"label": {
|
||||
"ko": "비만·바디 시술",
|
||||
"en": "Body Contouring"
|
||||
},
|
||||
"group": {
|
||||
"ko": "바디",
|
||||
"en": "Body"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"display": {
|
||||
"ko": "해당 없음",
|
||||
"en": "Not applicable"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sauna-3d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "비만|바디|지방분해|파워쉐이프|쿨스컬프팅|체형"
|
||||
}
|
||||
],
|
||||
"nationalities": {
|
||||
"_comment": "무비자 체류 한도(일). 2026-09 기준 확인 필요. null 은 사전 비자 필요. 화면은 항상 대한민국 비자포털 링크와 함께 보여준다.",
|
||||
"portal": "https://www.visa.go.kr",
|
||||
"list": [
|
||||
{
|
||||
"code": "US",
|
||||
"label": {
|
||||
"ko": "미국",
|
||||
"en": "United States"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CA",
|
||||
"label": {
|
||||
"ko": "캐나다",
|
||||
"en": "Canada"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "GB",
|
||||
"label": {
|
||||
"ko": "영국",
|
||||
"en": "United Kingdom"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "AU",
|
||||
"label": {
|
||||
"ko": "호주",
|
||||
"en": "Australia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "JP",
|
||||
"label": {
|
||||
"ko": "일본",
|
||||
"en": "Japan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TW",
|
||||
"label": {
|
||||
"ko": "대만",
|
||||
"en": "Taiwan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "HK",
|
||||
"label": {
|
||||
"ko": "홍콩",
|
||||
"en": "Hong Kong"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "SG",
|
||||
"label": {
|
||||
"ko": "싱가포르",
|
||||
"en": "Singapore"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TH",
|
||||
"label": {
|
||||
"ko": "태국",
|
||||
"en": "Thailand"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MY",
|
||||
"label": {
|
||||
"ko": "말레이시아",
|
||||
"en": "Malaysia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "AE",
|
||||
"label": {
|
||||
"ko": "아랍에미리트",
|
||||
"en": "United Arab Emirates"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "RU",
|
||||
"label": {
|
||||
"ko": "러시아",
|
||||
"en": "Russia"
|
||||
},
|
||||
"visaFreeDays": 60,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "KZ",
|
||||
"label": {
|
||||
"ko": "카자흐스탄",
|
||||
"en": "Kazakhstan"
|
||||
},
|
||||
"visaFreeDays": 30,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CN",
|
||||
"label": {
|
||||
"ko": "중국",
|
||||
"en": "China"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "VN",
|
||||
"label": {
|
||||
"ko": "베트남",
|
||||
"en": "Vietnam"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "ID",
|
||||
"label": {
|
||||
"ko": "인도네시아",
|
||||
"en": "Indonesia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "PH",
|
||||
"label": {
|
||||
"ko": "필리핀",
|
||||
"en": "Philippines"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MN",
|
||||
"label": {
|
||||
"ko": "몽골",
|
||||
"en": "Mongolia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "OTHER",
|
||||
"label": {
|
||||
"ko": "그 외",
|
||||
"en": "Other"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
961
supporters/scripts/template/planner/procedures.plastic.json
Normal file
961
supporters/scripts/template/planner/procedures.plastic.json
Normal file
@ -0,0 +1,961 @@
|
||||
{
|
||||
"_comment": "성형외과 기본 규칙표 (뷰성형외과 샘플에서 정리). 워커 planner 단계(scripts/build_planner_data.mjs)가 병원 시술 페이지 원문(recoveryNotes.json)을 matchKeywords 로 시술에 붙여 병원별 procedures.json 을 만든다. clinic 출처가 있는 값은 뷰성형외과 공개 안내이므로 다른 병원에서는 provisional 로 내려간다.",
|
||||
"clinicSources": [],
|
||||
"levels": [
|
||||
{
|
||||
"level": 0,
|
||||
"label": {
|
||||
"ko": "실내 안정",
|
||||
"en": "Rest indoors"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "병실이나 호텔에서 쉬는 날입니다. 필요한 것은 배달과 룸서비스로 해결합니다.",
|
||||
"en": "Rest at the hospital or hotel. Use delivery and room service."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 1,
|
||||
"label": {
|
||||
"ko": "근거리 외출",
|
||||
"en": "Short walks"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "호텔 반경 500m 안에서 짧게 다녀옵니다. 도보 10분 이내.",
|
||||
"en": "Short trips within 500 m of your hotel. Up to a 10-minute walk."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"label": {
|
||||
"ko": "반나절 외출",
|
||||
"en": "Half-day outing"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "택시로 반경 3km 안 쇼핑·식사·가벼운 관광이 가능합니다.",
|
||||
"en": "Shopping, dining and light sightseeing within 3 km by taxi."
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"label": {
|
||||
"ko": "자유 외출",
|
||||
"en": "Full day out"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "하루 단위 외출이 가능합니다. 시내 관광, 행사, 인파가 있는 곳도 담당 원장 안내 안에서 가능합니다.",
|
||||
"en": "Full-day outings. City sightseeing and events, within your surgeon's guidance."
|
||||
}
|
||||
}
|
||||
],
|
||||
"diets": {
|
||||
"liquid": {
|
||||
"label": {
|
||||
"ko": "유동식",
|
||||
"en": "Liquid diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기 없는 유동식만 드십니다.",
|
||||
"en": "Liquids only, no solids."
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"label": {
|
||||
"ko": "부드러운 음식",
|
||||
"en": "Soft foods"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "건더기가 있어도 부드러운 음식까지 가능합니다.",
|
||||
"en": "Soft foods are fine. Avoid anything chewy or hard."
|
||||
}
|
||||
},
|
||||
"mild": {
|
||||
"label": {
|
||||
"ko": "자극 없는 일반식",
|
||||
"en": "Regular, non-spicy"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "맵고 짜거나 딱딱한 음식은 피합니다.",
|
||||
"en": "Regular meals. Avoid spicy, salty or hard foods."
|
||||
}
|
||||
},
|
||||
"normal": {
|
||||
"label": {
|
||||
"ko": "일반식",
|
||||
"en": "Regular diet"
|
||||
},
|
||||
"desc": {
|
||||
"ko": "식사 제한이 없습니다.",
|
||||
"en": "No dietary restrictions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"no-heavy-lifting": {
|
||||
"ko": "무거운 짐과 팔을 많이 쓰는 동작을 피합니다. 쇼핑은 배송 서비스를 이용하세요.",
|
||||
"en": "Avoid heavy bags and arm-heavy movement. Use delivery for shopping."
|
||||
},
|
||||
"sports-bra": {
|
||||
"ko": "스포츠브라를 1~3개월 유지합니다.",
|
||||
"en": "Wear a sports bra for 1 to 3 months."
|
||||
},
|
||||
"no-alcohol-30d": {
|
||||
"ko": "음주와 흡연은 최소 1개월 이후입니다.",
|
||||
"en": "No alcohol or smoking for at least 1 month."
|
||||
},
|
||||
"no-sauna-60d": {
|
||||
"ko": "사우나, 찜질방, 수영, 과한 운동은 약 2개월 후입니다.",
|
||||
"en": "No sauna, spa, swimming or intense exercise for about 2 months."
|
||||
},
|
||||
"compression-band": {
|
||||
"ko": "압박밴드를 2~4주 착용합니다. 취침 시에는 하지 않습니다.",
|
||||
"en": "Wear the compression band for 2 to 4 weeks, not while sleeping."
|
||||
},
|
||||
"sleep-elevated": {
|
||||
"ko": "머리를 심장보다 높게 하고 정면 자세로 잡니다. 1주 후부터 옆으로 누워도 됩니다.",
|
||||
"en": "Sleep with your head above heart level, facing up. Side sleeping from week 2."
|
||||
},
|
||||
"saline-gargle": {
|
||||
"ko": "약 1개월간 생리식염수 가글을 꾸준히 합니다.",
|
||||
"en": "Rinse with saline regularly for about 1 month."
|
||||
},
|
||||
"no-hard-food-30d": {
|
||||
"ko": "질기고 딱딱한 음식은 한 달간 피합니다.",
|
||||
"en": "Avoid chewy or hard food for 1 month."
|
||||
},
|
||||
"no-sun": {
|
||||
"ko": "직사광선을 피합니다. 모자와 선글라스를 챙기세요.",
|
||||
"en": "Avoid direct sun. Bring a hat and sunglasses."
|
||||
},
|
||||
"mask": {
|
||||
"ko": "외출 시 마스크를 쓰면 부기와 멍이 덜 보입니다.",
|
||||
"en": "A mask helps cover swelling and bruising when out."
|
||||
},
|
||||
"no-glasses": {
|
||||
"ko": "안경 착용을 피합니다. 콘택트렌즈나 안경 없이 다닐 계획을 세우세요.",
|
||||
"en": "Avoid glasses resting on your nose. Plan for contacts or going without."
|
||||
},
|
||||
"no-lens": {
|
||||
"ko": "콘택트렌즈는 담당 원장이 허용할 때까지 쓰지 않습니다.",
|
||||
"en": "No contact lenses until your surgeon allows."
|
||||
},
|
||||
"cold-compress": {
|
||||
"ko": "초기에는 냉찜질, 이후 온찜질을 합니다.",
|
||||
"en": "Cold compresses at first, then warm."
|
||||
},
|
||||
"no-nose-blow": {
|
||||
"ko": "코를 세게 풀거나 코 부위에 압력을 주지 않습니다.",
|
||||
"en": "Do not blow your nose or press on the nose area."
|
||||
},
|
||||
"no-massage": {
|
||||
"ko": "수술 부위 마사지와 강한 압박을 피합니다.",
|
||||
"en": "No massage or firm pressure on the surgical area."
|
||||
},
|
||||
"no-hat-pressure": {
|
||||
"ko": "이식 부위를 누르는 모자나 헬멧을 피합니다.",
|
||||
"en": "Avoid hats or helmets that press on the graft area."
|
||||
},
|
||||
"no-scratch": {
|
||||
"ko": "가려워도 긁지 않습니다.",
|
||||
"en": "Do not scratch, even if itchy."
|
||||
},
|
||||
"driving": {
|
||||
"ko": "수술 당일은 운전할 수 없습니다. 보호자 동반을 권합니다.",
|
||||
"en": "No driving on surgery day. A companion is recommended."
|
||||
}
|
||||
},
|
||||
"procedures": [
|
||||
{
|
||||
"id": "breast",
|
||||
"label": {
|
||||
"ko": "가슴성형",
|
||||
"en": "Breast Augmentation"
|
||||
},
|
||||
"group": {
|
||||
"ko": "가슴",
|
||||
"en": "Breast"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "검진과 상담 1일, 여유 1일",
|
||||
"en": "1 day for tests and consultation, 1 buffer day"
|
||||
}
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"range": [
|
||||
5,
|
||||
10
|
||||
],
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "실밥이 있으면 약 1주에 제거. 피부 접착제를 쓴 경우 실밥 없음",
|
||||
"en": "Stitches, if any, removed at about 1 week. No stitches if skin adhesive was used"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부기는 2~3일째 가장 심합니다",
|
||||
"en": "Swelling peaks on days 2 to 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "3일 이후 붕대를 뗍니다",
|
||||
"en": "Bandage removed after day 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"no-heavy-lifting",
|
||||
"sports-bra",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "가슴|유방|보형물|모티바"
|
||||
},
|
||||
{
|
||||
"id": "contour",
|
||||
"label": {
|
||||
"ko": "안면윤곽·양악",
|
||||
"en": "Facial Contouring / Jaw Surgery"
|
||||
},
|
||||
"group": {
|
||||
"ko": "얼굴뼈",
|
||||
"en": "Facial bone"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "3D-CT 촬영과 검진, 상담",
|
||||
"en": "3D-CT, tests and consultation"
|
||||
}
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "입안 실은 녹는 실이라 보통 한 달 후 저절로 없어집니다. 별도 제거 방문 없음",
|
||||
"en": "Intraoral stitches dissolve on their own in about a month. No removal visit"
|
||||
},
|
||||
"display": {
|
||||
"ko": "제거 방문 없음",
|
||||
"en": "No removal visit"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 14,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부기는 3~4일까지 늘다가 가라앉습니다",
|
||||
"en": "Swelling increases until day 3 to 4, then subsides"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 1,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "5~7일 후 일상생활이 가능합니다",
|
||||
"en": "Daily activities possible after day 5 to 7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fromDay": 10,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 21,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "liquid",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"type": "soft",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"type": "mild",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 30,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"compression-band",
|
||||
"sleep-elevated",
|
||||
"saline-gargle",
|
||||
"no-hard-food-30d",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d",
|
||||
"no-massage"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "윤곽|양악|사각턱|광대|턱끝|무턱|주걱턱"
|
||||
},
|
||||
{
|
||||
"id": "eye",
|
||||
"label": {
|
||||
"ko": "눈성형",
|
||||
"en": "Eyelid Surgery"
|
||||
},
|
||||
"group": {
|
||||
"ko": "눈",
|
||||
"en": "Eyes"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 6,
|
||||
"range": [
|
||||
5,
|
||||
7
|
||||
],
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 10,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"cold-compress",
|
||||
"no-sun",
|
||||
"no-lens",
|
||||
"no-scratch",
|
||||
"no-alcohol-30d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "눈|쌍꺼풀|매몰|절개|트임|눈매"
|
||||
},
|
||||
{
|
||||
"id": "nose",
|
||||
"label": {
|
||||
"ko": "코성형",
|
||||
"en": "Rhinoplasty"
|
||||
},
|
||||
"group": {
|
||||
"ko": "코",
|
||||
"en": "Nose"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"range": [
|
||||
5,
|
||||
7
|
||||
],
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "부목과 실밥 제거",
|
||||
"en": "Splint and stitch removal"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "mild",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"mask",
|
||||
"no-glasses",
|
||||
"no-nose-blow",
|
||||
"no-sun",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "코|비중격|콧"
|
||||
},
|
||||
{
|
||||
"id": "fat",
|
||||
"label": {
|
||||
"ko": "지방성형",
|
||||
"en": "Liposuction / Fat Grafting"
|
||||
},
|
||||
"group": {
|
||||
"ko": "지방",
|
||||
"en": "Fat"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 2,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 5,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"compression-band",
|
||||
"no-massage",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "지방|흡입|바디"
|
||||
},
|
||||
{
|
||||
"id": "lifting",
|
||||
"label": {
|
||||
"ko": "안티에이징·리프팅",
|
||||
"en": "Anti-aging / Face Lift"
|
||||
},
|
||||
"group": {
|
||||
"ko": "리프팅",
|
||||
"en": "Lifting"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": 7,
|
||||
"status": "provisional"
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 10,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 14,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "soft",
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"driving",
|
||||
"mask",
|
||||
"no-massage",
|
||||
"no-sun",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "리프팅|거상|주름|안티에이징"
|
||||
},
|
||||
{
|
||||
"id": "skin",
|
||||
"label": {
|
||||
"ko": "피부과 시술",
|
||||
"en": "Dermatology Treatment"
|
||||
},
|
||||
"group": {
|
||||
"ko": "피부",
|
||||
"en": "Skin"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "실밥 없음",
|
||||
"en": "No stitches"
|
||||
},
|
||||
"display": {
|
||||
"ko": "제거 방문 없음",
|
||||
"en": "No removal visit"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "피부|레이저|보톡스|필러|토닝|색소"
|
||||
},
|
||||
{
|
||||
"id": "hair",
|
||||
"label": {
|
||||
"ko": "모발이식",
|
||||
"en": "Hair Transplant"
|
||||
},
|
||||
"group": {
|
||||
"ko": "모발",
|
||||
"en": "Hair"
|
||||
},
|
||||
"readMore": null,
|
||||
"preOpDays": {
|
||||
"value": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
"hospitalNights": {
|
||||
"value": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
"stitchRemovalDay": {
|
||||
"value": null,
|
||||
"status": "provisional",
|
||||
"note": {
|
||||
"ko": "채취 방식에 따라 다릅니다",
|
||||
"en": "Depends on the harvesting method"
|
||||
}
|
||||
},
|
||||
"earliestDepartureDay": {
|
||||
"value": 3,
|
||||
"status": "provisional"
|
||||
},
|
||||
"outingLevels": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"level": 0,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 1,
|
||||
"level": 1,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 3,
|
||||
"level": 2,
|
||||
"status": "provisional"
|
||||
},
|
||||
{
|
||||
"fromDay": 7,
|
||||
"level": 3,
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"diet": [
|
||||
{
|
||||
"fromDay": 0,
|
||||
"type": "normal",
|
||||
"status": "provisional"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
"no-sun",
|
||||
"no-hat-pressure",
|
||||
"no-scratch",
|
||||
"no-alcohol-30d",
|
||||
"no-sauna-60d"
|
||||
],
|
||||
"sources": [],
|
||||
"matchKeywords": "모발|탈모"
|
||||
}
|
||||
],
|
||||
"nationalities": {
|
||||
"_comment": "무비자 체류 한도(일). 2026-09 기준 확인 필요. null 은 사전 비자 필요. 화면은 항상 대한민국 비자포털 링크와 함께 보여준다.",
|
||||
"portal": "https://www.visa.go.kr",
|
||||
"list": [
|
||||
{
|
||||
"code": "US",
|
||||
"label": {
|
||||
"ko": "미국",
|
||||
"en": "United States"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CA",
|
||||
"label": {
|
||||
"ko": "캐나다",
|
||||
"en": "Canada"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "GB",
|
||||
"label": {
|
||||
"ko": "영국",
|
||||
"en": "United Kingdom"
|
||||
},
|
||||
"visaFreeDays": 180,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "AU",
|
||||
"label": {
|
||||
"ko": "호주",
|
||||
"en": "Australia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "JP",
|
||||
"label": {
|
||||
"ko": "일본",
|
||||
"en": "Japan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TW",
|
||||
"label": {
|
||||
"ko": "대만",
|
||||
"en": "Taiwan"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "HK",
|
||||
"label": {
|
||||
"ko": "홍콩",
|
||||
"en": "Hong Kong"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "SG",
|
||||
"label": {
|
||||
"ko": "싱가포르",
|
||||
"en": "Singapore"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "TH",
|
||||
"label": {
|
||||
"ko": "태국",
|
||||
"en": "Thailand"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MY",
|
||||
"label": {
|
||||
"ko": "말레이시아",
|
||||
"en": "Malaysia"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "AE",
|
||||
"label": {
|
||||
"ko": "아랍에미리트",
|
||||
"en": "United Arab Emirates"
|
||||
},
|
||||
"visaFreeDays": 90,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "RU",
|
||||
"label": {
|
||||
"ko": "러시아",
|
||||
"en": "Russia"
|
||||
},
|
||||
"visaFreeDays": 60,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "KZ",
|
||||
"label": {
|
||||
"ko": "카자흐스탄",
|
||||
"en": "Kazakhstan"
|
||||
},
|
||||
"visaFreeDays": 30,
|
||||
"longHaul": true
|
||||
},
|
||||
{
|
||||
"code": "CN",
|
||||
"label": {
|
||||
"ko": "중국",
|
||||
"en": "China"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "VN",
|
||||
"label": {
|
||||
"ko": "베트남",
|
||||
"en": "Vietnam"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "ID",
|
||||
"label": {
|
||||
"ko": "인도네시아",
|
||||
"en": "Indonesia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "PH",
|
||||
"label": {
|
||||
"ko": "필리핀",
|
||||
"en": "Philippines"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "MN",
|
||||
"label": {
|
||||
"ko": "몽골",
|
||||
"en": "Mongolia"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": false
|
||||
},
|
||||
{
|
||||
"code": "OTHER",
|
||||
"label": {
|
||||
"ko": "그 외",
|
||||
"en": "Other"
|
||||
},
|
||||
"visaFreeDays": null,
|
||||
"longHaul": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
313
supporters/src/components/Planner.astro
Normal file
313
supporters/src/components/Planner.astro
Normal file
@ -0,0 +1,313 @@
|
||||
---
|
||||
// 회복기 스케줄러 본문 (설계 v0.2 §13). /plan(한국어)과 /en/plan(영어)이 같은 컴포넌트를 lang 만 바꿔 쓴다.
|
||||
// 구성은 홈·영상 페이지와 같은 섹션 리듬이다: 다크 히어로(글래스 통계) → 틴트(입력 카드) → 다크(핵심 날짜) → 라이트(확인·규칙) → 틴트(타임라인) → 다크(예약).
|
||||
// 건강정보(시술·수술일)는 서버로 보내지 않는다. 상태는 URL 해시로만 공유하고, 언어 전환은 해시를 들고 다른 언어 페이지로 이동한다.
|
||||
import '../styles/plan.css';
|
||||
import strings from '../data/planStrings.json';
|
||||
import P from '../data/procedures.json';
|
||||
import PL from '../data/places.json';
|
||||
import TKO from '../data/medicalTourismKo.json';
|
||||
import TEN from '../data/medicalTourism.json';
|
||||
import { tourToPlaces } from '../lib/tour';
|
||||
interface Props { lang: 'ko' | 'en' }
|
||||
const { lang } = Astro.props;
|
||||
const t = strings[lang];
|
||||
const tour = tourToPlaces((lang === 'en' ? TEN : TKO) as any, lang);
|
||||
const nProcs = P.procedures.length;
|
||||
const nPlaces = PL.places.filter((x) => x.category !== 'hotel').length + tour.filter((x) => x.category !== 'hotel').length;
|
||||
const nHotels = PL.places.filter((x) => x.category === 'hotel').length + tour.filter((x) => x.category === 'hotel').length;
|
||||
const procList = (P.procedures as any[]).map((x) => x.label[lang]).join(lang === 'ko' ? '·' : ', ');
|
||||
const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
---
|
||||
<section class="section dark plan-hero">
|
||||
<div class="wrap-wide">
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow" style="color:var(--purple-300)">{t.eyebrow}</div>
|
||||
<h1>{t.h_hero}</h1>
|
||||
<p class="lede"><strong>{t.title}</strong> {t.lede}</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{nProcs}</b><span class="label">{t.stat_procs}</span><span class="desc">{procList}</span></div>
|
||||
<div class="stat"><b>{nPlaces}</b><span class="label">{t.stat_places}</span><span class="desc">{(tour.length ? t.stat_places_d : t.stat_places_d_curated).replace('{origin}', originLabel)}</span></div>
|
||||
<div class="stat"><b>{nHotels}</b><span class="label">{t.stat_hotels}</span><span class="desc">{t.stat_hotels_d}</span></div>
|
||||
<div class="stat"><b>0</b><span class="label">{t.stat_privacy}</span><span class="desc">{t.stat_privacy_d}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="planner" data-lang={lang}>
|
||||
<section class="section tint"><div class="wrap-wide"><noscript><p class="notice">{lang === 'ko' ? '이 일정표는 브라우저에서 계산합니다. 자바스크립트를 켜 주세요.' : 'This planner runs in your browser. Please enable JavaScript.'}</p></noscript></div></section>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<div class="sticky-bar" id="sticky" hidden><div class="s"><b id="sticky-title"></b><small id="sticky-sub"></small></div><button type="button" data-action="go">{t.stickyView}</button></div>
|
||||
|
||||
<script>
|
||||
import P from '../data/procedures.json';
|
||||
import PL from '../data/places.json';
|
||||
import STR from '../data/planStrings.json';
|
||||
import factSheet from '../data/factSheet.json';
|
||||
import TKO from '../data/medicalTourismKo.json';
|
||||
import TEN from '../data/medicalTourism.json';
|
||||
import { tourToPlaces, festivalsDuring } from '../lib/tour';
|
||||
import { buildPlan, encodeHash, decodeHash, bookingUrl, googleFlightsUrl, mapUrl, buildIcs, summaryText, isIsoDate, addDays } from '../lib/plan';
|
||||
import type { Lang, Procedure, Place, Nationality, Plan, PlanDay } from '../lib/plan';
|
||||
|
||||
type Strings = Record<string, string>;
|
||||
const procedures = P.procedures as unknown as Procedure[];
|
||||
const curated = (PL.places as unknown as Place[]).map((x) => ({ ...x, source: 'curated' as const }));
|
||||
const root0 = document.getElementById('planner')!;
|
||||
const tourData = ((root0.dataset.lang === 'en' ? TEN : TKO) as any);
|
||||
const places: Place[] = [...curated, ...tourToPlaces(tourData, root0.dataset.lang === 'en' ? 'en' : 'ko')];
|
||||
const tourDate: string = String(tourData?.meta?.fetchedAt ?? '').slice(0, 10);
|
||||
const nationalities = P.nationalities.list as Nationality[];
|
||||
const PREFS = ['sightseeing', 'shopping', 'food', 'kpop', 'care'];
|
||||
const root = document.getElementById('planner')!;
|
||||
const pageLang = (root.dataset.lang === 'en' ? 'en' : 'ko') as Lang;
|
||||
const toastEl = document.getElementById('toast')!;
|
||||
const sticky = document.getElementById('sticky')!;
|
||||
const reservationUrl: string = (factSheet as any).reservationUrl || (factSheet as any).url || '/visit';
|
||||
|
||||
interface State { lang: Lang; nat: string; proc: string; date: string; prefs: string[] }
|
||||
const initial = decodeHash(location.hash);
|
||||
const state: State = {
|
||||
lang: pageLang,
|
||||
nat: initial.n ?? '',
|
||||
proc: initial.p && procedures.some((p) => p.id === initial.p) ? initial.p : '',
|
||||
date: initial.d ?? '',
|
||||
prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)),
|
||||
};
|
||||
|
||||
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
const T = (): Strings => (STR as any)[state.lang];
|
||||
const fill = (s: string, vars: Record<string, string | number>) => Object.entries(vars).reduce((acc, [k, val]) => acc.replaceAll(`{${k}}`, String(val)), s);
|
||||
const locale = () => (state.lang === 'ko' ? 'ko-KR' : 'en-GB');
|
||||
const fmtDate = (iso: string) => new Intl.DateTimeFormat(locale(), { month: 'short', day: 'numeric', weekday: 'short', timeZone: 'UTC' }).format(new Date(`${iso}T00:00:00Z`));
|
||||
const weekday = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${iso}T00:00:00Z`));
|
||||
const L = (o: any) => esc(o?.[state.lang] ?? '');
|
||||
const currentProc = () => procedures.find((p) => p.id === state.proc) ?? null;
|
||||
const currentNat = () => nationalities.find((n) => n.code === state.nat) ?? null;
|
||||
const statusTag = (st: string) => st === 'clinic' ? `<span class="tag clinic">${esc(T().clinicSourced)}</span>` : `<span class="tag prov">${esc(T().provisional)}</span>`;
|
||||
/** 섹션 머리: 아이브로우(영문) + Playfair 영문 제목 + 한글 부제. 홈의 .sec-head 규격 */
|
||||
const secHead = (eyebrow: string, en: string, sub: string, dark = false) => `<div class="sec-head"><div class="eyebrow"${dark ? ' style="color:var(--purple-300)"' : ''}>${esc(eyebrow)}</div><h2>${esc(en)}</h2>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}</div>`;
|
||||
const cardHead = (n: number, en: string, sub: string) => `<div class="cat step"><span class="n">${n}</span>Step ${n}</div><h3 class="serif">${esc(en)}</h3>${sub ? `<p class="sub2">${esc(sub)}</p>` : ''}`;
|
||||
|
||||
function syncHash() {
|
||||
const h = encodeHash({ p: state.proc || undefined, d: state.date || undefined, l: state.lang, n: state.nat || undefined, pref: state.prefs });
|
||||
history.replaceState(null, '', `#${h}`);
|
||||
}
|
||||
function toast(msg: string) { toastEl.textContent = msg; toastEl.classList.add('show'); setTimeout(() => toastEl.classList.remove('show'), 1600); }
|
||||
|
||||
// ---------- 입력 (틴트 섹션, 카드 3장) ----------
|
||||
function renderInput(): string {
|
||||
const t = T();
|
||||
const natOpts = [`<option value="">${state.lang === 'ko' ? '선택' : 'Select'}</option>`].concat(nationalities.map((n) => `<option value="${n.code}" ${n.code === state.nat ? 'selected' : ''}>${L(n.label)}</option>`)).join('');
|
||||
const procBtns = procedures.map((p) => `<button type="button" data-action="proc" data-id="${p.id}" class="${p.id === state.proc ? 'on' : ''}" aria-pressed="${p.id === state.proc}"><small>${L(p.group)}</small><b>${L(p.label)}</b></button>`).join('');
|
||||
const prefBtns = PREFS.map((k) => `<button type="button" data-action="pref" data-id="${k}" class="${state.prefs.includes(k) ? 'on' : ''}" aria-pressed="${state.prefs.includes(k)}">${esc(t[`pref_${k}`])}</button>`).join('');
|
||||
const minDate = new Date().toISOString().slice(0, 10);
|
||||
return `
|
||||
<section class="section tint plan-input" style="padding:3.2rem 0"><div class="wrap-wide">
|
||||
${secHead('Plan', t.h_input, t.sub_input)}
|
||||
<div class="steps-grid">
|
||||
<div class="card">
|
||||
${cardHead(1, t.h_step1, t.step1)}
|
||||
<div class="field"><label for="nat">${esc(t.nationality)}</label><select id="nat" data-action="nat">${natOpts}</select></div>
|
||||
<p class="hint">${esc(t.h_step1_hint)}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
${cardHead(2, t.h_step2, t.step2)}
|
||||
<div class="field"><label>${esc(t.procedure)}</label><div class="proc-grid">${procBtns}</div></div>
|
||||
<div class="field"><label for="sdate">${esc(t.surgeryDate)}</label><input id="sdate" type="date" data-action="date" value="${esc(state.date)}" min="${minDate}" /></div>
|
||||
<p class="hint">${esc(t.surgeryDateHint)}</p>
|
||||
</div>
|
||||
<div class="card span">
|
||||
${cardHead(3, t.h_step3, t.step3)}
|
||||
<div class="pref-row">${prefBtns}</div>
|
||||
<p class="hint">${esc(t.prefs)}</p>
|
||||
<div class="plan-actions"><button type="button" class="btn primary" data-action="go">${esc(t.generate)}</button><button type="button" class="btn ghost small" data-action="reset">${esc(t.reset)}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>`;
|
||||
}
|
||||
|
||||
// ---------- 결과 ----------
|
||||
function dayLabel(d: PlanDay): string {
|
||||
const t = T();
|
||||
if (d.offset < 0) return fill(t.dayBefore, { n: -d.offset });
|
||||
if (d.offset === 0) return t.daySurgery;
|
||||
return fill(t.dayAfter, { n: d.offset });
|
||||
}
|
||||
function eventLabel(d: PlanDay): string {
|
||||
const t = T();
|
||||
const ev: string[] = [];
|
||||
if (d.isArrival) ev.push(t.arriveBy);
|
||||
if (d.isSurgery) ev.push(t.surgery);
|
||||
if (d.isHospital && !d.isSurgery) ev.push(t.hospital);
|
||||
if (d.isStitch) ev.push(t.stitch);
|
||||
if (d.isDeparture) ev.push(t.departure);
|
||||
return ev.join(' · ');
|
||||
}
|
||||
function pickHtml(p: Place): string {
|
||||
const t = T();
|
||||
const dist = p.travelLabel ? esc(p.travelLabel) : `${p.distanceKm <= 0.6 ? esc(t.walk) : esc(t.taxi)} ${esc(fill(t.km, { km: p.distanceKm }))}`;
|
||||
const meta = `<small>${dist}${p.indoor ? ' · ' + esc(t.indoor) : ''}${p.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</small>`;
|
||||
return p.mapQuery ? `<a href="${mapUrl(p.mapQuery)}" rel="noopener" target="_blank" title="${L(p.note)}">${L(p.name)} ${meta}</a>` : `<span class="pick" title="${L(p.note)}">${L(p.name)} ${meta}</span>`;
|
||||
}
|
||||
|
||||
function renderEmpty(): string {
|
||||
const t = T();
|
||||
return `<section class="section dark" id="result"><div class="wrap-wide">${secHead('Step 4', t.h_step4, t.step4, true)}<div class="empty"><b>${esc(t.empty_title)}</b>${esc(t.empty_sub)}</div></div></section>`;
|
||||
}
|
||||
|
||||
function renderResult(plan: Plan, p: Procedure): string {
|
||||
const t = T();
|
||||
const nat = currentNat();
|
||||
const levels = (P as any).levels as { level: number; label: any; desc: any }[];
|
||||
const diets = (P as any).diets as Record<string, { label: any; desc: any }>;
|
||||
const lv = (n: number) => levels.find((x) => x.level === n)!;
|
||||
const hn = p.hospitalNights.value;
|
||||
|
||||
// 다크 섹션: 핵심 날짜 6개 (글래스 통계 카드)
|
||||
const dateVal = (iso: string) => `${iso.replace(/-/g, '.')}<span class="wd">${esc(weekday(iso))}</span>`;
|
||||
const numVal = (n: number, unit: string) => `${n}<span class="wd">${esc(unit)}</span>`;
|
||||
const txtVal = (s: string) => `<span class="txt">${esc(s)}</span>`;
|
||||
const stat = (label: string, valueHtml: string | null, st: string, note?: string) => `<div class="stat"><span class="label">${esc(label)}</span><b>${valueHtml === null ? txtVal(t.pending) : valueHtml}</b><span class="desc">${statusTag(st)}${note ? ' ' + esc(note) : ''}</span></div>`;
|
||||
const stitchDisplay = (p.stitchRemovalDay as any).display?.[state.lang];
|
||||
const stats = [
|
||||
stat(t.arriveBy, dateVal(plan.arriveBy), p.preOpDays.status, p.preOpDays.note ? (p.preOpDays.note as any)[state.lang] : undefined),
|
||||
stat(t.surgery, dateVal(plan.surgeryDate), 'clinic'),
|
||||
stat(t.hospital, hn === null ? null : hn === 0 ? txtVal(t.hospitalNone) : numVal(hn, fill(t.hospitalNights, { n: '' }).trim()), p.hospitalNights.status),
|
||||
stat(t.stitch, plan.stitchDate ? dateVal(plan.stitchDate) : stitchDisplay ? txtVal(stitchDisplay) : null, p.stitchRemovalDay.status, p.stitchRemovalDay.note ? (p.stitchRemovalDay.note as any)[state.lang] : undefined),
|
||||
stat(t.departure, plan.earliestDeparture ? dateVal(plan.earliestDeparture) : null, p.earliestDepartureDay.status),
|
||||
stat(t.stayNights, plan.stayNights === null ? null : numVal(plan.stayNights, fill(t.nights, { n: '' }).trim()), p.earliestDepartureDay.status),
|
||||
].join('');
|
||||
const scheduleSec = `<section class="section dark" id="result"><div class="wrap-wide">
|
||||
${secHead('Step 4', t.h_step4, `${t.step4 ? t.step4 + ' · ' : ''}${(p.label as any)[state.lang]}. ${t.sub_schedule}`, true)}
|
||||
<div class="stats dates">${stats}</div>
|
||||
<p class="note">${esc(t.departureNote)} ${esc(t.provisionalNote)}${p.readMore ? ` <a href="${esc(p.readMore)}">${esc(t.readMore)}</a>` : ''}</p>
|
||||
</div></section>`;
|
||||
|
||||
// 라이트 섹션: 체류 확인 · 회복 규칙 · 외출 단계 근거
|
||||
let visaCard = '';
|
||||
if (nat) {
|
||||
const stay = plan.stayNights === null ? null : plan.stayNights + 1;
|
||||
const country = (nat.label as any)[state.lang];
|
||||
let cls = 'ok', msg = '';
|
||||
if (nat.visaFreeDays === null) { cls = 'warn'; msg = fill(t.visaRequired, { country }); }
|
||||
else if (stay !== null && stay > nat.visaFreeDays) { cls = 'warn'; msg = fill(t.visaOver, { country, days: nat.visaFreeDays, stay }); }
|
||||
else if (stay !== null) { msg = fill(t.visaOk, { country, days: nat.visaFreeDays, stay }); }
|
||||
if (msg) visaCard = `<div class="card ${cls}"><div class="cat">Check</div><h3>${esc(t.h_visa)}</h3><p>${esc(msg)}</p><p class="fine">${esc(t.visaVerify)} <a href="${esc((P as any).nationalities.portal)}" rel="noopener" target="_blank">${esc(t.visaPortal)}</a></p></div>`;
|
||||
}
|
||||
const cons = p.constraints.map((c) => `<li>${L((P as any).constraints[c])}</li>`).join('');
|
||||
const stepNotes = p.outingLevels.filter((s) => s.note).map((s) => `<li>${L(s.note)} ${statusTag(s.status)}</li>`).join('');
|
||||
const rulesSec = `<section class="section light"><div class="wrap-wide">
|
||||
${secHead('Rules', t.h_rules, t.sub_rules)}
|
||||
<div class="grid">
|
||||
${visaCard}
|
||||
<div class="card"><div class="cat">${esc(t.constraints)}</div><h3>${esc(t.h_rules)}</h3><ul class="rules">${cons}</ul></div>
|
||||
${stepNotes ? `<div class="card"><div class="cat">${esc(t.levelLabel)}</div><h3>${esc(t.h_days)}</h3><ul class="rules">${stepNotes}</ul></div>` : ''}
|
||||
</div>
|
||||
</div></section>`;
|
||||
|
||||
// 틴트 섹션: 타임라인
|
||||
const legend = `<div class="legend">${levels.map((l) => `<span><span class="tag l${l.level}">${L(l.label)}</span>${L(l.desc)}</span>`).join('')}</div>`;
|
||||
const days = plan.days.map((d) => {
|
||||
const ev = eventLabel(d);
|
||||
const lvl = lv(d.level);
|
||||
const festPicks = d.festivals.map((f) => `<a href="#fest-${esc(f.id)}" class="fest"><span class="tag l3">${esc(t.festival_pick)}</span> ${esc(f.title)}</a>`).join('');
|
||||
const picks = d.picks.length || festPicks ? `<div class="picks">${festPicks}${d.picks.map(pickHtml).join('')}</div>` : d.level === 0 || d.isSurgery ? `<div class="rest">${esc(t.noPicks)}</div>` : '';
|
||||
const dietTag = d.offset >= 0 && d.diet !== 'normal' ? `<span class="tag diet" title="${L(diets[d.diet].desc)}">${esc(t.diet)}: ${L(diets[d.diet].label)}</span>` : '';
|
||||
return `<div class="day ${ev ? 'mark' : ''}"><div class="d"><b>${esc(fmtDate(d.date))}</b><span>${esc(dayLabel(d))}</span>${ev ? `<span class="ev">${esc(ev)}</span>` : ''}</div><div class="body"><div class="tags"><span class="tag l${d.level}">${esc(t.level)}: ${L(lvl.label)}</span>${dietTag}<span class="desc">${L(lvl.desc)}</span></div>${picks}</div></div>`;
|
||||
}).join('');
|
||||
const daysSec = `<section class="section tint"><div class="wrap-wide">
|
||||
${secHead('Timeline', t.h_days, t.sub_days)}
|
||||
${legend}
|
||||
<div class="timeline">${days}</div>
|
||||
</div></section>`;
|
||||
|
||||
// 라이트 섹션: 체류 기간의 행사 (TourAPI)
|
||||
const firstL3 = plan.days.find((d) => d.level >= 3)?.date ?? null;
|
||||
const festCards = lastFest.map((f) => {
|
||||
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
|
||||
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
|
||||
}).join('');
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
|
||||
|
||||
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
|
||||
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
|
||||
const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)];
|
||||
const hotels = hotelList.map((h) => `
|
||||
<div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</div><h3>${L(h.name)}</h3><p>${L(h.note)}</p>
|
||||
<div class="links"><a href="${bookingUrl(h.bookingQuery ?? (h.name as any).en, checkin, checkout, state.lang)}" rel="noopener sponsored" target="_blank">${esc(t.hotelSearch)}</a>${h.mapQuery ? `<a href="${mapUrl(h.mapQuery)}" rel="noopener" target="_blank">${esc(t.map)}</a>` : ''}</div></div>`).join('');
|
||||
const srcs = p.sources.map((id) => (P as any).clinicSources.find((s: any) => s.id === id)).filter(Boolean).map((s: any) => `<li><a href="${esc(s.url)}" rel="noopener" target="_blank">${L(s.label)}</a></li>`).join('');
|
||||
const bookSec = `<section class="section dark"><div class="wrap-wide">
|
||||
${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)}
|
||||
<div class="cat" style="font-size:0.75rem;color:var(--purple-300);font-weight:700;letter-spacing:0.06em;font-family:Inter,Pretendard,sans-serif;margin-bottom:0.8rem">${esc(t.h_hotels).toUpperCase()} · ${esc(t.hotels)}</div>
|
||||
<div class="hotel-grid">${hotels}</div>
|
||||
<div class="book-row">
|
||||
<div class="card"><div class="cat">${esc(t.h_flights)}</div><h3>${esc(t.flights)}</h3><p>${esc(t.flightHint)}</p><div class="plan-actions"><a class="btn ghost small" href="${googleFlightsUrl(checkin, checkout, state.lang)}" rel="noopener" target="_blank">${esc(t.flightSearch)}</a></div></div>
|
||||
<div class="card"><div class="cat">${esc(t.h_consult)}</div><h3>${esc(t.consult)}</h3><p>${esc(t.consultHint)}</p><div class="plan-actions"><a class="btn primary small" href="${esc(reservationUrl)}" rel="noopener">${esc(t.consult)}</a><button type="button" class="btn ghost small" data-action="copy">${esc(t.copySummary)}</button></div></div>
|
||||
<div class="card"><div class="cat">Save</div><h3>${esc(t.ics)}</h3><p>${esc(t.hotelHint)}</p><div class="plan-actions"><button type="button" class="btn ghost small" data-action="ics">${esc(t.ics)}</button><button type="button" class="btn ghost small" data-action="share">${esc(t.shareLink)}</button><button type="button" class="btn ghost small" data-action="print">${esc(t.print)}</button></div></div>
|
||||
</div>
|
||||
<p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p>
|
||||
<p class="note" style="margin-top:0.6rem">${esc(t.sourceLabel)}</p><ul class="sources-list">${srcs}${tourDate ? `<li>${esc(fill(t.source_tour, { date: tourDate }))}</li>` : ''}</ul>
|
||||
</div></section>`;
|
||||
|
||||
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
|
||||
}
|
||||
|
||||
let lastPlan: Plan | null = null;
|
||||
let lastFest: ReturnType<typeof festivalsDuring> = [];
|
||||
function render() {
|
||||
const p = currentProc();
|
||||
lastPlan = null;
|
||||
let result = renderEmpty();
|
||||
if (p && isIsoDate(state.date)) {
|
||||
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
|
||||
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
|
||||
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
|
||||
result = renderResult(lastPlan, p);
|
||||
}
|
||||
root.innerHTML = renderInput() + result;
|
||||
syncHash();
|
||||
updateSticky();
|
||||
}
|
||||
/** 모바일 하단 요약 바: 결과가 있고 결과 섹션이 아직 화면 아래에 있을 때만 보인다. */
|
||||
function updateSticky() {
|
||||
const p = currentProc();
|
||||
if (!lastPlan || !p) { sticky.hidden = true; return; }
|
||||
const t = T();
|
||||
const short = (iso: string) => iso.slice(5).replace('-', '.');
|
||||
(document.getElementById('sticky-title') as HTMLElement).textContent = (p.label as any)[state.lang];
|
||||
(document.getElementById('sticky-sub') as HTMLElement).textContent = fill(t.stickyStay, { a: short(lastPlan.arriveBy), b: lastPlan.earliestDeparture ? short(lastPlan.earliestDeparture) : t.pending, n: lastPlan.stayNights ?? '?' });
|
||||
const r = document.getElementById('result');
|
||||
sticky.hidden = !r || r.getBoundingClientRect().top < window.innerHeight * 0.6;
|
||||
}
|
||||
window.addEventListener('scroll', () => { if (lastPlan) updateSticky(); }, { passive: true });
|
||||
|
||||
// ---------- 이벤트 ----------
|
||||
document.addEventListener('click', (e) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||
if (!el) return;
|
||||
const a = el.dataset.action;
|
||||
if (a === 'proc') { state.proc = el.dataset.id!; render(); if (state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
else if (a === 'pref') { const id = el.dataset.id!; state.prefs = state.prefs.includes(id) ? state.prefs.filter((x) => x !== id) : [...state.prefs, id]; render(); }
|
||||
else if (a === 'go') { render(); if (lastPlan) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); else (document.getElementById('sdate') as HTMLInputElement | null)?.focus(); }
|
||||
else if (a === 'reset') { state.proc = ''; state.date = ''; state.prefs = []; state.nat = ''; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
||||
else if (a === 'copy' && lastPlan) { navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).then(() => toast(T().copied)); }
|
||||
else if (a === 'share') { navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); }
|
||||
else if (a === 'print') { window.print(); }
|
||||
else if (a === 'ics' && lastPlan) {
|
||||
const t = T();
|
||||
const ics = buildIcs(lastPlan, { arrive: t.arriveBy, surgery: `${t.surgery} · ${(currentProc()!.label as any)[state.lang]}`, stitch: t.stitch, departure: t.departure }, t.summaryTitle);
|
||||
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));
|
||||
const link = Object.assign(document.createElement('a'), { href: url, download: `recovery-plan-${state.date}.ics` });
|
||||
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', (e) => {
|
||||
const el = e.target as HTMLInputElement | HTMLSelectElement;
|
||||
if (el.dataset.action === 'nat') { state.nat = el.value; render(); }
|
||||
if (el.dataset.action === 'date') { state.date = isIsoDate(el.value) ? el.value : ''; render(); if (state.proc && state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
103
supporters/src/components/VisitMap.astro
Normal file
103
supporters/src/components/VisitMap.astro
Normal file
@ -0,0 +1,103 @@
|
||||
---
|
||||
// 오시는 길: 지도 + 주소 복사 + 길찾기 버튼. 방문 안내(/visit) 3. 찾아가기와 영어 홈 Getting Here 에서 쓴다.
|
||||
// 좌표는 factSheet.geo {lat,lng,source} 를 먼저 보고(source 는 운영 기록이라 화면에 내지 않는다), 없으면 관광공사 수집 메타(medicalTourismKo.json meta.origin)를 쓴다. 둘 다 없으면 지도 없이 주소·버튼만 낸다.
|
||||
// 지도는 OpenStreetMap 타일 + Leaflet(cdnjs). 길찾기는 네이버·카카오 지도의 공개 URL 형식(벤치마크 2026-09-11 확인)이고, 영어 화면에는 구글 지도 길찾기를 더한다.
|
||||
import { fact, has, v } from '../lib';
|
||||
import tour from '../data/medicalTourismKo.json';
|
||||
interface Props { lang?: 'ko' | 'en'; nameEn?: string; addressEn?: string; title?: string; sub?: string; compact?: boolean; hideHead?: boolean }
|
||||
const { lang = 'ko', nameEn, addressEn, title, sub, compact = false, hideHead = false } = Astro.props;
|
||||
const f = fact as Record<string, any>;
|
||||
const origin = (tour as any)?.meta?.origin ?? {};
|
||||
const geo = f.geo && f.geo.lat && f.geo.lng ? f.geo : (origin.lat && origin.lng ? { lat: origin.lat, lng: origin.lng, source: origin.coordSource || '관광공사 수집 기준 좌표' } : null);
|
||||
const nameKo = v(f.shortName, '병원');
|
||||
const name = lang === 'en' ? (nameEn || f.shortNameEn || f.nameEn || nameKo) : nameKo;
|
||||
const address = lang === 'en' ? (addressEn || f.address?.fullEn || f.address?.full || '') : (f.address?.full || '');
|
||||
const phone: string = f.phone || '';
|
||||
const enc = encodeURIComponent;
|
||||
const naverUrl = geo ? `https://map.naver.com/p/directions/-/${geo.lng},${geo.lat},${enc(nameKo)}/-/transit` : (has(f.address?.full) ? `https://map.naver.com/p/search/${enc(f.address.full)}` : '');
|
||||
const kakaoUrl = geo ? `https://map.kakao.com/link/to/${enc(nameKo)},${geo.lat},${geo.lng}` : (has(f.address?.full) ? `https://map.kakao.com/link/search/${enc(f.address.full)}` : '');
|
||||
const googleUrl = geo ? `https://www.google.com/maps/dir/?api=1&destination=${geo.lat},${geo.lng}&destination_place_id=&travelmode=transit` : (address ? `https://www.google.com/maps/search/?api=1&query=${enc(address)}` : '');
|
||||
const T = lang === 'en'
|
||||
? { title: title ?? 'Getting Here', sub: sub ?? 'Address, and directions from where you are.', address: 'Address', copy: 'Copy', copied: 'Copied', phone: 'Phone', naver: 'Naver Map directions', kakao: 'Kakao Map directions', google: 'Google Maps directions', noMap: 'Map is added once the clinic confirms its location.', attribution: 'Map data © OpenStreetMap contributors' }
|
||||
: { title: title ?? '오시는 길', sub: sub ?? '주소와 현재 위치에서 오는 길을 안내합니다.', address: '주소', copy: '복사', copied: '복사했습니다', phone: '문의', naver: '네이버 길찾기', kakao: '카카오 길찾기', google: '구글 지도 길찾기', noMap: '지도는 병원이 위치를 확인한 뒤 싣습니다.', attribution: '지도 데이터 © OpenStreetMap 기여자' };
|
||||
const mapId = `visit-map-${Math.random().toString(36).slice(2, 8)}`;
|
||||
---
|
||||
{geo && <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="" />}
|
||||
<section class={`visit-map ${compact ? 'compact' : ''}`}>
|
||||
{!compact && !hideHead && <div class="vm-head"><div class="eyebrow">Directions</div><h2 class="serif">{T.title}</h2><p class="sub">{T.sub}</p></div>}
|
||||
<div class="vm-grid">
|
||||
<div class="vm-map-wrap">
|
||||
{geo
|
||||
? <div class="vm-map" id={mapId} data-lat={geo.lat} data-lng={geo.lng} data-name={name} role="img" aria-label={`${name} ${T.title}`}></div>
|
||||
: <div class="vm-map vm-empty"><p>{T.noMap}</p></div>}
|
||||
</div>
|
||||
<div class="vm-card">
|
||||
<div class="vm-label">{T.address}</div>
|
||||
<div class="vm-address"><span>{address || v(null)}</span>{address && <button type="button" class="vm-copy" data-copy={address} data-done={T.copied}><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z"/></svg><span>{T.copy}</span></button>}</div>
|
||||
{phone && <p class="vm-phone">{T.phone}: <a href={`tel:${phone.replace(/-/g, '')}`}>{phone}</a></p>}
|
||||
{lang === 'ko' && has(f.transit) && <p class="vm-transit">{f.transit}</p>}
|
||||
<div class="vm-actions">
|
||||
{lang === 'en' && googleUrl && <a class="vm-btn google" href={googleUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.google}</a>}
|
||||
{naverUrl && <a class="vm-btn naver" href={naverUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.naver}</a>}
|
||||
{kakaoUrl && <a class="vm-btn kakao" href={kakaoUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.kakao}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{geo && <p class="vm-attr">{T.attribution}</p>}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.visit-map { margin: 1.6rem 0 2.2rem; }
|
||||
.vm-head { margin-bottom: 1.2rem; }
|
||||
.vm-head h2 { margin: 0.2rem 0 0.2rem; font-size: 1.6rem; }
|
||||
.vm-head .sub { margin: 0; color: var(--slate-600); }
|
||||
.vm-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); gap: 1.25rem; align-items: stretch; }
|
||||
.vm-map-wrap { display: flex; flex-direction: column; min-height: 0; }
|
||||
.vm-map { width: 100%; flex: 1 1 auto; min-height: 340px; border-radius: 16px; overflow: hidden; border: 1px solid var(--slate-100); box-shadow: var(--shadow); background: var(--primary-50); z-index: 0; }
|
||||
.vm-empty { display: grid; place-items: center; color: var(--slate-500); font-size: 0.92rem; padding: 1rem; text-align: center; }
|
||||
.vm-empty p { margin: 0; }
|
||||
.vm-attr { font-size: 0.74rem; color: var(--slate-500); margin: 0.45rem 0 0; }
|
||||
.vm-card { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.4rem; box-shadow: var(--shadow); display: grid; gap: 0.55rem; align-content: start; }
|
||||
.vm-label { font-size: 0.78rem; color: var(--slate-500); font-weight: 600; letter-spacing: 0.02em; }
|
||||
.vm-address { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.7rem; font-size: 1.08rem; font-weight: 700; color: var(--primary-900); line-height: 1.45; }
|
||||
.vm-copy { display: inline-flex; align-items: center; gap: 0.3rem; border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 8px; padding: 0.25rem 0.55rem; font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; }
|
||||
.vm-copy:hover { color: var(--primary-900); border-color: var(--primary-900); }
|
||||
.vm-phone, .vm-transit { margin: 0; font-size: 0.92rem; color: var(--slate-600); }
|
||||
.vm-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.vm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; border-radius: 12px; padding: 0.75rem 0.9rem; font-size: 0.92rem; font-weight: 700; text-decoration: none; color: #fff; box-shadow: var(--shadow); }
|
||||
.vm-btn:hover { text-decoration: none; box-shadow: var(--shadow-hover); }
|
||||
.vm-btn.naver { background: #03C75A; }
|
||||
.vm-btn.kakao { background: #FEE500; color: #191919; }
|
||||
.vm-btn.google { background: var(--primary-900); }
|
||||
.visit-map.compact .vm-grid { grid-template-columns: 1fr; }
|
||||
@media (max-width: 760px) { .vm-grid { grid-template-columns: 1fr; } .vm-map { flex: none; aspect-ratio: 4 / 3; min-height: 0; } }
|
||||
@media print { .vm-map { break-inside: avoid; } .vm-actions { display: none; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 주소 복사
|
||||
document.querySelectorAll<HTMLButtonElement>('.vm-copy').forEach((b) => b.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(b.dataset.copy || ''); const s = b.querySelector('span'); const orig = s?.textContent ?? ''; if (s) s.textContent = b.dataset.done || orig; setTimeout(() => { if (s) s.textContent = orig; }, 1400); } catch { /* 클립보드 권한 없음 */ }
|
||||
}));
|
||||
// 지도: Leaflet 을 필요할 때만 불러온다(지도가 있는 페이지에서만).
|
||||
const maps = Array.from(document.querySelectorAll<HTMLElement>('.vm-map[data-lat]'));
|
||||
if (maps.length) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js';
|
||||
s.crossOrigin = '';
|
||||
s.onload = () => {
|
||||
const L = (window as any).L;
|
||||
for (const el of maps) {
|
||||
const lat = Number(el.dataset.lat), lng = Number(el.dataset.lng);
|
||||
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 16);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright" rel="noopener">OpenStreetMap</a> contributors' }).addTo(map);
|
||||
const icon = L.divIcon({ className: 'vm-pin', html: '<svg width="34" height="44" viewBox="0 0 34 44" aria-hidden="true"><path d="M17 0C7.6 0 0 7.6 0 17c0 12.5 17 27 17 27s17-14.5 17-27C34 7.6 26.4 0 17 0Z" fill="#4F1DA1"/><circle cx="17" cy="17" r="6.5" fill="#fff"/></svg>', iconSize: [34, 44], iconAnchor: [17, 44], popupAnchor: [0, -40] });
|
||||
L.marker([lat, lng], { icon }).addTo(map).bindPopup(`<b>${el.dataset.name ?? ''}</b>`);
|
||||
// 카드 높이에 맞춰 늘어난 뒤 타일이 빈 곳 없이 채워지도록
|
||||
const fix = () => map.invalidateSize();
|
||||
requestAnimationFrame(fix); window.addEventListener('resize', fix, { passive: true }); if ('ResizeObserver' in window) new ResizeObserver(fix).observe(el);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
@ -2,6 +2,9 @@
|
||||
"_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.",
|
||||
"name": "뷰성형외과의원",
|
||||
"shortName": "뷰성형외과",
|
||||
"nameEn": "View Plastic Surgery",
|
||||
"shortNameEn": "View Plastic Surgery",
|
||||
"shortNameEnSource": "https://www.viewplasticsurgery.com",
|
||||
"kind": "성형외과",
|
||||
"areaLabel": "서울 강남 신논현역",
|
||||
"founded": "2005",
|
||||
@ -12,6 +15,9 @@
|
||||
"locality": "강남구",
|
||||
"region": "서울특별시",
|
||||
"postalNote": "논현동 201-16",
|
||||
"fullEn": "107 Bongeunsa-ro, Gangnam-gu, Seoul",
|
||||
"postalCode": "06120",
|
||||
"fullEnSource": "https://api.visitkorea.or.kr EngService2 contentid 3364779 (한국관광공사 영문 관광정보 등재)",
|
||||
"navigation": "서울시 강남구 봉은사로 1길 4 (논현동 201-14, 주차장 진입 기준)",
|
||||
"source": "https://www.viewclinic.com/viewis/direction/"
|
||||
},
|
||||
@ -213,5 +219,10 @@
|
||||
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
|
||||
"businessNo": "220-08-86777",
|
||||
"checkedAt": "2026-09-04",
|
||||
"nextCheck": "2026-10-04"
|
||||
"nextCheck": "2026-10-04",
|
||||
"geo": {
|
||||
"lat": 37.5049823,
|
||||
"lng": 127.0254182,
|
||||
"source": "네이버 지역검색 실측 2026-09-09"
|
||||
}
|
||||
}
|
||||
|
||||
36
supporters/src/data/factSheetEn.json
Normal file
36
supporters/src/data/factSheetEn.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"_comment": "영어 페이지용 병원 사실 번역. 값의 원본은 factSheet.json 이고 여기에는 영어 표기만 둔다. 숫자·전화·URL 은 factSheet 에서 그대로 읽는다. 빈 값은 영어 페이지에서 '확인' 대신 항목을 숨긴다.",
|
||||
"name": "View Plastic Surgery Clinic",
|
||||
"shortName": "View Plastic Surgery",
|
||||
"kind": "plastic surgery clinic",
|
||||
"areaLabel": "Sinnonhyeon Station, Gangnam, Seoul",
|
||||
"address": {
|
||||
"full": "107 Bongeunsa-ro, Gangnam-gu, Seoul (View Plastic Surgery Building)",
|
||||
"mapQuery": "View Plastic Surgery Seoul Bongeunsa-ro 107"
|
||||
},
|
||||
"transit": "Line 9, Sinnonhyeon Station, Exit 3. Within 50 m of the exit. The whole building is the clinic, so you can see it from the exit.",
|
||||
"airport": "From Incheon Airport: take the AREX airport railroad to Gimpo Airport Station, then Line 9 express to Sinnonhyeon Station with no transfer. About 60 to 70 minutes. A taxi takes 60 to 90 minutes depending on traffic.",
|
||||
"airportShort": "Line 9 from Gimpo Airport with no transfer",
|
||||
"parking": "The street in front of the main entrance is one-way. Enter from the pharmacy alley and turn right at the crossroads. The car park is on the left.",
|
||||
"hoursDays": {
|
||||
"월·화·수·목": "Mon to Thu",
|
||||
"금": "Fri",
|
||||
"토": "Sat",
|
||||
"일·공휴일": "Sun and public holidays"
|
||||
},
|
||||
"hoursClosed": "Closed",
|
||||
"building": "19-storey clinic building (B4 to 15F). Consultation 4F to 5F, examination B1, surgery 11F to 14F, inpatient wards 8F and 10F, dental clinic 15F.",
|
||||
"arrival": "Go to the information desk on the 1st floor first. Consultation is on 4F to 5F, VIP consultation on 3F, and pre-operative tests at the B1 examination center.",
|
||||
"specialties": [
|
||||
"Breast augmentation",
|
||||
"Facial contouring",
|
||||
"Jaw surgery",
|
||||
"Eyelid surgery",
|
||||
"Rhinoplasty",
|
||||
"Liposuction and fat grafting",
|
||||
"Anti-aging and lifting",
|
||||
"Men's plastic surgery",
|
||||
"Dermatology",
|
||||
"Hair transplant"
|
||||
]
|
||||
}
|
||||
2852
supporters/src/data/medicalTourism.json
Normal file
2852
supporters/src/data/medicalTourism.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user