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
|
|
@ -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,,,,
|
||||||
|
|
|
@ -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,,,,,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
@ -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,,,,,,,,,,,,,,,,,,,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
@ -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 추측 무시
|
||||||
|
|
@ -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개** | |
|
||||||
|
|
@ -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 프롬프트에 경쟁사·키워드·톤 매트릭스 주입 |
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 마킹
|
||||||
|
|
@ -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 데이터를 리포트 프롬프트에 포함 (실제 데이터 기반)
|
||||||
|
```
|
||||||
|
|
@ -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곳의 검증된 마스터 데이터를 30–60분 내에 구축한다
|
||||||
|
|
||||||
|
**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 수집 (사용자 직접, 30–60min)
|
||||||
|
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.*
|
||||||
|
After Width: | Height: | Size: 751 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 1015 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
|
@ -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);
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
VideoFilled,
|
VideoFilled,
|
||||||
|
|
@ -6,10 +7,13 @@ import {
|
||||||
MegaphoneFilled,
|
MegaphoneFilled,
|
||||||
} from '../icons/FilledIcons';
|
} from '../icons/FilledIcons';
|
||||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||||
|
import EditEntryModal from './EditEntryModal';
|
||||||
import type { CalendarData, ContentCategory, CalendarEntry } from '../../types/plan';
|
import type { CalendarData, ContentCategory, CalendarEntry } from '../../types/plan';
|
||||||
|
|
||||||
interface ContentCalendarProps {
|
interface ContentCalendarProps {
|
||||||
data: CalendarData;
|
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 }> = {
|
const contentTypeColors: Record<ContentCategory, { bg: string; text: string; entry: string; border: string; shadow: string }> = {
|
||||||
|
|
@ -44,9 +48,118 @@ const channelEmojiMap: Record<string, string> = {
|
||||||
video: '▷',
|
video: '▷',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusDotColors: Record<string, string> = {
|
||||||
|
draft: 'bg-slate-300',
|
||||||
|
approved: 'bg-purple-400',
|
||||||
|
published: 'bg-green-400',
|
||||||
|
};
|
||||||
|
|
||||||
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
|
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 (
|
return (
|
||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
id="content-calendar"
|
id="content-calendar"
|
||||||
|
|
@ -54,18 +167,57 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
|
||||||
subtitle="콘텐츠 캘린더 (월간)"
|
subtitle="콘텐츠 캘린더 (월간)"
|
||||||
dark
|
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 */}
|
{/* Monthly Summary */}
|
||||||
<div className="flex flex-wrap gap-4 mb-8">
|
<div className="flex flex-wrap gap-4 mb-8">
|
||||||
{data.monthlySummary.map((item) => {
|
{data.monthlySummary.map((item) => {
|
||||||
const colors = contentTypeColors[item.type];
|
const colors = contentTypeColors[item.type];
|
||||||
|
const isActive = filterType === item.type;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.type}
|
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 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
|
onClick={() => toggleFilter(item.type)}
|
||||||
|
data-no-print={undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
|
|
@ -80,91 +232,95 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weekly Calendar Grid */}
|
{/* Calendar Content */}
|
||||||
{data.weeks.map((week, weekIdx) => {
|
{viewMode === 'monthly' ? (
|
||||||
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
|
renderMonthlyView()
|
||||||
for (const entry of week.entries) {
|
) : (
|
||||||
const dayIndex = entry.dayOfWeek;
|
/* Weekly Calendar Grid */
|
||||||
if (dayIndex >= 0 && dayIndex <= 6) {
|
weeks.map((week, weekIdx) => {
|
||||||
dayCells[dayIndex].push(entry);
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={week.weekNumber}
|
key={week.weekNumber}
|
||||||
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
|
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.4, delay: weekIdx * 0.1 }}
|
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>
|
||||||
<p className="text-sm font-bold text-[#0A1128] mb-3">{week.label}</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-2">
|
<div className="grid grid-cols-7 gap-2">
|
||||||
{/* Day headers */}
|
{dayHeaders.map((day) => (
|
||||||
{dayHeaders.map((day) => (
|
<div
|
||||||
<div
|
key={day}
|
||||||
key={day}
|
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
|
||||||
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
|
>
|
||||||
>
|
{day}
|
||||||
{day}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Day cells */}
|
{dayCells.map((entries, dayIdx) => (
|
||||||
{dayCells.map((entries, dayIdx) => (
|
<div
|
||||||
<div
|
key={dayIdx}
|
||||||
key={dayIdx}
|
className={`min-h-[80px] rounded-xl p-1.5 ${
|
||||||
className={`min-h-[80px] rounded-xl p-1.5 ${
|
entries.length > 0
|
||||||
entries.length > 0
|
? 'bg-slate-50/50 border border-slate-100'
|
||||||
? 'bg-slate-50/50 border border-slate-100'
|
: 'border border-dashed border-slate-200/60'
|
||||||
: 'border border-dashed border-slate-200/60'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
|
||||||
{entries.map((entry, entryIdx) => {
|
</div>
|
||||||
const colors = contentTypeColors[entry.contentType];
|
))}
|
||||||
const Icon = contentTypeIcons[entry.contentType];
|
</div>
|
||||||
const channelSymbol = channelEmojiMap[entry.channelIcon] || '·';
|
</motion.div>
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Color Legend */}
|
{/* Color Legend (clickable filter) */}
|
||||||
<div className="flex flex-wrap gap-3 mt-4">
|
<div className="flex flex-wrap gap-3 mt-4">
|
||||||
{(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => {
|
{(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => {
|
||||||
const colors = contentTypeColors[type];
|
const colors = contentTypeColors[type];
|
||||||
|
const isActive = filterType === type;
|
||||||
return (
|
return (
|
||||||
<span
|
<button
|
||||||
key={type}
|
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]}
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingEntry && (
|
||||||
|
<EditEntryModal
|
||||||
|
entry={editingEntry}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import type { MarketingPlan } from '../types/plan';
|
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan';
|
||||||
import { fetchReportById } from '../lib/supabase';
|
import { fetchReportById, fetchActiveContentPlan, supabase } from '../lib/supabase';
|
||||||
import { transformReportToPlan } from '../lib/transformPlan';
|
import { transformReportToPlan } from '../lib/transformPlan';
|
||||||
|
|
||||||
interface UseMarketingPlanResult {
|
interface UseMarketingPlanResult {
|
||||||
|
|
@ -14,6 +14,60 @@ interface LocationState {
|
||||||
report?: Record<string, unknown>;
|
report?: Record<string, unknown>;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
reportId?: string;
|
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 {
|
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;
|
const state = location.state as LocationState | undefined;
|
||||||
|
|
||||||
// Source 1: Report data passed via navigation state
|
async function loadPlan() {
|
||||||
if (state?.report && state?.metadata) {
|
|
||||||
try {
|
try {
|
||||||
const plan = transformReportToPlan({
|
// ─── Source 1: Try content_plans table (AI-generated strategy) ───
|
||||||
id: (state.reportId || id),
|
// First, resolve clinicId from navigation state or analysis_runs
|
||||||
url: (state.metadata.url as string) || '',
|
let clinicId = state?.clinicId || null;
|
||||||
clinic_name: (state.metadata.clinicName as string) || '',
|
let clinicName = '';
|
||||||
report: state.report,
|
let clinicNameEn = '';
|
||||||
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
|
let targetUrl = '';
|
||||||
});
|
|
||||||
setData(plan);
|
|
||||||
setIsLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to build marketing plan');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source 2: Fetch report from Supabase and transform to plan
|
if (!clinicId) {
|
||||||
fetchReportById(id)
|
// Try to find clinicId from analysis_runs by run/report ID
|
||||||
.then((row) => {
|
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);
|
const plan = transformReportToPlan(row);
|
||||||
setData(plan);
|
setData(plan);
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
||||||
})
|
} finally {
|
||||||
.finally(() => setIsLoading(false));
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlan();
|
||||||
}, [id, location.state]);
|
}, [id, location.state]);
|
||||||
|
|
||||||
return { data, isLoading, error };
|
return { data, isLoading, error };
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* Content Director Engine
|
* Content Director Engine v2
|
||||||
*
|
*
|
||||||
* Deterministic content planning agent that generates a 4-week editorial
|
* Deterministic content planning agent that generates a 4-week editorial
|
||||||
* calendar by combining channel strategies, content pillars, clinic services,
|
* calendar by combining channel strategies, content pillars, clinic services,
|
||||||
* and existing assets. No AI API calls — all logic is data-driven.
|
* 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 {
|
import type {
|
||||||
|
|
@ -23,6 +32,8 @@ export interface ContentDirectorInput {
|
||||||
services: string[];
|
services: string[];
|
||||||
youtubeVideos?: { title: string; views: number; type: 'Short' | 'Long' }[];
|
youtubeVideos?: { title: string; views: number; type: 'Short' | 'Long' }[];
|
||||||
clinicName: string;
|
clinicName: string;
|
||||||
|
keywords?: { keyword: string; monthlySearches?: number }[];
|
||||||
|
gangnamUnniData?: { rating?: number; reviews?: number; doctors?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentDirectorOutput {
|
export interface ContentDirectorOutput {
|
||||||
|
|
@ -35,70 +46,165 @@ export interface ContentDirectorOutput {
|
||||||
interface FormatSlot {
|
interface FormatSlot {
|
||||||
channel: string;
|
channel: string;
|
||||||
channelIcon: string;
|
channelIcon: string;
|
||||||
format: string; // e.g. "Shorts", "Carousel", "블로그"
|
format: string;
|
||||||
contentType: ContentCategory;
|
contentType: ContentCategory;
|
||||||
preferredDays: number[]; // 0=월 ~ 6=일
|
preferredDays: number[]; // 0=월 ~ 6=일
|
||||||
perWeek: number; // how many per week
|
perWeek: number;
|
||||||
titleTemplate: (topic: string, idx: number) => string;
|
titleTemplate: (topic: string, idx: number) => string;
|
||||||
|
tone: string;
|
||||||
|
journeyStage: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── YouTube: 4 formats ───
|
||||||
const YOUTUBE_SLOTS: FormatSlot[] = [
|
const YOUTUBE_SLOTS: FormatSlot[] = [
|
||||||
{
|
{
|
||||||
channel: 'YouTube', channelIcon: 'youtube', format: 'Shorts',
|
channel: 'YouTube', channelIcon: 'youtube', format: 'Shorts',
|
||||||
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
||||||
titleTemplate: (t, i) => `Shorts: ${t} #${i + 1}`,
|
titleTemplate: (t, i) => `Shorts: ${t} #${i + 1}`,
|
||||||
|
tone: '캐주얼·후킹', journeyStage: 'awareness',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
channel: 'YouTube', channelIcon: 'youtube', format: 'Long-form',
|
channel: 'YouTube', channelIcon: 'youtube', format: 'Long-form',
|
||||||
contentType: 'video', preferredDays: [3], perWeek: 1,
|
contentType: 'video', preferredDays: [3], perWeek: 1,
|
||||||
titleTemplate: (t) => `${t} 상세 설명`,
|
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[] = [
|
const INSTAGRAM_SLOTS: FormatSlot[] = [
|
||||||
{
|
{
|
||||||
channel: 'Instagram', channelIcon: 'instagram', format: 'Reel',
|
channel: 'Instagram', channelIcon: 'instagram', format: 'Reel',
|
||||||
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
||||||
titleTemplate: (t, i) => `Reel: ${t} #${i + 1}`,
|
titleTemplate: (t, i) => `Reel: ${t} #${i + 1}`,
|
||||||
|
tone: '트렌디·공감', journeyStage: 'awareness',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
channel: 'Instagram', channelIcon: 'instagram', format: 'Carousel',
|
channel: 'Instagram', channelIcon: 'instagram', format: 'Carousel',
|
||||||
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
|
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
|
||||||
titleTemplate: (t) => `Carousel: ${t}`,
|
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',
|
channel: 'Instagram', channelIcon: 'instagram', format: 'Stories',
|
||||||
contentType: 'social', preferredDays: [2, 5], perWeek: 2,
|
contentType: 'social', preferredDays: [2, 5], perWeek: 2,
|
||||||
titleTemplate: (t) => `Stories: ${t}`,
|
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[] = [
|
const NAVER_SLOTS: FormatSlot[] = [
|
||||||
{
|
{
|
||||||
channel: '네이버 블로그', channelIcon: 'blog', format: '블로그',
|
channel: '네이버 블로그', channelIcon: 'blog', format: 'SEO글',
|
||||||
contentType: 'blog', preferredDays: [1, 3], perWeek: 2,
|
contentType: 'blog', preferredDays: [1, 3], perWeek: 2,
|
||||||
titleTemplate: (t) => `${t}`,
|
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[] = [
|
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: '광고',
|
channel: 'Facebook', channelIcon: 'facebook', format: '광고',
|
||||||
contentType: 'ad', preferredDays: [5], perWeek: 1,
|
contentType: 'ad', preferredDays: [4, 6], perWeek: 2,
|
||||||
titleTemplate: (t) => `광고: ${t}`,
|
titleTemplate: (t) => `광고: ${t}`,
|
||||||
|
tone: '타겟팅·CTA', journeyStage: 'conversion',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Week Themes ───
|
// ─── Customer Journey-Based Week Themes (Audit C4) ───
|
||||||
|
|
||||||
const WEEK_THEMES = [
|
const WEEK_THEMES = [
|
||||||
{ label: 'Week 1: 브랜드 정비 & 첫 콘텐츠', pillarFocus: 0 }, // 전문성·신뢰
|
{
|
||||||
{ label: 'Week 2: 콘텐츠 엔진 가동', pillarFocus: 1 }, // 비포·애프터
|
label: 'Week 1: 인지 확대 & 브랜드 정비',
|
||||||
{ label: 'Week 3: 소셜 증거 강화', pillarFocus: 2 }, // 환자 후기
|
pillarFocus: 0, // 전문성·신뢰
|
||||||
{ label: 'Week 4: 전환 최적화', pillarFocus: 3 }, // 트렌드·교육
|
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 {
|
interface TopicPool {
|
||||||
topics: string[];
|
topics: string[];
|
||||||
|
|
@ -109,46 +215,71 @@ function buildTopicPool(
|
||||||
pillars: ContentPillar[],
|
pillars: ContentPillar[],
|
||||||
services: string[],
|
services: string[],
|
||||||
pillarIndex: number,
|
pillarIndex: number,
|
||||||
|
keywords?: { keyword: string; monthlySearches?: number }[],
|
||||||
): TopicPool {
|
): TopicPool {
|
||||||
const pillar = pillars[pillarIndex % pillars.length];
|
const pillar = pillars[pillarIndex % pillars.length];
|
||||||
const svcList = services.length > 0 ? services : ['시술'];
|
const svcList = services.length > 0 ? services : ['시술'];
|
||||||
|
|
||||||
const topics: string[] = [];
|
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
|
// Pillar-specific templates
|
||||||
switch (pillarIndex % 4) {
|
switch (pillarIndex % 5) {
|
||||||
case 0: // 전문성·신뢰
|
case 0: // 전문성·신뢰
|
||||||
for (const svc of svcList) {
|
for (const svc of svcList) {
|
||||||
topics.push(`${svc} 전문의 Q&A`);
|
topics.push(`${svc} 전문의 Q&A`);
|
||||||
topics.push(`${svc} 과정 완전정복`);
|
topics.push(`${svc} 과정 완전정복`);
|
||||||
topics.push(`${svc} 수술실 CCTV 공개`);
|
topics.push(`${svc} 수술실 CCTV 공개`);
|
||||||
}
|
}
|
||||||
|
topics.push('의료진 학회 발표·해외 연수');
|
||||||
|
topics.push('최신 장비 도입 소개');
|
||||||
topics.push('마취 안전 관리 시스템');
|
topics.push('마취 안전 관리 시스템');
|
||||||
topics.push('회복 관리 시스템');
|
|
||||||
break;
|
break;
|
||||||
case 1: // 비포·애프터
|
case 1: // 비포·애프터
|
||||||
for (const svc of svcList) {
|
for (const svc of svcList) {
|
||||||
topics.push(`${svc} 전후 비교`);
|
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;
|
break;
|
||||||
case 2: // 환자 후기
|
case 2: // 환자 후기
|
||||||
for (const svc of svcList) {
|
for (const svc of svcList) {
|
||||||
topics.push(`${svc} 실제 후기`);
|
topics.push(`${svc} 실제 환자 인터뷰`);
|
||||||
topics.push(`${svc} 회복 일기`);
|
topics.push(`${svc} 회복 일기`);
|
||||||
}
|
}
|
||||||
topics.push('환자가 설명하는: 왜 선택?');
|
topics.push('해외 환자 케이스');
|
||||||
topics.push('리뷰 하이라이트');
|
topics.push('환자가 말하는: 왜 여기를 선택했나');
|
||||||
|
topics.push('리뷰 하이라이트 모음');
|
||||||
break;
|
break;
|
||||||
case 3: // 트렌드·교육
|
case 3: // 트렌드·교육
|
||||||
for (const svc of svcList) {
|
for (const svc of svcList) {
|
||||||
topics.push(`${svc} 비용 가이드`);
|
topics.push(`${svc} 비용 완벽 가이드`);
|
||||||
topics.push(`${svc} FAQ`);
|
topics.push(`${svc} FAQ 총정리`);
|
||||||
}
|
}
|
||||||
topics.push('성형 가이드: 내 얼굴 뭐 할래');
|
topics.push('2026 성형 트렌드: 자연주의');
|
||||||
topics.push('트렌드 분석: 자연주의 성형');
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,10 +309,46 @@ function buildRepurposeTopics(
|
||||||
.filter(t => t.length > 2);
|
.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 ───
|
// ─── Main Engine ───
|
||||||
|
|
||||||
export function generateContentPlan(input: ContentDirectorInput): ContentDirectorOutput {
|
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
|
// 1. Determine active format slots based on available channels
|
||||||
const activeSlots: FormatSlot[] = [];
|
const activeSlots: FormatSlot[] = [];
|
||||||
|
|
@ -189,55 +356,71 @@ export function generateContentPlan(input: ContentDirectorInput): ContentDirecto
|
||||||
|
|
||||||
if (channelIds.has('youtube')) activeSlots.push(...YOUTUBE_SLOTS);
|
if (channelIds.has('youtube')) activeSlots.push(...YOUTUBE_SLOTS);
|
||||||
if (channelIds.has('instagram')) activeSlots.push(...INSTAGRAM_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('naverblog') || channelIds.has('naver_blog')) activeSlots.push(...NAVER_SLOTS);
|
||||||
if (channelIds.has('facebook')) activeSlots.push(...FACEBOOK_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) {
|
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
|
// 2. Build repurpose topics from existing YouTube videos
|
||||||
const repurposeTopics = youtubeVideos ? buildRepurposeTopics(youtubeVideos) : [];
|
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[] = [];
|
const weeks: CalendarWeek[] = [];
|
||||||
|
|
||||||
for (let weekIdx = 0; weekIdx < 4; weekIdx++) {
|
for (let weekIdx = 0; weekIdx < 4; weekIdx++) {
|
||||||
const theme = WEEK_THEMES[weekIdx];
|
const theme = WEEK_THEMES[weekIdx];
|
||||||
|
|
||||||
// Build topic pool focused on this week's pillar, with secondary pillars mixed in
|
// Build topic pools focused on this week's pillar + 5th pillar rotation
|
||||||
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus);
|
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus, keywords);
|
||||||
const secondaryPool = buildTopicPool(pillars, services, (theme.pillarFocus + 1) % 4);
|
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[] = [];
|
const entries: CalendarEntry[] = [];
|
||||||
|
|
||||||
// Week 1 special: add brand setup tasks
|
// Week 1 special: brand setup tasks
|
||||||
if (weekIdx === 0) {
|
if (weekIdx === 0) {
|
||||||
entries.push({
|
entries.push({
|
||||||
dayOfWeek: 0, // 월
|
dayOfWeek: 0,
|
||||||
channel: clinicName,
|
channel: clinicName,
|
||||||
channelIcon: 'globe',
|
channelIcon: 'globe',
|
||||||
contentType: 'social',
|
contentType: 'social',
|
||||||
title: `전 채널 프로필/VIEW 골드 정비`,
|
title: '전 채널 프로필/브랜드 일관성 정비',
|
||||||
});
|
description: '프로필 사진, 배너, 바이오를 모든 채널에서 통일',
|
||||||
entries.push({
|
pillar: '전문성 · 신뢰',
|
||||||
dayOfWeek: 1, // 화
|
status: 'draft',
|
||||||
channel: 'Instagram',
|
|
||||||
channelIcon: 'instagram',
|
|
||||||
contentType: 'social',
|
|
||||||
title: `프로필 리뉴얼 고지 + 팔로워`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill format slots for this week
|
// Fill format slots for this week
|
||||||
for (const slot of activeSlots) {
|
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++) {
|
for (let i = 0; i < slot.perWeek; i++) {
|
||||||
// Use repurpose topics for some slots in weeks 2-4
|
|
||||||
let topic: string;
|
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;
|
const rIdx = (weekIdx - 1 + i) % repurposeTopics.length;
|
||||||
topic = repurposeTopics[rIdx];
|
topic = repurposeTopics[rIdx];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -252,29 +435,51 @@ export function generateContentPlan(input: ContentDirectorInput): ContentDirecto
|
||||||
channelIcon: slot.channelIcon,
|
channelIcon: slot.channelIcon,
|
||||||
contentType: slot.contentType,
|
contentType: slot.contentType,
|
||||||
title: slot.titleTemplate(topic, i),
|
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) {
|
if (weekIdx === 3) {
|
||||||
entries.push({
|
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',
|
channel: 'Facebook',
|
||||||
channelIcon: 'facebook',
|
channelIcon: 'facebook',
|
||||||
contentType: 'ad',
|
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);
|
entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
|
||||||
|
|
||||||
weeks.push({
|
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 allEntries = weeks.flatMap(w => w.entries);
|
||||||
const monthlySummary: ContentCountSummary[] = [
|
const monthlySummary: ContentCountSummary[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -135,11 +135,16 @@ export async function discoverChannels(url: string, clinicName?: string) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,18 @@ const CHANNEL_ICON_MAP: Record<string, string> = {
|
||||||
tiktok: 'video',
|
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(
|
function buildChannelStrategies(
|
||||||
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
|
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
|
||||||
recommendations: Record<string, unknown>[] | undefined,
|
recommendations: Record<string, unknown>[] | undefined,
|
||||||
|
|
@ -61,7 +73,7 @@ function buildChannelStrategies(
|
||||||
targetGoal: (ch.recommendation as string) || '',
|
targetGoal: (ch.recommendation as string) || '',
|
||||||
contentTypes: relatedRecs.length > 0 ? relatedRecs : ['콘텐츠 전략 수립 필요'],
|
contentTypes: relatedRecs.length > 0 ? relatedRecs : ['콘텐츠 전략 수립 필요'],
|
||||||
postingFrequency: score >= 80 ? '주 3-5회' : score >= 60 ? '주 2-3회' : '주 1-2회 (시작)',
|
postingFrequency: score >= 80 ? '주 3-5회' : score >= 60 ? '주 2-3회' : '주 1-2회 (시작)',
|
||||||
tone: '전문적 · 친근한',
|
tone: CHANNEL_TONE_MAP[key] || '전문적 · 친근한',
|
||||||
formatGuidelines: [],
|
formatGuidelines: [],
|
||||||
priority: (score < 50 ? 'P0' : score < 70 ? 'P1' : 'P2') as 'P0' | 'P1' | 'P2',
|
priority: (score < 50 ? 'P0' : score < 70 ? 'P1' : 'P2') as 'P0' | 'P1' | 'P2',
|
||||||
};
|
};
|
||||||
|
|
@ -72,36 +84,43 @@ function buildContentPillars(
|
||||||
recommendations: Record<string, unknown>[] | undefined,
|
recommendations: Record<string, unknown>[] | undefined,
|
||||||
services: string[] | undefined,
|
services: string[] | undefined,
|
||||||
): ContentPillar[] {
|
): ContentPillar[] {
|
||||||
const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E'];
|
const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E', '#0984E3'];
|
||||||
const pillars: ContentPillar[] = [
|
const pillars: ContentPillar[] = [
|
||||||
{
|
{
|
||||||
title: '전문성 · 신뢰',
|
title: '전문성 · 신뢰',
|
||||||
description: '의료진 소개, 수술 과정, 인증/자격 콘텐츠로 신뢰 구축',
|
description: '의료진 소개, 수술 과정, 인증/자격, 학회 발표, 해외 연수 콘텐츠로 신뢰 구축',
|
||||||
relatedUSP: '전문 의료진',
|
relatedUSP: '전문 의료진',
|
||||||
exampleTopics: services?.slice(0, 3).map(s => `${s} 시술 과정 소개`) || ['시술 과정 소개'],
|
exampleTopics: services?.slice(0, 3).map(s => `${s} 시술 과정 소개`) || ['시술 과정 소개'],
|
||||||
color: PILLAR_COLORS[0],
|
color: PILLAR_COLORS[0],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '비포 · 애프터',
|
title: '비포 · 애프터',
|
||||||
description: '실제 환자 사례, 수술 전후 비교로 결과 시각화',
|
description: '실제 환자 사례, 수술 전후 비교, 3D 시뮬레이션, 시간별 변화 기록으로 결과 시각화',
|
||||||
relatedUSP: '검증된 결과',
|
relatedUSP: '검증된 결과',
|
||||||
exampleTopics: services?.slice(0, 3).map(s => `${s} 비포/애프터`) || ['비포/애프터 사례'],
|
exampleTopics: services?.slice(0, 3).map(s => `${s} 비포/애프터`) || ['비포/애프터 사례'],
|
||||||
color: PILLAR_COLORS[1],
|
color: PILLAR_COLORS[1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '환자 후기 · 리뷰',
|
title: '환자 후기 · 리뷰',
|
||||||
description: '실제 환자 인터뷰, 후기 콘텐츠로 사회적 증거 확보',
|
description: '실제 환자 인터뷰, 후기 콘텐츠, 강남언니 리뷰 관리로 사회적 증거 확보',
|
||||||
relatedUSP: '환자 만족도',
|
relatedUSP: '환자 만족도',
|
||||||
exampleTopics: ['환자 인터뷰 영상', '리뷰 하이라이트', '회복 일기'],
|
exampleTopics: ['환자 인터뷰 영상', '리뷰 하이라이트', '회복 일기'],
|
||||||
color: PILLAR_COLORS[2],
|
color: PILLAR_COLORS[2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '트렌드 · 교육',
|
title: '트렌드 · 교육',
|
||||||
description: '시술 트렌드, Q&A, 의학 정보로 잠재 고객 유입',
|
description: '시술 트렌드, Q&A, 의학 정보, 비용 가이드로 잠재 고객 유입',
|
||||||
relatedUSP: '최신 트렌드',
|
relatedUSP: '최신 트렌드',
|
||||||
exampleTopics: ['자주 묻는 질문 Q&A', '시술별 비용 가이드', '최신 성형 트렌드'],
|
exampleTopics: ['자주 묻는 질문 Q&A', '시술별 비용 가이드', '최신 성형 트렌드'],
|
||||||
color: PILLAR_COLORS[3],
|
color: PILLAR_COLORS[3],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '안전 · 케어',
|
||||||
|
description: '수술 후 관리, 24시간 모니터링, 전담 간호사, 리커버리 프로그램으로 프리미엄 케어 차별화',
|
||||||
|
relatedUSP: '프리미엄 안전 관리',
|
||||||
|
exampleTopics: ['수술 후 48시간 집중 케어', '전담 간호사 1:1 관리', '응급 대응 프로토콜'],
|
||||||
|
color: PILLAR_COLORS[4],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return pillars;
|
return pillars;
|
||||||
|
|
@ -118,6 +137,7 @@ function buildCalendar(
|
||||||
services: string[],
|
services: string[],
|
||||||
enrichment: EnrichmentData | undefined,
|
enrichment: EnrichmentData | undefined,
|
||||||
clinicName: string,
|
clinicName: string,
|
||||||
|
report?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
// Extract YouTube top videos for repurposing suggestions
|
// Extract YouTube top videos for repurposing suggestions
|
||||||
const youtubeVideos = enrichment?.youtube?.videos?.map(v => ({
|
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',
|
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({
|
return generateContentPlan({
|
||||||
channels,
|
channels,
|
||||||
pillars,
|
pillars,
|
||||||
services,
|
services,
|
||||||
youtubeVideos,
|
youtubeVideos,
|
||||||
clinicName,
|
clinicName,
|
||||||
|
keywords,
|
||||||
|
gangnamUnniData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +360,7 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
|
||||||
const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations);
|
const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations);
|
||||||
const pillars = buildContentPillars(recommendations, services);
|
const pillars = buildContentPillars(recommendations, services);
|
||||||
const clinicName = (clinicInfo?.name as string) || row.clinic_name || '';
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation, useParams } from 'react-router';
|
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Check, AlertCircle, RefreshCw } from 'lucide-react';
|
import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
discoverChannels,
|
discoverChannels,
|
||||||
collectChannelData,
|
collectChannelData,
|
||||||
generateReportV2,
|
generateReportV2,
|
||||||
|
generateContentPlan,
|
||||||
fetchPipelineStatus,
|
fetchPipelineStatus,
|
||||||
} from '../lib/supabase';
|
} from '../lib/supabase';
|
||||||
|
|
||||||
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'complete';
|
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'planning' | 'complete';
|
||||||
|
|
||||||
const PHASE_STEPS = [
|
const PHASE_STEPS = [
|
||||||
{ key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
|
{ 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: '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: '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' },
|
{ key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -49,6 +51,8 @@ function clearSession() {
|
||||||
export default function AnalysisLoadingPage() {
|
export default function AnalysisLoadingPage() {
|
||||||
const [phase, setPhase] = useState<Phase>('discovering');
|
const [phase, setPhase] = useState<Phase>('discovering');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -99,20 +103,35 @@ export default function AnalysisLoadingPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Generate Report
|
// Phase 3: Generate Report
|
||||||
|
let reportResult: Record<string, unknown> | null = null;
|
||||||
if (startPhase === 'generating') {
|
if (startPhase === 'generating') {
|
||||||
setPhase('generating');
|
setPhase('generating');
|
||||||
const result = await generateReportV2(reportId, clinicId, runId);
|
const result = await generateReportV2(reportId, clinicId, runId);
|
||||||
if (!result.success) throw new Error(result.error || 'Report generation failed');
|
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
|
// Complete — navigate to report
|
||||||
setPhase('complete');
|
setPhase('complete');
|
||||||
clearSession();
|
clearSession();
|
||||||
|
|
||||||
|
// Use stored report result or refetch
|
||||||
|
const result = reportResult || await generateReportV2(reportId, clinicId, runId).catch(() => null);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(`/report/${reportId}`, {
|
navigate(`/report/${reportId}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
state: result.report && result.metadata
|
state: result?.report && result?.metadata
|
||||||
? { report: result.report, metadata: result.metadata, reportId }
|
? { report: result.report, metadata: result.metadata, reportId, clinicId }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}, 800);
|
}, 800);
|
||||||
|
|
@ -120,6 +139,12 @@ export default function AnalysisLoadingPage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||||
setError(msg);
|
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]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
|
@ -260,41 +285,64 @@ export default function AnalysisLoadingPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<motion.div
|
errorCode === 'CLINIC_NOT_REGISTERED' ? (
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<motion.div
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center"
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
>
|
className="w-full p-6 rounded-2xl bg-purple-500/10 border border-purple-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>
|
<ShieldX className="w-10 h-10 text-purple-400 mx-auto mb-3" />
|
||||||
|
<p className="text-white text-lg font-medium mb-2">미등록 병원</p>
|
||||||
{errorDetails && (
|
<p className="text-purple-300/80 text-sm mb-1">
|
||||||
<div className="mb-4 text-left bg-red-500/5 rounded-lg p-3">
|
<span className="font-mono text-purple-200">{errorDomain}</span>
|
||||||
<p className="text-red-400/60 text-xs font-mono mb-1">Failed channels:</p>
|
</p>
|
||||||
{Object.entries(errorDetails).map(([ch, err]) => (
|
<p className="text-purple-300/60 text-sm mb-6">
|
||||||
<p key={ch} className="text-red-400/80 text-xs font-mono">
|
현재 분석 대상 병원 목록에 포함되지 않은 도메인입니다.
|
||||||
• {ch}: {err}
|
</p>
|
||||||
</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
|
<button
|
||||||
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
|
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>
|
</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' ? (
|
{phase === 'resuming' ? (
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ const API_REGISTRY: ApiConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'apify',
|
id: 'apify',
|
||||||
name: 'Apify',
|
name: 'Apify',
|
||||||
description: 'Instagram/Facebook/Google Places 데이터 수집',
|
description: 'Instagram/Facebook 데이터 수집',
|
||||||
envKeys: ['APIFY_API_TOKEN'],
|
envKeys: ['APIFY_API_TOKEN'],
|
||||||
layer: 'edge-function',
|
layer: 'edge-function',
|
||||||
docsUrl: 'https://docs.apify.com',
|
docsUrl: 'https://docs.apify.com',
|
||||||
|
|
@ -95,7 +95,22 @@ const API_REGISTRY: ApiConfig[] = [
|
||||||
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
|
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
|
||||||
estimatedCostPerRun: '~$0.10-0.30',
|
estimatedCostPerRun: '~$0.10-0.30',
|
||||||
rateLimit: 'Actor-dependent (30-120s timeout)',
|
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',
|
id: 'youtube',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useLocation } from 'react-router';
|
||||||
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
||||||
import { ReportNav } from '../components/report/ReportNav';
|
import { ReportNav } from '../components/report/ReportNav';
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ import ContentStrategy from '../components/plan/ContentStrategy';
|
||||||
import ContentCalendar from '../components/plan/ContentCalendar';
|
import ContentCalendar from '../components/plan/ContentCalendar';
|
||||||
import AssetCollection from '../components/plan/AssetCollection';
|
import AssetCollection from '../components/plan/AssetCollection';
|
||||||
import MyAssetUpload from '../components/plan/MyAssetUpload';
|
import MyAssetUpload from '../components/plan/MyAssetUpload';
|
||||||
|
import StrategyAdjustmentSection from '../components/plan/StrategyAdjustmentSection';
|
||||||
import PlanCTA from '../components/plan/PlanCTA';
|
import PlanCTA from '../components/plan/PlanCTA';
|
||||||
|
|
||||||
const PLAN_SECTIONS = [
|
const PLAN_SECTIONS = [
|
||||||
|
|
@ -19,10 +20,13 @@ const PLAN_SECTIONS = [
|
||||||
{ id: 'content-calendar', label: '콘텐츠 캘린더' },
|
{ id: 'content-calendar', label: '콘텐츠 캘린더' },
|
||||||
{ id: 'asset-collection', label: '에셋 수집' },
|
{ id: 'asset-collection', label: '에셋 수집' },
|
||||||
{ id: 'my-asset-upload', label: 'My Assets' },
|
{ id: 'my-asset-upload', label: 'My Assets' },
|
||||||
|
{ id: 'strategy-adjustment', label: '전략 조정' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MarketingPlanPage() {
|
export default function MarketingPlanPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const clinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
|
||||||
const { data, isLoading, error } = useMarketingPlan(id);
|
const { data, isLoading, error } = useMarketingPlan(id);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -75,6 +79,10 @@ export default function MarketingPlanPage() {
|
||||||
<MyAssetUpload />
|
<MyAssetUpload />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div data-no-print>
|
||||||
|
<StrategyAdjustmentSection clinicId={clinicId} planId={data.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<PlanCTA />
|
<PlanCTA />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export interface ChannelStrategyCard {
|
||||||
tone: string;
|
tone: string;
|
||||||
formatGuidelines: string[];
|
formatGuidelines: string[];
|
||||||
priority: 'P0' | 'P1' | 'P2';
|
priority: 'P0' | 'P1' | 'P2';
|
||||||
|
customerJourneyStage?: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Section 3: Content Marketing Strategy ───
|
// ─── Section 3: Content Marketing Strategy ───
|
||||||
|
|
@ -110,6 +111,13 @@ export interface CalendarEntry {
|
||||||
channelIcon: string;
|
channelIcon: string;
|
||||||
contentType: ContentCategory;
|
contentType: ContentCategory;
|
||||||
title: string;
|
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 {
|
export interface CalendarWeek {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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 (1950–currentYear)
|
||||||
|
* - 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 };
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithRetry } from "./retry.ts";
|
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";
|
const FIRECRAWL_BASE = "https://api.firecrawl.dev/v1";
|
||||||
|
|
||||||
|
|
@ -124,54 +131,28 @@ async function captureScreenshot(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find relevant sub-pages from siteMap for additional screenshots.
|
* Find relevant sub-pages from siteMap for additional screenshots.
|
||||||
|
* Delegates classification to urlClassifier.ts for testability.
|
||||||
*/
|
*/
|
||||||
export function findRelevantPages(
|
export function findRelevantPages(
|
||||||
siteMap: string[],
|
siteMap: string[],
|
||||||
baseUrl: string,
|
_baseUrl: string,
|
||||||
): { doctorPage?: string; surgeryPage?: string; aboutPage?: 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 doctorUrls: string[] = [];
|
||||||
const surgeryUrls: string[] = [];
|
const surgeryUrls: string[] = [];
|
||||||
const aboutUrls: string[] = [];
|
const aboutUrls: string[] = [];
|
||||||
|
|
||||||
for (const url of siteMap) {
|
for (const url of siteMap) {
|
||||||
const lower = url.toLowerCase();
|
const category = classifyPageUrl(url);
|
||||||
|
if (category === "doctor") doctorUrls.push(url);
|
||||||
const isDoctor = (
|
else if (category === "surgery") surgeryUrls.push(url);
|
||||||
lower.includes('/doctor') || lower.includes('/doctors') ||
|
else if (category === "about") aboutUrls.push(url);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Pick best URL per category
|
return {
|
||||||
result.doctorPage = doctorUrls[0];
|
doctorPage: doctorUrls[0],
|
||||||
result.surgeryPage = surgeryUrls[0];
|
surgeryPage: surgeryUrls[0],
|
||||||
result.aboutPage = aboutUrls[0];
|
aboutPage: aboutUrls[0],
|
||||||
|
};
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,21 @@ import { PERPLEXITY_MODEL } from "../_shared/config.ts";
|
||||||
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
|
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
|
||||||
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
|
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
|
||||||
import { searchGooglePlace } from "../_shared/googlePlaces.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 = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
|
@ -305,7 +320,9 @@ Deno.serve(async (req) => {
|
||||||
channelData.gangnamUnni = {
|
channelData.gangnamUnni = {
|
||||||
name: hospital.hospitalName,
|
name: hospital.hospitalName,
|
||||||
rawRating: hospital.rating,
|
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",
|
ratingScale: "/10",
|
||||||
totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10),
|
totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10),
|
||||||
procedures: hospital.procedures || [], address: hospital.address,
|
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 ───
|
// ─── Execute all channel tasks ───
|
||||||
const taskResults = await Promise.all(channelTasks);
|
const taskResults = await Promise.all(channelTasks);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,78 @@ const corsHeaders = {
|
||||||
|
|
||||||
const APIFY_BASE = "https://api.apify.com/v2";
|
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 {
|
interface DiscoverRequest {
|
||||||
url: string;
|
url: string;
|
||||||
clinicName?: 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 FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY") || "";
|
||||||
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY") || "";
|
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY") || "";
|
||||||
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_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 = {
|
const scrapeDataFull = {
|
||||||
clinic, branding: brandData.data?.json || {},
|
clinic, branding: brandData.data?.json || {},
|
||||||
siteLinks, siteMap: mapData.links || [],
|
siteLinks, siteMap: mapData.links || [],
|
||||||
|
|
|
||||||
|
|
@ -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: "한국 직장인/성형 관심층 주요 활동 시간대 기반",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
import { normalizeInstagramHandle } from "../_shared/normalizeHandles.ts";
|
import { normalizeInstagramHandle } from "../_shared/normalizeHandles.ts";
|
||||||
import { PERPLEXITY_MODEL } from "../_shared/config.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 = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -121,7 +126,7 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
||||||
],
|
],
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
}),
|
}),
|
||||||
});
|
}, { maxRetries: 2, backoffMs: [5000, 15000], timeoutMs: 90000, label: "perplexity-report" });
|
||||||
|
|
||||||
const aiData = await aiRes.json();
|
const aiData = await aiRes.json();
|
||||||
let reportText = aiData.choices?.[0]?.message?.content || "";
|
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 ───
|
// ─── Post-processing: Inject Vision Analysis data directly ───
|
||||||
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
|
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
|
||||||
const vision = channelData.visionAnalysis as Record<string, unknown> | undefined;
|
const vision = channelData.visionAnalysis as Record<string, unknown> | undefined;
|
||||||
const isEstablishedMissing = (val: unknown): boolean => {
|
// Use Harness 3's isMissingValue() — covers all known LLM "no data" variants
|
||||||
if (!val) return true;
|
|
||||||
const s = String(val).trim().toLowerCase();
|
|
||||||
return s === '' || s === '데이터 없음' || s === '데이터없음' || s === 'n/a' || s === '확인불가' || s === '미확인' || s === '정보없음' || s === '정보 없음';
|
|
||||||
};
|
|
||||||
if (vision) {
|
if (vision) {
|
||||||
// Force-inject foundingYear if Vision found it but Perplexity didn't
|
// 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 = report.clinicInfo || {};
|
||||||
report.clinicInfo.established = String(vision.foundingYear);
|
report.clinicInfo.established = String(vision.foundingYear);
|
||||||
console.log(`[report] Injected foundingYear from Vision: ${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);
|
const year = new Date().getFullYear() - Number(vision.operationYears);
|
||||||
report.clinicInfo = report.clinicInfo || {};
|
report.clinicInfo = report.clinicInfo || {};
|
||||||
report.clinicInfo.established = String(year);
|
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,
|
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
|
// Legacy: marketing_reports
|
||||||
await supabase.from("marketing_reports").update({
|
await supabase.from("marketing_reports").update({
|
||||||
report,
|
report,
|
||||||
status: "complete",
|
status: "complete",
|
||||||
|
data_quality_score: qualityReport.score,
|
||||||
pipeline_completed_at: new Date().toISOString(),
|
pipeline_completed_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}).eq("id", body.reportId);
|
}).eq("id", body.reportId);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||