Compare commits

...

18 Commits

Author SHA1 Message Date
Mina Choi e5a9036e47 fix(report+analysis): Instagram/Facebook Optional 완화 + viewclinic mock 제거 + brand_assets 강제주입
- schemas/report.py: InstagramAccount/InstagramAudit/FacebookPage/FacebookAudit 필드 Optional 완화
  (LLM이 page 1·2개 모두 language/label/logo/has_whatsapp 등 빼먹는 케이스 차단)
- analysis.py: viewclinic mock 분기(_is_mock, _load_mock_report, _load_mock_plan) 제거 — raw_data 충분
- analysis.py: _build_clinic_snapshot에 brandAssets.logo_images/brand_colors 강제 주입
  (LLM 프롬프트 가드 무시하고 null 두는 케이스 차단)
- analysis.py: facebook_audit.pages 머지 방식 변경 — LLM 첫 페이지 템플릿 복제 후 코드 patch로 인덱스별 덮어쓰기
  (EN(index 1) 드랍 + label/logo 누락 검증 실패 동시 회피)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:50:35 +09:00
Mina Choi 5dbc7d7ffe fix(report): ClinicSnapshot/YouTubeAudit/Instagram*/Facebook* Optional 완화
required로 두면 LLM 응답이나 수집 데이터 누락 시 pydantic ValidationError로
리포트 endpoint 전체가 500으로 죽음. 실제 테스트(청담오라클)에서 LLM이
weekly_view_growth, established 등 10개 필드를 null 반환하는 케이스 확인.

- ClinicSnapshot/YouTubeAudit: schemas + models 양쪽 모두 Optional (LLM 입력 검증
  + FastAPI 응답 검증 둘 다 통과 필요)
- InstagramAccount/InstagramAudit/FacebookPage/FacebookAudit: models만 (인스타·페북 빈
  계정/페이지 케이스 대응)
- list[T] 필드는 기본값 [] 부여

트레이드오프: 스키마 레벨 데이터 완결성 보장 약화. 운영하며 자주 비는 필드
패턴 보고 collection 단계 보강 필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:42:04 +09:00
Mina Choi 71b605eaa6 Merge branch 'wip/channel-brand-2026-05-29'
채널 확장 + 브랜드 자산 파이프라인을 main에 통합.

신규/주요 변경:
- 5채널 외 부가 수집 (틱톡/IG·FB 영문/네이버 카페/카카오톡) — collect_extras.py
- 브랜드 자산: 홈페이지 로고 URL + CSS 색상 추출 (color_extractor.py) + Gemini Vision 로고 묘사 (vision.py)
- 채널 로고 비교: 공식 로고와 각 채널 프로필 이미지 일치 여부 평가
- 인스타/페북 audit 빌더 분리 (instagram_audit.py, facebook_audit.py)
- mock_urls.py: 78개 병원 영문 채널 51건 + 필드 캐노니컬 순서 정규화
- ReportInput/PlanInput 신규 채널 필드 추가
- ChannelBrandingRule literal "missing" → "N/A"

teammate eed5772와의 conflict 해결:
- ClinicSnapshot/YouTubeAudit: teammate가 신뢰 못하는 필드 제거 (established/years_in_business/price_range/media_appearances/medical_tourism/nearest_station/subscriber_rank)
- services/analysis.py: teammate의 _build_clinic_snapshot/_build_youtube_audit/duration helpers + 우리의 _naver_blog_summary 둘 다 보존
- imports: youtube_diagnosis_prompt + build_instagram_accounts/build_facebook_pages 모두 채택

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:40:21 +09:00
Mina Choi aff2b2720d WIP: channel-brand merge + Optional 모델 완화 + collect_extras rename + mock_urls 영문 채널 51건
머지 본체:
- 5채널 외 부가 수집(틱톡/IG·FB EN/네이버 카페/카카오톡)
- 브랜드 자산/채널 로고 Vision 분석
- ReportInput/PlanInput에 신규 채널 필드 추가
- ChannelBrandingRule literal "missing" → "N/A"

후속 로컬 작업 (분리 커밋 예정):
- fix(report): ClinicSnapshot/YouTubeAudit/Instagram*/Facebook* required→Optional (LLM null 응답 대응)
- refactor: enrichment.py → collect_extras.py (네이밍 명확화)
- data(mock_urls): 38개 병원 영문 채널 51건 추가 + 78개 필드 캐노니컬 순서 정규화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:22:17 +09:00
Mina Choi 56fa2c6238 chore: schema/model 잔여 sync (이전 커밋에 빠진 스키마 필드)
- ReportInput / Channels: kakao_talk, naver_cafe 필드 (이전 카카오/카페 채널 커밋 092bfe7 에서 누락)
- PlanInput: naver_blog 필드 (이번 네이버 블로그 채널 커밋 9da285e 에서 누락)
- ChannelBrandingRule literal: "missing" → "N/A" 통일 (이전 missing→N/A 커밋 5f1eee8 에서 누락)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:46:15 +09:00
Mina Choi 4bc7c9652c data(mock_urls): 카카오톡·네이버 카페 URL 일괄 추가 + 필드 정렬
78개 클리닉에 kakaoTalk / naverCafe 필드 추가, 검색 agent 가 일괄 조회한 결과
적용:
- kakaoTalk: 68개 (한국 클리닉 87% 가 카카오톡 채널 운영 — pf.kakao.com/_X 형태)
- naverCafe: 3개 (의료 클리닉 공식 카페 운영은 드물어 적음)

URL 형식 정규화: https://, www. 접두사 제거하고 호스트부터 시작.

확실하지 않은 케이스는 agent 가 의도적으로 빈값으로 둠 (개인 카톡 친구 추가
링크나 오픈채팅, 동명 다른 병원 카페 같이 false positive 위험 있는 케이스).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:45:36 +09:00
Mina Choi bed5f0c274 chore: TIKTOK_ACTOR 상수 + 수집기 옵저버빌리티 정리
apify.py: 라이브 actor id 들을 모두 모듈 상단 상수로 통일 (TIKTOK_ACTOR 추가).
fetch_tiktok_profile 이 raw 문자열 'clockworks~tiktok-scraper' 쓰던 것 정리.
이제 IG_PROFILE / IG_HIGHLIGHTS / FB_PAGES / FB_POSTS / TIKTOK 5개 상수.

수집기 옵저버빌리티 정리:
- collect.py: 채널별 done 로그에 붙이던 _summarize (followers/posts 등 데이터
  shape inspection) 제거 — production 로그가 아니라 진단용에 가까워 test_raw.py
  의 summarize() 로 대신 충분.
- enrichment.py / pipeline.py / collect.py: 저레벨 수집기의 timing instrumentation
  은 정리. orchestrator 레벨(pipeline 의 stage_times, analysis/market 의 LLM
  호출 timing)은 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:45:23 +09:00
Mina Choi fa32109658 fix(color_extractor): CSS .logo 패턴 우선순위 + lang/flag noise 필터 강화
문제: JK 성형외과 (jkplastic.com) 처럼 <h1 class="logo"><a>JK PLASTIC</a></h1>
형태로 logo 텍스트만 있고 진짜 이미지는 외부 CSS의 .logo { background-image: url(...) }
로 들어가는 사이트에서, generic <header> 첫 img 패턴이 한국어 깃발(lang-kor.png)을
먼저 잡아 잘못된 로고가 박혔음.

수정:
- find_logo_url_in_html 흐름 재정렬:
  1) class/id/alt/src 명시 + 부모 class="logo" + 중첩 img (specific)
  2) **외부 CSS 의 .logo background-image** ← generic 보다 앞으로 (class-based 라
     더 specific)
  3) <header>/<nav> 첫 img (가장 generic, 잘못 잡힐 위험)
- noise 필터 강화: lang-kor / lang-eng / flag / country / icon- / btn- / arrow /
  prev / next / search 같이 logo 아닌 게 명백한 src 는 모든 단계에서 skip

검증: JK 는 lang-kor.png → logo-color.png 로 정확히 잡힘.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:45:08 +09:00
Mina Choi dca0c78860 fix(url): _with_scheme 강화 — www 자동 보강 + 중첩 https:// 정리 + API 입력 적용
문제 1: gangnamunni.com 의 SSL 인증서가 www.gangnamunni.com 에만 유효 →
  사용자가 'gangnamunni.com/hospitals/189' 같이 줬을 때 클릭 시 브라우저 SSL warning.
문제 2: LLM 출력에 'https://www.facebook.com/https://facebook.com/X' 같이 중첩된
  URL이 가끔 박힘.

수정 (_with_scheme):
- 중첩된 'http(s)://' 발견 시 마지막 URL 만 잘라 사용
- _WWW_REQUIRED 도메인 (gangnamunni / facebook / instagram) 은 bare 도메인이면
  www. 자동 보강

api/analysis.py: main 채널(instagram/facebook/naver_blog/youtube/gangnam_unni)
URL 도 _with_scheme 적용해서 DB에 정규화된 형태로 저장. 이전엔 extra channels
(tiktok/EN/카카오톡/카페) 에만 적용돼있어서 강남언니 같은 main 채널이 빠져있었음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:44:53 +09:00
Mina Choi db42805fdb fix(report): LLM 환각 잠금 — channel mapping 보호 + URL prefix + registry_data
brand_inconsistencies 데이터 보호:
- 채널-묘사 mapping 을 LLM이 swap·재해석해서 Brand Consistency Map 이 어긋났던
  문제 (VIEW 한국페북에 영문 인스타 묘사가 박힌다든가) 해결.
- channel_logos.channel_logos[] 의 channel / logo_description / is_official 을
  **그대로 박을 것** 명시. 절대 swap·변형 금지.

URL 환각 잠금:
- LLM이 'https://www.facebook.com/' 같은 prefix를 raw URL 앞에 붙여서
  'https://www.facebook.com/https://facebook.com/THEPS16445998' 같이 깨지던 문제 차단.
- "URL prefix 절대 직접 만들지 마세요. 받은 URL = 출력 URL" 강제.

registry_data 환각 잠금:
- registry_data.website_en 같은 자유 필드를 LLM이 그럴듯하게 ('thepsclinic.com'
  같이) 지어내던 문제. "데이터에 없으면 반드시 null" 강제.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:44:38 +09:00
Mina Choi 9da285e905 feat(plan): 네이버 블로그 채널 + brand_guide profile_photo 시스템 박기
네이버 블로그 채널 추가:
- naver.fetch_blog_total_count: RSS에 totalCount 없으면 blog.naver.com 의 PostList
  페이지 HTML에서 '(\d+)개의 글' 패턴으로 진짜 전체 글 수 추출
  (RSS는 최근 50개만 줘서 그동안 totalResults=50 으로 잘못 박혔음 — 뷰성형외과 실제 554개)
- analysis._naver_blog_summary 다이어트: totalPosts + latestPostDate 만 LLM에 보냄
  (posts 본문/링크/제목 빼서 토큰 절약 + LLM의 무관 정보 hallucinate 방지)
- plan_prompt: channelStrategies 리스트에 네이버 블로그 명시 포함

brand_guide.channel_branding.profile_photo 코드 박기:
- 기존: LLM이 "공식 로고로 통일 (가이드 미보유)" 같은 fallback 문구 hallucinate
- 수정: analysis._patch_plan 이 모든 채널의 profile_photo 를 brand_assets.logo_description
  으로 일괄 박음 (채널 통일 전략이라 모두 동일 값)
- plan_prompt: "profilePhoto 는 빈 문자열로 두세요 — 시스템이 채웁니다" 명시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:44:18 +09:00
Mina Choi 8c1e513dc0 fix(vision): channel logo describe — 3채널씩 청크 호출로 매칭 정확도 향상
기존: 공식 로고 + 모든 채널 프로필 이미지를 한 번에 묶어 Gemini에 보냄 →
LLM이 채널-이미지 매칭을 헷갈려 같은 묘사를 여러 채널에 복사하는 문제.
VIEW 케이스에서 한국 페북·영문 인스타가 둘 다 "보라/노란 V자형 공식 로고" 묘사로
잘못 박혔음 (실제로는 흰배경 V자 심볼 vs 금색 VIEW로 완전히 다름).

수정: describe_channel_logos를 3채널씩 청크로 분리 + 명시적 이미지 번호 매핑:
- "이미지 1 = 공식 로고, 이미지 2 = Instagram 채널, 이미지 3 = Facebook..." 식
- "공식 로고 묘사를 절대 복사하지 마세요" 강한 지시
- 청크별 병렬 호출 (asyncio.gather)
- inconsistency_summary / recommendation 은 LLM 한 번 더 안 부르고 결정적 산출

비용: 호출 1회 → 청크 수 만큼 (보통 2회), 페니 수준 증가
시간: 병렬이라 거의 동일
정확도: 사용자가 본 실제 묘사와 일치하게 됨 (개별 호출 테스트로 검증)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:44:00 +09:00
Mina Choi 652265cd19 페북 수집·지표·저장 파이프라인 정리
수집:
- pages + posts 두 actor 병렬 호출 (facebook-pages-scraper, facebook-posts-scraper)
- 저장 필드 슬림화: 페이지 메타에서 likes/rating/email/phone/address 제거
  (followers/reviews와 중복이거나 클리닉 raw_data에 이미 있음)
- 게시물 저장은 캡션 160자 + likes/reactions/shares/views/isVideo/timestamp만

지표 계산 위치 이동: 리포트 시점 → 수집 시점:
- recent_post_age / post_frequency / engagement 를 transform_for_storage에서
  결정적으로 산출해 DB에 박음 (재계산 불필요)
- 저장된 게시물은 LLM용 캡션·타입 2필드만 — 추가 슬림 단계 제거

리팩토링:
- services/facebook_audit.py 신설 (instagram_audit 패턴) — _build_overrides의
  인라인 클로저(_fb_page_patch)와 analysis.py의 _fb_post_metrics 분리
- collect.py / enrichment.py 가 transform_for_storage를 호출하도록

엔게이지먼트 표기:
- 범위(min~max)로 표시, 전부 0인 지표는 제외
- 댓글은 actor 미제공이라 "댓글 거의 없음" 고정 부가

콘텐츠 유형:
- top_content_type 은 캡션 본문 주제 추론이 필요해 LLM에 위임
- report_prompt.txt 에 facebook_audit.pages[].top_content_type 작성 지침 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:49:22 +09:00
Mina Choi 4f756cf001 인스타 highlights/계정 수집 개선 (VIEW actor + 코드로 계정 구성)
- apify: 프로필 coderx, 하이라이트 igview actor로 교체. highlights/category/
  following(followsCount)/profileImage(hdProfilePicUrl)/latestPosts.mediaType 수집.
  reel 스크래퍼 제거, post 스크래퍼 비활성화(주석)
- instagram_audit.py(신규): KR·EN 계정 hard 필드를 수집 데이터로 구성
- analysis: _build_overrides에서 위 함수로 계정 구성, _patch_report가 accounts를
  코드값으로 주입 (LLM은 diagnosis만, 프롬프트에서 accounts는 []로 두게 지시)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:43:03 +09:00
Mina Choi 163e9d1c02 리포트/플랜에 브랜드·영문채널 반영
- overrides에 brandAssets·영문 인스타/페북 audit 보장 (채널별 빌더 분리)
- logoRules·other_channels·channel_scores 프롬프트 수정, 스키마 입력 필드 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:24:21 +09:00
Mina Choi 4855d44381 수집 파이프라인 통합 (enrichment 분리, raw_data merge 헬퍼)
- enrichment.py: brand_assets/extra_channels/channel_logos 수집 분리
- db.merge_hospital_raw_data: raw_data read-modify-write 헬퍼
- utils: _run_optional_step·URL 헬퍼 공통화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:27:39 +09:00
Mina Choi 843ccdb806 브랜드 자산(로고/색상)·채널 로고 Vision 분석 추가
- color_extractor: 홈페이지 HTML/CSS에서 로고 URL·브랜드 hex 추출
- vision: Gemini Vision 로고 묘사·채널 로고 일치 평가
- youtube: 채널 profileImage 추출 / firecrawl: clinic_info 추출 보정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:27:39 +09:00
Mina Choi 9817b53be1 틱톡·영문 인스타/페북 채널 수집 추가
- apify: 틱톡 프로필 액터
- mock_urls.py: 클리닉별 채널 URL 매핑 (mockUrls.json → 파이썬 모듈)
- api/analysis: homepage 매칭으로 미지원 채널 보충 (추후 DB)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:27:39 +09:00
27 changed files with 3046 additions and 281 deletions

View File

@ -8,10 +8,38 @@ from models.file import FileListItem, FileType, FileUploadResponse
from models.status import AnalysisStatus from models.status import AnalysisStatus
from services.pipeline import run_pipeline from services.pipeline import run_pipeline
from services.file import get_analysis_files_response, handle_analysis_file_upload, soft_delete_analysis_file from services.file import get_analysis_files_response, handle_analysis_file_upload, soft_delete_analysis_file
from mock_urls import MOCK_CLINICS
from common.utils import _normalize_homepage, _with_scheme
router = APIRouter(prefix="/api/analysis", tags=["analysis"], dependencies=[Depends(verify_api_key)]) router = APIRouter(prefix="/api/analysis", tags=["analysis"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 추후 DB에 클리닉별로 매핑할 채널들 — 지금은 mock_urls에서 homepage 매칭으로 보충.
# 메인 채널(IG/FB/YT/네이버블로그/강남언니) + 부가 채널(틱톡/영문 IG·FB/카카오/네이버카페) 모두 포함.
# 클라가 일부만 보내거나 빈 값이면 mock에서 동일 hospital을 찾아 채워줌.
def _channels_from_mockurls(homepage_url: str) -> dict:
target = _normalize_homepage(homepage_url)
if not target:
return {}
for c in MOCK_CLINICS:
urls = c["urls"]
if _normalize_homepage(urls.get("homepage", "")) == target:
return {
# main
"instagram": _with_scheme(urls.get("instagram")),
"facebook": _with_scheme(urls.get("facebook")),
"naver_blog": _with_scheme(urls.get("naverBlog")),
"youtube": _with_scheme(urls.get("youtube")),
"gangnam_unni": _with_scheme(urls.get("gangnamUnni")),
# extra
"tiktok": _with_scheme(urls.get("tiktok")),
"instagram_en": _with_scheme(urls.get("instagramEn")),
"facebook_en": _with_scheme(urls.get("facebookEn")),
"kakao_talk": _with_scheme(urls.get("kakaoTalk")),
"naver_cafe": _with_scheme(urls.get("naverCafe")),
}
return {}
@router.post("", status_code=status.HTTP_202_ACCEPTED, response_model=AnalysisStartResponse) @router.post("", status_code=status.HTTP_202_ACCEPTED, response_model=AnalysisStartResponse)
async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks): async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks):
@ -27,18 +55,36 @@ async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks
if not hospital: if not hospital:
raise HTTPException(status_code=409, detail="Clinic not found") raise HTTPException(status_code=409, detail="Clinic not found")
ig_id = await insert_instagram_row(hospital_id, body.channels.instagram) if body.channels.instagram else None # 클라가 안 보낸 채널은 mock_urls에서 homepage 매칭으로 보충 (main + extra 동일 규칙)
fb_id = await insert_facebook_row(hospital_id, body.channels.facebook) if body.channels.facebook else None mock = _channels_from_mockurls(hospital["url"])
nb_id = await insert_naver_blog_row(hospital_id, body.channels.naver_blog) if body.channels.naver_blog else None
yt_id = await insert_youtube_row(hospital_id, body.channels.youtube) if body.channels.youtube else None # 사용자가 'gangnamunni.com/...' 같이 scheme/www 없이 줘도 _with_scheme이 https://www. 보강.
gu_id = await insert_gangnam_unni_row(hospital_id, body.channels.gangnam_unni) if body.channels.gangnam_unni else None ig_url = _with_scheme(body.channels.instagram) or mock.get("instagram")
fb_url = _with_scheme(body.channels.facebook) or mock.get("facebook")
nb_url = _with_scheme(body.channels.naver_blog) or mock.get("naver_blog")
yt_url = _with_scheme(body.channels.youtube) or mock.get("youtube")
gu_url = _with_scheme(body.channels.gangnam_unni) or mock.get("gangnam_unni")
ig_id = await insert_instagram_row(hospital_id, ig_url) if ig_url else None
fb_id = await insert_facebook_row(hospital_id, fb_url) if fb_url else None
nb_id = await insert_naver_blog_row(hospital_id, nb_url) if nb_url else None
yt_id = await insert_youtube_row(hospital_id, yt_url) if yt_url else None
gu_id = await insert_gangnam_unni_row(hospital_id, gu_url) if gu_url else None
analysis_run_id = await insert_analysis_run( analysis_run_id = await insert_analysis_run(
analysis_run_id, hospital_id, hospital["owner_user_id"], analysis_run_id, hospital_id, hospital["owner_user_id"],
ig_id, fb_id, nb_id, yt_id, gu_id, ig_id, fb_id, nb_id, yt_id, gu_id,
) )
background_tasks.add_task(run_pipeline, analysis_run_id) extra_channels = {
"tiktok": body.channels.tiktok or mock.get("tiktok"),
"instagram_en": body.channels.instagram_en or mock.get("instagram_en"),
"facebook_en": body.channels.facebook_en or mock.get("facebook_en"),
"kakao_talk": body.channels.kakao_talk or mock.get("kakao_talk"),
"naver_cafe": body.channels.naver_cafe or mock.get("naver_cafe"),
}
logger.info("[analysis] main+extra channels resolved (mock_matched=%s)", bool(mock))
background_tasks.add_task(run_pipeline, analysis_run_id, extra_channels)
return AnalysisStartResponse( return AnalysisStartResponse(
analysis_run_id=analysis_run_id, analysis_run_id=analysis_run_id,

View File

@ -1,8 +1,9 @@
import json import json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from common.db import fetchone from common.db import fetchone, fetch_raw
from common.deps import verify_api_key from common.deps import verify_api_key
from common.utils import _with_scheme
from integrations.llm.schemas.plan import PlanOutput from integrations.llm.schemas.plan import PlanOutput
from models.plan import PlanApiResponse from models.plan import PlanApiResponse
@ -14,7 +15,7 @@ logger = logging.getLogger(__name__)
async def get_plan(run_id: str): async def get_plan(run_id: str):
logger.info("GET /api/plan/%s", run_id) logger.info("GET /api/plan/%s", run_id)
row = await fetchone( row = await fetchone(
"SELECT ar.plan_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url" "SELECT ar.plan_data, ar.created_at, ar.gangnam_unni_data_id, h.hospital_name, h.hospital_name_en, h.url"
" FROM analysis_runs ar" " FROM analysis_runs ar"
" JOIN hospital_baseinfo h ON ar.hospital_id = h.hospital_id" " JOIN hospital_baseinfo h ON ar.hospital_id = h.hospital_id"
" WHERE ar.analysis_run_id = %s", " WHERE ar.analysis_run_id = %s",
@ -26,11 +27,13 @@ async def get_plan(run_id: str):
return Response(status_code=204) return Response(status_code=204)
data = json.loads(row["plan_data"]) if isinstance(row["plan_data"], str) else row["plan_data"] data = json.loads(row["plan_data"]) if isinstance(row["plan_data"], str) else row["plan_data"]
plan = PlanOutput(**data) plan = PlanOutput(**data)
gangnam_unni = await fetch_raw("gangnam_unni_data", row["gangnam_unni_data_id"]) or {}
clinic_name = gangnam_unni.get("name") or row["hospital_name"]
return PlanApiResponse( return PlanApiResponse(
id=run_id, id=run_id,
clinic_name=row["hospital_name"], clinic_name=clinic_name,
clinic_name_en=row["hospital_name_en"], clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]), created_at=str(row["created_at"]),
target_url=row["url"], target_url=_with_scheme(row["url"]),
**plan.model_dump(), **plan.model_dump(),
) )

View File

@ -3,6 +3,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from common.db import fetchone from common.db import fetchone
from common.deps import verify_api_key from common.deps import verify_api_key
from common.utils import _with_scheme
from integrations.llm.schemas.report import ReportOutput from integrations.llm.schemas.report import ReportOutput
from models.report import MarketingReportResponse from models.report import MarketingReportResponse
@ -31,6 +32,6 @@ async def get_report(run_id: str):
clinic_name=row["hospital_name"], clinic_name=row["hospital_name"],
clinic_name_en=row["hospital_name_en"], clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]), created_at=str(row["created_at"]),
target_url=row["url"], target_url=_with_scheme(row["url"]),
**llm_output.model_dump(exclude={"id", "created_at", "target_url"}), **llm_output.model_dump(exclude={"id", "created_at", "target_url"}),
) )

View File

@ -263,6 +263,19 @@ async def save_hospital_raw_data(hospital_id: str, data: dict, analysis_run_id:
await _insert_hospital_history(hospital_id, analysis_run_id) await _insert_hospital_history(hospital_id, analysis_run_id)
async def merge_hospital_raw_data(hospital_id: str, patch: dict) -> None:
"""hospital_baseinfo.raw_data를 읽어 patch를 top-level 병합 후 저장 (read-modify-write).
부가 수집 단계들이 순차로 raw_data에 키를 덧붙일 사용."""
row = await fetchone("SELECT raw_data FROM hospital_baseinfo WHERE hospital_id = %s", (hospital_id,))
raw = row["raw_data"] if row else None
raw_data = json.loads(raw) if isinstance(raw, str) else (raw or {})
raw_data.update(patch)
await execute(
"UPDATE hospital_baseinfo SET raw_data = %s WHERE hospital_id = %s",
(json.dumps(raw_data, ensure_ascii=False), hospital_id),
)
async def get_market_analysis(analysis_run_id: str) -> dict: async def get_market_analysis(analysis_run_id: str) -> dict:
rows = await fetchall( rows = await fetchall(
"SELECT analysis_type, data FROM market_analysis WHERE analysis_run_id = %s AND status = 'done'", "SELECT analysis_type, data FROM market_analysis WHERE analysis_run_id = %s AND status = 'done'",

View File

@ -1,8 +1,11 @@
import os import os
import asyncio import asyncio
import logging
from http import HTTPMethod from http import HTTPMethod
import httpx import httpx
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = 60 REQUEST_TIMEOUT = 60
@ -37,3 +40,48 @@ async def http_request(
print(f" [error] {label}{e}") print(f" [error] {label}{e}")
return None return None
return None return None
async def _run_optional_step(coro, label: str) -> None:
"""부가 단계 실행 헬퍼: 예외를 삼키고 경고 로그만 남겨 호출측 흐름이 멈추지 않게 격리."""
try:
await coro
except Exception as e:
logger.warning("%s 실패 (무시하고 진행): %s", label, e)
def _normalize_homepage(url: str) -> str:
"""URL을 scheme/www/끝슬래시 제거 + 소문자로 정규화 (homepage 매칭용)."""
u = (url or "").strip().lower()
for p in ("https://", "http://"):
if u.startswith(p):
u = u[len(p):]
if u.startswith("www."):
u = u[4:]
return u.rstrip("/")
# SSL 인증서가 www.* 에만 유효한 도메인 — bare 도메인이면 사용자 클릭 시 브라우저 SSL warning 뜸.
_WWW_REQUIRED = ("gangnamunni.com", "facebook.com", "instagram.com", "toxnfill.com")
def _with_scheme(u: str | None) -> str | None:
"""scheme 없는 URL에 https:// 보정 (수집기/링크 표시용). 빈 값은 None.
+ 중첩된 https:// 끼어있으면 마지막 URL만 추출 (LLM이 가끔 'https://www.X/https://Y' 같이 만듦).
+ SSL 엄격 도메인(gangnamunni/facebook/instagram) www. 자동 보강."""
if not u:
return None
u = u.strip()
# 'https://www.facebook.com/https://facebook.com/X' 같은 중첩 → 마지막 'http(s)://' 부터 잘라 사용
last = max(u.rfind("https://"), u.rfind("http://"))
if last > 0:
u = u[last:]
if "://" not in u:
u = "https://" + u
# scheme 뒤가 www. 없이 SSL 엄격 도메인이면 www. 추가
for dom in _WWW_REQUIRED:
for scheme in ("https://", "http://"):
if u.startswith(scheme + dom):
u = scheme + "www." + u[len(scheme):]
break
return u

View File

@ -1,16 +1,32 @@
import asyncio
from http import HTTPMethod from http import HTTPMethod
from urllib.parse import urlparse from urllib.parse import urlparse
from common.utils import http_request from common.utils import http_request
APIFY_BASE = "https://api.apify.com/v2" APIFY_BASE = "https://api.apify.com/v2"
# Instagram: profile + highlights 두 actor 직접 호출.
IG_PROFILE_ACTOR = "coderx~instagram-profile-scraper-bio-posts"
IG_HIGHLIGHTS_ACTOR = "igview-owner~instagram-highlights-scraper"
# Facebook: pages + posts 두 actor 직접 호출.
FB_PAGES_ACTOR = "apify~facebook-pages-scraper"
FB_POSTS_ACTOR = "apify~facebook-posts-scraper"
# TikTok
TIKTOK_ACTOR = "clockworks~tiktok-scraper"
def _ig_username(url: str) -> str:
return urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
class ApifyClient: class ApifyClient:
def __init__(self, token: str, wait_for_finish: int = 120): def __init__(self, token: str, wait_for_finish: int = 120):
self.token = token self.token = token
self.wait_for_finish = wait_for_finish self.wait_for_finish = wait_for_finish
async def _run_actor(self, actor_id: str, input_data: dict) -> list[dict]: async def _run_actor(self, actor_id: str, input_data: dict, limit: int = 20) -> list[dict]:
resp = await http_request( resp = await http_request(
HTTPMethod.POST, HTTPMethod.POST,
url=f"{APIFY_BASE}/acts/{actor_id}/runs", url=f"{APIFY_BASE}/acts/{actor_id}/runs",
@ -26,33 +42,53 @@ class ApifyClient:
items_resp = await http_request( items_resp = await http_request(
HTTPMethod.GET, HTTPMethod.GET,
url=f"{APIFY_BASE}/datasets/{dataset_id}/items", url=f"{APIFY_BASE}/datasets/{dataset_id}/items",
params={"token": self.token, "limit": 20}, params={"token": self.token, "limit": limit},
label=f"apify-dataset-{dataset_id}", label=f"apify-dataset-{dataset_id}",
) )
if not items_resp or not items_resp.is_success: if not items_resp or not items_resp.is_success:
return [] return []
return items_resp.json() return items_resp.json()
async def fetch_instagram_profile(self, url: str) -> dict | None: async def fetch_instagram_profile(self, username: str) -> dict | None:
username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@") items = await self._run_actor(IG_PROFILE_ACTOR, {"usernames": [username]})
items = await self._run_actor("apify~instagram-profile-scraper", {"usernames": [username], "resultsLimit": 12})
return items[0] if items else None return items[0] if items else None
async def fetch_instagram_highlights(self, username: str) -> list[dict]:
return await self._run_actor(IG_HIGHLIGHTS_ACTOR, {"usernames": [username]})
async def get_instagram_profile(self, url: str) -> dict | None: async def get_instagram_profile(self, url: str) -> dict | None:
profile = await self.fetch_instagram_profile(url) username = _ig_username(url)
if not profile or profile.get("error"): # profile·highlights 두 actor를 병렬 호출 (highlights 실패해도 profile만 있으면 진행)
profile, highlights = await asyncio.gather(
self.fetch_instagram_profile(username),
self.fetch_instagram_highlights(username),
return_exceptions=True,
)
if isinstance(profile, Exception) or not profile or profile.get("error"):
return None return None
if isinstance(highlights, Exception):
highlights = []
# 프로필상 하이라이트가 있다고 하면(highlight_reel_count>0) 빈 결과일 때 최대 2회 재시도.
if not highlights and (profile.get("highlight_reel_count", 0) or profile.get("highlightReelCount", 0)) > 0:
for _ in range(2):
retry = await self.fetch_instagram_highlights(username)
if retry:
highlights = retry
break
return { return {
"username": profile["username"], "username": profile["username"],
"profileImage": profile.get("hdProfilePicUrl") or profile.get("profilePicUrl"),
"followers": profile.get("followersCount", 0), "followers": profile.get("followersCount", 0),
"following": profile.get("followsCount", 0), "following": profile.get("followsCount", 0),
"posts": profile.get("postsCount", 0), "posts": profile.get("postsCount", 0),
"bio": profile.get("biography", ""), "bio": profile.get("biography", ""),
"category": profile.get("businessCategoryName") or "",
"isBusinessAccount": profile.get("isBusinessAccount", False), "isBusinessAccount": profile.get("isBusinessAccount", False),
#"externalUrl": profile.get("externalUrl"), LLM에 혼동을 주는 듯 하여 비활성화 #"externalUrl": profile.get("externalUrl"), LLM에 혼동을 주는 듯 하여 비활성화
"highlights": [h["highlightTitle"] for h in (highlights or []) if isinstance(h, dict) and h.get("highlightTitle")],
"latestPosts": [ "latestPosts": [
{ {
"type": p.get("type"), "type": p.get("mediaType") or p.get("type"),
"likes": p.get("likesCount", 0), "likes": p.get("likesCount", 0),
"comments": p.get("commentsCount", 0), "comments": p.get("commentsCount", 0),
"caption": (p.get("caption") or "")[:500], "caption": (p.get("caption") or "")[:500],
@ -62,87 +98,119 @@ class ApifyClient:
], ],
} }
async def fetch_instagram_posts(self, url: str, limit: int = 20) -> list[dict]: # 인스타 post 스크래퍼는 현재 파이프라인 미사용 — 비활성화 (필요 시 복구)
username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@") # async def fetch_instagram_posts(self, url: str, limit: int = 20) -> list[dict]:
return await self._run_actor("apify~instagram-post-scraper", { # username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
"username": [f"https://www.instagram.com/{username}/"], # return await self._run_actor("apify~instagram-post-scraper", {
"resultsLimit": limit, # "directUrls": [f"https://www.instagram.com/{username}/"],
}) # "resultsLimit": limit,
# })
async def get_instagram_posts(self, url: str, limit: int = 20) -> dict: #
items = await self.fetch_instagram_posts(url, limit) # async def get_instagram_posts(self, url: str, limit: int = 20) -> dict:
posts = [ # items = await self.fetch_instagram_posts(url, limit)
{ # posts = [
"id": p["id"], # {
"type": p.get("type"), # "id": p["id"],
"url": p.get("url"), # "type": p.get("type"),
"caption": (p.get("caption") or "")[:500], # "url": p.get("url"),
"hashtags": p.get("hashtags", []), # "caption": (p.get("caption") or "")[:500],
"likesCount": p.get("likesCount", 0), # "hashtags": p.get("hashtags", []),
"commentsCount": p.get("commentsCount", 0), # "likesCount": p.get("likesCount", 0),
"timestamp": p.get("timestamp"), # "commentsCount": p.get("commentsCount", 0),
} # "timestamp": p.get("timestamp"),
for p in items # }
] # for p in items
n = len(posts) or 1 # ]
return { # n = len(posts) or 1
"posts": posts, # return {
"totalPosts": len(posts), # "posts": posts,
"avgLikes": round(sum(p["likesCount"] for p in posts) / n), # "totalPosts": len(posts),
"avgComments": round(sum(p["commentsCount"] for p in posts) / n), # "avgLikes": round(sum(p["likesCount"] for p in posts) / n),
} # "avgComments": round(sum(p["commentsCount"] for p in posts) / n),
# }
async def fetch_instagram_reels(self, url: str, limit: int = 15) -> list[dict]:
username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
print(username)
return await self._run_actor("apify~instagram-reel-scraper", {
"username": [f"https://www.instagram.com/{username}/reels/"],
"resultsLimit": limit,
})
async def get_instagram_reels(self, url: str, limit: int = 15) -> dict:
items = await self.fetch_instagram_reels(url, limit)
reels = [
{
"id": r["id"],
"url": r.get("url"),
"caption": (r.get("caption") or "")[:500],
"hashtags": r.get("hashtags", []),
"likesCount": r.get("likesCount", 0),
"commentsCount": r.get("commentsCount", 0),
"videoViewCount": r.get("videoViewCount", 0),
"videoPlayCount": r.get("videoPlayCount", 0),
"videoDuration": r.get("videoDuration", 0),
"timestamp": r.get("timestamp"),
}
for r in items
]
n = len(reels) or 1
return {
"reels": reels,
"totalReels": len(reels),
"avgViews": round(sum(r["videoViewCount"] for r in reels) / n),
"avgPlays": round(sum(r["videoPlayCount"] for r in reels) / n),
}
async def fetch_facebook_page(self, page_url: str) -> dict | None: async def fetch_facebook_page(self, page_url: str) -> dict | None:
items = await self._run_actor("apify~facebook-pages-scraper", {"startUrls": [{"url": page_url}]}) items = await self._run_actor(FB_PAGES_ACTOR, {"startUrls": [{"url": page_url}]})
return items[0] if items else None return items[0] if items else None
async def fetch_facebook_posts(self, page_url: str, limit: int = 20) -> list[dict]:
return await self._run_actor(
FB_POSTS_ACTOR, {"startUrls": [{"url": page_url}], "resultsLimit": limit}, limit=limit,
)
async def get_facebook_page(self, page_url: str) -> dict | None: async def get_facebook_page(self, page_url: str) -> dict | None:
page = await self.fetch_facebook_page(page_url) # pages·posts 두 task 병렬 호출 (posts 실패해도 page만 있으면 진행)
if not page: page, posts = await asyncio.gather(
self.fetch_facebook_page(page_url),
self.fetch_facebook_posts(page_url),
return_exceptions=True,
)
if isinstance(page, Exception) or not page:
return None return None
if isinstance(posts, Exception):
posts = []
return { return {
"pageName": page.get("title") or page.get("name"), "pageName": page.get("title") or page.get("name"),
"profileImage": page.get("profilePictureUrl") or page.get("profilePhoto") or page.get("profilePic"),
"pageUrl": page.get("pageUrl", page_url), "pageUrl": page.get("pageUrl", page_url),
"followers": page.get("followers", 0), "followers": page.get("followers", 0),
"likes": page.get("likes", 0), "following": page.get("followings", 0),
"reviews": page.get("ratingCount", 0),
"categories": page.get("categories", []), "categories": page.get("categories", []),
"email": page.get("email"), "website": page.get("website") or page.get("websites"),
"phone": page.get("phone"),
"website": page.get("website"),
"address": page.get("address"),
"intro": page.get("intro"), "intro": page.get("intro"),
"rating": page.get("rating"), "latestPosts": [
{
"text": (p.get("text") or "")[:160],
"likes": p.get("likes", 0),
"reactions": p.get("topReactionsCount", 0),
"shares": p.get("shares", 0),
"views": p.get("viewsCount") or 0,
"isVideo": p.get("isVideo", False),
"timestamp": p.get("time") or p.get("timestamp"),
}
for p in (posts or []) if isinstance(p, dict)
],
}
async def fetch_tiktok_profile(self, url: str) -> list[dict]:
user = urlparse(url).path.strip("/").lstrip("@").split("/")[0] if "://" in url else url.lstrip("@")
return await self._run_actor(TIKTOK_ACTOR, {
"profiles": [user],
"resultsPerPage": 10,
"profileScrapeSections": ["videos"],
"profileSorting": "latest",
"shouldDownloadVideos": False,
"shouldDownloadCovers": False,
"shouldDownloadSubtitles": False,
})
async def get_tiktok_profile(self, url: str) -> dict | None:
items = await self.fetch_tiktok_profile(url)
if not items:
return None
author = (items[0] or {}).get("authorMeta") or {}
videos = [
{
"title": (v.get("text") or "")[:300],
"playCount": v.get("playCount", 0),
"diggCount": v.get("diggCount", 0),
"commentCount": v.get("commentCount", 0),
"shareCount": v.get("shareCount", 0),
"createTime": v.get("createTimeISO"),
"url": v.get("webVideoUrl"),
}
for v in items if isinstance(v, dict)
]
return {
"handle": author.get("name"),
"profileImage": author.get("avatar"),
"nickname": author.get("nickName"),
"followers": author.get("fans", 0),
"following": author.get("following", 0),
"likes": author.get("heart", 0),
"videoCount": author.get("video", 0),
"verified": author.get("verified", False),
"bio": author.get("signature", ""),
"recentVideos": videos[:10],
} }

View File

@ -0,0 +1,275 @@
"""홈페이지 HTML/CSS에서 hex 색상 직접 추출 + 빈도 기반 brand palette 산출.
Vision LLM에 의존하지 않고 페이지의 실제 CSS 값을 정규식으로 잡음.
로고만 분석하는 Vision보다 사이트 전체 컬러 시스템 (primary/secondary/background/text) 정확히 추출.
"""
import logging
import re
import ssl
from collections import Counter
from urllib.parse import urljoin, urlparse
import httpx
logger = logging.getLogger(__name__)
def _make_ssl_context() -> ssl.SSLContext:
"""오래된 한국 의료 사이트들이 SSL DH_KEY_TOO_SMALL / cipher 약함 등으로 차단되는 문제 우회.
보안 등급 1 낮춤 + cert 검증 유지."""
ctx = ssl.create_default_context()
try:
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
except ssl.SSLError:
pass
return ctx
async def _fetch_html(url: str, timeout: float = 20.0) -> tuple[int, str]:
"""SSL/검증 단계별 fallback으로 HTML 받기. 그랜드/톡스앤필 같은 oldsite 대응."""
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
# 1차: 표준 검증
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as c:
r = await c.get(url)
return r.status_code, r.text
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError) as e:
logger.info("[fetch] %s standard SSL failed: %s — fallback to weak cipher", url, e)
# 2차: 약한 cipher 허용
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers, verify=_make_ssl_context()) as c:
r = await c.get(url)
return r.status_code, r.text
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError) as e:
logger.info("[fetch] %s weak cipher failed: %s — fallback to verify=False", url, e)
# 3차: SSL 검증 끔 (host mismatch 등)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers, verify=False) as c:
r = await c.get(url)
return r.status_code, r.text
except Exception as e:
logger.warning("[fetch] %s all fallbacks failed: %s", url, e)
return 0, ""
LOGO_IMG_PATTERNS = [
# 1) <img class="...logo..." src="...">
re.compile(r'<img[^>]*\bclass=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
# 2) <img src="..." class="...logo...">
re.compile(r'<img[^>]*\bsrc=["\']([^"\']+)["\'][^>]*\bclass=["\'][^"\']*\blogo\b[^"\']*["\']', re.IGNORECASE),
# 3) <img id="...logo..." src="...">
re.compile(r'<img[^>]*\bid=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
# 4) <img alt="...logo..." src="...">
re.compile(r'<img[^>]*\balt=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
# 5) <a/h1 class="logo"><...nested...><img src="...">
re.compile(r'<(?:a|h[1-6]|div|span)[^>]*\b(?:class|id)=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*>(?:[^<]|<(?!img))*<img[^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE | re.DOTALL),
# 6) inline background-image: <a/div class="logo" style="background-image: url(...)">
re.compile(r'<(?:a|div|span|h[1-6])[^>]*\b(?:class|id)=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bstyle=["\'][^"\']*background(?:-image)?\s*:\s*url\(\s*["\']?([^"\')\s]+)', re.IGNORECASE),
# 7) inline background-image: <a/div style="background-image: url(...)" class="logo"> (속성 순서 반대)
re.compile(r'<(?:a|div|span|h[1-6])[^>]*\bstyle=["\'][^"\']*background(?:-image)?\s*:\s*url\(\s*["\']?([^"\')\s]+)[^"\']*["\'][^>]*\b(?:class|id)=["\'][^"\']*\blogo\b', re.IGNORECASE),
# 8) src 자체에 "logo" 포함 (header_logo.png, brand-logo.svg 등)
re.compile(r'<img[^>]*\bsrc=["\']([^"\']*\blogo\b[^"\']*\.(?:png|svg|jpe?g|webp)[^"\']*)["\']', re.IGNORECASE),
# 9) <header>...<img src="..."> (헤더 영역 첫 img)
re.compile(r'<header\b[^>]*>(?:[^<]|<(?!img))*<img[^>]*\bsrc=["\']([^"\']+\.(?:png|svg|jpe?g|webp)[^"\']*)["\']', re.IGNORECASE | re.DOTALL),
# 10) <nav>...<img src="..."> (nav 영역 첫 img)
re.compile(r'<nav\b[^>]*>(?:[^<]|<(?!img))*<img[^>]*\bsrc=["\']([^"\']+\.(?:png|svg|jpe?g|webp)[^"\']*)["\']', re.IGNORECASE | re.DOTALL),
# 11) Open Graph image (대표 이미지) - 최후 fallback
re.compile(r'<meta[^>]*\bproperty=["\']og:image["\'][^>]*\bcontent=["\']([^"\']+)["\']', re.IGNORECASE),
re.compile(r'<meta[^>]*\bcontent=["\']([^"\']+)["\'][^>]*\bproperty=["\']og:image["\']', re.IGNORECASE),
]
# CSS 파일에서 .logo { background-image: url(...) } 추출용
LOGO_CSS_PATTERN = re.compile(
r'\.[\w-]*\blogo\b[\w-]*\s*(?:,\s*\.[\w-]+\s*)*\{[^}]*background(?:-image)?\s*:\s*url\(\s*["\']?([^"\')\s]+)',
re.IGNORECASE | re.DOTALL,
)
def find_logo_url_in_html(html: str, base_url: str, css_texts: list[str] | None = None) -> str | None:
"""HTML에서 logo URL 찾기. 우선순위:
1) 패턴 1~8 (class/id/alt/src에 'logo' 명시된 img 가장 specific)
2) 외부 CSS의 .logo background-image (class-based, specific)
3) 패턴 9~10 (<header>/<nav> img 가장 generic, 잘못 잡힐 위험 )
"""
def _is_noise(src: str) -> bool:
"""logo로 잘못 잡힐 가능성 높은 URL 패턴 — lang/flag/icon/arrow/spacer 등."""
if not src or src.startswith("data:"):
return True
if re.search(r"(blank|spacer|pixel|transparent|1x1)\b", src, re.IGNORECASE):
return True
# 헤더 첫 img가 lang flag / 검색 아이콘 / 네비 화살표인 경우 (JK plastic 한국어 깃발이 잡히던 케이스)
if re.search(r"(lang[-_]?(kor|eng|chn|jpn|rus|jp|en|ko|cn|ar|in)|flag|country|icon-|btn-|arrow|prev|next|search)\b", src, re.IGNORECASE):
return True
return False
# 1) class/id/alt/src/inline-bg/src-with-logo 패턴 (1~8)
for pat in LOGO_IMG_PATTERNS[:8]:
for m in pat.finditer(html):
src = m.group(1)
if _is_noise(src):
continue
return urljoin(base_url, src)
# 2) 외부 CSS의 .logo { background-image } — class-based 이므로 generic 패턴보다 우선
for css in (css_texts or []):
m = LOGO_CSS_PATTERN.search(css)
if m:
src = m.group(1)
if not _is_noise(src):
return urljoin(base_url, src)
# 3) header/nav 첫 img — 가장 generic, lang flag 등 noise 필터 강화 적용
for pat in LOGO_IMG_PATTERNS[8:]:
for m in pat.finditer(html):
src = m.group(1)
if _is_noise(src):
continue
return urljoin(base_url, src)
return None
HEX6 = re.compile(r"#([0-9a-fA-F]{6})\b")
HEX3 = re.compile(r"#([0-9a-fA-F]{3})\b(?![0-9a-fA-F])")
RGB = re.compile(r"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)")
CSS_VAR_HEX = re.compile(r"--[\w-]+\s*:\s*(#[0-9a-fA-F]{3,8})", re.IGNORECASE)
CSS_LINK = re.compile(r'<link[^>]+rel=["\']stylesheet["\'][^>]+href=["\']([^"\']+)["\']', re.IGNORECASE)
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.IGNORECASE | re.DOTALL)
# 무채색·아주 흔한 노이즈 컬러 (이런 건 brand color로 잡지 않음)
NOISE = {
"#ffffff", "#000000", "#fff", "#000",
"#333", "#222", "#111", "#444", "#555", "#666", "#777", "#888", "#999",
"#aaa", "#bbb", "#ccc", "#ddd", "#eee", "#f0f0f0", "#f5f5f5", "#fafafa",
}
def _normalize(hex_str: str) -> str:
h = hex_str.lstrip("#").lower()
if len(h) == 3:
h = "".join(c * 2 for c in h)
if len(h) == 8:
h = h[:6]
return f"#{h}"
def _rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _distance(a: str, b: str) -> float:
ar, ag, ab = _hex_to_rgb(a)
br, bg, bb = _hex_to_rgb(b)
return ((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2) ** 0.5
def _is_grayscale(h: str, tol: int = 12) -> bool:
r, g, b = _hex_to_rgb(h)
return max(r, g, b) - min(r, g, b) < tol
def _extract_hex(text: str) -> list[str]:
"""텍스트에서 모든 hex 색상 추출 (정규화)."""
out: list[str] = []
out.extend(_normalize(m.group(0)) for m in HEX6.finditer(text))
out.extend(_normalize(m.group(0)) for m in HEX3.finditer(text))
for m in RGB.finditer(text):
r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
out.append(_rgb_to_hex(r, g, b))
return out
def _cluster(colors: Counter, threshold: float = 25.0) -> list[tuple[str, int]]:
"""비슷한 색은 묶음. 가장 빈도 높은 색을 대표로."""
ranked = colors.most_common()
clusters: list[tuple[str, int]] = []
for color, count in ranked:
merged = False
for i, (rep, rep_count) in enumerate(clusters):
if _distance(color, rep) < threshold:
clusters[i] = (rep, rep_count + count)
merged = True
break
if not merged:
clusters.append((color, count))
return clusters
async def _fetch_html_and_css(homepage_url: str, max_css_files: int = 8) -> tuple[str, list[str]]:
"""홈페이지 HTML + 외부 CSS(Top N)를 한 번에 fetch. 로고/색상 추출이 사이트를 중복으로 긁지 않도록 공유.
_fetch_html이 SSL 약함/host mismatch까지 fallback 처리. 실패 ("", [])."""
status, html = await _fetch_html(homepage_url)
if status != 200 or not html:
logger.warning("[color_extractor] homepage fetch failed status=%s url=%s", status, homepage_url)
return "", []
css_texts: list[str] = []
for css_href in CSS_LINK.findall(html)[:max_css_files]:
cstatus, ctext = await _fetch_html(urljoin(homepage_url, css_href), timeout=15.0)
if cstatus == 200 and ctext:
css_texts.append(ctext)
return html, css_texts
def _colors_from_text(html: str, css_texts: list[str], source_url: str = "") -> dict:
"""이미 받아온 HTML + CSS 텍스트에서 hex 빈도 분석 → primary/accent/text + palette. (fetch 없음, 순수 계산)"""
# 1. HTML 내 <style> 블록 + 통째(inline style="color:#...") + 외부 CSS
all_text_chunks: list[str] = list(STYLE_BLOCK.findall(html))
all_text_chunks.append(html)
all_text_chunks.extend(css_texts)
# 2. 모든 hex 추출 (NOISE 제외)
counter: Counter = Counter()
for text in all_text_chunks:
for color in _extract_hex(text):
if color in NOISE:
continue
counter[color] += 1
if not counter:
logger.info("[color_extractor] no colors extracted from %s", source_url)
return {}
# 3. 비슷한 색 클러스터링
clustered = _cluster(counter)
# 4. primary = 빈도 높은 채도 있는 색 / accent = 두번째 채도 있는 색 / text = 빈도 높은 무채색
chromatic = [c for c, _ in clustered if not _is_grayscale(c)]
grayscale = [c for c, _ in clustered if _is_grayscale(c)]
palette_top = clustered[:8]
palette = [{"name": f"색상 {i+1}", "hex": h, "usage": f"빈도 {n}"} for i, (h, n) in enumerate(palette_top)]
return {
"brand_colors": {
"primary": chromatic[0] if chromatic else None,
"accent": chromatic[1] if len(chromatic) > 1 else None,
"text": grayscale[0] if grayscale else None,
},
"color_palette": palette,
"extracted_from": "html+css",
}
async def extract_brand_colors_from_site(homepage_url: str, max_css_files: int = 8) -> dict:
"""홈페이지 HTML + 외부 CSS fetch → hex 색상 빈도 분석 → primary/accent/text + palette 5종."""
html, css_texts = await _fetch_html_and_css(homepage_url, max_css_files)
if not html:
return {}
return _colors_from_text(html, css_texts, homepage_url)
async def extract_brand_assets_from_site(homepage_url: str, max_css_files: int = 8) -> dict:
"""사이트를 한 번만 fetch해서 로고 URL과 brand 색상을 함께 추출.
반환: {"logo_url": str | None, "colors": {brand_colors, color_palette, ...} | {}}"""
html, css_texts = await _fetch_html_and_css(homepage_url, max_css_files)
if not html:
return {"logo_url": None, "colors": {}}
return {
"logo_url": find_logo_url_in_html(html, homepage_url, css_texts=css_texts),
"colors": _colors_from_text(html, css_texts, homepage_url),
}

View File

@ -76,7 +76,7 @@ class FirecrawlClient:
"url": url, "url": url,
"formats": ["json", "links"], "formats": ["json", "links"],
"jsonOptions": { "jsonOptions": {
"prompt": "Extract: clinic name (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL, favicon URL)", "prompt": "Extract: clinic name (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL from the actual header/main <img> src, og:image from <meta property='og:image'> content, favicon URL)",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -119,6 +119,7 @@ class FirecrawlClient:
"headingFont": {"type": "string"}, "headingFont": {"type": "string"},
"bodyFont": {"type": "string"}, "bodyFont": {"type": "string"},
"logoUrl": {"type": "string"}, "logoUrl": {"type": "string"},
"ogImage": {"type": "string"},
"faviconUrl": {"type": "string"}, "faviconUrl": {"type": "string"},
}, },
}, },

View File

@ -15,6 +15,14 @@ class PlanInput(BaseModel):
market_keywords: str | None = None market_keywords: str | None = None
market_trend: str | None = None market_trend: str | None = None
market_target_audience: str | None = None market_target_audience: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
naver_blog: str | None = None
naver_cafe: str | None = None
kakao_talk: str | None = None
channel_logos: str | None = None
brand_assets: str | None = None
# --- BrandGuide --- # --- BrandGuide ---
@ -51,7 +59,7 @@ class ChannelBrandingRule(BaseModel):
profile_photo: str profile_photo: str
banner_spec: str banner_spec: str
bio_template: str bio_template: str
current_status: Literal["correct", "incorrect", "missing"] current_status: Literal["correct", "incorrect", "N/A"]
class BrandPlanInconsistencyValue(BaseModel): class BrandPlanInconsistencyValue(BaseModel):

View File

@ -68,16 +68,18 @@ class RegistryData(BaseModel):
class ClinicSnapshot(BaseModel): class ClinicSnapshot(BaseModel):
name: str # _build_clinic_snapshot은 source 데이터 있을 때만 채움 (`if x:` 가드).
name_en: str # required면 강남언니/홈페이지 누락 병원에서 ValidationError로 리포트 실패.
staff_count: int name: str | None = None
lead_doctor: LeadDoctor name_en: str | None = None
overall_rating: float staff_count: int | None = None
total_reviews: int lead_doctor: LeadDoctor | None = None
certifications: list[str] overall_rating: float | None = None
location: str total_reviews: int | None = None
phone: str certifications: list[str] = []
domain: str location: str | None = None
phone: str | None = None
domain: str | None = None
logo_images: LogoImages | None = None logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None brand_colors: BrandColors | None = None
source: DataSource | None = None source: DataSource | None = None
@ -121,44 +123,47 @@ class TopVideo(BaseModel):
class YouTubeAudit(BaseModel): class YouTubeAudit(BaseModel):
channel_name: str # YouTube 미수집 병원에서 _build_youtube_audit가 채울 수 없는 필드 빔.
handle: str # required면 ValidationError로 리포트 실패 → Optional로 받아 부분 응답 허용.
subscribers: int channel_name: str | None = None
total_videos: int handle: str | None = None
total_views: int subscribers: int | None = None
weekly_view_growth: WeeklyViewGrowth total_videos: int | None = None
estimated_monthly_revenue: EstimatedRevenue total_views: int | None = None
avg_video_length: str weekly_view_growth: WeeklyViewGrowth | None = None
upload_frequency: str estimated_monthly_revenue: EstimatedRevenue | None = None
channel_created_date: str avg_video_length: str | None = None
channel_description: str upload_frequency: str | None = None
linked_urls: list[LinkedUrl] channel_created_date: str | None = None
playlists: list[str] channel_description: str | None = None
top_videos: list[TopVideo] linked_urls: list[LinkedUrl] = []
diagnosis: list[DiagnosisItem] playlists: list[str] = []
top_videos: list[TopVideo] = []
diagnosis: list[DiagnosisItem] = []
# --- Instagram --- # --- Instagram ---
class InstagramAccount(BaseModel): class InstagramAccount(BaseModel):
handle: str # LLM이 누락 가능 — Optional로 받아 ValidationError 차단.
language: Language handle: str | None = None
label: str language: Language | None = None
posts: int label: str | None = None
followers: int posts: int | None = None
following: int followers: int | None = None
category: str following: int | None = None
profile_link: str category: str | None = None
highlights: list[str] profile_link: str | None = None
reels_count: int highlights: list[str] = []
content_format: str reels_count: int | None = None
profile_photo: str content_format: str | None = None
bio: str profile_photo: str | None = None
bio: str | None = None
class InstagramAudit(BaseModel): class InstagramAudit(BaseModel):
accounts: list[InstagramAccount] accounts: list[InstagramAccount] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem] = []
# --- Facebook --- # --- Facebook ---
@ -177,31 +182,32 @@ class BrandInconsistency(BaseModel):
class FacebookPage(BaseModel): class FacebookPage(BaseModel):
url: str # LLM이 누락 가능 (page 1·2개 모두 language/label/logo/has_whatsapp 빼먹는 경우 관찰됨).
page_name: str url: str | None = None
language: Language page_name: str | None = None
label: str language: Language | None = None
followers: int label: str | None = None
following: int followers: int | None = None
category: str following: int | None = None
bio: str category: str | None = None
logo: str bio: str | None = None
logo_description: str logo: str | None = None
link: str logo_description: str | None = None
linked_domain: str link: str | None = None
reviews: int linked_domain: str | None = None
recent_post_age: str reviews: int | None = None
has_whatsapp: bool recent_post_age: str | None = None
has_whatsapp: bool | None = None
post_frequency: str | None = None post_frequency: str | None = None
top_content_type: str | None = None top_content_type: str | None = None
engagement: str | None = None engagement: str | None = None
class FacebookAudit(BaseModel): class FacebookAudit(BaseModel):
pages: list[FacebookPage] pages: list[FacebookPage] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem] = []
brand_inconsistencies: list[BrandInconsistency] brand_inconsistencies: list[BrandInconsistency] = []
consolidation_recommendation: str consolidation_recommendation: str | None = None
# --- 기타 채널 / 웹사이트 --- # --- 기타 채널 / 웹사이트 ---
@ -314,6 +320,14 @@ class ReportInput(BaseModel):
market_keywords: str | None = None market_keywords: str | None = None
market_trend: str | None = None market_trend: str | None = None
market_target_audience: str | None = None market_target_audience: str | None = None
branding: str | None = None
brand_assets: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
kakao_talk: str | None = None
naver_cafe: str | None = None
channel_logos: str | None = None
# --- MarketingReport --- # --- MarketingReport ---

View File

@ -32,19 +32,65 @@
## 분석 리포트 ## 분석 리포트
{report} {report}
## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN / 네이버 카페 / 카카오톡)
아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 영문 인스타그램, 영문 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외.
### 네이버 블로그 (Naver Blog)
{naver_blog}
### 틱톡 (TikTok)
{tiktok}
### 인스타그램 (영문 계정)
{instagram_en}
### 페이스북 (영문 페이지)
{facebook_en}
### 네이버 카페 (공식 카페 운영 신호)
{naver_cafe}
- naver_cafe.cafeName: 카페명, naver_cafe.memberCount: 회원수
- currentStatus는 "회원 N명" 형태로 간단하게. 게시글 수·최근 활동은 수집 불가 (추측 금지).
- targetGoal은 회원 확보 목표 수치 + 운영 권장 (예: "회원 5,000명, 주 1~2회 공지 발행").
### 카카오톡 채널 (URL only — 콘텐츠 수집 X, 존재 여부만)
{kakao_talk}
- channelStrategies 카드 하나로 포함. currentStatus는 "공식 카카오톡 채널 운영 중" 정도, targetGoal은 친구 추가 유도·상담 전환·자동응답 시나리오 구체화 등.
## 채널별 로고 분석 (Gemini Vision) — 채널룰/일관성의 근거
{channel_logos}
- 위 channel_logos[]의 각 항목: channel(채널명), logo_description(프로필이 어떻게 생겼는지), is_official(공식 로고와 일치 여부).
- **channelBranding[]은 "어떻게 해야 하는지 권장 가이드라인" 섹션입니다.** 채널 통일 전략 기준으로 권장값 박을 것:
- profilePhoto: **빈 문자열 ""로 두세요.** 시스템이 brand_assets.logo_description으로 직접 채우므로 LLM은 만들지 마세요.
- bannerSpec: 권장 배너 규격 (크기·디자인 가이드)
- bioTemplate: 권장 bio 템플릿 (구조·필수 요소·예약 링크 포함 여부)
- currentStatus: is_official=true면 "correct" / false면 "incorrect" (데이터 없는 채널은 "N/A") — 현재 상태 마커는 이 필드 하나로만.
- 현재 채널 프로필 이미지의 실제 묘사(channel_logos.channel_logos[].logo_description)는 brandInconsistencies에서만 사용. channelBranding에서 채널별로 다른 묘사를 박지 마세요.
- **brandInconsistencies[]에 "로고" 항목을 반드시 만드세요**: values[]에 채널마다 channel(채널명) / value(logo_description 그대로) / is_correct(is_official 값) 세 필드를 넣고, impact는 inconsistency_summary, recommendation은 channel_logos.recommendation 기반으로 작성 (공식 로고로 통일 권고 포함).
## 브랜드 자산 (홈페이지 CSS에서 추출 — 결정적 데이터)
{brand_assets}
- brand_assets.color_palette[]의 hex와 brand_assets.brand_colors(primary/accent/text)는 **홈페이지 CSS에서 실제로 추출한 값**입니다.
- **brandGuide.colors의 hex는 반드시 이 추출값을 그대로 사용하세요. hex를 새로 지어내거나 변형하지 마세요** (매 실행마다 동일해야 함). name/usage 설명은 의미있게 써도 되지만 hex 값 자체는 추출값으로 고정.
## 섹션별 작성 지침 ## 섹션별 작성 지침
### Section 1: brandGuide ### Section 1: brandGuide
- colors: 병원 아이덴티티에 맞는 컬러 팔레트 3~5개 (hex + 사용 가이드) - colors: **brand_assets.color_palette / brand_colors의 hex를 그대로 사용** (홈페이지 CSS 추출값, 지어내기 금지). 3~5개, 각 hex에 name/usage 부여
- fonts: 제목/본문/캡션용 폰트 시스템 (한글/영문 포함) - fonts: 제목/본문/캡션용 폰트 시스템 (한글/영문 포함)
- logoRules: DO/DON'T 형식의 로고 사용 규칙 4~6개 - logoRules: 로고 사용 규칙 4~6개. 각 항목은 rule / description / correct 3개 필드로 구성:
- rule: 규칙을 요약한 **구체적인 제목**. "DO"·"DON'T" 같은 단어를 그대로 넣지 말 것. 실제 규칙 내용을 쓸 것 (예: "보라색+골드 깃털 로고 통일 사용", "모델 사진 프로필 금지", "비공식 변형 로고 사용 금지", "로고 주변 여백 확보").
- description: 해당 규칙의 상세 설명.
- correct: 권장 규칙(DO)이면 true, 금지/지양 규칙(DON'T)이면 false. 권장(true)과 금지(false)를 섞어서 작성.
- toneOfVoice: 브랜드 성격 키워드, 커뮤니케이션 스타일, 권장/지양 표현 예시 - toneOfVoice: 브랜드 성격 키워드, 커뮤니케이션 스타일, 권장/지양 표현 예시
- channelBranding: 리포트에 존재하는 채널별 브랜딩 적용 규칙 - channelBranding: 리포트에 존재하는 채널별 브랜딩 적용 규칙
- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고 - brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고
### Section 2: channelStrategies ### Section 2: channelStrategies
- 리포트에 데이터가 있는 채널만 포함 - 메인 SNS 채널(Instagram, Facebook, YouTube, TikTok, 네이버 블로그) + 영문 계정(Instagram EN, Facebook EN) + **네이버 카페 / 카카오톡** (URL 있을 때) 카드를 **모두 포함**. 데이터 없는 채널도 빠뜨리지 말 것.
- 각 채널의 우선순위(P0/P1/P2), 목표, 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성 - **currentStatus**: 데이터 있는 채널은 실제 수치로 서술 (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). **데이터 없는 채널은 "계정 없음"** 으로 표시. `excellent`/`warning`/`good` 같은 등급·평가어 금지.
- **targetGoal은 모든 채널에 반드시 채울 것** — 구체적 목표 수치(예: "50K 팔로워, Reels 주 5개"). 데이터 없는 채널도 시작 시 권장 목표를 작성하고 비우지 말 것.
- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 모두 권장값으로 작성 — 데이터 없어도 시작 권장값으로 채울 것.
- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정 - customerJourneyStage는 해당 채널의 주요 기여 단계로 설정
### Section 3: contentStrategy ### Section 3: contentStrategy

View File

@ -12,6 +12,17 @@
- 시술: {services} - 시술: {services}
- 의료진: {doctors} - 의료진: {doctors}
## 브랜드 자산 (홈페이지에서 자동 추출)
### Firecrawl branding (로고 URL / 색상 / 폰트)
{branding}
### 추출된 브랜드 자산 (로고 묘사 + CSS 색상 팔레트)
{brand_assets}
⚠️ clinic_snapshot.logo_images / brand_colors는 위 추출값(brand_assets.logo_images, brand_assets.brand_colors)을 **그대로** 사용하세요. hex나 로고 URL을 절대 추측하지 마세요. 추출값이 null이면 해당 필드도 null로 두세요.
로고에 대한 정성 평가(심볼/워드마크/톤)는 brand_assets.logo_description을 근거로 하고, 채널 프로필 이미지가 공식 로고와 일치하는지 판단할 때도 이 묘사를 기준으로 삼으세요.
## 시장 분석 데이터 ## 시장 분석 데이터
### 경쟁 병원 ### 경쟁 병원
@ -43,9 +54,61 @@
### 강남언니 ### 강남언니
{gangnam_unni} {gangnam_unni}
### 틱톡 (TikTok)
{tiktok}
### 인스타그램 (영문 계정)
{instagram_en}
### 페이스북 (영문 페이지)
{facebook_en}
### 카카오톡 채널 (URL only — 수집 데이터 없음, 존재 여부만 확인)
{kakao_talk}
### 네이버 카페 (공식 카페 운영 신호)
{naver_cafe}
- naver_cafe.cafeName: 카페명
- naver_cafe.memberCount: 회원수
- 게시글 총 수·최근 게시일은 로그인 필요라 수집 불가. 추측 금지. 위 두 값만 사용.
### 채널별 로고 분석 (Gemini Vision)
{channel_logos}
- channel_logos.channel_logos[]에 각 채널의 로고 설명(logo_description)과 공식 로고 일치 여부(is_official)가 있습니다.
- **facebook_audit.pages[].logo** 는 짧은 판정 타이틀로: is_official=true면 `"일치 (공식 로고)"`, false면 `"불일치 (비공식 변형)"`. 그리고 **facebook_audit.pages[].logo_description** 에 해당 채널의 logo_description(설명문)을 넣으세요.
- 위 값들은 channel_logos 데이터 기반으로만 작성하고 추측하지 마세요.
- 채널 간 로고 불일치(is_official=false)는 brand 일관성 진단(problem_diagnosis/weaknesses)에 반영하세요.
- **brand_inconsistencies[]에 "로고" 항목을 반드시 만드세요**: values[]에 channel_logos.channel_logos[] 각 채널마다 다음 3필드를 **그대로** 박을 것 — channel(채널명 그대로), value(해당 채널의 logo_description 문자열 그대로 복붙), is_correct(해당 채널의 is_official 값 그대로). ❗ **채널-묘사 매핑을 절대 swap·재해석·임의 변형 금지**. channel_logos에 적힌 그대로 사용. impact는 channel_logos.inconsistency_summary 사용, recommendation은 channel_logos.recommendation 사용.
## clinic_snapshot / 채널 audit 작성 지침 (수집 데이터 그대로, 추측 금지)
- clinic_snapshot.name 은 {clinic_name} 을 **그대로** 사용 (강남언니 표기명 '-본원' 등으로 바꾸지 말 것).
- clinic_snapshot 의 overall_rating/total_reviews/staff_count/location/certifications/lead_doctor 는 강남언니({gangnam_unni}) 데이터의 값을 그대로 사용.
- **instagram_audit.accounts 는 반드시 빈 배열 []로 두세요.** 계정 정보는 시스템이 수집 데이터로 직접 채우니 LLM은 만들지 말고, instagram_audit.diagnosis(진단)만 작성하세요.
- facebook_audit.pages: KR 페북({facebook})·영문 페북({facebook_en}) 데이터가 있으면 **각각 별도 페이지**로 넣고, url/page_name/followers 등은 그 데이터 그대로. language/label 동일 규칙.
- facebook_audit.pages[].top_content_type 은 해당 페이지 latestPosts의 **캡션·미디어를 읽고** 주로 올리는 콘텐츠를 의미 기반으로 짧게 묘사하세요 (예: "Before/After 사진 + 환자 여정 Reels", "이벤트·프로모션 카드뉴스", "다국어 시술 소개"). 단순 "동영상/이미지 위주"가 아니라 **무슨 주제**인지 쓰세요. (recent_post_age·post_frequency·engagement 수치는 시스템이 덮어쓰니 대략 적어도 됩니다.)
- 위 수치·URL·이름은 제공된 데이터에서 그대로 쓰고 절대 지어내지 마세요.
## 기타 채널 현황 (other_channels) 작성 지침
- other_channels에는 메인 audit(YouTube/Instagram/Facebook/Website)에 **포함되지 않은** 채널만 넣으세요.
- 위 '채널 데이터'에 **실제 수집된 데이터가 있는 채널만** status=active와 실제 url로 일관되게 포함: 네이버 블로그, 강남언니, 틱톡, 영문 인스타그램({instagram_en}), 영문 페이스북({facebook_en}).
- **영문 인스타그램·영문 페이스북은 KR 메인 audit(Instagram/Facebook)과 별개 계정이므로, 데이터가 있으면 반드시 other_channels에 "Instagram EN" / "Facebook EN"으로 각각 포함하세요 (절대 누락 금지).**
- **카카오톡·네이버 카페**: {kakao_talk} 또는 {naver_cafe}에 url이 있으면 other_channels에 각각 "KakaoTalk" / "Naver Cafe"로 status=active + 해당 url로 포함. 수집된 콘텐츠 데이터는 없으므로 URL 존재 자체가 활성 채널 신호. **둘 다 null/빈 값이면 절대 만들지 마세요.**
- **그 외 데이터 없는 채널(네이버플레이스/Threads 등)은 절대 임의로 만들지 마세요.** 데이터 없으면 그 채널은 생략 (랜덤 생성·추측 금지).
- url은 수집 데이터의 실제 URL만 사용. 없으면 빈 문자열.
- **URL에 'https://www.facebook.com/' 같은 prefix를 절대 직접 만들지 마세요.** 수집 데이터의 URL을 그대로 사용. 이미 'https://...' 가 붙은 URL에 또 prefix 붙이면 'https://www.facebook.com/https://facebook.com/X' 같이 깨집니다. 받은 URL = 출력 URL.
## registry_data 작성 지침 (clinic_snapshot 안)
- **registry_data.website_en / district / branches / brand_group / naver_place_url / gangnam_unni_url / google_maps_url 모두 제공된 데이터에 명시되지 않으면 반드시 null로 두세요.**
- 영문 사이트 URL, 영문명, 지점 정보 같은 거 데이터에 없으면 **절대 추측하거나 그럴듯해 보이는 도메인을 지어내지 마세요** (예: 'thepsclinic.com', '*-eng.com' 같은 거).
## 분석 지침 ## 분석 지침
- 점수는 0~100 기준입니다. - 점수는 0~100 기준입니다.
- **channel_scores(채널 종합도)에는 데이터가 있는 모든 채널을 각각 별도 항목으로 만드세요. 같은 플랫폼이라도 한국 계정과 영문 계정을 절대 하나로 합치지 마세요:**
- 인스타그램 KR → channel "Instagram", 영문 인스타그램({instagram_en}) 데이터가 있으면 → channel "Instagram EN" (별도 항목)
- 페이스북 KR → channel "Facebook", 영문 페이스북({facebook_en}) 데이터가 있으면 → channel "Facebook EN" (별도 항목)
- 틱톡({tiktok}) 데이터가 있으면 → channel "TikTok" (별도 항목)
- 데이터가 null인 계정은 항목을 만들지 마세요. icon은 instagram/facebook/video 등 플랫폼에 맞게 설정.
- strengths와 weaknesses는 각 3개 이상 작성하세요. - strengths와 weaknesses는 각 3개 이상 작성하세요.
- roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요. - roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요.
- kpis는 실제 수집된 수치 기반으로 현실적인 측정 가능 지표로 작성하세요. - kpis는 실제 수집된 수치 기반으로 현실적인 측정 가능 지표로 작성하세요.

View File

@ -1,4 +1,5 @@
import re import re
import httpx
from http import HTTPMethod from http import HTTPMethod
from urllib.parse import urlparse from urllib.parse import urlparse
from common.utils import http_request from common.utils import http_request
@ -64,6 +65,20 @@ class NaverClient:
return None return None
return resp.text return resp.text
async def fetch_blog_total_count(self, handle: str) -> int | None:
"""블로그 전체 글 수는 RSS에 없어서 PostList HTML에서 '554개의 글' 패턴 추출.
<h4 class="category_title pcol2">... 554개의 </h4> 구조."""
resp = await http_request(
HTTPMethod.GET,
url=f"https://blog.naver.com/PostList.naver?blogId={handle}&from=postList&directAccess=true",
timeout=15,
label="naver-blog-postlist",
)
if not resp or not resp.is_success:
return None
m = re.search(r"(\d+)개의 글", resp.text)
return int(m.group(1)) if m else None
async def get_blog_rss(self, url: str) -> dict | None: async def get_blog_rss(self, url: str) -> dict | None:
blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url
xml = await self.fetch_blog_rss(blog_handle) xml = await self.fetch_blog_rss(blog_handle)
@ -82,10 +97,71 @@ class NaverClient:
"postDate": date.group(1) if date else "", "postDate": date.group(1) if date else "",
"description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150], "description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150],
}) })
# RSS의 totalCount 우선, 없으면 블로그 PostList 페이지에서 "N개의 글" 파싱, 그것도 없으면 RSS 글수
total_match = re.search(r"<totalCount>(\d+)</totalCount>", xml) total_match = re.search(r"<totalCount>(\d+)</totalCount>", xml)
if total_match:
total = int(total_match.group(1))
else:
total = await self.fetch_blog_total_count(blog_handle) or len(posts)
return { return {
"officialBlogUrl": f"https://blog.naver.com/{blog_handle}", "officialBlogUrl": f"https://blog.naver.com/{blog_handle}",
"officialBlogHandle": blog_handle, "officialBlogHandle": blog_handle,
"totalResults": int(total_match.group(1)) if total_match else len(posts), "totalResults": total,
"posts": posts[:10], "posts": posts[:10],
} }
async def get_cafe_info(self, cafe_url: str, *_args, **_kwargs) -> dict | None:
"""네이버 카페 운영 신호 수집. 2단계 fetch:
1) https://cafe.naver.com/{handle} cafeId 추출
2) ArticleList.nhn?search.clubid={cafeId} memberCount + cafeName 추출
본문/게시글은 로그인 필요라 가져옴. 회원수·카페명만 잡히면 충분.
common.http_request는 redirect 따라가서 카페 페이지에 맞아 httpx 직접 사용."""
handle = urlparse(cafe_url).path.strip("/").split("/")[0] if "://" in cafe_url else cafe_url.split("/")[-1]
if not handle:
return None
async with httpx.AsyncClient(
timeout=10, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
) as c:
# 1. cafeId 추출
try:
main = await c.get(f"https://cafe.naver.com/{handle}")
except Exception:
return {"url": f"https://cafe.naver.com/{handle}", "cafeHandle": handle, "accessible": False}
if main.status_code != 200:
return {"url": f"https://cafe.naver.com/{handle}", "cafeHandle": handle, "accessible": False}
cid_match = re.search(r'cafeId["\']?\s*[:=]\s*["\']?(\d+)', main.text)
cafe_id = cid_match.group(1) if cid_match else None
result: dict = {
"url": f"https://cafe.naver.com/{handle}",
"cafeHandle": handle,
"cafeId": cafe_id,
"accessible": True,
"cafeName": None,
"memberCount": None,
}
if not cafe_id:
return result
# 2. ArticleList 페이지에서 회원수 + 카페명 추출 (로그인 없이 접근 가능한 유일한 endpoint)
try:
listing = await c.get(
f"https://cafe.naver.com/ArticleList.nhn?search.clubid={cafe_id}&search.menuid=&search.boardtype=L",
headers={"Referer": f"https://cafe.naver.com/{handle}"},
)
except Exception:
return result
if listing.status_code != 200:
return result
mc = re.search(r'memberCount[^0-9]+(\d[\d,]*)', listing.text)
if mc:
result["memberCount"] = int(mc.group(1).replace(",", ""))
tm = re.search(r"<title>(.+?)\s*:\s*네이버 카페</title>", listing.text)
if tm:
name = re.sub(r"&amp;", "&", tm.group(1)).strip()
if "," in name:
name = name.split(",", 1)[1].strip()
result["cafeName"] = name
return result

325
app/integrations/vision.py Normal file
View File

@ -0,0 +1,325 @@
"""Gemini Vision — 로고/브랜드 비주얼 자동 분석 (OpenAI 호환 모드).
정확한 hex 색상은 color_extractor가 CSS에서 직접 뽑음 (Vision은 근사값밖에 ).
Vision은 사람이 봐야 있는 정성 정보 심볼 형태/워드마크/ 담당.
"""
import asyncio
import base64
import json
import logging
import re
import ssl
import httpx
import resvg_py
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "gemini-2.5-flash"
class VisionClient:
"""Gemini Vision을 OpenAI 호환 endpoint로 호출. GEMINI_API_KEY만 필요."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, timeout: float = 30.0, max_retries: int = 2):
self.client = AsyncOpenAI(
api_key=api_key,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
timeout=timeout,
max_retries=max_retries,
)
self.model = model
@staticmethod
def _extract_json(text: str) -> dict | None:
if not text:
return None
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
return None
@staticmethod
async def _fetch_as_data_url(url: str) -> str | None:
"""Gemini는 URL 직접 fetch가 막힌 호스트가 많아 base64 인라인으로 변환.
+ 'image does not exist' 같은 placeholder 이미지 거부 (작은 bytes / 잘못된 content-type).
+ 한국 의료 사이트 SSL이 약해서 표준 검증에 실패하는 대응 (3 SSL fallback)."""
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
def _weak_ctx() -> ssl.SSLContext:
ctx = ssl.create_default_context()
try:
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
except ssl.SSLError:
pass
return ctx
last_err: Exception | None = None
for verify in (True, _weak_ctx(), False):
try:
async with httpx.AsyncClient(
timeout=15.0, follow_redirects=True, headers=headers, verify=verify,
) as c:
resp = await c.get(url)
if resp.status_code != 200:
logger.warning("[vision] fetch %s status=%s", url, resp.status_code)
return None
mime = resp.headers.get("content-type", "").split(";")[0].strip()
# 실제 이미지가 아니면 거부 (HTML 페이지가 404 대신 200으로 리다이렉트 되는 경우)
if not mime.startswith("image/"):
logger.warning("[vision] %s not an image (content-type=%s)", url, mime)
return None
# SVG는 Gemini가 못 보므로 즉시 PNG로 래스터화 (resvg, in-memory ~1ms)
content = resp.content
if mime == "image/svg+xml" or url.lower().split("?")[0].endswith(".svg"):
try:
content = bytes(resvg_py.svg_to_bytes(svg_string=resp.text))
mime = "image/png"
except Exception as e:
logger.warning("[vision] svg rasterize failed %s: %s", url, e)
return None
size = len(content)
if size < 500:
logger.warning("[vision] %s too small (%d bytes) — likely placeholder", url, size)
return None
b64 = base64.b64encode(content).decode("ascii")
return f"data:{mime};base64,{b64}"
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError) as e:
last_err = e
continue
except Exception as e:
logger.warning("[vision] fetch error %s: %s", url, e)
return None
logger.warning("[vision] fetch %s SSL fallback all failed: %s", url, last_err)
return None
async def _ask(self, image_urls: list[str], prompt: str, max_tokens: int = 4000) -> dict | None:
content: list[dict] = []
for u in image_urls:
if not u:
continue
data_url = await self._fetch_as_data_url(u)
if not data_url:
continue
content.append({"type": "image_url", "image_url": {"url": data_url}})
if not any(c.get("type") == "image_url" for c in content):
logger.warning("[vision] no images could be fetched")
return None
content.append({"type": "text", "text": prompt})
try:
resp = await self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": content}],
max_tokens=max_tokens,
)
choice = resp.choices[0]
if choice.finish_reason != "stop":
logger.warning("[vision] unexpected finish_reason=%s", choice.finish_reason)
return self._extract_json(choice.message.content or "")
except Exception as e:
logger.warning("[vision] error: %s", e)
return None
async def describe_svg_text(self, svg_url: str) -> dict | None:
"""SVG는 Gemini Vision이 못 보지만 XML 텍스트 자체는 LLM이 읽을 수 있음.
SVG 소스를 받아 그대로 text endpoint에 던지고 ·심볼·텍스트를 추론하게 .
analyze_brand_assets와 동일한 스키마(logo_description/style/has_symbol/...) 반환."""
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
def _weak_ctx() -> ssl.SSLContext:
ctx = ssl.create_default_context()
try:
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
except ssl.SSLError:
pass
return ctx
svg_text: str | None = None
for verify in (True, _weak_ctx(), False):
try:
async with httpx.AsyncClient(
timeout=15.0, follow_redirects=True, headers=headers, verify=verify,
) as c:
resp = await c.get(svg_url)
if resp.status_code == 200:
svg_text = resp.text
break
except (httpx.ConnectError, httpx.ReadError, ssl.SSLError):
continue
except Exception as e:
logger.warning("[vision] svg fetch error %s: %s", svg_url, e)
return None
if not svg_text:
logger.warning("[vision] svg fetch failed %s", svg_url)
return None
# 페이로드 폭주 방지 — 평범한 로고 SVG는 수 KB 수준
if len(svg_text) > 60000:
svg_text = svg_text[:60000]
prompt = (
"아래는 병원 로고 SVG 소스 코드입니다. SVG 마크업(path/circle/text/fill/stroke 등)을 "
"읽고 로고의 시각적 특징을 추론해 아래 JSON 스키마로만 응답하세요. 코드펜스 없이 순수 JSON.\n"
"{\n"
' "logo_description": "심볼 형태 + 워드마크 + 톤을 1~2문장 한국어로",\n'
' "logo_style": "minimal | illustrative | typographic | abstract 중 하나",\n'
' "has_symbol": "심볼/아이콘이 있으면 true, 글자만 있으면 false (boolean)",\n'
' "logo_symbol": "심볼 묘사 (예: \'잎사귀\'). 없으면 빈 문자열",\n'
' "logo_text": "워드마크 텍스트 그대로. <text> 태그 내용 우선",\n'
' "logo_colors_desc": "쓰인 색감을 사람이 부르는 이름으로 (예: \'딥네이비 + 골드\'). hex 출력 금지"\n'
"}\n"
"주의: hex 값이나 URL은 출력하지 마세요 (별도 추출 로직 처리). 모든 텍스트는 한국어로.\n\n"
"SVG 소스:\n"
f"{svg_text}"
)
try:
resp = await self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
max_tokens=8000, # Gemini 2.5는 thinking 토큰을 max_tokens에서 차감하므로 여유 필요
)
choice = resp.choices[0]
if choice.finish_reason != "stop":
logger.warning("[vision] svg describe finish_reason=%s", choice.finish_reason)
result = self._extract_json(choice.message.content or "")
except Exception as e:
logger.warning("[vision] svg describe error: %s", e)
return None
if not result:
return None
result["logo_images"] = {"circle": None, "horizontal": svg_url, "korean": None}
return result
async def analyze_brand_assets(
self,
logo_url: str | None,
homepage_url: str | None,
additional_images: list[str] | None = None,
) -> dict:
"""로고 이미지를 보고 정성 분석. 정확한 hex는 color_extractor가 따로 처리하므로 여기선 안 뽑음."""
urls = [u for u in [logo_url] + list(additional_images or []) if u]
if not urls:
return {}
prompt = (
"당신은 브랜드 로고 시각 분석가입니다. 첨부된 이미지(첫 번째가 병원의 대표 로고)를 보고 "
"아래 JSON 스키마로만 응답하세요. 코드펜스 없이 순수 JSON만 출력.\n"
"{\n"
' "logo_description": "로고를 1~2문장으로 설명 (심볼 형태 + 워드마크 + 전반적 톤). 예: \'둥근 잎사귀를 감싼 추상 심볼에 세리프 한글 워드마크, 차분하고 고급스러운 톤\'",\n'
' "logo_style": "minimal | illustrative | typographic | abstract 중 하나",\n'
' "has_symbol": "심볼/아이콘이 있으면 true, 글자만 있으면 false (boolean)",\n'
' "logo_symbol": "심볼이 묘사하는 대상 (예: \'잎사귀\', \'추상 곡선\'). 없으면 빈 문자열",\n'
' "logo_text": "로고에 보이는 워드마크 텍스트 그대로 (한글/영문). 없으면 빈 문자열",\n'
' "logo_colors_desc": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\'). 정확한 hex는 출력하지 말 것"\n'
"}\n"
"주의: 색상 hex 값이나 logo URL 같은 필드는 출력하지 마세요 (별도 추출 로직이 처리).\n"
"모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)."
)
result = await self._ask(urls, prompt)
if not result:
return {}
# logo_images는 우리가 직접 채움 (Vision은 묘사만)
result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
return result
async def describe_channel_logos(
self,
official_logo_url: str | None,
channel_logos: list[dict],
) -> dict | None:
"""채널별 프로필 이미지(로고)를 보고 각각 설명 + 공식 로고와 일치 여부 평가.
channel_logos: [{"channel": "Instagram", "url": "..."}, ...]
반환: {"channel_logos": [{"channel","logo_description","is_official"}], "inconsistency_summary", "recommendation"}
**3채널씩 묶어 병렬 호출** ( 번에 묶으면 LLM이 채널-이미지 매칭 헷갈려 같은 묘사를
여러 채널에 복사하는 문제 VIEW 한국페북·영문인스타가 "공식 로고" 묘사로 잘못
박혔던 케이스 있어서 분리. 1채널씩 N번보다 가성비 좋음)."""
items = [c for c in channel_logos if c.get("url")]
if not items:
return None
CHUNK = 3
async def _chunk(batch: list[dict]) -> list[dict]:
urls = [official_logo_url] + [c["url"] for c in batch] if official_logo_url else [c["url"] for c in batch]
n = len(batch)
# 이미지 번호 ↔ 채널 매핑 명시
if official_logo_url:
mapping = "이미지 1 = 공식 로고\n" + "\n".join(
f"이미지 {i+2} = {c.get('channel','?')} 채널 프로필" for i, c in enumerate(batch)
)
instruction = (
f"{mapping}\n\n"
f"이미지 2~{n+1}(채널 프로필 {n}개)을 각각 **그 이미지에 실제로 보이는 그대로** "
"한국어 1문장으로 묘사하세요 (색·형태·텍스트·배경 그대로).\n"
"❗ 공식 로고(이미지 1) 묘사를 절대 복사하지 마세요. 각 채널 이미지에 보이는 실제 특징만.\n"
"각 채널이 공식 로고와 시각적으로 거의 동일하면 is_official=true, "
"심볼/색/배경/텍스트가 다르거나 모델 사진이면 false.\n"
)
else:
mapping = "\n".join(f"이미지 {i+1} = {c.get('channel','?')} 채널 프로필" for i, c in enumerate(batch))
instruction = (
f"{mapping}\n\n"
f"각 이미지를 보이는 그대로 한국어 1문장으로 묘사 (색·형태·텍스트·배경).\n"
)
schema_lines = ",\n".join(
f' {{"channel": "{c.get("channel","?")}", "logo_description": "...", "is_official": true}}'
for c in batch
)
p = (
instruction
+ "\n아래 JSON으로만 응답 (코드펜스 없이, 순수 JSON):\n{\n"
+ f' "channel_logos": [\n{schema_lines}\n ]\n'
+ "}\n"
+ f"channel 필드는 위 매핑 그대로 ({', '.join(c.get('channel','?') for c in batch)}). "
+ "logo_description은 반드시 한국어 (영어 금지)."
)
r = await self._ask(urls, p)
if not r:
return []
out = []
for c in r.get("channel_logos", []):
out.append({
"channel": c.get("channel", ""),
"logo_description": c.get("logo_description", ""),
"is_official": bool(c.get("is_official", False)) if official_logo_url else None,
})
return out
# 3개씩 청크 → 병렬
chunks = [items[i:i+CHUNK] for i in range(0, len(items), CHUNK)]
results = await asyncio.gather(*[_chunk(b) for b in chunks], return_exceptions=True)
channel_logos_out: list[dict] = []
for r in results:
if isinstance(r, Exception):
logger.warning("[vision] channel_logo chunk error: %s", r)
continue
channel_logos_out.extend(r)
if not channel_logos_out:
return None
# 일관성 요약 + 권고는 결정적 산출 (LLM 한번 더 안 부름)
if official_logo_url:
mismatches = [c["channel"] for c in channel_logos_out if not c.get("is_official")]
if not mismatches:
summary = "모든 채널이 공식 로고를 일관되게 사용하고 있습니다."
rec = "현재 일관성 유지."
else:
summary = f"{len(mismatches)}개 채널({', '.join(mismatches)})이 공식 로고와 다른 이미지를 사용해 브랜드 일관성이 부족합니다."
rec = "비공식 채널 프로필을 공식 로고로 통일 권고."
else:
summary, rec = "", ""
return {
"channel_logos": channel_logos_out,
"inconsistency_summary": summary,
"recommendation": rec,
}

View File

@ -98,9 +98,11 @@ class YouTubeClient:
ch = raw["channel"] ch = raw["channel"]
stats = ch.get("statistics", {}) stats = ch.get("statistics", {})
snippet = ch.get("snippet", {}) snippet = ch.get("snippet", {})
thumbs = snippet.get("thumbnails", {})
return { return {
"channelId": raw["channelId"], "channelId": raw["channelId"],
"channelName": snippet.get("title"), "channelName": snippet.get("title"),
"profileImage": (thumbs.get("high") or thumbs.get("medium") or thumbs.get("default") or {}).get("url"),
"handle": snippet.get("customUrl"), "handle": snippet.get("customUrl"),
"description": snippet.get("description", ""), "description": snippet.get("description", ""),
"publishedAt": snippet.get("publishedAt"), "publishedAt": snippet.get("publishedAt"),

View File

@ -153,7 +153,7 @@
"profile_photo": "보라색+골드 깃털 로고", "profile_photo": "보라색+골드 깃털 로고",
"banner_spec": "블로그 상단: 깃털 심볼 + 대표 이미지", "banner_spec": "블로그 상단: 깃털 심볼 + 대표 이미지",
"bio_template": "21년 무사고 VIEW 성형외과 공식 블로그\n가슴성형·안면윤곽·양악·눈코·리프팅", "bio_template": "21년 무사고 VIEW 성형외과 공식 블로그\n가슴성형·안면윤곽·양악·눈코·리프팅",
"current_status": "missing" "current_status": "N/A"
}, },
{ {
"channel": "TikTok", "channel": "TikTok",
@ -161,7 +161,7 @@
"profile_photo": "보라색+골드 깃털 원형 로고", "profile_photo": "보라색+골드 깃털 원형 로고",
"banner_spec": "N/A", "banner_spec": "N/A",
"bio_template": "VIEW 성형외과 — 안전이 예술이 되는 곳\n강남 신논현역 | 02-539-1177", "bio_template": "VIEW 성형외과 — 안전이 예술이 되는 곳\n강남 신논현역 | 02-539-1177",
"current_status": "missing" "current_status": "N/A"
} }
], ],
"brand_inconsistencies": [ "brand_inconsistencies": [

1373
app/mock_urls.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,11 @@ class Channels(BaseModel):
facebook: str | None = None facebook: str | None = None
naver_blog: str | None = None naver_blog: str | None = None
gangnam_unni: str | None = None gangnam_unni: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
kakao_talk: str | None = None
naver_cafe: str | None = None
class AnalysisOptions(BaseModel): class AnalysisOptions(BaseModel):

View File

@ -49,7 +49,7 @@ class ChannelBrandingRule(CamelModel):
profile_photo: str profile_photo: str
banner_spec: str banner_spec: str
bio_template: str bio_template: str
current_status: Literal["correct", "incorrect", "missing"] current_status: Literal["correct", "incorrect", "N/A"]
class BrandGuide(CamelModel): class BrandGuide(CamelModel):

View File

@ -66,16 +66,18 @@ class RegistryData(CamelModel):
class ClinicSnapshot(CamelModel): class ClinicSnapshot(CamelModel):
name: str # _build_clinic_snapshot은 source 데이터 있을 때만 필드 추가 (`if x:` 가드).
name_en: str # 강남언니/홈페이지 수집 누락된 병원에서 required면 ValidationError로 리포트 전체 실패.
staff_count: int name: str | None = None
lead_doctor: LeadDoctor name_en: str | None = None
overall_rating: float staff_count: int | None = None
total_reviews: int lead_doctor: LeadDoctor | None = None
certifications: list[str] overall_rating: float | None = None
location: str total_reviews: int | None = None
phone: str certifications: list[str] = []
domain: str location: str | None = None
phone: str | None = None
domain: str | None = None
logo_images: LogoImages | None = None logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None brand_colors: BrandColors | None = None
source: DataSource | None = None source: DataSource | None = None
@ -115,42 +117,45 @@ class TopVideo(CamelModel):
class YouTubeAudit(CamelModel): class YouTubeAudit(CamelModel):
channel_name: str # YouTube 채널 없는 병원이면 _build_youtube_audit가 채울 수 없는 필드들 (channel_name 등)이 빔.
handle: str # required면 ValidationError로 리포트 실패 → Optional로 받아 부분 응답 허용.
subscribers: int channel_name: str | None = None
total_videos: int handle: str | None = None
total_views: int subscribers: int | None = None
weekly_view_growth: WeeklyViewGrowth total_videos: int | None = None
estimated_monthly_revenue: EstimatedRevenue total_views: int | None = None
avg_video_length: str weekly_view_growth: WeeklyViewGrowth | None = None
upload_frequency: str estimated_monthly_revenue: EstimatedRevenue | None = None
channel_created_date: str avg_video_length: str | None = None
channel_description: str upload_frequency: str | None = None
linked_urls: list[LinkedUrl] channel_created_date: str | None = None
playlists: list[str] channel_description: str | None = None
top_videos: list[TopVideo] linked_urls: list[LinkedUrl] = []
diagnosis: list[DiagnosisItem] playlists: list[str] = []
top_videos: list[TopVideo] = []
diagnosis: list[DiagnosisItem] = []
class InstagramAccount(CamelModel): class InstagramAccount(CamelModel):
handle: str # 인스타 계정(KR/EN) 미수집 시 빈 필드 가능 — Optional.
language: Language handle: str | None = None
label: str language: Language | None = None
posts: int label: str | None = None
followers: int posts: int | None = None
following: int followers: int | None = None
category: str following: int | None = None
profile_link: str category: str | None = None
highlights: list[str] profile_link: str | None = None
reels_count: int highlights: list[str] = []
content_format: str reels_count: int | None = None
profile_photo: str content_format: str | None = None
bio: str profile_photo: str | None = None
bio: str | None = None
class InstagramAudit(CamelModel): class InstagramAudit(CamelModel):
accounts: list[InstagramAccount] accounts: list[InstagramAccount] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem] = []
class BrandInconsistencyValue(CamelModel): class BrandInconsistencyValue(CamelModel):
@ -167,31 +172,32 @@ class BrandInconsistency(CamelModel):
class FacebookPage(CamelModel): class FacebookPage(CamelModel):
url: str # 페북 페이지(KR/EN) 미수집 시 빈 필드 가능 — Optional.
page_name: str url: str | None = None
language: Language page_name: str | None = None
label: str language: Language | None = None
followers: int label: str | None = None
following: int followers: int | None = None
category: str following: int | None = None
bio: str category: str | None = None
logo: str bio: str | None = None
logo_description: str logo: str | None = None
link: str logo_description: str | None = None
linked_domain: str link: str | None = None
reviews: int linked_domain: str | None = None
recent_post_age: str reviews: int | None = None
has_whatsapp: bool recent_post_age: str | None = None
has_whatsapp: bool | None = None
post_frequency: str | None = None post_frequency: str | None = None
top_content_type: str | None = None top_content_type: str | None = None
engagement: str | None = None engagement: str | None = None
class FacebookAudit(CamelModel): class FacebookAudit(CamelModel):
pages: list[FacebookPage] pages: list[FacebookPage] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem] = []
brand_inconsistencies: list[BrandInconsistency] brand_inconsistencies: list[BrandInconsistency] = []
consolidation_recommendation: str consolidation_recommendation: str | None = None
class OtherChannel(CamelModel): class OtherChannel(CamelModel):

View File

@ -1,12 +1,13 @@
import json import json
import logging import logging
import os
import re import re
from datetime import datetime from datetime import datetime
from common.db import fetchone, execute, fetch_raw, get_analysis_raw_data, save_analysis_report, get_market_analysis from common.db import fetchone, execute, fetch_raw, get_analysis_raw_data, save_analysis_report, get_market_analysis
from integrations.llm.llm_service import LLMService from integrations.llm.llm_service import LLMService
from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit
from services.instagram_audit import build_instagram_accounts
from services.facebook_audit import build_facebook_pages
from integrations.llm.schemas.plan import PlanOutput from integrations.llm.schemas.plan import PlanOutput
from models.status import AnalysisStatus from models.status import AnalysisStatus
@ -42,6 +43,14 @@ async def generate_report(analysis_run_id: str) -> ReportOutput:
"market_keywords": _json(market.get("keywords")), "market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")), "market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")), "market_target_audience": _json(market.get("target_audience")),
"branding": _json(clinic.get("branding")),
"brand_assets": _json(clinic.get("brandAssets")),
"tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")),
"kakao_talk": _json(clinic.get("kakaoTalk")),
"naver_cafe": _json(clinic.get("naverCafe")),
"channel_logos": _json(clinic.get("channelLogos")),
**{ **{
channel: _json(data) channel: _json(data)
for channel, data in raw.items() for channel, data in raw.items()
@ -64,6 +73,7 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
report_data = run["report_data"] report_data = run["report_data"]
report = json.loads(report_data) if isinstance(report_data, str) else report_data report = json.loads(report_data) if isinstance(report_data, str) else report_data
market = await get_market_analysis(analysis_run_id) market = await get_market_analysis(analysis_run_id)
raw = await get_analysis_raw_data(analysis_run_id)
def _json(v) -> str | None: def _json(v) -> str | None:
return json.dumps(v, ensure_ascii=False) if v else None return json.dumps(v, ensure_ascii=False) if v else None
@ -81,6 +91,14 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"market_keywords": _json(market.get("keywords")), "market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")), "market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")), "market_target_audience": _json(market.get("target_audience")),
"tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")),
"naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))),
"naver_cafe": _json(clinic.get("naverCafe")),
"kakao_talk": _json(clinic.get("kakaoTalk")),
"channel_logos": _json(clinic.get("channelLogos")),
"brand_assets": _json(clinic.get("brandAssets")),
} }
return await LLMService(provider="perplexity").generate(plan_prompt, input_data) return await LLMService(provider="perplexity").generate(plan_prompt, input_data)
@ -106,9 +124,25 @@ def _build_clinic_snapshot(gangnam_unni: dict, hospital: dict) -> dict:
"rating": lead.get("rating"), "rating": lead.get("rating"),
"review_count": lead.get("reviews"), "review_count": lead.get("reviews"),
} }
# brand_assets에서 logo_images / brand_colors 강제 주입. LLM이 프롬프트 가드 무시하고 null로 두는 케이스 차단.
ba = hospital.get("brandAssets") or {}
if ba.get("logo_images"): snapshot["logo_images"] = ba["logo_images"]
if ba.get("brand_colors"): snapshot["brand_colors"] = ba["brand_colors"]
return ClinicSnapshot.model_validate(snapshot).model_dump() return ClinicSnapshot.model_validate(snapshot).model_dump()
def _naver_blog_summary(blog: dict | None) -> dict | None:
"""plan 카드 한 장에 들어가는 건 전체 포스트 수와 최근 활동 시점뿐. 그 외(본문·링크·제목)는
던져봐야 토큰만 늘고 LLM이 무관 정보로 hallucinate ."""
if not blog:
return None
posts = blog.get("posts") or []
return {
"totalPosts": blog.get("totalResults"),
"latestPostDate": posts[0].get("postDate") if posts else None,
}
def _parse_iso_duration_seconds(iso: str) -> int: def _parse_iso_duration_seconds(iso: str) -> int:
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "") m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
if not m: if not m:
@ -245,33 +279,22 @@ async def _build_overrides(analysis_run_id: str) -> dict:
snapshot: dict = _build_clinic_snapshot(gangnam_unni, hospital) snapshot: dict = _build_clinic_snapshot(gangnam_unni, hospital)
yt_patch: dict = await _build_youtube_audit(youtube) yt_patch: dict = await _build_youtube_audit(youtube)
# ── instagram ───────────────────────────────────────────────────────────── # ── instagram (KR·EN 계정을 코드에서 구성 → LLM 출력 무시하고 교체) ──────────────
ig_patch: dict = {} ig_patch = build_instagram_accounts(
if instagram.get("username"): ig_patch["handle"] = instagram["username"] instagram, hospital.get("instagramEn") or {}, hospital.get("channelLogos") or {},
if instagram.get("posts"): ig_patch["posts"] = instagram["posts"] )
if instagram.get("followers"): ig_patch["followers"] = instagram["followers"]
if instagram.get("following"): ig_patch["following"] = instagram["following"]
if instagram.get("bio"): ig_patch["bio"] = instagram["bio"]
if instagram.get("username"): ig_patch["profile_link"] = f"https://www.instagram.com/{instagram['username']}/"
# ── facebook ────────────────────────────────────────────────────────────── # ── facebook (KR=facebook_data, EN=hospital.facebookEn 둘 다 코드 산출, [KR, EN] 순서) ──
fb_patch: dict = {} fb_pages = build_facebook_pages(facebook, hospital.get("facebookEn") or {})
if facebook.get("pageUrl"): fb_patch["url"] = facebook["pageUrl"]
if facebook.get("pageUrl"): fb_patch["link"] = facebook["pageUrl"]
if facebook.get("pageName"): fb_patch["page_name"] = facebook["pageName"]
if facebook.get("followers"): fb_patch["followers"] = facebook["followers"]
if facebook.get("intro"): fb_patch["bio"] = facebook["intro"]
if facebook.get("categories"): fb_patch["category"] = ", ".join(facebook["categories"])
if facebook.get("website"): fb_patch["linked_domain"] = facebook["website"]
overrides: dict = {} overrides: dict = {}
if snapshot: if snapshot:
overrides["clinic_snapshot"] = snapshot overrides["clinic_snapshot"] = snapshot
if ig_patch: if ig_patch:
overrides["instagram_audit"] = {"accounts": [ig_patch]} overrides["instagram_audit"] = {"accounts": ig_patch}
if fb_patch: if fb_pages:
overrides["facebook_audit"] = {"pages": [fb_patch]} overrides["facebook_audit"] = {"pages": fb_pages}
if yt_patch: if yt_patch:
overrides["youtube_audit"] = yt_patch overrides["youtube_audit"] = yt_patch
return overrides return overrides
@ -291,56 +314,50 @@ def _deep_merge(base: dict, overrides: dict) -> dict:
def _patch_report(result: ReportOutput, overrides: dict) -> ReportOutput: def _patch_report(result: ReportOutput, overrides: dict) -> ReportOutput:
merged = _deep_merge(result.model_dump(), overrides) merged = _deep_merge(result.model_dump(), overrides)
# 인스타 계정은 프롬프트에서 LLM이 []로 두게 했고, 코드가 수집 데이터로 채운다 (데이터 없으면 빈 리스트)
merged.setdefault("instagram_audit", {})["accounts"] = (overrides.get("instagram_audit") or {}).get("accounts") or []
# 페북 페이지(KR+EN): _page_patch가 부분 필드만 만들어 그대로 박으면 검증 실패(label/logo 등 누락).
# LLM이 만든 첫 페이지(보통 KR)를 템플릿으로 복사한 뒤 코드 patch로 인덱스별 덮어쓰기 →
# 필수 필드는 LLM 디폴트 받고, 수집 수치는 코드 값. EN 누락 버그 회피.
fb_pages = (overrides.get("facebook_audit") or {}).get("pages") or []
if fb_pages:
base_pages = merged.setdefault("facebook_audit", {}).setdefault("pages", [])
template = base_pages[0] if base_pages else None
while len(base_pages) < len(fb_pages) and template:
base_pages.append({**template})
for i, patch in enumerate(fb_pages):
if i < len(base_pages):
base_pages[i].update(patch)
return ReportOutput(**merged) return ReportOutput(**merged)
_MOCK_DOMAINS = {"viewclinic.com"}
_MOCK_REPORT_PATH = os.path.join(os.path.dirname(__file__), "../mock/report_viewclinic.json")
async def _is_mock(analysis_run_id: str) -> bool:
row = await fetchone(
"SELECT h.url FROM analysis_runs ar JOIN hospital_baseinfo h USING (hospital_id)"
" WHERE ar.analysis_run_id = %s",
(analysis_run_id,),
)
url = (row or {}).get("url") or ""
return any(domain in url for domain in _MOCK_DOMAINS)
def _load_mock_report() -> ReportOutput:
with open(_MOCK_REPORT_PATH, encoding="utf-8") as f:
return ReportOutput(**json.load(f))
_MOCK_PLAN_PATH = os.path.join(os.path.dirname(__file__), "../mock/plan_viewclinic.json")
def _load_mock_plan() -> PlanOutput:
with open(_MOCK_PLAN_PATH, encoding="utf-8") as f:
return PlanOutput(**json.load(f))
async def run_report_task(analysis_run_id: str) -> None: async def run_report_task(analysis_run_id: str) -> None:
logger.info("[report] start run=%s", analysis_run_id) logger.info("[report] start run=%s", analysis_run_id)
if await _is_mock(analysis_run_id): result = await generate_report(analysis_run_id)
logger.info("[report] mock mode run=%s", analysis_run_id)
result = _load_mock_report()
result.youtube_audit.linked_urls = []
else:
result = await generate_report(analysis_run_id)
result = _patch_report(result, await _build_overrides(analysis_run_id)) result = _patch_report(result, await _build_overrides(analysis_run_id))
await save_analysis_report(analysis_run_id, result.model_dump()) await save_analysis_report(analysis_run_id, result.model_dump())
logger.info("[report] done run=%s", analysis_run_id) logger.info("[report] done run=%s", analysis_run_id)
def _patch_plan(result: PlanOutput, logo_desc: str) -> PlanOutput:
"""brand_guide.channel_branding[].profile_photo 는 LLM 안 맡기고 코드가 박는다
(모든 채널 동일값 = brand_assets.logo_description). LLM이 fallback 문구 hallucinate 방지."""
p = result.model_dump()
for ch in (p.get("brand_guide") or {}).get("channel_branding") or []:
ch["profile_photo"] = logo_desc
return PlanOutput(**p)
async def run_plan_task(analysis_run_id: str) -> None: async def run_plan_task(analysis_run_id: str) -> None:
logger.info("[plan] start run=%s", analysis_run_id) logger.info("[plan] start run=%s", analysis_run_id)
if await _is_mock(analysis_run_id): result = await generate_plan(analysis_run_id)
logger.info("[plan] mock mode run=%s", analysis_run_id) # profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단)
result = _load_mock_plan() run = await fetchone("SELECT hospital_id FROM analysis_runs WHERE analysis_run_id = %s", (analysis_run_id,))
else: if run:
result = await generate_plan(analysis_run_id) hr = await fetchone("SELECT raw_data FROM hospital_baseinfo WHERE hospital_id = %s", (run["hospital_id"],))
h = json.loads(hr["raw_data"]) if hr and isinstance(hr.get("raw_data"), str) else (hr or {}).get("raw_data") or {}
logo_desc = ((h.get("brandAssets") or {}).get("logo_description")) or ""
result = _patch_plan(result, logo_desc)
await execute( await execute(
"UPDATE analysis_runs SET plan_data = %s WHERE analysis_run_id = %s", "UPDATE analysis_runs SET plan_data = %s WHERE analysis_run_id = %s",
(json.dumps(result.model_dump(), ensure_ascii=False), analysis_run_id), (json.dumps(result.model_dump(), ensure_ascii=False), analysis_run_id),

View File

@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
from common.db import fetchone
from common.db import ( from common.db import (
fetchone,
set_instagram_status, save_instagram_raw_data, set_instagram_status, save_instagram_raw_data,
set_facebook_status, save_facebook_raw_data, set_facebook_status, save_facebook_raw_data,
set_naver_blog_status, save_naver_blog_raw_data, set_naver_blog_status, save_naver_blog_raw_data,
@ -9,11 +9,13 @@ from common.db import (
set_gangnam_unni_status, save_gangnam_unni_raw_data, set_gangnam_unni_status, save_gangnam_unni_raw_data,
execute, save_hospital_raw_data, execute, save_hospital_raw_data,
) )
from common.utils import get_env from common.utils import get_env, _run_optional_step
from integrations.apify import ApifyClient from integrations.apify import ApifyClient
from integrations.naver import NaverClient from integrations.naver import NaverClient
from integrations.youtube import YouTubeClient from integrations.youtube import YouTubeClient
from integrations.firecrawl import FirecrawlClient from integrations.firecrawl import FirecrawlClient
from services.collect_extras import collect_brand_assets, collect_extra_channels, collect_channel_logos
from services.facebook_audit import transform_for_storage as transform_facebook
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,6 +32,7 @@ async def collect_facebook(analysis_run_id: str, row_id: int, url: str) -> None:
logger.info("[facebook] start run=%s url=%s", analysis_run_id, url) logger.info("[facebook] start run=%s url=%s", analysis_run_id, url)
await set_facebook_status(row_id, "processing") await set_facebook_status(row_id, "processing")
data = await ApifyClient(get_env("APIFY_API_TOKEN")).get_facebook_page(url) data = await ApifyClient(get_env("APIFY_API_TOKEN")).get_facebook_page(url)
data = transform_facebook(data)
await save_facebook_raw_data(row_id, data) await save_facebook_raw_data(row_id, data)
logger.info("[facebook] done run=%s", analysis_run_id) logger.info("[facebook] done run=%s", analysis_run_id)
@ -74,6 +77,11 @@ async def collect_all(
naver_blog_id: int | None = None, naver_blog_id: int | None = None,
youtube_id: int | None = None, youtube_id: int | None = None,
gangnam_unni_id: int | None = None, gangnam_unni_id: int | None = None,
tiktok_url: str | None = None,
instagram_en_url: str | None = None,
facebook_en_url: str | None = None,
kakao_talk_url: str | None = None,
naver_cafe_url: str | None = None,
) -> None: ) -> None:
async def _url(table: str, row_id: int) -> str: async def _url(table: str, row_id: int) -> str:
row = await fetchone(f"SELECT url FROM {table} WHERE id = %s", (row_id,)) row = await fetchone(f"SELECT url FROM {table} WHERE id = %s", (row_id,))
@ -94,3 +102,19 @@ async def collect_all(
tasks.append(collect_gangnam_unni(analysis_run_id, gangnam_unni_id, await _url("gangnam_unni_data", gangnam_unni_id))) tasks.append(collect_gangnam_unni(analysis_run_id, gangnam_unni_id, await _url("gangnam_unni_data", gangnam_unni_id)))
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
# 아래 3단계는 모두 hospital raw_data를 read-modify-write 하므로 race 방지 위해 순차.
# brand_assets : clinic_info가 채운 branding.logoUrl로 공식 로고/hex 추출
# extra_channels: 틱톡/인스타EN/페북EN 수집
# channel_logos : 공식 로고(brand_assets)+채널 profileImage(extra_channels) 채워진 뒤 Vision 비교
# 부가 기능이라 실패해도 리포트는 나와야 하므로 _run_optional_step으로 각각 격리.
await _run_optional_step(collect_brand_assets(analysis_run_id, hospital_id), "brand_assets")
await _run_optional_step(
collect_extra_channels(
analysis_run_id, hospital_id,
tiktok_url=tiktok_url, instagram_en_url=instagram_en_url, facebook_en_url=facebook_en_url,
kakao_talk_url=kakao_talk_url, naver_cafe_url=naver_cafe_url,
),
"extra_channels",
)
await _run_optional_step(collect_channel_logos(analysis_run_id, hospital_id), "channel_logos")

View File

@ -0,0 +1,191 @@
import asyncio
import json
import logging
import os
from urllib.parse import urlparse
from common.db import fetchone, fetch_raw, merge_hospital_raw_data
from common.utils import get_env
from integrations.apify import ApifyClient
from integrations.vision import VisionClient
from integrations.naver import NaverClient
from integrations.color_extractor import extract_brand_assets_from_site
from services.facebook_audit import transform_for_storage as transform_facebook
logger = logging.getLogger(__name__)
async def collect_brand_assets(analysis_run_id: str, hospital_id: str) -> None:
"""홈페이지에서 로고 URL + brand hex 색상을 뽑아 raw_data["brandAssets"]에 저장.
- 로고 URL/hex: HTML·CSS 정규식 (color_extractor) Vision 의존 X, 사이트 전체 컬러 시스템이 정확.
- 로고 정성 묘사(심볼/워드마크/): Gemini Vision (GEMINI_API_KEY 없으면 색상만 저장하고 skip).
"""
logger.info("[brand_assets] start run=%s", analysis_run_id)
row = await fetchone(
"SELECT raw_data, url FROM hospital_baseinfo WHERE hospital_id = %s",
(hospital_id,),
)
if not row:
return
raw = row["raw_data"]
raw_data = json.loads(raw) if isinstance(raw, str) else (raw or {})
branding = raw_data.get("branding") or {}
homepage_url = row["url"]
# 0~1. 사이트 1회 fetch로 logo URL + brand hex 동시 추출 (img/background-image/CSS .logo, Vision 의존 X)
site = await extract_brand_assets_from_site(homepage_url) if homepage_url else {}
html_logo_url = site.get("logo_url")
css_colors = site.get("colors") or {}
if html_logo_url:
logger.info("[brand_assets] HTML logo found: %s", html_logo_url)
if css_colors:
logger.info("[brand_assets] css colors: %s", css_colors.get("brand_colors"))
# 2. 로고/대표 이미지 후보 (logo → og:image → favicon 순)
logo_url = html_logo_url or branding.get("logoUrl")
og_image = branding.get("ogImage")
favicon = branding.get("faviconUrl")
candidates: list[tuple[str, str]] = []
if logo_url: candidates.append(("logo", logo_url))
if og_image: candidates.append(("og", og_image))
if favicon: candidates.append(("favicon", favicon))
if homepage_url:
parsed = urlparse(homepage_url)
if parsed.scheme and parsed.netloc:
candidates.append(("favicon", f"{parsed.scheme}://{parsed.netloc}/favicon.ico"))
if not candidates and not css_colors:
logger.info("[brand_assets] skip — no logo/og/favicon candidates and no CSS colors")
return
# 3. Vision은 로고 정성 묘사만 (hex는 CSS 추출이 더 정확). 키 없으면 색상만 저장.
# SVG는 vision 내부에서 resvg로 PNG 래스터화 후 Gemini에 던지므로 분기 불필요.
result: dict = {}
used_kind: str | None = None
api_key = os.getenv("GEMINI_API_KEY")
if api_key and candidates:
vc = VisionClient(api_key)
for kind, cand in candidates:
result = await vc.analyze_brand_assets(logo_url=cand, homepage_url=homepage_url)
if result:
used_kind = kind
break
# favicon으로만 분석된 경우 진짜 로고가 아니므로 logo URL은 박지 않음 (묘사는 OK)
if result and used_kind == "favicon" and result.get("logo_images"):
result["logo_images"] = {"circle": None, "horizontal": None, "korean": None}
elif not api_key:
logger.info("[brand_assets] GEMINI_API_KEY not set — 색상만 저장, Vision 묘사 skip")
# 4. CSS에서 추출한 brand_colors/palette를 Vision보다 우선 사용
if css_colors:
if css_colors.get("brand_colors"):
result["brand_colors"] = css_colors["brand_colors"]
if css_colors.get("color_palette"):
result["color_palette"] = css_colors["color_palette"]
result["color_source"] = "html+css"
elif result:
result["color_source"] = "vision"
if result:
result["logo_source"] = used_kind or "none"
await merge_hospital_raw_data(hospital_id, {"brandAssets": result})
logger.info("[brand_assets] done keys=%s", list(result.keys()) if result else None)
async def collect_extra_channels(
analysis_run_id: str,
hospital_id: str,
tiktok_url: str | None = None,
instagram_en_url: str | None = None,
facebook_en_url: str | None = None,
kakao_talk_url: str | None = None,
naver_cafe_url: str | None = None,
) -> None:
"""틱톡 / 인스타 EN / 페북 EN / 네이버 카페 수집 + 카카오톡 URL만 보관 →
모두 hospital raw_data에 저장. 인스타EN·페북EN은 기존 Apify 수집기 재사용, 틱톡은 신규 액터.
네이버 카페는 로그인 필요라 본문 보지만 URL 활성·cafeId·이름 언급수만 신호로 수집.
카카오톡은 URL만 (LLM이 채널 존재 신호로만 사용)."""
apify = ApifyClient(get_env("APIFY_API_TOKEN"))
jobs: dict = {}
if instagram_en_url:
jobs["instagramEn"] = apify.get_instagram_profile(instagram_en_url)
if facebook_en_url:
jobs["facebookEn"] = apify.get_facebook_page(facebook_en_url)
if tiktok_url:
jobs["tiktok"] = apify.get_tiktok_profile(tiktok_url)
if naver_cafe_url:
nc = NaverClient(get_env("NAVER_CLIENT_ID"), get_env("NAVER_CLIENT_SECRET"))
jobs["naverCafe"] = nc.get_cafe_info(naver_cafe_url)
results: dict = {}
if jobs:
logger.info("[extra_channels] start run=%s channels=%s", analysis_run_id, list(jobs))
done = await asyncio.gather(*jobs.values(), return_exceptions=True)
for key, res in zip(jobs.keys(), done):
if isinstance(res, Exception):
logger.warning("[extra_channels] %s 수집 실패: %s", key, res)
elif res:
if key == "facebookEn":
res = transform_facebook(res)
results[key] = res
# URL-only 채널 (수집 X, 존재 여부만)
if kakao_talk_url:
results["kakaoTalk"] = {"url": kakao_talk_url}
if not results:
logger.info("[extra_channels] 수집 결과 없음 run=%s", analysis_run_id)
return
await merge_hospital_raw_data(hospital_id, results)
logger.info("[extra_channels] done run=%s keys=%s", analysis_run_id, list(results))
async def collect_channel_logos(analysis_run_id: str, hospital_id: str) -> None:
"""채널별 프로필 이미지(로고)를 모아 Gemini Vision으로 설명 + 공식 로고 일치 여부 평가.
hospital raw_data["channelLogos"] 저장. GEMINI_API_KEY 없으면 skip.
brand_assets(공식 로고)·extra_channels(틱톡/EN profileImage) 다음에 실행돼야 ."""
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
logger.info("[channel_logos] skip — GEMINI_API_KEY 없음")
return
hrow = await fetchone("SELECT raw_data FROM hospital_baseinfo WHERE hospital_id = %s", (hospital_id,))
raw = hrow["raw_data"] if hrow else None
raw_data = json.loads(raw) if isinstance(raw, str) else (raw or {})
official = ((raw_data.get("brandAssets") or {}).get("logo_images") or {}).get("horizontal")
run = await fetchone(
"SELECT instagram_data_id, facebook_data_id, youtube_data_id"
" FROM analysis_runs WHERE analysis_run_id = %s",
(analysis_run_id,),
)
logos: list[dict] = []
# 전용 테이블 채널 (KR)
for ch, table, col in [
("Instagram", "instagram_data", "instagram_data_id"),
("Facebook", "facebook_data", "facebook_data_id"),
("YouTube", "youtube_data", "youtube_data_id"),
]:
rid = (run or {}).get(col)
if rid:
d = await fetch_raw(table, rid) or {}
if d.get("profileImage"):
logos.append({"channel": ch, "url": d["profileImage"]})
# 추가 채널 (hospital raw_data)
for ch, key in [("Instagram EN", "instagramEn"), ("Facebook EN", "facebookEn"), ("TikTok", "tiktok")]:
img = (raw_data.get(key) or {}).get("profileImage")
if img:
logos.append({"channel": ch, "url": img})
if not logos:
logger.info("[channel_logos] skip — 채널 프로필 이미지 없음")
return
logger.info("[channel_logos] start run=%s channels=%s official=%s", analysis_run_id,
[l["channel"] for l in logos], bool(official))
result = await VisionClient(api_key).describe_channel_logos(official, logos)
if result:
# Vision이 못 본 채널도 url은 채워둠 (프론트에서 이미지 표시용)
result["logos"] = logos
await merge_hospital_raw_data(hospital_id, {"channelLogos": result})
logger.info("[channel_logos] done run=%s keys=%s", analysis_run_id, list(result.keys()) if result else None)

View File

@ -0,0 +1,105 @@
"""Facebook audit 페이지(KR·EN)를 수집 데이터로 구성.
수치 지표(최근 게시일·게시 빈도·참여율) **수집 시점에** 결정적으로 산출해 DB에 박는다 (transform_for_storage).
콘텐츠 주제(top_content_type) 캡션 본문 이해가 필요해 LLM이 채운다 (리포트 프롬프트 지시)."""
from datetime import datetime, timezone
def _parse_ts(v) -> datetime | None:
if isinstance(v, (int, float)):
return datetime.fromtimestamp(v, tz=timezone.utc)
if isinstance(v, str):
try:
return datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
return None
return None
def _humanize_age(days: int) -> str:
days = max(days, 0)
if days == 0: return "오늘"
if days < 7: return f"{days}일 전"
if days < 30: return f"{days // 7}주 전"
if days < 365: return f"{days // 30}개월 전"
return f"{days // 365}년 전"
def _frequency_label(avg_gap_days: float) -> str:
"""게시물 사이 평균 간격(일) → 빈도 라벨."""
if avg_gap_days <= 1.5: return "거의 매일"
if avg_gap_days <= 10: return f"{7 / avg_gap_days:.1f}"
if avg_gap_days <= 45: return f"{30 / avg_gap_days:.1f}"
return "비정기 (분기 이상 간격)"
def _engagement_text(posts: list[dict]) -> str:
"""게시물당 좋아요/반응/공유/조회를 min~max 범위로. 전부 0인 지표는 제외.
댓글은 posts actor가 줘서 '댓글 거의 없음' 고정 부가 (FB 페이지는 댓글 희박이 일반적)."""
def _rng(vals: list[int], label: str, unit: str) -> str | None:
lo, hi = min(vals), max(vals)
if hi == 0:
return None
return f"{label} {lo}{unit}" if lo == hi else f"{label} {lo}~{hi}{unit}"
parts = [
_rng([p.get("likes", 0) for p in posts], "좋아요", ""),
_rng([p.get("reactions", 0) for p in posts], "반응", ""),
_rng([p.get("shares", 0) for p in posts], "공유", ""),
]
vid_views = [p.get("views", 0) for p in posts if p.get("isVideo")]
if vid_views:
parts.append(_rng(vid_views, "영상 조회", ""))
parts = [x for x in parts if x]
if not parts:
return "게시물당 참여 거의 없음"
return "게시물당 " + " · ".join(parts) + " · 댓글 거의 없음"
def transform_for_storage(fb: dict | None) -> dict | None:
"""apify 원본 → DB에 저장할 최종 형태.
- 수치 지표(recent_post_age·post_frequency·engagement) 자리에서 계산해 박음.
- 게시물은 캡션·타입만 남김 (raw 숫자/timestamp는 어차피 재계산 하므로 버림).
수집 시점에 계산 리포트 생성 때는 그대로 갖다 박기만 ."""
if not isinstance(fb, dict):
return fb
posts = fb.get("latestPosts") or []
out = {k: v for k, v in fb.items() if k != "latestPosts"}
if posts:
dts = sorted((d for d in (_parse_ts(p.get("timestamp")) for p in posts) if d), reverse=True)
if dts:
out["recent_post_age"] = _humanize_age((datetime.now(timezone.utc) - dts[0]).days)
if len(dts) > 1:
avg_gap = ((dts[0] - dts[-1]).days or 1) / (len(dts) - 1)
out["post_frequency"] = _frequency_label(avg_gap)
out["engagement"] = _engagement_text(posts)
out["latestPosts"] = [
{"caption": (p.get("text") or "")[:160],
"type": "video" if p.get("isVideo") else "image"}
for p in posts
]
else:
out["latestPosts"] = []
return out
def _page_patch(fb: dict) -> dict:
"""저장된 페북 페이지 → FacebookPage 스키마 필드 패치. 수치 지표는 수집 시점에 박혀있어 그대로 복사."""
p: dict = {}
if fb.get("pageUrl"): p["url"] = p["link"] = fb["pageUrl"]
if fb.get("pageName"): p["page_name"] = fb["pageName"]
if fb.get("followers"): p["followers"] = fb["followers"]
if fb.get("intro"): p["bio"] = fb["intro"]
if fb.get("categories"): p["category"] = ", ".join(fb["categories"])
if fb.get("website"): p["linked_domain"] = fb["website"]
if fb.get("reviews") is not None: p["reviews"] = fb["reviews"]
if fb.get("following") is not None: p["following"] = fb["following"]
for key in ("recent_post_age", "post_frequency", "engagement"):
if fb.get(key): p[key] = fb[key]
return p
def build_facebook_pages(facebook: dict, facebook_en: dict) -> list[dict]:
"""KR·EN 페북 페이지 패치 리스트 구성. 프롬프트가 pages를 [KR, EN] 순서로 만들므로 동일 순서 유지.
패치는 제외 (해당 채널 데이터 없음 LLM도 페이지 만듦 인덱스 정렬 유지)."""
return [pp for pp in (_page_patch(facebook), _page_patch(facebook_en)) if pp]

View File

@ -0,0 +1,48 @@
"""Instagram audit 계정(KR·EN)을 수집 데이터로 구성.
fix (handle/followers/highlights/content_format ) 전부 코드에서 박는다 LLM 출력 무시."""
_MEDIA = {"GraphImage": "이미지", "GraphSidecar": "카드뉴스", "GraphVideo": "영상/릴스"}
def _content_format(data: dict) -> str:
"""latestPosts 미디어 타입으로 콘텐츠 포맷 도출 (표기 순서는 _MEDIA 정의 순서로 고정)."""
present = {_MEDIA.get(p.get("type")) for p in (data.get("latestPosts") or [])}
return "/".join(m for m in _MEDIA.values() if m in present)
def _logo_desc(channel_logos: dict, channel: str) -> str:
"""channelLogos(비전 결과)에서 해당 채널 로고 설명만 가져온다."""
for c in (channel_logos or {}).get("channel_logos", []):
if c.get("channel") == channel:
return c.get("logo_description") or ""
return ""
def _account(data: dict, language: str, label: str, channel: str, channel_logos: dict) -> dict:
"""스크래퍼 수집값으로 InstagramAccount 전 필드를 구성."""
handle = data.get("username") or ""
return {
"handle": handle,
"language": language,
"label": label,
"posts": data.get("posts", 0),
"followers": data.get("followers", 0),
"following": data.get("following", 0),
"category": data.get("category", ""),
"profile_link": f"https://www.instagram.com/{handle}/" if handle else "",
"highlights": data.get("highlights") or [],
"reels_count": 0, # 릴스 스크래퍼 미사용
"content_format": _content_format(data),
"profile_photo": _logo_desc(channel_logos, channel),
"bio": data.get("bio", ""),
}
def build_instagram_accounts(instagram: dict, instagram_en: dict, channel_logos: dict) -> list[dict]:
"""KR·EN 인스타 계정 리스트 구성 (username 있는 것만)."""
accounts: list[dict] = []
if instagram.get("username"):
accounts.append(_account(instagram, "KR", "인스타그램 KR", "Instagram", channel_logos))
if instagram_en.get("username"):
accounts.append(_account(instagram_en, "EN", "인스타그램 EN", "Instagram EN", channel_logos))
return accounts

View File

@ -8,8 +8,9 @@ from services.analysis import run_report_task, run_plan_task
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def run_pipeline(analysis_run_id: str) -> None: async def run_pipeline(analysis_run_id: str, extra_channels: dict | None = None) -> None:
logger.info("[pipeline] start run=%s", analysis_run_id) logger.info("[pipeline] start run=%s", analysis_run_id)
extra_channels = extra_channels or {}
# ── 1. Collect ────────────────────────────────────────────────────────── # ── 1. Collect ──────────────────────────────────────────────────────────
run = await fetchone( run = await fetchone(
@ -26,6 +27,11 @@ async def run_pipeline(analysis_run_id: str) -> None:
naver_blog_id=run["naver_blog_data_id"], naver_blog_id=run["naver_blog_data_id"],
youtube_id=run["youtube_data_id"], youtube_id=run["youtube_data_id"],
gangnam_unni_id=run["gangnam_unni_data_id"], gangnam_unni_id=run["gangnam_unni_data_id"],
tiktok_url=extra_channels.get("tiktok"),
instagram_en_url=extra_channels.get("instagram_en"),
facebook_en_url=extra_channels.get("facebook_en"),
kakao_talk_url=extra_channels.get("kakao_talk"),
naver_cafe_url=extra_channels.get("naver_cafe"),
) )
# ── 2. Market ──────────────────────────────────────────────────────────── # ── 2. Market ────────────────────────────────────────────────────────────

View File

@ -10,3 +10,4 @@ passlib[bcrypt]==1.7.4
python-multipart==0.0.26 python-multipart==0.0.26
uuid6==2025.0.1 uuid6==2025.0.1
aiomysql==0.3.2 aiomysql==0.3.2
resvg-py==0.3.2