feat: clinic registry DB + pipeline audit P0 fixes

## Clinic Registry
- data/clinic-registry/clinic_registry_working.csv — 91개 병원 채널 마스터 DB
- data/clinic-registry/INFINITH_Outbound_List.csv — BD팀 아웃바운드 리스트 (17컬럼)
- data/clinic-registry/update_csv.py — 안전 CSV 업데이트 스크립트 (빈 필드만 채움)
- data/clinic-registry/extract_place_ids.py — 네이버 플레이스 ID 추출기
- scripts/import-registry.ts — CSV → Supabase clinic_registry 테이블 임포트
- supabase/migrations/20260406_clinic_registry.sql — clinic_registry 테이블 스키마

## Pipeline P0 Bug Fixes (전수 감사 후)
- fix(collect-channel-data): 강남언니 rating 0-10 스케일 오변환 제거
  - 기존: rating ≤ 5이면 ×2 → 4.8/10을 9.6/10으로 잘못 변환
  - 수정: Firecrawl 프롬프트가 이미 0-10 지시 → rawValue 직접 신뢰
- fix(generate-report): Perplexity 단일 fetch → fetchWithRetry 교체
  - maxRetries:2, backoffMs:[5000,15000], timeoutMs:90s
  - 기존: 타임아웃/429 시 리포트 생성 전체 실패
  - 수정: 자동 재시도로 일시적 API 오류 극복

## Docs
- docs/PIPELINE_IMPROVEMENT_PLAN.md — Sprint 0/1/2 완료 표시 + 전수 감사 결과 추가
- docs/REGISTRY_FUNCTIONAL_SPECS.md, DB_SCHEMA_V3.md 외 기획 문서 다수 추가

## New Components & Features
- supabase/functions/generate-content-plan, adjust-strategy — 콘텐츠 플랜/전략 조정
- src/components/plan/EditEntryModal, StrategyAdjustmentSection — 플랜 편집 UI
- supabase/functions/_shared/dataQuality, foundingYearExtractor, urlClassifier — 데이터 품질 유틸

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 09:33:25 +09:00
parent ec991057e6
commit d5f7f24e0a
51 changed files with 5390 additions and 252 deletions

View File

@ -0,0 +1,92 @@
순번,병원명,지역,지점/규모,공식 웹사이트,네이버 플레이스,네이버 리뷰 현황,강남언니,강남언니 비고,YouTube,Instagram (KR),네이버 블로그,활성 채널 수,컨택 담당자,컨택 상태,미팅 일정,BD 메모
1,바노바기성형외과,강남,,https://www.banobagi.com,https://m.place.naver.com/hospital/21033469,리뷰 773개,https://www.gangnamunni.com/hospitals/23,,https://www.youtube.com/c/banobagips,https://www.instagram.com/banobagi_ps/,https://blog.naver.com/banobagips,6,,,,
2,뷰성형외과,강남,뷰성형외과 역삼센터(역삼),https://www.viewclinic.com,https://m.place.naver.com/hospital/11709005,리뷰 776개,https://www.gangnamunni.com/hospitals/189,,https://www.youtube.com/@ViewclinicKR,https://www.instagram.com/viewplastic/,https://blog.naver.com/viewclinicps,6,,,,
3,아이디병원,강남,아이디병원 별관(역삼),https://www.idhospital.com,https://m.place.naver.com/hospital/11548359,,https://www.gangnamunni.com/hospitals/257,,https://www.youtube.com/user/IDhospital,https://www.instagram.com/idhospital,https://blog.naver.com/idfacial,6,,,,
4,그랜드성형외과,강남,,https://www.grandsurgery.com,https://m.place.naver.com/hospital/12322994,,https://www.gangnamunni.com/hospitals/62,,https://www.youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ,https://www.instagram.com/grand_korea/,https://blog.naver.com/grandprs,6,,,,
5,원진성형외과,강남,,https://www.k-wonjin.co.kr,https://m.place.naver.com/hospital/11887873,리뷰 9개,https://www.gangnamunni.com/hospitals/2500,,https://www.youtube.com/@wjwonjin,https://www.instagram.com/wonjin_official/,https://blog.naver.com/popokpop,7,,,,
6,마인드성형외과,강남,,https://www.mindprs.com,https://m.place.naver.com/hospital/1342923541,리뷰 8개,https://www.gangnamunni.com/hospitals/729,,https://www.youtube.com/channel/UCzM5tIgkC8Es10YmLI55R_w,https://www.instagram.com/mind.prs/,,4,,,,
7,브라운성형외과,강남,,https://www.braunps.co.kr,https://m.place.naver.com/hospital/13299185,리뷰 2451개,https://www.gangnamunni.com/hospitals/215,,https://www.youtube.com/@BraunPlasticSurgery,https://www.instagram.com/braunps_official/,,5,,,,
8,오메가성형외과,강남,,http://www.omegaps.co.kr,https://m.place.naver.com/hospital/12840364,리뷰 1711개,https://www.gangnamunni.com/hospitals/926,,https://www.youtube.com/channel/UC4C2BB4Dp9L_QyuyD22FL0A,https://www.instagram.com/omega_plastic_surgery/,,5,,,,
9,나나성형외과,강남,,https://www.nanaprs.com,https://m.place.naver.com/hospital/1518147116,리뷰 919개,https://www.gangnamunni.com/hospitals/938,,https://www.youtube.com/@Nanaprstv,https://www.instagram.com/nanaprs/,https://blog.naver.com/nanaprs1,6,,,,
10,노트성형외과,강남,,http://notebreast.com,https://m.place.naver.com/hospital/1572670484,리뷰 9203개,https://www.gangnamunni.com/hospitals/2186,,https://www.youtube.com/channel/UC2OIyb2serotqDyEzQYyCtA,https://www.instagram.com/note.prs/,https://blog.naver.com/noteprs01,6,,,,
11,디에이성형외과,강남,,https://daprs.com,https://m.place.naver.com/hospital/33084820,리뷰 1908개,https://www.gangnamunni.com/hospitals/250,,https://www.youtube.com/channel/UC0wlA-w5JIt0G0EeWQf65AQ,https://www.instagram.com/da_plastic_surgery,https://blog.naver.com/daprs,7,,,,
12,에이비성형외과,강남,,https://www.abps.co.kr,https://m.place.naver.com/hospital/1304260302,리뷰 23599개,https://www.gangnamunni.com/hospitals/3004,,https://www.youtube.com/@abplasticsurgery,https://www.instagram.com/ab_plasticsurgery_kr/,https://blog.naver.com/abps20,6,,,,
13,비아이오성형외과,강남,,http://biopskorea.com,https://m.place.naver.com/hospital/21428819,,,,https://www.youtube.com/channel/UC2WCPPw2onOI4UVqt7LIfKw,https://www.instagram.com/bio_dr.hongsungpyo,https://blog.naver.com/bioplastics20,5,,,,
14,윈성형외과,강남,,,,,,,,,,0,,,,
15,제이준성형외과,강남,,http://www.jjprs.com,https://m.place.naver.com/hospital/36294945,리뷰 873개,https://www.gangnamunni.com/hospitals/139,,https://www.youtube.com/channel/UC1p4msJnetDKeOW-B4nlwZA,https://www.instagram.com/jayjunps/,https://blog.naver.com/gusrlf00,6,,,,
16,마블성형외과,강남,마블성형외과 압구정(압구정),https://marbleps.com,https://m.place.naver.com/hospital/37206762,리뷰 15207개,https://www.gangnamunni.com/hospitals/141,,https://www.youtube.com/@marbleps,https://www.instagram.com/marble__ps/,https://blog.naver.com/marbleplastic,6,,,,
17,쥬얼리성형외과,강남,,https://www.jewelryps.kr,https://m.place.naver.com/hospital/13443226,리뷰 257개,https://www.gangnamunni.com/hospitals/55,,https://www.youtube.com/@JewelrypsKr1,https://www.instagram.com/jewelryps_kr,https://blog.naver.com/prsshin2,6,,,,
18,티에스성형외과,강남,,http://www.tsprs.com,https://m.place.naver.com/hospital/36905792,리뷰 397개,https://www.gangnamunni.com/hospitals/116,,https://www.youtube.com/channel/UCgnizu8p7lbCnfIs76O8J-A,https://www.instagram.com/tsprs_official/,https://blog.naver.com/tsprs,6,,,,
19,유노성형외과,강남,,https://www.yunoprs.com,https://m.place.naver.com/hospital/38486006,리뷰 9209개,https://www.gangnamunni.com/hospitals/248,,https://www.youtube.com/channel/UCrEe-LfBLXA1-bTGS4bWmtg,https://www.instagram.com/doctor.yuno/,https://blog.naver.com/yuno_blog,6,,,,
20,리젠성형외과,강남,,http://www.regen.co.kr,,,,,,,,0,,,,
21,리팅성형외과,강남,,https://liting.co.kr,https://m.place.naver.com/hospital/38673363,리뷰 5474개,https://www.gangnamunni.com/hospitals/331,,https://www.youtube.com/@liting_ps,https://www.instagram.com/liting_psps/,https://blog.naver.com/night140160,5,,,,
22,앤써성형외과,강남,,http://www.answer-ps.co.kr,https://m.place.naver.com/hospital/34138819,,https://www.gangnamunni.com/hospitals/1449,,https://www.youtube.com/@TV-gf8ms,https://www.instagram.com/answerps,https://blog.naver.com/answerps,6,,,,
23,더픽스성형외과,강남,,http://thefixps.com,https://m.place.naver.com/hospital/1310778318,,https://www.gangnamunni.com/hospitals/2196,,https://www.youtube.com/channel/UCXVNowmecu1tF-kCYhFE_QQ,https://www.instagram.com/thefixps/,https://blog.naver.com/donghwn,6,,,,
24,기린성형외과,강남,,https://girinps.com,https://m.place.naver.com/hospital/21705376,리뷰 152개,https://www.gangnamunni.com/hospitals/398,,https://www.youtube.com/@girin_official,https://www.instagram.com/girin_ps_official/,https://blog.naver.com/girinlife,6,,,,
25,페이스성형외과,강남,,http://www.face-plus.co.kr,https://m.place.naver.com/hospital/13347560,,https://www.gangnamunni.com/hospitals/1515,,https://www.youtube.com/@koreanplasticsurgery,https://www.instagram.com/faceplus_ps/,https://blog.naver.com/soonjung-49,5,,,,
26,압구정서울성형외과,압구정,,http://www.asps.co.kr,https://m.place.naver.com/hospital/11531189,리뷰 357개,https://www.gangnamunni.com/hospitals/213,,https://www.youtube.com/channel/UCqNtVCL2u5Xvx74ymboxDyg,https://www.instagram.com/asps_no.1/,https://blog.naver.com/asps0119,5,,,,
27,리젠메디컬그룹,압구정,,http://regenskin.co.kr,,,https://www.gangnamunni.com/hospitals/4154,윈느성형외과 리젠메디컬타워,,https://www.instagram.com/regenskin,https://blog.naver.com/shinygn1,4,,,,
28,코코성형외과,압구정,,http://www.kodoctor.co.kr,https://m.place.naver.com/hospital/1023834988,,https://www.gangnamunni.com/hospitals/623,,https://www.youtube.com/channel/UCc19LE6Elp0OTNo9gDizJMw,https://www.instagram.com/koko_ps_official/,https://blog.naver.com/koko_plastic_surgery,6,,,,
29,오브제성형외과,압구정,,http://objetps.com,,,https://www.gangnamunni.com/hospitals/2122,,https://www.youtube.com/channel/UC2QrFhj-S8oUrbfOp1NXKZA,https://www.instagram.com/objet_plastic_surgery/,https://blog.naver.com/objetps,4,,,,
30,리상성형외과,압구정,,https://theregenps.com,https://m.place.naver.com/hospital/1125605502,,https://www.gangnamunni.com/hospitals/6597,,https://www.youtube.com/@theregen_ps,https://www.instagram.com/theregen_ps,https://blog.naver.com/theps_kor,5,,,,
31,에이트성형외과,압구정,에이트성형외과 서초(서초),https://www.eightprs.com,https://m.place.naver.com/hospital/31986276,"방문자리뷰 586, 블로그리뷰 3120",https://www.gangnamunni.com/hospitals/166,,https://www.youtube.com/channel/UCpTmm84yJLgIBwBcBhjie4Q,https://www.instagram.com/eightps8/,https://blog.naver.com/eightplasticsurgery,6,,,,
32,오페라성형외과,압구정,,http://www.operasurgery.co.kr,https://m.place.naver.com/hospital/10827484,,https://www.gangnamunni.com/hospitals/108,,https://www.youtube.com/channel/UC2R_jfmmn0zE-dkButFS_ZA,https://www.instagram.com/opera_ps/,https://blog.naver.com/fasolt2,6,,,,
33,리엔장성형외과,압구정,,https://ps.lienjang.net,,,https://www.gangnamunni.com/hospitals/69,,https://www.youtube.com/channel/UCxyiPTH9xHqhPVfwyyosXMQ,https://www.instagram.com/lienjang_official/,,4,,,,
34,루호성형외과,압구정,,https://www.luho.kr,https://m.place.naver.com/hospital/21868487,"방문자리뷰 1577, 블로그리뷰 608",https://www.gangnamunni.com/hospitals/660,,https://www.youtube.com/channel/UCZE8dRSsc6CwdORbAhxV2Rw,https://www.instagram.com/luho_beauty/,,5,,,,
35,티아라성형외과,압구정,,,,,https://www.gangnamunni.com/hospitals/231,SC301의원으로 상호변경,,,,1,,,,
36,쏘울성형외과,압구정,,https://www.soulps.kr,,,https://www.gangnamunni.com/hospitals/4244,,,https://www.instagram.com/soul_plastic_surgery/,,2,,,,
37,에이탑성형외과,압구정,,https://atopps.com,https://m.place.naver.com/hospital/877412762,,https://www.gangnamunni.com/hospitals/300,,https://www.youtube.com/channel/UCPBCTxCX1hyRnXejA8QfFEQ,https://www.instagram.com/atop_instalog/,https://blog.naver.com/atopps_,6,,,,
38,유스성형외과,압구정,,,,,,,,,,0,,,,
39,유캔비성형외과,압구정,,https://www.ucanb.co.kr,,,https://www.gangnamunni.com/hospitals/563,,https://www.youtube.com/channel/UCegLA3CpLkDOe5h_DnhUpFA,https://www.instagram.com/ucanb_plastic_surgery/,https://blog.naver.com/ucanb2338,5,,,,
40,스타성형외과,압구정,,https://www.starclinic.co.kr,https://m.place.naver.com/hospital/2092418109,,,,https://www.youtube.com/channel/UCpIF_ffpk4R10_iw0q0tR7g,,,2,,,,
41,아이템성형외과,압구정,,https://www.ittemps.kr,,,,,https://www.youtube.com/channel/UCJolLz6ag4m2UfZh56HC2hQ,https://www.instagram.com/ittem_ps,https://blog.naver.com/ittem_ps,4,,,,
42,리코성형외과,압구정,,http://www.licoclinic.com,https://m.place.naver.com/hospital/20800108,,,,https://www.youtube.com/channel/UC3T_EiEpQr1xE16o69XCqjQ,https://www.instagram.com/licobest/,https://blog.naver.com/licops,5,,,,
43,플레저성형외과,압구정,,http://www.pleasureps.com,,,https://www.gangnamunni.com/hospitals/2991,,https://www.youtube.com/c/PSPS_korea,https://www.instagram.com/pleasure_ps/,https://blog.naver.com/2amsomething,4,,,,
44,비너스성형외과,압구정,,,,,,,,,,0,,,,
45,아이웰성형외과,압구정,,http://www.iwellps.com,,,https://www.gangnamunni.com/hospitals/58,,,https://www.instagram.com/iwellps/,,2,,,,
46,글로비성형외과,압구정,,https://glovips.com,https://m.place.naver.com/hospital/13359904,,https://www.gangnamunni.com/hospitals/54,,https://www.youtube.com/channel/UCH4Orbmc3cFqajWx1I6Xs2w,https://www.instagram.com/glovips/,https://blog.naver.com/glovips,6,,,,
47,엘르성형외과,압구정,,http://www.elleclinic.com,,,https://www.gangnamunni.com/hospitals/1181,,,https://www.instagram.com/elleps_korea/,,2,,,,
48,원픽성형외과,압구정,,https://onepeakps.com,,,https://www.gangnamunni.com/hospitals/3000,,,,,1,,,,
49,탑페이스성형외과,압구정,,https://www.topfaceps.com,https://m.place.naver.com/hospital/33283530,"방문자리뷰 312, 블로그리뷰 179",https://www.gangnamunni.com/hospitals/66,,https://www.youtube.com/channel/UCNfZc5aXZ5A1mqX5iIw91EA,https://www.instagram.com/topfaceps/,https://blog.naver.com/ina3599,5,,,,
50,세민성형외과,역삼,,http://www.semin100.co.kr,,,,,https://www.youtube.com/channel/UCKaNYEvRqME2h1lUSOYIYew,,https://blog.naver.com/semin100,2,,,,
51,에이치성형외과,역삼,,https://www.3dfit.co.kr,,,,,https://www.youtube.com/channel/UC6d8i9WwIkm161ueFyVU2PA,,https://blog.naver.com/ys100ps,3,,,,
52,리노보성형외과,역삼,,http://www.renovo.co.kr,https://m.place.naver.com/hospital/13023672,,https://www.gangnamunni.com/hospitals/4749,,,https://www.instagram.com/renovo.clinic.1/,,3,,,,
53,디엘성형외과,역삼,,https://www.dlprs.com,https://m.place.naver.com/hospital/1998543478,,https://www.gangnamunni.com/hospitals/5500,,https://www.youtube.com/@LINEKING_DL,https://www.instagram.com/dl_plastic_official/,,4,,,,
54,피알성형외과,역삼,,http://prprs.co.kr,https://m.place.naver.com/hospital/1655749880,,https://www.gangnamunni.com/hospitals/431,,https://www.youtube.com/@pr_ps,https://www.instagram.com/prprs_official/,,5,,,,
55,라프린성형외과,역삼,,http://laprinps.com,https://m.place.naver.com/hospital/33741139,,,,https://www.youtube.com/channel/UCfMt8TTT8kJklZORjf4nJ2w,https://www.instagram.com/laprin_kr/,https://blog.naver.com/ff8kds6t5cmxd,5,,,,
56,도도성형외과,역삼,,http://www.dodobeauty.com,,,,,,,https://blog.naver.com/mmscjh,1,,,,
57,엠제이성형외과,역삼,,https://www.mjskinclinic.com,,,,,https://www.youtube.com/channel/UCFjkFyYDu4HpLjc9axlh6YQ,,https://blog.naver.com/mjskinclinic,3,,,,
58,아우라성형외과,역삼,,http://psaura.com,https://m.place.naver.com/hospital/1806079685,,https://www.gangnamunni.com/hospitals/5870,,https://www.youtube.com/@auraps2024,https://www.instagram.com/auraps_official/,https://blog.naver.com/pristor,5,,,,
59,하이봄성형외과,역삼,,https://www.highvom.com,https://m.place.naver.com/hospital/1714219923,,https://www.gangnamunni.com/hospitals/2052,,https://www.youtube.com/channel/UCuuH9oWh9h29hVRmH2sHAuA,https://www.instagram.com/highvom/,https://blog.naver.com/hivom,6,,,,
60,에이원성형외과,역삼,,,,,,,,,,0,,,,
61,유앤아이성형외과,역삼,,https://www.uni114.co.kr,https://m.place.naver.com/hospital/60058049,,https://www.gangnamunni.com/hospitals/4459,,https://www.youtube.com/channel/UCHgZnNk3JIDxnoYcWSWtAJw,https://www.instagram.com/skinuni114/,,5,,,,
62,리본성형외과,역삼,,,,,https://www.gangnamunni.com/hospitals/339,,https://www.youtube.com/channel/UCyFdX9zxfu2e2JVTwPjki5A,https://www.instagram.com/rebornps_/,https://blog.naver.com/reborn1999,5,,,,
63,리메이성형외과,역삼,,,,,,,,,,0,,,,
64,라이크성형외과,역삼,,http://www.likeps.com,,,https://www.gangnamunni.com/hospitals/912,,https://www.youtube.com/c/LIKEPLASTICSURGERY,https://www.instagram.com/likeps_kr/,,3,,,,
65,케이플러스성형외과,역삼,,https://k-clinics.com,https://m.place.naver.com/hospital/36912372,,https://www.gangnamunni.com/hospitals/1265,,https://www.youtube.com/@k-plasticsurgery,https://www.instagram.com/k_plasticsurgery/,https://blog.naver.com/kclinics-osh,6,,,,
66,케이아트성형외과,역삼,,http://www.k-artps.com,,,,,https://www.youtube.com/channel/UCHs9LOYtauBIklXhPPZBAgA,https://www.instagram.com/kartps/,https://blog.naver.com/kartps,4,,,,
67,이룸성형외과,역삼,,http://www.seoulips.com,https://m.place.naver.com/hospital/1240083198,,https://www.gangnamunni.com/hospitals/839,,https://www.youtube.com/@seouliplasticsurgery,https://www.instagram.com/seoulips/,https://blog.naver.com/seoulips,7,,,,
68,서초서울성형외과,서초,,https://srprs.co.kr,https://m.place.naver.com/hospital/1692465198,,https://www.gangnamunni.com/hospitals/5554,,,https://www.instagram.com/saerops/,,3,,,,
69,서초연세성형외과,서초,,https://www.chaminst.com,,,https://www.gangnamunni.com/hospitals/4212,,https://www.youtube.com/channel/UCMxBfZivKz5jjhRiy0mzOXQ,https://www.instagram.com/chamin_ps,https://blog.naver.com/chaminst,4,,,,
70,리모성형외과,서초,,http://www.ksh-ps.com,https://m.place.naver.com/hospital/1865533181,,https://www.gangnamunni.com/hospitals/6680,,https://www.youtube.com/channel/UCCiH9OffJgZTMnkxatK8-5A,https://www.instagram.com/sh___ps/,https://blog.naver.com/shpsclinic,5,,,,
71,화이트성형외과,서초,,http://www.whiteclinic.com,,,,,,,,0,,,,
72,미호성형외과,서초,,https://mihops.co.kr,https://m.place.naver.com/hospital/344342362,,https://www.gangnamunni.com/hospitals/369,,https://www.youtube.com/channel/UC8kyWAnMM7f_xguyPxR_gtA,https://www.instagram.com/mihops_kr/,https://blog.naver.com/qalfuqbjtmnf,5,,,,
73,나우성형외과,서초,,,,,,,,,,0,,,,
74,와이즈성형외과,서초,,,,,https://www.gangnamunni.com/hospitals/2414,와이즈유의원,,https://www.instagram.com/wise_ps/,,2,,,,
75,케이스타성형외과,서초,,,,,,,,,,0,,,,
76,셀린성형외과,서초,,,,,,,,,,0,,,,
77,에비뉴성형외과,서초,,http://www.avenueps.com,,,https://www.gangnamunni.com/hospitals/6204,,https://www.youtube.com/channel/UCOTVAerYogSEia3L-ERkAFg,,,2,,,,
78,비온성형외과,서초,,http://www.bon-ps.com,https://m.place.naver.com/hospital/1492257313,,,,https://www.youtube.com/channel/UClCjGIfEb3b1N-Q5tCNSU5w,,https://blog.naver.com/miz2199,3,,,,
79,아크성형외과,서초,,http://arc-ps.com,https://m.place.naver.com/hospital/1638918034,,https://www.gangnamunni.com/hospitals/3569,,https://www.youtube.com/@-arcisart7955,https://www.instagram.com/arc_plastic_surgery/,,4,,,,
80,에버성형외과,서초,,http://www.everclinic.com,,,,,https://www.youtube.com/channel/UCwSusNRTM2B_mvdRd0yNVcw,https://www.instagram.com/everclinic0088/,https://blog.naver.com/drblue007,4,,,,
81,원스성형외과,서초,,,,,,,,,,0,,,,
82,메이성형외과,서초,,,,,,,,,,0,,,,
83,라인업성형외과,서초,,,,,,,,,,0,,,,
84,유앤미성형외과,서초,,https://www.knyounmeclinic.co.kr,https://m.place.naver.com/hospital/31068065,,https://www.gangnamunni.com/hospitals/1178,,https://www.youtube.com/channel/UCAROD-B1ZBinpyzql7o_IlA,https://www.instagram.com/knyounmeclinic/,,4,,,,
85,리더스성형외과,서초,,,,,,,,,,0,,,,
86,마노성형외과,서초,,https://manops.co.kr,,,https://www.gangnamunni.com/hospitals/3429,,https://www.youtube.com/@mano_ps,https://www.instagram.com/manops_official,https://blog.naver.com/tkhr4747,4,,,,
87,예롬성형외과,서초,,,,,https://www.gangnamunni.com/hospitals/450,,,,,1,,,,
88,에스엠성형외과,서초,,https://www.sm-ps.co.kr,https://m.place.naver.com/hospital/32876182,,https://www.gangnamunni.com/hospitals/413,,https://www.youtube.com/@smpsclinic,https://www.instagram.com/smps_plastic_surgery/,https://blog.naver.com/smps1004,6,,,,
89,리안성형외과,서초,,,,,,,,,,0,,,,
90,서울미작성형외과,서초,,https://mijakclinic.co.kr,,,,,https://www.youtube.com/channel/UCld-NgVeydtDRVTt6xQRbmA,https://www.instagram.com/mijak2444/,https://blog.naver.com/mijak3444,4,,,,
91,더원성형외과,서초,,https://www.theoneclinic.co.kr,,,https://www.gangnamunni.com/hospitals/5636,,,https://www.instagram.com/theone_plastic_surgery/,https://blog.naver.com/brs0714,4,,,,
1 순번 병원명 지역 지점/규모 공식 웹사이트 네이버 플레이스 네이버 리뷰 현황 강남언니 강남언니 비고 YouTube Instagram (KR) 네이버 블로그 활성 채널 수 컨택 담당자 컨택 상태 미팅 일정 BD 메모
2 1 바노바기성형외과 강남 https://www.banobagi.com https://m.place.naver.com/hospital/21033469 리뷰 773개 https://www.gangnamunni.com/hospitals/23 https://www.youtube.com/c/banobagips https://www.instagram.com/banobagi_ps/ https://blog.naver.com/banobagips 6
3 2 뷰성형외과 강남 뷰성형외과 역삼센터(역삼) https://www.viewclinic.com https://m.place.naver.com/hospital/11709005 리뷰 776개 https://www.gangnamunni.com/hospitals/189 https://www.youtube.com/@ViewclinicKR https://www.instagram.com/viewplastic/ https://blog.naver.com/viewclinicps 6
4 3 아이디병원 강남 아이디병원 별관(역삼) https://www.idhospital.com https://m.place.naver.com/hospital/11548359 https://www.gangnamunni.com/hospitals/257 https://www.youtube.com/user/IDhospital https://www.instagram.com/idhospital https://blog.naver.com/idfacial 6
5 4 그랜드성형외과 강남 https://www.grandsurgery.com https://m.place.naver.com/hospital/12322994 https://www.gangnamunni.com/hospitals/62 https://www.youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ https://www.instagram.com/grand_korea/ https://blog.naver.com/grandprs 6
6 5 원진성형외과 강남 https://www.k-wonjin.co.kr https://m.place.naver.com/hospital/11887873 리뷰 9개 https://www.gangnamunni.com/hospitals/2500 https://www.youtube.com/@wjwonjin https://www.instagram.com/wonjin_official/ https://blog.naver.com/popokpop 7
7 6 마인드성형외과 강남 https://www.mindprs.com https://m.place.naver.com/hospital/1342923541 리뷰 8개 https://www.gangnamunni.com/hospitals/729 https://www.youtube.com/channel/UCzM5tIgkC8Es10YmLI55R_w https://www.instagram.com/mind.prs/ 4
8 7 브라운성형외과 강남 https://www.braunps.co.kr https://m.place.naver.com/hospital/13299185 리뷰 2451개 https://www.gangnamunni.com/hospitals/215 https://www.youtube.com/@BraunPlasticSurgery https://www.instagram.com/braunps_official/ 5
9 8 오메가성형외과 강남 http://www.omegaps.co.kr https://m.place.naver.com/hospital/12840364 리뷰 1711개 https://www.gangnamunni.com/hospitals/926 https://www.youtube.com/channel/UC4C2BB4Dp9L_QyuyD22FL0A https://www.instagram.com/omega_plastic_surgery/ 5
10 9 나나성형외과 강남 https://www.nanaprs.com https://m.place.naver.com/hospital/1518147116 리뷰 919개 https://www.gangnamunni.com/hospitals/938 https://www.youtube.com/@Nanaprstv https://www.instagram.com/nanaprs/ https://blog.naver.com/nanaprs1 6
11 10 노트성형외과 강남 http://notebreast.com https://m.place.naver.com/hospital/1572670484 리뷰 9203개 https://www.gangnamunni.com/hospitals/2186 https://www.youtube.com/channel/UC2OIyb2serotqDyEzQYyCtA https://www.instagram.com/note.prs/ https://blog.naver.com/noteprs01 6
12 11 디에이성형외과 강남 https://daprs.com https://m.place.naver.com/hospital/33084820 리뷰 1908개 https://www.gangnamunni.com/hospitals/250 https://www.youtube.com/channel/UC0wlA-w5JIt0G0EeWQf65AQ https://www.instagram.com/da_plastic_surgery https://blog.naver.com/daprs 7
13 12 에이비성형외과 강남 https://www.abps.co.kr https://m.place.naver.com/hospital/1304260302 리뷰 23599개 https://www.gangnamunni.com/hospitals/3004 https://www.youtube.com/@abplasticsurgery https://www.instagram.com/ab_plasticsurgery_kr/ https://blog.naver.com/abps20 6
14 13 비아이오성형외과 강남 http://biopskorea.com https://m.place.naver.com/hospital/21428819 https://www.youtube.com/channel/UC2WCPPw2onOI4UVqt7LIfKw https://www.instagram.com/bio_dr.hongsungpyo https://blog.naver.com/bioplastics20 5
15 14 윈성형외과 강남 0
16 15 제이준성형외과 강남 http://www.jjprs.com https://m.place.naver.com/hospital/36294945 리뷰 873개 https://www.gangnamunni.com/hospitals/139 https://www.youtube.com/channel/UC1p4msJnetDKeOW-B4nlwZA https://www.instagram.com/jayjunps/ https://blog.naver.com/gusrlf00 6
17 16 마블성형외과 강남 마블성형외과 압구정(압구정) https://marbleps.com https://m.place.naver.com/hospital/37206762 리뷰 15207개 https://www.gangnamunni.com/hospitals/141 https://www.youtube.com/@marbleps https://www.instagram.com/marble__ps/ https://blog.naver.com/marbleplastic 6
18 17 쥬얼리성형외과 강남 https://www.jewelryps.kr https://m.place.naver.com/hospital/13443226 리뷰 257개 https://www.gangnamunni.com/hospitals/55 https://www.youtube.com/@JewelrypsKr1 https://www.instagram.com/jewelryps_kr https://blog.naver.com/prsshin2 6
19 18 티에스성형외과 강남 http://www.tsprs.com https://m.place.naver.com/hospital/36905792 리뷰 397개 https://www.gangnamunni.com/hospitals/116 https://www.youtube.com/channel/UCgnizu8p7lbCnfIs76O8J-A https://www.instagram.com/tsprs_official/ https://blog.naver.com/tsprs 6
20 19 유노성형외과 강남 https://www.yunoprs.com https://m.place.naver.com/hospital/38486006 리뷰 9209개 https://www.gangnamunni.com/hospitals/248 https://www.youtube.com/channel/UCrEe-LfBLXA1-bTGS4bWmtg https://www.instagram.com/doctor.yuno/ https://blog.naver.com/yuno_blog 6
21 20 리젠성형외과 강남 http://www.regen.co.kr 0
22 21 리팅성형외과 강남 https://liting.co.kr https://m.place.naver.com/hospital/38673363 리뷰 5474개 https://www.gangnamunni.com/hospitals/331 https://www.youtube.com/@liting_ps https://www.instagram.com/liting_psps/ https://blog.naver.com/night140160 5
23 22 앤써성형외과 강남 http://www.answer-ps.co.kr https://m.place.naver.com/hospital/34138819 https://www.gangnamunni.com/hospitals/1449 https://www.youtube.com/@TV-gf8ms https://www.instagram.com/answerps https://blog.naver.com/answerps 6
24 23 더픽스성형외과 강남 http://thefixps.com https://m.place.naver.com/hospital/1310778318 https://www.gangnamunni.com/hospitals/2196 https://www.youtube.com/channel/UCXVNowmecu1tF-kCYhFE_QQ https://www.instagram.com/thefixps/ https://blog.naver.com/donghwn 6
25 24 기린성형외과 강남 https://girinps.com https://m.place.naver.com/hospital/21705376 리뷰 152개 https://www.gangnamunni.com/hospitals/398 https://www.youtube.com/@girin_official https://www.instagram.com/girin_ps_official/ https://blog.naver.com/girinlife 6
26 25 페이스성형외과 강남 http://www.face-plus.co.kr https://m.place.naver.com/hospital/13347560 https://www.gangnamunni.com/hospitals/1515 https://www.youtube.com/@koreanplasticsurgery https://www.instagram.com/faceplus_ps/ https://blog.naver.com/soonjung-49 5
27 26 압구정서울성형외과 압구정 http://www.asps.co.kr https://m.place.naver.com/hospital/11531189 리뷰 357개 https://www.gangnamunni.com/hospitals/213 https://www.youtube.com/channel/UCqNtVCL2u5Xvx74ymboxDyg https://www.instagram.com/asps_no.1/ https://blog.naver.com/asps0119 5
28 27 리젠메디컬그룹 압구정 http://regenskin.co.kr https://www.gangnamunni.com/hospitals/4154 윈느성형외과 리젠메디컬타워 https://www.instagram.com/regenskin https://blog.naver.com/shinygn1 4
29 28 코코성형외과 압구정 http://www.kodoctor.co.kr https://m.place.naver.com/hospital/1023834988 https://www.gangnamunni.com/hospitals/623 https://www.youtube.com/channel/UCc19LE6Elp0OTNo9gDizJMw https://www.instagram.com/koko_ps_official/ https://blog.naver.com/koko_plastic_surgery 6
30 29 오브제성형외과 압구정 http://objetps.com https://www.gangnamunni.com/hospitals/2122 https://www.youtube.com/channel/UC2QrFhj-S8oUrbfOp1NXKZA https://www.instagram.com/objet_plastic_surgery/ https://blog.naver.com/objetps 4
31 30 리상성형외과 압구정 https://theregenps.com https://m.place.naver.com/hospital/1125605502 https://www.gangnamunni.com/hospitals/6597 https://www.youtube.com/@theregen_ps https://www.instagram.com/theregen_ps https://blog.naver.com/theps_kor 5
32 31 에이트성형외과 압구정 에이트성형외과 서초(서초) https://www.eightprs.com https://m.place.naver.com/hospital/31986276 방문자리뷰 586, 블로그리뷰 3120 https://www.gangnamunni.com/hospitals/166 https://www.youtube.com/channel/UCpTmm84yJLgIBwBcBhjie4Q https://www.instagram.com/eightps8/ https://blog.naver.com/eightplasticsurgery 6
33 32 오페라성형외과 압구정 http://www.operasurgery.co.kr https://m.place.naver.com/hospital/10827484 https://www.gangnamunni.com/hospitals/108 https://www.youtube.com/channel/UC2R_jfmmn0zE-dkButFS_ZA https://www.instagram.com/opera_ps/ https://blog.naver.com/fasolt2 6
34 33 리엔장성형외과 압구정 https://ps.lienjang.net https://www.gangnamunni.com/hospitals/69 https://www.youtube.com/channel/UCxyiPTH9xHqhPVfwyyosXMQ https://www.instagram.com/lienjang_official/ 4
35 34 루호성형외과 압구정 https://www.luho.kr https://m.place.naver.com/hospital/21868487 방문자리뷰 1577, 블로그리뷰 608 https://www.gangnamunni.com/hospitals/660 https://www.youtube.com/channel/UCZE8dRSsc6CwdORbAhxV2Rw https://www.instagram.com/luho_beauty/ 5
36 35 티아라성형외과 압구정 https://www.gangnamunni.com/hospitals/231 SC301의원으로 상호변경 1
37 36 쏘울성형외과 압구정 https://www.soulps.kr https://www.gangnamunni.com/hospitals/4244 https://www.instagram.com/soul_plastic_surgery/ 2
38 37 에이탑성형외과 압구정 https://atopps.com https://m.place.naver.com/hospital/877412762 https://www.gangnamunni.com/hospitals/300 https://www.youtube.com/channel/UCPBCTxCX1hyRnXejA8QfFEQ https://www.instagram.com/atop_instalog/ https://blog.naver.com/atopps_ 6
39 38 유스성형외과 압구정 0
40 39 유캔비성형외과 압구정 https://www.ucanb.co.kr https://www.gangnamunni.com/hospitals/563 https://www.youtube.com/channel/UCegLA3CpLkDOe5h_DnhUpFA https://www.instagram.com/ucanb_plastic_surgery/ https://blog.naver.com/ucanb2338 5
41 40 스타성형외과 압구정 https://www.starclinic.co.kr https://m.place.naver.com/hospital/2092418109 https://www.youtube.com/channel/UCpIF_ffpk4R10_iw0q0tR7g 2
42 41 아이템성형외과 압구정 https://www.ittemps.kr https://www.youtube.com/channel/UCJolLz6ag4m2UfZh56HC2hQ https://www.instagram.com/ittem_ps https://blog.naver.com/ittem_ps 4
43 42 리코성형외과 압구정 http://www.licoclinic.com https://m.place.naver.com/hospital/20800108 https://www.youtube.com/channel/UC3T_EiEpQr1xE16o69XCqjQ https://www.instagram.com/licobest/ https://blog.naver.com/licops 5
44 43 플레저성형외과 압구정 http://www.pleasureps.com https://www.gangnamunni.com/hospitals/2991 https://www.youtube.com/c/PSPS_korea https://www.instagram.com/pleasure_ps/ https://blog.naver.com/2amsomething 4
45 44 비너스성형외과 압구정 0
46 45 아이웰성형외과 압구정 http://www.iwellps.com https://www.gangnamunni.com/hospitals/58 https://www.instagram.com/iwellps/ 2
47 46 글로비성형외과 압구정 https://glovips.com https://m.place.naver.com/hospital/13359904 https://www.gangnamunni.com/hospitals/54 https://www.youtube.com/channel/UCH4Orbmc3cFqajWx1I6Xs2w https://www.instagram.com/glovips/ https://blog.naver.com/glovips 6
48 47 엘르성형외과 압구정 http://www.elleclinic.com https://www.gangnamunni.com/hospitals/1181 https://www.instagram.com/elleps_korea/ 2
49 48 원픽성형외과 압구정 https://onepeakps.com https://www.gangnamunni.com/hospitals/3000 1
50 49 탑페이스성형외과 압구정 https://www.topfaceps.com https://m.place.naver.com/hospital/33283530 방문자리뷰 312, 블로그리뷰 179 https://www.gangnamunni.com/hospitals/66 https://www.youtube.com/channel/UCNfZc5aXZ5A1mqX5iIw91EA https://www.instagram.com/topfaceps/ https://blog.naver.com/ina3599 5
51 50 세민성형외과 역삼 http://www.semin100.co.kr https://www.youtube.com/channel/UCKaNYEvRqME2h1lUSOYIYew https://blog.naver.com/semin100 2
52 51 에이치성형외과 역삼 https://www.3dfit.co.kr https://www.youtube.com/channel/UC6d8i9WwIkm161ueFyVU2PA https://blog.naver.com/ys100ps 3
53 52 리노보성형외과 역삼 http://www.renovo.co.kr https://m.place.naver.com/hospital/13023672 https://www.gangnamunni.com/hospitals/4749 https://www.instagram.com/renovo.clinic.1/ 3
54 53 디엘성형외과 역삼 https://www.dlprs.com https://m.place.naver.com/hospital/1998543478 https://www.gangnamunni.com/hospitals/5500 https://www.youtube.com/@LINEKING_DL https://www.instagram.com/dl_plastic_official/ 4
55 54 피알성형외과 역삼 http://prprs.co.kr https://m.place.naver.com/hospital/1655749880 https://www.gangnamunni.com/hospitals/431 https://www.youtube.com/@pr_ps https://www.instagram.com/prprs_official/ 5
56 55 라프린성형외과 역삼 http://laprinps.com https://m.place.naver.com/hospital/33741139 https://www.youtube.com/channel/UCfMt8TTT8kJklZORjf4nJ2w https://www.instagram.com/laprin_kr/ https://blog.naver.com/ff8kds6t5cmxd 5
57 56 도도성형외과 역삼 http://www.dodobeauty.com https://blog.naver.com/mmscjh 1
58 57 엠제이성형외과 역삼 https://www.mjskinclinic.com https://www.youtube.com/channel/UCFjkFyYDu4HpLjc9axlh6YQ https://blog.naver.com/mjskinclinic 3
59 58 아우라성형외과 역삼 http://psaura.com https://m.place.naver.com/hospital/1806079685 https://www.gangnamunni.com/hospitals/5870 https://www.youtube.com/@auraps2024 https://www.instagram.com/auraps_official/ https://blog.naver.com/pristor 5
60 59 하이봄성형외과 역삼 https://www.highvom.com https://m.place.naver.com/hospital/1714219923 https://www.gangnamunni.com/hospitals/2052 https://www.youtube.com/channel/UCuuH9oWh9h29hVRmH2sHAuA https://www.instagram.com/highvom/ https://blog.naver.com/hivom 6
61 60 에이원성형외과 역삼 0
62 61 유앤아이성형외과 역삼 https://www.uni114.co.kr https://m.place.naver.com/hospital/60058049 https://www.gangnamunni.com/hospitals/4459 https://www.youtube.com/channel/UCHgZnNk3JIDxnoYcWSWtAJw https://www.instagram.com/skinuni114/ 5
63 62 리본성형외과 역삼 https://www.gangnamunni.com/hospitals/339 https://www.youtube.com/channel/UCyFdX9zxfu2e2JVTwPjki5A https://www.instagram.com/rebornps_/ https://blog.naver.com/reborn1999 5
64 63 리메이성형외과 역삼 0
65 64 라이크성형외과 역삼 http://www.likeps.com https://www.gangnamunni.com/hospitals/912 https://www.youtube.com/c/LIKEPLASTICSURGERY https://www.instagram.com/likeps_kr/ 3
66 65 케이플러스성형외과 역삼 https://k-clinics.com https://m.place.naver.com/hospital/36912372 https://www.gangnamunni.com/hospitals/1265 https://www.youtube.com/@k-plasticsurgery https://www.instagram.com/k_plasticsurgery/ https://blog.naver.com/kclinics-osh 6
67 66 케이아트성형외과 역삼 http://www.k-artps.com https://www.youtube.com/channel/UCHs9LOYtauBIklXhPPZBAgA https://www.instagram.com/kartps/ https://blog.naver.com/kartps 4
68 67 이룸성형외과 역삼 http://www.seoulips.com https://m.place.naver.com/hospital/1240083198 https://www.gangnamunni.com/hospitals/839 https://www.youtube.com/@seouliplasticsurgery https://www.instagram.com/seoulips/ https://blog.naver.com/seoulips 7
69 68 서초서울성형외과 서초 https://srprs.co.kr https://m.place.naver.com/hospital/1692465198 https://www.gangnamunni.com/hospitals/5554 https://www.instagram.com/saerops/ 3
70 69 서초연세성형외과 서초 https://www.chaminst.com https://www.gangnamunni.com/hospitals/4212 https://www.youtube.com/channel/UCMxBfZivKz5jjhRiy0mzOXQ https://www.instagram.com/chamin_ps https://blog.naver.com/chaminst 4
71 70 리모성형외과 서초 http://www.ksh-ps.com https://m.place.naver.com/hospital/1865533181 https://www.gangnamunni.com/hospitals/6680 https://www.youtube.com/channel/UCCiH9OffJgZTMnkxatK8-5A https://www.instagram.com/sh___ps/ https://blog.naver.com/shpsclinic 5
72 71 화이트성형외과 서초 http://www.whiteclinic.com 0
73 72 미호성형외과 서초 https://mihops.co.kr https://m.place.naver.com/hospital/344342362 https://www.gangnamunni.com/hospitals/369 https://www.youtube.com/channel/UC8kyWAnMM7f_xguyPxR_gtA https://www.instagram.com/mihops_kr/ https://blog.naver.com/qalfuqbjtmnf 5
74 73 나우성형외과 서초 0
75 74 와이즈성형외과 서초 https://www.gangnamunni.com/hospitals/2414 와이즈유의원 https://www.instagram.com/wise_ps/ 2
76 75 케이스타성형외과 서초 0
77 76 셀린성형외과 서초 0
78 77 에비뉴성형외과 서초 http://www.avenueps.com https://www.gangnamunni.com/hospitals/6204 https://www.youtube.com/channel/UCOTVAerYogSEia3L-ERkAFg 2
79 78 비온성형외과 서초 http://www.bon-ps.com https://m.place.naver.com/hospital/1492257313 https://www.youtube.com/channel/UClCjGIfEb3b1N-Q5tCNSU5w https://blog.naver.com/miz2199 3
80 79 아크성형외과 서초 http://arc-ps.com https://m.place.naver.com/hospital/1638918034 https://www.gangnamunni.com/hospitals/3569 https://www.youtube.com/@-arcisart7955 https://www.instagram.com/arc_plastic_surgery/ 4
81 80 에버성형외과 서초 http://www.everclinic.com https://www.youtube.com/channel/UCwSusNRTM2B_mvdRd0yNVcw https://www.instagram.com/everclinic0088/ https://blog.naver.com/drblue007 4
82 81 원스성형외과 서초 0
83 82 메이성형외과 서초 0
84 83 라인업성형외과 서초 0
85 84 유앤미성형외과 서초 https://www.knyounmeclinic.co.kr https://m.place.naver.com/hospital/31068065 https://www.gangnamunni.com/hospitals/1178 https://www.youtube.com/channel/UCAROD-B1ZBinpyzql7o_IlA https://www.instagram.com/knyounmeclinic/ 4
86 85 리더스성형외과 서초 0
87 86 마노성형외과 서초 https://manops.co.kr https://www.gangnamunni.com/hospitals/3429 https://www.youtube.com/@mano_ps https://www.instagram.com/manops_official https://blog.naver.com/tkhr4747 4
88 87 예롬성형외과 서초 https://www.gangnamunni.com/hospitals/450 1
89 88 에스엠성형외과 서초 https://www.sm-ps.co.kr https://m.place.naver.com/hospital/32876182 https://www.gangnamunni.com/hospitals/413 https://www.youtube.com/@smpsclinic https://www.instagram.com/smps_plastic_surgery/ https://blog.naver.com/smps1004 6
90 89 리안성형외과 서초 0
91 90 서울미작성형외과 서초 https://mijakclinic.co.kr https://www.youtube.com/channel/UCld-NgVeydtDRVTt6xQRbmA https://www.instagram.com/mijak2444/ https://blog.naver.com/mijak3444 4
92 91 더원성형외과 서초 https://www.theoneclinic.co.kr https://www.gangnamunni.com/hospitals/5636 https://www.instagram.com/theone_plastic_surgery/ https://blog.naver.com/brs0714 4

View File

@ -0,0 +1,92 @@
hospital_name,brand_group,district,branches,website_kr,website_en,youtube_url,youtube_note,instagram_kr_url,instagram_kr_note,instagram_en_url,instagram_en_note,facebook_url,facebook_note,tiktok_url,tiktok_note,gangnam_unni_url,gangnam_unni_note,naver_blog_url,naver_blog_note,naver_place_url,naver_place_reviews_note,google_maps_url,google_reviews_note
바노바기성형외과,프리미엄/하이타깃 후보,강남,,https://www.banobagi.com,,https://www.youtube.com/c/banobagips,,https://www.instagram.com/banobagi_ps/,,,,https://www.facebook.com/BanobagiPlasticSurgery,,,,https://www.gangnamunni.com/hospitals/23,,https://blog.naver.com/banobagips,,https://m.place.naver.com/hospital/21033469,리뷰 773개,,
뷰성형외과,프리미엄/하이타깃 후보,강남,뷰성형외과 역삼센터(역삼),https://www.viewclinic.com,,https://www.youtube.com/@ViewclinicKR,,https://www.instagram.com/viewplastic/,,,,https://www.facebook.com/viewps1/,,,,https://www.gangnamunni.com/hospitals/189,,https://blog.naver.com/viewclinicps,,https://m.place.naver.com/hospital/11709005,리뷰 776개,,
아이디병원,프리미엄/하이타깃 후보,강남,아이디병원 별관(역삼),https://www.idhospital.com,,https://www.youtube.com/user/IDhospital,,https://www.instagram.com/idhospital,,,,https://www.facebook.com/idhospital0050,,,,https://www.gangnamunni.com/hospitals/257,,https://blog.naver.com/idfacial,,https://m.place.naver.com/hospital/11548359,,,
그랜드성형외과,프리미엄/하이타깃 후보,강남,,https://www.grandsurgery.com,,https://www.youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ,,https://www.instagram.com/grand_korea/,,,,https://www.facebook.com/grandps.korea,,,,https://www.gangnamunni.com/hospitals/62,,https://blog.naver.com/grandprs,,https://m.place.naver.com/hospital/12322994,,,
원진성형외과,프리미엄/하이타깃 후보,강남,,https://www.k-wonjin.co.kr,,https://www.youtube.com/@wjwonjin,,https://www.instagram.com/wonjin_official/,,,,https://www.facebook.com/KwonjinPS,,https://www.tiktok.com/@wonjin_official,,https://www.gangnamunni.com/hospitals/2500,,https://blog.naver.com/popokpop,,https://m.place.naver.com/hospital/11887873,리뷰 9개,,
마인드성형외과,프리미엄/하이타깃 후보,강남,,https://www.mindprs.com,,https://www.youtube.com/channel/UCzM5tIgkC8Es10YmLI55R_w,,https://www.instagram.com/mind.prs/,,,,,,,,https://www.gangnamunni.com/hospitals/729,,,,https://m.place.naver.com/hospital/1342923541,리뷰 8개,,
브라운성형외과,프리미엄/하이타깃 후보,강남,,https://www.braunps.co.kr,,https://www.youtube.com/@BraunPlasticSurgery,,https://www.instagram.com/braunps_official/,,,,https://www.facebook.com/braunps,,,,https://www.gangnamunni.com/hospitals/215,,,,https://m.place.naver.com/hospital/13299185,리뷰 2451개,,
오메가성형외과,프리미엄/하이타깃 후보,강남,,http://www.omegaps.co.kr,,https://www.youtube.com/channel/UC4C2BB4Dp9L_QyuyD22FL0A,,https://www.instagram.com/omega_plastic_surgery/,,,,https://www.facebook.com/오메가성형외과-1682577712065719/,,,,https://www.gangnamunni.com/hospitals/926,,,,https://m.place.naver.com/hospital/12840364,리뷰 1711개,,
나나성형외과,프리미엄/하이타깃 후보,강남,,https://www.nanaprs.com,,https://www.youtube.com/@Nanaprstv,,https://www.instagram.com/nanaprs/,,,,https://www.facebook.com/nanaprs2,,,,https://www.gangnamunni.com/hospitals/938,,https://blog.naver.com/nanaprs1,,https://m.place.naver.com/hospital/1518147116,리뷰 919개,,
노트성형외과,프리미엄/하이타깃 후보,강남,,http://notebreast.com,,https://www.youtube.com/channel/UC2OIyb2serotqDyEzQYyCtA,,https://www.instagram.com/note.prs/,,,,https://www.facebook.com/noteprs2/,,,,https://www.gangnamunni.com/hospitals/2186,,https://blog.naver.com/noteprs01,,https://m.place.naver.com/hospital/1572670484,리뷰 9203개,,
디에이성형외과,프리미엄/하이타깃 후보,강남,,https://daprs.com,,https://www.youtube.com/channel/UC0wlA-w5JIt0G0EeWQf65AQ,,https://www.instagram.com/da_plastic_surgery,,,,https://www.facebook.com/daprs/,,https://www.tiktok.com/@daprs,,https://www.gangnamunni.com/hospitals/250,,https://blog.naver.com/daprs,,https://m.place.naver.com/hospital/33084820,리뷰 1908개,,
에이비성형외과,프리미엄/하이타깃 후보,강남,,https://www.abps.co.kr,,https://www.youtube.com/@abplasticsurgery,,https://www.instagram.com/ab_plasticsurgery_kr/,,,,https://www.facebook.com/profile.php?id=100063560996454,,,,https://www.gangnamunni.com/hospitals/3004,,https://blog.naver.com/abps20,,https://m.place.naver.com/hospital/1304260302,리뷰 23599개,,
비아이오성형외과,프리미엄/하이타깃 후보,강남,,http://biopskorea.com,,https://www.youtube.com/channel/UC2WCPPw2onOI4UVqt7LIfKw,,https://www.instagram.com/bio_dr.hongsungpyo,,,,https://www.facebook.com/biopskorea,,,,,,https://blog.naver.com/bioplastics20,,https://m.place.naver.com/hospital/21428819,,,
윈성형외과,프리미엄/하이타깃 후보,강남,,,,,,,,,,,,,,,,,,,,,
제이준성형외과,프리미엄/하이타깃 후보,강남,,http://www.jjprs.com,,https://www.youtube.com/channel/UC1p4msJnetDKeOW-B4nlwZA,,https://www.instagram.com/jayjunps/,,,,https://www.facebook.com/jayjunps,,,,https://www.gangnamunni.com/hospitals/139,,https://blog.naver.com/gusrlf00,,https://m.place.naver.com/hospital/36294945,리뷰 873개,,
마블성형외과,프리미엄/하이타깃 후보,강남,마블성형외과 압구정(압구정),https://marbleps.com,,https://www.youtube.com/@marbleps,,https://www.instagram.com/marble__ps/,,,,https://www.facebook.com/marbleps/,,,,https://www.gangnamunni.com/hospitals/141,,https://blog.naver.com/marbleplastic,,https://m.place.naver.com/hospital/37206762,리뷰 15207개,,
쥬얼리성형외과,프리미엄/하이타깃 후보,강남,,https://www.jewelryps.kr,,https://www.youtube.com/@JewelrypsKr1,,https://www.instagram.com/jewelryps_kr,,,,https://www.facebook.com/juelyps,,,,https://www.gangnamunni.com/hospitals/55,,https://blog.naver.com/prsshin2,,https://m.place.naver.com/hospital/13443226,리뷰 257개,,
티에스성형외과,프리미엄/하이타깃 후보,강남,,http://www.tsprs.com,,https://www.youtube.com/channel/UCgnizu8p7lbCnfIs76O8J-A,,https://www.instagram.com/tsprs_official/,,,,https://www.facebook.com/tsprs,,,,https://www.gangnamunni.com/hospitals/116,,https://blog.naver.com/tsprs,,https://m.place.naver.com/hospital/36905792,리뷰 397개,,
유노성형외과,프리미엄/하이타깃 후보,강남,,https://www.yunoprs.com,,https://www.youtube.com/channel/UCrEe-LfBLXA1-bTGS4bWmtg,,https://www.instagram.com/doctor.yuno/,,,,https://www.facebook.com/doctor.yuno/,,,,https://www.gangnamunni.com/hospitals/248,,https://blog.naver.com/yuno_blog,,https://m.place.naver.com/hospital/38486006,리뷰 9209개,,
리젠성형외과,프리미엄/하이타깃 후보,강남,,http://www.regen.co.kr,,,,,,,,,,,,,,,,,,,
리팅성형외과,프리미엄/하이타깃 후보,강남,,https://liting.co.kr,,https://www.youtube.com/@liting_ps,,https://www.instagram.com/liting_psps/,,,,,,,,https://www.gangnamunni.com/hospitals/331,,https://blog.naver.com/night140160,,https://m.place.naver.com/hospital/38673363,리뷰 5474개,,
앤써성형외과,프리미엄/하이타깃 후보,강남,,http://www.answer-ps.co.kr,,https://www.youtube.com/@TV-gf8ms,,https://www.instagram.com/answerps,,,,https://www.facebook.com/answerps4881,,,,https://www.gangnamunni.com/hospitals/1449,,https://blog.naver.com/answerps,,https://m.place.naver.com/hospital/34138819,,,
더픽스성형외과,프리미엄/하이타깃 후보,강남,,http://thefixps.com,,https://www.youtube.com/channel/UCXVNowmecu1tF-kCYhFE_QQ,,https://www.instagram.com/thefixps/,,,,https://www.facebook.com/psdonghoon,,,,https://www.gangnamunni.com/hospitals/2196,,https://blog.naver.com/donghwn,,https://m.place.naver.com/hospital/1310778318,,,
기린성형외과,프리미엄/하이타깃 후보,강남,,https://girinps.com,,https://www.youtube.com/@girin_official,,https://www.instagram.com/girin_ps_official/,,,,,,https://www.tiktok.com/@girin_korea,,https://www.gangnamunni.com/hospitals/398,,https://blog.naver.com/girinlife,,https://m.place.naver.com/hospital/21705376,리뷰 152개,,
페이스성형외과,프리미엄/하이타깃 후보,강남,,http://www.face-plus.co.kr,,https://www.youtube.com/@koreanplasticsurgery,,https://www.instagram.com/faceplus_ps/,,,,,,,,https://www.gangnamunni.com/hospitals/1515,,https://blog.naver.com/soonjung-49,,https://m.place.naver.com/hospital/13347560,,,
압구정서울성형외과,프리미엄/하이타깃 후보,압구정,,http://www.asps.co.kr,,https://www.youtube.com/channel/UCqNtVCL2u5Xvx74ymboxDyg,,https://www.instagram.com/asps_no.1/,,https://www.instagram.com/asps_en/,,,,,,https://www.gangnamunni.com/hospitals/213,,https://blog.naver.com/asps0119,,https://m.place.naver.com/hospital/11531189,리뷰 357개,,
리젠메디컬그룹,프리미엄/하이타깃 후보,압구정,,http://regenskin.co.kr,,,,https://www.instagram.com/regenskin,,,,https://www.facebook.com/pages/리젠피부과/785877121503059,,,,https://www.gangnamunni.com/hospitals/4154,윈느성형외과 리젠메디컬타워,https://blog.naver.com/shinygn1,,,,,
코코성형외과,프리미엄/하이타깃 후보,압구정,,http://www.kodoctor.co.kr,,https://www.youtube.com/channel/UCc19LE6Elp0OTNo9gDizJMw,,https://www.instagram.com/koko_ps_official/,,,,https://www.facebook.com/kokodoctor/,,,,https://www.gangnamunni.com/hospitals/623,,https://blog.naver.com/koko_plastic_surgery,,https://m.place.naver.com/hospital/1023834988,,,
오브제성형외과,프리미엄/하이타깃 후보,압구정,,http://objetps.com,,https://www.youtube.com/channel/UC2QrFhj-S8oUrbfOp1NXKZA,,https://www.instagram.com/objet_plastic_surgery/,,,,,,,,https://www.gangnamunni.com/hospitals/2122,,https://blog.naver.com/objetps,,,,,
리상성형외과,프리미엄/하이타깃 후보,압구정,,https://theregenps.com,,https://www.youtube.com/@theregen_ps,,https://www.instagram.com/theregen_ps,,,,,,,,https://www.gangnamunni.com/hospitals/6597,,https://blog.naver.com/theps_kor,,https://m.place.naver.com/hospital/1125605502,,,
에이트성형외과,프리미엄/하이타깃 후보,압구정,에이트성형외과 서초(서초),https://www.eightprs.com,,https://www.youtube.com/channel/UCpTmm84yJLgIBwBcBhjie4Q,,https://www.instagram.com/eightps8/,,,,,,https://www.tiktok.com/@eighthospital.th,,https://www.gangnamunni.com/hospitals/166,,https://blog.naver.com/eightplasticsurgery,,https://m.place.naver.com/hospital/31986276,"방문자리뷰 586, 블로그리뷰 3120",,
오페라성형외과,프리미엄/하이타깃 후보,압구정,,http://www.operasurgery.co.kr,,https://www.youtube.com/channel/UC2R_jfmmn0zE-dkButFS_ZA,,https://www.instagram.com/opera_ps/,,,,https://www.facebook.com/operasurgery/,,,,https://www.gangnamunni.com/hospitals/108,,https://blog.naver.com/fasolt2,,https://m.place.naver.com/hospital/10827484,,,
리엔장성형외과,프리미엄/하이타깃 후보,압구정,,https://ps.lienjang.net,,https://www.youtube.com/channel/UCxyiPTH9xHqhPVfwyyosXMQ,,https://www.instagram.com/lienjang_official/,,,,,,https://www.tiktok.com/@lienjang_ps,,https://www.gangnamunni.com/hospitals/69,,,,,,,
루호성형외과,프리미엄/하이타깃 후보,압구정,,https://www.luho.kr,,https://www.youtube.com/channel/UCZE8dRSsc6CwdORbAhxV2Rw,,https://www.instagram.com/luho_beauty/,,,,,,https://www.tiktok.com/@luho_beauty_jp,,https://www.gangnamunni.com/hospitals/660,,,,https://m.place.naver.com/hospital/21868487,"방문자리뷰 1577, 블로그리뷰 608",,
티아라성형외과,프리미엄/하이타깃 후보,압구정,,,,,,,,,,,,,,https://www.gangnamunni.com/hospitals/231,SC301의원으로 상호변경,,,,,,
쏘울성형외과,프리미엄/하이타깃 후보,압구정,,https://www.soulps.kr,,,,https://www.instagram.com/soul_plastic_surgery/,,,,,,,,https://www.gangnamunni.com/hospitals/4244,,,,,,,
에이탑성형외과,프리미엄/하이타깃 후보,압구정,,https://atopps.com,,https://www.youtube.com/channel/UCPBCTxCX1hyRnXejA8QfFEQ,,https://www.instagram.com/atop_instalog/,,,,https://www.facebook.com/Dr.goodhand,,,,https://www.gangnamunni.com/hospitals/300,,https://blog.naver.com/atopps_,,https://m.place.naver.com/hospital/877412762,,,
유스성형외과,프리미엄/하이타깃 후보,압구정,,,,,,,,,,,,,,,,,,,,,
유캔비성형외과,프리미엄/하이타깃 후보,압구정,,https://www.ucanb.co.kr,,https://www.youtube.com/channel/UCegLA3CpLkDOe5h_DnhUpFA,,https://www.instagram.com/ucanb_plastic_surgery/,,,,https://www.facebook.com/ucanbps,,,,https://www.gangnamunni.com/hospitals/563,,https://blog.naver.com/ucanb2338,,,,,
스타성형외과,프리미엄/하이타깃 후보,압구정,,https://www.starclinic.co.kr,,https://www.youtube.com/channel/UCpIF_ffpk4R10_iw0q0tR7g,,,,,,,,,,,,,,https://m.place.naver.com/hospital/2092418109,,,
아이템성형외과,프리미엄/하이타깃 후보,압구정,,https://www.ittemps.kr,,https://www.youtube.com/channel/UCJolLz6ag4m2UfZh56HC2hQ,,https://www.instagram.com/ittem_ps,,,,https://www.facebook.com/잇템성형외과-101634361973942,,,,,,https://blog.naver.com/ittem_ps,,,,,
리코성형외과,프리미엄/하이타깃 후보,압구정,,http://www.licoclinic.com,,https://www.youtube.com/channel/UC3T_EiEpQr1xE16o69XCqjQ,,https://www.instagram.com/licobest/,,,,https://www.facebook.com/licotop,,,,,,https://blog.naver.com/licops,,https://m.place.naver.com/hospital/20800108,,,
플레저성형외과,프리미엄/하이타깃 후보,압구정,,http://www.pleasureps.com,,https://www.youtube.com/c/PSPS_korea,,https://www.instagram.com/pleasure_ps/,,,,,,,,https://www.gangnamunni.com/hospitals/2991,,https://blog.naver.com/2amsomething,,,,,
비너스성형외과,프리미엄/하이타깃 후보,압구정,,,,,,,,,,,,,,,,,,,,,
아이웰성형외과,프리미엄/하이타깃 후보,압구정,,http://www.iwellps.com,,,,https://www.instagram.com/iwellps/,,,,,,,,https://www.gangnamunni.com/hospitals/58,,,,,,,
글로비성형외과,프리미엄/하이타깃 후보,압구정,,https://glovips.com,,https://www.youtube.com/channel/UCH4Orbmc3cFqajWx1I6Xs2w,,https://www.instagram.com/glovips/,,,,https://www.facebook.com/glovips,,,,https://www.gangnamunni.com/hospitals/54,,https://blog.naver.com/glovips,,https://m.place.naver.com/hospital/13359904,,,
엘르성형외과,프리미엄/하이타깃 후보,압구정,,http://www.elleclinic.com,,,,https://www.instagram.com/elleps_korea/,,,,,,,,https://www.gangnamunni.com/hospitals/1181,,,,,,,
원픽성형외과,프리미엄/하이타깃 후보,압구정,,https://onepeakps.com,,,,,,,,,,,,https://www.gangnamunni.com/hospitals/3000,,,,,,,
탑페이스성형외과,프리미엄/하이타깃 후보,압구정,,https://www.topfaceps.com,,https://www.youtube.com/channel/UCNfZc5aXZ5A1mqX5iIw91EA,,https://www.instagram.com/topfaceps/,,,,,,,,https://www.gangnamunni.com/hospitals/66,,https://blog.naver.com/ina3599,,https://m.place.naver.com/hospital/33283530,"방문자리뷰 312, 블로그리뷰 179",,
세민성형외과,프리미엄/하이타깃 후보,역삼,,http://www.semin100.co.kr,,https://www.youtube.com/channel/UCKaNYEvRqME2h1lUSOYIYew,,,,,,,,,,,,https://blog.naver.com/semin100,,,,,
에이치성형외과,프리미엄/하이타깃 후보,역삼,,https://www.3dfit.co.kr,,https://www.youtube.com/channel/UC6d8i9WwIkm161ueFyVU2PA,,,,,,https://www.facebook.com/3DFIT100,,,,,,https://blog.naver.com/ys100ps,,,,,
리노보성형외과,프리미엄/하이타깃 후보,역삼,,http://www.renovo.co.kr,,,,https://www.instagram.com/renovo.clinic.1/,,,,,,,,https://www.gangnamunni.com/hospitals/4749,,,,https://m.place.naver.com/hospital/13023672,,,
디엘성형외과,프리미엄/하이타깃 후보,역삼,,https://www.dlprs.com,,https://www.youtube.com/@LINEKING_DL,,https://www.instagram.com/dl_plastic_official/,,,,,,,,https://www.gangnamunni.com/hospitals/5500,,,,https://m.place.naver.com/hospital/1998543478,,,
피알성형외과,프리미엄/하이타깃 후보,역삼,,http://prprs.co.kr,,https://www.youtube.com/@pr_ps,,https://www.instagram.com/prprs_official/,,,,https://www.facebook.com/PRPRS.official,,,,https://www.gangnamunni.com/hospitals/431,,,,https://m.place.naver.com/hospital/1655749880,,,
라프린성형외과,프리미엄/하이타깃 후보,역삼,,http://laprinps.com,,https://www.youtube.com/channel/UCfMt8TTT8kJklZORjf4nJ2w,,https://www.instagram.com/laprin_kr/,,,,https://www.facebook.com/laprinprincess/,,,,,,https://blog.naver.com/ff8kds6t5cmxd,,https://m.place.naver.com/hospital/33741139,,,
도도성형외과,프리미엄/하이타깃 후보,역삼,,http://www.dodobeauty.com,,,,,,,,,,,,,,https://blog.naver.com/mmscjh,,,,,
엠제이성형외과,프리미엄/하이타깃 후보,역삼,,https://www.mjskinclinic.com,,https://www.youtube.com/channel/UCFjkFyYDu4HpLjc9axlh6YQ,,,,,,https://www.facebook.com/people/MJ피부과/100063928095304/,,,,,,https://blog.naver.com/mjskinclinic,,,,,
아우라성형외과,프리미엄/하이타깃 후보,역삼,,http://psaura.com,,https://www.youtube.com/@auraps2024,,https://www.instagram.com/auraps_official/,,,,,,,,https://www.gangnamunni.com/hospitals/5870,,https://blog.naver.com/pristor,,https://m.place.naver.com/hospital/1806079685,,,
하이봄성형외과,프리미엄/하이타깃 후보,역삼,,https://www.highvom.com,,https://www.youtube.com/channel/UCuuH9oWh9h29hVRmH2sHAuA,,https://www.instagram.com/highvom/,,,,https://www.facebook.com/highvom1,,,,https://www.gangnamunni.com/hospitals/2052,,https://blog.naver.com/hivom,,https://m.place.naver.com/hospital/1714219923,,,
에이원성형외과,프리미엄/하이타깃 후보,역삼,,,,,,,,,,,,,,,,,,,,,
유앤아이성형외과,프리미엄/하이타깃 후보,역삼,,https://www.uni114.co.kr,,https://www.youtube.com/channel/UCHgZnNk3JIDxnoYcWSWtAJw,,https://www.instagram.com/skinuni114/,,,,https://www.facebook.com/uni114,,,,https://www.gangnamunni.com/hospitals/4459,,,,https://m.place.naver.com/hospital/60058049,,,
리본성형외과,프리미엄/하이타깃 후보,역삼,,,,https://www.youtube.com/channel/UCyFdX9zxfu2e2JVTwPjki5A,,https://www.instagram.com/rebornps_/,,,,https://www.facebook.com/reborn3355/,,,,https://www.gangnamunni.com/hospitals/339,,https://blog.naver.com/reborn1999,,,,,
리메이성형외과,프리미엄/하이타깃 후보,역삼,,,,,,,,,,,,,,,,,,,,,
라이크성형외과,프리미엄/하이타깃 후보,역삼,,http://www.likeps.com,,https://www.youtube.com/c/LIKEPLASTICSURGERY,,https://www.instagram.com/likeps_kr/,,,,,,,,https://www.gangnamunni.com/hospitals/912,,,,,,,
케이플러스성형외과,프리미엄/하이타깃 후보,역삼,,https://k-clinics.com,,https://www.youtube.com/@k-plasticsurgery,,https://www.instagram.com/k_plasticsurgery/,,,,https://www.facebook.com/kclinics,,,,https://www.gangnamunni.com/hospitals/1265,,https://blog.naver.com/kclinics-osh,,https://m.place.naver.com/hospital/36912372,,,
케이아트성형외과,프리미엄/하이타깃 후보,역삼,,http://www.k-artps.com,,https://www.youtube.com/channel/UCHs9LOYtauBIklXhPPZBAgA,,https://www.instagram.com/kartps/,,,,https://www.facebook.com/kartpsseoul,,,,,,https://blog.naver.com/kartps,,,,,
이룸성형외과,프리미엄/하이타깃 후보,역삼,,http://www.seoulips.com,,https://www.youtube.com/@seouliplasticsurgery,,https://www.instagram.com/seoulips/,,,,https://www.facebook.com/서울아이성형외과-105199207892670/,,https://www.tiktok.com/@seoulips_jp,,https://www.gangnamunni.com/hospitals/839,,https://blog.naver.com/seoulips,,https://m.place.naver.com/hospital/1240083198,,,
서초서울성형외과,프리미엄/하이타깃 후보,서초,,https://srprs.co.kr,,,,https://www.instagram.com/saerops/,,,,,,,,https://www.gangnamunni.com/hospitals/5554,,,,https://m.place.naver.com/hospital/1692465198,,,
서초연세성형외과,프리미엄/하이타깃 후보,서초,,https://www.chaminst.com,,https://www.youtube.com/channel/UCMxBfZivKz5jjhRiy0mzOXQ,,https://www.instagram.com/chamin_ps,,,,,,,,https://www.gangnamunni.com/hospitals/4212,,https://blog.naver.com/chaminst,,,,,
리모성형외과,프리미엄/하이타깃 후보,서초,,http://www.ksh-ps.com,,https://www.youtube.com/channel/UCCiH9OffJgZTMnkxatK8-5A,,https://www.instagram.com/sh___ps/,,,,,,,,https://www.gangnamunni.com/hospitals/6680,,https://blog.naver.com/shpsclinic,,https://m.place.naver.com/hospital/1865533181,,,
화이트성형외과,프리미엄/하이타깃 후보,서초,,http://www.whiteclinic.com,,,,,,,,,,,,,,,,,,,
미호성형외과,프리미엄/하이타깃 후보,서초,,https://mihops.co.kr,,https://www.youtube.com/channel/UC8kyWAnMM7f_xguyPxR_gtA,,https://www.instagram.com/mihops_kr/,,,,,,,,https://www.gangnamunni.com/hospitals/369,,https://blog.naver.com/qalfuqbjtmnf,,https://m.place.naver.com/hospital/344342362,,,
나우성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
와이즈성형외과,프리미엄/하이타깃 후보,서초,,,,,,https://www.instagram.com/wise_ps/,,,,,,,,https://www.gangnamunni.com/hospitals/2414,와이즈유의원,,,,,,
케이스타성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
셀린성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
에비뉴성형외과,프리미엄/하이타깃 후보,서초,,http://www.avenueps.com,,https://www.youtube.com/channel/UCOTVAerYogSEia3L-ERkAFg,,,,,,,,,,https://www.gangnamunni.com/hospitals/6204,,,,,,,
비온성형외과,프리미엄/하이타깃 후보,서초,,http://www.bon-ps.com,,https://www.youtube.com/channel/UClCjGIfEb3b1N-Q5tCNSU5w,,,,,,,,,,,,https://blog.naver.com/miz2199,,https://m.place.naver.com/hospital/1492257313,,,
아크성형외과,프리미엄/하이타깃 후보,서초,,http://arc-ps.com,,https://www.youtube.com/@-arcisart7955,,https://www.instagram.com/arc_plastic_surgery/,,,,,,,,https://www.gangnamunni.com/hospitals/3569,,,,https://m.place.naver.com/hospital/1638918034,,,
에버성형외과,프리미엄/하이타깃 후보,서초,,http://www.everclinic.com,,https://www.youtube.com/channel/UCwSusNRTM2B_mvdRd0yNVcw,,https://www.instagram.com/everclinic0088/,,,,https://www.facebook.com/everplasticsurgery,,,,,,https://blog.naver.com/drblue007,,,,,
원스성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
메이성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
라인업성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
유앤미성형외과,프리미엄/하이타깃 후보,서초,,https://www.knyounmeclinic.co.kr,,https://www.youtube.com/channel/UCAROD-B1ZBinpyzql7o_IlA,,https://www.instagram.com/knyounmeclinic/,,,,,,,,https://www.gangnamunni.com/hospitals/1178,,,,https://m.place.naver.com/hospital/31068065,,,
리더스성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
마노성형외과,프리미엄/하이타깃 후보,서초,,https://manops.co.kr,,https://www.youtube.com/@mano_ps,,https://www.instagram.com/manops_official,,,,,,,,https://www.gangnamunni.com/hospitals/3429,,https://blog.naver.com/tkhr4747,,,,,
예롬성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,https://www.gangnamunni.com/hospitals/450,,,,,,,
에스엠성형외과,프리미엄/하이타깃 후보,서초,,https://www.sm-ps.co.kr,,https://www.youtube.com/@smpsclinic,,https://www.instagram.com/smps_plastic_surgery/,,,,https://www.facebook.com/thammySM3707,,,,https://www.gangnamunni.com/hospitals/413,,https://blog.naver.com/smps1004,,https://m.place.naver.com/hospital/32876182,,,
리안성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,,
서울미작성형외과,프리미엄/하이타깃 후보,서초,,https://mijakclinic.co.kr,,https://www.youtube.com/channel/UCld-NgVeydtDRVTt6xQRbmA,,https://www.instagram.com/mijak2444/,,,,https://www.facebook.com/mijaknose,,,,,,https://blog.naver.com/mijak3444,,,,,
더원성형외과,프리미엄/하이타깃 후보,서초,,https://www.theoneclinic.co.kr,,,,https://www.instagram.com/theone_plastic_surgery/,,,,https://www.facebook.com/pages/category/Hospital/더원성형외과-1539415639613021/,,,,https://www.gangnamunni.com/hospitals/5636,,https://blog.naver.com/brs0714,,,,,
1 hospital_name brand_group district branches website_kr website_en youtube_url youtube_note instagram_kr_url instagram_kr_note instagram_en_url instagram_en_note facebook_url facebook_note tiktok_url tiktok_note gangnam_unni_url gangnam_unni_note naver_blog_url naver_blog_note naver_place_url naver_place_reviews_note google_maps_url google_reviews_note
2 바노바기성형외과 프리미엄/하이타깃 후보 강남 https://www.banobagi.com https://www.youtube.com/c/banobagips https://www.instagram.com/banobagi_ps/ https://www.facebook.com/BanobagiPlasticSurgery https://www.gangnamunni.com/hospitals/23 https://blog.naver.com/banobagips https://m.place.naver.com/hospital/21033469 리뷰 773개
3 뷰성형외과 프리미엄/하이타깃 후보 강남 뷰성형외과 역삼센터(역삼) https://www.viewclinic.com https://www.youtube.com/@ViewclinicKR https://www.instagram.com/viewplastic/ https://www.facebook.com/viewps1/ https://www.gangnamunni.com/hospitals/189 https://blog.naver.com/viewclinicps https://m.place.naver.com/hospital/11709005 리뷰 776개
4 아이디병원 프리미엄/하이타깃 후보 강남 아이디병원 별관(역삼) https://www.idhospital.com https://www.youtube.com/user/IDhospital https://www.instagram.com/idhospital https://www.facebook.com/idhospital0050 https://www.gangnamunni.com/hospitals/257 https://blog.naver.com/idfacial https://m.place.naver.com/hospital/11548359
5 그랜드성형외과 프리미엄/하이타깃 후보 강남 https://www.grandsurgery.com https://www.youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ https://www.instagram.com/grand_korea/ https://www.facebook.com/grandps.korea https://www.gangnamunni.com/hospitals/62 https://blog.naver.com/grandprs https://m.place.naver.com/hospital/12322994
6 원진성형외과 프리미엄/하이타깃 후보 강남 https://www.k-wonjin.co.kr https://www.youtube.com/@wjwonjin https://www.instagram.com/wonjin_official/ https://www.facebook.com/KwonjinPS https://www.tiktok.com/@wonjin_official https://www.gangnamunni.com/hospitals/2500 https://blog.naver.com/popokpop https://m.place.naver.com/hospital/11887873 리뷰 9개
7 마인드성형외과 프리미엄/하이타깃 후보 강남 https://www.mindprs.com https://www.youtube.com/channel/UCzM5tIgkC8Es10YmLI55R_w https://www.instagram.com/mind.prs/ https://www.gangnamunni.com/hospitals/729 https://m.place.naver.com/hospital/1342923541 리뷰 8개
8 브라운성형외과 프리미엄/하이타깃 후보 강남 https://www.braunps.co.kr https://www.youtube.com/@BraunPlasticSurgery https://www.instagram.com/braunps_official/ https://www.facebook.com/braunps https://www.gangnamunni.com/hospitals/215 https://m.place.naver.com/hospital/13299185 리뷰 2451개
9 오메가성형외과 프리미엄/하이타깃 후보 강남 http://www.omegaps.co.kr https://www.youtube.com/channel/UC4C2BB4Dp9L_QyuyD22FL0A https://www.instagram.com/omega_plastic_surgery/ https://www.facebook.com/오메가성형외과-1682577712065719/ https://www.gangnamunni.com/hospitals/926 https://m.place.naver.com/hospital/12840364 리뷰 1711개
10 나나성형외과 프리미엄/하이타깃 후보 강남 https://www.nanaprs.com https://www.youtube.com/@Nanaprstv https://www.instagram.com/nanaprs/ https://www.facebook.com/nanaprs2 https://www.gangnamunni.com/hospitals/938 https://blog.naver.com/nanaprs1 https://m.place.naver.com/hospital/1518147116 리뷰 919개
11 노트성형외과 프리미엄/하이타깃 후보 강남 http://notebreast.com https://www.youtube.com/channel/UC2OIyb2serotqDyEzQYyCtA https://www.instagram.com/note.prs/ https://www.facebook.com/noteprs2/ https://www.gangnamunni.com/hospitals/2186 https://blog.naver.com/noteprs01 https://m.place.naver.com/hospital/1572670484 리뷰 9203개
12 디에이성형외과 프리미엄/하이타깃 후보 강남 https://daprs.com https://www.youtube.com/channel/UC0wlA-w5JIt0G0EeWQf65AQ https://www.instagram.com/da_plastic_surgery https://www.facebook.com/daprs/ https://www.tiktok.com/@daprs https://www.gangnamunni.com/hospitals/250 https://blog.naver.com/daprs https://m.place.naver.com/hospital/33084820 리뷰 1908개
13 에이비성형외과 프리미엄/하이타깃 후보 강남 https://www.abps.co.kr https://www.youtube.com/@abplasticsurgery https://www.instagram.com/ab_plasticsurgery_kr/ https://www.facebook.com/profile.php?id=100063560996454 https://www.gangnamunni.com/hospitals/3004 https://blog.naver.com/abps20 https://m.place.naver.com/hospital/1304260302 리뷰 23599개
14 비아이오성형외과 프리미엄/하이타깃 후보 강남 http://biopskorea.com https://www.youtube.com/channel/UC2WCPPw2onOI4UVqt7LIfKw https://www.instagram.com/bio_dr.hongsungpyo https://www.facebook.com/biopskorea https://blog.naver.com/bioplastics20 https://m.place.naver.com/hospital/21428819
15 윈성형외과 프리미엄/하이타깃 후보 강남
16 제이준성형외과 프리미엄/하이타깃 후보 강남 http://www.jjprs.com https://www.youtube.com/channel/UC1p4msJnetDKeOW-B4nlwZA https://www.instagram.com/jayjunps/ https://www.facebook.com/jayjunps https://www.gangnamunni.com/hospitals/139 https://blog.naver.com/gusrlf00 https://m.place.naver.com/hospital/36294945 리뷰 873개
17 마블성형외과 프리미엄/하이타깃 후보 강남 마블성형외과 압구정(압구정) https://marbleps.com https://www.youtube.com/@marbleps https://www.instagram.com/marble__ps/ https://www.facebook.com/marbleps/ https://www.gangnamunni.com/hospitals/141 https://blog.naver.com/marbleplastic https://m.place.naver.com/hospital/37206762 리뷰 15207개
18 쥬얼리성형외과 프리미엄/하이타깃 후보 강남 https://www.jewelryps.kr https://www.youtube.com/@JewelrypsKr1 https://www.instagram.com/jewelryps_kr https://www.facebook.com/juelyps https://www.gangnamunni.com/hospitals/55 https://blog.naver.com/prsshin2 https://m.place.naver.com/hospital/13443226 리뷰 257개
19 티에스성형외과 프리미엄/하이타깃 후보 강남 http://www.tsprs.com https://www.youtube.com/channel/UCgnizu8p7lbCnfIs76O8J-A https://www.instagram.com/tsprs_official/ https://www.facebook.com/tsprs https://www.gangnamunni.com/hospitals/116 https://blog.naver.com/tsprs https://m.place.naver.com/hospital/36905792 리뷰 397개
20 유노성형외과 프리미엄/하이타깃 후보 강남 https://www.yunoprs.com https://www.youtube.com/channel/UCrEe-LfBLXA1-bTGS4bWmtg https://www.instagram.com/doctor.yuno/ https://www.facebook.com/doctor.yuno/ https://www.gangnamunni.com/hospitals/248 https://blog.naver.com/yuno_blog https://m.place.naver.com/hospital/38486006 리뷰 9209개
21 리젠성형외과 프리미엄/하이타깃 후보 강남 http://www.regen.co.kr
22 리팅성형외과 프리미엄/하이타깃 후보 강남 https://liting.co.kr https://www.youtube.com/@liting_ps https://www.instagram.com/liting_psps/ https://www.gangnamunni.com/hospitals/331 https://blog.naver.com/night140160 https://m.place.naver.com/hospital/38673363 리뷰 5474개
23 앤써성형외과 프리미엄/하이타깃 후보 강남 http://www.answer-ps.co.kr https://www.youtube.com/@TV-gf8ms https://www.instagram.com/answerps https://www.facebook.com/answerps4881 https://www.gangnamunni.com/hospitals/1449 https://blog.naver.com/answerps https://m.place.naver.com/hospital/34138819
24 더픽스성형외과 프리미엄/하이타깃 후보 강남 http://thefixps.com https://www.youtube.com/channel/UCXVNowmecu1tF-kCYhFE_QQ https://www.instagram.com/thefixps/ https://www.facebook.com/psdonghoon https://www.gangnamunni.com/hospitals/2196 https://blog.naver.com/donghwn https://m.place.naver.com/hospital/1310778318
25 기린성형외과 프리미엄/하이타깃 후보 강남 https://girinps.com https://www.youtube.com/@girin_official https://www.instagram.com/girin_ps_official/ https://www.tiktok.com/@girin_korea https://www.gangnamunni.com/hospitals/398 https://blog.naver.com/girinlife https://m.place.naver.com/hospital/21705376 리뷰 152개
26 페이스성형외과 프리미엄/하이타깃 후보 강남 http://www.face-plus.co.kr https://www.youtube.com/@koreanplasticsurgery https://www.instagram.com/faceplus_ps/ https://www.gangnamunni.com/hospitals/1515 https://blog.naver.com/soonjung-49 https://m.place.naver.com/hospital/13347560
27 압구정서울성형외과 프리미엄/하이타깃 후보 압구정 http://www.asps.co.kr https://www.youtube.com/channel/UCqNtVCL2u5Xvx74ymboxDyg https://www.instagram.com/asps_no.1/ https://www.instagram.com/asps_en/ https://www.gangnamunni.com/hospitals/213 https://blog.naver.com/asps0119 https://m.place.naver.com/hospital/11531189 리뷰 357개
28 리젠메디컬그룹 프리미엄/하이타깃 후보 압구정 http://regenskin.co.kr https://www.instagram.com/regenskin https://www.facebook.com/pages/리젠피부과/785877121503059 https://www.gangnamunni.com/hospitals/4154 윈느성형외과 리젠메디컬타워 https://blog.naver.com/shinygn1
29 코코성형외과 프리미엄/하이타깃 후보 압구정 http://www.kodoctor.co.kr https://www.youtube.com/channel/UCc19LE6Elp0OTNo9gDizJMw https://www.instagram.com/koko_ps_official/ https://www.facebook.com/kokodoctor/ https://www.gangnamunni.com/hospitals/623 https://blog.naver.com/koko_plastic_surgery https://m.place.naver.com/hospital/1023834988
30 오브제성형외과 프리미엄/하이타깃 후보 압구정 http://objetps.com https://www.youtube.com/channel/UC2QrFhj-S8oUrbfOp1NXKZA https://www.instagram.com/objet_plastic_surgery/ https://www.gangnamunni.com/hospitals/2122 https://blog.naver.com/objetps
31 리상성형외과 프리미엄/하이타깃 후보 압구정 https://theregenps.com https://www.youtube.com/@theregen_ps https://www.instagram.com/theregen_ps https://www.gangnamunni.com/hospitals/6597 https://blog.naver.com/theps_kor https://m.place.naver.com/hospital/1125605502
32 에이트성형외과 프리미엄/하이타깃 후보 압구정 에이트성형외과 서초(서초) https://www.eightprs.com https://www.youtube.com/channel/UCpTmm84yJLgIBwBcBhjie4Q https://www.instagram.com/eightps8/ https://www.tiktok.com/@eighthospital.th https://www.gangnamunni.com/hospitals/166 https://blog.naver.com/eightplasticsurgery https://m.place.naver.com/hospital/31986276 방문자리뷰 586, 블로그리뷰 3120
33 오페라성형외과 프리미엄/하이타깃 후보 압구정 http://www.operasurgery.co.kr https://www.youtube.com/channel/UC2R_jfmmn0zE-dkButFS_ZA https://www.instagram.com/opera_ps/ https://www.facebook.com/operasurgery/ https://www.gangnamunni.com/hospitals/108 https://blog.naver.com/fasolt2 https://m.place.naver.com/hospital/10827484
34 리엔장성형외과 프리미엄/하이타깃 후보 압구정 https://ps.lienjang.net https://www.youtube.com/channel/UCxyiPTH9xHqhPVfwyyosXMQ https://www.instagram.com/lienjang_official/ https://www.tiktok.com/@lienjang_ps https://www.gangnamunni.com/hospitals/69
35 루호성형외과 프리미엄/하이타깃 후보 압구정 https://www.luho.kr https://www.youtube.com/channel/UCZE8dRSsc6CwdORbAhxV2Rw https://www.instagram.com/luho_beauty/ https://www.tiktok.com/@luho_beauty_jp https://www.gangnamunni.com/hospitals/660 https://m.place.naver.com/hospital/21868487 방문자리뷰 1577, 블로그리뷰 608
36 티아라성형외과 프리미엄/하이타깃 후보 압구정 https://www.gangnamunni.com/hospitals/231 SC301의원으로 상호변경
37 쏘울성형외과 프리미엄/하이타깃 후보 압구정 https://www.soulps.kr https://www.instagram.com/soul_plastic_surgery/ https://www.gangnamunni.com/hospitals/4244
38 에이탑성형외과 프리미엄/하이타깃 후보 압구정 https://atopps.com https://www.youtube.com/channel/UCPBCTxCX1hyRnXejA8QfFEQ https://www.instagram.com/atop_instalog/ https://www.facebook.com/Dr.goodhand https://www.gangnamunni.com/hospitals/300 https://blog.naver.com/atopps_ https://m.place.naver.com/hospital/877412762
39 유스성형외과 프리미엄/하이타깃 후보 압구정
40 유캔비성형외과 프리미엄/하이타깃 후보 압구정 https://www.ucanb.co.kr https://www.youtube.com/channel/UCegLA3CpLkDOe5h_DnhUpFA https://www.instagram.com/ucanb_plastic_surgery/ https://www.facebook.com/ucanbps https://www.gangnamunni.com/hospitals/563 https://blog.naver.com/ucanb2338
41 스타성형외과 프리미엄/하이타깃 후보 압구정 https://www.starclinic.co.kr https://www.youtube.com/channel/UCpIF_ffpk4R10_iw0q0tR7g https://m.place.naver.com/hospital/2092418109
42 아이템성형외과 프리미엄/하이타깃 후보 압구정 https://www.ittemps.kr https://www.youtube.com/channel/UCJolLz6ag4m2UfZh56HC2hQ https://www.instagram.com/ittem_ps https://www.facebook.com/잇템성형외과-101634361973942 https://blog.naver.com/ittem_ps
43 리코성형외과 프리미엄/하이타깃 후보 압구정 http://www.licoclinic.com https://www.youtube.com/channel/UC3T_EiEpQr1xE16o69XCqjQ https://www.instagram.com/licobest/ https://www.facebook.com/licotop https://blog.naver.com/licops https://m.place.naver.com/hospital/20800108
44 플레저성형외과 프리미엄/하이타깃 후보 압구정 http://www.pleasureps.com https://www.youtube.com/c/PSPS_korea https://www.instagram.com/pleasure_ps/ https://www.gangnamunni.com/hospitals/2991 https://blog.naver.com/2amsomething
45 비너스성형외과 프리미엄/하이타깃 후보 압구정
46 아이웰성형외과 프리미엄/하이타깃 후보 압구정 http://www.iwellps.com https://www.instagram.com/iwellps/ https://www.gangnamunni.com/hospitals/58
47 글로비성형외과 프리미엄/하이타깃 후보 압구정 https://glovips.com https://www.youtube.com/channel/UCH4Orbmc3cFqajWx1I6Xs2w https://www.instagram.com/glovips/ https://www.facebook.com/glovips https://www.gangnamunni.com/hospitals/54 https://blog.naver.com/glovips https://m.place.naver.com/hospital/13359904
48 엘르성형외과 프리미엄/하이타깃 후보 압구정 http://www.elleclinic.com https://www.instagram.com/elleps_korea/ https://www.gangnamunni.com/hospitals/1181
49 원픽성형외과 프리미엄/하이타깃 후보 압구정 https://onepeakps.com https://www.gangnamunni.com/hospitals/3000
50 탑페이스성형외과 프리미엄/하이타깃 후보 압구정 https://www.topfaceps.com https://www.youtube.com/channel/UCNfZc5aXZ5A1mqX5iIw91EA https://www.instagram.com/topfaceps/ https://www.gangnamunni.com/hospitals/66 https://blog.naver.com/ina3599 https://m.place.naver.com/hospital/33283530 방문자리뷰 312, 블로그리뷰 179
51 세민성형외과 프리미엄/하이타깃 후보 역삼 http://www.semin100.co.kr https://www.youtube.com/channel/UCKaNYEvRqME2h1lUSOYIYew https://blog.naver.com/semin100
52 에이치성형외과 프리미엄/하이타깃 후보 역삼 https://www.3dfit.co.kr https://www.youtube.com/channel/UC6d8i9WwIkm161ueFyVU2PA https://www.facebook.com/3DFIT100 https://blog.naver.com/ys100ps
53 리노보성형외과 프리미엄/하이타깃 후보 역삼 http://www.renovo.co.kr https://www.instagram.com/renovo.clinic.1/ https://www.gangnamunni.com/hospitals/4749 https://m.place.naver.com/hospital/13023672
54 디엘성형외과 프리미엄/하이타깃 후보 역삼 https://www.dlprs.com https://www.youtube.com/@LINEKING_DL https://www.instagram.com/dl_plastic_official/ https://www.gangnamunni.com/hospitals/5500 https://m.place.naver.com/hospital/1998543478
55 피알성형외과 프리미엄/하이타깃 후보 역삼 http://prprs.co.kr https://www.youtube.com/@pr_ps https://www.instagram.com/prprs_official/ https://www.facebook.com/PRPRS.official https://www.gangnamunni.com/hospitals/431 https://m.place.naver.com/hospital/1655749880
56 라프린성형외과 프리미엄/하이타깃 후보 역삼 http://laprinps.com https://www.youtube.com/channel/UCfMt8TTT8kJklZORjf4nJ2w https://www.instagram.com/laprin_kr/ https://www.facebook.com/laprinprincess/ https://blog.naver.com/ff8kds6t5cmxd https://m.place.naver.com/hospital/33741139
57 도도성형외과 프리미엄/하이타깃 후보 역삼 http://www.dodobeauty.com https://blog.naver.com/mmscjh
58 엠제이성형외과 프리미엄/하이타깃 후보 역삼 https://www.mjskinclinic.com https://www.youtube.com/channel/UCFjkFyYDu4HpLjc9axlh6YQ https://www.facebook.com/people/MJ피부과/100063928095304/ https://blog.naver.com/mjskinclinic
59 아우라성형외과 프리미엄/하이타깃 후보 역삼 http://psaura.com https://www.youtube.com/@auraps2024 https://www.instagram.com/auraps_official/ https://www.gangnamunni.com/hospitals/5870 https://blog.naver.com/pristor https://m.place.naver.com/hospital/1806079685
60 하이봄성형외과 프리미엄/하이타깃 후보 역삼 https://www.highvom.com https://www.youtube.com/channel/UCuuH9oWh9h29hVRmH2sHAuA https://www.instagram.com/highvom/ https://www.facebook.com/highvom1 https://www.gangnamunni.com/hospitals/2052 https://blog.naver.com/hivom https://m.place.naver.com/hospital/1714219923
61 에이원성형외과 프리미엄/하이타깃 후보 역삼
62 유앤아이성형외과 프리미엄/하이타깃 후보 역삼 https://www.uni114.co.kr https://www.youtube.com/channel/UCHgZnNk3JIDxnoYcWSWtAJw https://www.instagram.com/skinuni114/ https://www.facebook.com/uni114 https://www.gangnamunni.com/hospitals/4459 https://m.place.naver.com/hospital/60058049
63 리본성형외과 프리미엄/하이타깃 후보 역삼 https://www.youtube.com/channel/UCyFdX9zxfu2e2JVTwPjki5A https://www.instagram.com/rebornps_/ https://www.facebook.com/reborn3355/ https://www.gangnamunni.com/hospitals/339 https://blog.naver.com/reborn1999
64 리메이성형외과 프리미엄/하이타깃 후보 역삼
65 라이크성형외과 프리미엄/하이타깃 후보 역삼 http://www.likeps.com https://www.youtube.com/c/LIKEPLASTICSURGERY https://www.instagram.com/likeps_kr/ https://www.gangnamunni.com/hospitals/912
66 케이플러스성형외과 프리미엄/하이타깃 후보 역삼 https://k-clinics.com https://www.youtube.com/@k-plasticsurgery https://www.instagram.com/k_plasticsurgery/ https://www.facebook.com/kclinics https://www.gangnamunni.com/hospitals/1265 https://blog.naver.com/kclinics-osh https://m.place.naver.com/hospital/36912372
67 케이아트성형외과 프리미엄/하이타깃 후보 역삼 http://www.k-artps.com https://www.youtube.com/channel/UCHs9LOYtauBIklXhPPZBAgA https://www.instagram.com/kartps/ https://www.facebook.com/kartpsseoul https://blog.naver.com/kartps
68 이룸성형외과 프리미엄/하이타깃 후보 역삼 http://www.seoulips.com https://www.youtube.com/@seouliplasticsurgery https://www.instagram.com/seoulips/ https://www.facebook.com/서울아이성형외과-105199207892670/ https://www.tiktok.com/@seoulips_jp https://www.gangnamunni.com/hospitals/839 https://blog.naver.com/seoulips https://m.place.naver.com/hospital/1240083198
69 서초서울성형외과 프리미엄/하이타깃 후보 서초 https://srprs.co.kr https://www.instagram.com/saerops/ https://www.gangnamunni.com/hospitals/5554 https://m.place.naver.com/hospital/1692465198
70 서초연세성형외과 프리미엄/하이타깃 후보 서초 https://www.chaminst.com https://www.youtube.com/channel/UCMxBfZivKz5jjhRiy0mzOXQ https://www.instagram.com/chamin_ps https://www.gangnamunni.com/hospitals/4212 https://blog.naver.com/chaminst
71 리모성형외과 프리미엄/하이타깃 후보 서초 http://www.ksh-ps.com https://www.youtube.com/channel/UCCiH9OffJgZTMnkxatK8-5A https://www.instagram.com/sh___ps/ https://www.gangnamunni.com/hospitals/6680 https://blog.naver.com/shpsclinic https://m.place.naver.com/hospital/1865533181
72 화이트성형외과 프리미엄/하이타깃 후보 서초 http://www.whiteclinic.com
73 미호성형외과 프리미엄/하이타깃 후보 서초 https://mihops.co.kr https://www.youtube.com/channel/UC8kyWAnMM7f_xguyPxR_gtA https://www.instagram.com/mihops_kr/ https://www.gangnamunni.com/hospitals/369 https://blog.naver.com/qalfuqbjtmnf https://m.place.naver.com/hospital/344342362
74 나우성형외과 프리미엄/하이타깃 후보 서초
75 와이즈성형외과 프리미엄/하이타깃 후보 서초 https://www.instagram.com/wise_ps/ https://www.gangnamunni.com/hospitals/2414 와이즈유의원
76 케이스타성형외과 프리미엄/하이타깃 후보 서초
77 셀린성형외과 프리미엄/하이타깃 후보 서초
78 에비뉴성형외과 프리미엄/하이타깃 후보 서초 http://www.avenueps.com https://www.youtube.com/channel/UCOTVAerYogSEia3L-ERkAFg https://www.gangnamunni.com/hospitals/6204
79 비온성형외과 프리미엄/하이타깃 후보 서초 http://www.bon-ps.com https://www.youtube.com/channel/UClCjGIfEb3b1N-Q5tCNSU5w https://blog.naver.com/miz2199 https://m.place.naver.com/hospital/1492257313
80 아크성형외과 프리미엄/하이타깃 후보 서초 http://arc-ps.com https://www.youtube.com/@-arcisart7955 https://www.instagram.com/arc_plastic_surgery/ https://www.gangnamunni.com/hospitals/3569 https://m.place.naver.com/hospital/1638918034
81 에버성형외과 프리미엄/하이타깃 후보 서초 http://www.everclinic.com https://www.youtube.com/channel/UCwSusNRTM2B_mvdRd0yNVcw https://www.instagram.com/everclinic0088/ https://www.facebook.com/everplasticsurgery https://blog.naver.com/drblue007
82 원스성형외과 프리미엄/하이타깃 후보 서초
83 메이성형외과 프리미엄/하이타깃 후보 서초
84 라인업성형외과 프리미엄/하이타깃 후보 서초
85 유앤미성형외과 프리미엄/하이타깃 후보 서초 https://www.knyounmeclinic.co.kr https://www.youtube.com/channel/UCAROD-B1ZBinpyzql7o_IlA https://www.instagram.com/knyounmeclinic/ https://www.gangnamunni.com/hospitals/1178 https://m.place.naver.com/hospital/31068065
86 리더스성형외과 프리미엄/하이타깃 후보 서초
87 마노성형외과 프리미엄/하이타깃 후보 서초 https://manops.co.kr https://www.youtube.com/@mano_ps https://www.instagram.com/manops_official https://www.gangnamunni.com/hospitals/3429 https://blog.naver.com/tkhr4747
88 예롬성형외과 프리미엄/하이타깃 후보 서초 https://www.gangnamunni.com/hospitals/450
89 에스엠성형외과 프리미엄/하이타깃 후보 서초 https://www.sm-ps.co.kr https://www.youtube.com/@smpsclinic https://www.instagram.com/smps_plastic_surgery/ https://www.facebook.com/thammySM3707 https://www.gangnamunni.com/hospitals/413 https://blog.naver.com/smps1004 https://m.place.naver.com/hospital/32876182
90 리안성형외과 프리미엄/하이타깃 후보 서초
91 서울미작성형외과 프리미엄/하이타깃 후보 서초 https://mijakclinic.co.kr https://www.youtube.com/channel/UCld-NgVeydtDRVTt6xQRbmA https://www.instagram.com/mijak2444/ https://www.facebook.com/mijaknose https://blog.naver.com/mijak3444
92 더원성형외과 프리미엄/하이타깃 후보 서초 https://www.theoneclinic.co.kr https://www.instagram.com/theone_plastic_surgery/ https://www.facebook.com/pages/category/Hospital/더원성형외과-1539415639613021/ https://www.gangnamunni.com/hospitals/5636 https://blog.naver.com/brs0714

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Extract Naver Place IDs from links arrays"""
import re
import json
import sys
def extract_place_id(links):
"""Extract first valid Naver Place ID from list of URLs"""
place_ids = set()
for link in links:
# Pattern: place/DIGITS in map.naver.com URLs
# But NOT in search URLs or directions URLs with coordinates
if 'map.naver.com' in link:
matches = re.findall(r'place/(\d{7,12})', link)
for m in matches:
# Filter out coordinate-like numbers (14140xxx pattern)
if not m.startswith('1414'):
place_ids.add(m)
if place_ids:
# Return the most common ID (first one found in entry/place URLs)
for link in links:
if 'entry/place/' in link:
match = re.search(r'entry/place/(\d{7,12})', link)
if match and not match.group(1).startswith('1414'):
return match.group(1)
# Fallback: return smallest ID (usually the main one)
return min(place_ids, key=len)
return None
if __name__ == '__main__':
data = json.load(sys.stdin)
pid = extract_place_id(data)
if pid:
print(pid)
else:
print("NOT_FOUND")

View File

@ -0,0 +1,96 @@
hospital_name,brand_group,district,website_kr,website_en,youtube_url,youtube_note,instagram_kr_url,instagram_kr_note,instagram_en_url,instagram_en_note,facebook_url,facebook_note,tiktok_url,tiktok_note,gangnam_unni_url,gangnam_unni_note,naver_blog_url,naver_blog_note,naver_place_url,naver_place_reviews_note,google_maps_url,google_reviews_note
바노바기성형외과,프리미엄/하이타깃 후보,강남,https://www.banobagi.com,,,,,,,,,,,,,,,,,,,
뷰성형외과,프리미엄/하이타깃 후보,강남,https://www.viewclinic.com,,,,,,,,,,,,,,,,,,,
아이디병원,프리미엄/하이타깃 후보,강남,https://www.idhospital.com,,,,,,,,,,,,,,,,,,,
그랜드성형외과,프리미엄/하이타깃 후보,강남,https://www.grandsurgery.com,,,,,,,,,,,,,,,,,,,
원진성형외과,프리미엄/하이타깃 후보,강남,https://www.k-wonjin.co.kr,,,,,,,,,,,,,,,,,,,
마인드성형외과,프리미엄/하이타깃 후보,강남,https://www.mindprs.com,,,,,,,,,,,,,,,,,,,
브라운성형외과,프리미엄/하이타깃 후보,강남,https://www.braunps.co.kr,,,,,,,,,,,,,,,,,,,
오메가성형외과,프리미엄/하이타깃 후보,강남,http://www.omegaps.co.kr,,,,,,,,,,,,,,,,,,,
나나성형외과,프리미엄/하이타깃 후보,강남,https://www.nanaprs.com,,,,,,,,,,,,,,,,,,,
노트성형외과,프리미엄/하이타깃 후보,강남,http://notebreast.com,,,,,,,,,,,,,,,,,,,
디에이성형외과,프리미엄/하이타깃 후보,강남,https://daprs.com,,,,,,,,,,,,,,,,,,,
에이비성형외과,프리미엄/하이타깃 후보,강남,https://www.abps.co.kr,,,,,,,,,,,,,,,,,,,
비아이오성형외과,프리미엄/하이타깃 후보,강남,http://biopskorea.com,,,,,,,,,,,,,,,,,,,
윈성형외과,프리미엄/하이타깃 후보,강남,https://www.k-wonjin.co.kr,,,,,,,,,,,,,,,,,,,
제이준성형외과,프리미엄/하이타깃 후보,강남,http://www.jjprs.com,,,,,,,,,,,,,,,,,,,
마블성형외과,프리미엄/하이타깃 후보,강남,https://marbleps.com,,,,,,,,,,,,,,,,,,,
쥬얼리성형외과,프리미엄/하이타깃 후보,강남,https://www.jewelryps.kr,,,,,,,,,,,,,,,,,,,
티에스성형외과,프리미엄/하이타깃 후보,강남,http://www.tsprs.com,,,,,,,,,,,,,,,,,,,
유노성형외과,프리미엄/하이타깃 후보,강남,https://www.yunoprs.com,,,,,,,,,,,,,,,,,,,
리젠성형외과,프리미엄/하이타깃 후보,강남,리젠 성형외과,,,,,,,,,,,,,,,,,,,
리팅성형외과,프리미엄/하이타깃 후보,강남,https://liting.co.kr,,,,,,,,,,,,,,,,,,,
앤써성형외과,프리미엄/하이타깃 후보,강남,http://www.answer-ps.co.kr,,,,,,,,,,,,,,,,,,,
더픽스성형외과,프리미엄/하이타깃 후보,강남,http://thefixps.com,,,,,,,,,,,,,,,,,,,
기린성형외과,프리미엄/하이타깃 후보,강남,https://girinps.com,,,,,,,,,,,,,,,,,,,
페이스성형외과,프리미엄/하이타깃 후보,강남,http://www.face-plus.co.kr,,,,,,,,,,,,,,,,,,,
압구정서울성형외과,프리미엄/하이타깃 후보,압구정,http://www.asps.co.kr,,,,,,,,,,,,,,,,,,,
리젠메디컬그룹,프리미엄/하이타깃 후보,압구정,http://regenskin.co.kr,,,,,,,,,,,,,,,,,,,
마블성형외과 압구정,프리미엄/하이타깃 후보,압구정,https://marbleps.com,,,,,,,,,,,,,,,,,,,
코코성형외과,프리미엄/하이타깃 후보,압구정,http://www.kodoctor.co.kr,,,,,,,,,,,,,,,,,,,
오브제성형외과,프리미엄/하이타깃 후보,압구정,http://objetps.com,,,,,,,,,,,,,,,,,,,
리상성형외과,프리미엄/하이타깃 후보,압구정,https://theregenps.com,,,,,,,,,,,,,,,,,,,
에이트성형외과,프리미엄/하이타깃 후보,압구정,https://www.eightprs.com,,,,,,,,,,,,,,,,,,,
오페라성형외과,프리미엄/하이타깃 후보,압구정,https://gangnam.lienjang.net,,,,,,,,,,,,,,,,,,,
리엔장성형외과,프리미엄/하이타깃 후보,압구정,https://www.soulps.kr,,,,,,,,,,,,,,,,,,,
루호성형외과,프리미엄/하이타깃 후보,압구정,https://m.atopps.com,,,,,,,,,,,,,,,,,,,
티아라성형외과,프리미엄/하이타깃 후보,압구정,https://www.ucanb.co.kr,,,,,,,,,,,,,,,,,,,
쏘울성형외과,프리미엄/하이타깃 후보,압구정,https://www.starclinic.co.kr,,,,,,,,,,,,,,,,,,,
에이탑성형외과,프리미엄/하이타깃 후보,압구정,http://www.licoclinic.com,,,,,,,,,,,,,,,,,,,
유스성형외과,프리미엄/하이타깃 후보,압구정,https://www.pspskorea.com,,,,,,,,,,,,,,,,,,,
유캔비성형외과,프리미엄/하이타깃 후보,압구정,http://www.iwellps.com,,,,,,,,,,,,,,,,,,,
스타성형외과,프리미엄/하이타깃 후보,압구정,https://glovips.com,,,,,,,,,,,,,,,,,,,
아이템성형외과,프리미엄/하이타깃 후보,압구정,http://www.elleclinic.com,,,,,,,,,,,,,,,,,,,
리코성형외과,프리미엄/하이타깃 후보,압구정,https://onepeakps.com,,,,,,,,,,,,,,,,,,,
플레저성형외과,프리미엄/하이타깃 후보,압구정,https://www.pspskorea.com,,,,,,,,,,,,,,,,,,,
비너스성형외과,프리미엄/하이타깃 후보,압구정,http://www.venusbreast.com,,,,,,,,,,,,,,,,,,,
아이웰성형외과,프리미엄/하이타깃 후보,압구정,http://www.iwellps.com,,,,,,,,,,,,,,,,,,,
글로비성형외과,프리미엄/하이타깃 후보,압구정,https://glovips.com,,,,,,,,,,,,,,,,,,,
엘르성형외과,프리미엄/하이타깃 후보,압구정,http://www.elleclinic.com,,,,,,,,,,,,,,,,,,,
원픽성형외과,프리미엄/하이타깃 후보,압구정,https://onepeakps.com,,,,,,,,,,,,,,,,,,,
탑페이스성형외과,프리미엄/하이타깃 후보,압구정,https://www.topfaceps.com/,,,,,,,,,,,,,,,,,,,
아이디병원 별관,프리미엄/하이타깃 후보,역삼,https://www.idhospital.com,,,,,,,,,,,,,,,,,,,
뷰성형외과 역삼센터,프리미엄/하이타깃 후보,역삼,https://www.viewclinic.com,,,,,,,,,,,,,,,,,,,
세민성형외과,프리미엄/하이타깃 후보,역삼,http://www.semin100.co.kr,,,,,,,,,,,,,,,,,,,
에이치성형외과,프리미엄/하이타깃 후보,역삼,https://www.3dfit.co.kr,,,,,,,,,,,,,,,,,,,
리노보성형외과,프리미엄/하이타깃 후보,역삼,http://www.renovo.co.kr,,,,,,,,,,,,,,,,,,,
디엘성형외과,프리미엄/하이타깃 후보,역삼,https://www.dlprs.com,,,,,,,,,,,,,,,,,,,
피알성형외과,프리미엄/하이타깃 후보,역삼,http://prprs.co.kr,,,,,,,,,,,,,,,,,,,
라프린성형외과,프리미엄/하이타깃 후보,역삼,http://laprinps.com,,,,,,,,,,,,,,,,,,,
도도성형외과,프리미엄/하이타깃 후보,역삼,http://www.dodobeauty.com,,,,,,,,,,,,,,,,,,,
엠제이성형외과,프리미엄/하이타깃 후보,역삼,https://www.mjskinclinic.com,,,,,,,,,,,,,,,,,,,
아우라성형외과,프리미엄/하이타깃 후보,역삼,http://psaura.com,,,,,,,,,,,,,,,,,,,
하이봄성형외과,프리미엄/하이타깃 후보,역삼,https://www.highvom.com,,,,,,,,,,,,,,,,,,,
에이원성형외과,프리미엄/하이타깃 후보,역삼,http://www.aone.achttps://www.uni114.co.kr,,,,,,,,,,,,,,,,,,,
유앤아이성형외과,프리미엄/하이타깃 후보,역삼,https://www.rebornps.com검색 중,,,,,,,,,,,,,,,,,,,
리본성형외과,프리미엄/하이타깃 후보,역삼,http://www.likeps.com,,,,,,,,,,,,,,,,,,,
리메이성형외과,프리미엄/하이타깃 후보,역삼,https://remayps.com,,,,,,,,,,,,,,,,,,,
라이크성형외과,프리미엄/하이타깃 후보,역삼,http://www.likeps.com,,,,,,,,,,,,,,,,,,,
케이플러스성형외과,프리미엄/하이타깃 후보,역삼,https://k-clinics.com,,,,,,,,,,,,,,,,,,,
케이아트성형외과,프리미엄/하이타깃 후보,역삼,http://www.k-artps.com,,,,,,,,,,,,,,,,,,,
이룸성형외과,프리미엄/하이타깃 후보,역삼,http://www.seoulips.com,,,,,,,,,,,,,,,,,,,
서초서울성형외과,프리미엄/하이타깃 후보,서초,https://srprs.co.kr,,,,,,,,,,,,,,,,,,,
서초연세성형외과,프리미엄/하이타깃 후보,서초,https://www.chaminst.com,,,,,,,,,,,,,,,,,,,
리모성형외과,프리미엄/하이타깃 후보,서초,http://www.ksh-ps.com,,,,,,,,,,,,,,,,,,,
화이트성형외과,프리미엄/하이타깃 후보,서초,http://www.whiteclinic.com,,,,,,,,,,,,,,,,,,,
미호성형외과,프리미엄/하이타깃 후보,서초,https://mihops.co.kr,,,,,,,,,,,,,,,,,,,
나나성형외과,프리미엄/하이타깃 후보,서초,https://www.nanaprs.com,,,,,,,,,,,,,,,,,,,
와이즈성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
케이스타성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
셀린성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
에비뉴성형외과,프리미엄/하이타깃 후보,서초,http://www.avenueps.com,,,,,,,,,,,,,,,,,,,
에이트성형외과 서초,프리미엄/하이타깃 후보,서초,https://www.eightprs.com,,,,,,,,,,,,,,,,,,,
비온성형외과,프리미엄/하이타깃 후보,서초,http://www.bon-ps.com,,,,,,,,,,,,,,,,,,,
아크성형외과,프리미엄/하이타깃 후보,서초,http://arc-ps.com,,,,,,,,,,,,,,,,,,,
에버성형외과,프리미엄/하이타깃 후보,서초,http://www.everclinic.com,,,,,,,,,,,,,,,,,,,
원스성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
메이성형외과,프리미엄/하이타깃 후보,서초,http://www.makeps.com,,,,,,,,,,,,,,,,,,,
라인업성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
유앤미성형외과,프리미엄/하이타깃 후보,서초,https://www.knyounmeclinic.co.kr,,,,,,,,,,,,,,,,,,,
리더스성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
마노성형외과,프리미엄/하이타깃 후보,서초,https://manops.co.kr,,,,,,,,,,,,,,,,,,,
예롬성형외과,프리미엄/하이타깃 후보,서초,http://www.yeromclinic.co.kr,,,,,,,,,,,,,,,,,,,
에스엠성형외과,프리미엄/하이타깃 후보,서초,https://www.sm-ps.co.kr,,,,,,,,,,,,,,,,,,,
리안성형외과,프리미엄/하이타깃 후보,서초,,,,,,,,,,,,,,,,,,,,
서울미작성형외과,프리미엄/하이타깃 후보,서초,https://mijakclinic.co.kr,,,,,,,,,,,,,,,,,,,
더원성형외과,프리미엄/하이타깃 후보,서초,https://www.theoneclinic.co.kr,,,,,,,,,,,,,,,,,,,
1 hospital_name brand_group district website_kr website_en youtube_url youtube_note instagram_kr_url instagram_kr_note instagram_en_url instagram_en_note facebook_url facebook_note tiktok_url tiktok_note gangnam_unni_url gangnam_unni_note naver_blog_url naver_blog_note naver_place_url naver_place_reviews_note google_maps_url google_reviews_note
2 바노바기성형외과 프리미엄/하이타깃 후보 강남 https://www.banobagi.com
3 뷰성형외과 프리미엄/하이타깃 후보 강남 https://www.viewclinic.com
4 아이디병원 프리미엄/하이타깃 후보 강남 https://www.idhospital.com
5 그랜드성형외과 프리미엄/하이타깃 후보 강남 https://www.grandsurgery.com
6 원진성형외과 프리미엄/하이타깃 후보 강남 https://www.k-wonjin.co.kr
7 마인드성형외과 프리미엄/하이타깃 후보 강남 https://www.mindprs.com
8 브라운성형외과 프리미엄/하이타깃 후보 강남 https://www.braunps.co.kr
9 오메가성형외과 프리미엄/하이타깃 후보 강남 http://www.omegaps.co.kr
10 나나성형외과 프리미엄/하이타깃 후보 강남 https://www.nanaprs.com
11 노트성형외과 프리미엄/하이타깃 후보 강남 http://notebreast.com
12 디에이성형외과 프리미엄/하이타깃 후보 강남 https://daprs.com
13 에이비성형외과 프리미엄/하이타깃 후보 강남 https://www.abps.co.kr
14 비아이오성형외과 프리미엄/하이타깃 후보 강남 http://biopskorea.com
15 윈성형외과 프리미엄/하이타깃 후보 강남 https://www.k-wonjin.co.kr
16 제이준성형외과 프리미엄/하이타깃 후보 강남 http://www.jjprs.com
17 마블성형외과 프리미엄/하이타깃 후보 강남 https://marbleps.com
18 쥬얼리성형외과 프리미엄/하이타깃 후보 강남 https://www.jewelryps.kr
19 티에스성형외과 프리미엄/하이타깃 후보 강남 http://www.tsprs.com
20 유노성형외과 프리미엄/하이타깃 후보 강남 https://www.yunoprs.com
21 리젠성형외과 프리미엄/하이타깃 후보 강남 리젠 성형외과
22 리팅성형외과 프리미엄/하이타깃 후보 강남 https://liting.co.kr
23 앤써성형외과 프리미엄/하이타깃 후보 강남 http://www.answer-ps.co.kr
24 더픽스성형외과 프리미엄/하이타깃 후보 강남 http://thefixps.com
25 기린성형외과 프리미엄/하이타깃 후보 강남 https://girinps.com
26 페이스성형외과 프리미엄/하이타깃 후보 강남 http://www.face-plus.co.kr
27 압구정서울성형외과 프리미엄/하이타깃 후보 압구정 http://www.asps.co.kr
28 리젠메디컬그룹 프리미엄/하이타깃 후보 압구정 http://regenskin.co.kr
29 마블성형외과 압구정 프리미엄/하이타깃 후보 압구정 https://marbleps.com
30 코코성형외과 프리미엄/하이타깃 후보 압구정 http://www.kodoctor.co.kr
31 오브제성형외과 프리미엄/하이타깃 후보 압구정 http://objetps.com
32 리상성형외과 프리미엄/하이타깃 후보 압구정 https://theregenps.com
33 에이트성형외과 프리미엄/하이타깃 후보 압구정 https://www.eightprs.com
34 오페라성형외과 프리미엄/하이타깃 후보 압구정 https://gangnam.lienjang.net
35 리엔장성형외과 프리미엄/하이타깃 후보 압구정 https://www.soulps.kr
36 루호성형외과 프리미엄/하이타깃 후보 압구정 https://m.atopps.com
37 티아라성형외과 프리미엄/하이타깃 후보 압구정 https://www.ucanb.co.kr
38 쏘울성형외과 프리미엄/하이타깃 후보 압구정 https://www.starclinic.co.kr
39 에이탑성형외과 프리미엄/하이타깃 후보 압구정 http://www.licoclinic.com
40 유스성형외과 프리미엄/하이타깃 후보 압구정 https://www.pspskorea.com
41 유캔비성형외과 프리미엄/하이타깃 후보 압구정 http://www.iwellps.com
42 스타성형외과 프리미엄/하이타깃 후보 압구정 https://glovips.com
43 아이템성형외과 프리미엄/하이타깃 후보 압구정 http://www.elleclinic.com
44 리코성형외과 프리미엄/하이타깃 후보 압구정 https://onepeakps.com
45 플레저성형외과 프리미엄/하이타깃 후보 압구정 https://www.pspskorea.com
46 비너스성형외과 프리미엄/하이타깃 후보 압구정 http://www.venusbreast.com
47 아이웰성형외과 프리미엄/하이타깃 후보 압구정 http://www.iwellps.com
48 글로비성형외과 프리미엄/하이타깃 후보 압구정 https://glovips.com
49 엘르성형외과 프리미엄/하이타깃 후보 압구정 http://www.elleclinic.com
50 원픽성형외과 프리미엄/하이타깃 후보 압구정 https://onepeakps.com
51 탑페이스성형외과 프리미엄/하이타깃 후보 압구정 https://www.topfaceps.com/
52 아이디병원 별관 프리미엄/하이타깃 후보 역삼 https://www.idhospital.com
53 뷰성형외과 역삼센터 프리미엄/하이타깃 후보 역삼 https://www.viewclinic.com
54 세민성형외과 프리미엄/하이타깃 후보 역삼 http://www.semin100.co.kr
55 에이치성형외과 프리미엄/하이타깃 후보 역삼 https://www.3dfit.co.kr
56 리노보성형외과 프리미엄/하이타깃 후보 역삼 http://www.renovo.co.kr
57 디엘성형외과 프리미엄/하이타깃 후보 역삼 https://www.dlprs.com
58 피알성형외과 프리미엄/하이타깃 후보 역삼 http://prprs.co.kr
59 라프린성형외과 프리미엄/하이타깃 후보 역삼 http://laprinps.com
60 도도성형외과 프리미엄/하이타깃 후보 역삼 http://www.dodobeauty.com
61 엠제이성형외과 프리미엄/하이타깃 후보 역삼 https://www.mjskinclinic.com
62 아우라성형외과 프리미엄/하이타깃 후보 역삼 http://psaura.com
63 하이봄성형외과 프리미엄/하이타깃 후보 역삼 https://www.highvom.com
64 에이원성형외과 프리미엄/하이타깃 후보 역삼 http://www.aone.achttps://www.uni114.co.kr
65 유앤아이성형외과 프리미엄/하이타깃 후보 역삼 https://www.rebornps.com검색 중
66 리본성형외과 프리미엄/하이타깃 후보 역삼 http://www.likeps.com
67 리메이성형외과 프리미엄/하이타깃 후보 역삼 https://remayps.com
68 라이크성형외과 프리미엄/하이타깃 후보 역삼 http://www.likeps.com
69 케이플러스성형외과 프리미엄/하이타깃 후보 역삼 https://k-clinics.com
70 케이아트성형외과 프리미엄/하이타깃 후보 역삼 http://www.k-artps.com
71 이룸성형외과 프리미엄/하이타깃 후보 역삼 http://www.seoulips.com
72 서초서울성형외과 프리미엄/하이타깃 후보 서초 https://srprs.co.kr
73 서초연세성형외과 프리미엄/하이타깃 후보 서초 https://www.chaminst.com
74 리모성형외과 프리미엄/하이타깃 후보 서초 http://www.ksh-ps.com
75 화이트성형외과 프리미엄/하이타깃 후보 서초 http://www.whiteclinic.com
76 미호성형외과 프리미엄/하이타깃 후보 서초 https://mihops.co.kr
77 나나성형외과 프리미엄/하이타깃 후보 서초 https://www.nanaprs.com
78 와이즈성형외과 프리미엄/하이타깃 후보 서초
79 케이스타성형외과 프리미엄/하이타깃 후보 서초
80 셀린성형외과 프리미엄/하이타깃 후보 서초
81 에비뉴성형외과 프리미엄/하이타깃 후보 서초 http://www.avenueps.com
82 에이트성형외과 서초 프리미엄/하이타깃 후보 서초 https://www.eightprs.com
83 비온성형외과 프리미엄/하이타깃 후보 서초 http://www.bon-ps.com
84 아크성형외과 프리미엄/하이타깃 후보 서초 http://arc-ps.com
85 에버성형외과 프리미엄/하이타깃 후보 서초 http://www.everclinic.com
86 원스성형외과 프리미엄/하이타깃 후보 서초
87 메이성형외과 프리미엄/하이타깃 후보 서초 http://www.makeps.com
88 라인업성형외과 프리미엄/하이타깃 후보 서초
89 유앤미성형외과 프리미엄/하이타깃 후보 서초 https://www.knyounmeclinic.co.kr
90 리더스성형외과 프리미엄/하이타깃 후보 서초
91 마노성형외과 프리미엄/하이타깃 후보 서초 https://manops.co.kr
92 예롬성형외과 프리미엄/하이타깃 후보 서초 http://www.yeromclinic.co.kr
93 에스엠성형외과 프리미엄/하이타깃 후보 서초 https://www.sm-ps.co.kr
94 리안성형외과 프리미엄/하이타깃 후보 서초
95 서울미작성형외과 프리미엄/하이타깃 후보 서초 https://mijakclinic.co.kr
96 더원성형외과 프리미엄/하이타깃 후보 서초 https://www.theoneclinic.co.kr

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""CSV 업데이트 도우미 - 빈 필드만 안전하게 채움"""
import csv
import sys
import json
import os
CSV_PATH = os.path.join(os.path.dirname(__file__), 'clinic_registry_working.csv')
# Column indices
COLS = {
'hospital_name': 0, 'brand_group': 1, 'district': 2, 'branches': 3,
'website_kr': 4, 'website_en': 5,
'youtube_url': 6, 'youtube_note': 7,
'instagram_kr_url': 8, 'instagram_kr_note': 9,
'instagram_en_url': 10, 'instagram_en_note': 11,
'facebook_url': 12, 'facebook_note': 13,
'tiktok_url': 14, 'tiktok_note': 15,
'gangnam_unni_url': 16, 'gangnam_unni_note': 17,
'naver_blog_url': 18, 'naver_blog_note': 19,
'naver_place_url': 20, 'naver_place_reviews_note': 21,
'google_maps_url': 22, 'google_reviews_note': 23,
}
def load_csv():
with open(CSV_PATH, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
header = next(reader)
rows = list(reader)
return header, rows
def save_csv(header, rows):
with open(CSV_PATH, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerow(header)
writer.writerows(rows)
def ensure_row_length(row, min_len=24):
while len(row) < min_len:
row.append('')
return row
def update_hospital(rows, hospital_name, updates: dict):
"""
updates: {'youtube_url': 'https://...', 'instagram_kr_url': 'https://...', ...}
Only fills EMPTY fields. Never overwrites existing data.
Returns True if hospital found.
"""
for row in rows:
row = ensure_row_length(row)
if row[0].strip() == hospital_name.strip():
changed = []
for col_name, value in updates.items():
if col_name not in COLS:
print(f" ⚠️ Unknown column: {col_name}")
continue
idx = COLS[col_name]
if row[idx].strip() == '' and value.strip() != '':
row[idx] = value.strip()
changed.append(f"{col_name}={value.strip()[:50]}")
elif row[idx].strip() != '':
pass # Skip - already has data
if changed:
print(f"{hospital_name}: {', '.join(changed)}")
else:
print(f" ⏭️ {hospital_name}: no empty fields to fill")
return True
print(f"{hospital_name}: NOT FOUND in CSV")
return False
def batch_update(updates_list):
"""
updates_list: [{'hospital_name': '...', 'youtube_url': '...', ...}, ...]
"""
header, rows = load_csv()
count = 0
for item in updates_list:
name = item.pop('hospital_name', None)
if name:
if update_hospital(rows, name, item):
count += 1
save_csv(header, rows)
print(f"\n📊 Updated {count} hospitals. CSV saved.")
def print_coverage():
header, rows = load_csv()
cols_to_check = {
'website_kr': 4, 'youtube': 6, 'instagram_kr': 8,
'facebook': 12, 'tiktok': 14, 'gangnam_unni': 16,
'naver_blog': 18, 'naver_place': 20, 'google_maps': 22
}
total = len(rows)
print(f"\n{'Channel':15s} {'Filled':>6s}/{total} {'%':>5s}")
print("-" * 35)
for name, idx in cols_to_check.items():
filled = sum(1 for r in rows if len(r) > idx and r[idx].strip())
pct = filled * 100 // total if total > 0 else 0
bar = '' * (pct // 5) + '' * (20 - pct // 5)
print(f'{name:15s} {filled:3d}/{total} {pct:3d}% {bar}')
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == 'coverage':
print_coverage()
elif len(sys.argv) > 1 and sys.argv[1] == 'update':
# Read JSON from stdin
data = json.load(sys.stdin)
if isinstance(data, list):
batch_update(data)
elif isinstance(data, dict):
batch_update([data])
else:
print("Usage:")
print(" python update_csv.py coverage")
print(" echo '[{...}]' | python update_csv.py update")

View File

@ -0,0 +1,234 @@
# INFINITH Agent System & Prompts
각 에이전트의 역할, 시스템 프롬프트, 프로세스 정의.
---
## Pipeline Overview
```
URL 입력
[Agent 1] Channel Discovery Agent — 채널 발견 + 검증
[Agent 2] Data Collection Agent — 채널 데이터 전량 수집 + 시장 분석
[Agent 3] Marketing Intelligence Agent — AI 리포트 생성
[Agent 4] Content Director Agent — 콘텐츠 기획 + 캘린더
[Agent 5] Brand Strategist Agent — 브랜드 가이드 + 채널 전략
```
---
## Agent 1: Channel Discovery Agent (채널 발견)
**역할**: 마케팅 리서처. 병원의 모든 온라인 채널을 찾아내는 전문가.
**File**: `supabase/functions/discover-channels/index.ts`
### Process (3단계)
1. **Stage A**: Firecrawl 웹사이트 스크래핑 (병원명 추출 + 소셜 링크 파싱)
2. **Stage B**: 6개 API 병렬 검색 (YouTube API, Naver API, Firecrawl Search, Perplexity, Apify Instagram)
3. **Stage C**: 5개 소스 병합 + 핸들 검증
### System Prompt (Perplexity — Online Presence 종합 분석)
```
Role: Digital marketing analyst specializing in Korean medical clinics.
Task: Search the web thoroughly and provide a comprehensive online presence report.
Output: ONLY valid JSON, no explanation.
User Prompt:
"{clinicName}" 병원의 Online Presence를 종합 분석해줘.
아래 채널들을 모두 검색해서 찾아줘:
- 인스타그램 계정 (병원 공식, 원장 개인, 영문 계정 등 여러개 있을 수 있음)
- 유튜브 채널 (메인 채널, Q&A 채널 등)
- 페이스북 페이지
- 틱톡 계정
- 네이버 블로그 (공식 블로그)
- 카카오톡 채널
- 강남언니 등록 여부 및 URL
- 바비톡 등록 여부
- 네이버 플레이스 등록 여부
```
### System Prompt (Perplexity — 병원명 추출 fallback)
```
Role: None (simple extraction)
System: Respond with ONLY the clinic name in Korean, nothing else.
User: {url} 이 URL의 병원/클리닉 한국어 이름이 뭐야?
```
### Data Sources
| Source | API | 검색 방법 |
|--------|-----|----------|
| 웹사이트 HTML | Firecrawl scrape + map | URL 파싱으로 소셜 링크 추출 |
| YouTube | YouTube Data API v3 | `search?type=channel&q={clinicName}` |
| Naver Blog | Naver Search API | `blog.json?query={clinicName} 공식 블로그` |
| Naver Web | Naver Search API | `webkr.json?query={clinicName} 인스타그램 유튜브` |
| Instagram | Apify instagram-profile-scraper | 병원명 변형으로 직접 프로필 검색 |
| 종합 검색 | Firecrawl Search | `{clinicName} instagram youtube 공식` |
| 종합 분석 | Perplexity sonar | Online Presence 종합 분석 |
---
## Agent 2: Data Collection Agent (데이터 수집)
**역할**: 데이터 엔지니어. 검증된 채널에서 raw 데이터를 전량 수집.
**File**: `supabase/functions/collect-channel-data/index.ts`
### Process
9개 API 병렬 호출 (Promise.allSettled):
1. Instagram — Apify `instagram-profile-scraper`
2. YouTube — YouTube Data API v3 (채널 통계 + 인기 영상 10개)
3. Facebook — Apify `facebook-pages-scraper`
4. 강남언니 — Firecrawl JSON 추출
5. Naver Blog — Naver Search API
6. Naver Place — Naver Local API
7. Google Maps — Apify `compass~crawler-google-places`
8-11. 시장 분석 — Perplexity (경쟁사, 키워드, 시장, 타겟 4개 병렬)
### System Prompt (시장 분석 — 4개 공통)
```
Role: Korean medical marketing analyst
System: Always respond in Korean. Provide data in valid JSON format.
Queries:
1. 경쟁사: {address} 근처 {services} 전문 경쟁 병원 5곳 분석
2. 키워드: {services} 관련 검색 키워드 트렌드 (네이버+구글 월간 검색량 20개)
3. 시장: {services[0]} 시장 트렌드 2025-2026 (규모, 성장률, 트렌드)
4. 타겟: {clinicName} 잠재 고객 분석 (연령, 성별, 채널, 의사결정)
```
### System Prompt (강남언니 추출)
```
Firecrawl JSON extraction:
Extract: hospital name, overall rating (out of 10), total review count,
doctors with names/ratings/review counts/specialties, procedures offered,
address, certifications/badges
```
---
## Agent 3: Marketing Intelligence Agent (리포트 생성)
**역할**: 마케팅 커뮤니케이션 전략가. 실제 수집 데이터를 기반으로 종합 리포트 작성.
**File**: `supabase/functions/generate-report/index.ts`
### System Prompt
```
Role: Korean medical marketing analyst
Constraints:
- Respond ONLY with valid JSON, no markdown code blocks
- Use Korean for text fields
- 강남언니 rating is 10-point scale
- Use ONLY the provided real data — NEVER invent metrics
- If data is missing, write "데이터 없음"
User Prompt Structure:
1. 병원 기본 정보 (scraped data)
2. 실제 채널 데이터 (collected from APIs — YouTube 구독자, Instagram 팔로워 등)
3. 시장 분석 데이터 (Perplexity 검색 결과)
4. 웹사이트 브랜딩 (Firecrawl 추출)
5. JSON 리포트 구조 (channelAnalysis, brandIdentity, kpiTargets, recommendations 등)
```
### Output Structure
```json
{
"clinicInfo": {},
"executiveSummary": "",
"overallScore": 0-100,
"channelAnalysis": { "naverBlog": {}, "instagram": {}, "youtube": {}, ... },
"brandIdentity": [{ "area": "", "asIs": "", "toBe": "" }],
"kpiTargets": [{ "metric": "", "current": "", "target3Month": "", "target12Month": "" }],
"recommendations": [{ "priority": "", "category": "", "title": "", "description": "" }],
"competitors": [],
"keywords": {},
"targetAudience": {},
"marketTrends": []
}
```
---
## Agent 4: Content Director Agent (콘텐츠 기획)
**역할**: 콘텐츠 디렉터. 채널 전략과 브랜드 가이드를 기반으로 4주 콘텐츠 캘린더 기획.
**File**: `src/lib/contentDirector.ts`
### Process (결정론적 — AI 호출 없음)
1. 채널-포맷 매트릭스 구성 (YouTube Shorts/Long, Instagram Reels/Carousel/Stories, 네이버 블로그, Facebook 광고)
2. 주차별 테마 할당 (Week 1: 브랜드 정비, Week 2: 콘텐츠 엔진, Week 3: 소셜 증거, Week 4: 전환 최적화)
3. Pillar-Service 매트릭스로 토픽 생성 (전문성×서비스, 비포애프터×서비스, 후기×서비스, 트렌드×서비스)
4. 기존 YouTube 인기 영상 리퍼포징 배치
5. 월간 콘텐츠 서머리 계산
### Input
```typescript
{
channels: ChannelStrategyCard[]; // 활성 채널 목록
pillars: ContentPillar[]; // 4개 콘텐츠 필라
services: string[]; // 시술 목록
youtubeVideos: TopVideo[]; // 리퍼포징 소스
clinicName: string;
}
```
---
## Agent 5: Brand Strategist Agent (브랜드 전략)
**역할**: 브랜드 전략가. 채널 분석 결과를 브랜드 가이드와 채널별 커뮤니케이션 전략으로 변환.
**File**: `src/lib/transformPlan.ts`
### Process (결정론적 — AI 호출 없음)
1. 채널 스코어 기반 전략 카드 생성 (P0/P1/P2 우선순위)
2. 브랜드 일관성 분석 (채널 간 이름/로고/연락처 비교)
3. 콘텐츠 필라 정의 (전문성·신뢰 / 비포·애프터 / 환자 후기 / 트렌드·교육)
4. 에셋 수집 및 리퍼포징 제안
---
## Agent 6: Image Creator Agent (이미지 생성)
**역할**: 비주얼 디자이너. 마케팅 이미지 생성.
**File**: `src/services/geminiImageGen.ts`
### System Prompt
```
Generate a premium medical marketing image for a plastic surgery clinic.
Theme: {pillarContext} // safety | expertise | results | care
Style: {channelHint} // youtube | instagram | naver_blog | tiktok | facebook
Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).
Premium, luxurious, trustworthy aesthetic.
No text or logos in the image.
Photorealistic, high quality, professional medical marketing.
```
---
## Known Issues & Improvement Plan
### 검색 성능
- [ ] Instagram 검색: Perplexity가 찾아도 verify에서 탈락 → unverified 핸들도 후보로 유지
- [ ] Apify Instagram 검색 타임아웃 30초 → 60초로 증가
- [ ] 강남언니 verify 성공률 개선 — Perplexity URL 힌트 활용도 높이기
### 프롬프트 품질
- [ ] Few-shot example 추가 (성공 응답 예시 포함)
- [ ] Chain-of-thought 유도 (리포트 생성 시 분석 과정 단계별 진행)
- [ ] JSON 파싱 실패 시 재시도 (temperature 올려서 1회)
- [ ] Perplexity `response_format: json_object` 옵션 활용
### 데이터 품질
- [ ] 주소 정보: Google Maps + Naver Place에서 수집한 주소를 최우선 사용
- [ ] 개원 연도 파싱: "데이터 없음 (NaN년)" 방지
- [ ] KPI 수치: enrichment 실제 데이터 우선, AI 추측 무시

428
docs/AI_PROMPTS_CATALOG.md Normal file
View File

@ -0,0 +1,428 @@
# INFINITH AI Prompts Catalog (v2 — Updated 2026-04-04)
현재 프로덕션에서 사용 중인 모든 AI 프롬프트.
Pipeline V2 아키텍처 기반 (discover → collect → generate).
---
## Pipeline Overview
```
Phase 1: discover-channels
├─ A. Firecrawl scrape+map (병원 정보 + 소셜 링크 추출)
├─ B1. YouTube Data API (채널 직접 검색)
├─ B2. Naver Search API (블로그 + 웹 검색)
├─ B3. Firecrawl Search (소셜 URL 웹 검색)
├─ B4. Perplexity sonar (Online Presence 통합 검색)
├─ B4b. Perplexity sonar (강남언니 URL 검색)
├─ B5. Apify Instagram (프로필 직접 검색)
└─ C. 핸들 검증 (HEAD 요청 + YouTube API)
Phase 2: collect-channel-data
├─ Instagram (Apify)
├─ YouTube (YouTube Data API v3)
├─ Facebook (Apify)
├─ 강남언니 (Firecrawl JSON 추출)
├─ Naver Blog + Place (Naver API)
├─ Google Maps (Apify)
└─ 시장 분석 (Perplexity × 4 병렬)
Phase 3: generate-report
└─ Perplexity sonar (실제 수집 데이터 기반 리포트 생성)
```
---
## Phase 1: discover-channels
### P1-A. Firecrawl — 병원 정보 + 소셜 링크 추출
**File**: `supabase/functions/discover-channels/index.ts` (Stage A)
**API**: Firecrawl `v1/scrape` (JSON + links)
**Wait**: 5000ms
**Extraction Prompt**:
```
Extract: clinic name (Korean), clinic name (English), address, phone,
services offered, doctors with specialties, ALL social media links
(instagram handles/URLs, youtube channel URL/handle, naver blog URL,
facebook page URL, tiktok, kakao channel), business hours, slogan
```
**Schema**: clinicName, clinicNameEn, address, phone, businessHours, slogan, services[], doctors[], socialMedia{}
**용도**: 병원 기본 정보 수집 + HTML에서 소셜 링크 직접 추출
---
### P1-A2. Firecrawl — 브랜딩 추출
**API**: Firecrawl `v1/scrape` (JSON)
**Wait**: 3000ms
**Extraction Prompt**:
```
Extract brand identity: primary/accent/background/text colors (hex),
heading/body fonts, logo URL, favicon URL, tagline
```
**Schema**: primaryColor, accentColor, backgroundColor, textColor, headingFont, bodyFont, logoUrl, faviconUrl, tagline
---
### P1-B1. YouTube Data API — 채널 직접 검색
**File**: `supabase/functions/discover-channels/index.ts` (Stage B1)
**API**: YouTube Data API v3 `search?type=channel`
**Prompt**: 없음 (API 직접 호출)
```
GET https://www.googleapis.com/youtube/v3/search
?part=snippet
&type=channel
&q={clinicName}
&maxResults=3
&key={YOUTUBE_API_KEY}
```
**매칭 로직**: 검색 결과 채널명이 병원명을 포함하면 channelId 추출
---
### P1-B2a. Naver Search API — 블로그 검색
**File**: `supabase/functions/discover-channels/index.ts` (Stage B2a)
**API**: Naver Search `blog.json`
```
GET https://openapi.naver.com/v1/search/blog.json
?query={clinicName} 공식 블로그
&display=5
&sort=sim
```
**추출**: `blog.naver.com/{blogId}` 패턴 매칭
---
### P1-B2b. Naver Search API — 웹 검색 (소셜 URL 발견)
**API**: Naver Search `webkr.json`
```
GET https://openapi.naver.com/v1/search/webkr.json
?query={clinicName} 인스타그램 유튜브 공식
&display=10
```
**추출**: 검색 결과 URL에서 instagram.com, youtube.com, facebook.com 패턴 매칭
---
### P1-B3. Firecrawl Search — 소셜 URL 웹 검색
**API**: Firecrawl `v1/search`
```json
{
"query": "{clinicName} 성형외과 instagram youtube 공식",
"limit": 10
}
```
**추출**: 검색 결과 URL에서 소셜 핸들 패턴 매칭 (extractSocialLinks)
---
### P1-B4. Perplexity — Online Presence 통합 검색 ⭐ (핵심 프롬프트)
**File**: `supabase/functions/discover-channels/index.ts` (Stage B4)
**API**: Perplexity `sonar`, temp=0.1
**목적**: 다른 API가 놓친 소셜 계정을 웹 검색으로 보충 발견
**System Message**:
```
You are a social media researcher. Search the web and find social media accounts. Respond ONLY with valid JSON.
```
**User Message** (template):
```
{clinicName} ({clinicNameEn}) 병원의 인스타그램, 유튜브, 페이스북, 틱톡, 네이버블로그 계정을 검색해서 찾아줘. 검색 결과에서 발견된 계정을 모두 알려줘. 인스타그램은 여러 계정이 있을 수 있어.
{"instagram": ["handle1", "handle2"], "youtube": "channel URL or handle", "facebook": "page name or URL", "tiktok": "handle", "naverBlog": "blog ID"}
```
**핵심 학습 (프롬프트 엔지니어링)**:
- ❌ 실패 패턴: "공식 계정만 찾아줘" / "확인된 계정만" / "Never guess" → 전부 null 반환
- ❌ 실패 패턴: sonar-pro + 장문 시스템 프롬프트 → 빈 결과
- ❌ 실패 패턴: 3개로 분리된 쿼리 → 각각 빈 결과
- ✅ 성공 패턴: 짧은 시스템 프롬프트 + 모든 채널 한 쿼리 + "검색해서 찾아줘" + 영문명 괄호 포함
- ✅ 성공 패턴: `sonar` 모델 (sonar-pro보다 오히려 나음)
- ✅ 성공 패턴: 예시 JSON을 user message 끝에 포함 (output 형식 유도)
**변수 구성**:
```typescript
const clinicNameEn = clinic.clinicNameEn || '';
const searchName = clinicNameEn
? `${resolvedName} (${clinicNameEn})` // "그랜드성형외과 (Grand Plastic Surgery)"
: resolvedName; // "그랜드성형외과"
```
---
### P1-B4b. Perplexity — 강남언니 URL 검색
**API**: Perplexity `sonar`, temp=0.1
**System Message**:
```
You search for clinic listings on medical platforms. Respond ONLY with valid JSON.
```
**User Message**:
```
{clinicName} 병원 강남언니 gangnamunni.com 페이지를 찾아줘.
{"gangnamUnni": {"url": "https://gangnamunni.com/hospitals/...", "rating": 9.5, "reviews": 1000}}
```
---
### P1-B5. Apify — Instagram 프로필 직접 검색
**File**: `supabase/functions/discover-channels/index.ts` (Stage B5)
**API**: Apify `instagram-profile-scraper`
**Timeout**: 30초 per candidate
**핸들 후보 생성 로직**:
```typescript
const baseName = clinicName.replace(/성형외과|병원|의원|클리닉|피부과/g, '').trim().toLowerCase();
const baseNameEn = clinic.clinicNameEn.replace(/\s+/g, '').toLowerCase();
candidates = [
baseNameEn, // "grandplasticsurgery"
`${baseNameEn}_official`, // "grandplasticsurgery_official"
`${baseNameEn}_ps`, // "grandplasticsurgery_ps"
`${baseNameEn}_clinic`, // "grandplasticsurgery_clinic"
domainBase, // "grandplasticsurgery" (from URL)
`${domainBase}_official`, // "grandplasticsurgery_official"
]
```
**유효성 조건**: `followersCount >= 50` → 후보로 채택
---
### P1-C. 병원명 추출 Fallback (Perplexity)
**조건**: Firecrawl이 clinicName을 추출하지 못한 경우
**API**: Perplexity `sonar`, temp=0.1
**System Message**:
```
Respond with ONLY the clinic name in Korean, nothing else.
```
**User Message**:
```
{url} 이 URL의 병원/클리닉 한국어 이름이 뭐야?
```
---
## Phase 2: collect-channel-data
### P2-1. Firecrawl — 강남언니 페이지 데이터 추출
**File**: `supabase/functions/collect-channel-data/index.ts`
**API**: Firecrawl `v1/scrape` (JSON)
**Wait**: 5000ms
**Extraction Prompt**:
```
Extract: hospital name, overall rating (out of 10), total review count,
doctors with names/ratings/review counts/specialties, procedures offered,
address, certifications/badges
```
**Schema**: hospitalName, rating(number), totalReviews(number), doctors[], procedures[], address, badges[]
---
### P2-2~5. Perplexity — 시장 분석 (4개 병렬)
**API**: Perplexity `sonar`, temp=0.3
**공통 System Message**:
```
You are a Korean medical marketing analyst. Always respond in Korean. Provide data in valid JSON format.
```
**쿼리 4개**:
| ID | User Prompt |
|----|-------------|
| competitors | `{address} 근처 {services} 전문 성형외과/피부과 경쟁 병원 5곳을 분석해줘. 각 병원의 이름, 주요 시술, 온라인 평판, 마케팅 채널을 JSON 형식으로 제공해줘.` |
| keywords | `한국 {services} 관련 검색 키워드 트렌드. 네이버와 구글에서 월간 검색량이 높은 키워드 20개, 경쟁 강도, 추천 롱테일 키워드를 JSON 형식으로 제공해줘.` |
| market | `한국 {services[0]} 시장 트렌드 2025-2026. 시장 규모, 성장률, 주요 트렌드, 마케팅 채널별 효과를 JSON 형식으로 제공해줘.` |
| targetAudience | `{clinicName}의 잠재 고객 분석. 연령대별, 성별, 관심 시술, 정보 탐색 채널, 의사결정 요인을 JSON 형식으로 제공해줘.` |
---
## Phase 3: generate-report
### P3. Perplexity — 마케팅 리포트 생성 (V2: 실제 데이터 기반)
**File**: `supabase/functions/generate-report/index.ts`
**API**: Perplexity `sonar`, temp=0.3
**System Message**:
```
You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Use Korean for text fields. 강남언니 rating is 10-point scale. Use ONLY the provided real data — never invent metrics.
```
**User Message** (template 구조):
```
당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 **실제 수집된 데이터**를 기반으로 종합 마케팅 리포트를 생성해주세요.
⚠️ 중요: 아래 데이터에 없는 수치는 절대 추측하지 마세요. 데이터가 없으면 "데이터 없음"으로 표시하세요.
## 병원 기본 정보
- 병원명: {clinic.clinicName}
- 주소: {clinic.address}
- 전화: {clinic.phone}
- 시술: {services.join(", ")}
- 의료진: {doctors JSON}
- 슬로건: {clinic.slogan}
## 실제 채널 데이터 (수집 완료)
### Instagram @{handle}
- 팔로워: {followers}명, 게시물: {posts}개
- 비즈니스 계정: O/X
- Bio: {bio}
### YouTube {handle}
- 구독자: {subscribers}명, 영상: {totalVideos}개, 총 조회수: {totalViews}
- 인기 영상 TOP 5: [실제 제목+조회수]
### 강남언니 {name}
- 평점: {rating}/10, 리뷰: {totalReviews}건
- 등록 의사: [실제 이름+전문분야]
### Google Maps {name}
- 평점: {rating}/5, 리뷰: {reviewCount}건
### 네이버 블로그: 검색결과 {totalResults}건
### 네이버 플레이스: {name} ({category})
## 시장 분석 데이터
{market analysis JSON}
## 웹사이트 브랜딩
{branding JSON}
## 리포트 형식 (JSON 구조)
{
"clinicInfo": { ... },
"executiveSummary": "경영진 요약 (3-5문장)",
"overallScore": 0-100,
"channelAnalysis": {
"naverBlog": { score, status, posts, recommendation, diagnosis[] },
"instagram": { score, status, followers, posts, recommendation, diagnosis[] },
"youtube": { score, status, subscribers, recommendation, diagnosis[] },
"naverPlace": { score, rating, reviews, recommendation },
"gangnamUnni": { score, rating, ratingScale:10, reviews, status, recommendation },
"website": { score, issues[], recommendation, trackingPixels[], snsLinksOnSite, mainCTA }
},
"brandIdentity": [{ area, asIs, toBe }],
"kpiTargets": [{ metric, current, target3Month, target12Month }],
"recommendations": [{ priority, category, title, description, expectedImpact }],
"competitors": [],
"keywords": { primary: [], longTail: [] },
"targetAudience": {},
"marketTrends": [],
"newChannelProposals": [{ channel, priority, rationale }]
}
```
**핵심**: `channelSummary``buildChannelSummary()` 함수가 `channel_data` DB 컬럼에서 실제 수집된 데이터를 요약 텍스트로 변환. AI는 이 텍스트에 포함된 수치만 사용.
---
## 이미지 생성 (별도)
### Gemini — 마케팅 이미지 생성
**File**: `src/services/geminiImageGen.ts`
**API**: Google Gemini `gemini-2.5-flash-image`
**Prompt** (template):
```
Generate a premium medical marketing image for a plastic surgery clinic.
Theme: {pillarContext} // safety | expertise | results | care
Style: {channelHint} // youtube | instagram | naver_blog | tiktok | facebook
Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).
Premium, luxurious, trustworthy aesthetic.
No text or logos in the image.
Photorealistic, high quality, professional medical marketing.
```
**Pillar Context**:
- safety: "hospital safety systems, clean surgical rooms, CCTV monitoring"
- expertise: "medical expertise, advanced surgical equipment, certifications"
- results: "natural beautiful results, before and after transformation"
- care: "patient-centered care, warm consultation, personalized treatment"
---
## 프롬프트 엔지니어링 교훈
### 1. Perplexity sonar — 프롬프트 길이 vs 성능
| 프롬프트 길이 | 결과 |
|-------------|------|
| 시스템 1줄 + 유저 3줄 | ✅ Instagram, YouTube 등 잘 찾음 |
| 시스템 50줄 + 유저 30줄 | ❌ 전부 null/빈 배열 |
| 시스템 5줄 + 유저 10줄 (분리 3회) | ❌ 각각 빈 결과 |
### 2. 검색 키워드 패턴
| 패턴 | 결과 |
|------|------|
| `"{병원명}" 성형외과 공식 인스타그램` | ❌ null (너무 제한적) |
| `{병원명} 병원의 인스타그램...검색해서 찾아줘` | ✅ 핸들 발견 |
| `{병원명} ({영문명}) 병원의 인스타그램...` | ✅✅ 국제 계정도 발견 |
### 3. 모델 선택
| 모델 | 용도 | 온도 |
|------|------|------|
| sonar | 채널 검색, 강남언니 검색 | 0.1 |
| sonar | 시장 분석, 리포트 생성 | 0.3 |
| sonar-pro | ❌ 테스트 결과 오히려 성능 저하 | - |
### 4. JSON 응답 안정성
- 항상 시스템에 "Respond ONLY with valid JSON" 포함
- 유저 메시지 끝에 예시 JSON 구조를 포함하면 포맷 준수율 ↑
- `text.match(/\{[\s\S]*\}/)` 로 JSON 추출 (설명 텍스트 제거)
- `text.match(/```(?:json)?\n?([\s\S]*?)```/)` 로 코드 블록 내 JSON 추출
### 5. Verify 전략
- Instagram HEAD 요청은 불안정 → unverified도 후보로 유지
- YouTube `channels?part=id` API가 가장 정확한 검증
- Apify instagram-profile-scraper가 HEAD 요청보다 신뢰성 높음
- `UC`로 시작하는 channel ID에 `@` 붙이면 검증 실패
---
## Summary
| Phase | API | 프롬프트 수 | 주 용도 |
|-------|-----|-----------|---------|
| discover-channels | Perplexity sonar | 2~3개 | 소셜 채널 검색 + 강남언니 |
| discover-channels | Firecrawl | 2개 | 웹사이트 스크래핑 + 브랜딩 |
| discover-channels | YouTube API | 0 (직접 호출) | 채널 검색 |
| discover-channels | Naver API | 0 (직접 호출) | 블로그/웹 검색 |
| discover-channels | Apify | 0 (직접 호출) | Instagram 프로필 검색 |
| collect-channel-data | Perplexity sonar | 4개 | 시장/경쟁/키워드/타겟 분석 |
| collect-channel-data | Firecrawl | 1개 | 강남언니 데이터 추출 |
| generate-report | Perplexity sonar | 1개 | 종합 리포트 생성 |
| 이미지 생성 | Gemini | 1개 | 마케팅 이미지 |
| **합계** | | **~12개** | |

View File

@ -0,0 +1,236 @@
# Channel & Content Strategy Planning Agent — Audit Report
> 작성일: 2026-04-06
> 대상: INFINITH Content Director Engine + generate-content-plan Edge Function
> 관점: 하이티켓 메디컬 클리닉 콘텐츠 마케팅 디렉터
---
## 1. Executive Summary
INFINITH의 콘텐츠 전략 수립 시스템은 **2-layer 아키텍처** (Deterministic Engine + AI Engine)로 구성되어 있다. 분석 파이프라인(discover → collect → generate-report)에서 수집한 실제 채널 데이터를 기반으로 전략을 생성하는 구조는 올바르나, **하이티켓 성형외과의 특수한 마케팅 요구사항**을 충분히 반영하지 못하는 critical gap이 다수 존재한다.
### 전체 성숙도: 40/100
| 영역 | 점수 | 판정 |
|------|------|------|
| YouTube 전략 | 55/100 | 기본 구조 있으나 포맷 다양성 부족 |
| Instagram 전략 | 45/100 | 멀티계정·해시태그·CTA 전략 부재 |
| 강남언니 전략 | 10/100 | 캘린더에서 완전 누락 (Critical) |
| 네이버 블로그 전략 | 35/100 | 키워드 연결·포맷 다양성 부재 |
| Facebook 전략 | 25/100 | 오가닉/페이드 미분리, 최소 빈도 |
| TikTok 전략 | 5/100 | 캘린더에서 완전 누락 |
| 채널 간 크로스 전략 | 20/100 | 리퍼포징 기초만 존재 |
| 커뮤니케이션 전략 | 30/100 | 톤 단일화, 고객여정 미매핑 |
---
## 2. 채널별 상세 감사
### 2-1. YouTube
**현재 구현:**
- `YOUTUBE_SLOTS`: Shorts(주3, 월수금) + Long-form(주1, 목) = **2 포맷**
- 토픽: 4-Pillar × 시술 매트릭스로 자동 생성
- 리퍼포징: 인기 영상 제목 기반 Shorts 토픽 재활용
**Gap 분석:**
| # | Gap | 심각도 | 설명 |
|---|-----|--------|------|
| Y1 | 포맷 부족 | High | Shorts/Long 2가지만 존재. 라이브 Q&A, 의사 브이로그, 수술실 CCTV, Community Post 누락 |
| Y2 | 빈도 계산 로직 단순 | Medium | 채널 점수(0-100)만으로 결정. 구독자 수, 조회수 트렌드, 경쟁사 대비 위치 미반영 |
| Y3 | 리퍼포징 얕음 | Medium | 영상 제목만 추출. 타임스탬프, 하이라이트 구간, 댓글 기반 토픽 확장 없음 |
| Y4 | SEO 전략 없음 | High | YouTube 검색 키워드와 토픽 매칭이 없음. report.keywords 미활용 |
| Y5 | 톤 단일 | Medium | "전문적·친근한" 고정. Shorts(캐주얼+후킹) vs Long(교육적+권위) 차별화 필요 |
**개선 계획:**
- [x] `YOUTUBE_SHORTS_SLOT`, `YOUTUBE_LONG_SLOT`, `YOUTUBE_LIVE_SLOT`, `YOUTUBE_COMMUNITY_SLOT` 4포맷 확장
- [x] 톤 매트릭스: `shorts: "캐주얼·후킹"`, `long: "교육적·권위"`, `live: "친근·대화"`
- [x] report.keywords.primary를 토픽 생성 시 주입
### 2-2. Instagram
**현재 구현:**
- `INSTAGRAM_SLOTS`: Reel(주3) + Carousel(주2) + Stories(주2) = **3 포맷**
- 멀티 계정 데이터는 수집하지만 전략 생성 시 구분하지 않음
**Gap 분석:**
| # | Gap | 심각도 | 설명 |
|---|-----|--------|------|
| I1 | 멀티계정 전략 없음 | High | KR/EN 계정별 차별화된 콘텐츠 전략 부재 |
| I2 | 해시태그 전략 부재 | High | instagramAnalysis.topHashtags 수집하지만 전략에 미반영 |
| I3 | CTA 퍼널 없음 | Critical | DM→카카오톡→전화 단계별 전환 전략 부재 |
| I4 | Feed 포맷 부재 | Medium | 정적 피드 이미지(포트폴리오, 의사 소개) 포맷이 캘린더에 없음 |
| I5 | Stories 활용 약함 | Medium | 폴, Q&A 스티커, 카운트다운 등 인터랙티브 기능 활용 전략 없음 |
**개선 계획:**
- [x] `INSTAGRAM_FEED_SLOT` 추가 (정적 포트폴리오 이미지)
- [x] 해시태그 전략을 `ChannelStrategyCard.formatGuidelines`에 주입
- [x] CTA 전략을 주간 테마에 반영 (Week 4: 전환 최적화 → CTA 집중)
### 2-3. 강남언니
**현재 구현:**
- `channelAnalysis.gangnamUnni`: score/rating/reviews 분석만 존재
- **캘린더 슬롯 완전 누락** — `FormatSlot`에 포함되지 않음
- 콘텐츠 전략 액션 아이템 없음
**Gap 분석:**
| # | Gap | 심각도 | 설명 |
|---|-----|--------|------|
| G1 | 캘린더 완전 누락 | Critical | 성형외과 핵심 전환 채널이 전략에서 빠짐 |
| G2 | 리뷰 대응 전략 없음 | Critical | 부정 리뷰 대응, 긍정 리뷰 활용 프로토콜 없음 |
| G3 | 의사 프로필 최적화 없음 | High | 의사별 전문분야, 경력, 사진 관리 전략 없음 |
| G4 | 가격 전략 없음 | High | 시술별 가격 포지셔닝, 프로모션 전략 없음 |
| G5 | 배지/인증 관리 없음 | Medium | 강남언니 뱃지 획득 전략 없음 |
**개선 계획:**
- [x] `GANGNAMUNNI_SLOTS` 추가: 리뷰관리(주2) + 프로필최적화(주1)
- [x] 리뷰 대응 전략을 Week 3(소셜 증거) 테마에 통합
- [x] 강남언니 데이터 (doctors, rating, reviews)를 전략 생성에 활용
### 2-4. 네이버 블로그
**현재 구현:**
- `NAVER_SLOTS`: 블로그(주2, 화목) = **1 포맷**
- 토픽: 시술명 + 필러 조합
**Gap 분석:**
| # | Gap | 심각도 | 설명 |
|---|-----|--------|------|
| N1 | 키워드 미연결 | Critical | report.keywords 존재하지만 블로그 토픽에 미활용 |
| N2 | 포맷 단일 | High | SEO글, 의사칼럼, FAQ시리즈, 후기정리, 비교분석 구분 없음 |
| N3 | 네이버 플레이스 연동 없음 | Medium | 블로그→플레이스 리뷰 유도 퍼널 부재 |
| N4 | 네이버 검색 광고 미고려 | Low | 유기적+유료 통합 전략 없음 |
**개선 계획:**
- [x] `NAVER_SEO_SLOT` + `NAVER_COLUMN_SLOT` 2포맷으로 확장
- [x] `buildTopicPool()`에 report.keywords 주입
### 2-5. Facebook
**현재 구현:**
- `FACEBOOK_SLOTS`: 광고(주1, 토) = **1 포맷**
**Gap 분석:**
| # | Gap | 심각도 | 설명 |
|---|-----|--------|------|
| F1 | 오가닉/페이드 미분리 | High | 광고만 있고 오가닉 포스트 없음 |
| F2 | 캠페인 유형 미분화 | High | 리타겟팅, 리드젠, 브랜드 인지도 구분 없음 |
| F3 | 빈도 너무 적음 | Medium | 주 1회로는 A/B 테스트 불가 |
**개선 계획:**
- [x] `FACEBOOK_ORGANIC_SLOT`(주1) + `FACEBOOK_AD_SLOT`(주2) 분리
### 2-6. TikTok
**현재 구현:**
- 캘린더 슬롯 **완전 누락**. channelAnalysis에서 score만 표시
**개선 계획:**
- [x] `TIKTOK_SLOTS` 추가: 숏폼(주3, 월수금). YouTube Shorts와 크로스포스팅 전략
---
## 3. 크로스채널 전략 감사
### 3-1. 고객 여정 매핑 (현재: 없음 → 추가 필요)
| 단계 | 채널 | 콘텐츠 유형 | 목표 |
|------|------|------------|------|
| **인지** (Awareness) | YouTube Shorts, TikTok, Instagram Reels | 후킹 숏폼, 트렌드 | 신규 유입 |
| **관심** (Interest) | YouTube Long, 네이버 블로그, Instagram Carousel | 교육 콘텐츠, SEO글 | 정보 탐색 |
| **고려** (Consideration) | 강남언니, 네이버 플레이스, Instagram Feed | 리뷰, 비포애프터, 의사 소개 | 비교 검토 |
| **전환** (Conversion) | Instagram DM, Facebook Ad, 카카오톡 | CTA, 상담 예약, 프로모션 | 상담 예약 |
| **충성** (Loyalty) | Instagram Stories, 카카오톡, YouTube Community | 후속 관리, VIP 혜택 | 재방문·추천 |
### 3-2. 리퍼포징 매트릭스 (현재: YouTube→Shorts만 → 확장 필요)
| 원본 | → 파생 1 | → 파생 2 | → 파생 3 |
|------|----------|----------|----------|
| YouTube Long | Shorts ×3 | 블로그 스크립트 | Carousel 4장 |
| 네이버 블로그 | Instagram Carousel | Facebook Post | TikTok 요약 |
| 환자 후기 영상 | Reel 하이라이트 | 강남언니 리뷰 참조 | Stories 추천 |
| 의사 칼럼 | LinkedIn Post | YouTube Community | 네이버 칼럼 |
---
## 4. 커뮤니케이션 전략 감사
### 4-1. 채널별 톤 매트릭스 (현재: "전문적·친근한" 단일 → 차별화 필요)
| 채널 | 톤 | 말투 예시 | 금지 표현 |
|------|-----|----------|----------|
| YouTube Long | 교육적 · 권위 | "오늘은 코성형의 3가지 접근법을 비교해보겠습니다" | 과장, 비교광고 |
| YouTube Shorts | 캐주얼 · 후킹 | "코성형 전에 이것만은 꼭 확인하세요!" | 의학적 보장 |
| Instagram Feed | 감성적 · 프리미엄 | "자연스러운 아름다움의 완성" | 저가 이미지 |
| Instagram Reel | 트렌디 · 공감 | "성형 고민 있으신 분? 이 영상 저장하세요" | 전문용어 과다 |
| Instagram Stories | 친근 · 일상 | "오늘 수술실에서 있었던 일 🏥" | 환자 정보 노출 |
| 네이버 블로그 | 정보성 · SEO | "{시술명} 비용, 회복기간, 부작용 총정리" | 감성적 표현 |
| 강남언니 | 전문 · 응대 | "소중한 후기 감사합니다. 추가 문의 사항..." | 방어적 태도 |
| Facebook | 타겟팅 · CTA | "지금 상담 예약하시면 3D 시뮬레이션 무료" | 스팸성 반복 |
| TikTok | 밈 · 교육 | "성형외과 의사가 알려주는 TMI" | 의료광고법 위반 |
### 4-2. 위기 대응 커뮤니케이션 (현재: 없음)
| 상황 | 대응 채널 | 프로토콜 |
|------|----------|----------|
| 부정 리뷰 (강남언니) | 강남언니 답변 | 24시간 내 전문 응대, 사과+해결방안 |
| SNS 부정 댓글 | Instagram/YouTube | 공개 응대 → DM 전환 → 사적 해결 |
| 의료 사고 루머 | 전 채널 | 공식 입장문 + 법적 대응 병행 |
| 가격 비교 공격 | 네이버 블로그 | 가치 중심 콘텐츠로 간접 대응 |
---
## 5. 콘텐츠 필러 감사
### 현재 4-Pillar (하드코딩):
1. 전문성·신뢰 — 의료진, 수술 과정, 인증
2. 비포·애프터 — 전후 비교, 결과 시각화
3. 환자 후기 — 인터뷰, 리뷰
4. 트렌드·교육 — Q&A, 비용 가이드
### 추가 필요 Pillar:
5. **안전·케어** — 수술 후 관리, 24시간 모니터링, 리커버리 프로그램
- 하이티켓 클리닉의 핵심 차별점. 가격이 높은 이유를 정당화하는 콘텐츠
- 예시: "수술 후 48시간 집중 케어 시스템", "전담 간호사 1:1 관리"
---
## 6. 개선 사항 요약
### Critical (즉시 반영)
- [x] C1: 강남언니 캘린더 슬롯 추가
- [x] C2: TikTok 캘린더 슬롯 추가
- [x] C3: 채널별 톤 매트릭스 구현
- [x] C4: 고객 여정 매핑 → 주간 테마 재설계
- [x] C5: report.keywords → 토픽 생성 연결
### High (이번 스프린트)
- [x] H1: YouTube 4포맷 확장
- [x] H2: Instagram Feed 슬롯 추가
- [x] H3: 네이버 블로그 2포맷 확장
- [x] H4: Facebook 오가닉/페이드 분리
- [x] H5: 5th Pillar "안전·케어" 추가
### Medium (다음 스프린트)
- [ ] M1: 해시태그 전략 자동 생성
- [ ] M2: 리퍼포징 매트릭스 자동화
- [ ] M3: 위기 대응 프로토콜 UI
- [ ] M4: 시즌 전략 (성수기/비수기) 캘린더 반영
---
## 7. 영향 받는 파일
| 파일 | 변경 내용 |
|------|-----------|
| `src/lib/contentDirector.ts` | 강남언니/TikTok 슬롯, 5th Pillar, 키워드 연결, 톤 매트릭스 |
| `src/types/plan.ts` | ChannelStrategyCard에 tone 세분화, customerJourneyStage 추가 |
| `src/lib/transformPlan.ts` | 5th Pillar 추가, 톤 매트릭스 적용, 키워드 주입 |
| `supabase/functions/generate-content-plan/index.ts` | AI 프롬프트에 경쟁사·키워드·톤 매트릭스 주입 |

View File

@ -0,0 +1,180 @@
# 콘텐츠 기획 & 전략 수립 기능 구현 계획
## Context
현재 INFINITH 파이프라인은 3-phase (discover → collect → generate-report)까지 완성되어 있고, DB 스키마에는 `content_plans`, `performance_metrics`, `strategy_adjustments`, `content_performance` 테이블이 이미 설계되어 있다. 하지만:
- **콘텐츠 플랜 생성 Edge Function이 없다** — `content_plans` 테이블은 비어 있음
- **`contentDirector.ts`는 완전 deterministic** — AI 호출 없이 하드코딩 템플릿으로 캘린더 생성
- **PerformancePage는 mock 데이터** — 실제 DB 연동 없음
- **전략 조정 루프가 없다** — 성과 → 전략 피드백 메커니즘 미구현
이 기능은 파이프라인 Phase 4로 자연스럽게 확장되며, 기존 데이터 흐름 위에 AI 전략 계층을 추가한다.
---
## Phase 1: `generate-content-plan` Edge Function + 타입 확장
### 1-1. Edge Function 생성
**새 파일:** `supabase/functions/generate-content-plan/index.ts`
- [x] `generate-report/index.ts` 패턴 그대로 따름 (CORS, service role, Deno.serve)
- [x] Input 인터페이스: `{ reportId, clinicId?, runId? }`
- [x] `analysis_runs` (또는 `marketing_reports`)에서 channelAnalysis, kpiTargets, recommendations, services 읽기
- [x] Perplexity sonar 호출 (temp=0.3, 짧은 프롬프트)
- System: `"You are a Korean medical marketing content strategist. Respond ONLY with valid JSON."`
- User: 채널 점수 요약 + 서비스 목록 + KPI 타겟 + 전략 생성 요청
- [x] JSON 파싱 (기존 regex 패턴 재사용)
- [x] `content_plans` 테이블에 INSERT (`is_active=true`, 이전 플랜 비활성화)
- [x] AI 실패 시 fallback: deterministic `contentDirector.ts` 결과 사용
**AI 출력 스키마:**
```json
{
"channelStrategies": [{ "channelId", "targetGoal", "contentTypes", "postingFrequency", "priority" }],
"contentPillars": [{ "title", "description", "relatedUSP", "exampleTopics" }],
"calendar": { "weeks": [{ "weekNumber", "label", "entries": [...] }], "monthlySummary": [...] },
"postingSchedule": { "bestTimes", "rationale" }
}
```
### 1-2. CalendarEntry 타입 확장
**수정:** `src/types/plan.ts` (line 107-113)
- [x] `id?: string` — UUID (드래그&드롭, 개별 재생성용)
- [x] `description?: string` — AI 생성 가이드
- [x] `pillar?: string` — 콘텐츠 필러 연결
- [x] `status?: 'draft' | 'approved' | 'published'`
- [x] `isManualEdit?: boolean` — AI 덮어쓰기 방지 플래그
- [x] `aiPromptSeed?: string` — 개별 재생성 컨텍스트
### 1-3. 데이터 레이어
**수정:** `src/lib/supabase.ts`
- [x] `generateContentPlan(reportId, clinicId?, runId?)` — Edge Function 호출
- [x] `fetchActiveContentPlan(clinicId)` — content_plans 쿼리
- [x] `updateCalendarEntry(planId, entryId, updates)` — JSONB 패치
**수정:** `src/hooks/useMarketingPlan.ts`
- [x] 데이터 소스 우선순위: content_plans → navigation state → marketing_reports
---
## Phase 2: Enhanced Content Calendar UI
### 2-1. ContentCalendar 인터랙티브 업그레이드
**수정:** `src/components/plan/ContentCalendar.tsx`
- [x] `useState`로 로컬 편집 상태 관리
- [x] 엔트리 클릭 → EditEntryModal 열기
- [x] Status 뱃지 (gray=draft, purple=approved, green=published)
- [x] 엔트리 hover 시 AI 재생성 버튼
- [x] Weekly/Monthly 뷰 토글
- [x] 채널/콘텐츠 타입 필터
### 2-2. EditEntryModal 생성
**새 파일:** `src/components/plan/EditEntryModal.tsx`
- [x] Title, Description, Channel, Content Type, Day, Status 필드
- [x] "AI 재생성" 버튼 (aiPromptSeed 기반)
- [x] Save / Cancel 버튼
- [x] 기존 디자인 시스템 적용 (rounded-2xl, shadow, purple accent)
---
## Phase 3: Strategy Adjustment Loop
### 3-1. adjust-strategy Edge Function
**새 파일:** `supabase/functions/adjust-strategy/index.ts`
- [x] Input: `{ clinicId }`
- [x] `channel_weekly_delta` 뷰로 최근 채널 변화량 조회
- [x] 활성 `content_plans` 조회
- [x] `analysis_runs.report`에서 kpiTargets 추출
- [x] KPI 달성률 계산
- [x] Perplexity sonar로 조정 추천 생성
- [x] `performance_metrics` INSERT (channel_deltas, kpi_progress, strategy_suggestions)
- [x] `strategy_adjustments` INSERT (adjustment_type, before/after values)
### 3-2. StrategyAdjustmentSection
**새 파일:** `src/components/plan/StrategyAdjustmentSection.tsx`
- [x] KPI 진행률 프로그레스 바
- [x] 전략 제안 카드 (수락/거절)
- [x] 조정 이력 타임라인
---
## Phase 4: 파이프라인 & 페이지 통합
### 4-1. AnalysisLoadingPage에 Phase 4 추가
**수정:** `src/pages/AnalysisLoadingPage.tsx`
- [x] `PHASE_STEPS`에 planning 단계 추가
- [x] `runPipeline`에서 generate-report 후 `generateContentPlan()` 호출
### 4-2. MarketingPlanPage에 조정 섹션 추가
**수정:** `src/pages/MarketingPlanPage.tsx`
- [x] AssetCollection 다음에 `<StrategyAdjustmentSection>` 추가
### 4-3. PerformancePage 실제 데이터 연동
**수정:** `src/pages/PerformancePage.tsx`
- [x] Mock 데이터 → 실제 DB 쿼리로 교체
- [x] "전략 조정 실행" 버튼 → `triggerStrategyAdjustment()` 호출
**새 파일:** `src/hooks/usePerformanceData.ts`
- [x] `channel_snapshots`, `performance_metrics`, `content_performance` 통합 조회
### 4-4. Route 업데이트
**수정:** `src/main.tsx`
- [x] `/performance/:clinicId?` 형태로 변경
---
## 파일 변경 요약
### 새로 생성 (6)
| 파일 | 목적 | Phase |
|------|------|-------|
| `supabase/functions/generate-content-plan/index.ts` | AI 콘텐츠 전략 생성 | 1 |
| `supabase/functions/adjust-strategy/index.ts` | 성과 기반 전략 조정 | 3 |
| `src/components/plan/EditEntryModal.tsx` | 캘린더 엔트리 편집 | 2 |
| `src/components/plan/StrategyAdjustmentSection.tsx` | 전략 조정 UI | 3 |
| `src/hooks/useContentPlan.ts` | content_plans 데이터 훅 | 1 |
| `src/hooks/usePerformanceData.ts` | 성과 데이터 통합 훅 | 4 |
### 수정 (7)
| 파일 | 변경 내용 | Phase |
|------|-----------|-------|
| `src/types/plan.ts` | CalendarEntry에 optional 필드 6개 추가 | 1 |
| `src/hooks/useMarketingPlan.ts` | content_plans 우선 소스 추가 | 1 |
| `src/lib/supabase.ts` | 새 API 함수 4개 추가 | 1 |
| `src/components/plan/ContentCalendar.tsx` | 인터랙티브 UI 업그레이드 | 2 |
| `src/pages/AnalysisLoadingPage.tsx` | Phase 4 파이프라인 추가 | 4 |
| `src/pages/MarketingPlanPage.tsx` | StrategyAdjustment 섹션 추가 | 4 |
| `src/pages/PerformancePage.tsx` | Mock → 실제 DB 데이터 | 4 |
### 변경 없음 (Fallback 유지)
- `src/lib/contentDirector.ts` — deterministic fallback 엔진
- `src/lib/transformPlan.ts` — fallback 변환
- `supabase/functions/_shared/config.ts` — 재사용
---
## 검증 체크리스트
- [x] Edge Function 테스트: `curl``generate-content-plan` 호출 → `content_plans` 테이블 적재 확인
- [x] 프론트엔드 테스트: `npm run dev``/plan/:id` → content_plans 데이터 렌더링 확인
- [x] 캘린더 인터랙션: 엔트리 클릭 → 편집 → 저장 → 새로고침 후 유지 확인
- [x] 파이프라인 E2E: URL 입력 → 4단계 파이프라인 완료 → 플랜 페이지 자동 표시
- [x] 타입 체크: `npm run lint` (tsc --noEmit) 통과
## 핵심 패턴 참조 파일
- `supabase/functions/generate-report/index.ts` — Edge Function 패턴
- `src/lib/contentDirector.ts` — 현재 deterministic 엔진 (fallback)
- `src/hooks/useMarketingPlan.ts` — 데이터 소스 우선순위 체인
- `src/components/plan/ContentCalendar.tsx` — 현재 캘린더 UI

262
docs/DB_SCHEMA_V3.md Normal file
View File

@ -0,0 +1,262 @@
# INFINITH SaaS Database Schema V3
**작성일**: 2026-04-05
**마이그레이션 파일**: `supabase/migrations/20260405_saas_schema_v3.sql`
## 설계 원칙
1. **CLINIC-CENTRIC**: 병원 1개 = 1행. URL이 달라도 같은 병원이면 같은 행
2. **TIME-SERIES**: 채널 메트릭은 INSERT-only 스냅샷 (시계열 쿼리)
3. **SEPARATION**: 원시 데이터 / 분석 리포트 / 콘텐츠 전략 분리
4. **LOOP-READY**: 매 분석이 이전 데이터를 참조해 전략 자동 조정
5. **MULTI-TENANT**: user_id 기반 접근 제어 (미래 auth)
---
## ERD (Entity Relationship)
```
clinics (병원 마스터)
├─ analysis_runs (분석 실행 히스토리) ← 매주 1행씩 쌓임
│ ├─ channel_snapshots (채널별 시계열 메트릭) ← INSERT-only
│ ├─ screenshots (스크린샷 증거)
│ └─ performance_metrics (성과 메트릭)
│ └─ strategy_adjustments (전략 조정 근거)
├─ channel_configs (사용자 수동 채널 연결)
├─ content_plans (콘텐츠 기획 — 활성 1개)
│ └─ content_performance (개별 콘텐츠 성과)
└─ (marketing_reports) ← 레거시 호환
```
---
## 테이블 상세
### 1. `clinics` — 병원 마스터
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| user_id | UUID | 소유자 (미래 auth) |
| url | TEXT UNIQUE | 대표 URL |
| name | TEXT | 한국어 병원명 |
| name_en | TEXT | 영문 병원명 |
| domain | TEXT | 도메인 |
| address, phone | TEXT | 기본 정보 |
| established_year | INT | 개원 연도 |
| services | TEXT[] | 시술 목록 |
| branding | JSONB | 컬러, 폰트, 로고, 태그라인 |
| social_handles | JSONB | 검증된 소셜 핸들 |
| verified_channels | JSONB | Phase 1 결과 캐시 |
| analysis_frequency | TEXT | 'manual' / 'daily' / 'weekly' / 'monthly' |
| last_analyzed_at | TIMESTAMPTZ | 마지막 분석 시간 |
### 2. `analysis_runs` — 분석 실행 히스토리
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id | UUID FK → clinics | |
| status | TEXT | pending/discovering/collecting/generating/complete/partial/error |
| scrape_data | JSONB | Firecrawl 원시 데이터 |
| raw_channel_data | JSONB | API 수집 원시 데이터 |
| analysis_data | JSONB | 시장 분석 |
| vision_analysis | JSONB | Vision 분석 결과 |
| report | JSONB | AI 리포트 |
| channel_errors | JSONB | 채널별 에러 기록 |
| trigger | TEXT | 'manual' / 'scheduled' / 'webhook' |
### 3. `channel_snapshots` — 채널별 시계열 ⭐ 핵심
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id | UUID FK → clinics | |
| run_id | UUID FK → analysis_runs | |
| channel | TEXT | youtube/instagram/facebook/gangnamUnni/... |
| handle | TEXT | @handle 또는 URL |
| followers | INT | 구독자/팔로워 |
| posts | INT | 게시물/영상 수 |
| total_views | BIGINT | 총 조회수 |
| rating | NUMERIC(3,1) | 평점 |
| reviews | INT | 리뷰 수 |
| health_score | INT | 0-100 |
| details | JSONB | 상세 (top videos, latest posts 등) |
| screenshot_url | TEXT | 채널 랜딩 스크린샷 |
| captured_at | TIMESTAMPTZ | 캡처 시간 |
**핵심**: INSERT-only. 절대 UPDATE 하지 않음. `captured_at` 기준 시계열 쿼리.
### 4. `screenshots` — 스크린샷 증거
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id, run_id | UUID FK | |
| channel | TEXT | 어떤 채널 |
| page_type | TEXT | main/doctors/surgery/landing |
| url | TEXT | 이미지 URL (Supabase Storage) |
| source_url | TEXT | 원본 페이지 URL |
| vision_data | JSONB | Gemini Vision 추출 데이터 |
### 5. `content_plans` — 콘텐츠 기획
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id | UUID FK → clinics | |
| run_id | UUID FK → analysis_runs | 어떤 분석 기반 |
| brand_guide | JSONB | 브랜딩 가이드 |
| channel_strategies | JSONB | 채널별 전략 |
| content_strategy | JSONB | 필라, 타입, 워크플로우 |
| calendar | JSONB | 4주 캘린더 |
| is_active | BOOLEAN | 현재 활성 기획 |
### 6. `channel_configs` — 사용자 수동 채널 연결
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id | UUID FK | |
| channel | TEXT | 채널 종류 |
| handle | TEXT | @handle |
| is_verified | BOOLEAN | 사용자가 직접 확인 |
| access_token | TEXT | OAuth token (미래) |
### 7. `performance_metrics` — 성과 메트릭 (루프 핵심)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id, run_id | UUID FK | |
| prev_run_id | UUID FK | 이전 분석 (비교 기준) |
| channel_deltas | JSONB | 채널별 변화량 |
| kpi_progress | JSONB | KPI 달성률 |
| top_performing_content | JSONB | 성과 좋은 콘텐츠 |
| underperforming_channels | TEXT[] | 성과 미달 채널 |
| strategy_suggestions | JSONB | AI 전략 조정 제안 |
### 8. `content_performance` — 개별 콘텐츠 성과
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id | UUID FK | |
| plan_id | UUID FK | 어떤 기획의 콘텐츠 |
| channel, content_type | TEXT | 채널 + 유형 |
| views, likes, comments, shares | INT | 반응 지표 |
| engagement_rate | NUMERIC | 참여율 |
| performance_score | INT | 0-100 |
### 9. `strategy_adjustments` — 전략 조정 히스토리
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | UUID PK | |
| clinic_id, plan_id | UUID FK | |
| performance_id | UUID FK | 어떤 성과 분석 기반 |
| adjustment_type | TEXT | frequency_change/pillar_shift/channel_add/... |
| description | TEXT | "YouTube Shorts 주 3회 → 5회로 증가" |
| reason | TEXT | "Shorts 조회수가 Long-form 대비 300% 높음" |
| before_value, after_value | JSONB | 변경 전/후 값 |
---
## Views (트렌드 쿼리용)
### `channel_latest`
각 채널의 최신 스냅샷만 반환. `DISTINCT ON` + `ORDER BY captured_at DESC`.
### `channel_weekly_delta`
현재 vs 7일 전 스냅샷을 `LATERAL JOIN`으로 비교. 팔로워 변화율(%) 자동 계산.
---
## 성과 → 기획 루프 플로우
```
Week N:
1. analysis_run 생성
2. channel_snapshots INSERT (현재 메트릭)
3. performance_metrics 생성 (이전 run과 비교)
→ channel_deltas: {youtube: +2%, instagram: +7.1%}
→ kpi_progress: [{metric: "YouTube 구독자", progress: 97.4%}]
→ strategy_suggestions: ["Instagram Reels 빈도 증가 권장"]
4. strategy_adjustments INSERT (조정 내역)
5. content_plans UPDATE (조정 반영)
→ Content Director가 strategy_suggestions를 기반으로 캘린더 자동 조정
6. report 생성 (이전 분석 대비 변화 포함)
Week N+1:
→ 2번부터 반복. channel_snapshots에 데이터가 쌓이면서 트렌드 정확도 ↑
```
---
## 기존 호환성
| 기존 테이블 | 상태 | 이관 계획 |
|-----------|------|---------|
| `marketing_reports` | 유지 (deprecated) | 기존 API 호환 유지, 새 데이터는 새 테이블에만 저장 |
| `scrape_results` | 유지 | 캐시용 |
---
## 구현 체크리스트
### Phase 1: DB 마이그레이션 (테이블 생성)
- [x] Supabase Dashboard에서 `20260405_saas_schema_v3.sql` 실행
- [x] 테이블 9개 생성 확인: clinics, analysis_runs, channel_snapshots, screenshots, content_plans, channel_configs, performance_metrics, content_performance, strategy_adjustments
- [x] View 2개 생성 확인: channel_latest, channel_weekly_delta
- [x] RLS 정책 적용 확인
- [x] 인덱스 생성 확인
### Phase 2: Edge Function 전환 — discover-channels
- [x] `discover-channels/index.ts`: `clinics` 테이블에 UPSERT (url 기준)
- [x] `discover-channels/index.ts`: `analysis_runs` 테이블에 INSERT (status: 'discovering')
- [x] `discover-channels/index.ts`: `clinic.verified_channels` 업데이트
- [x] 기존 `marketing_reports`에도 병행 쓰기 유지 (호환성)
- [ ] 검증: 분석 실행 → clinics + analysis_runs 행 생성 확인
### Phase 3: Edge Function 전환 — collect-channel-data
- [x] `collect-channel-data/index.ts`: `channel_snapshots`에 채널별 INSERT
- [x] `collect-channel-data/index.ts`: `screenshots`에 스크린샷 INSERT
- [x] `collect-channel-data/index.ts`: `analysis_runs.raw_channel_data` 업데이트
- [x] `collect-channel-data/index.ts`: `analysis_runs.vision_analysis` 업데이트
- [x] 기존 `marketing_reports.channel_data`에도 병행 쓰기 유지
- [ ] 검증: channel_snapshots에 채널별 행 생성 확인
### Phase 4: Edge Function 전환 — generate-report
- [x] `generate-report/index.ts`: `analysis_runs.report` 업데이트
- [x] `generate-report/index.ts`: `analysis_runs.status = 'complete'`
- [x] `generate-report/index.ts`: `clinics.last_analyzed_at` 업데이트
- [ ] `content_plans` 자동 생성 (transformPlan 로직 서버사이드 이동)
- [x] 기존 `marketing_reports.report`에도 병행 쓰기 유지
- [ ] 검증: analysis_runs.status = 'complete' + report JSONB 생성 확인
### Phase 5: 성과 분석 루프 구현
- [ ] 반복 분석 시 `performance_metrics` 자동 생성 (이전 run 대비)
- [ ] `channel_weekly_delta` View 쿼리 테스트
- [ ] `strategy_suggestions` 생성 로직 (Content Director 연동)
- [ ] `content_plans` 자동 업데이트 (전략 조정 반영)
- [ ] `strategy_adjustments` 히스토리 기록
- [ ] 검증: 2회 연속 분석 → performance_metrics에 변화량 기록 확인
### Phase 6: Frontend 전환
- [ ] `useReport` 훅: analysis_runs + channel_snapshots에서 읽기
- [ ] `useMarketingPlan` 훅: content_plans에서 읽기
- [ ] ReportPage: channel_snapshots 시계열 데이터로 트렌드 차트 추가
- [ ] KPIDashboard: performance_metrics의 달성률 표시
- [ ] 검증: 기존 리포트 URL이 새 테이블에서도 정상 렌더링
### Phase 7: 스케줄링
- [ ] `clinics.analysis_frequency` 기반 자동 분석 트리거
- [ ] Supabase Cron 또는 external scheduler 연동
- [ ] `analysis_runs.trigger = 'scheduled'` 기록
- [ ] 검증: 주간 자동 분석 실행 확인
### Phase 8: 기존 데이터 이관
- [ ] `marketing_reports``clinics` + `analysis_runs` 이관 스크립트
- [ ] `marketing_reports.channel_data``channel_snapshots` 변환
- [ ] `marketing_reports.report``analysis_runs.report` 복사
- [ ] 이관 후 기존 리포트 URL 정상 작동 확인
- [ ] `marketing_reports` deprecated 마킹

View File

@ -0,0 +1,291 @@
# 검색/분석 파이프라인 종합 개선 계획
**작성일**: 2026-04-04
**최종 수정**: 2026-04-07 (파이프라인 전수 감사 + P0 버그 수정)
**상태**: Sprint 0 ✅ 구현 완료 | Sprint 1 ✅ 완료 | Sprint 2 🔜 진행 중
---
## 2026-04-07 전수 감사 결과 (Full Pipeline Audit)
### URL → DB → Channel Discovery → Firecrawl → Report 흐름 검증
전체 4단계 파이프라인을 코드 수준으로 감사한 결과:
### ✅ 이미 구현 완료된 것들
| 항목 | 파일 | 상태 |
|------|------|------|
| Registry-first domain lookup | `discover-channels/index.ts` | ✅ 완전 구현 |
| Vision Analysis (screenshot + Gemini) | `_shared/visionAnalysis.ts`, `collect-channel-data` | ✅ 완전 구현 |
| `wrapChannelTask` 에러 격리 | `_shared/retry.ts` | ✅ Promise.all 안전 |
| `fetchWithRetry` + 지수 백오프 | `_shared/retry.ts` | ✅ Firecrawl/Apify에 적용 |
| channel_errors 추적 + unconditional DB save | `collect-channel-data/index.ts` | ✅ 구현 |
| 강남언니 Firecrawl JSON scrape | `collect-channel-data/index.ts` | ✅ 구현 (rating 버그 수정됨) |
| Vision data → report 강제 주입 harness | `generate-report/index.ts` | ✅ 구현 |
| Founding year 3단계 fallback chain | collect → generate 양쪽 | ✅ 구현 |
| V3 dual-write (clinics + analysis_runs) | discover + collect + generate | ✅ 구현 |
### 🔧 이번 세션에서 수정한 P0 버그
| 버그 | 위치 | 수정 내용 |
|------|------|----------|
| 강남언니 rating 0-10 스케일 오변환 | `collect-channel-data:323` | `rating ≤ 5 → ×2` 로직 제거. Firecrawl 프롬프트가 이미 0-10 지시 → 직접 신뢰 |
| Perplexity 단일 fetch (재시도 없음) | `generate-report:115` | `fetchWithRetry(maxRetries:2, backoffMs:[5000,15000], timeoutMs:90s)` 로 교체 |
### 🚧 남은 Gap (우선순위순)
| 우선순위 | Gap | 세부 내용 |
|---------|-----|---------|
| P1 | Health score 미계산 | `channel_snapshots.health_score` 컬럼은 있지만 항상 NULL. 수집된 followers/rating/reviews 기반으로 계산 필요 |
| P1 | 네이버 블로그 공식 컨텐츠 미수집 | 현재: Naver Search API로 3rd-party 언급 수집. 필요: 등록된 공식 블로그 URL을 Firecrawl로 직접 스크랩 |
| P1 | Firecrawl 스크린샷 URL 만료 | GCS URL 7일 후 만료 → Supabase Storage로 아카이빙 필요 |
| P2 | Delta/트렌드 비교 없음 | `channel_snapshots`에 시계열 데이터 있지만 이전 run 대비 delta 계산 미구현 |
| P2 | V3 dual-write silent error | 에러 발생 시 console만 출력, `analysis_runs.error_message`에 기록 안 됨 |
| P3 | 강남언니 rating > 5 엣지케이스 | 수정 후에도 Firecrawl이 0-5 반환 시 그대로 저장. 추후 `rawRating`과 비교하는 정규화 로직 고려 |
---
## Context
현재 파이프라인의 3-phase 아키텍처(discover → collect → generate)는 구조적으로는 괜찮으나, **실행 신뢰성**과 **정보 수집 범위**에 심각한 문제가 있음.
### 핵심 문제
- **Vision 분석 부재**: 텍스트만 수집 → 이미지 속 정보(개원 연도, 의료진 사진, 인증 마크, 시술 전후) 100% 누락. 전체 정보의 약 40%를 놓침
- **Silent failure 16건**: 모든 catch 블록이 에러를 삼킴
- **데이터 품질 8건**: 잘못된 URL, 동명이인 병원, 평점 스케일 혼동
- **에러 복구 0건**: 재시도 로직 없음, 새로고침 시 데이터 유실
- **API 불안정 6건**: 타임아웃, 쿼터 소진, 인증 실패 미감지
**목표**: Vision 분석 추가 + 검색 정확도 + 에러 회복력 + UX 투명성 대폭 개선
---
## 진행 상태 체크리스트
### Sprint 0: Vision Analysis 추가 ✅ 완료
성형외과 홈페이지의 핵심 정보가 이미지로 제공됨 (배너, 의료진 사진, 인증 마크 등).
Firecrawl screenshot + Gemini Vision으로 이미지 속 정보를 추출.
- [x] **WP-V1**. 멀티페이지 스크린샷 캡처 + 저장 (45min)
- 파일: `collect-channel-data/index.ts`에 추가 (Phase 2 — 검증된 URL들이 있는 시점)
- 캡처 대상 (6+ 페이지):
1. **병원 메인 페이지** — 배너, 소셜 아이콘, 카카오톡 버튼
2. **의료진 페이지** — siteMap에서 `/doctor`, `/team`, `/staff` URL 자동 탐지
3. **시술 안내 페이지** — `/surgery`, `/service`, `/procedure` 탐지
4. **YouTube 채널 랜딩**`youtube.com/@{handle}` (구독자 수, 고정 영상)
5. **Instagram 프로필**`instagram.com/{handle}` (팔로워 수, 피드 미리보기)
6. **강남언니 페이지** — verified URL (평점, 리뷰 수, 의료진)
- API: Firecrawl `formats: ["screenshot"]` + `screenshotOptions: { fullPage: false, quality: 80, viewport: { width: 1280, height: 800 } }`
- 저장: Supabase Storage `screenshots/{reportId}/{channel}.png` → signed URL
- DB: `channel_data.screenshots[]``ScreenshotEvidence` 형태로 저장
- 프론트엔드: 이미 구현된 `ScreenshotProvider``EvidenceGallery``EvidenceLightbox` 에 바로 연결
- [x] **WP-V2**. Gemini Vision 분석 (1h)
- 파일: 새 `_shared/visionAnalysis.ts`
- API: Google Gemini `gemini-2.0-flash` (GEMINI_API_KEY 이미 .env에 있음)
- 각 스크린샷별 분석 프롬프트:
- **메인 페이지**: 개원 연도, 소셜 아이콘, 카카오톡 버튼, 브랜드 컬러, 슬로건, 인증 마크
- **의료진 페이지**: 의사 이름 + 전문 분야 + 약력 (이미지 내 텍스트 OCR)
- **시술 페이지**: 시술 카테고리, 가격 정보, 전후 사진 유무
- **YouTube 랜딩**: 구독자 수, 영상 수, 최근 업로드 제목, 고정 영상
- **Instagram 프로필**: 팔로워 수, 게시물 수, 최근 피드 미리보기, 바이오
- **강남언니**: 평점(/10), 리뷰 수, 의료진 수, 시술 종류
- 응답: 페이지별 JSON → `channel_data.visionAnalysis`에 저장
- [x] **WP-V3**. Vision 데이터 → 리포트 + 증거 통합 (45min)
- 파일: `generate-report/index.ts`, `transformReport.ts`
- 변경:
- 스크린샷 → `report.screenshots[]` (ScreenshotEvidence 형태)
- 프론트엔드의 기존 EvidenceGallery/Lightbox에 바로 표시
- Vision 추출 의료진 → `clinicSnapshot.doctors` 보강 (이미지에서 읽은 정보)
- Vision 추출 개원 연도 → `clinicSnapshot.established` 보강
- Vision 추출 YouTube/Instagram 수치 → KPI에 cross-reference
- 각 채널 진단 항목에 `evidenceIds` 연결 → 해당 채널 스크린샷이 증거로 표시
- 검증:
- 그랜드성형외과 분석 → "SINCE 2004" 개원 연도 추출 확인
- YouTube 분석 섹션 → 채널 랜딩 스크린샷 썸네일 표시 확인
- Instagram 분석 섹션 → 프로필 스크린샷 표시 확인
- 스크린샷 클릭 → 라이트박스 모달에서 풀사이즈 보기 확인
### Vision Analysis 프롬프트 설계
```
System: You are a medical clinic website visual analyst. Extract structured
information from website screenshots. Respond ONLY with valid JSON.
User: Analyze this Korean plastic surgery clinic homepage screenshot.
Extract:
1. Founding year or operation duration (e.g., "SINCE 2004", "21년 무사고")
2. Doctor names and specialties shown in profile photos
3. Certification badges/marks (JCI, 보건복지부, medical tourism)
4. Main service categories from navigation menu
5. Social media icons/buttons visible (Instagram, YouTube, Blog, KakaoTalk, etc.)
6. Floating consultation buttons (KakaoTalk, LINE, WhatsApp)
7. Brand colors (primary, accent) from visual elements
8. Any promotional text or slogans in banners
{
"foundingYear": "2004",
"operationYears": 21,
"doctors": [{"name": "김OO", "specialty": "안면윤곽", "position": "대표원장"}],
"certifications": ["JCI", "보건복지부 인증"],
"serviceCategories": ["눈성형", "코성형", "가슴성형", "안면윤곽"],
"socialIcons": [{"platform": "instagram", "visible": true}, ...],
"floatingButtons": ["kakaotalk", "line"],
"brandColors": {"primary": "#C4A882", "accent": "#FF1493"},
"slogans": ["끊임없이 의료성형 뷰티 트렌드를 연구하는 그랜드 의료진"]
}
```
### Vision Analysis에 필요한 API/리소스
| API | 용도 | 비용 |
|-----|------|------|
| Firecrawl `formats: ["screenshot"]` | 페이지 스크린샷 캡처 | 기존 요금에 포함 |
| Gemini `gemini-2.0-flash-exp` | 이미지 분석 (Vision) | ~$0.002/이미지 |
| Gemini `GEMINI_API_KEY` | 이미 설정됨 (.env) | ✅ 사용 가능 |
### Vision으로 수집 가능한 추가 정보 (현재 누락)
| 정보 | 현재 수집 | Vision 추가 후 |
|------|---------|-------------|
| 개원 연도 | ❌ AI 추측 (자주 틀림) | ✅ 배너에서 직접 읽기 |
| 의료진 수/이름 | ⚠️ 강남언니에서만 | ✅ 홈페이지 프로필에서 추출 |
| 인증 마크 | ❌ 미수집 | ✅ JCI, 보건복지부 등 인식 |
| 시술 카테고리 | ⚠️ Firecrawl JSON | ✅ 네비게이션 메뉴에서 확인 |
| 카카오톡 상담 버튼 | ❌ JS 렌더링이라 못 잡음 | ✅ 플로팅 버튼 감지 |
| 브랜드 컬러 | ⚠️ CSS 추출 (부정확) | ✅ 실제 비주얼에서 추출 |
| 슬로건/태그라인 | ⚠️ 이미지 내 텍스트 누락 | ✅ 배너 텍스트 OCR |
---
### Sprint 1: 데이터 품질 Quick Wins (~2.5h) ✅ 완료
- [x] **WP-1**. YouTube Channel ID 정규식 수정 (20min)
- [x] **WP-2**. Naver Place 동명이인 방지 (30min)
- [x] **WP-3**. Google Maps URL 수정 (20min)
- [x] **WP-4**. Naver Blog 공식 블로그 분리 (30min)
- [x] **WP-5**. 강남언니 평점 정규화 (30min)
- [x] **WP-6**. Perplexity 모델 상수화 (20min)
- [x] **WP-7**. Apify 타임아웃 증가 (10min)
**추가 완료:**
- [x] 소셜 버튼 직접 추출 (Firecrawl actions + JS 렌더링 후 href 추출)
---
### Sprint 2: 에러 가시성 ✅ 완료
- [x] **WP-8**. 채널 수집 에러 추적 ⭐ 핵심 (1.5h)
- 파일: `collect-channel-data/index.ts`
- DB: `ALTER TABLE marketing_reports ADD COLUMN IF NOT EXISTS channel_errors JSONB DEFAULT '{}';`
- 변경:
- [ ] HTTP 상태 코드 체크 (429, 403, 500)
- [ ] `Promise.allSettled` 결과 순회 → `channelErrors` 기록
- [ ] 부분 성공이어도 항상 DB 저장 (unconditional save)
- [ ] status: `"collected"` vs `"partial"` vs `"collection_failed"`
- [ ] 응답: `{ channelData, channelErrors, partialFailure }`
- 검증: API 토큰 무효화 시 에러가 기록되는지 확인
- [ ] **WP-9**. Instagram/Facebook 검증 개선 (45min)
- 파일: `_shared/verifyHandles.ts`
- 변경:
- [ ] `verified` 타입: `boolean | "unverifiable"`
- [ ] Instagram: 로그인 리다이렉트 감지
- [ ] Facebook: HEAD → GET, 실패 시 `"unverifiable"`
- [ ] `collect-channel-data`에서 `"unverifiable"` 포함
- 검증: Facebook 핸들이 "unverifiable"로 표시되고 수집은 시도됨
---
### Sprint 3: 에러 회복 🔜 일부 완료 (~5.25h)
- [x] **WP-10**. API 재시도 유틸리티 (2h)
- 파일: 새 `_shared/retry.ts`
- 변경: `fetchWithRetry()` — 지수 백오프, 429 존중, AbortController 타임아웃
- 검증: 429 응답 시 자동 재시도 후 성공
- [x] **WP-11**. 부분 실패 복구 (1h)
- 파일: `collect-channel-data/index.ts`
- 변경: `Promise.allSettled` 직후 무조건 중간 DB 저장
- 검증: 일부 채널 실패해도 나머지 데이터 보존
- [ ] **WP-12**. 파이프라인 이어하기 (1.5h)
- 파일: `AnalysisLoadingPage.tsx`, `supabase.ts`
- 변경:
- [ ] `sessionStorage`에 reportId 저장
- [ ] URL에 reportId 포함
- [ ] DB status 폴링
- [ ] status별 이어하기 로직
- 검증: 분석 중 새로고침 → 이어서 진행
- [ ] **WP-13**. Enrichment 재시도 버튼 (45min)
- 파일: `useEnrichment.ts`, `EmptyState.tsx`
- 변경: `retry()` 함수 + "다시 시도" 버튼 (최대 2회)
- 검증: Enrichment 실패 후 버튼 클릭 → 재시도 성공
---
### Sprint 4: UX 마무리 (~50min)
- [ ] **WP-14**. EmptyState 상태별 UI (20min)
- 파일: `EmptyState.tsx`
- 변경: `loading | error | not_found` 상태별 다른 UI
- 검증: 각 상태에 맞는 아이콘/메시지/버튼 표시
- [ ] **WP-15**. Firecrawl Rate Limiting (30min)
- 파일: `_shared/retry.ts`에 추가
- 변경: 도메인별 500ms 간격 강제
- 검증: Firecrawl 429 에러 발생 안 함
---
## 핵심 파일 목록
| 파일 | Sprint | 설명 |
|------|--------|------|
| `supabase/functions/discover-channels/index.ts` | 0, 1 | 채널 발견 + Vision |
| `supabase/functions/collect-channel-data/index.ts` | 0, 1, 2, 3 | 데이터 수집 |
| `supabase/functions/_shared/visionAnalysis.ts` (신규) | 0 | Vision 분석 유틸 |
| `supabase/functions/_shared/config.ts` | 1 | 공유 설정 |
| `supabase/functions/_shared/verifyHandles.ts` | 1, 2 | 핸들 검증 |
| `supabase/functions/_shared/retry.ts` (신규) | 3 | 재시도 유틸 |
| `supabase/functions/enrich-channels/index.ts` | 1 | 레거시 enrichment |
| `supabase/functions/generate-report/index.ts` | 0 | 리포트에 Vision 데이터 반영 |
| `supabase/migrations/20260404_channel_errors.sql` (신규) | 2 | DB 마이그레이션 |
| `src/pages/AnalysisLoadingPage.tsx` | 3 | 로딩 페이지 |
| `src/hooks/useEnrichment.ts` | 3 | Enrichment 훅 |
| `src/components/report/ui/EmptyState.tsx` | 4 | 빈 상태 UI |
| `src/lib/supabase.ts` | 3 | API 클라이언트 |
## 총 예상 소요 시간
| Sprint | 시간 | 배포 범위 | 상태 |
|--------|------|---------|------|
| Sprint 0: Vision Analysis | ~2h | Edge Functions + Gemini | 🔜 다음 |
| Sprint 1: Quick Wins | ~2.5h | Edge Functions × 5 | ✅ 완료 |
| Sprint 2: 에러 가시성 | ~2.25h | DB + Edge Functions | 대기 |
| Sprint 3: 에러 회복 | ~5.25h | Edge Functions + Frontend + Vercel | 대기 |
| Sprint 4: UX 마무리 | ~50min | Frontend + Vercel | 대기 |
| **합계** | **~13h** | | |
---
## Vision Analysis 아키텍처
```
discover-channels (Stage A)
├─ A1. Firecrawl scrape (JSON + links) ← 기존
├─ A2. Firecrawl map ← 기존
├─ A3. Firecrawl branding ← 기존
├─ A4. Firecrawl social buttons (JS actions) ← 방금 추가
└─ A5. Firecrawl screenshot + Gemini Vision ← Sprint 0 신규
├─ 메인 페이지 스크린샷 캡처
├─ Gemini Vision 분석 (개원 연도, 의료진, 인증, 소셜 아이콘)
└─ 결과를 clinic 정보 + socialHandles에 병합
collect-channel-data
└─ Vision 데이터를 channel_data.visionAnalysis에 저장
generate-report
└─ Vision 데이터를 리포트 프롬프트에 포함 (실제 데이터 기반)
```

View File

@ -0,0 +1,284 @@
# Clinic Registry — Functional Specifications
**Document Type**: Functional Specifications
**Version**: 1.0
**Status**: Design Approved — Implementation Hold
**Date**: 2026-04-05
**Author**: Claude Code, directed by Haewon Kam
---
## 1. 목적 (Purpose)
잠재 고객사 성형외과의 **사전 검증된 채널 마스터 DB**를 구축하여, INFINITH 분석 파이프라인의 discovery 단계를 **확률적 검색(probabilistic search)**에서 **결정적 조회(deterministic lookup)**로 전환한다.
## 2. Scope
### In-Scope
- 국내 성형외과 **잠재 고객사 100곳 (MVP)** → 500곳 확장
- 병원 마스터 정보 (이름, 도메인, 웹사이트, 개원연도, 위치)
- 사전 검증된 채널 핸들 (YouTube/Instagram/Facebook/Naver Blog/Naver Place/강남언니/TikTok)
- Human-in-the-loop 방식 초기 구축 (Perplexity Comet 활용)
- `discover-channels` Edge Function의 Registry-only 전환
- 미등록 도메인에 대한 에러 UX
### Out-of-Scope
- 미등록 병원 자동 fallback discovery (명시적으로 제외)
- 병원 자가 등록 포털 (추후 Sprint)
- 경쟁사 자동 업데이트 추적
- 실시간 채널 메트릭 수집 (별도 `collect-channel-data` Function의 책임)
## 3. 사용자 스토리 (User Stories)
### US-1: 등록된 고객사 분석
**As a** INFINITH 운영자
**I want** 등록된 병원의 URL로 분석을 요청하면
**So that** 10초 이내에 검증된 채널 기반의 정확한 분석 결과를 받는다
**Acceptance Criteria**:
- URL의 도메인이 `clinic_registry.domain`과 매칭되면 discovery 단계를 건너뛴다
- 분석 리포트에 "✓ Registry-verified" 배지가 표시된다
- 개원연도, 위치 정보가 자동 포함된다
### US-2: 미등록 병원 차단
**As a** INFINITH 운영자
**I want** 등록되지 않은 병원 URL 입력 시 명확한 에러를 받고
**So that** 검색 오류가 있는 부정확한 분석을 방지한다
**Acceptance Criteria**:
- 미등록 도메인 입력 시 `404 CLINIC_NOT_REGISTERED` 응답
- 사용자에게 "지원하지 않는 병원" 안내 + "등록 요청" CTA 표시
- 에러는 analytics에 기록되어 향후 등록 우선순위에 반영
### US-3: Registry 데이터 큐레이션
**As a** INFINITH 운영자
**I want** Perplexity Comet으로 병원 정보를 수집하고 CSV로 import하여
**So that** 100곳의 검증된 마스터 데이터를 3060분 내에 구축한다
**Acceptance Criteria**:
- 제공된 CSV 템플릿에 맞는 데이터를 입력할 수 있다
- import 스크립트가 데이터 검증 (필수 필드, 중복 domain 체크) 후 DB 적재
- Import 결과 요약: 성공/실패/경고 카운트
## 4. 데이터 모델 (Data Model)
### 4.1 Table: `clinic_registry`
```sql
CREATE TABLE clinic_registry (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- 식별 정보
name text NOT NULL,
name_aliases text[] DEFAULT '{}',
domain text UNIQUE NOT NULL,
website_url text NOT NULL,
-- 병원 메타
founded_year int,
location_gu text,
category text DEFAULT '성형외과',
-- 채널 핸들 (사전 검증)
youtube_handle text,
youtube_channel_id text,
instagram_handle text,
facebook_handle text,
facebook_url text,
naver_blog_handle text,
naver_place_id text,
gangnam_unni_url text,
tiktok_handle text,
-- 큐레이션 메타
verified_by text NOT NULL CHECK (verified_by IN ('manual','llm','scrape')),
verified_at timestamptz DEFAULT now(),
last_checked_at timestamptz DEFAULT now(),
is_active boolean DEFAULT true,
notes text
);
CREATE UNIQUE INDEX idx_clinic_registry_domain ON clinic_registry (domain) WHERE is_active = true;
CREATE INDEX idx_clinic_registry_aliases ON clinic_registry USING gin (name_aliases);
CREATE INDEX idx_clinic_registry_last_checked ON clinic_registry (last_checked_at);
```
### 4.2 Field Dictionary
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | text | ✓ | 공식 병원명 (e.g., "아이디병원") |
| `name_aliases` | text[] | | 별칭/영문명 (e.g., `{"아이디성형외과","ID Hospital"}`) |
| `domain` | text | ✓ | `www.` 제거된 도메인. **Lookup key**. |
| `website_url` | text | ✓ | 전체 URL (redirect 대응) |
| `founded_year` | int | | 개원연도 (4-digit) |
| `location_gu` | text | | "강남구", "서초구" 등 |
| `youtube_channel_id` | text | | `UC...` 형식 (24자) |
| `naver_place_id` | text | | Naver Place 고유 ID (동명이인 방지) |
| `verified_by` | enum | ✓ | `manual` / `llm` / `scrape` |
| `is_active` | boolean | | false면 lookup에서 제외 |
## 5. 기능 요구사항 (Functional Requirements)
### FR-1: Registry Lookup
- **Input**: `url` (string, full URL)
- **Process**:
1. `new URL(url).hostname` 추출
2. `^www\.` prefix 제거
3. `clinic_registry` 테이블에서 `domain = {hostname} AND is_active = true` 조회
- **Output (match)**: `{ clinicName, verifiedChannels, source: 'registry', foundedYear, location }`
- **Output (miss)**: `{ error: 'CLINIC_NOT_REGISTERED', domain, status: 404 }`
- **Latency**: <100ms (single indexed query)
### FR-2: VerifiedChannels 매핑
`clinic_registry` 레코드를 기존 `VerifiedChannels` 타입으로 변환:
```typescript
function mapRegistryToVerifiedChannels(r: ClinicRegistry): VerifiedChannels {
return {
youtube: r.youtube_channel_id ? {
handle: r.youtube_handle, channelId: r.youtube_channel_id,
verified: true, url: `https://youtube.com/${r.youtube_handle}`
} : null,
instagram: r.instagram_handle ? [{
handle: r.instagram_handle, verified: true,
url: `https://instagram.com/${r.instagram_handle}`
}] : [],
facebook: r.facebook_handle ? {
handle: r.facebook_handle, verified: true,
url: r.facebook_url ?? `https://facebook.com/${r.facebook_handle}`
} : null,
naverBlog: r.naver_blog_handle ? {
handle: r.naver_blog_handle, verified: true,
url: `https://blog.naver.com/${r.naver_blog_handle}`
} : null,
gangnamUnni: r.gangnam_unni_url ? { handle: '', url: r.gangnam_unni_url, verified: true } : null,
tiktok: r.tiktok_handle ? {
handle: r.tiktok_handle, verified: true,
url: `https://tiktok.com/@${r.tiktok_handle}`
} : null,
};
}
```
### FR-3: CSV Import
- **Input**: CSV 파일 (컬럼: FR-1 field dictionary 참조)
- **Validation**:
- `name`, `domain`, `website_url`, `verified_by` 필수
- `domain` 중복 검사 (upsert on conflict)
- `youtube_channel_id` 형식 체크 (`^UC[a-zA-Z0-9_-]{22}$`)
- `founded_year` 범위 체크 (1950현재)
- **Output**: `{ inserted, updated, skipped, errors: [] }`
### FR-4: 미등록 병원 UX
- `AnalysisLoadingPage.tsx`에서 `CLINIC_NOT_REGISTERED` 응답 감지
- 표시: 전용 페이지 (로딩 스피너 대신)
- 헤드라인: "현재 지원하지 않는 병원입니다"
- 본문: "{domain}은(는) 분석 대상 병원 목록에 포함되지 않습니다."
- CTA: "병원 등록 요청하기" 버튼 → Slack 알림 또는 간단한 폼
### FR-5: Registry-verified 배지
- `ClinicSnapshot.tsx`에서 `source === 'registry'`일 때
- 녹색 "✓ 검증된 병원" 배지
- 개원연도 + 위치 표시 (`founded_year` + `location_gu`)
## 6. 비기능 요구사항 (Non-Functional Requirements)
| 항목 | 목표 | 근거 |
|------|------|------|
| Registry lookup 레이턴시 | <100ms | |
| Registry 정확도 | 100% | Human-in-the-loop 큐레이션 |
| Registry 커버리지 (MVP) | 100곳 | 잠재 고객사 TOP 100 |
| CSV import 처리량 | 500행/30초 | 1회성 bulk insert |
| 재검증 주기 | 30일 | `last_checked_at` 기반 알림 |
## 7. API 계약 (API Contract)
### POST `/functions/v1/discover-channels` (변경됨)
**Request**:
```json
{ "url": "https://www.idhospital.com", "clinicName": "아이디병원" }
```
**Response 200 (registry hit)**:
```json
{
"clinicName": "아이디병원",
"verifiedChannels": { "youtube": {...}, "instagram": [...], ... },
"source": "registry",
"foundedYear": 2005,
"location": "강남구"
}
```
**Response 404 (registry miss)**:
```json
{
"error": "CLINIC_NOT_REGISTERED",
"message": "현재 지원하지 않는 병원입니다.",
"domain": "unknown-clinic.com"
}
```
**Breaking Changes**:
- 기존 5개 외부 API 호출 전부 제거
- `source` 필드 추가 (항상 `"registry"` 또는 에러)
- 미등록 도메인은 404 반환 (이전에는 부정확한 결과라도 200 반환)
## 8. 마이그레이션 전략 (Migration Strategy)
### Phase 0: 문서화 (완료)
- Plan / Functional Specs / Retrospective ✓
### Phase 1: Schema & Template (30min)
1. `supabase/migrations/20260405_clinic_registry.sql` 작성/적용
2. `data/clinic-registry-template.csv` 템플릿 배포
### Phase 2: Human-in-the-loop 수집 (사용자 직접, 3060min)
1. 사용자가 Perplexity Comet으로 100곳 정보 수집
2. CSV 템플릿에 기입
3. 수동 QA (명백한 오류 제거)
### Phase 3: Import & Integration (1.5h)
1. `scripts/import-registry.ts` 작성 + 실행
2. `discover-channels/index.ts` Registry-only 전환
3. `AnalysisLoadingPage.tsx` 에러 UX
4. `ClinicSnapshot.tsx` verified 배지
### Phase 4: 검증 (30min)
1. 등록 도메인 테스트 (아이디병원) → registry hit + 정확한 채널
2. 미등록 도메인 테스트 → 404 + 등록 요청 UX
3. Edge Function 배포 + Vercel 배포
## 9. 검증 시나리오 (Test Scenarios)
| # | 시나리오 | Expected |
|---|----------|----------|
| T1 | 등록된 `idhospital.com`으로 분석 | `source: registry`, verified 배지, <100ms discovery |
| T2 | 등록된 `www.banobagi.com`으로 분석 (`www.` 처리) | Registry hit |
| T3 | 미등록 `random-clinic.com`으로 분석 | 404 + 등록 요청 UX |
| T4 | `is_active=false`인 병원 분석 | 404 (폐업 병원 차단) |
| T5 | 잘못된 CSV (중복 domain) import | Error 반환, insert 차단 |
| T6 | Registry 레코드의 Instagram 핸들 수정 후 재분석 | 수정된 핸들로 즉시 분석 |
## 10. 의존성 & 리스크 (Dependencies & Risks)
### Dependencies
- Supabase PostgreSQL (기존 인프라)
- Perplexity Comet (사용자가 수집에 사용)
- 기존 `collect-channel-data` Function (변경 없음)
### Risks
| Risk | Mitigation |
|------|-----------|
| 병원 폐업/리브랜딩 추적 실패 | `last_checked_at` 30일 기반 알림, `is_active` 플래그 |
| 채널 핸들 변경 (병원이 Instagram ID 교체) | 수동 업데이트 + 분석 시 404 응답 패턴 모니터링 |
| 고객사 확장 시 curation 병목 | Admin UI 추후 개발 (Sprint 6) |
| 미등록 URL 입력이 많아 UX 저하 | 등록 요청 CTA + 운영자 알림으로 scope 확장 |
## 11. Open Questions
1. 미등록 병원의 "등록 요청" CTA는 어떤 채널로 알림을 보낼지? (Slack / email / DB)
2. Registry 수정 권한 관리는 어떻게? (현재는 직접 SQL, 추후 Admin UI)
3. 경쟁사(non-고객사)를 비교 분석용으로 registry에 추가할 것인지?
---
*This document specifies the Registry-first architecture that replaces probabilistic channel discovery with deterministic lookup over a curated customer scope.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

154
scripts/import-registry.ts Normal file
View File

@ -0,0 +1,154 @@
/**
* Import clinic_registry_working.csv into clinic_registry table.
*
* Usage:
* npx tsx scripts/import-registry.ts
*
* Requires env vars:
* SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
*
* Or use .env.local file.
*/
import { createClient } from "@supabase/supabase-js";
import { readFileSync } from "fs";
import { config } from "dotenv";
config({ path: ".env.local" });
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SUPABASE_KEY) {
console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY");
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// CSV column indices (0-based)
const COL = {
name: 0,
brand_group: 1,
district: 2,
branches: 3,
website_kr: 4,
website_en: 5,
youtube_url: 6,
// 7: youtube_note
instagram_kr_url: 8,
// 9: instagram_kr_note
instagram_en_url: 10,
// 11: instagram_en_note
facebook_url: 12,
// 13: facebook_note
tiktok_url: 14,
// 15: tiktok_note
gangnam_unni_url: 16,
// 17: gangnam_unni_note
naver_blog_url: 18,
// 19: naver_blog_note
naver_place_url: 20,
// 21: naver_place_reviews_note
google_maps_url: 22,
// 23: google_reviews_note
} as const;
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
// Handle URLs without protocol
const clean = url.replace(/^https?:\/\//, "").replace(/^www\./, "");
return clean.split("/")[0];
}
}
function parseCSVLine(line: string): string[] {
// Simple CSV parser (no quoted fields with commas in this CSV)
return line.split(",");
}
async function main() {
const csv = readFileSync("data/clinic-registry/clinic_registry_working.csv", "utf8");
const lines = csv.split("\n").filter((l) => l.trim());
const rows = lines.slice(1); // skip header
console.log(`Parsing ${rows.length} clinics from CSV...`);
const records: Record<string, unknown>[] = [];
const skipped: string[] = [];
for (const line of rows) {
const cols = parseCSVLine(line);
const name = cols[COL.name]?.trim();
const website = cols[COL.website_kr]?.trim();
if (!name || !website) {
skipped.push(name || "(unnamed)");
continue;
}
const domain = extractDomain(website);
if (!domain) {
skipped.push(name);
continue;
}
records.push({
name,
name_aliases: [], // Can be enriched later
domain,
website_url: website,
brand_group: cols[COL.brand_group]?.trim() || null,
district: cols[COL.district]?.trim() || null,
branches: cols[COL.branches]?.trim() || null,
website_en: cols[COL.website_en]?.trim() || null,
youtube_url: cols[COL.youtube_url]?.trim() || null,
instagram_url: cols[COL.instagram_kr_url]?.trim() || null,
instagram_en_url: cols[COL.instagram_en_url]?.trim() || null,
facebook_url: cols[COL.facebook_url]?.trim() || null,
tiktok_url: cols[COL.tiktok_url]?.trim() || null,
gangnam_unni_url: cols[COL.gangnam_unni_url]?.trim() || null,
naver_blog_url: cols[COL.naver_blog_url]?.trim() || null,
naver_place_url: cols[COL.naver_place_url]?.trim() || null,
google_maps_url: cols[COL.google_maps_url]?.trim() || null,
verified_by: "scrape",
is_active: true,
});
}
console.log(`Prepared ${records.length} records (skipped ${skipped.length}: ${skipped.join(", ")})`);
// Upsert in batches of 20
const BATCH_SIZE = 20;
let inserted = 0;
let updated = 0;
let errors = 0;
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, i + BATCH_SIZE);
const { data, error } = await supabase
.from("clinic_registry")
.upsert(batch, { onConflict: "domain" })
.select("id, domain");
if (error) {
console.error(`Batch ${i / BATCH_SIZE + 1} error:`, error.message);
errors += batch.length;
} else {
inserted += data?.length || 0;
console.log(`Batch ${i / BATCH_SIZE + 1}: ${data?.length} rows upserted`);
}
}
console.log(`\nDone! Inserted/updated: ${inserted}, Errors: ${errors}`);
// Verify
const { count } = await supabase
.from("clinic_registry")
.select("*", { count: "exact", head: true });
console.log(`Total rows in clinic_registry: ${count}`);
}
main().catch(console.error);

View File

@ -1,3 +1,4 @@
import { useState, useCallback } from 'react';
import { motion } from 'motion/react';
import {
VideoFilled,
@ -6,10 +7,13 @@ import {
MegaphoneFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import EditEntryModal from './EditEntryModal';
import type { CalendarData, ContentCategory, CalendarEntry } from '../../types/plan';
interface ContentCalendarProps {
data: CalendarData;
planId?: string;
onEntryUpdate?: (entryId: string, updates: Partial<CalendarEntry>) => void;
}
const contentTypeColors: Record<ContentCategory, { bg: string; text: string; entry: string; border: string; shadow: string }> = {
@ -44,9 +48,118 @@ const channelEmojiMap: Record<string, string> = {
video: '▷',
};
const statusDotColors: Record<string, string> = {
draft: 'bg-slate-300',
approved: 'bg-purple-400',
published: 'bg-green-400',
};
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
export default function ContentCalendar({ data }: ContentCalendarProps) {
export default function ContentCalendar({ data, planId, onEntryUpdate }: ContentCalendarProps) {
const [weeks, setWeeks] = useState(data.weeks);
const [editingEntry, setEditingEntry] = useState<CalendarEntry | null>(null);
const [filterType, setFilterType] = useState<ContentCategory | null>(null);
const [viewMode, setViewMode] = useState<'weekly' | 'monthly'>('weekly');
const handleEntryClick = useCallback((entry: CalendarEntry) => {
setEditingEntry(entry);
}, []);
const handleSave = useCallback((updated: CalendarEntry) => {
setWeeks((prev) =>
prev.map((week) => ({
...week,
entries: week.entries.map((e) =>
(e.id && e.id === updated.id) ? updated : e
),
}))
);
if (onEntryUpdate && updated.id) {
onEntryUpdate(updated.id, updated);
}
setEditingEntry(null);
}, [onEntryUpdate]);
const handleClose = useCallback(() => {
setEditingEntry(null);
}, []);
const toggleFilter = (type: ContentCategory) => {
setFilterType((prev) => (prev === type ? null : type));
};
// Monthly view: flatten all weeks into one 7-day grid
const renderMonthlyView = () => {
const allEntries = weeks.flatMap((w) => w.entries);
const filtered = filterType
? allEntries.filter((e) => e.contentType === filterType)
: allEntries;
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of filtered) {
if (entry.dayOfWeek >= 0 && entry.dayOfWeek <= 6) {
dayCells[entry.dayOfWeek].push(entry);
}
}
return (
<div className="bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
<p className="text-sm font-bold text-[#0A1128] mb-3"> </p>
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div key={day} className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium">
{day}
</div>
))}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[100px] rounded-xl p-1.5 ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
}`}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
</div>
))}
</div>
</div>
);
};
const renderEntry = (entry: CalendarEntry, entryIdx: number) => {
const colors = contentTypeColors[entry.contentType];
const Icon = contentTypeIcons[entry.contentType];
const channelSymbol = channelEmojiMap[entry.channelIcon] || '·';
const statusDot = statusDotColors[entry.status || 'draft'];
return (
<div
key={entry.id || entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0 cursor-pointer hover:ring-2 hover:ring-purple-200 transition-all group relative`}
onClick={() => handleEntryClick(entry)}
>
<div className="flex items-center gap-1 mb-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot} flex-shrink-0`} />
<span className={`text-[9px] font-bold ${colors.text} leading-none`}>
{channelSymbol}
</span>
<Icon size={10} className={colors.text} />
</div>
<p className="text-[11px] text-slate-700 leading-tight line-clamp-2">
{entry.title}
</p>
{entry.description && (
<p className="text-[9px] text-slate-400 leading-tight mt-0.5 line-clamp-1 hidden group-hover:block">
{entry.description}
</p>
)}
</div>
);
};
return (
<SectionWrapper
id="content-calendar"
@ -54,18 +167,57 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
subtitle="콘텐츠 캘린더 (월간)"
dark
>
{/* Toolbar: View Toggle + Filters */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2" data-no-print>
{/* View toggle */}
<div className="flex bg-slate-100 rounded-xl p-0.5">
<button
onClick={() => setViewMode('weekly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'weekly'
? 'bg-white text-[#0A1128] shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setViewMode('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'monthly'
? 'bg-white text-[#0A1128] shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
{/* Status legend */}
<div className="flex gap-3 text-[10px] text-slate-400">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-slate-300" /></span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-purple-400" /></span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-400" /></span>
</div>
</div>
{/* Monthly Summary */}
<div className="flex flex-wrap gap-4 mb-8">
{data.monthlySummary.map((item) => {
const colors = contentTypeColors[item.type];
const isActive = filterType === item.type;
return (
<motion.div
key={item.type}
className={`flex-1 min-w-[140px] rounded-2xl border p-4 ${colors.bg} ${colors.border} ${colors.shadow}`}
className={`flex-1 min-w-[140px] rounded-2xl border p-4 cursor-pointer transition-all ${colors.bg} ${colors.border} ${colors.shadow} ${
isActive ? 'ring-2 ring-purple-400 ring-offset-1' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3 }}
onClick={() => toggleFilter(item.type)}
data-no-print={undefined}
>
<div className="flex items-center gap-2 mb-2">
<span
@ -80,91 +232,95 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
})}
</div>
{/* Weekly Calendar Grid */}
{data.weeks.map((week, weekIdx) => {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of week.entries) {
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
{/* Calendar Content */}
{viewMode === 'monthly' ? (
renderMonthlyView()
) : (
/* Weekly Calendar Grid */
weeks.map((week, weekIdx) => {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of week.entries) {
if (filterType && entry.contentType !== filterType) continue;
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
}
}
}
return (
<motion.div
key={week.weekNumber}
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: weekIdx * 0.1 }}
>
{/* Week header with theme */}
<p className="text-sm font-bold text-[#0A1128] mb-3">{week.label}</p>
return (
<motion.div
key={week.weekNumber}
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: weekIdx * 0.1 }}
>
<p className="text-sm font-bold text-[#0A1128] mb-3">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{/* Day headers */}
{dayHeaders.map((day) => (
<div
key={day}
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
>
{day}
</div>
))}
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div
key={day}
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
>
{day}
</div>
))}
{/* Day cells */}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-1.5 ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
}`}
>
{entries.map((entry, entryIdx) => {
const colors = contentTypeColors[entry.contentType];
const Icon = contentTypeIcons[entry.contentType];
const channelSymbol = channelEmojiMap[entry.channelIcon] || '·';
return (
<div
key={entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0`}
>
<div className="flex items-center gap-1 mb-0.5">
<span className={`text-[9px] font-bold ${colors.text} leading-none`}>
{channelSymbol}
</span>
<Icon size={10} className={colors.text} />
</div>
<p className="text-[11px] text-slate-700 leading-tight line-clamp-2">
{entry.title}
</p>
</div>
);
})}
</div>
))}
</div>
</motion.div>
);
})}
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-1.5 ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
}`}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
</div>
))}
</div>
</motion.div>
);
})
)}
{/* Color Legend */}
{/* Color Legend (clickable filter) */}
<div className="flex flex-wrap gap-3 mt-4">
{(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => {
const colors = contentTypeColors[type];
const isActive = filterType === type;
return (
<span
<button
key={type}
className={`${colors.bg} ${colors.text} border ${colors.border} rounded-full px-3 py-1 text-xs font-medium ${colors.shadow}`}
onClick={() => toggleFilter(type)}
className={`${colors.bg} ${colors.text} border ${colors.border} rounded-full px-3 py-1 text-xs font-medium ${colors.shadow} transition-all ${
isActive ? 'ring-2 ring-purple-300' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
>
{contentTypeLabels[type]}
</span>
</button>
);
})}
{filterType && (
<button
onClick={() => setFilterType(null)}
className="text-xs text-slate-400 hover:text-slate-600 px-2 py-1 transition-colors"
>
</button>
)}
</div>
{/* Edit Modal */}
{editingEntry && (
<EditEntryModal
entry={editingEntry}
onSave={handleSave}
onClose={handleClose}
/>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,235 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import type { CalendarEntry, ContentCategory } from '../../types/plan';
interface EditEntryModalProps {
entry: CalendarEntry | null;
onSave: (updated: CalendarEntry) => void;
onClose: () => void;
onRegenerate?: (entry: CalendarEntry) => void;
}
const CHANNELS = [
{ id: 'YouTube', icon: 'youtube' },
{ id: 'Instagram', icon: 'instagram' },
{ id: '네이버 블로그', icon: 'blog' },
{ id: 'Facebook', icon: 'facebook' },
{ id: 'TikTok', icon: 'video' },
];
const CONTENT_TYPES: { value: ContentCategory; label: string }[] = [
{ value: 'video', label: '영상' },
{ value: 'blog', label: '블로그' },
{ value: 'social', label: '소셜' },
{ value: 'ad', label: '광고' },
];
const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
const STATUS_OPTIONS: { value: CalendarEntry['status']; label: string; color: string }[] = [
{ value: 'draft', label: '초안', color: 'bg-slate-200 text-slate-600' },
{ value: 'approved', label: '승인', color: 'bg-purple-100 text-purple-700' },
{ value: 'published', label: '게시됨', color: 'bg-green-100 text-green-700' },
];
export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: EditEntryModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [channel, setChannel] = useState('YouTube');
const [channelIcon, setChannelIcon] = useState('youtube');
const [contentType, setContentType] = useState<ContentCategory>('video');
const [dayOfWeek, setDayOfWeek] = useState(0);
const [status, setStatus] = useState<CalendarEntry['status']>('draft');
useEffect(() => {
if (!entry) return;
setTitle(entry.title || '');
setDescription(entry.description || '');
setChannel(entry.channel || 'YouTube');
setChannelIcon(entry.channelIcon || 'youtube');
setContentType(entry.contentType || 'video');
setDayOfWeek(entry.dayOfWeek);
setStatus(entry.status || 'draft');
}, [entry]);
if (!entry) return null;
const handleChannelChange = (channelId: string) => {
const ch = CHANNELS.find((c) => c.id === channelId);
if (ch) {
setChannel(ch.id);
setChannelIcon(ch.icon);
}
};
const handleSave = () => {
onSave({
...entry,
title,
description,
channel,
channelIcon,
contentType,
dayOfWeek,
status,
isManualEdit: true,
});
};
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Modal */}
<motion.div
className="relative w-full max-w-lg bg-white rounded-2xl shadow-xl overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 className="text-lg font-bold text-[#0A1128]"> </h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all"
placeholder="콘텐츠 제목을 입력하세요"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"> </label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all resize-none"
placeholder="AI가 생성한 제작 가이드 또는 직접 메모를 입력하세요"
/>
</div>
{/* Channel + Content Type Row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={channel}
onChange={(e) => handleChannelChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
>
{CHANNELS.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.id}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={contentType}
onChange={(e) => setContentType(e.target.value as ContentCategory)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
>
{CONTENT_TYPES.map((ct) => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
))}
</select>
</div>
</div>
{/* Day + Status Row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
>
{DAYS.map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<div className="flex gap-2">
{STATUS_OPTIONS.map((s) => (
<button
key={s.value}
onClick={() => setStatus(s.value)}
className={`px-3 py-2 rounded-xl text-xs font-medium transition-all ${
status === s.value
? `${s.color} ring-2 ring-offset-1 ring-purple-300`
: 'bg-slate-50 text-slate-400 hover:bg-slate-100'
}`}
>
{s.label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50">
{onRegenerate ? (
<button
onClick={() => onRegenerate(entry)}
className="flex items-center gap-1.5 px-3 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-xl transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
AI
</button>
) : (
<div />
)}
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700 transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-5 py-2 text-sm font-medium text-white bg-[#6C5CE7] hover:bg-[#5A4BD6] rounded-xl transition-colors shadow-sm"
>
</button>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}

View File

@ -0,0 +1,300 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { supabase, triggerStrategyAdjustment } from '../../lib/supabase';
interface StrategyAdjustmentSectionProps {
clinicId: string | null;
planId?: string;
}
interface KpiProgress {
metric: string;
current: string;
target: string;
progress: number;
}
interface StrategySuggestion {
adjustmentType: string;
channel: string;
description: string;
reason: string;
priority: 'high' | 'medium' | 'low';
beforeValue: string;
afterValue: string;
}
interface AdjustmentHistoryItem {
id: string;
adjustment_type: string;
description: string;
reason: string;
created_at: string;
}
const priorityColors: Record<string, { bg: string; text: string; dot: string }> = {
high: { bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
medium: { bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
low: { bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
};
const typeLabels: Record<string, string> = {
frequency_change: '게시 빈도 조정',
pillar_shift: '콘텐츠 필러 전환',
channel_add: '채널 추가',
channel_pause: '채널 일시 중지',
content_format_change: '포맷 변경',
tone_shift: '톤 조정',
other: '기타 조정',
};
export default function StrategyAdjustmentSection({ clinicId, planId }: StrategyAdjustmentSectionProps) {
const [kpiProgress, setKpiProgress] = useState<KpiProgress[]>([]);
const [suggestions, setSuggestions] = useState<StrategySuggestion[]>([]);
const [history, setHistory] = useState<AdjustmentHistoryItem[]>([]);
const [overallAssessment, setOverallAssessment] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [hasData, setHasData] = useState(false);
useEffect(() => {
if (!clinicId) return;
loadExistingData();
}, [clinicId]);
async function loadExistingData() {
setIsLoading(true);
try {
// Load latest performance metrics
const { data: perf } = await supabase
.from('performance_metrics')
.select('kpi_progress, strategy_suggestions')
.eq('clinic_id', clinicId!)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (perf) {
setKpiProgress((perf.kpi_progress as KpiProgress[]) || []);
setSuggestions((perf.strategy_suggestions as StrategySuggestion[]) || []);
setHasData(true);
}
// Load adjustment history
const { data: adj } = await supabase
.from('strategy_adjustments')
.select('id, adjustment_type, description, reason, created_at')
.eq('clinic_id', clinicId!)
.order('created_at', { ascending: false })
.limit(10);
if (adj) setHistory(adj);
} catch {
// No data yet — show empty state
} finally {
setIsLoading(false);
}
}
async function handleRunAdjustment() {
if (!clinicId || isRunning) return;
setIsRunning(true);
try {
const result = await triggerStrategyAdjustment(clinicId);
if (result.success) {
setKpiProgress(result.kpiProgress || []);
setSuggestions(result.adjustments?.strategySuggestions || []);
setOverallAssessment(result.adjustments?.overallAssessment || '');
setHasData(true);
await loadExistingData(); // Refresh history
}
} catch (err) {
console.error('Strategy adjustment failed:', err);
} finally {
setIsRunning(false);
}
}
if (isLoading) {
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="전략 조정" dark>
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-purple-300 border-t-purple-600 rounded-full animate-spin" />
</div>
</SectionWrapper>
);
}
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="성과 기반 전략 조정" dark>
{/* Action Button */}
<div className="flex items-center justify-between mb-6" data-no-print>
<p className="text-sm text-slate-500">
</p>
<button
onClick={handleRunAdjustment}
disabled={isRunning}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
isRunning
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-[#6C5CE7] text-white hover:bg-[#5A4BD6] shadow-sm'
}`}
>
{isRunning ? (
<>
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</>
)}
</button>
</div>
{!hasData ? (
/* Empty state */
<div className="text-center py-12 bg-white rounded-2xl border border-dashed border-slate-200">
<div className="text-3xl mb-3">📊</div>
<p className="text-sm font-medium text-slate-600 mb-1"> </p>
<p className="text-xs text-slate-400">
2
</p>
</div>
) : (
<>
{/* Overall Assessment */}
{overallAssessment && (
<motion.div
className="bg-purple-50 border border-purple-100 rounded-2xl p-4 mb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<p className="text-sm text-purple-800">{overallAssessment}</p>
</motion.div>
)}
{/* KPI Progress */}
{kpiProgress.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3">KPI </h4>
<div className="space-y-3">
{kpiProgress.map((kpi, i) => (
<motion.div
key={i}
className="bg-white rounded-xl p-4 border border-slate-100"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-600">{kpi.metric}</span>
<span className={`text-xs font-bold ${
kpi.progress >= 100 ? 'text-green-600' :
kpi.progress >= 70 ? 'text-amber-600' : 'text-red-600'
}`}>
{kpi.progress}%
</span>
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full ${
kpi.progress >= 100 ? 'bg-green-400' :
kpi.progress >= 70 ? 'bg-amber-400' : 'bg-red-400'
}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min(kpi.progress, 100)}%` }}
transition={{ duration: 0.8, delay: i * 0.1 }}
/>
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-slate-400">
<span>: {kpi.current}</span>
<span>: {kpi.target}</span>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Strategy Suggestions */}
{suggestions.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3"> </h4>
<div className="space-y-3">
{suggestions.map((s, i) => {
const colors = priorityColors[s.priority] || priorityColors.medium;
return (
<motion.div
key={i}
className={`${colors.bg} rounded-xl p-4 border border-slate-100`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<div className="flex items-start gap-3">
<span className={`w-2 h-2 rounded-full ${colors.dot} mt-1.5 flex-shrink-0`} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-bold ${colors.text}`}>
{typeLabels[s.adjustmentType] || s.adjustmentType}
</span>
{s.channel && (
<span className="text-[10px] text-slate-400 bg-white px-2 py-0.5 rounded-full">
{s.channel}
</span>
)}
</div>
<p className="text-sm text-slate-700 mb-1">{s.description}</p>
<p className="text-xs text-slate-500">{s.reason}</p>
{s.beforeValue && s.afterValue && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-slate-400 line-through">{s.beforeValue}</span>
<span className="text-slate-400"></span>
<span className={`font-medium ${colors.text}`}>{s.afterValue}</span>
</div>
)}
</div>
</div>
</motion.div>
);
})}
</div>
</div>
)}
{/* Adjustment History */}
{history.length > 0 && (
<div>
<h4 className="text-sm font-bold text-[#0A1128] mb-3"> </h4>
<div className="relative border-l-2 border-slate-200 ml-2 pl-4 space-y-4">
{history.map((item) => (
<div key={item.id} className="relative">
<div className="absolute -left-[21px] top-1 w-2.5 h-2.5 rounded-full bg-purple-300 border-2 border-white" />
<div className="bg-white rounded-xl p-3 border border-slate-100">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-purple-600">
{typeLabels[item.adjustment_type] || item.adjustment_type}
</span>
<span className="text-[10px] text-slate-400">
{new Date(item.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<p className="text-xs text-slate-600">{item.description}</p>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</SectionWrapper>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingPlan } from '../types/plan';
import { fetchReportById } from '../lib/supabase';
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan';
import { fetchReportById, fetchActiveContentPlan, supabase } from '../lib/supabase';
import { transformReportToPlan } from '../lib/transformPlan';
interface UseMarketingPlanResult {
@ -14,6 +14,60 @@ interface LocationState {
report?: Record<string, unknown>;
metadata?: Record<string, unknown>;
reportId?: string;
clinicId?: string;
}
/**
* Build a MarketingPlan from content_plans DB row.
* content_plans stores AI-generated strategy in JSONB columns.
*/
function buildPlanFromContentPlans(
row: Record<string, unknown>,
clinicName: string,
clinicNameEn: string,
targetUrl: string,
): MarketingPlan {
const channelStrategies = (row.channel_strategies || []) as ChannelStrategyCard[];
const contentStrategy = (row.content_strategy || {}) as ContentStrategyData;
const calendar = (row.calendar || { weeks: [], monthlySummary: [] }) as CalendarData;
return {
id: row.id as string,
reportId: (row.run_id as string) || (row.id as string),
clinicName,
clinicNameEn,
createdAt: (row.created_at as string) || new Date().toISOString(),
targetUrl,
brandGuide: (row.brand_guide as MarketingPlan['brandGuide']) || {
colors: [],
fonts: [],
logoRules: [],
toneOfVoice: {
personality: ['전문적', '친근한', '신뢰할 수 있는'],
communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달',
doExamples: [],
dontExamples: [],
},
channelBranding: [],
brandInconsistencies: [],
},
channelStrategies,
contentStrategy: {
pillars: contentStrategy.pillars || [],
typeMatrix: contentStrategy.typeMatrix || [],
workflow: contentStrategy.workflow || [
{ step: 1, name: '기획', description: 'AI 콘텐츠 주제 선정', owner: 'INFINITH AI', duration: '자동' },
{ step: 2, name: '제작', description: 'AI 초안 생성 + 의료진 감수', owner: 'INFINITH AI', duration: '1시간' },
{ step: 3, name: '편집', description: '영상/이미지 편집', owner: 'INFINITH Studio', duration: '30분' },
{ step: 4, name: '배포', description: '채널별 최적화 배포', owner: 'INFINITH Distribution', duration: '자동' },
{ step: 5, name: '분석', description: '성과 데이터 수집 + 전략 조정', owner: 'INFINITH Analytics', duration: '자동' },
],
repurposingSource: contentStrategy.repurposingSource || '1개 롱폼 영상',
repurposingOutputs: contentStrategy.repurposingOutputs || [],
},
calendar,
assetCollection: { assets: [], youtubeRepurpose: [] },
};
}
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
@ -31,35 +85,79 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
const state = location.state as LocationState | undefined;
// Source 1: Report data passed via navigation state
if (state?.report && state?.metadata) {
async function loadPlan() {
try {
const plan = transformReportToPlan({
id: (state.reportId || id),
url: (state.metadata.url as string) || '',
clinic_name: (state.metadata.clinicName as string) || '',
report: state.report,
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
});
setData(plan);
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to build marketing plan');
setIsLoading(false);
}
return;
}
// ─── Source 1: Try content_plans table (AI-generated strategy) ───
// First, resolve clinicId from navigation state or analysis_runs
let clinicId = state?.clinicId || null;
let clinicName = '';
let clinicNameEn = '';
let targetUrl = '';
// Source 2: Fetch report from Supabase and transform to plan
fetchReportById(id)
.then((row) => {
if (!clinicId) {
// Try to find clinicId from analysis_runs by run/report ID
const { data: run } = await supabase
.from('analysis_runs')
.select('clinic_id')
.eq('id', id)
.single();
if (run) clinicId = run.clinic_id;
}
if (clinicId) {
// Fetch clinic info for plan metadata
const { data: clinic } = await supabase
.from('clinics')
.select('name, name_en, url')
.eq('id', clinicId)
.single();
if (clinic) {
clinicName = clinic.name || '';
clinicNameEn = clinic.name_en || '';
targetUrl = clinic.url || '';
}
// Try fetching active content plan
const contentPlan = await fetchActiveContentPlan(clinicId);
if (contentPlan) {
const plan = buildPlanFromContentPlans(
contentPlan,
clinicName,
clinicNameEn,
targetUrl,
);
setData(plan);
setIsLoading(false);
return;
}
}
// ─── Source 2: Report data from navigation state (fallback) ───
if (state?.report && state?.metadata) {
const plan = transformReportToPlan({
id: (state.reportId || id),
url: (state.metadata.url as string) || '',
clinic_name: (state.metadata.clinicName as string) || '',
report: state.report,
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
});
setData(plan);
setIsLoading(false);
return;
}
// ─── Source 3: Fetch report from Supabase and transform (fallback) ───
const row = await fetchReportById(id);
const plan = transformReportToPlan(row);
setData(plan);
})
.catch((err) => {
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
})
.finally(() => setIsLoading(false));
} finally {
setIsLoading(false);
}
}
loadPlan();
}, [id, location.state]);
return { data, isLoading, error };

View File

@ -1,9 +1,18 @@
/**
* Content Director Engine
* Content Director Engine v2
*
* Deterministic content planning agent that generates a 4-week editorial
* calendar by combining channel strategies, content pillars, clinic services,
* and existing assets. No AI API calls all logic is data-driven.
*
* v2 changes (Audit-driven):
* - Added , TikTok, YouTube Live/Community slots
* - Added Instagram Feed slot, Naver column format
* - Split Facebook into organic/ad
* - 5th Pillar: ·
* - Customer journey-based week themes
* - Channel-specific tone matrix
* - Keyword-driven topic generation
*/
import type {
@ -23,6 +32,8 @@ export interface ContentDirectorInput {
services: string[];
youtubeVideos?: { title: string; views: number; type: 'Short' | 'Long' }[];
clinicName: string;
keywords?: { keyword: string; monthlySearches?: number }[];
gangnamUnniData?: { rating?: number; reviews?: number; doctors?: number };
}
export interface ContentDirectorOutput {
@ -35,70 +46,165 @@ export interface ContentDirectorOutput {
interface FormatSlot {
channel: string;
channelIcon: string;
format: string; // e.g. "Shorts", "Carousel", "블로그"
format: string;
contentType: ContentCategory;
preferredDays: number[]; // 0=월 ~ 6=일
perWeek: number; // how many per week
perWeek: number;
titleTemplate: (topic: string, idx: number) => string;
tone: string;
journeyStage: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty';
}
// ─── YouTube: 4 formats ───
const YOUTUBE_SLOTS: FormatSlot[] = [
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Shorts',
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
titleTemplate: (t, i) => `Shorts: ${t} #${i + 1}`,
tone: '캐주얼·후킹', journeyStage: 'awareness',
},
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Long-form',
contentType: 'video', preferredDays: [3], perWeek: 1,
titleTemplate: (t) => `${t} 상세 설명`,
tone: '교육적·권위', journeyStage: 'interest',
},
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Live Q&A',
contentType: 'video', preferredDays: [5], perWeek: 1,
titleTemplate: (t) => `Live: ${t} 실시간 Q&A`,
tone: '친근·대화', journeyStage: 'consideration',
},
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Community',
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
titleTemplate: (t) => `[커뮤니티] ${t} 투표/질문`,
tone: '친근·참여유도', journeyStage: 'loyalty',
},
];
// ─── Instagram: 4 formats ───
const INSTAGRAM_SLOTS: FormatSlot[] = [
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Reel',
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
titleTemplate: (t, i) => `Reel: ${t} #${i + 1}`,
tone: '트렌디·공감', journeyStage: 'awareness',
},
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Carousel',
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
titleTemplate: (t) => `Carousel: ${t}`,
tone: '정보성·교육', journeyStage: 'interest',
},
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Feed',
contentType: 'social', preferredDays: [3], perWeek: 1,
titleTemplate: (t) => `Feed: ${t} 포트폴리오`,
tone: '감성적·프리미엄', journeyStage: 'consideration',
},
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Stories',
contentType: 'social', preferredDays: [2, 5], perWeek: 2,
titleTemplate: (t) => `Stories: ${t}`,
tone: '친근·일상', journeyStage: 'loyalty',
},
];
// ─── 강남언니: 3 formats (Audit C1) ───
const GANGNAMUNNI_SLOTS: FormatSlot[] = [
{
channel: '강남언니', channelIcon: 'star', format: '리뷰관리',
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
titleTemplate: (t) => `리뷰 응대: ${t}`,
tone: '전문·응대', journeyStage: 'consideration',
},
{
channel: '강남언니', channelIcon: 'star', format: '프로필최적화',
contentType: 'social', preferredDays: [0], perWeek: 1,
titleTemplate: (t) => `프로필 업데이트: ${t}`,
tone: '전문·신뢰', journeyStage: 'consideration',
},
{
channel: '강남언니', channelIcon: 'star', format: '이벤트/가격',
contentType: 'ad', preferredDays: [3], perWeek: 1,
titleTemplate: (t) => `이벤트: ${t}`,
tone: '프로모션·CTA', journeyStage: 'conversion',
},
];
// ─── TikTok: 1 format (Audit C2) ───
const TIKTOK_SLOTS: FormatSlot[] = [
{
channel: 'TikTok', channelIcon: 'video', format: '숏폼',
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
titleTemplate: (t, i) => `TikTok: ${t} #${i + 1}`,
tone: '밈·교육', journeyStage: 'awareness',
},
];
// ─── 네이버 블로그: 2 formats (Audit H3) ───
const NAVER_SLOTS: FormatSlot[] = [
{
channel: '네이버 블로그', channelIcon: 'blog', format: '블로그',
channel: '네이버 블로그', channelIcon: 'blog', format: 'SEO글',
contentType: 'blog', preferredDays: [1, 3], perWeek: 2,
titleTemplate: (t) => `${t}`,
tone: '정보성·SEO', journeyStage: 'interest',
},
{
channel: '네이버 블로그', channelIcon: 'blog', format: '의사칼럼',
contentType: 'blog', preferredDays: [5], perWeek: 1,
titleTemplate: (t) => `[칼럼] ${t}`,
tone: '전문·교육', journeyStage: 'consideration',
},
];
// ─── Facebook: organic + ad (Audit H4) ───
const FACEBOOK_SLOTS: FormatSlot[] = [
{
channel: 'Facebook', channelIcon: 'facebook', format: '오가닉',
contentType: 'social', preferredDays: [2], perWeek: 1,
titleTemplate: (t) => `${t}`,
tone: '커뮤니티·공유', journeyStage: 'interest',
},
{
channel: 'Facebook', channelIcon: 'facebook', format: '광고',
contentType: 'ad', preferredDays: [5], perWeek: 1,
contentType: 'ad', preferredDays: [4, 6], perWeek: 2,
titleTemplate: (t) => `광고: ${t}`,
tone: '타겟팅·CTA', journeyStage: 'conversion',
},
];
// ─── Week Themes ───
// ─── Customer Journey-Based Week Themes (Audit C4) ───
const WEEK_THEMES = [
{ label: 'Week 1: 브랜드 정비 & 첫 콘텐츠', pillarFocus: 0 }, // 전문성·신뢰
{ label: 'Week 2: 콘텐츠 엔진 가동', pillarFocus: 1 }, // 비포·애프터
{ label: 'Week 3: 소셜 증거 강화', pillarFocus: 2 }, // 환자 후기
{ label: 'Week 4: 전환 최적화', pillarFocus: 3 }, // 트렌드·교육
{
label: 'Week 1: 인지 확대 & 브랜드 정비',
pillarFocus: 0, // 전문성·신뢰
journeyFocus: 'awareness' as const,
description: '신규 유입 극대화 — YouTube Shorts, TikTok, Instagram Reels 집중',
},
{
label: 'Week 2: 관심 유도 & 교육 콘텐츠',
pillarFocus: 1, // 비포·애프터
journeyFocus: 'interest' as const,
description: '정보 탐색 — Long-form, 블로그 SEO, Carousel 집중',
},
{
label: 'Week 3: 신뢰 구축 & 소셜 증거',
pillarFocus: 2, // 환자 후기
journeyFocus: 'consideration' as const,
description: '비교 검토 — 강남언니 리뷰, Before/After, 의사 소개 집중',
},
{
label: 'Week 4: 전환 최적화 & CTA',
pillarFocus: 3, // 트렌드·교육
journeyFocus: 'conversion' as const,
description: '상담 예약 유도 — Facebook 광고, Instagram DM CTA, 프로모션',
},
];
// ─── Topic Generation ───
// ─── Topic Generation (with keyword injection — Audit C5) ───
interface TopicPool {
topics: string[];
@ -109,46 +215,71 @@ function buildTopicPool(
pillars: ContentPillar[],
services: string[],
pillarIndex: number,
keywords?: { keyword: string; monthlySearches?: number }[],
): TopicPool {
const pillar = pillars[pillarIndex % pillars.length];
const svcList = services.length > 0 ? services : ['시술'];
const topics: string[] = [];
// Inject high-volume keywords as topics (Audit C5)
if (keywords?.length) {
const topKeywords = keywords
.sort((a, b) => (b.monthlySearches || 0) - (a.monthlySearches || 0))
.slice(0, 5)
.map(k => k.keyword);
for (const kw of topKeywords) {
topics.push(kw);
}
}
// Pillar-specific templates
switch (pillarIndex % 4) {
switch (pillarIndex % 5) {
case 0: // 전문성·신뢰
for (const svc of svcList) {
topics.push(`${svc} 전문의 Q&A`);
topics.push(`${svc} 과정 완전정복`);
topics.push(`${svc} 수술실 CCTV 공개`);
}
topics.push('의료진 학회 발표·해외 연수');
topics.push('최신 장비 도입 소개');
topics.push('마취 안전 관리 시스템');
topics.push('회복 관리 시스템');
break;
case 1: // 비포·애프터
for (const svc of svcList) {
topics.push(`${svc} 전후 비교`);
topics.push(`${svc} Before/After`);
topics.push(`${svc} Before/After 타임랩스`);
topics.push(`${svc} 3D 시뮬레이션 미리보기`);
}
topics.push('자연스러운 라인');
topics.push('내 얼굴에 가장 예쁜 코');
topics.push('자연스러운 라인 완성');
topics.push('시간별 회복 과정 기록');
break;
case 2: // 환자 후기
for (const svc of svcList) {
topics.push(`${svc} 실제 후기`);
topics.push(`${svc} 실제 환자 인터뷰`);
topics.push(`${svc} 회복 일기`);
}
topics.push('환자가 설명하는: 왜 선택?');
topics.push('리뷰 하이라이트');
topics.push('해외 환자 케이스');
topics.push('환자가 말하는: 왜 여기를 선택했나');
topics.push('리뷰 하이라이트 모음');
break;
case 3: // 트렌드·교육
for (const svc of svcList) {
topics.push(`${svc} 비용 가이드`);
topics.push(`${svc} FAQ`);
topics.push(`${svc} 비용 완벽 가이드`);
topics.push(`${svc} FAQ 총정리`);
}
topics.push('성형 가이드: 내 얼굴 뭐 할래');
topics.push('트렌드 분석: 자연주의 성형');
topics.push('2026 성형 트렌드: 자연주의');
topics.push('성형 전 필수 체크리스트');
topics.push('시술 비교: 이걸로 고민 끝');
break;
case 4: // 안전·케어 (5th Pillar — Audit H5)
for (const svc of svcList) {
topics.push(`${svc} 수술 후 관리 가이드`);
}
topics.push('24시간 집중 케어 시스템');
topics.push('전담 간호사 1:1 관리');
topics.push('리커버리 프로그램 소개');
topics.push('응급 상황 대응 프로토콜');
break;
}
@ -178,10 +309,46 @@ function buildRepurposeTopics(
.filter(t => t.length > 2);
}
// ─── 강남언니 Special Topics ───
function buildGangnamUnniTopics(
services: string[],
data?: { rating?: number; reviews?: number; doctors?: number },
): string[] {
const topics: string[] = [];
const svcList = services.length > 0 ? services : ['시술'];
// Review management topics
topics.push('신규 리뷰 감사 응대');
topics.push('부정 리뷰 전문 응대');
// Profile optimization
for (const svc of svcList.slice(0, 3)) {
topics.push(`${svc} 시술 정보 업데이트`);
}
topics.push('의사 프로필 사진·경력 갱신');
topics.push('대표 시술 가격 재검토');
// Data-driven topics
if (data?.rating && data.rating < 9.0) {
topics.push('평점 개선 액션 플랜');
}
if (data?.reviews && data.reviews < 500) {
topics.push('리뷰 수 증가 캠페인');
}
// Events
for (const svc of svcList.slice(0, 2)) {
topics.push(`${svc} 시즌 이벤트`);
}
return topics;
}
// ─── Main Engine ───
export function generateContentPlan(input: ContentDirectorInput): ContentDirectorOutput {
const { channels, pillars, services, youtubeVideos, clinicName } = input;
const { channels, pillars, services, youtubeVideos, clinicName, keywords, gangnamUnniData } = input;
// 1. Determine active format slots based on available channels
const activeSlots: FormatSlot[] = [];
@ -189,55 +356,71 @@ export function generateContentPlan(input: ContentDirectorInput): ContentDirecto
if (channelIds.has('youtube')) activeSlots.push(...YOUTUBE_SLOTS);
if (channelIds.has('instagram')) activeSlots.push(...INSTAGRAM_SLOTS);
if (channelIds.has('gangnamunni') || channelIds.has('gangnamUnni')) activeSlots.push(...GANGNAMUNNI_SLOTS);
if (channelIds.has('tiktok')) activeSlots.push(...TIKTOK_SLOTS);
if (channelIds.has('naverblog') || channelIds.has('naver_blog')) activeSlots.push(...NAVER_SLOTS);
if (channelIds.has('facebook')) activeSlots.push(...FACEBOOK_SLOTS);
// Fallback: if no channels matched, use YouTube + Instagram as defaults
// Fallback: if no channels matched, use core 3 channels
if (activeSlots.length === 0) {
activeSlots.push(...YOUTUBE_SLOTS, ...INSTAGRAM_SLOTS);
activeSlots.push(...YOUTUBE_SLOTS, ...INSTAGRAM_SLOTS, ...NAVER_SLOTS);
}
// 2. Build repurpose topics from existing YouTube videos
const repurposeTopics = youtubeVideos ? buildRepurposeTopics(youtubeVideos) : [];
// 3. Generate 4 weeks of content
// 3. Build 강남언니 special topic pool
const guTopics = buildGangnamUnniTopics(services, gangnamUnniData);
let guCursor = 0;
// 4. Generate 4 weeks of content
const weeks: CalendarWeek[] = [];
for (let weekIdx = 0; weekIdx < 4; weekIdx++) {
const theme = WEEK_THEMES[weekIdx];
// Build topic pool focused on this week's pillar, with secondary pillars mixed in
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus);
const secondaryPool = buildTopicPool(pillars, services, (theme.pillarFocus + 1) % 4);
// Build topic pools focused on this week's pillar + 5th pillar rotation
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus, keywords);
const secondaryPool = buildTopicPool(pillars, services, (theme.pillarFocus + 1) % 5, keywords);
// 5th pillar (안전·케어) injected every other week
const carePool = weekIdx % 2 === 1
? buildTopicPool(pillars, services, 4, keywords)
: null;
const entries: CalendarEntry[] = [];
// Week 1 special: add brand setup tasks
// Week 1 special: brand setup tasks
if (weekIdx === 0) {
entries.push({
dayOfWeek: 0, // 월
dayOfWeek: 0,
channel: clinicName,
channelIcon: 'globe',
contentType: 'social',
title: `전 채널 프로필/VIEW 골드 정비`,
});
entries.push({
dayOfWeek: 1, // 화
channel: 'Instagram',
channelIcon: 'instagram',
contentType: 'social',
title: `프로필 리뉴얼 고지 + 팔로워`,
title: '전 채널 프로필/브랜드 일관성 정비',
description: '프로필 사진, 배너, 바이오를 모든 채널에서 통일',
pillar: '전문성 · 신뢰',
status: 'draft',
});
}
// Fill format slots for this week
for (const slot of activeSlots) {
const pool = entries.length % 2 === 0 ? primaryPool : secondaryPool;
// Use care pool occasionally
const pool = carePool && entries.length % 5 === 0
? carePool
: entries.length % 2 === 0 ? primaryPool : secondaryPool;
for (let i = 0; i < slot.perWeek; i++) {
// Use repurpose topics for some slots in weeks 2-4
let topic: string;
if (weekIdx >= 1 && repurposeTopics.length > 0 && i === 0 && slot.format === 'Shorts') {
// 강남언니: use dedicated topic pool
if (slot.channel === '강남언니') {
topic = guTopics[guCursor % guTopics.length];
guCursor++;
}
// Repurpose popular YouTube content for Shorts/TikTok in weeks 2-4
else if (weekIdx >= 1 && repurposeTopics.length > 0 && i === 0 &&
(slot.format === 'Shorts' || slot.format === '숏폼')) {
const rIdx = (weekIdx - 1 + i) % repurposeTopics.length;
topic = repurposeTopics[rIdx];
} else {
@ -252,29 +435,51 @@ export function generateContentPlan(input: ContentDirectorInput): ContentDirecto
channelIcon: slot.channelIcon,
contentType: slot.contentType,
title: slot.titleTemplate(topic, i),
pillar: pillars[theme.pillarFocus % pillars.length]?.title,
status: 'draft',
});
}
}
// Week 4 special: add conversion-focused entries
// Week 3 special: 강남언니 리뷰 집중 (소셜 증거 강화)
if (weekIdx === 2 && !channelIds.has('gangnamunni') && !channelIds.has('gangnamUnni')) {
entries.push({
dayOfWeek: 3,
channel: '강남언니',
channelIcon: 'star',
contentType: 'social',
title: '강남언니 프로필 개설/최적화',
description: '아직 강남언니 계정이 없다면 개설, 있다면 프로필 최적화',
pillar: '환자 후기 · 리뷰',
status: 'draft',
});
}
// Week 4 special: conversion-focused entries
if (weekIdx === 3) {
entries.push({
dayOfWeek: 5, // 토
dayOfWeek: 5,
channel: 'Instagram',
channelIcon: 'instagram',
contentType: 'social',
title: 'Stories: 상담 예약 CTA + 카카오톡 링크',
description: 'DM→카카오톡→전화 전환 퍼널 활성화',
pillar: '트렌드 · 교육',
status: 'draft',
});
entries.push({
dayOfWeek: 6,
channel: 'Facebook',
channelIcon: 'facebook',
contentType: 'ad',
title: `광고: 상담 예약 CTA`,
title: '광고: 리타겟팅 — 웹사이트 방문자 재유입',
description: '최근 30일 웹사이트 방문자 대상 리타겟팅 광고',
pillar: '트렌드 · 교육',
status: 'draft',
});
if (!channelIds.has('facebook')) {
// Replace channel for non-Facebook users
entries[entries.length - 1].channel = 'Instagram';
entries[entries.length - 1].channelIcon = 'instagram';
entries[entries.length - 1].contentType = 'social';
entries[entries.length - 1].title = '가슴성형 할인 이벤트 피드';
}
}
// Sort entries by day of week for clean display
// Sort entries by day of week
entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
weeks.push({
@ -284,7 +489,7 @@ export function generateContentPlan(input: ContentDirectorInput): ContentDirecto
});
}
// 4. Calculate monthly summary
// 5. Calculate monthly summary
const allEntries = weeks.flatMap(w => w.entries);
const monthlySummary: ContentCountSummary[] = [
{

View File

@ -135,11 +135,16 @@ export async function discoverChannels(url: string, clinicName?: string) {
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(`Channel discovery failed: ${response.statusText}`);
const err = new Error(data.error || `Channel discovery failed: ${response.statusText}`);
(err as Error & { code?: string; domain?: string }).code = data.error;
(err as Error & { code?: string; domain?: string }).domain = data.domain;
throw err;
}
return response.json();
return data;
}
/**
@ -182,3 +187,104 @@ export async function generateReportV2(reportId: string, clinicId?: string, runI
return response.json();
}
// ─── Pipeline V2 Phase 4: Content Plan ───
/**
* Phase 4: Generate AI content strategy and calendar.
* Reads report data from DB, calls Perplexity for strategy generation,
* stores result in content_plans table.
*/
export async function generateContentPlan(reportId: string, clinicId?: string, runId?: string) {
const response = await fetch(
`${supabaseUrl}/functions/v1/generate-content-plan`,
{
method: "POST",
headers: fnHeaders(),
body: JSON.stringify({ reportId, clinicId, runId }),
}
);
if (!response.ok) {
throw new Error(`Content plan generation failed: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch the active content plan for a clinic.
* Returns the most recent is_active=true plan with all JSONB columns.
*/
export async function fetchActiveContentPlan(clinicId: string) {
const { data, error } = await supabase
.from("content_plans")
.select("*")
.eq("clinic_id", clinicId)
.eq("is_active", true)
.order("created_at", { ascending: false })
.limit(1)
.single();
if (error) return null;
return data;
}
/**
* Update a single calendar entry within a content plan's JSONB.
* Uses Postgres JSONB path update.
*/
export async function updateCalendarEntry(
planId: string,
entryId: string,
updates: Record<string, unknown>,
) {
// Read current calendar
const { data: plan, error: readErr } = await supabase
.from("content_plans")
.select("calendar")
.eq("id", planId)
.single();
if (readErr || !plan) throw new Error(`Plan not found: ${readErr?.message}`);
const calendar = plan.calendar as { weeks: { entries: Record<string, unknown>[] }[] };
// Find and update entry
for (const week of calendar.weeks) {
for (let i = 0; i < week.entries.length; i++) {
if (week.entries[i].id === entryId) {
week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true };
break;
}
}
}
const { error: writeErr } = await supabase
.from("content_plans")
.update({ calendar })
.eq("id", planId);
if (writeErr) throw new Error(`Update failed: ${writeErr.message}`);
}
/**
* Trigger strategy adjustment for a clinic.
* Compares current vs previous channel_snapshots, generates AI recommendations.
*/
export async function triggerStrategyAdjustment(clinicId: string) {
const response = await fetch(
`${supabaseUrl}/functions/v1/adjust-strategy`,
{
method: "POST",
headers: fnHeaders(),
body: JSON.stringify({ clinicId }),
}
);
if (!response.ok) {
throw new Error(`Strategy adjustment failed: ${response.statusText}`);
}
return response.json();
}

View File

@ -38,6 +38,18 @@ const CHANNEL_ICON_MAP: Record<string, string> = {
tiktok: 'video',
};
// Channel-specific tone matrix (Audit C3)
const CHANNEL_TONE_MAP: Record<string, string> = {
youtube: '교육적 · 권위 (Long) / 캐주얼 · 후킹 (Shorts)',
instagram: '트렌디 · 공감 (Reel) / 감성적 · 프리미엄 (Feed)',
naverBlog: '정보성 · SEO 최적화',
gangnamUnni: '전문 · 응대 · 신뢰',
facebook: '타겟팅 · CTA 중심',
tiktok: '밈 · 교육 · MZ세대',
naverPlace: '정보성 · 지역 SEO',
website: '브랜드 · 프리미엄',
};
function buildChannelStrategies(
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
recommendations: Record<string, unknown>[] | undefined,
@ -61,7 +73,7 @@ function buildChannelStrategies(
targetGoal: (ch.recommendation as string) || '',
contentTypes: relatedRecs.length > 0 ? relatedRecs : ['콘텐츠 전략 수립 필요'],
postingFrequency: score >= 80 ? '주 3-5회' : score >= 60 ? '주 2-3회' : '주 1-2회 (시작)',
tone: '전문적 · 친근한',
tone: CHANNEL_TONE_MAP[key] || '전문적 · 친근한',
formatGuidelines: [],
priority: (score < 50 ? 'P0' : score < 70 ? 'P1' : 'P2') as 'P0' | 'P1' | 'P2',
};
@ -72,36 +84,43 @@ function buildContentPillars(
recommendations: Record<string, unknown>[] | undefined,
services: string[] | undefined,
): ContentPillar[] {
const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E'];
const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E', '#0984E3'];
const pillars: ContentPillar[] = [
{
title: '전문성 · 신뢰',
description: '의료진 소개, 수술 과정, 인증/자격 콘텐츠로 신뢰 구축',
description: '의료진 소개, 수술 과정, 인증/자격, 학회 발표, 해외 연수 콘텐츠로 신뢰 구축',
relatedUSP: '전문 의료진',
exampleTopics: services?.slice(0, 3).map(s => `${s} 시술 과정 소개`) || ['시술 과정 소개'],
color: PILLAR_COLORS[0],
},
{
title: '비포 · 애프터',
description: '실제 환자 사례, 수술 전후 비교로 결과 시각화',
description: '실제 환자 사례, 수술 전후 비교, 3D 시뮬레이션, 시간별 변화 기록으로 결과 시각화',
relatedUSP: '검증된 결과',
exampleTopics: services?.slice(0, 3).map(s => `${s} 비포/애프터`) || ['비포/애프터 사례'],
color: PILLAR_COLORS[1],
},
{
title: '환자 후기 · 리뷰',
description: '실제 환자 인터뷰, 후기 콘텐츠로 사회적 증거 확보',
description: '실제 환자 인터뷰, 후기 콘텐츠, 강남언니 리뷰 관리로 사회적 증거 확보',
relatedUSP: '환자 만족도',
exampleTopics: ['환자 인터뷰 영상', '리뷰 하이라이트', '회복 일기'],
color: PILLAR_COLORS[2],
},
{
title: '트렌드 · 교육',
description: '시술 트렌드, Q&A, 의학 정보로 잠재 고객 유입',
description: '시술 트렌드, Q&A, 의학 정보, 비용 가이드로 잠재 고객 유입',
relatedUSP: '최신 트렌드',
exampleTopics: ['자주 묻는 질문 Q&A', '시술별 비용 가이드', '최신 성형 트렌드'],
color: PILLAR_COLORS[3],
},
{
title: '안전 · 케어',
description: '수술 후 관리, 24시간 모니터링, 전담 간호사, 리커버리 프로그램으로 프리미엄 케어 차별화',
relatedUSP: '프리미엄 안전 관리',
exampleTopics: ['수술 후 48시간 집중 케어', '전담 간호사 1:1 관리', '응급 대응 프로토콜'],
color: PILLAR_COLORS[4],
},
];
return pillars;
@ -118,6 +137,7 @@ function buildCalendar(
services: string[],
enrichment: EnrichmentData | undefined,
clinicName: string,
report?: Record<string, unknown>,
) {
// Extract YouTube top videos for repurposing suggestions
const youtubeVideos = enrichment?.youtube?.videos?.map(v => ({
@ -126,12 +146,26 @@ function buildCalendar(
type: ((v.duration && parseInt(v.duration) < 60) ? 'Short' : 'Long') as 'Short' | 'Long',
}));
// Extract keywords from report for topic generation (Audit C5)
const reportKeywords = report?.keywords as { primary?: { keyword: string; monthlySearches?: number }[] } | undefined;
const keywords = reportKeywords?.primary;
// Extract 강남언니 data for strategy-driven topics (Audit C1)
const guData = enrichment?.gangnamUnni as { rating?: number; totalReviews?: number; doctors?: { name: string }[] } | undefined;
const gangnamUnniData = guData ? {
rating: guData.rating,
reviews: guData.totalReviews,
doctors: guData.doctors?.length,
} : undefined;
return generateContentPlan({
channels,
pillars,
services,
youtubeVideos,
clinicName,
keywords,
gangnamUnniData,
});
}
@ -326,7 +360,7 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations);
const pillars = buildContentPillars(recommendations, services);
const clinicName = (clinicInfo?.name as string) || row.clinic_name || '';
const calendar = buildCalendar(channelStrategies, pillars, services, enrichment, clinicName);
const calendar = buildCalendar(channelStrategies, pillars, services, enrichment, clinicName, report);
return {
id: row.id,

View File

@ -1,20 +1,22 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router';
import { motion } from 'motion/react';
import { Check, AlertCircle, RefreshCw } from 'lucide-react';
import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react';
import {
discoverChannels,
collectChannelData,
generateReportV2,
generateContentPlan,
fetchPipelineStatus,
} from '../lib/supabase';
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'complete';
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'planning' | 'complete';
const PHASE_STEPS = [
{ key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
{ key: 'collecting' as Phase, label: 'Collecting channel data & market analysis...', labelDone: 'Data collected' },
{ key: 'generating' as Phase, label: 'Generating AI marketing report...', labelDone: 'Report generated' },
{ key: 'planning' as Phase, label: 'AI 콘텐츠 전략 생성 중...', labelDone: '콘텐츠 플랜 생성 완료' },
{ key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' },
];
@ -49,6 +51,8 @@ function clearSession() {
export default function AnalysisLoadingPage() {
const [phase, setPhase] = useState<Phase>('discovering');
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [errorDomain, setErrorDomain] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
const navigate = useNavigate();
const location = useLocation();
@ -99,20 +103,35 @@ export default function AnalysisLoadingPage() {
}
// Phase 3: Generate Report
let reportResult: Record<string, unknown> | null = null;
if (startPhase === 'generating') {
setPhase('generating');
const result = await generateReportV2(reportId, clinicId, runId);
if (!result.success) throw new Error(result.error || 'Report generation failed');
reportResult = result;
startPhase = 'planning';
}
// Phase 4: Generate Content Plan (non-blocking — failure doesn't stop pipeline)
if (startPhase === 'planning') {
setPhase('planning');
try {
await generateContentPlan(reportId, clinicId, runId);
} catch (planErr) {
console.warn('[pipeline] Content plan generation failed (non-blocking):', planErr);
}
// Complete — navigate to report
setPhase('complete');
clearSession();
// Use stored report result or refetch
const result = reportResult || await generateReportV2(reportId, clinicId, runId).catch(() => null);
setTimeout(() => {
navigate(`/report/${reportId}`, {
replace: true,
state: result.report && result.metadata
? { report: result.report, metadata: result.metadata, reportId }
state: result?.report && result?.metadata
? { report: result.report, metadata: result.metadata, reportId, clinicId }
: undefined,
});
}, 800);
@ -120,6 +139,12 @@ export default function AnalysisLoadingPage() {
} catch (err) {
const msg = err instanceof Error ? err.message : 'An error occurred';
setError(msg);
if (err && typeof err === 'object' && 'code' in err) {
setErrorCode((err as { code?: string }).code || null);
}
if (err && typeof err === 'object' && 'domain' in err) {
setErrorDomain((err as { domain?: string }).domain || null);
}
}
}, [navigate]);
@ -260,41 +285,64 @@ export default function AnalysisLoadingPage() {
)}
{error ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center"
>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 text-sm mb-4">{error}</p>
{errorDetails && (
<div className="mb-4 text-left bg-red-500/5 rounded-lg p-3">
<p className="text-red-400/60 text-xs font-mono mb-1">Failed channels:</p>
{Object.entries(errorDetails).map(([ch, err]) => (
<p key={ch} className="text-red-400/80 text-xs font-mono">
{ch}: {err}
</p>
))}
</div>
)}
<div className="flex gap-3 justify-center">
<button
onClick={handleRetry}
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
errorCode === 'CLINIC_NOT_REGISTERED' ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full p-6 rounded-2xl bg-purple-500/10 border border-purple-500/20 text-center"
>
<ShieldX className="w-10 h-10 text-purple-400 mx-auto mb-3" />
<p className="text-white text-lg font-medium mb-2"> </p>
<p className="text-purple-300/80 text-sm mb-1">
<span className="font-mono text-purple-200">{errorDomain}</span>
</p>
<p className="text-purple-300/60 text-sm mb-6">
.
</p>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors"
>
Start Over
</button>
</div>
</motion.div>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center"
>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 text-sm mb-4">{error}</p>
{errorDetails && (
<div className="mb-4 text-left bg-red-500/5 rounded-lg p-3">
<p className="text-red-400/60 text-xs font-mono mb-1">Failed channels:</p>
{Object.entries(errorDetails).map(([ch, err]) => (
<p key={ch} className="text-red-400/80 text-xs font-mono">
{ch}: {err}
</p>
))}
</div>
)}
<div className="flex gap-3 justify-center">
<button
onClick={handleRetry}
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
>
Start Over
</button>
</div>
</motion.div>
)
) : (
<>
{phase === 'resuming' ? (

View File

@ -85,7 +85,7 @@ const API_REGISTRY: ApiConfig[] = [
{
id: 'apify',
name: 'Apify',
description: 'Instagram/Facebook/Google Places 데이터 수집',
description: 'Instagram/Facebook 데이터 수집',
envKeys: ['APIFY_API_TOKEN'],
layer: 'edge-function',
docsUrl: 'https://docs.apify.com',
@ -95,7 +95,22 @@ const API_REGISTRY: ApiConfig[] = [
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
estimatedCostPerRun: '~$0.10-0.30',
rateLimit: 'Actor-dependent (30-120s timeout)',
notes: 'IG profile/posts/reels + FB pages + Google Places actors',
notes: 'IG profile/posts/reels + FB pages actors',
},
{
id: 'google-places',
name: 'Google Places API (New)',
description: 'Google Maps 장소 검색, 평점, 리뷰, 연락처 데이터',
envKeys: ['GOOGLE_PLACES_API_KEY'],
layer: 'edge-function',
docsUrl: 'https://developers.google.com/maps/documentation/places/web-service',
IconComponent: GlobeFilled,
category: 'social',
pricingModel: '$200/mo 무료 크레딧 포함',
usedInPhases: ['Phase 2: collect', 'Enrich', 'Registry enrichment'],
estimatedCostPerRun: '~$0.04',
rateLimit: 'No hard limit (pay-as-you-go)',
notes: 'Text Search + Place Details (1-2초 응답, Apify 대비 30x 빠름)',
},
{
id: 'youtube',

View File

@ -1,4 +1,4 @@
import { useParams } from 'react-router';
import { useParams, useLocation } from 'react-router';
import { useMarketingPlan } from '../hooks/useMarketingPlan';
import { ReportNav } from '../components/report/ReportNav';
@ -10,6 +10,7 @@ import ContentStrategy from '../components/plan/ContentStrategy';
import ContentCalendar from '../components/plan/ContentCalendar';
import AssetCollection from '../components/plan/AssetCollection';
import MyAssetUpload from '../components/plan/MyAssetUpload';
import StrategyAdjustmentSection from '../components/plan/StrategyAdjustmentSection';
import PlanCTA from '../components/plan/PlanCTA';
const PLAN_SECTIONS = [
@ -19,10 +20,13 @@ const PLAN_SECTIONS = [
{ id: 'content-calendar', label: '콘텐츠 캘린더' },
{ id: 'asset-collection', label: '에셋 수집' },
{ id: 'my-asset-upload', label: 'My Assets' },
{ id: 'strategy-adjustment', label: '전략 조정' },
];
export default function MarketingPlanPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const clinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
const { data, isLoading, error } = useMarketingPlan(id);
if (isLoading) {
@ -75,6 +79,10 @@ export default function MarketingPlanPage() {
<MyAssetUpload />
</div>
<div data-no-print>
<StrategyAdjustmentSection clinicId={clinicId} planId={data.id} />
</div>
<PlanCTA />
</div>
</div>

View File

@ -59,6 +59,7 @@ export interface ChannelStrategyCard {
tone: string;
formatGuidelines: string[];
priority: 'P0' | 'P1' | 'P2';
customerJourneyStage?: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty';
}
// ─── Section 3: Content Marketing Strategy ───
@ -110,6 +111,13 @@ export interface CalendarEntry {
channelIcon: string;
contentType: ContentCategory;
title: string;
// AI-generated fields (optional — backward compatible with deterministic engine)
id?: string;
description?: string;
pillar?: string;
status?: 'draft' | 'approved' | 'published';
isManualEdit?: boolean;
aiPromptSeed?: string;
}
export interface CalendarWeek {

View File

@ -0,0 +1,192 @@
/**
* Missing Value Guard & Report Quality Validator Harness 3
*
* Detects all known variants of "no data" from LLM outputs and
* provides a quality score for generated reports.
*
* Usage:
* - isMissingValue(val) boolean (use everywhere you check for missing data)
* - validateReportQuality(report) DataQualityReport (use before saving reports)
*/
// ─── Missing Value Detection ───
/**
* All known LLM expressions for "no data available".
* Normalized to lowercase for comparison.
*/
const MISSING_PATTERNS: ReadonlyArray<string> = [
// Korean
"데이터 없음", "데이터없음", "데이터 미확인", "데이터미확인",
"정보없음", "정보 없음", "정보 미제공", "미제공",
"확인불가", "확인 불가", "미확인", "미발견",
"알 수 없음", "알수없음", "해당 없음", "해당없음",
"없음", "미정",
// English
"n/a", "na", "none", "null", "undefined",
"not available", "unknown", "not found", "no data",
// Symbols
"-", "—", "", ".", "...", "N/A",
];
/**
* Check if a value represents missing/unavailable data.
* Handles null, undefined, empty strings, zero, and LLM "no data" variants.
*/
export function isMissingValue(val: unknown): boolean {
if (val == null) return true;
if (typeof val === "number") return val === 0 || isNaN(val);
const s = String(val).trim().toLowerCase();
if (s === "" || s === "0") return true;
return MISSING_PATTERNS.some((p) => s === p.toLowerCase());
}
/**
* Clean a value: return it if valid, or return the fallback if missing.
* Useful for providing defaults without silent data loss.
*/
export function cleanValue<T>(val: unknown, fallback: T): T | unknown {
return isMissingValue(val) ? fallback : val;
}
// ─── Report Quality Validation ───
export interface DataQualityReport {
score: number; // 0-100
missingCritical: string[]; // Missing critical fields
missingImportant: string[]; // Missing important fields
missingOptional: string[]; // Missing optional fields
warnings: string[]; // Human-readable warnings
}
/** Fields that MUST be present for a valid report */
const CRITICAL_FIELDS = [
"clinicInfo.name",
"clinicInfo.established",
];
/** Fields that significantly impact report quality */
const IMPORTANT_FIELDS = [
"clinicInfo.doctors",
"channelAnalysis.youtube",
"channelAnalysis.instagram",
"channelAnalysis.naverBlog",
"channelAnalysis.gangnamUnni",
];
/** Nice-to-have fields */
const OPTIONAL_FIELDS = [
"channelAnalysis.facebook",
"channelAnalysis.tiktok",
"channelAnalysis.naverPlace",
"channelAnalysis.googleMaps",
"clinicInfo.location",
];
/**
* Validate report data quality and return a score with details.
*
* Scoring:
* - Each critical field missing: -20 points
* - Each important field missing: -5 points
* - Each optional field missing: -2 points
*/
export function validateReportQuality(
report: Record<string, unknown>,
): DataQualityReport {
const result: DataQualityReport = {
score: 100,
missingCritical: [],
missingImportant: [],
missingOptional: [],
warnings: [],
};
// Check critical fields
for (const path of CRITICAL_FIELDS) {
const val = getNestedValue(report, path);
if (isMissingValue(val)) {
result.missingCritical.push(path);
result.score -= 20;
result.warnings.push(`❌ Critical: '${path}' is missing`);
}
}
// Check important fields
for (const path of IMPORTANT_FIELDS) {
const val = getNestedValue(report, path);
if (isMissingValue(val)) {
result.missingImportant.push(path);
result.score -= 5;
result.warnings.push(`⚠️ Important: '${path}' is missing`);
}
}
// Check optional fields
for (const path of OPTIONAL_FIELDS) {
const val = getNestedValue(report, path);
if (isMissingValue(val)) {
result.missingOptional.push(path);
result.score -= 2;
}
}
result.score = Math.max(0, result.score);
return result;
}
// ─── Helpers ───
/**
* Traverse a nested object by dot-separated path.
* e.g., getNestedValue({ a: { b: 1 } }, "a.b") 1
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce(
(current, key) => {
if (current == null || typeof current !== "object") return undefined;
return (current as Record<string, unknown>)[key];
},
obj as unknown,
);
}
// ─── Self-Test ───
const MISSING_VALUE_TEST_CORPUS: ReadonlyArray<readonly [unknown, boolean]> = [
[null, true],
[undefined, true],
["", true],
[" ", true],
[0, true],
["데이터 없음", true],
["데이터없음", true],
["N/A", true],
["n/a", true],
["확인 불가", true],
["미확인", true],
["unknown", true],
["-", true],
["—", true],
["none", true],
// Valid values
["뷰성형외과", false],
[4.5, false],
["2004", false],
[387, false],
["https://example.com", false],
];
export function validateDataQuality(): { pass: boolean; failures: string[] } {
const failures: string[] = [];
for (const [val, expected] of MISSING_VALUE_TEST_CORPUS) {
const result = isMissingValue(val);
if (result !== expected) {
failures.push(`isMissingValue(${JSON.stringify(val)}): expected ${expected}, got ${result}`);
}
}
return { pass: failures.length === 0, failures };
}

View File

@ -0,0 +1,100 @@
/**
* Founding Year Extraction Guard Harness 2
*
* Regex-based founding year extraction to complement Gemini Vision.
* Acts as a fallback when the LLM misses patterns like "2004년개원 이래".
*
* Defense layers:
* 1. Gemini Vision (primary)
* 2. This module on scraped text (secondary)
* 3. generate-report post-processing on all channel_data text (tertiary)
*/
// ─── Patterns ───
/**
* Regex patterns for founding year detection.
* Each captures a numeric group that is either:
* - A 4-digit year (1950currentYear)
* - A 1-2 digit number to subtract from currentYear
*/
const FOUNDING_PATTERNS: RegExp[] = [
// Direct year expressions
/(\d{4})년\s*개원/, // "2004년개원", "2004년 개원"
/(\d{4})년개원\s*이래/, // "2004년개원 이래" ← 그랜드 패턴
/개원\s*(\d{4})년/, // "개원 2004년"
/설립\s*(\d{4})년/, // "설립 2004년"
/(\d{4})년\s*설립/, // "2004년 설립"
/since\s*(\d{4})/i, // "SINCE 2004"
/established\s*(?:in\s*)?(\d{4})/i, // "Established in 2004"
/(\d{4})년\s*오픈/, // "2004년 오픈"
/(\d{4})년\s*개업/, // "2004년 개업"
/개원일?\s*:\s*(\d{4})/, // "개원: 2004", "개원일: 2004"
// Anniversary / relative year expressions
/(\d{1,2})주년/, // "22주년" → currentYear-22
/(\d{1,2})년\s*전통/, // "20년 전통" → currentYear-20
/(\d{1,2})년\s*동안/, // "22년 동안" → currentYear-22
/개원\s*(\d{1,2})주년/, // "개원 15주년" → currentYear-15
/(\d{1,2})년\s*역사/, // "20년 역사" → currentYear-20
];
// ─── Extraction Function ───
/**
* Extract founding year from arbitrary text.
* Returns a 4-digit year or null if no pattern matches.
*
* @param text - Any text (HTML markdown, scraped content, vision output)
* @param currentYear - For relative calculations (default: current year)
*/
export function extractFoundingYear(
text: string,
currentYear: number = new Date().getFullYear(),
): number | null {
for (const pattern of FOUNDING_PATTERNS) {
const match = text.match(pattern);
if (!match) continue;
const num = parseInt(match[1], 10);
// 4-digit: direct year
if (num >= 1950 && num <= currentYear) return num;
// 1-2 digit: years ago → subtract from currentYear
if (num >= 1 && num <= 80) return currentYear - num;
}
return null;
}
// ─── Test Corpus ───
export const FOUNDING_YEAR_TEST_CORPUS: ReadonlyArray<readonly [string, number | null]> = [
["2004년개원 이래 중국, 베트남 환자들이 찾아오고 있습니다", 2004],
["SINCE 2004", 2004],
["22주년 기념 이벤트", 2004], // 2026-22
["개원 15주년을 맞이하여", 2011], // 2026-15
["20년 전통의 성형외과", 2006], // 2026-20
["2005년 설립된 뷰성형외과", 2005],
["설립 2010년, 강남에 위치한", 2010],
["Established in 2003", 2003],
["2018년 오픈한 신규 클리닉", 2018],
["개원: 2015", 2015],
["아무 관련 없는 텍스트입니다", null],
["전화번호 02-1234-5678", null],
];
/**
* Self-test: validate all known patterns.
* Uses fixed currentYear=2026 for deterministic results.
*/
export function validateFoundingYearExtractor(): { pass: boolean; failures: string[] } {
const failures: string[] = [];
for (const [text, expected] of FOUNDING_YEAR_TEST_CORPUS) {
const result = extractFoundingYear(text, 2026);
if (result !== expected) {
failures.push(`"${text.slice(0, 30)}...": expected ${expected}, got ${result}`);
}
}
return { pass: failures.length === 0, failures };
}

View File

@ -0,0 +1,120 @@
/**
* URL Classification Guard Harness 1
*
* Separates URL classification into a testable pure function with
* an embedded test corpus. Prevents misclassification bugs like
* `/about/meet-our-doctors.php` being tagged as an "about" page.
*
* Priority: doctor > surgery > about (specific general)
*/
// ─── Pattern Definitions ───
const PAGE_PATTERNS = {
doctor: {
positive: [
"/doctor", "/doctors", "/team", "/staff", "/specialist", "/professor",
"/의료진", "/원장", "meet-our-doctor", "/physicians", "/surgeon",
"our-team", "our-doctors",
],
},
surgery: {
positive: [
"/surgery", "/service", "/procedure", "/treatment", "/시술", "/수술",
"/procedures", "/treatments", "/services", "/진료",
],
},
about: {
positive: [
"/about", "/intro", "/소개", "/greeting", "/인사말", "/history", "/연혁",
"/company", "/clinic-info",
],
negative: [
"/doctor", "/doctors", "/procedure", "/surgery", "/service",
"/treatment", "meet-our", "/team", "/staff", "/specialist",
"/의료진", "/원장", "/시술", "/수술", "/진료",
"our-team", "our-doctors",
],
},
} as const;
// ─── Classification Function ───
/**
* Classify a URL into one of: 'doctor' | 'surgery' | 'about' | null.
* Uses priority ordering (doctor > surgery > about) so that
* compound URLs like `/about/meet-our-doctors.php` correctly resolve
* to the more specific category.
*/
export function classifyPageUrl(url: string): "doctor" | "surgery" | "about" | null {
const lower = url.toLowerCase();
// Priority 1: Doctor pages
if (PAGE_PATTERNS.doctor.positive.some((p) => lower.includes(p))) {
return "doctor";
}
// Priority 2: Surgery/service pages
if (PAGE_PATTERNS.surgery.positive.some((p) => lower.includes(p))) {
return "surgery";
}
// Priority 3: About pages (with negative exclusion)
if (PAGE_PATTERNS.about.positive.some((p) => lower.includes(p))) {
if (PAGE_PATTERNS.about.negative.some((p) => lower.includes(p))) {
return null; // Contains about + doctor/surgery keyword → ambiguous, skip
}
return "about";
}
return null;
}
// ─── Test Corpus ───
/**
* Known failure cases and expected classifications.
* Used by validateClassifier() for regression prevention.
*/
export const CLASSIFICATION_TEST_CORPUS: ReadonlyArray<readonly [string, "doctor" | "surgery" | "about" | null]> = [
// Bug cases (historically misclassified)
["/about/meet-our-doctors.php", "doctor"], // 그랜드성형외과 bug
["/about/procedures.php", "surgery"], // 그랜드성형외과
// Standard doctor pages
["/doctors", "doctor"],
["/의료진", "doctor"],
["/team/professor-kim", "doctor"],
["/about/our-team", "doctor"],
// Standard surgery pages
["/surgery/rhinoplasty", "surgery"],
["/시술안내", "surgery"],
["/services/breast", "surgery"],
// Standard about pages
["/about/", "about"],
["/about/greeting.php", "about"],
["/about/intro", "about"],
["/about/history", "about"],
["/소개", "about"],
["/인사말", "about"],
// Null cases
["/gallery", null],
["/contact", null],
["/blog/post-123", null],
];
// ─── Self-Test ───
/**
* Run classification against the test corpus.
* Call during Edge Function cold-start for automatic regression detection.
*/
export function validateClassifier(): { pass: boolean; failures: string[] } {
const failures: string[] = [];
for (const [url, expected] of CLASSIFICATION_TEST_CORPUS) {
const result = classifyPageUrl(url);
if (result !== expected) {
failures.push(`${url}: expected '${expected}', got '${result}'`);
}
}
return { pass: failures.length === 0, failures };
}

View File

@ -7,6 +7,13 @@
*/
import { fetchWithRetry } from "./retry.ts";
import { classifyPageUrl, validateClassifier } from "./urlClassifier.ts";
// Run URL classifier self-test on cold-start
const classifierValidation = validateClassifier();
if (!classifierValidation.pass) {
console.warn(`[harness] URL classifier self-test FAILED:`, classifierValidation.failures);
}
const FIRECRAWL_BASE = "https://api.firecrawl.dev/v1";
@ -124,54 +131,28 @@ async function captureScreenshot(
/**
* Find relevant sub-pages from siteMap for additional screenshots.
* Delegates classification to urlClassifier.ts for testability.
*/
export function findRelevantPages(
siteMap: string[],
baseUrl: string,
_baseUrl: string,
): { doctorPage?: string; surgeryPage?: string; aboutPage?: string } {
const result: { doctorPage?: string; surgeryPage?: string; aboutPage?: string } = {};
// Phase 1: Classify each URL into one category (doctor > surgery > about priority)
const doctorUrls: string[] = [];
const surgeryUrls: string[] = [];
const aboutUrls: string[] = [];
for (const url of siteMap) {
const lower = url.toLowerCase();
const isDoctor = (
lower.includes('/doctor') || lower.includes('/doctors') ||
lower.includes('/team') || lower.includes('/staff') ||
lower.includes('/specialist') || lower.includes('/professor') ||
lower.includes('/의료진') || lower.includes('/원장') ||
lower.includes('meet-our-doctor') // 하이픈 패턴 (그랜드성형외과 등)
);
const isSurgery = (
lower.includes('/surgery') || lower.includes('/service') || lower.includes('/procedure') ||
lower.includes('/treatment') || lower.includes('/시술') || lower.includes('/수술')
);
const isAbout = (
lower.includes('/about') || lower.includes('/intro') || lower.includes('/소개') ||
lower.includes('/greeting') || lower.includes('/인사말') || lower.includes('/history') ||
lower.includes('/연혁')
);
// Priority: specific page type > generic /about
if (isDoctor) {
doctorUrls.push(url);
} else if (isSurgery) {
surgeryUrls.push(url);
} else if (isAbout) {
aboutUrls.push(url);
}
const category = classifyPageUrl(url);
if (category === "doctor") doctorUrls.push(url);
else if (category === "surgery") surgeryUrls.push(url);
else if (category === "about") aboutUrls.push(url);
}
// Phase 2: Pick best URL per category
result.doctorPage = doctorUrls[0];
result.surgeryPage = surgeryUrls[0];
result.aboutPage = aboutUrls[0];
return result;
return {
doctorPage: doctorUrls[0],
surgeryPage: surgeryUrls[0],
aboutPage: aboutUrls[0],
};
}
/**

View File

@ -0,0 +1,239 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
interface AdjustStrategyRequest {
clinicId: string;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const body = (await req.json()) as AdjustStrategyRequest;
if (!body.clinicId) throw new Error("clinicId is required");
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY");
if (!PERPLEXITY_API_KEY) throw new Error("PERPLEXITY_API_KEY not configured");
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseKey);
const clinicId = body.clinicId;
// ─── 1. Get channel deltas from weekly comparison view ───
const { data: deltas } = await supabase
.from("channel_weekly_delta")
.select("*")
.eq("clinic_id", clinicId);
// ─── 2. Get current active content plan ───
const { data: activePlan } = await supabase
.from("content_plans")
.select("id, channel_strategies, content_strategy, calendar")
.eq("clinic_id", clinicId)
.eq("is_active", true)
.order("created_at", { ascending: false })
.limit(1)
.single();
// ─── 3. Get latest analysis run for KPI targets ───
const { data: latestRun } = await supabase
.from("analysis_runs")
.select("id, report")
.eq("clinic_id", clinicId)
.eq("status", "complete")
.order("created_at", { ascending: false })
.limit(1)
.single();
const report = (latestRun?.report || {}) as Record<string, unknown>;
const kpiTargets = (report.kpiTargets || []) as Record<string, unknown>[];
// ─── 4. Calculate KPI progress ───
const kpiProgress = kpiTargets.map((kpi) => {
const current = parseFloat(String(kpi.current || "0").replace(/[^0-9.]/g, "")) || 0;
const target = parseFloat(String(kpi.target3Month || "0").replace(/[^0-9.]/g, "")) || 0;
const progress = target > 0 ? Math.min((current / target) * 100, 200) : 0;
return {
metric: kpi.metric,
current: kpi.current,
target: kpi.target3Month,
progress: Math.round(progress),
};
});
// ─── 5. Build channel delta summary ───
const deltaLines = (deltas || []).map((d: Record<string, unknown>) => {
const channel = d.channel as string;
const currentFollowers = d.current_followers as number || 0;
const prevFollowers = d.prev_followers as number || 0;
const change = currentFollowers - prevFollowers;
const changePercent = prevFollowers > 0
? ((change / prevFollowers) * 100).toFixed(1)
: "N/A";
return `${channel}: ${prevFollowers}${currentFollowers} (${change >= 0 ? "+" : ""}${changePercent}%)`;
});
const underperforming = (deltas || [])
.filter((d: Record<string, unknown>) => {
const current = d.current_followers as number || 0;
const prev = d.prev_followers as number || 0;
return prev > 0 && current <= prev;
})
.map((d: Record<string, unknown>) => d.channel as string);
// ─── 6. Call Perplexity for strategy adjustment ───
const userPrompt = `
.
## ( 1)
${deltaLines.join("\n") || "데이터 수집 중 (아직 비교 데이터 없음)"}
## KPI
${kpiProgress.map((k) => `${k.metric}: ${k.progress}% (${k.current} / ${k.target})`).join("\n") || "KPI 설정 전"}
##
${underperforming.join(", ") || "없음"}
##
${JSON.stringify(activePlan?.channel_strategies || [], null, 2).slice(0, 2000)}
## JSON
{
"strategySuggestions": [
{
"adjustmentType": "frequency_change|pillar_shift|channel_add|channel_pause|content_format_change|tone_shift",
"channel": "채널명",
"description": "변경 내용 설명",
"reason": "근거 (데이터 기반)",
"priority": "high|medium|low",
"beforeValue": "변경 전",
"afterValue": "변경 후"
}
],
"overallAssessment": "전체 전략 평가 (2-3문장)",
"topPerformingChannel": "가장 성과 좋은 채널",
"focusArea": "다음 2주 집중 영역"
}
`;
console.log(`[adjust-strategy] Calling Perplexity for clinic ${clinicId}...`);
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PERPLEXITY_API_KEY}`,
},
body: JSON.stringify({
model: PERPLEXITY_MODEL,
messages: [
{
role: "system",
content: "You are a Korean medical marketing performance analyst. Respond ONLY with valid JSON. Use Korean for text fields.",
},
{ role: "user", content: userPrompt },
],
temperature: 0.3,
}),
});
const aiData = await aiRes.json();
let responseText = aiData.choices?.[0]?.message?.content || "";
const jsonMatch = responseText.match(/```(?:json)?\n?([\s\S]*?)```/);
if (jsonMatch) responseText = jsonMatch[1];
if (!responseText.trim().startsWith("{")) {
const rawMatch = responseText.match(/\{[\s\S]*\}/);
if (rawMatch) responseText = rawMatch[0];
}
let adjustments: Record<string, unknown>;
try {
adjustments = JSON.parse(responseText);
} catch {
console.error("[adjust-strategy] JSON parse failed");
adjustments = {
strategySuggestions: [],
overallAssessment: "AI 분석 실패 — 수동 검토 필요",
topPerformingChannel: "N/A",
focusArea: "데이터 수집 대기",
};
}
// ─── 7. Store in performance_metrics ───
const channelDeltas: Record<string, unknown> = {};
for (const d of deltas || []) {
const ch = d.channel as string;
channelDeltas[ch] = {
currentFollowers: d.current_followers,
prevFollowers: d.prev_followers,
change: (d.current_followers as number || 0) - (d.prev_followers as number || 0),
};
}
const { data: perfMetric, error: perfErr } = await supabase
.from("performance_metrics")
.insert({
clinic_id: clinicId,
run_id: latestRun?.id || null,
prev_run_id: null,
channel_deltas: channelDeltas,
kpi_progress: kpiProgress,
top_performing_content: { channel: adjustments.topPerformingChannel },
underperforming_channels: underperforming,
strategy_suggestions: adjustments.strategySuggestions || [],
})
.select("id")
.single();
if (perfErr) console.error("[adjust-strategy] performance_metrics insert error:", perfErr);
// ─── 8. Store individual strategy_adjustments ───
const suggestions = (adjustments.strategySuggestions || []) as Record<string, unknown>[];
for (const s of suggestions) {
await supabase.from("strategy_adjustments").insert({
clinic_id: clinicId,
plan_id: activePlan?.id || null,
performance_id: perfMetric?.id || null,
adjustment_type: s.adjustmentType || "other",
description: s.description || "",
reason: s.reason || "",
before_value: { value: s.beforeValue },
after_value: { value: s.afterValue },
});
}
console.log(`[adjust-strategy] Created ${suggestions.length} adjustments for clinic ${clinicId}`);
return new Response(
JSON.stringify({
success: true,
performanceMetricId: perfMetric?.id || null,
adjustments,
channelDeltas,
kpiProgress,
underperforming,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("[adjust-strategy] Error:", error);
return new Response(
JSON.stringify({ success: false, error: error.message }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});

View File

@ -5,6 +5,21 @@ import { PERPLEXITY_MODEL } from "../_shared/config.ts";
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
import { searchGooglePlace } from "../_shared/googlePlaces.ts";
import { extractFoundingYear, validateFoundingYearExtractor } from "../_shared/foundingYearExtractor.ts";
import { validateClassifier } from "../_shared/urlClassifier.ts";
import { validateDataQuality } from "../_shared/dataQuality.ts";
// ─── Harness Self-Tests (cold-start) ───
const harnessResults = {
classifier: validateClassifier(),
foundingYear: validateFoundingYearExtractor(),
dataQuality: validateDataQuality(),
};
for (const [name, result] of Object.entries(harnessResults)) {
if (!result.pass) {
console.warn(`[harness] ${name} self-test FAILED:`, result.failures);
}
}
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@ -305,7 +320,9 @@ Deno.serve(async (req) => {
channelData.gangnamUnni = {
name: hospital.hospitalName,
rawRating: hospital.rating,
rating: typeof hospital.rating === 'number' && hospital.rating > 0 && hospital.rating <= 5 ? hospital.rating * 2 : hospital.rating,
// 강남언니 rating is always /10 (enforced in Firecrawl prompt) — trust the value directly.
// Do NOT multiply by 2: a score of 4.8 means 4.8/10, not 9.6/10.
rating: typeof hospital.rating === 'number' && hospital.rating > 0 ? hospital.rating : null,
ratingScale: "/10",
totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10),
procedures: hospital.procedures || [], address: hospital.address,
@ -475,6 +492,20 @@ Deno.serve(async (req) => {
}));
}
// ─── 9. Founding Year Text Fallback (Harness 2) ───
// If Vision didn't find foundingYear, try regex extraction from scraped text
if (!channelData.visionAnalysis?.foundingYear) {
const htmlText = row.scrape_data?.markdown || row.scrape_data?.text || "";
if (htmlText) {
const textYear = extractFoundingYear(htmlText);
if (textYear) {
channelData.visionAnalysis = channelData.visionAnalysis || {};
channelData.visionAnalysis.foundingYear = String(textYear);
console.log(`[harness] Founding year extracted from text fallback: ${textYear}`);
}
}
}
// ─── Execute all channel tasks ───
const taskResults = await Promise.all(channelTasks);

View File

@ -12,6 +12,78 @@ const corsHeaders = {
const APIFY_BASE = "https://api.apify.com/v2";
// ─── Registry Helper: Convert registry row → VerifiedChannels ───
function extractHandleFromUrl(url: string, platform: string): string | null {
if (!url) return null;
try {
if (platform === 'instagram') {
const m = url.match(/instagram\.com\/([a-zA-Z0-9._]+)/);
return m ? m[1] : null;
}
if (platform === 'youtube') {
const m = url.match(/youtube\.com\/(?:@([a-zA-Z0-9._-]+)|channel\/(UC[a-zA-Z0-9_-]+)|c\/([a-zA-Z0-9._-]+)|user\/([a-zA-Z0-9._-]+))/);
if (m) return m[1] ? `@${m[1]}` : m[2] || m[3] || m[4] || null;
return null;
}
if (platform === 'facebook') {
const m = url.match(/facebook\.com\/([a-zA-Z0-9._-]+)/);
return m ? m[1] : null;
}
if (platform === 'naverBlog') {
const m = url.match(/blog\.naver\.com\/([a-zA-Z0-9_-]+)/);
return m ? m[1] : null;
}
if (platform === 'tiktok') {
const m = url.match(/tiktok\.com\/@([a-zA-Z0-9._-]+)/);
return m ? m[1] : null;
}
} catch { /* ignore */ }
return null;
}
interface RegistryRow {
name: string;
domain: string;
website_url: string;
brand_group?: string;
district?: string;
branches?: string;
website_en?: string;
youtube_url?: string;
instagram_url?: string;
instagram_en_url?: string;
facebook_url?: string;
tiktok_url?: string;
naver_blog_url?: string;
naver_place_url?: string;
gangnam_unni_url?: string;
google_maps_url?: string;
founded_year?: number;
}
function registryToVerifiedChannels(reg: RegistryRow): import("../_shared/verifyHandles.ts").VerifiedChannels {
const igHandles: import("../_shared/verifyHandles.ts").VerifiedChannel[] = [];
const igHandle = extractHandleFromUrl(reg.instagram_url || '', 'instagram');
if (igHandle) igHandles.push({ handle: igHandle, verified: true, url: reg.instagram_url! });
const igEnHandle = extractHandleFromUrl(reg.instagram_en_url || '', 'instagram');
if (igEnHandle) igHandles.push({ handle: igEnHandle, verified: true, url: reg.instagram_en_url! });
const ytHandle = extractHandleFromUrl(reg.youtube_url || '', 'youtube');
const fbHandle = extractHandleFromUrl(reg.facebook_url || '', 'facebook');
const blogHandle = extractHandleFromUrl(reg.naver_blog_url || '', 'naverBlog');
const ttHandle = extractHandleFromUrl(reg.tiktok_url || '', 'tiktok');
return {
instagram: igHandles,
youtube: ytHandle ? { handle: ytHandle, verified: true, url: reg.youtube_url! } : null,
facebook: fbHandle ? { handle: fbHandle, verified: true, url: reg.facebook_url! } : null,
naverBlog: blogHandle ? { handle: blogHandle, verified: true, url: reg.naver_blog_url! } : null,
gangnamUnni: reg.gangnam_unni_url ? { handle: reg.gangnam_unni_url, verified: true, url: reg.gangnam_unni_url } : null,
tiktok: ttHandle ? { handle: ttHandle, verified: true, url: reg.tiktok_url! } : null,
};
}
interface DiscoverRequest {
url: string;
clinicName?: string;
@ -84,6 +156,153 @@ Deno.serve(async (req) => {
);
}
// ═══════════════════════════════════════════
// REGISTRY CHECK: Pre-verified clinic DB lookup
// If domain is registered, skip all API discovery
// ═══════════════════════════════════════════
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseKey);
let registryDomain: string;
try {
registryDomain = new URL(url).hostname.replace(/^www\./, '');
} catch {
registryDomain = '';
}
if (registryDomain) {
const { data: registered } = await supabase
.from("clinic_registry")
.select("*")
.eq("domain", registryDomain)
.eq("is_active", true)
.maybeSingle();
if (registered) {
console.log(`[registry] Hit: ${registered.name} (${registryDomain})`);
const verified = registryToVerifiedChannels(registered as RegistryRow);
const scrapeDataFromRegistry = {
clinic: { clinicName: registered.name },
branding: {},
siteLinks: [],
siteMap: [],
sourceUrl: url,
scrapedAt: new Date().toISOString(),
source: "registry",
registryData: {
district: registered.district,
branches: registered.branches,
brandGroup: registered.brand_group,
foundedYear: registered.founded_year,
websiteEn: registered.website_en,
naverPlaceUrl: registered.naver_place_url,
googleMapsUrl: registered.google_maps_url,
},
};
// Legacy: marketing_reports
const { data: saved, error: saveError } = await supabase
.from("marketing_reports")
.insert({
url, clinic_name: registered.name,
status: "discovered",
verified_channels: verified,
scrape_data: scrapeDataFromRegistry,
report: {},
pipeline_started_at: new Date().toISOString(),
})
.select("id")
.single();
if (saveError) throw new Error(`DB save failed: ${saveError.message}`);
// V3: clinics + analysis_runs
let clinicId: string | null = null;
let runId: string | null = null;
try {
const { data: clinicRow } = await supabase
.from("clinics")
.upsert({
url,
name: registered.name,
name_en: null,
domain: registryDomain,
address: null,
phone: null,
services: [],
branding: {},
social_handles: {
instagram: verified.instagram?.map((v: Record<string, unknown>) => v.handle) || [],
youtube: (verified.youtube as Record<string, unknown>)?.handle || null,
facebook: (verified.facebook as Record<string, unknown>)?.handle || null,
naverBlog: (verified.naverBlog as Record<string, unknown>)?.handle || null,
},
verified_channels: verified,
last_analyzed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}, { onConflict: 'url' })
.select("id")
.single();
clinicId = clinicRow?.id || null;
if (clinicId) {
const { data: runRow } = await supabase
.from("analysis_runs")
.insert({
clinic_id: clinicId,
status: "discovering",
scrape_data: scrapeDataFromRegistry,
discovered_channels: verified,
trigger: "manual",
pipeline_started_at: new Date().toISOString(),
})
.select("id")
.single();
runId = runRow?.id || null;
}
} catch (e) {
console.error("V3 dual-write error (registry):", e);
}
return new Response(
JSON.stringify({
success: true, reportId: saved.id,
clinicId, runId,
clinicName: registered.name,
verifiedChannels: verified,
address: "",
services: [],
scrapeData: scrapeDataFromRegistry,
source: "registry",
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
}
// ═══════════════════════════════════════════
// NOT REGISTERED: Return error for unregistered domains
// (Registry-only mode — no API fallback)
// ═══════════════════════════════════════════
console.log(`[registry] Miss: ${registryDomain} — returning CLINIC_NOT_REGISTERED`);
return new Response(
JSON.stringify({
success: false,
error: "CLINIC_NOT_REGISTERED",
message: "현재 지원하지 않는 병원입니다. 등록된 병원만 분석 가능합니다.",
domain: registryDomain,
}),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
// ═══════════════════════════════════════════
// LEGACY FALLBACK: Full API discovery (disabled — registry-only mode)
// Kept for reference; unreachable in production
// ═══════════════════════════════════════════
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY") || "";
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY") || "";
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY") || "";
@ -478,13 +697,9 @@ Deno.serve(async (req) => {
);
// ═══════════════════════════════════════════
// Save to DB
// Save to DB (supabase client reused from registry check above)
// ═══════════════════════════════════════════
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseKey);
const scrapeDataFull = {
clinic, branding: brandData.data?.json || {},
siteLinks, siteMap: mapData.links || [],

View File

@ -0,0 +1,456 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
interface ContentPlanRequest {
reportId?: string;
clinicId?: string;
runId?: string;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const body = (await req.json()) as ContentPlanRequest;
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY");
if (!PERPLEXITY_API_KEY) throw new Error("PERPLEXITY_API_KEY not configured");
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseKey);
// ─── 1. Load report data ───
let report: Record<string, unknown> = {};
let clinicId = body.clinicId;
let runId = body.runId;
let clinicName = "";
let services: string[] = [];
// Try V3 analysis_runs first
if (runId) {
const { data: run } = await supabase
.from("analysis_runs")
.select("report, clinic_id")
.eq("id", runId)
.single();
if (run?.report) {
report = run.report as Record<string, unknown>;
clinicId = clinicId || run.clinic_id;
}
}
// Fallback to marketing_reports
if (Object.keys(report).length === 0 && body.reportId) {
const { data: row } = await supabase
.from("marketing_reports")
.select("report, clinic_name")
.eq("id", body.reportId)
.single();
if (row?.report) {
report = row.report as Record<string, unknown>;
clinicName = row.clinic_name || "";
}
}
if (Object.keys(report).length === 0) {
throw new Error("No report data found. Provide reportId or runId.");
}
// Resolve clinic info
if (clinicId) {
const { data: clinic } = await supabase
.from("clinics")
.select("name, services")
.eq("id", clinicId)
.single();
if (clinic) {
clinicName = clinic.name || clinicName;
services = clinic.services || [];
}
}
const clinicInfo = report.clinicInfo as Record<string, unknown> | undefined;
if (!clinicName) clinicName = (clinicInfo?.name as string) || "병원";
if (services.length === 0) services = (clinicInfo?.services as string[]) || [];
// ─── 2. Build enriched channel summary for AI prompt ───
const channelAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
const kpiTargets = report.kpiTargets as Record<string, unknown>[] | undefined;
const recommendations = report.recommendations as Record<string, unknown>[] | undefined;
const competitors = report.competitors as Record<string, unknown>[] | undefined;
const reportKeywords = report.keywords as { primary?: Record<string, unknown>[]; longTail?: Record<string, unknown>[] } | undefined;
// Channel-specific tone matrix (Audit C3)
const TONE_MAP: Record<string, string> = {
youtube: "교육적·권위(Long) / 캐주얼·후킹(Shorts) / 친근·대화(Live)",
instagram: "트렌디·공감(Reel) / 감성적·프리미엄(Feed) / 친근·일상(Stories)",
naverBlog: "정보성·SEO 최적화",
gangnamUnni: "전문·응대·신뢰",
facebook: "타겟팅·CTA 중심",
tiktok: "밈·교육·MZ세대",
};
const channelLines: string[] = [];
if (channelAnalysis) {
for (const [key, ch] of Object.entries(channelAnalysis)) {
const score = (ch.score as number) ?? 0;
const status = (ch.status as string) || "unknown";
const followers = ch.followers || ch.subscribers || ch.reviews || "";
const tone = TONE_MAP[key] || "전문적·친근한";
channelLines.push(`${key}: 점수 ${score}/100, 상태: ${status}${followers ? `, 규모: ${followers}` : ""}, 톤: ${tone}`);
}
}
const kpiLines = (kpiTargets || []).slice(0, 5).map(
(k) => `${k.metric}: 현재 ${k.current} → 3개월 목표 ${k.target3Month}`
);
const recLines = (recommendations || []).slice(0, 5).map(
(r) => `[${r.priority}] ${r.title}`
);
// Competitor summary (Audit: inject competitor data)
const competitorLines = (competitors || []).slice(0, 3).map(
(c) => `${c.name}: 강점=${(c.strengths as string[] || []).join(",")} 약점=${(c.weaknesses as string[] || []).join(",")}`
);
// Keyword summary (Audit C5: connect keywords to topics)
const keywordLines = (reportKeywords?.primary || []).slice(0, 10).map(
(k) => `${k.keyword} (월 ${k.monthlySearches || "?"}회 검색)`
);
// ─── 3. Call Perplexity sonar (enhanced prompt) ───
const userPrompt = `
${clinicName} .
##
${services.join(", ") || "성형외과 전반"}
## ( )
${channelLines.join("\n")}
## (·YouTube SEO )
${keywordLines.join("\n") || "키워드 데이터 없음"}
##
${competitorLines.join("\n") || "경쟁사 데이터 없음"}
## KPI
${kpiLines.join("\n") || "설정 전"}
##
${recLines.join("\n") || "없음"}
## ( )
- Week 1: (YouTube Shorts, TikTok, Instagram Reels )
- Week 2: (Long-form, SEO, Carousel )
- Week 3: ( , Before/After, )
- Week 4: (Facebook , Instagram DM CTA, )
## ( )
YouTube (Shorts + Long + Live + Community), Instagram (Reel + Carousel + Feed + Stories), ( + + ), TikTok (), (SEO + ), Facebook ( + )
## (5 )
1. ·, 2. ·, 3. , 4. ·, 5. ·
## ( JSON )
{
"channelStrategies": [
{
"channelId": "youtube|instagram|naverBlog|facebook|tiktok",
"channelName": "한국어 채널명",
"icon": "youtube|instagram|blog|facebook|video",
"currentStatus": "현재 상태 요약",
"targetGoal": "3개월 목표",
"contentTypes": ["콘텐츠 유형 1", "콘텐츠 유형 2"],
"postingFrequency": "주 N회",
"tone": "톤앤매너",
"formatGuidelines": ["가이드라인 1"],
"priority": "P0|P1|P2"
}
],
"contentPillars": [
{
"title": "필러 제목",
"description": "설명",
"relatedUSP": "연관 USP",
"exampleTopics": ["주제 1", "주제 2", "주제 3"],
"color": "#hex"
}
],
"calendar": {
"weeks": [
{
"weekNumber": 1,
"label": "Week 1: 주제",
"entries": [
{
"id": "uuid",
"dayOfWeek": 0,
"channel": "채널명",
"channelIcon": "아이콘",
"contentType": "video|blog|social|ad",
"title": "콘텐츠 제목",
"description": "제작 가이드",
"pillar": "필러 제목",
"status": "draft",
"aiPromptSeed": "AI 재생성용 컨텍스트"
}
]
}
],
"monthlySummary": [
{"type": "video", "label": "영상", "count": 0, "color": "#6C5CE7"},
{"type": "blog", "label": "블로그", "count": 0, "color": "#00B894"},
{"type": "social", "label": "소셜", "count": 0, "color": "#E17055"},
{"type": "ad", "label": "광고", "count": 0, "color": "#FDCB6E"}
]
},
"postingSchedule": {
"bestTimes": {"youtube": "오후 6-8시", "instagram": "오후 12-1시, 오후 7-9시"},
"rationale": "근거 설명"
}
}
4 , 8-12 .
id UUID .
(: "코성형 전후 비교 리얼 후기" , "콘텐츠 #1" ).
`;
console.log(`[content-plan] Calling Perplexity for ${clinicName}...`);
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PERPLEXITY_API_KEY}`,
},
body: JSON.stringify({
model: PERPLEXITY_MODEL,
messages: [
{
role: "system",
content:
"You are a Korean medical marketing content strategist. Respond ONLY with valid JSON, no markdown code blocks. Use Korean for all text fields. Generate UUID v4 for each calendar entry id.",
},
{ role: "user", content: userPrompt },
],
temperature: 0.3,
}),
});
const aiData = await aiRes.json();
let responseText = aiData.choices?.[0]?.message?.content || "";
// Strip markdown code blocks if present
const jsonMatch = responseText.match(/```(?:json)?\n?([\s\S]*?)```/);
if (jsonMatch) responseText = jsonMatch[1];
// Try raw JSON match as fallback
if (!responseText.trim().startsWith("{")) {
const rawMatch = responseText.match(/\{[\s\S]*\}/);
if (rawMatch) responseText = rawMatch[0];
}
let contentPlan: Record<string, unknown>;
try {
contentPlan = JSON.parse(responseText);
} catch {
console.error("[content-plan] JSON parse failed, using fallback");
contentPlan = buildFallbackPlan(channelAnalysis, services, clinicName);
}
// ─── 4. Store in content_plans table ───
const resolvedClinicId = clinicId || null;
const resolvedRunId = runId || null;
// Deactivate previous active plans for this clinic
if (resolvedClinicId) {
await supabase
.from("content_plans")
.update({ is_active: false })
.eq("clinic_id", resolvedClinicId)
.eq("is_active", true);
}
const { data: plan, error: insertErr } = await supabase
.from("content_plans")
.insert({
clinic_id: resolvedClinicId,
run_id: resolvedRunId,
channel_strategies: contentPlan.channelStrategies || [],
content_strategy: {
pillars: contentPlan.contentPillars || [],
typeMatrix: [],
workflow: [],
repurposingSource: "",
repurposingOutputs: [],
},
calendar: contentPlan.calendar || { weeks: [], monthlySummary: [] },
brand_guide: {},
is_active: true,
})
.select("id")
.single();
if (insertErr) {
console.error("[content-plan] DB insert error:", insertErr);
}
console.log(`[content-plan] Plan created: ${plan?.id} for ${clinicName}`);
return new Response(
JSON.stringify({
success: true,
planId: plan?.id || null,
contentPlan,
clinicName,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("[content-plan] Error:", error);
return new Response(
JSON.stringify({ success: false, error: error.message }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});
// ─── Fallback: Deterministic plan when AI fails ───
function buildFallbackPlan(
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
services: string[],
clinicName: string,
): Record<string, unknown> {
const channels = channelAnalysis ? Object.keys(channelAnalysis) : ["youtube", "instagram"];
const svcList = services.length > 0 ? services : ["성형외과 시술"];
const PILLAR_COLORS = ["#6C5CE7", "#E17055", "#00B894", "#FDCB6E"];
const pillars = [
{ title: "전문성 · 신뢰", description: "의료진 소개, 수술 과정, 인증 콘텐츠", relatedUSP: "전문 의료진", exampleTopics: svcList.slice(0, 3).map((s) => `${s} 시술 과정 소개`), color: PILLAR_COLORS[0] },
{ title: "비포 · 애프터", description: "실제 환자 사례, 수술 전후 비교", relatedUSP: "검증된 결과", exampleTopics: svcList.slice(0, 3).map((s) => `${s} 전후 비교`), color: PILLAR_COLORS[1] },
{ title: "환자 후기", description: "실제 환자 인터뷰, 후기 콘텐츠", relatedUSP: "환자 만족도", exampleTopics: ["환자 인터뷰 영상", "리뷰 하이라이트", "회복 일기"], color: PILLAR_COLORS[2] },
{ title: "트렌드 · 교육", description: "시술 트렌드, Q&A, 의학 정보", relatedUSP: "최신 트렌드", exampleTopics: ["자주 묻는 질문 Q&A", "시술별 비용 가이드", "최신 트렌드"], color: PILLAR_COLORS[3] },
];
const WEEK_THEMES = ["브랜드 정비", "콘텐츠 엔진 가동", "소셜 증거 강화", "전환 최적화"];
const weeks = WEEK_THEMES.map((theme, wi) => {
const entries = [];
const pillar = pillars[wi % pillars.length];
let entryIdx = 0;
if (channels.includes("youtube")) {
for (let d = 0; d < 3; d++) {
entries.push({
id: crypto.randomUUID(),
dayOfWeek: d * 2,
channel: "YouTube",
channelIcon: "youtube",
contentType: "video",
title: `Shorts: ${svcList[entryIdx % svcList.length]} ${pillar.title}`,
description: `${pillar.description} 관련 숏폼 콘텐츠`,
pillar: pillar.title,
status: "draft",
aiPromptSeed: `${clinicName} ${svcList[entryIdx % svcList.length]} ${pillar.title} shorts`,
});
entryIdx++;
}
}
if (channels.includes("instagram")) {
entries.push({
id: crypto.randomUUID(),
dayOfWeek: 1,
channel: "Instagram",
channelIcon: "instagram",
contentType: "social",
title: `Carousel: ${svcList[0]} ${pillar.exampleTopics[0] || ""}`,
description: "인스타그램 카드뉴스",
pillar: pillar.title,
status: "draft",
aiPromptSeed: `${clinicName} instagram carousel ${pillar.title}`,
});
entries.push({
id: crypto.randomUUID(),
dayOfWeek: 3,
channel: "Instagram",
channelIcon: "instagram",
contentType: "video",
title: `Reel: ${svcList[0]} ${pillar.title}`,
description: "인스타그램 릴스",
pillar: pillar.title,
status: "draft",
aiPromptSeed: `${clinicName} instagram reel ${pillar.title}`,
});
}
if (channels.includes("naverBlog")) {
entries.push({
id: crypto.randomUUID(),
dayOfWeek: 2,
channel: "네이버 블로그",
channelIcon: "blog",
contentType: "blog",
title: `${svcList[entryIdx % svcList.length]} ${pillar.exampleTopics[0] || ""}`,
description: "SEO 최적화 블로그 포스트",
pillar: pillar.title,
status: "draft",
aiPromptSeed: `${clinicName} naver blog ${pillar.title}`,
});
}
entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
return {
weekNumber: wi + 1,
label: `Week ${wi + 1}: ${theme}`,
entries,
};
});
const allEntries = weeks.flatMap((w) => w.entries);
return {
channelStrategies: channels.map((ch) => ({
channelId: ch,
channelName: ch,
icon: ch === "youtube" ? "youtube" : ch === "instagram" ? "instagram" : "globe",
currentStatus: "분석 완료",
targetGoal: "콘텐츠 전략 수립",
contentTypes: ["숏폼", "피드"],
postingFrequency: "주 2-3회",
tone: "전문적 · 친근한",
formatGuidelines: [],
priority: "P1",
})),
contentPillars: pillars,
calendar: {
weeks,
monthlySummary: [
{ type: "video", label: "영상", count: allEntries.filter((e) => e.contentType === "video").length, color: "#6C5CE7" },
{ type: "blog", label: "블로그", count: allEntries.filter((e) => e.contentType === "blog").length, color: "#00B894" },
{ type: "social", label: "소셜", count: allEntries.filter((e) => e.contentType === "social").length, color: "#E17055" },
{ type: "ad", label: "광고", count: allEntries.filter((e) => e.contentType === "ad").length, color: "#FDCB6E" },
],
},
postingSchedule: {
bestTimes: { youtube: "오후 6-8시", instagram: "오후 12-1시, 오후 7-9시" },
rationale: "한국 직장인/성형 관심층 주요 활동 시간대 기반",
},
};
}

View File

@ -2,6 +2,9 @@ import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { normalizeInstagramHandle } from "../_shared/normalizeHandles.ts";
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
import { isMissingValue, validateReportQuality } from "../_shared/dataQuality.ts";
import { extractFoundingYear } from "../_shared/foundingYearExtractor.ts";
import { fetchWithRetry } from "../_shared/retry.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@ -110,7 +113,9 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
}
`;
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
// Use fetchWithRetry to handle transient Perplexity failures (429, 502, 503).
// 2 retries with 5s/15s backoff — total budget ~3min before giving up.
const aiRes = await fetchWithRetry("https://api.perplexity.ai/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
body: JSON.stringify({
@ -121,7 +126,7 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
],
temperature: 0.3,
}),
});
}, { maxRetries: 2, backoffMs: [5000, 15000], timeoutMs: 90000, label: "perplexity-report" });
const aiData = await aiRes.json();
let reportText = aiData.choices?.[0]?.message?.content || "";
@ -134,19 +139,15 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
// ─── Post-processing: Inject Vision Analysis data directly ───
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
const vision = channelData.visionAnalysis as Record<string, unknown> | undefined;
const isEstablishedMissing = (val: unknown): boolean => {
if (!val) return true;
const s = String(val).trim().toLowerCase();
return s === '' || s === '데이터 없음' || s === '데이터없음' || s === 'n/a' || s === '확인불가' || s === '미확인' || s === '정보없음' || s === '정보 없음';
};
// Use Harness 3's isMissingValue() — covers all known LLM "no data" variants
if (vision) {
// Force-inject foundingYear if Vision found it but Perplexity didn't
if (vision.foundingYear && isEstablishedMissing(report.clinicInfo?.established)) {
if (vision.foundingYear && isMissingValue(report.clinicInfo?.established)) {
report.clinicInfo = report.clinicInfo || {};
report.clinicInfo.established = String(vision.foundingYear);
console.log(`[report] Injected foundingYear from Vision: ${vision.foundingYear}`);
}
if (vision.operationYears && isEstablishedMissing(report.clinicInfo?.established)) {
if (vision.operationYears && isMissingValue(report.clinicInfo?.established)) {
const year = new Date().getFullYear() - Number(vision.operationYears);
report.clinicInfo = report.clinicInfo || {};
report.clinicInfo.established = String(year);
@ -289,10 +290,39 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
facebook: verified.facebook?.verified ? verified.facebook.handle : null,
};
// ─── Harness 2 (tertiary): Last-resort founding year from all channel text ───
if (isMissingValue(report.clinicInfo?.established)) {
const allTexts = [
channelData.scrapeMarkdown,
channelData.naverBlog?.description,
channelData.gangnamUnni?.description,
JSON.stringify(channelData.visionPerPage || {}),
].filter(Boolean).join(" ");
const lastResortYear = extractFoundingYear(allTexts);
if (lastResortYear) {
report.clinicInfo = report.clinicInfo || {};
report.clinicInfo.established = String(lastResortYear);
console.log(`[harness] Founding year from tertiary text scan: ${lastResortYear}`);
}
}
// ─── Harness 3: Report quality validation ───
const qualityReport = validateReportQuality(report);
report.dataQualityScore = qualityReport.score;
report.dataQualityDetails = {
missingCritical: qualityReport.missingCritical,
missingImportant: qualityReport.missingImportant,
warnings: qualityReport.warnings,
};
if (qualityReport.score < 60) {
console.warn(`[harness] Low report quality (${qualityReport.score}/100):`, qualityReport.warnings);
}
// Legacy: marketing_reports
await supabase.from("marketing_reports").update({
report,
status: "complete",
data_quality_score: qualityReport.score,
pipeline_completed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).eq("id", body.reportId);

View File

@ -0,0 +1,61 @@
-- ═══════════════════════════════════════════════════════════════
-- Clinic Registry: Pre-verified channel database
-- ═══════════════════════════════════════════════════════════════
-- 사전 검증된 병원 채널 DB. discover-channels에서 API 검색 전에
-- domain으로 조회하여 검증된 채널을 즉시 반환.
-- 미등록 도메인은 기존 API 기반 discovery fallback 사용.
CREATE TABLE IF NOT EXISTS clinic_registry (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
-- 병원 식별
name TEXT NOT NULL, -- 한국어 병원명 (ex: "아이디병원")
name_aliases TEXT[] DEFAULT '{}', -- 별칭 (ex: {"아이디성형외과", "ID Hospital"})
domain TEXT UNIQUE NOT NULL, -- 도메인 (www 제외, lookup key)
website_url TEXT NOT NULL, -- 전체 URL
-- 기본 정보
brand_group TEXT, -- 그룹/카테고리 (ex: "프리미엄/하이타깃 후보")
district TEXT, -- 지역구 (ex: "강남", "서초")
branches TEXT, -- 분점 정보
founded_year INT, -- 개원 연도
-- 영문 사이트
website_en TEXT,
-- 소셜 채널 (사전 검증 완료)
youtube_url TEXT,
instagram_url TEXT,
instagram_en_url TEXT,
facebook_url TEXT,
tiktok_url TEXT,
naver_blog_url TEXT,
naver_place_url TEXT,
gangnam_unni_url TEXT,
google_maps_url TEXT,
-- 메타
verified_by TEXT DEFAULT 'scrape', -- 'manual' | 'scrape' | 'llm'
verified_at TIMESTAMPTZ DEFAULT now(),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 인덱스: domain lookup (primary query path)
CREATE INDEX IF NOT EXISTS idx_clinic_registry_domain ON clinic_registry (domain);
-- GIN 인덱스: 별칭 검색
CREATE INDEX IF NOT EXISTS idx_clinic_registry_aliases ON clinic_registry USING gin (name_aliases);
-- 지역 필터
CREATE INDEX IF NOT EXISTS idx_clinic_registry_district ON clinic_registry (district);
-- RLS (서비스 키로만 접근, 프론트엔드에서는 Edge Function 통해서만)
ALTER TABLE clinic_registry ENABLE ROW LEVEL SECURITY;
-- 서비스 역할만 CRUD 가능
CREATE POLICY "service_role_full_access" ON clinic_registry
FOR ALL USING (auth.role() = 'service_role');
-- anon/authenticated는 읽기만
CREATE POLICY "public_read_registry" ON clinic_registry
FOR SELECT USING (true);