feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리

- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵
- 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다
- 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건)
- 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-11 11:27:59 +09:00
parent 5c08fa97ab
commit aa5b05227f
126 changed files with 12916 additions and 2068 deletions

View File

@ -1,24 +1,40 @@
#!/usr/bin/env python3
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어).
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어). 병원별로 돈다.
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
거리와 함께 붙여 프론트가 JSON 하나로 낸다.
거리와 함께 붙여 프론트가 JSON 으로 낸다. 영문(EngService2) 국문(KorService2)
번에 만들 있다.
사전 수집인가
영문 서비스는 강남 일대 커버리지가 얇고(5km 음식점 18, 국문은 338) 관광지의
79% 의료기관이라 필터링이 많이 필요하다. 처리를 수집 시점에 끝내고 프론트는
정제된 결과만 그린다. 미팅 데모 외부 API 상태에 화면이 종속되지도 않는다.
영문 서비스는 도심 커버리지가 얇고 관광지 항목의 대부분이 의료기관이라 필터링이 많이
필요하다. 처리를 수집 시점에 끝내고 프론트는 정제된 결과만 그린다. 미팅 데모
외부 API 상태에 화면이 종속되지도 않는다.
무엇을 만들지 않는가
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 주지 않으므로 비워 두고,
Google Places 승인 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
판정하지 않는다.
판정하지 않는다. 좌표도 지어내지 않는다. 주소를 좌표로 바꾸지 못하면 종료 코드 1 멈춘다.
출력은 서포터즈 사이트(Astro) 데이터다. 화면은 supporters/src/pages/en/stay.astro .
INFINITH 제품 화면(/discovery) 아니라 병원 서포터즈 사이트에 실린다. 외국인 환자가 보는 곳이다.
기준 좌표를 정하는 순서
1. --lat --lng 직접 주면 그대로 쓴다 (coordSource: 명령 인자).
2. --address --name 주면 네이버 지역검색(NAVER_CLIENT_ID/SECRET)으로 상호를 찾고,
결과의 도로명 주소가 주소의 도로명·번지와 같을 때만 좌표를 쓴다.
네이버가 찾으면 TourAPI 키워드 검색(국문·영문)으로 찾는다.
3. 아무 인자도 없으면 .env TOUR_API_ORIGIN_LAT/LNG (초기 샘플 사이트용).
어느 경우든 meta.origin.coordSource 출처와 날짜를 적는다.
python3 scripts/fetch_medical_tourism.py
python3 scripts/fetch_medical_tourism.py --out supporters/src/data/medicalTourism.json
출력은 서포터즈 사이트(Astro) 데이터다. 화면은 src/pages/stay.astro(국문)
src/pages/en/stay.astro(영문). 외국인 환자와 보호자가 보는 곳이다.
# 워커가 부르는 형태 (팩트 시트 주소 기준, 영문·국문 동시)
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
@ -51,7 +67,7 @@ TYPES_KO = {
"32": ("stay", "5000"),
"39": ("restaurant", "5000"),
}
TYPES = TYPES_EN # main() 에서 --lang 에 맞게 바꾼다
# 언어별 타입 표는 collect() 가 lang 에 맞게 고른다.
def ssl_ctx():
@ -62,11 +78,17 @@ def ssl_ctx():
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()
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
@ -305,32 +327,119 @@ def clean(it, lat, lng):
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
ap.add_argument("--lang", choices=["en", "ko"], default="en",
help="en 은 EngService2(외국인 환자용 /en/stay), ko 는 KorService2(/stay)")
ap.add_argument("--out", default="")
a = ap.parse_args()
# ---------- 기준 좌표 ----------
global TYPES
env = load_env(os.path.join(ROOT, ".env"))
if a.lang == "ko":
TYPES = TYPES_KO
env = dict(env, TOUR_API_SERVICE="KorService2")
if not a.out:
a.out = ("supporters/src/data/medicalTourism.json" if a.lang == "en"
else "supporters/src/data/medicalTourismKo.json")
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
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)
out = {"places": {}, "festivals": [], "meta": {}}
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():
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"})
@ -348,7 +457,7 @@ def main():
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": "관광지(76)에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
filtered_log["wellness"] = {"note": "관광지에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
@ -372,24 +481,23 @@ def main():
# 설명문은 항목마다 detailCommon2 를 한 번씩 부른다. 카드에 이름과 주소만 있으면 고를 수 없다.
cache: dict = {}
targets = [p for v in out["places"].values() for p in v] + out["festivals"]
print(f"\n 설명문 수집 {len(targets)}", end="", flush=True)
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
# 1) 영문 detailImage2 → 2) 국문 서비스 → 3) 업체 홈페이지 og:image → 4) Naver 이미지 검색
# 1) 같은 언어 detailImage2 → 2) 국문 서비스 → 3) 업체 홈페이지 og:image → 4) Naver 이미지 검색
src = ""
img = fetch_image(env, p["id"], cache)
if img: src = "tourapi"
if not img and a.lang == "en":
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:
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
@ -414,26 +522,63 @@ def main():
}
out["meta"] = {
"lang": a.lang,
"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": "뷰성형외과의원 (봉은사로 107)",
"coordSource": "Naver 지역검색 실측 2026-09-09"},
"radiusByType": {n: int(r) for n, r in TYPES.values()}, "festivalRadiusKm": a.festival_km,
"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": [
"영문 서비스는 강남 일대 숙박·음식점 커버리지가 얇다(반경 5km 숙박 4건·음식점 18건, 국문은 43건·338건).",
"관광지 항목의 대부분(반경 5km 100건 92건)이 분류코드 A02020500 \"Medical Tourism Sites\" 였고 제외했다.",
"영문 서비스는 도심 숙박·음식점 커버리지가 국문보다 얇다.",
"관광지 항목 중 분류코드 A02020500 \"Medical Tourism Sites\"(병원·유치업체)는 제외했다.",
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
],
}
p = os.path.join(ROOT, a.out)
os.makedirs(os.path.dirname(p), exist_ok=True)
io.open(p, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
print(f"\n{a.out}")
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__":

View File

@ -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

View 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` 안에 "주변 장소·축제" 보강 데이터로 합치는 것을 다음 단계로 검토합니다. 합치기 전까지 프로덕션에 올리지 않습니다.

View File

@ -9,4 +9,6 @@ export default defineConfig({
trailingSlash: 'never',
build: { format: 'file' },
compressHTML: true,
// 주변 안내(/stay)는 회복 일정(/recovery)으로 합쳤다(haewon 2026-09-11). 정적 빌드라 meta refresh 페이지가 생긴다.
redirects: { '/stay': '/recovery', '/en/stay': '/en/recovery' },
});

View File

@ -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 코드를 검사한다.

View 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 (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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"
}
]
}

View 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 }
]
}

View File

@ -0,0 +1,83 @@
// 회복 일정 플래너 병원별 데이터 생성 (워커 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;
let attached = 0, unmatched = [];
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 || '' });
}
(it.ko ?? []).forEach((ko, i) => {
const en = (it.en ?? [])[i] || ko; // 번역 실패 문장은 한글 원문 그대로 (워커 recovery 단계와 같은 규칙)
const cid = `${srcId}-${i}`;
base.constraints[cid] = { ko: ko.trim(), en: en.trim() };
if (!target.constraints.includes(cid)) target.constraints.unshift(cid); // 병원 원문이 잠정 안내보다 먼저 보이게
attached++;
});
if (!target.sources.includes(srcId)) target.sources.push(srcId);
}
for (const p of base.procedures) delete p.matchKeywords;
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}문장 붙임${unmatched.length ? ` · 매칭 안 된 시술 ${unmatched.length}(${unmatched.slice(0, 4).join(', ')})` : ''} · ${placesSummary}`);

View File

@ -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

View File

@ -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(', ')}`);

View 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}`);

View File

@ -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(', ')}` : ''}`);

View File

@ -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('/');

View File

@ -0,0 +1,2 @@
<!-- expect: BANNED_PHRASE -->
<body><main><h1>사례</h1><p>비포애프터 사진은 상담실에서 볼 수 있습니다.</p></main></body>

View File

@ -0,0 +1,2 @@
<!-- expect: (none) -->
<body><main><h1>정품 정량 서약</h1><p>병원은 홈페이지에서 정품·정량 사용을 밝힙니다. 이 매체는 전후 사진을 싣지 않습니다. 수술 전후 1:1 모니터링을 안내합니다.</p></main></body>

View File

@ -0,0 +1,2 @@
<!-- expect: BANNED_PHRASE -->
<body><main><h1>정품 정량</h1><p>이 병원은 정품 정량 인증을 받은 병원입니다. 전후 사진을 꼭 확인하세요.</p></main></body>

View File

@ -0,0 +1,12 @@
---
expect: [DEVICE_CLAIM]
---
## 장비
이 병원의 리프팅 장비는 식약처 허가를 받은 장비라고 홈페이지에 적혀 있습니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 직접 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [DEVICE_CLAIM]
---
## 효과
레이저 제모는 5회면 영구 제모가 됩니다. 여드름은 치료 후 재발이 없습니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 직접 |
상담에서 확인합니다.

View 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 }
---
## 장비와 효과
홈페이지는 이 장비가 식약처 허가를 받았다고 씁니다. 허가는 해당 제품과 적응증에 한정되며 위험이 없다는 뜻은 아닙니다. 레이저 제모가 영구 제모라는 뜻은 아니며, 유지기간은 개인차가 있습니다. 반영구 화장은 별도 시술입니다.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 감독 |
상담에서 확인합니다.

View File

@ -0,0 +1,14 @@
---
expect: []
expectWarn: [PRACTITIONER_MISSING]
industry: derm
category: D
---
## 시술 조건
| 항목 | 병원 안내 |
|---|---|
| 시술시간 | 확인 |
| 마취 | 확인 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,13 @@
---
expect: []
expectNoWarn: [PRACTITIONER_MISSING]
industry: plastic
category: D
---
## 수술 조건
| 항목 | 병원 안내 |
|---|---|
| 수술 시간 | 확인 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,14 @@
---
expect: []
expectNoWarn: [PRACTITIONER_MISSING]
industry: derm
category: D
---
## 시술 조건
| 항목 | 병원 안내 |
|---|---|
| 시술시간 | 확인 |
| 시술자 | 확인 대기 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,12 @@
---
expect: [PRICE_MENTION]
---
## 시술 비용
울쎄라 300샷은 이벤트가 990,000원이고 정가 대비 30% 할인입니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 확인 대기 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: []
---
## 시술 비용 구성
가격은 샷 수와 회차, 패키지 조건으로 구성됩니다. 금액은 병원의 비급여 진료비용 고지 페이지(https://example.com/notice)에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 확인 대기 |
상담에서 확인합니다.

View File

@ -1,5 +1,5 @@
---
expect: [REGULATION_SOURCE_MISSING]
expect: [REGULATION_SOURCE_MISSING, DEVICE_CLAIM]
---
## 승인

View 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}건 통과`);

View File

@ -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 = '') {
@ -249,7 +249,7 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
/** (news.json). ·· .
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56 2 2) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
* 수집기(collect_news.mjs) 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼/i; // "수술 전후 유방검진" 같은 시기 표현은 통과
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 = [];
@ -265,8 +265,12 @@ export function checkNewsItems(items, { level = E } = {}) {
// 본문 금칙 표현(의료법 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 [
@ -305,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),
@ -317,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)),
];

View File

@ -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 규칙)
@ -109,5 +115,38 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
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);

View File

@ -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') : '(제공된 페이지 없음)'}`);

View File

@ -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). */

View File

@ -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}

View 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"])
}

View 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 ? ' …' : ''}`);

View 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": ""
}
}

View 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": ""
}
}

View 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
}
]
}

View 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
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"_comment": "시술 후 관리 안내(빈 값). 워커의 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 채운다. items 가 비어 있으면 /stay·/en/stay 는 이 블록을 그리지 않는다.",
"clinic": "",
"generatedAt": "",
"sourceNote": "병원 홈페이지 시술 페이지 원문",
"items": [],
"counts": {}
}

View 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
}
]
}

View 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
}
]
}
}

View 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
}
]
}
}

View 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">{t.stat_places_d.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>

View 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 }
]
}

View File

@ -0,0 +1,235 @@
{
"_comment": "회복기 스케줄러 UI 문자열. ko/en 두 언어. 일본어·중국어는 P1에서 같은 키로 추가한다. 고객 화면이므로 운영자 어휘를 쓰지 않는다.",
"ko": {
"eyebrow": "Recovery Planner",
"title": "수술일을 정하셨다면, 여행 일정은 여기서 짜세요.",
"lede": "시술과 수술일을 고르면 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 그날 갈 만한 곳을 정리해 드립니다. 계산은 이 화면 안에서만 이루어지고 어디에도 저장되지 않습니다.",
"step1": "국적",
"step2": "시술과 수술일",
"step3": "여유 시간에 하고 싶은 것",
"step4": "일정표",
"step5": "예약과 저장",
"lang": "언어",
"nationality": "국적",
"procedure": "시술",
"surgeryDate": "수술일",
"surgeryDateHint": "상담에서 확정한 날짜를 넣으세요. 아직 정하지 않았다면 예상일도 괜찮습니다.",
"prefs": "여러 개 고를 수 있습니다",
"pref_sightseeing": "관광",
"pref_shopping": "쇼핑",
"pref_food": "미식",
"pref_kpop": "K-pop·핫플",
"pref_care": "휴식·케어",
"generate": "일정표 만들기",
"arriveBy": "입국 권장일",
"surgery": "수술일",
"hospital": "입원",
"hospitalNights": "{n}박",
"hospitalNone": "당일 귀가",
"stitch": "실밥·부목 제거",
"departure": "출국 가능 최소일",
"stayNights": "총 체류",
"nights": "{n}박",
"pending": "확인",
"provisional": "병원 확인 전",
"clinicSourced": "병원 안내 기준",
"departureNote": "출국 가능 최소일은 최소 기준입니다. 담당 원장이 경과를 보고 다르게 안내할 수 있으니 항공권은 변경 가능한 조건으로 예약하세요.",
"dayBefore": "수술 {n}일 전",
"daySurgery": "수술 당일",
"dayAfter": "수술 후 {n}일",
"level": "외출",
"diet": "식사",
"picks": "추천",
"noPicks": "이 날은 쉬는 날입니다. 배달과 룸서비스를 이용하세요.",
"constraints": "회복기 동안 지킬 것",
"visaTitle": "체류 기간 확인",
"visaOk": "{country} 국적의 무비자 체류 한도는 {days}일이고 이 일정은 {stay}일입니다. 한도 안에 있습니다.",
"visaOver": "{country} 국적의 무비자 체류 한도는 {days}일인데 이 일정은 {stay}일입니다. 비자가 필요할 수 있으니 확인하세요.",
"visaRequired": "{country} 국적은 사전 비자가 필요할 수 있습니다. 출발 전 확인하세요.",
"visaVerify": "체류 한도는 2026년 9월 기준이며 바뀔 수 있습니다. 대한민국 비자포털에서 확인하세요.",
"visaPortal": "대한민국 비자포털",
"hotels": "병원 근처 숙소",
"hotelHint": "입국 권장일부터 출국 가능 최소일까지 날짜를 넣어 검색합니다. 엘리베이터, 룸서비스, 24시간 프런트가 있는 곳을 골랐습니다.",
"hotelSearch": "이 날짜로 검색",
"flights": "항공권",
"flightHint": "인천공항 도착 기준으로 날짜를 채워 검색합니다. 변경 가능한 요금을 권합니다.",
"flightSearch": "구글 항공권에서 보기",
"consult": "상담 예약",
"consultHint": "아래 일정 요약을 복사해 상담 신청 때 함께 보내면 병원이 회복 일정에 맞춰 안내할 수 있습니다.",
"copySummary": "일정 요약 복사",
"copied": "복사했습니다",
"shareLink": "링크 복사",
"ics": "캘린더에 저장",
"print": "인쇄",
"map": "지도",
"readMore": "회복 일정 자세히",
"affiliate": "숙소·항공 링크는 각 예약 사이트로 이동하며, 제휴 링크가 적용될 경우 이 매체가 소정의 수수료를 받을 수 있습니다. 예약 조건과 결제는 각 사이트의 안내를 따릅니다.",
"medical": "이 일정표는 병원 공개 안내를 바탕으로 한 일반 정보입니다. 본인 수술 범위와 경과에 따라 담당 원장의 지시가 우선합니다.",
"km": "{km}km",
"walk": "도보",
"taxi": "택시",
"indoor": "실내",
"summaryTitle": "회복 일정 요약",
"levelLabel": "외출 단계",
"sourceLabel": "근거",
"reset": "처음부터",
"provisionalNote": "\"병원 확인 전\"으로 표시된 값은 병원 공개 안내에 없어 일반 기준으로 넣은 잠정값입니다. 상담에서 본인 일정으로 확정하세요.",
"noRemoval": "제거 방문 없음",
"h_hero": "Plan Your Recovery Stay",
"h_step1": "Nationality",
"h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time",
"h_step4": "Your Schedule",
"h_visa": "Length of Stay",
"h_rules": "During Recovery",
"h_days": "Day by Day",
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation",
"h_input": "Build Your Schedule",
"sub_input": "세 가지만 고르면 일정표가 바로 나옵니다.",
"stat_procs": "시술",
"stat_places": "추천 장소",
"stat_places_d": "큐레이션과 한국관광공사 TourAPI. {origin} 기준 거리·이동 시간 표시",
"stat_hotels": "근처 숙소",
"stat_hotels_d": "병원 도보 10분 안, 엘리베이터·룸서비스·24시간 프런트",
"stat_privacy": "서버 저장",
"stat_privacy_d": "시술과 수술일은 이 화면 안에서만 계산합니다",
"sub_schedule": "핵심 날짜 여섯 개입니다. 항공권과 숙소는 이 날짜로 검색합니다.",
"sub_days": "날짜마다 외출 단계와 식사 단계, 그날 갈 만한 곳을 표시합니다.",
"sub_book": "입국 권장일부터 출국 가능 최소일까지 날짜를 채워 검색합니다.",
"sub_rules": "담당 원장 안내가 우선이고, 아래는 병원 공개 안내 기준입니다.",
"empty_title": "시술과 수술일을 고르면 여기에 일정표가 나옵니다",
"empty_sub": "수술일은 예상일이어도 됩니다.",
"stickyView": "일정표 보기",
"stickyStay": "{a} → {b} · {n}박",
"h_step1_hint": "국적을 고르면 시차와 무비자 체류 한도를 함께 봅니다.",
"h_fest": "Festivals During Your Stay",
"sub_fest": "체류 기간과 겹치는 행사입니다. 자유 외출 단계가 되는 날부터 갈 수 있습니다.",
"fest_none": "체류 기간과 겹치는 행사가 없습니다.",
"fest_when": "{a} ~ {b}",
"fest_from": "{d}부터 가능",
"fest_notYet": "이번 체류에서는 자유 외출 단계에 닿지 않습니다",
"source_tour": "장소·행사 데이터: 한국관광공사 TourAPI, {date} 기준. 영업 여부와 행사 일정은 방문 전에 확인하세요.",
"tour_badge": "관광공사",
"festival_pick": "행사",
"readMoreShort": "홈페이지"
},
"en": {
"eyebrow": "Recovery Planner",
"title": "Surgery date set? Plan the rest of your trip here.",
"lede": "Pick your procedure and surgery date. We work out when to arrive, the earliest you can fly home, which recovery days you can go out, and where to go on those days. Everything is calculated on this page and stored nowhere.",
"step1": "",
"step2": "",
"step3": "",
"step4": "",
"step5": "",
"lang": "Language",
"nationality": "Nationality",
"procedure": "Procedure",
"surgeryDate": "Surgery date",
"surgeryDateHint": "Use the date confirmed at consultation. An estimate is fine if it's not set yet.",
"prefs": "Choose as many as you like",
"pref_sightseeing": "Sightseeing",
"pref_shopping": "Shopping",
"pref_food": "Food",
"pref_kpop": "K-pop & hotspots",
"pref_care": "Rest & care",
"generate": "Build my schedule",
"arriveBy": "Arrive by",
"surgery": "Surgery",
"hospital": "Hospital stay",
"hospitalNights": "{n} night(s)",
"hospitalNone": "Same-day discharge",
"stitch": "Stitch / splint removal",
"departure": "Earliest departure",
"stayNights": "Total stay",
"nights": "{n} nights",
"pending": "To confirm",
"provisional": "Provisional",
"clinicSourced": "From clinic guidance",
"departureNote": "Earliest departure is a minimum. Your surgeon may advise differently after follow-up, so book flights with flexible terms.",
"dayBefore": "{n} day(s) before surgery",
"daySurgery": "Surgery day",
"dayAfter": "Day {n} after surgery",
"level": "Outing",
"diet": "Diet",
"picks": "Suggestions",
"noPicks": "A rest day. Use delivery and room service.",
"constraints": "During recovery",
"visaTitle": "Length of stay check",
"visaOk": "Visa-free stay for {country} nationals is {days} days and this plan is {stay} days. You're within the limit.",
"visaOver": "Visa-free stay for {country} nationals is {days} days but this plan is {stay} days. You may need a visa, please check.",
"visaRequired": "{country} nationals may need a visa in advance. Check before you travel.",
"visaVerify": "Stay limits are as of September 2026 and may change. Confirm at the Korea Visa Portal.",
"visaPortal": "Korea Visa Portal",
"hotels": "Hotels near the clinic",
"hotelHint": "Searches with your arrival and earliest-departure dates filled in. We picked places with elevators, room service and a 24-hour front desk.",
"hotelSearch": "Search these dates",
"flights": "Flights",
"flightHint": "Searches with your dates filled in, arriving at Incheon. Flexible fares recommended.",
"flightSearch": "Open in Google Flights",
"consult": "Book a consultation",
"consultHint": "Copy the summary below and send it with your consultation request so the clinic can plan around your recovery dates.",
"copySummary": "Copy summary",
"copied": "Copied",
"shareLink": "Copy link",
"ics": "Save to calendar",
"print": "Print",
"map": "Map",
"readMore": "Full recovery timeline",
"affiliate": "Hotel and flight links open the booking sites. Where an affiliate link applies, this site may earn a small commission. Booking terms and payment follow each site.",
"medical": "This schedule is general information based on the clinic's published guidance. Your surgeon's instructions for your own case take priority.",
"km": "{km} km",
"walk": "walk",
"taxi": "taxi",
"indoor": "indoor",
"summaryTitle": "Recovery schedule summary",
"levelLabel": "Outing level",
"sourceLabel": "Source",
"reset": "Start over",
"provisionalNote": "Values marked \"Provisional\" are not in the clinic's published guidance and use general estimates. Confirm your own dates at consultation.",
"noRemoval": "No removal visit",
"h_hero": "Plan Your Recovery Stay",
"h_step1": "Nationality",
"h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time",
"h_step4": "Your Schedule",
"h_visa": "Length of Stay",
"h_rules": "During Recovery",
"h_days": "Day by Day",
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation",
"h_input": "Build Your Schedule",
"sub_input": "Pick three things and your schedule appears.",
"stat_procs": "procedures",
"stat_places": "curated places",
"stat_places_d": "Curated picks plus Korea Tourism Organization data, with distance and travel time from {origin}",
"stat_hotels": "hotels nearby",
"stat_hotels_d": "Within a 10-minute walk, with elevators, room service and 24-hour desks",
"stat_privacy": "stored on servers",
"stat_privacy_d": "Your procedure and dates are calculated on this page only",
"sub_schedule": "Six key dates. Flights and hotels are searched with these.",
"sub_days": "Each day shows the outing level, diet stage and places worth going.",
"sub_book": "Searches are filled with your arrival and earliest-departure dates.",
"sub_rules": "Your surgeon's instructions come first. Below is the clinic's published guidance.",
"empty_title": "Pick a procedure and a surgery date to see your schedule here",
"empty_sub": "An estimated date is fine.",
"stickyView": "See schedule",
"stickyStay": "{a} → {b} · {n} nights",
"h_step1_hint": "Your nationality sets the jet-lag buffer and the visa-free stay check.",
"h_fest": "Festivals During Your Stay",
"sub_fest": "Events that overlap your stay. You can go from the first full-day-out date.",
"fest_none": "No events overlap your stay.",
"fest_when": "{a} to {b}",
"fest_from": "From {d}",
"fest_notYet": "Your stay does not reach the full-day-out stage",
"source_tour": "Places and events: Korea Tourism Organization TourAPI, as of {date}. Check opening status and event dates before you go.",
"tour_badge": "KTO",
"festival_pick": "Event",
"readMoreShort": "Website"
}
}

View File

@ -0,0 +1,989 @@
{
"_comment": "회복기 스케줄러 시술별 규칙표 (설계 v0.2 §13.4). 숫자마다 status 가 붙는다. clinic = 병원 공개 주의사항·애프터케어에 있는 값, provisional = 병원 확인 전 잠정값(화면에 배지 표시), null = 값 없음(화면에 '확인'). 병원 확인이 끝나면 status 를 clinic 으로 바꾸고 source 를 채운다.",
"clinicSources": [
{
"id": "precautions",
"label": {
"ko": "뷰성형외과 홈페이지 · 수술 전·후 주의사항",
"en": "View Plastic Surgery · Pre/Post-operative precautions"
},
"url": "https://www.viewclinic.com/system/precautions/",
"accessed": "2026-09-07"
},
{
"id": "aftercare",
"label": {
"ko": "뷰성형외과 홈페이지 · 애프터케어",
"en": "View Plastic Surgery · Aftercare"
},
"url": "https://www.viewclinic.com/system/after-care/",
"accessed": "2026-09-07"
}
],
"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": "/posts/breast-recovery-timeline",
"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": "clinic",
"source": "precautions",
"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": "clinic",
"source": "precautions",
"note": {
"ko": "부기는 2~3일째 가장 심합니다",
"en": "Swelling peaks on days 2 to 3"
}
},
{
"fromDay": 3,
"level": 1,
"status": "clinic",
"source": "precautions",
"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": "clinic",
"source": "precautions"
}
],
"constraints": [
"driving",
"no-heavy-lifting",
"sports-bra",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": [
"precautions",
"aftercare"
]
},
{
"id": "contour",
"label": {
"ko": "안면윤곽·양악",
"en": "Facial Contouring / Jaw Surgery"
},
"group": {
"ko": "얼굴뼈",
"en": "Facial bone"
},
"readMore": "/posts/contour-recovery",
"preOpDays": {
"value": 2,
"status": "provisional",
"note": {
"ko": "3D-CT 촬영과 검진, 상담",
"en": "3D-CT, tests and consultation"
}
},
"hospitalNights": {
"value": 1,
"status": "provisional"
},
"stitchRemovalDay": {
"value": null,
"status": "clinic",
"source": "precautions",
"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": "clinic",
"source": "precautions",
"note": {
"ko": "부기는 3~4일까지 늘다가 가라앉습니다",
"en": "Swelling increases until day 3 to 4, then subsides"
}
},
{
"fromDay": 5,
"level": 1,
"status": "clinic",
"source": "precautions",
"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": "clinic",
"source": "precautions"
},
{
"fromDay": 7,
"type": "soft",
"status": "clinic",
"source": "precautions"
},
{
"fromDay": 14,
"type": "mild",
"status": "clinic",
"source": "precautions"
},
{
"fromDay": 30,
"type": "normal",
"status": "clinic",
"source": "precautions"
}
],
"constraints": [
"driving",
"compression-band",
"sleep-elevated",
"saline-gargle",
"no-hard-food-30d",
"no-alcohol-30d",
"no-sauna-60d",
"no-massage"
],
"sources": [
"precautions",
"aftercare"
]
},
{
"id": "eye",
"label": {
"ko": "눈성형",
"en": "Eyelid Surgery"
},
"group": {
"ko": "눈",
"en": "Eyes"
},
"readMore": "/posts/eye-canthoplasty-types",
"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": []
},
{
"id": "nose",
"label": {
"ko": "코성형",
"en": "Rhinoplasty"
},
"group": {
"ko": "코",
"en": "Nose"
},
"readMore": "/posts/functional-rhinoplasty-ent",
"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": []
},
{
"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": []
},
{
"id": "lifting",
"label": {
"ko": "안티에이징·리프팅",
"en": "Anti-aging / Face Lift"
},
"group": {
"ko": "리프팅",
"en": "Lifting"
},
"readMore": "/posts/thread-lift-vs-facelift",
"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": []
},
{
"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": []
},
{
"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": []
}
],
"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
}
]
}
}

View File

@ -0,0 +1,254 @@
{
"clinic": "viewclinic",
"generatedAt": "2026-09-11",
"sourceNote": "병원 홈페이지 시술 페이지 원문",
"items": [
{
"procedure": "수술 전·후 주의사항",
"procedureEn": "Pre- and Post-Operative Precautions",
"area": "",
"url": "https://www.viewclinic.com/system/precautions/",
"ko": [
"수술 당일 콘택트렌즈, 귀금속, 액세서리, 매니큐어, 화장 등은 미리 제거해 주시기 바랍니다.",
"수술 후 2~3일째 가장 부기가 많고 3일 이후 붕대 제거가 가능합니다.",
"전체적인 부기는 1개월 정도 지나면서 자연스러워지고 잔 붓기는 6개월까지 좋아집니다.",
"절개 부위에 더마본드 사용 시 2주 후부터 샤워 가능합니다.",
"절개 부위에 실밥이 있는 경우, 실밥 제거 후 다음날부터 가벼운 샤워 가능합니다.",
"6개월 이후 흉터 레이저 관리 가능합니다.",
"수술 후 스포츠브라는 1~3개월 유지하고, 3개월 후 일반 속옷 착용이 가능합니다.",
"수술 후 음주 및 흡연은 최소 1개월 이후 가능합니다.",
"찜질방, 사우나, 수영장, 과도한 운동은 약 2개월 후 가능합니다.",
"재수술 후 운동은 3개월 이후 권장합니다.",
"수술 후 회복 및 경과는 개인에 따라 차이가 있을 수 있으며 염증, 출혈, 신경 손상 등의 부작용이 생길 수 있으므로 주의가 필요합니다.",
"수술 후 3~4일까지는 부기가 증가하고 이후부터 가라앉으며 5~7일 후 일상생활이 가능합니다.",
"3일 후부터 머리를 감을 수 있으며, 간단한 샤워는 가능하나 목욕은 삼갑니다.",
"턱 테이프 제거 후 다음 날부터 가벼운 거품 세안, 피부 화장이 가능합니다.",
"수술 후 1주가 지나면 어린이용 작은 칫솔모로 가볍게 양치 가능하고, 음식물 섭취 후 항상 가글을 잘 해주세요.",
"수술 후 3일 정도는 냉찜질, 그 후에는 온찜질로 부기를 예방할 수 있습니다.",
"바깥쪽 실밥 제거 후 1주일은 더 상처연고로 관리해 주시고, 이후 흉터연고로 관리해주세요.",
"찜질방, 사우나, 수영장, 과도한 운동, 마사지는 약 1~2개월 후 가능합니다.",
"수술 후 3~4일까지는 부기가 증가하고 이후부터 부기가 가라앉으며 5~7일 후 일상생활이 가능합니다.",
"수술 후 1달 정도가 되면 부기의 80%가 빠지게 되며 이후부터는 점점 자연스러워집니다.",
"수술 후 3일 동안은 냉찜질, 그 후에는 온찜질로 부기를 예방할 수 있습니다.",
"수술 후 7일째부터 입술 운동(A/E/I/O/U), 웃는 연습(입꼬리 위치가 같게) 꾸준히 해주시기 바랍니다.",
"수술 후 2~3일은 멍이나 부기가 증가할 수 있으며 개인차가 있습니다.",
"수술 후 1~2주 정도 큰 부기가 있고 눈 주위 멍이나 큰 부기는 한 달 정도 지나면서 자연스러워지며 잔 붓기는 6개월 사이에 점차 감소합니다.",
"수술 후 3일간은 냉찜질을 해주시기 바랍니다.",
"눈썹거상 눈썹 문신은 6개월 이후에 가능합니다.",
"눈밑지방재배치 초반에 눈 밑이 볼록하고 단단하게 느껴질 수 있으나 평균 1개월정도 부기가 흡수되면서 볼록한게 감소하고 단단한 것도 풀어지며 자연스러워집니다.",
"실밥을 제거했어도 절개 부위가 약하기 때문에 눈을 비비고 만지는 행동, 눈에 자극을 주는 행동은 1개월까지 삼가주세요.",
"실밥 제거 후 다음날부터 가벼운 거품 세안, 연한 피부 화장이 가능합니다.",
"수술 후 3일간은 냉찜질을 해주시기 바랍니다.(코 주변, 이마 쪽으로 찜질해 주시기 바랍니다.)",
"실밥 제거 후 다음날부터 가벼운 거품 세안, 피부 화장 가능합니다.",
"붙이는 코팩 또는 스크럽 제품 사용은 1개월 이후 가능합니다.",
"부위에 따라 멍이 1~3주 지속될 수 있으며, 부기는 3~4주 정도면 70%~80% 이상 감소하게 됩니다.",
"1개월 이후 전체적인 부기가 빠지고 압박복이 늘어나게 되면 압박복은 수선 후 착용해 주시는 것이 좋습니다.",
"수술 후 2주 후부터 식이조절 및 가벼운 운동을 병행해 주시면 체중조절 및 회복에 도움이 됩니다.",
"실밥 제거 후 다음날부터 가벼운 거품 세안, 샤워가 가능합니다.",
"부기가 빠지고 지방이 생착 되는 기간은 1개월 정도 소요됩니다.",
"지방이 생착 되는 기간 동안에는 외부의 압력으로 인해 눌리거나 충격이 가해지는 행동을 주의해주세요.",
"1개월 이후 원장님 경과 체크를 진행하며, 필요시 부족한 부위에 2차 지방이식이 가능합니다.",
"실밥 제거 후 다음날부터 가벼운 거품 세안, 샴푸, 화장이 가능합니다."
],
"en": [
"On the day of surgery, please remove contact lenses, jewelry, accessories, nail polish, and makeup in advance.",
"Swelling is greatest on the 2nd to 3rd day after surgery, and bandages can be removed after the 3rd day.",
"Overall swelling naturally improves after about 1 month, and residual swelling improves up to 6 months.",
"If Dermabond is used on the incision site, showering is possible from 2 weeks after surgery.",
"If there are stitches on the incision site, light showering is possible from the day after stitch removal.",
"Scar laser treatment is possible after 6 months.",
"Wear a sports bra for 1 to 3 months after surgery, and normal underwear is allowed after 3 months.",
"Drinking alcohol and smoking are allowed at least 1 month after surgery.",
"Saunas, jjimjilbangs, swimming pools, and excessive exercise are allowed about 2 months after surgery.",
"Exercise after revision surgery is recommended after 3 months.",
"Recovery and progress after surgery may vary by individual, and complications such as inflammation, bleeding, and nerve damage may occur, so caution is required.",
"Swelling increases for 3 to 4 days after surgery, then subsides, and daily activities are possible after 5 to 7 days.",
"You can wash your hair from 3 days after surgery; simple showers are allowed but baths are not.",
"After removing chin tape, light foam cleansing and skin makeup are possible from the next day.",
"After 1 week post-surgery, you can gently brush your teeth with a small children's toothbrush, and always gargle well after eating.",
"Cold compresses are recommended for about 3 days after surgery, then warm compresses can help prevent swelling.",
"Manage the outer stitches with wound ointment for 1 week after removal, then use scar ointment afterward.",
"Saunas, jjimjilbangs, swimming pools, excessive exercise, and massages are allowed about 1 to 2 months after surgery.",
"Swelling increases for 3 to 4 days after surgery, then subsides, and daily activities are possible after 5 to 7 days.",
"About 80% of swelling reduces after 1 month post-surgery, and the appearance gradually becomes natural.",
"Cold compresses are recommended for 3 days after surgery, then warm compresses can help prevent swelling.",
"From the 7th day after surgery, consistently practice lip exercises (A/E/I/O/U) and smiling exercises (keep the corners of the mouth aligned).",
"Bruising or swelling may increase for 2 to 3 days after surgery, with individual differences.",
"",
"Apply cold compresses for 3 days after surgery.",
"Eyebrow lift and eyebrow tattooing are possible after 6 months.",
"After lower eyelid fat repositioning, the under-eye area may feel bulging and firm initially, but swelling reduces and firmness softens over about 1 month, becoming natural.",
"Even after stitch removal, avoid rubbing or touching the eyes and any actions that irritate the eyes for 1 month because the incision area is weak.",
"Light foam cleansing and light skin makeup are possible from the day after stitch removal.",
"Apply cold compresses for 3 days after surgery, focusing on the nose area and forehead.",
"Light foam cleansing and skin makeup are possible from the day after stitch removal.",
"Use of adhesive nose packs or scrub products is allowed after 1 month.",
"Bruising may last 1 to 3 weeks depending on the area, and swelling reduces by 70% to 80% after about 3 to 4 weeks.",
"After 1 month, overall swelling decreases and compression garments may loosen; it is recommended to have the compression garment altered and wear it again.",
"From 2 weeks after surgery, dietary control and light exercise help with weight management and recovery.",
"Light foam cleansing and showering are possible from the day after stitch removal.",
"It takes about 1 month for swelling to subside and fat to stabilize.",
"During the fat stabilization period, avoid pressure or impact on the area.",
"",
"Light foam cleansing, shampooing, and makeup are possible from the day after stitch removal."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "눈밑지방재배치",
"procedureEn": "Lower Eyelid Fat Repositioning",
"area": "",
"url": "https://www.viewclinic.com/eye/under-eye-fat-relocation/",
"ko": [
"회복기간 바로 일상생활 가능",
"수술 후 약 1주일 정도는 눈을 심하게 비비는 등의 행동은 피하는 것이 좋습니다."
],
"en": [
"You can resume daily activities immediately during the recovery period.",
"For about 1 week after surgery, avoid actions such as rubbing your eyes vigorously."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "무턱",
"procedureEn": "Receding Chin",
"area": "",
"url": "https://www.viewclinic.com/jaw/type-jaw/short-chin/",
"ko": [
"평균적으로 수술 후 3~4주가 지난 후 출근 등 큰 체력이 소모되지 않는 일상생활은 가능합니다.",
"수술이 끝난 후, 8~12시간 가량 경과 후 간단한 실내 움직임은 가능해지며 수술 후 2주 정도까지는 경과체크를 하며 상처 및 부종 등을 관리하시게 됩니다."
],
"en": [
"On average, after 3 to 4 weeks post-surgery, you can return to work and other daily activities that do not require heavy physical exertion.",
"After surgery, simple indoor movements are possible about 8 to 12 hours later, and until about 2 weeks after surgery, you will have follow-up checks to manage wounds and swelling."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "뷰 복부거상술",
"procedureEn": "View Abdominal Lift",
"area": "",
"url": "https://www.viewclinic.com/lipo/tummy-tuck/",
"ko": [
"일상 복귀는 1~2주, 조직 회복은 4~6주 정도 소요됩니다.",
"일정 기간 보정속옷 착용, 운동 제한 등이 필요합니다."
],
"en": [
"Returning to daily life takes 1 to 2 weeks, and tissue recovery takes about 4 to 6 weeks.",
"Wearing compression garments and exercise restrictions are necessary for a certain period."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "뷰 줄기세포",
"procedureEn": "View Stem Cell",
"area": "",
"url": "https://www.viewclinic.com/view_stemcell/",
"ko": [
"시술 직후 일상생활이 가능하며, 특별한 회복 기간을 필요로 하지 않습니다.",
"다만 무리한 운동은 1~2일 정도 피하는 것이 좋습니다."
],
"en": [
"You can perform daily activities immediately after the procedure, and no special recovery period is required.",
"However, it is recommended to avoid strenuous exercise for about 1 to 2 days."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "남성여유증, 여성형유방",
"procedureEn": "Gynecomastia",
"area": "",
"url": "https://www.viewclinic.com/view-male/gynecomastia/",
"ko": [
"수술 후 부기 완화, 염증 관리에 도움되는 APL 관리를 진행합니다."
],
"en": [
"APL treatment is performed to help reduce swelling and manage inflammation after surgery."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "모발이식",
"procedureEn": "Hair Transplant",
"area": "",
"url": "https://www.viewclinic.com/hair-transplant/",
"ko": [
"이식모가 생착 되기까지 약 3주가 소요되며, 수술 후 10~15일까지는 이식부위에 딱지가 조금씩 떨어지게 됩니다."
],
"en": [
"It takes about 3 weeks for transplanted hair to take root, and scabs on the transplant area will gradually fall off by 10 to 15 days after surgery."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "프로모션2_가슴",
"procedureEn": "Promotion 2_Breast",
"area": "",
"url": "https://www.viewclinic.com/ldpg-2/",
"ko": [
"수술 후 발생할 수 있는 합병증인 출혈, 감염, 염증 등은 개인에 따라 차이가 있을 수 있으므로 주의해야 합니다."
],
"en": [
"Complications such as bleeding, infection, and inflammation may occur after surgery and vary by individual, so caution is necessary."
],
"ocr": false,
"fetchedAt": "2026-09-07"
},
{
"procedure": "UIU 가슴성형",
"procedureEn": "UIU Breast Augmentation",
"area": "",
"url": "https://www.viewclinic.com/breast/main/",
"ko": [
"수술 후 통증을 줄이고 빠른 회복 가능"
],
"en": [
"Pain after surgery can be reduced and rapid recovery is possible."
],
"ocr": false,
"fetchedAt": "2026-09-07"
}
],
"counts": {
"pagesScanned": 110,
"pagesWithNotes": 9,
"pages": 9,
"sentences": 52,
"translated": 59,
"cacheHits": 26,
"enFailed": 2,
"costUsd": 0.0023,
"dropped": {
"time": 7923,
"length": 3895,
"action": 490,
"preop": 7,
"dup": 28,
"contact": 1,
"promo": 66,
"unfinished": 49,
"fragment": 39,
"table": 3,
"broken": 3
}
},
"enFailed": [
{
"ko": "수술 후 1~2주 정도 큰 부기가 있고 눈 주위 멍이나 큰 부기는 한 달 정도 지나면서 자연스러워지며 잔 붓기는 6개월 사이에 점차 감소합니다.",
"en": "Significant swelling lasts about 1 to 2 weeks after surgery; bruising and major swelling around the eyes improve naturally after about 1 month, and residual swelling gradually decreases within 6 months.",
"why": "numbers"
},
{
"ko": "1개월 이후 원장님 경과 체크를 진행하며, 필요시 부족한 부위에 2차 지방이식이 가능합니다.",
"en": "After 1 month, the doctor will check progress and a second fat graft is possible if needed.",
"why": "numbers"
}
]
}

View File

@ -18,14 +18,14 @@ const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.
const isEn = lang === 'en';
// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조.
const T = isEn
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/stay', '주변 안내'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/plan', 'Recovery Planner'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/plan', '회복 일정'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다.
// 영문 페이지가 늘어나면 이 표에 줄만 추가한다.
const PAIRS: Record<string, string> = { '/stay': '/en/stay', '/visit': '/en/stay' };
const PAIRS: Record<string, string> = { '/plan': '/en/plan', '/visit': '/en/plan' };
const KO_ENTRY = '/';
const EN_ENTRY = '/en/stay';
const EN_ENTRY = '/en/plan';
const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/');
const koPair = Object.entries(PAIRS).find(([, en]) => en === path)?.[0];
const otherHref = isEn ? (koPair ?? KO_ENTRY) : (PAIRS[path] ?? EN_ENTRY);
@ -34,7 +34,7 @@ const otherExact = isEn ? Boolean(koPair) : Boolean(PAIRS[path]);
// 영문 페이지의 예약 버튼은 병원의 외국인용 영문 사이트로 보낸다(factSheet.urlEn).
// 한국어 예약 페이지로 보내면 영어로 온 사람이 한국어 화면을 만난다.
const ctaHref = isEn
? ((f as Record<string, any>).reservationUrlEn || (f as Record<string, any>).urlEn || reservationUrl || '/en/stay')
? ((f as Record<string, any>).reservationUrlEn || (f as Record<string, any>).urlEn || reservationUrl || '/en/plan')
: (reservationUrl || '/visit');
// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 56조·추천보증 심사지침).
// 병원이 영문 문구를 확정해 site.json 의 sponsorNoticeEn / factSheet 의 sideEffectNoticeEn 에 넣기 전까지는

177
supporters/src/lib/plan.ts Normal file
View File

@ -0,0 +1,177 @@
// 회복기 스케줄러 계산 로직 (설계 v0.2 §13.3·§13.7·§13.8). 화면과 분리된 순수 함수만 둔다.
// 입력: 시술 id, 수술일, 국적, 선호. 출력: 날짜별 외출 등급·식사·추천과 예약 링크. 서버 호출 없음.
// 검증: node --experimental-strip-types scripts/gate/plan.test.mjs
export type Lang = 'ko' | 'en';
export type L10n = Record<Lang, string>;
export type Status = 'clinic' | 'provisional';
export interface Valued { value: number | null; range?: [number, number]; status: Status; source?: string; note?: L10n }
export interface LevelStep { fromDay: number; level: number; status: Status; source?: string; note?: L10n }
export interface DietStep { fromDay: number; type: 'liquid' | 'soft' | 'mild' | 'normal'; status: Status; source?: string }
export interface Procedure {
id: string; label: L10n; group: L10n; readMore: string | null;
preOpDays: Valued; hospitalNights: Valued; stitchRemovalDay: Valued; earliestDepartureDay: Valued;
outingLevels: LevelStep[]; diet: DietStep[]; constraints: string[]; sources: string[];
}
export interface Place {
id: string; name: L10n; category: string; minLevel: number; dietOk?: string[]; distanceKm: number; indoor: boolean;
area: L10n; note: L10n; mapQuery: string | null; bookingQuery?: string; verify: boolean;
source?: 'curated' | 'tourapi'; travelLabel?: string; image?: string; url?: string;
}
export interface Nationality { code: string; label: L10n; visaFreeDays: number | null; longHaul: boolean }
export interface PlanFestival { id: string; title: string; start: string; end: string }
export interface PlanInput { procedure: Procedure; surgeryDate: string; nationality?: Nationality | null; prefs: string[]; festivals?: PlanFestival[] }
export interface PlanDay { date: string; offset: number; level: number; diet: DietStep['type']; picks: Place[]; festivals: PlanFestival[]; isSurgery: boolean; isHospital: boolean; isStitch: boolean; isArrival: boolean; isDeparture: boolean }
export interface Plan {
arriveBy: string; surgeryDate: string; dischargeDate: string | null; stitchDate: string | null; earliestDeparture: string | null;
stayNights: number | null; preOpDays: number; days: PlanDay[];
}
const DAY = 86_400_000;
export const addDays = (iso: string, n: number): string => {
const d = new Date(`${iso}T00:00:00Z`);
return new Date(d.getTime() + n * DAY).toISOString().slice(0, 10);
};
export const isIsoDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
/** fromDay 오름차순 단계표에서 offset 에 해당하는 단계. 첫 단계 이전이면 첫 단계. */
function stepAt<T extends { fromDay: number }>(steps: T[], offset: number): T {
const sorted = [...steps].sort((a, b) => a.fromDay - b.fromDay);
let cur = sorted[0];
for (const s of sorted) if (offset >= s.fromDay) cur = s;
return cur;
}
/** 장거리 국적은 시차 회복으로 준비일에 1일을 더한다 (§13.3). */
export function effectivePreOpDays(p: Procedure, nat?: Nationality | null): number {
const base = p.preOpDays.value ?? 2;
return base + (nat?.longHaul ? 1 : 0);
}
/**
* . , , .
* .
*/
export function scorePlace(place: Place, level: number, diet: DietStep['type'], prefs: string[]): number {
if (place.category === 'hotel') return -1;
if (place.minLevel > level) return -1;
if (place.category === 'food' && place.dietOk && !place.dietOk.includes(diet)) return -1;
let s = 0;
if (prefs.length === 0 || prefs.includes(place.category)) s += 3;
if (place.source !== 'tourapi') s += 0.75; // 큐레이션(1순위)이 같은 조건이면 먼저 나온다
if (place.category === 'essentials' || place.category === 'care') s += 1; // 회복에 직접 필요한 것은 기본 가점
s += 2 - Math.min(2, level - place.minLevel); // 오늘 등급에 맞는 곳을 우선
s += Math.max(0, 1.5 - place.distanceKm / 4); // 가까울수록 가점(최대 1.5)
return s;
}
/** 하루에 최대 3곳. 직전 2일에 나온 곳은 뒤로 미뤄 날마다 다른 곳이 나오게 한다. */
export function pickForDay(places: Place[], level: number, diet: DietStep['type'], prefs: string[], recent: Set<string>, max = 3): Place[] {
return places
.map((p) => ({ p, s: scorePlace(p, level, diet, prefs) - (recent.has(p.id) ? 2.5 : 0) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s || a.p.distanceKm - b.p.distanceKm)
.slice(0, max)
.map((x) => x.p);
}
export function buildPlan(input: PlanInput, places: Place[]): Plan {
const { procedure: p, surgeryDate, nationality, prefs, festivals = [] } = input;
const preOp = effectivePreOpDays(p, nationality);
const arriveBy = addDays(surgeryDate, -preOp);
const nights = p.hospitalNights.value;
const dischargeDate = nights === null ? null : addDays(surgeryDate, nights);
const stitchDate = p.stitchRemovalDay.value === null ? null : addDays(surgeryDate, p.stitchRemovalDay.value);
const dep = p.earliestDepartureDay.value;
const earliestDeparture = dep === null ? null : addDays(surgeryDate, dep);
const lastOffset = dep ?? Math.max(14, ...p.outingLevels.map((l) => l.fromDay));
const days: PlanDay[] = [];
const recent: string[] = [];
for (let off = -preOp; off <= lastOffset; off++) {
const date = addDays(surgeryDate, off);
const isHospital = nights !== null && off >= 0 && off < nights;
const pre = off < 0;
const level = pre ? 2 : isHospital ? 0 : stepAt(p.outingLevels, off).level;
const diet = pre ? 'normal' : stepAt(p.diet, off).type;
const recentSet = new Set(recent.slice(-6));
const picks = level === 0 || off === 0 ? [] : pickForDay(places, level, diet, prefs, recentSet);
recent.push(...picks.map((x) => x.id));
const dayFest = level >= 3 ? festivals.filter((f) => f.start <= date && f.end >= date).slice(0, 1) : []; // 행사는 자유 외출(L3) 날에만
days.push({ date, offset: off, level, diet, picks, festivals: dayFest, isSurgery: off === 0, isHospital, isStitch: stitchDate === date, isArrival: off === -preOp, isDeparture: earliestDeparture === date });
}
return { arriveBy, surgeryDate, dischargeDate, stitchDate, earliestDeparture, stayNights: dep === null ? null : preOp + dep, preOpDays: preOp, days };
}
// ---------- 예약 링크 (§13.7). 검색 결과 페이지 패턴만 쓴다. 제휴 ID 는 확정 후 aid 파라미터로 붙인다. ----------
export function bookingUrl(query: string, checkin: string, checkout: string, lang: Lang, aid?: string): string {
const u = new URL('https://www.booking.com/searchresults.html');
u.searchParams.set('ss', query);
u.searchParams.set('checkin', checkin);
u.searchParams.set('checkout', checkout);
u.searchParams.set('group_adults', '1');
u.searchParams.set('no_rooms', '1');
u.searchParams.set('lang', lang === 'ko' ? 'ko' : 'en-us');
if (aid) u.searchParams.set('aid', aid);
return u.toString();
}
export function googleFlightsUrl(arrive: string, depart: string, lang: Lang): string {
const q = `Flights to ICN on ${arrive} returning ${depart}`;
return `https://www.google.com/travel/flights?hl=${lang}&q=${encodeURIComponent(q)}`;
}
export function mapUrl(query: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
// ---------- 공유 URL 해시 (서버 미전송) ----------
export interface HashState { p?: string; d?: string; l?: Lang; n?: string; pref?: string[] }
export function encodeHash(s: HashState): string {
const q = new URLSearchParams();
if (s.p) q.set('p', s.p);
if (s.d) q.set('d', s.d);
if (s.l) q.set('l', s.l);
if (s.n) q.set('n', s.n);
if (s.pref?.length) q.set('pref', s.pref.join(','));
return q.toString();
}
export function decodeHash(hash: string): HashState {
const q = new URLSearchParams(hash.replace(/^#/, ''));
const l = q.get('l');
return {
p: q.get('p') ?? undefined,
d: isIsoDate(q.get('d')) ? (q.get('d') as string) : undefined,
l: l === 'ko' || l === 'en' ? l : undefined,
n: q.get('n') ?? undefined,
pref: (q.get('pref') ?? '').split(',').filter(Boolean),
};
}
// ---------- ICS (종일 일정 4개: 입국·수술·실밥·출국) ----------
export function buildIcs(plan: Plan, titles: { arrive: string; surgery: string; stitch: string; departure: string }, calName: string): string {
const ev = (date: string, title: string, uid: string) => [
'BEGIN:VEVENT', `UID:${uid}@view-supporters`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`,
`DTSTART;VALUE=DATE:${date.replace(/-/g, '')}`, `DTEND;VALUE=DATE:${addDays(date, 1).replace(/-/g, '')}`, `SUMMARY:${title}`, 'END:VEVENT',
];
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//View Supporters//Recovery Planner//KO', `X-WR-CALNAME:${calName}`];
lines.push(...ev(plan.arriveBy, titles.arrive, `arrive-${plan.arriveBy}`));
lines.push(...ev(plan.surgeryDate, titles.surgery, `surgery-${plan.surgeryDate}`));
if (plan.stitchDate) lines.push(...ev(plan.stitchDate, titles.stitch, `stitch-${plan.stitchDate}`));
if (plan.earliestDeparture) lines.push(...ev(plan.earliestDeparture, titles.departure, `depart-${plan.earliestDeparture}`));
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
/** 상담 신청에 붙여 보낼 요약문. */
export function summaryText(plan: Plan, p: Procedure, lang: Lang, t: Record<string, string>): string {
const pending = t.pending;
const row = (k: string, v: string | null) => `${k}: ${v ?? pending}`;
return [
`${t.summaryTitle} (${p.label[lang]})`,
row(t.arriveBy, plan.arriveBy),
row(t.surgery, plan.surgeryDate),
row(t.hospital, p.hospitalNights.value === null ? null : p.hospitalNights.value === 0 ? t.hospitalNone : t.hospitalNights.replace('{n}', String(p.hospitalNights.value))),
row(t.stitch, plan.stitchDate),
row(t.departure, plan.earliestDeparture),
row(t.stayNights, plan.stayNights === null ? null : t.nights.replace('{n}', String(plan.stayNights))),
].join('\n');
}

View File

@ -0,0 +1,89 @@
// 한국관광공사 TourAPI 사전 수집 데이터(src/data/medicalTourism*.json)를 회복 일정 플래너의 장소·축제 후보로 바꾼다 (설계 v0.2 §13.5, 2순위 소스).
// 수집은 scripts/fetch_medical_tourism.py 가 병원 좌표 기준으로 미리 해 두고, 화면은 이 파일의 결과만 쓴다. 외부 호출 없음.
// 식이 적합성은 TourAPI 가 주지 않으므로 판정하지 않는다: 음식점은 일반식 단계에서만 후보가 되고, 죽·스무디 같은 회복 초기 식사는 큐레이션(places.json)이 맡는다.
import type { Place, L10n } from './plan';
export interface TourItem {
id: string; title: string; address: string; image: string | null; tel: string | null; lat: number; lng: number;
distanceM: number; travel: { mode: string; minutes: number; label: string } | null; overview: string; homepage: string;
startDate?: string; endDate?: string; season?: string;
}
export interface TourData {
places: Record<string, TourItem[]>;
festivals: TourItem[];
meta: { lang?: string; source: string; fetchedAt: string; origin?: { lat: number; lng: number; label: string } };
}
/** TourAPI 유형 → 플래너 카테고리. wellness(사우나·스파)는 회복기 금지 항목이라 후보에서 뺀다. */
const CATEGORY: Record<string, string | null> = { attraction: 'sightseeing', culture: 'sightseeing', shopping: 'shopping', restaurant: 'food', stay: 'hotel', wellness: null };
/** 거리로 외출 등급을 정한다: 600m 안 근거리(L1), 3km 안 반나절(L2), 그 밖은 자유 외출(L3). */
export const levelByDistance = (m: number): number => (m <= 600 ? 1 : m <= 3000 ? 2 : 3);
const both = (s: string): L10n => ({ ko: s, en: s });
/** 주소를 짧은 동네 표기로. 국문은 '구 도로명', 영문은 첫 쉼표 앞 도로명. */
const areaLabel = (address: string, lang: 'ko' | 'en'): string => {
const a = (address || '').trim();
if (lang === 'en') return a.split(',').map((x) => x.trim()).filter((x) => x && !/^\d+$/.test(x))[0] ?? a;
return a.replace(/^서울특별시\s*/, '').split(/\s+/).slice(0, 2).join(' ');
};
const firstSentence = (s: string, n = 140): string => {
const t = (s || '').replace(/\d+\s*m?\s*NAVER\s*©\s*NAVER Corp\.?/gi, '').replace(/\s+/g, ' ').trim();
if (t.length <= n) return t;
const cut = t.slice(0, n);
const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('다. '), cut.lastIndexOf('。'));
return (stop > 40 ? cut.slice(0, stop + 1) : cut).trim() + (stop > 40 ? '' : '…');
};
/** "K-그랜드 호스텔 강남 1(K-GRAND HOSTEL GANGNAM1)" → 예약 검색용 영문 이름이 괄호 안에 있으면 그것을 쓴다. */
export const bookingName = (title: string): string => {
const m = title.match(/\(([^)]*[A-Za-z][^)]*)\)\s*$/);
return (m ? m[1] : title).trim();
};
export function tourToPlaces(data: TourData, lang: 'ko' | 'en'): Place[] {
const out: Place[] = [];
for (const [type, items] of Object.entries(data.places ?? {})) {
const category = CATEGORY[type];
if (!category) continue;
for (const it of items) {
if (!it.lat || !it.lng) continue;
const km = Math.round((it.distanceM / 1000) * 10) / 10;
out.push({
id: `tour-${type}-${it.id}`,
name: both(it.title),
category,
minLevel: category === 'hotel' ? 0 : levelByDistance(it.distanceM),
dietOk: category === 'food' ? ['normal'] : undefined,
distanceKm: km,
indoor: type === 'shopping' || type === 'culture' || type === 'restaurant' || type === 'stay',
area: both(areaLabel(it.address, lang)),
note: both(firstSentence(it.overview)),
mapQuery: it.title,
bookingQuery: category === 'hotel' ? bookingName(it.title) : undefined,
verify: true,
source: 'tourapi',
travelLabel: it.travel?.label ?? undefined,
image: it.image ?? undefined,
url: it.homepage || undefined,
});
}
}
return out;
}
export interface Festival { id: string; title: string; start: string; end: string; distanceKm: number; travelLabel?: string; blurb: string; image?: string; url?: string; address: string }
const iso = (d?: string) => (d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '');
/** 체류 기간 [from, to] 와 겹치는 행사만, 가까운 순. */
export function festivalsDuring(data: TourData, from: string, to: string, max = 6): Festival[] {
return (data.festivals ?? [])
.map((f) => ({ f, start: iso(f.startDate), end: iso(f.endDate) }))
.filter((x) => x.start && x.end && x.start <= to && x.end >= from)
.sort((a, b) => a.f.distanceM - b.f.distanceM)
.slice(0, max)
.map(({ f, start, end }) => ({
id: `fest-${f.id}`, title: f.title, start, end, distanceKm: Math.round((f.distanceM / 1000) * 10) / 10,
travelLabel: f.travel?.label ?? undefined, blurb: firstSentence(f.overview), image: f.image ?? undefined,
url: f.homepage || undefined, address: f.address,
}));
}

View File

@ -0,0 +1,17 @@
---
// 없는 주소. Vercel 이 dist/404.html 을 자동으로 쓴다. 옛 주소(/recovery, /stay)는 vercel.json 리다이렉트가 /plan 으로 먼저 보낸다.
import Base from '../layouts/Base.astro';
import { SITE_NAME } from '../lib';
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = { '@type': 'WebPage', '@id': `${site}/404#page`, name: '페이지를 찾을 수 없음', url: `${site}/404`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
---
<Base title="페이지를 찾을 수 없음" description="주소가 바뀌었거나 없는 페이지입니다. 홈, 회복 일정, 방문 안내로 이동할 수 있습니다." jsonLd={ld} noindex={true}>
<section class="section dark" style="padding:5rem 0 4.5rem"><div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">404</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Page Not Found</h1><p class="sub">주소가 바뀌었거나 없는 페이지입니다. 아래에서 이어서 보실 수 있습니다.</p></div>
<div class="grid">
<a class="card" href="/" style="text-decoration:none"><div class="cat">Home</div><h3>홈</h3><p>병원을 알아보는 분들의 질문과 답</p></a>
<a class="card" href="/plan" style="text-decoration:none"><div class="cat">Recovery Planner</div><h3>회복 일정</h3><p>수술일 기준 입국·출국 계산과 회복기 외출 계획. 영어 화면은 /en/plan</p></a>
<a class="card" href="/visit" style="text-decoration:none"><div class="cat">Visit</div><h3>방문 안내</h3><p>예약, 가는 길, 주차, 도착 후 동선</p></a>
</div>
</div></section>
</Base>

View File

@ -0,0 +1,16 @@
---
// Recovery planner, English page. Body lives in components/Planner.astro. Korean page is /plan.
import Base from '../../layouts/Base.astro';
import Planner from '../../components/Planner.astro';
import { fact, SITE_NAME, clinicSchema, v } from '../../lib';
const f = fact as Record<string, any>;
const clinicEn = (fact as Record<string, any>).nameEn || (fact as Record<string, any>).shortNameEn || (fact as Record<string, any>).shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/plan#page`, name: `${clinicEn} Recovery Planner`, url: `${site}/en/plan`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base lang="en" title="Recovery Planner · Arrival, earliest departure and recovery outings" description={`Plan your stay around your surgery date at ${clinicEn} in Seoul: when to arrive, the earliest you can fly home, which recovery days you can go out and where, plus hotels near the clinic. Calculated in your browser, stored nowhere.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/plan' }, { lang: 'en', href: '/en/plan' }]}>
<Planner lang="en" />
</Base>

View File

@ -1,440 +0,0 @@
---
// 외국인 환자용 체류 안내. 세 부분이다.
// 1. Around the clinic — 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals — 계절로 묶은 행사
// 3. Suggested days — 시간표와 지도가 붙은 동선
//
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has } from '../../lib';
import data from '../../data/medicalTourism.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' },
{ key: 'stay', label: 'Hotels', note: 'Places to sleep' },
{ key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' },
{ key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' },
{ key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' },
{ key: 'shopping', label: 'Shopping', note: 'Stores and markets' },
];
const SEASONS = [
{ key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' },
{ key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' },
];
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(q + ' Seoul')}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = T.meta.origin.lat, oLng = T.meta.origin.lng;
const pick = (key: string, maxMin: number, n: number): Place[] =>
((T.places[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => ((T.places[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
blurb: 'Everything within a short walk. You do not need a taxi.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, 'Your appointment ends here.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
blurb: 'Still close, but out of the block.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, 'Start from the clinic.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: 'When you can travel', who: 'Later in your stay',
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, 'Set off from the clinic.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
((T.places[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Your stay near ${clinic}`, url: `${site}/en/stay`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
lang="en"
title={`Your stay near ${clinic} — food, rest, sights and festivals`}
description={`For international patients at ${clinic} in Gangnam, Seoul: where to eat and rest, what to see, which festivals are on, and how to shape your days. Distances and walking times from the clinic. Source: Korea Tourism Organization.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">For international patients</div>
<h1 class="serif">Your Stay in Seoul</h1>
<p class="lede">
<strong>Around {clinic}.</strong> Everything below is measured from the clinic at {f.address?.fullEn || f.address?.full}.
Names, photographs and descriptions come from the Korea Tourism Organization.
For the clinic's own hours and phone, see <a href="/clinic" hreflang="ko">Clinic information<span class="lang-tag">KO</span></a>.
</p>
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">Loading</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="Jump to a section">
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
))}
<a href="#festivals">Festivals<b>{T.festivals.length}</b></a>
<a href="#days">Days<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>Around the clinic</h2>
<span class="upd">Updated {T.meta.fetchedAt.slice(0, 10)}</span>
</div>
<p class="sub">{T.filters.total} places, sorted by how long they take to reach on foot.</p>
<div class="chips" id="walkchips" role="group" aria-label="Filter by travel time">
<button class="chip on" data-max="all">All <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">Within 5 min walk <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">Within 15 min walk <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">Walking distance <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">By car <b>{T.filters.drive}</b></button>
</div>
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title} — ${g.label.toLowerCase()} near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">Official site ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>Festivals and events</h2></div>
<p class="sub">{T.festivals.length} events within {T.meta.festivalRadiusKm} km, grouped by season. Right now it is <strong>{nowSeason}</strong>.</p>
<div class="chips" role="group" aria-label="Filter by season">
<button class="chip on" data-season="all">All <b>{T.festivals.length}</b></button>
{SEASONS.map((s) => {
const n = T.festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>
<div class="cards fest">
{T.festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} — festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}</p>
<p class="desc">{firstSentence(e.overview) || 'No description provided by the tourism data.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>
</div>
</article>
))}
</div>
<p class="src">Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>Shaping your days</h2></div>
<p class="sub">
Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule.
</p>
{Sx.recoveryStagesEn
? <p><strong>What {clinic} advises.</strong> {Sx.recoveryStagesEn}</p>
: <p class="pending-note">Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`Map of ${pins.pins.length} stops: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>What this page does not tell you</h2>
<ul>
<li>No ratings, review counts or opening hours. The tourism data does not carry them.</li>
<li>No claim that any place suits your recovery. That is for {clinic} to say.</li>
<li>English tourism listings around Gangnam are thin. Only {T.filters.walk5} places sit within a five minute walk, and {T.filters.drive} of {T.filters.total} need a car.</li>
<li>We are not a travel agency, take no booking and no commission.</li>
</ul>
<p class="botnote">
{has(f.urlEn) && <a class="btn primary" href={f.urlEn} rel="noopener" hreflang="en">Book a consultation in English</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
56: 'Freezing drizzle', 57: 'Freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Freezing rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Violent showers',
85: 'Snow showers', 86: 'Snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with hail'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || 'Current conditions';
document.getElementById('wx-m').textContent =
'Feels like ' + Math.round(c.apparent_temperature) + '°C · wind ' +
Math.round(c.wind_speed_10m) + ' km/h · observed ' + String(c.time).replace('T', ' ');
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? 'Wet outside. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m >= 30
? 'Hot today. The Culture and Shopping listings below are indoors.'
: (c.temperature_2m <= 0
? 'Below freezing. The Culture and Shopping listings below are indoors.'
: 'Dry at the moment. The walking times below are on foot from the clinic.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -0,0 +1,16 @@
---
// 회복기 스케줄러 한국어 페이지. 본문은 components/Planner.astro. 영어 페이지는 /en/plan.
import Base from '../layouts/Base.astro';
import Planner from '../components/Planner.astro';
import { fact, SITE_NAME, clinicSchema, v } from '../lib';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [
{ '@type': 'WebPage', '@id': `${site}/plan#page`, name: `${clinic} 회복 일정 플래너`, url: `${site}/plan`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base title="회복 일정 · 입국일, 출국 가능일, 회복기 외출 계획" description={`${clinic} 수술일을 기준으로 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 갈 만한 곳, 병원 근처 숙소를 한 화면에서 정리합니다. 외국에서 오시는 분을 위한 일정표입니다.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/plan' }, { lang: 'en', href: '/en/plan' }]}>
<Planner lang="ko" />
</Base>

View File

@ -11,8 +11,8 @@ export const GET: APIRoute = async ({ site }) => {
{ loc: `${base}/posts`, lastmod: today },
{ loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/stay`, lastmod: today },
{ loc: `${base}/en/stay`, lastmod: today },
{ loc: `${base}/plan`, lastmod: today },
{ loc: `${base}/en/plan`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today },
{ loc: `${base}/videos`, lastmod: today },
{ loc: `${base}/about`, lastmod: today },

View File

@ -1,445 +0,0 @@
---
// 병원 주변 안내(한국어). /en/stay 의 한국어 짝이다. 데이터는 국문 서비스(KorService2)라
// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건).
// 구조는 /en/stay 와 같다. 세 부분이다.
// 1. 병원 주변 — 도보·차 소요 시간으로 거른 주변 장소
// 2. Festivals — 계절로 묶은 행사
// 3. Suggested days — 시간표와 지도가 붙은 동선
//
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
//
// 만들지 않는 것
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
import Base from '../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib';
import data from '../data/medicalTourismKo.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortName || '병원';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
type Place = {
id: string; title: string; address: string; image: string | null; overview: string;
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
travel: { mode: string; minutes: number; label: string } | null;
};
const GROUPS: Array<{ key: string; label: string; note: string }> = [
{ key: 'restaurant', label: '식사', note: '음식점과 카페' },
{ key: 'stay', label: '숙박', note: '묵을 곳' },
{ key: 'wellness', label: '휴식', note: '스파와 찜질방' },
{ key: 'attraction', label: '관광', note: '거리, 공원, 명소' },
{ key: 'culture', label: '문화', note: '미술관, 공연장' },
{ key: 'shopping', label: '쇼핑', note: '백화점과 시장' },
];
const SEASONS = [
{ key: 'spring', label: '봄' }, { key: 'summer', label: '여름' },
{ key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' },
];
const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record<string, string>)[nowSeasonKey];
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
// 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다.
const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
const dt = (d: string | null) => (d && d.length === 8 ? `${+d.slice(4, 6)}.${+d.slice(6, 8)}` : '');
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
const shortName = (t: string) => {
const m = t.match(/\(([^)]+)\)\s*$/);
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
};
// travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다.
const travelLabel = (t: { mode: string; minutes: number } | null) =>
!t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`;
const firstSentence = (s: string, n = 150) => {
if (!s) return '';
const cut = s.slice(0, n);
const stop = cut.lastIndexOf('. ');
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
};
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
const oLat = T.meta.origin.lat, oLng = T.meta.origin.lng;
const pick = (key: string, maxMin: number, n: number): Place[] =>
((T.places[key] ?? []) as Place[])
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
.slice(0, n);
const pickAny = (key: string, n: number): Place[] => ((T.places[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
let t = startMin;
const out: any[] = [];
stops.forEach((s, i) => {
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
t += move;
const from = t;
t += s.stay;
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
});
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
}
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
const ROUTES = [
{
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
blurb: 'Everything within a short walk. You do not need a taxi.',
route: buildRoute(15 * 60, [
clinicStop(clinic, 40, 'Your appointment ends here.'),
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
blurb: 'Still close, but out of the block.',
route: buildRoute(13 * 60, [
clinicStop(clinic, 20, 'Start from the clinic.'),
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
]),
},
{
id: 'city', title: 'When you can travel', who: 'Later in your stay',
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
route: buildRoute(10 * 60, [
clinicStop(clinic, 10, 'Set off from the clinic.'),
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
]),
},
].filter((r) => r.route.stops.length > 1);
/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다.
빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다.
타일은 OSM 정책상 출처를 반드시 표기한다. */
const TILE = 256, MAPW = 680, MAPH = 340;
const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z;
const latToPx = (lat: number, z: number) => {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z;
};
function miniMap(stops: any[]) {
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
let z = 17;
for (; z > 10; z--) {
const w = lngToPx(ln1, z) - lngToPx(ln0, z);
const h = latToPx(la0, z) - latToPx(la1, z);
if (w <= MAPW - 90 && h <= MAPH - 90) break;
}
const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2;
const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2;
const left = cx - MAPW / 2, top = cy - MAPH / 2;
const tiles: Array<{ src: string; x: number; y: number }> = [];
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) {
const n = 2 ** z;
if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue;
tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top });
}
}
const pins = stops.map((s, i) => ({
x: +(lngToPx(s.lng, z) - left).toFixed(1),
y: +(latToPx(s.lat, z) - top).toFixed(1),
n: i + 1, label: s.label,
}));
return { tiles, pins, z };
}
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
((T.places[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
const ld = [
{ '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base
title={`${clinic} 주변 안내 · 식사, 숙박, 휴식, 관광, 축제`}
description={`${clinic} 주변에서 어디서 먹고 묵고 쉴 수 있는지, 무엇을 볼 수 있는지, 어떤 축제가 열리는지를 병원에서의 거리와 도보 시간으로 정리했습니다. 출처는 한국관광공사입니다.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">병원 주변</div>
<h1 class="serif">병원 주변 안내</h1>
<p class="lede">
<strong>{clinic} 주변입니다.</strong> 아래 거리와 시간은 모두 {v(f.address?.full)} 기준입니다.
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에 있습니다.
</p>
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">불러오는 중</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="섹션으로 이동">
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
))}
<a href="#festivals">축제<b>{T.festivals.length}</b></a>
<a href="#days">동선<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>병원 주변</h2>
<span class="upd">{T.meta.fetchedAt.slice(0, 10)} 갱신</span>
</div>
<p class="sub">{T.filters.total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.</p>
<div class="chips" id="walkchips" role="group" aria-label="이동 시간으로 거르기">
<button class="chip on" data-max="all">전체 <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">도보 5분 이내 <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">도보 15분 이내 <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">걸어갈 수 있는 곳 <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">차로 가는 곳 <b>{T.filters.drive}</b></button>
</div>
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title} — ${g.label.toLowerCase()} near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{travelLabel(p.travel)} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">공식 홈페이지 ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>계절별 축제</h2></div>
<p class="sub">{T.meta.festivalRadiusKm}km 안의 행사 {T.festivals.length}건을 계절로 묶었습니다. 지금은 <strong>{nowSeason}</strong>입니다.</p>
<div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip on" data-season="all">전체 <b>{T.festivals.length}</b></button>
{SEASONS.map((s) => {
const n = T.festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>
<div class="cards fest">
{T.festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} — festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}</p>
<p class="desc">{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>
</div>
</article>
))}
</div>
<p class="src">출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>하루 동선</h2></div>
<p class="sub">
위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다.
</p>
{Sx.recoveryStagesKo
? <p><strong>{clinic} 안내.</strong> {Sx.recoveryStagesKo}</p>
: <p class="pending-note">어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`동선 지도, 정차 ${pins.pins.length}곳: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>이 페이지가 답하지 못하는 것</h2>
<ul>
<li>평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.</li>
<li>어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.</li>
<li>도보 5분 안에는 {T.filters.walk5}곳뿐이고, {T.filters.total}곳 중 {T.filters.drive}곳은 차로 가야 합니다.</li>
<li>여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.</li>
</ul>
<p class="botnote">
{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: '맑음', 1: '대체로 맑음', 2: '구름 조금', 3: '흐림',
45: '안개', 48: '서리 안개', 51: '약한 이슬비', 53: '이슬비', 55: '강한 이슬비',
56: '언 이슬비', 57: '언 이슬비', 61: '약한 비', 63: '비', 65: '강한 비',
66: '언 비', 67: '언 비', 71: '약한 눈', 73: '눈', 75: '강한 눈',
77: '싸락눈', 80: '약한 소나기', 81: '소나기', 82: '강한 소나기',
85: '진눈깨비', 86: '진눈깨비', 95: '뇌우', 96: '우박을 동반한 뇌우', 99: '우박을 동반한 뇌우'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || '현재 날씨';
document.getElementById('wx-m').textContent =
'체감 ' + Math.round(c.apparent_temperature) + '°C · 바람 ' +
Math.round(c.wind_speed_10m) + ' km/h · ' + String(c.time).replace('T', ' ') + ' 관측';
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? '비가 옵니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m >= 30
? '더운 날입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m <= 0
? '영하입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: '지금은 비가 오지 않습니다. 아래 시간은 병원에서 걸어가는 기준입니다.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>

View File

@ -42,7 +42,7 @@ const ld = [
{!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
</ul>
<h2>6. 해외에서 오시는 경우</h2>
<p>수술 전후에 머무는 동안의 숙박·식사·회복·관광 정보를 영문으로 정리했습니다. <a href="/en/stay" hreflang="en">Your Stay in Seoul</a>.</p>
<p>수술 전후에 머무는 동안의 숙박·식사·회복·관광 정보를 영문으로 정리했습니다. <a href="/en/plan" hreflang="en">Recovery Planner</a>.</p>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p>
<p class="disclosure">{fact.sideEffectNotice}</p>

View File

@ -387,6 +387,24 @@ td { color: var(--slate-700); }
.stay .timeline h4 { margin: 0.1rem 0 0.2rem; font-size: 0.98rem; }
.stay .timeline p { margin: 0; font-size: 0.86rem; color: var(--slate-600, #475569); }
.stay .botnote { margin-top: 1.6rem; }
/* 시술 후 관리 안내(병원 원문 인용). 동선 카드(.route)·정차(.stop) 룩을 그대로 쓰고 접이식 동작만 더한다. 새 색·크기 없음. */
.stay details.recovery > summary.btn { list-style: none; cursor: pointer; }
.stay details.recovery > summary.btn::-webkit-details-marker { display: none; }
.stay details.recovery > summary.btn .hide { display: none; }
.stay details.recovery[open] > summary.btn .show { display: none; }
.stay details.recovery[open] > summary.btn .hide { display: inline; }
.stay details.recovery[open] > summary.btn { margin-bottom: 1.1rem; }
.stay details.recovery > .timeline { margin-top: 0.2rem; }
.stay .recovery .timeline > li { border-top: 1px solid var(--line, #E2E8F0); padding: 0.7rem 0; }
.stay .recovery .timeline > li:first-child { border-top: 0; padding-top: 0; }
.stay .recovery summary { cursor: pointer; list-style: none; }
.stay .recovery summary::-webkit-details-marker { display: none; }
.stay .recovery summary .stop { display: flex; }
.stay .recovery summary h4 { margin: 0.1rem 0 0; }
.stay .recovery details[open] summary h4 { text-decoration: underline; text-underline-offset: 0.2em; text-decoration-color: var(--slate-200, #E2E8F0); }
.stay .recovery details > ul { margin: 0.7rem 0 0.5rem 2.2rem; padding-left: 1.1rem; }
.stay .recovery details > ul li { font-size: 0.9rem; color: var(--slate-700, #334155); margin-bottom: 0.35rem; }
.stay .recovery details > p { margin: 0 0 0 2.2rem; font-size: 0.84rem; color: var(--slate-500, #64748B); }
@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } }
/* 섹션 점프. 머리말 아래에 붙어 스크롤을 따라온다. 헤더(68px) 아래에 걸리게 top 을 맞춘다. */

View File

@ -0,0 +1,172 @@
/* 회복기 스케줄러 (/plan, /en/plan). 섹션 리듬·카드·통계는 global.css .section/.sec-head/.grid/.card/.stats/.stat 그대로 쓰고,
여기에는 입력 컨트롤·타임라인·배지처럼 페이지에만 있는 요소만 둔다. 배경 강조는 인디고, 연보라는 다크 전용. */
:root { --indigo: #1D0024; }
/* 히어로 (다크, videos.astro 규격) */
.plan-hero { padding: 3.5rem 0 3rem; }
.plan-hero h1 { font-family: "Playfair Display", Georgia, serif; font-size: 2.3rem; font-weight: 700; letter-spacing: 0; line-height: 1.15; display: inline-block; background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; margin: 0; }
.plan-hero .lede { color: rgba(255,255,255,0.82); font-size: 1.02rem; max-width: 760px; margin: 0.6rem 0 0; }
.plan-hero .lede strong { color: #fff; }
.lang-links a { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1rem 0.45rem 0.55rem; border-radius: 999px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #fff; text-decoration: none; font-size: 0.9rem; font-weight: 600; }
.lang-links a .ico { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; background: rgba(255,255,255,0.12); font-family: Inter, sans-serif; font-size: 0.66rem; letter-spacing: 0.06em; color: var(--purple-200); }
.lang-links a.on { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); border-color: transparent; }
.lang-links a.on .ico { background: rgba(255,255,255,0.2); color: #fff; }
.lang-links a:hover { text-decoration: none; border-color: rgba(216,180,254,0.6); }
.plan-hero .stats { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
/* 입력 단계 카드 (틴트 섹션) */
.steps-grid { display: grid; grid-template-columns: minmax(260px, 1fr) 2fr; gap: 1.25rem; }
.steps-grid .card.span { grid-column: 1 / -1; }
@media (max-width: 860px) { .steps-grid { grid-template-columns: 1fr; } }
.card .cat.step { display: inline-flex; align-items: center; gap: 0.45rem; }
.card .cat.step .n { display: inline-grid; place-items: center; width: 1.35rem; height: 1.35rem; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; font-family: "Playfair Display", Georgia, serif; font-size: 0.8rem; font-weight: 700; }
.card h3.serif { font-family: "Playfair Display", Georgia, serif; font-size: 1.12rem; }
.card .sub2 { font-size: 0.88rem; color: var(--slate-600); margin: 0; flex: 0; }
.card .hint { font-size: 0.83rem; color: var(--slate-500); margin: 0.4rem 0 0; flex: 0; }
.field { display: grid; gap: 0.35rem; margin-top: 0.5rem; }
.field label { font-size: 0.82rem; font-weight: 600; color: var(--slate-600); }
.field select, .field input[type="date"] { font: inherit; font-size: 1rem; padding: 0.6rem 0.8rem; border: 1px solid var(--slate-200); border-radius: 12px; background: #fff; color: var(--indigo); max-width: 320px; }
.lang-inline a { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.85rem; font-weight: 600; text-decoration: none; }
.lang-inline a.on { background: var(--primary-900); color: #fff; border-color: var(--primary-900); }
.proc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.55rem; }
.proc-grid button { text-align: left; border: 1px solid var(--slate-200); background: #fff; border-radius: 14px; padding: 0.7rem 0.85rem; font: inherit; cursor: pointer; color: var(--indigo); display: grid; gap: 0.1rem; }
.proc-grid button small { color: var(--slate-500); font-size: 0.7rem; letter-spacing: 0.05em; text-transform: uppercase; font-family: Inter, Pretendard, sans-serif; }
.proc-grid button b { font-size: 0.95rem; font-weight: 700; }
.proc-grid button.on { border-color: var(--accent); background: var(--st-good-bg); box-shadow: 0 0 0 2px rgba(108,92,231,0.25) inset; }
.pref-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pref-row button { border: 1px solid var(--slate-200); background: #fff; color: var(--indigo); border-radius: 999px; padding: 0.5rem 1rem; font: inherit; font-size: 0.92rem; font-weight: 600; cursor: pointer; }
.pref-row button.on { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-color: transparent; }
.plan-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; margin-top: 1rem; }
.btn.small { padding: 0.55rem 1rem; font-size: 0.88rem; }
.btn.ghost { background: #fff; color: var(--indigo); border: 1px solid var(--slate-200); }
button.btn { font: inherit; cursor: pointer; border: 0; }
/* 빈 상태 (다크) */
.empty { text-align: center; padding: 2.4rem 1rem; border: 1px dashed rgba(255,255,255,0.25); border-radius: 16px; color: rgba(255,255,255,0.75); }
.empty b { display: block; color: #fff; font-size: 1.1rem; margin-bottom: 0.3rem; }
/* 배지 */
.tag { display: inline-block; font-size: 0.72rem; font-weight: 700; padding: 0.12rem 0.55rem; border-radius: 999px; border: 1px solid transparent; vertical-align: middle; white-space: nowrap; }
.tag.prov { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); }
.tag.clinic { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.tag.l0 { background: var(--st-critical-bg); color: var(--st-critical-text); border-color: var(--st-critical-border); }
.tag.l1 { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); }
.tag.l2 { background: var(--st-info-bg); color: var(--st-info-text); border-color: var(--st-info-border); }
.tag.l3 { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.tag.diet { background: var(--slate-100); color: var(--slate-700); }
/* 다크 섹션 핵심 날짜: 글래스 통계 카드 변형 */
.stats.dates { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.stats.dates .stat { display: flex; flex-direction: column; }
.stats.dates .stat .label { order: -1; margin: 0 0 0.35rem; font-size: 0.8rem; color: rgba(255,255,255,0.72); font-weight: 600; letter-spacing: 0.02em; }
.stats.dates .stat b { font-size: 1.75rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; }
.stats.dates .stat b .wd { font-family: Pretendard, Inter, sans-serif; font-size: 0.85rem; font-weight: 600; color: rgba(255,255,255,0.7); }
.stats.dates .stat b .txt { font-family: Pretendard, Inter, sans-serif; font-size: 1.15rem; color: #fff; }
.stats.dates .stat .desc { margin-top: 0.55rem; }
.stats.dates .tag.clinic { background: rgba(175,144,255,0.18); color: var(--purple-200); border-color: rgba(216,180,254,0.45); }
.section.dark .note { color: rgba(255,255,255,0.72); font-size: 0.88rem; margin: 1.2rem 0 0; max-width: 900px; }
.section.dark .note a { color: var(--purple-300); }
/* 라이트 섹션 카드 내용 */
.card ul.rules { margin: 0.2rem 0 0; padding-left: 1.15rem; font-size: 0.92rem; color: var(--slate-700); }
.card ul.rules li { margin-bottom: 0.4rem; }
.card.ok { background: var(--st-good-bg); border-color: var(--st-good-border); }
.card.warn { background: var(--st-warning-bg); border-color: var(--st-warning-border); }
.card.warn h3, .card.warn p { color: var(--st-warning-text); }
.card.ok h3, .card.ok p { color: var(--st-good-text); }
.card .fine { font-size: 0.8rem; color: var(--slate-500); margin-top: 0.3rem; flex: 0; }
.card.warn .fine, .card.ok .fine { color: inherit; opacity: 0.85; }
/* 타임라인 (틴트 섹션 위 흰 카드) */
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.2rem; font-size: 0.85rem; color: var(--slate-600); margin: 0 0 1.25rem; }
.legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
.timeline { display: grid; gap: 1.1rem; }
.day { display: grid; grid-template-columns: 160px 1fr; gap: 0.6rem 1.2rem; align-items: start; padding: 1rem 1.2rem; border-radius: 16px; border: 1px solid var(--slate-100); background: #fff; box-shadow: var(--shadow); }
.day.mark { border-color: var(--st-good-border); background: #FBFAFF; }
.day .d { display: grid; gap: 0.1rem; }
.day .d b { color: var(--primary-900); font-size: 1rem; font-family: "Playfair Display", Georgia, serif; font-weight: 700; }
.day .d span { font-size: 0.78rem; color: var(--slate-500); }
.day .d .ev { font-size: 0.78rem; font-weight: 700; color: var(--grad-start); }
.day .body { display: grid; gap: 0.45rem; }
.day .tags { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.day .tags .desc { font-size: 0.85rem; color: var(--slate-600); }
.day .picks { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.day .picks a, .day .picks span.pick { display: inline-flex; align-items: center; gap: 0.35rem; border: 1px solid var(--slate-200); border-radius: 999px; padding: 0.3rem 0.75rem; font-size: 0.85rem; color: var(--indigo); background: #fff; }
.day .picks a:hover { border-color: var(--accent); text-decoration: none; }
.day .picks small { color: var(--slate-500); font-size: 0.72rem; }
.day .rest { font-size: 0.88rem; color: var(--slate-500); }
@media (max-width: 640px) { .day { grid-template-columns: 1fr; } }
/* 체류 기간의 행사 카드 (TourAPI 이미지) */
.fest-card { padding: 0; overflow: hidden; }
.fest-card img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
.fest-card .cat, .fest-card h3, .fest-card p { margin-left: 1.5rem; margin-right: 1.5rem; }
.fest-card .cat { margin-top: 1.2rem; }
.fest-card > p:last-child { margin-bottom: 1.3rem; }
.day .picks a.fest { border-color: var(--st-good-border); background: var(--st-good-bg); }
/* 예약 (다크 섹션 위 흰 카드) */
.hotel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1.25rem; }
.hotel-grid .card small { color: var(--slate-500); font-size: 0.78rem; }
.hotel-grid .card .links { display: flex; gap: 0.7rem; flex-wrap: wrap; font-size: 0.85rem; font-weight: 600; }
.book-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.25rem; margin-top: 1.5rem; }
.book-row .card .plan-actions { margin-top: 0.4rem; }
.section.dark .disclosure { color: rgba(255,255,255,0.6); border-top-color: rgba(255,255,255,0.12); }
.section.dark .disclosure a, .section.dark .sources-list a { color: var(--purple-300); }
.section.dark .sources-list { font-size: 0.85rem; color: rgba(255,255,255,0.7); margin: 0.3rem 0 0; }
.toast { position: fixed; left: 50%; bottom: 2rem; transform: translateX(-50%); background: var(--primary-900); color: #fff; padding: 0.6rem 1.1rem; border-radius: 999px; font-size: 0.9rem; opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 20; }
.toast.show { opacity: 1; }
/* 방문 안내 진입 CTA 카드 (웜 그라디언트는 CTA 카드 한 장에만) */
.plan-entry { background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); border-radius: 16px; padding: 1.3rem 1.5rem; box-shadow: var(--shadow); margin: 2rem 0; display: grid; gap: 0.4rem; }
.plan-entry h2 { margin: 0; font-size: 1.1rem; color: var(--indigo); }
.plan-entry p { margin: 0; color: var(--slate-700); font-size: 0.95rem; }
.plan-entry .btn { justify-self: start; margin-top: 0.4rem; }
/* 태블릿 (≤900px): 히어로 통계 2열 */
@media (max-width: 900px) { .plan-hero .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* 모바일 (≤720px): 통계·날짜 카드 2열, 타임라인 날짜 머리 가로 배치, 히어로 여백 축소 */
@media (max-width: 720px) {
.plan-hero h1 { font-size: 1.9rem; }
.plan-hero .lede { font-size: 0.95rem; }
.plan-hero .stats, .stats.dates { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.7rem; }
.plan-hero .stat, .stats.dates .stat { padding: 1.2rem 1.2rem; }
.plan-hero .stat b { font-size: 1.8rem; }
.plan-hero .stat .label { font-size: 0.9rem; }
.plan-hero .stat .desc { font-size: 0.78rem; }
.stats.dates .stat b { font-size: 1.35rem; }
.stats.dates .stat .desc { font-size: 0.78rem; }
.field select, .field input[type="date"] { max-width: none; width: 100%; font-size: 16px; }
.proc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.day { gap: 0.5rem; }
.day .d { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.6rem; }
.day .picks a, .day .picks span.pick { font-size: 0.82rem; padding: 0.3rem 0.65rem; }
.book-row { grid-template-columns: 1fr; }
.hotel-grid { grid-template-columns: 1fr; }
.plan-result-pad { padding-bottom: 4.5rem; }
}
/* 하단 고정 요약 바 (모바일에서 결과가 있을 때만) */
.sticky-bar { display: none; }
@media (max-width: 720px) {
.sticky-bar { position: fixed; left: 0.8rem; right: 4.2rem; bottom: 0.9rem; z-index: 15; display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; background: var(--primary-900); color: #fff; border-radius: 999px; padding: 0.55rem 0.6rem 0.55rem 1rem; box-shadow: 0 10px 28px rgba(2,19,65,0.35); transition: opacity 0.2s, transform 0.2s; }
.sticky-bar[hidden] { display: none; }
.sticky-bar .s { display: grid; line-height: 1.2; min-width: 0; }
.sticky-bar .s b { font-family: "Playfair Display", Georgia, serif; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sticky-bar .s small { font-size: 0.7rem; color: var(--purple-200); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sticky-bar button { flex: none; border: 0; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), #6C5CE7); color: #fff; font: inherit; font-size: 0.82rem; font-weight: 700; padding: 0.5rem 0.9rem; cursor: pointer; }
}
@media print {
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .plan-input, .plan-actions, .toast, .lang-links, .sticky-bar { display: none !important; }
.section { padding: 1rem 0 !important; }
.section.dark, .section.tint { background: #fff !important; color: var(--primary-900); }
.section.dark .sec-head h2, .plan-hero h1 { -webkit-text-fill-color: var(--primary-900); background: none; }
.stat { background: #fff; border: 1px solid var(--slate-200); } .stat b { color: var(--primary-900); } .stat .label, .stat .desc { color: var(--slate-700); }
.card, .day { box-shadow: none; break-inside: avoid; }
}

View File

@ -21,5 +21,27 @@
}
],
"cleanUrls": true,
"trailingSlash": false
"trailingSlash": false,
"redirects": [
{
"source": "/recovery",
"destination": "/plan",
"permanent": true
},
{
"source": "/stay",
"destination": "/plan",
"permanent": true
},
{
"source": "/en/recovery",
"destination": "/en/plan",
"permanent": true
},
{
"source": "/en/stay",
"destination": "/en/plan",
"permanent": true
}
]
}

View File

@ -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

View 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` 안에 "주변 장소·축제" 보강 데이터로 합치는 것을 다음 단계로 검토합니다. 합치기 전까지 프로덕션에 올리지 않습니다.

View File

@ -9,4 +9,6 @@ export default defineConfig({
trailingSlash: 'never',
build: { format: 'file' },
compressHTML: true,
// 주변 안내(/stay)는 회복 일정(/recovery)으로 합쳤다(haewon 2026-09-11). 정적 빌드라 meta refresh 페이지가 생긴다.
redirects: { '/stay': '/recovery', '/en/stay': '/en/recovery' },
});

View File

@ -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 코드를 검사한다.

View 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 (렌더링 이미지, 간판 오라클피부과의원). 같은 페이지의 다른 사진(화장품·모델·장비·전자차트)은 제외."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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"
}
]
}

View 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 }
]
}

View File

@ -0,0 +1,83 @@
// 회복 일정 플래너 병원별 데이터 생성 (워커 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;
let attached = 0, unmatched = [];
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 || '' });
}
(it.ko ?? []).forEach((ko, i) => {
const en = (it.en ?? [])[i] || ko; // 번역 실패 문장은 한글 원문 그대로 (워커 recovery 단계와 같은 규칙)
const cid = `${srcId}-${i}`;
base.constraints[cid] = { ko: ko.trim(), en: en.trim() };
if (!target.constraints.includes(cid)) target.constraints.unshift(cid); // 병원 원문이 잠정 안내보다 먼저 보이게
attached++;
});
if (!target.sources.includes(srcId)) target.sources.push(srcId);
}
for (const p of base.procedures) delete p.matchKeywords;
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}문장 붙임${unmatched.length ? ` · 매칭 안 된 시술 ${unmatched.length}(${unmatched.slice(0, 4).join(', ')})` : ''} · ${placesSummary}`);

View File

@ -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

View File

@ -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(', ')}`);

View 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}`);

View File

@ -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(', ')}` : ''}`);

View File

@ -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('/');

View File

@ -0,0 +1,2 @@
<!-- expect: BANNED_PHRASE -->
<body><main><h1>사례</h1><p>비포애프터 사진은 상담실에서 볼 수 있습니다.</p></main></body>

View File

@ -0,0 +1,2 @@
<!-- expect: (none) -->
<body><main><h1>정품 정량 서약</h1><p>병원은 홈페이지에서 정품·정량 사용을 밝힙니다. 이 매체는 전후 사진을 싣지 않습니다. 수술 전후 1:1 모니터링을 안내합니다.</p></main></body>

View File

@ -0,0 +1,2 @@
<!-- expect: BANNED_PHRASE -->
<body><main><h1>정품 정량</h1><p>이 병원은 정품 정량 인증을 받은 병원입니다. 전후 사진을 꼭 확인하세요.</p></main></body>

View File

@ -0,0 +1,12 @@
---
expect: [DEVICE_CLAIM]
---
## 장비
이 병원의 리프팅 장비는 식약처 허가를 받은 장비라고 홈페이지에 적혀 있습니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 직접 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [DEVICE_CLAIM]
---
## 효과
레이저 제모는 5회면 영구 제모가 됩니다. 여드름은 치료 후 재발이 없습니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 직접 |
상담에서 확인합니다.

View 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 }
---
## 장비와 효과
홈페이지는 이 장비가 식약처 허가를 받았다고 씁니다. 허가는 해당 제품과 적응증에 한정되며 위험이 없다는 뜻은 아닙니다. 레이저 제모가 영구 제모라는 뜻은 아니며, 유지기간은 개인차가 있습니다. 반영구 화장은 별도 시술입니다.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 의사 감독 |
상담에서 확인합니다.

View File

@ -0,0 +1,14 @@
---
expect: []
expectWarn: [PRACTITIONER_MISSING]
industry: derm
category: D
---
## 시술 조건
| 항목 | 병원 안내 |
|---|---|
| 시술시간 | 확인 |
| 마취 | 확인 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,13 @@
---
expect: []
expectNoWarn: [PRACTITIONER_MISSING]
industry: plastic
category: D
---
## 수술 조건
| 항목 | 병원 안내 |
|---|---|
| 수술 시간 | 확인 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,14 @@
---
expect: []
expectNoWarn: [PRACTITIONER_MISSING]
industry: derm
category: D
---
## 시술 조건
| 항목 | 병원 안내 |
|---|---|
| 시술시간 | 확인 |
| 시술자 | 확인 대기 |
개인차가 있습니다. 상담에서 확인하세요.

View File

@ -0,0 +1,12 @@
---
expect: [PRICE_MENTION]
---
## 시술 비용
울쎄라 300샷은 이벤트가 990,000원이고 정가 대비 30% 할인입니다. 상담에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 확인 대기 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: []
---
## 시술 비용 구성
가격은 샷 수와 회차, 패키지 조건으로 구성됩니다. 금액은 병원의 비급여 진료비용 고지 페이지(https://example.com/notice)에서 확인하세요.
| 항목 | 병원 안내 |
|---|---|
| 시술자 | 확인 대기 |
상담에서 확인합니다.

View File

@ -1,5 +1,5 @@
---
expect: [REGULATION_SOURCE_MISSING]
expect: [REGULATION_SOURCE_MISSING, DEVICE_CLAIM]
---
## 승인

View 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}건 통과`);

View File

@ -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 = '') {
@ -249,7 +249,7 @@ export function checkVideoTitles(list, { level = E, label = '' } = {}) {
/** (news.json). ·· .
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56 2 2) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
* 수집기(collect_news.mjs) 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼/i; // "수술 전후 유방검진" 같은 시기 표현은 통과
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 = [];
@ -265,8 +265,12 @@ export function checkNewsItems(items, { level = E } = {}) {
// 본문 금칙 표현(의료법 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 [
@ -305,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),
@ -317,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)),
];

View File

@ -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 규칙)
@ -109,5 +115,38 @@ negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) :
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);

View File

@ -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') : '(제공된 페이지 없음)'}`);

View File

@ -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). */

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