diff --git a/app/api/analysis.py b/app/api/analysis.py
index e5d30cc..c2d99d7 100644
--- a/app/api/analysis.py
+++ b/app/api/analysis.py
@@ -5,23 +5,50 @@ from common.deps import verify_api_key
from common.db.hospital import select_hospital
from common.db.source import select_source_mainpage, insert_source, insert_raw_info
from common.db.run import insert_run, select_run_status
+from common.utils import _normalize_homepage, _with_scheme
from models.analysis import AnalysisCreate, AnalysisStartResponse, AnalysisStatusResponse
from models.file import FileListItem, FileType, FileUploadResponse
from models.status import AnalysisStatus, SourceType
from services.pipeline import run_pipeline
from services.file_data import get_analysis_files_response, handle_analysis_file_upload, soft_delete_analysis_file
+from mock_urls import MOCK_CLINICS
router = APIRouter(prefix="/api/analysis", tags=["analysis"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
+# 클라가 일부만 보내거나 빈 값이면 mock_urls 의 동일 homepage 매칭으로 채워줌 (메인 + 부가 채널 동일 규칙).
+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)
async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks):
logger.info("POST /api/analysis clinic_id=%s", body.clinic_id)
analysis_run_id = str(uuid6.uuid7())
hospital_id = body.clinic_id
- # 사실 hospital과 owner_user_id 비교 후 검증이 필요한 거지만 일단 PoC 니까. 나중에 바꿉니다.
+ # 사실 hospital 과 owner_user_id 비교 후 검증이 필요한 거지만 일단 PoC 니까. 나중에 바꿉니다.
hospital = await select_hospital(hospital_id)
if not hospital:
raise HTTPException(status_code=409, detail="Clinic not found")
@@ -31,19 +58,40 @@ async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks
mainpage = await select_source_mainpage(hospital_id)
if mainpage:
await insert_raw_info(mainpage["source_id"], analysis_run_id, data_tag=SourceType.MAINPAGE)
+ # branding (HTML/CSS + Vision 로고 매칭) — mainpage 와 같은 homepage URL 을 source 로 사용.
+ branding_id = await insert_source(hospital_id, SourceType.BRANDING, mainpage["url"], language="KR")
+ await insert_raw_info(branding_id, analysis_run_id, data_tag=SourceType.BRANDING)
- channels = [
- (SourceType.INSTAGRAM, body.channels.instagram),
- (SourceType.FACEBOOK, body.channels.facebook),
- (SourceType.NAVER_BLOG, body.channels.naver_blog),
- (SourceType.YOUTUBE, body.channels.youtube),
- (SourceType.GANGNAM_UNNI, body.channels.gangnam_unni),
+ # 클라가 안 보낸 채널은 mock_urls 에서 homepage 매칭으로 보충 (main + extra 동일 규칙).
+ mock = _channels_from_mockurls((mainpage or {}).get("url") or "")
+
+ # 메인 5채널 (KR). _with_scheme 으로 'gangnamunni.com/...' 같이 scheme/www 없이 와도 보강.
+ main_channels = [
+ (SourceType.INSTAGRAM, _with_scheme(body.channels.instagram) or mock.get("instagram")),
+ (SourceType.FACEBOOK, _with_scheme(body.channels.facebook) or mock.get("facebook")),
+ (SourceType.NAVER_BLOG, _with_scheme(body.channels.naver_blog) or mock.get("naver_blog")),
+ (SourceType.YOUTUBE, _with_scheme(body.channels.youtube) or mock.get("youtube")),
+ (SourceType.GANGNAM_UNNI, _with_scheme(body.channels.gangnam_unni) or mock.get("gangnam_unni")),
]
- for source_type, url in channels:
+ for source_type, url in main_channels:
if url:
- source_id = await insert_source(hospital_id, source_type, url)
+ source_id = await insert_source(hospital_id, source_type, url, language="KR")
await insert_raw_info(source_id, analysis_run_id, data_tag=source_type)
+ # 부가 채널 — instagram_en/facebook_en 은 동일 source_type 에 language='EN' 으로 구분, 나머지는 자체 source_type.
+ extra_channels = [
+ (SourceType.INSTAGRAM, "EN", _with_scheme(body.channels.instagram_en) or mock.get("instagram_en")),
+ (SourceType.FACEBOOK, "EN", _with_scheme(body.channels.facebook_en) or mock.get("facebook_en")),
+ (SourceType.TIKTOK, "KR", _with_scheme(body.channels.tiktok) or mock.get("tiktok")),
+ (SourceType.KAKAOTALK, "KR", _with_scheme(body.channels.kakao_talk) or mock.get("kakao_talk")),
+ (SourceType.NAVER_CAFE, "KR", _with_scheme(body.channels.naver_cafe) or mock.get("naver_cafe")),
+ ]
+ for source_type, language, url in extra_channels:
+ if url:
+ source_id = await insert_source(hospital_id, source_type, url, language=language)
+ await insert_raw_info(source_id, analysis_run_id, data_tag=source_type)
+
+ logger.info("[analysis] main+extra channels resolved (mock_matched=%s)", bool(mock))
background_tasks.add_task(run_pipeline, analysis_run_id)
return AnalysisStartResponse(
diff --git a/app/api/plan.py b/app/api/plan.py
index 2b3464a..697354c 100644
--- a/app/api/plan.py
+++ b/app/api/plan.py
@@ -2,9 +2,12 @@ import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Response
from common.db.run import select_run_with_clinic
+from common.db.source import select_run_source_raw
from common.deps import verify_api_key
+from common.utils import _with_scheme
from integrations.llm.schemas.plan import PlanOutput
from models.plan import PlanApiResponse
+from models.status import SourceType
router = APIRouter(prefix="/api/plan", tags=["plan"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@@ -20,11 +23,14 @@ async def get_plan(run_id: str):
return Response(status_code=204)
data = json.loads(row["plan_data"]) if isinstance(row["plan_data"], str) else row["plan_data"]
plan = PlanOutput(**data)
+ # 강남언니에서 긁어온 이름이 있으면 우선 (hospital_baseinfo 의 정식 이름보다 강남언니가 더 광고용 표기).
+ gu = await select_run_source_raw(run_id, SourceType.GANGNAM_UNNI) or {}
+ clinic_name = gu.get("name") or row["hospital_name"]
return PlanApiResponse(
id=run_id,
- clinic_name=row["hospital_name"],
+ clinic_name=clinic_name,
clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]),
- target_url=row["target_url"],
+ target_url=_with_scheme(row["target_url"]),
**plan.model_dump(),
)
diff --git a/app/api/report.py b/app/api/report.py
index bf7cb61..ac139f5 100644
--- a/app/api/report.py
+++ b/app/api/report.py
@@ -3,6 +3,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response
from common.db.run import select_run_with_clinic
from common.deps import verify_api_key
+from common.utils import _with_scheme
from integrations.llm.schemas.report import ReportOutput
from models.report import MarketingReportResponse
@@ -25,6 +26,6 @@ async def get_report(run_id: str):
clinic_name=row["hospital_name"],
clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]),
- target_url=row["target_url"],
+ target_url=_with_scheme(row["target_url"]),
**llm_output.model_dump(exclude={"id", "created_at", "target_url"}),
)
diff --git a/app/common/db/__init__.py b/app/common/db/__init__.py
index e7be4bd..67048a8 100644
--- a/app/common/db/__init__.py
+++ b/app/common/db/__init__.py
@@ -1,14 +1,16 @@
from common.db.base import execute, fetchone, fetchall
from common.db.hospital import select_hospital, update_hospital_status, insert_hospital, update_hospital
from common.db.source import (
- insert_source, select_source_mainpage,
- insert_raw_info, update_raw_info_status, update_raw_info,
+ insert_source, select_source_mainpage, select_source_by_type,
+ insert_raw_info, update_raw_info_status, update_raw_info, update_raw_info_merge,
+ update_raw_info_logo_url, select_mainpage_logo_url, select_branding_info_id,
select_raw_info_data,
- select_run_sources, select_run_raw_data, select_run_mainpage_url,
+ select_run_sources, select_run_raw_data, select_run_source_raw,
+ select_run_mainpage_url,
)
from common.db.run import (
insert_run, select_run, select_run_status, update_run_status,
- update_run_report, update_run_plan, select_run_with_clinic,
+ update_run_report, update_run_plan, select_run_with_clinic, select_run_report_data,
)
from common.db.market import upsert_market_status, upsert_market_result, select_market
from common.db.file_data import insert_file, select_run_files, select_file, delete_file
diff --git a/app/common/db/run.py b/app/common/db/run.py
index cbf188c..4169f5f 100644
--- a/app/common/db/run.py
+++ b/app/common/db/run.py
@@ -22,6 +22,18 @@ async def select_run(analysis_run_id: str) -> dict | None:
)
+async def select_run_report_data(analysis_run_id: str) -> dict | None:
+ """report 결과가 필요할 때만 호출. raw JSON 파싱해서 dict 반환."""
+ import json
+ row = await fetchone(
+ "SELECT report_data FROM analysis_runs WHERE analysis_run_id = %s",
+ (analysis_run_id,),
+ )
+ if not row or not row["report_data"]:
+ return None
+ return json.loads(row["report_data"]) if isinstance(row["report_data"], str) else row["report_data"]
+
+
async def select_run_status(analysis_run_id: str) -> str | None:
row = await fetchone(
"SELECT status FROM analysis_runs WHERE analysis_run_id = %s",
diff --git a/app/common/db/source.py b/app/common/db/source.py
index 0f8c5c4..a9087d0 100644
--- a/app/common/db/source.py
+++ b/app/common/db/source.py
@@ -17,7 +17,7 @@ async def insert_source(
async def select_source_mainpage(hospital_id: str) -> dict | None:
return await fetchone(
- "SELECT source_id FROM remote_source WHERE hospital_id = %s AND source_type = 'mainpage'",
+ "SELECT source_id, url FROM remote_source WHERE hospital_id = %s AND source_type = 'mainpage'",
(hospital_id,),
)
@@ -64,18 +64,99 @@ async def select_run_sources(analysis_run_id: str) -> list[dict]:
async def select_run_raw_data(analysis_run_id: str) -> dict:
rows = await fetchall(
- "SELECT rs.source_type, ri.raw_data"
+ "SELECT rs.source_type, rs.language, ri.raw_data, ri.logo_url"
" FROM raw_info ri JOIN remote_source rs USING (source_id)"
- " WHERE ri.analysis_run_id = %s",
+ " WHERE ri.analysis_run_id = %s AND ri.status = 'done'",
(analysis_run_id,),
)
result: dict = {}
for row in rows:
- raw = row["raw_data"]
- result[row["source_type"]] = json.loads(raw) if isinstance(raw, str) else raw
+ source_type = row["source_type"]
+ if source_type not in result:
+ result[source_type] = list()
+
+ item : dict = {}
+ item["raw_data"] = json.loads(row["raw_data"])
+ item["logo_url"] = row["logo_url"]
+ item["source_type"] = row["source_type"]
+ item["language"] = row["language"]
+
+ result[source_type].append(item)
+
return result
+async def select_run_source_raw(
+ analysis_run_id: str, source_type: str, language: str | None = None,
+) -> dict | None:
+ sql = (
+ "SELECT ri.raw_data FROM raw_info ri JOIN remote_source rs USING (source_id)"
+ " WHERE ri.analysis_run_id = %s AND rs.source_type = %s"
+ )
+ args: tuple = (analysis_run_id, source_type)
+ if language:
+ sql += " AND rs.language = %s"
+ args = (*args, language)
+ sql += " LIMIT 1"
+ row = await fetchone(sql, args)
+ if not row or not row["raw_data"]:
+ return None
+ return json.loads(row["raw_data"]) if isinstance(row["raw_data"], str) else row["raw_data"]
+
+
+async def update_raw_info_logo_url(info_id: int, logo_url: str) -> None:
+ """raw_info.logo_url 컬럼에 로고 URL 저장 (JSON raw_data 와 분리해 컬럼 인덱스/조회 용이)."""
+ await execute(
+ "UPDATE raw_info SET logo_url = %s WHERE info_id = %s",
+ (logo_url, info_id),
+ )
+
+
+async def select_branding_info_id(analysis_run_id: str) -> int | None:
+ row = await fetchone(
+ "SELECT ri.info_id FROM raw_info ri JOIN remote_source rs USING (source_id)"
+ " WHERE ri.analysis_run_id = %s AND rs.source_type = 'branding' LIMIT 1",
+ (analysis_run_id,),
+ )
+ return (row or {}).get("info_id")
+
+
+async def select_mainpage_logo_url(analysis_run_id: str) -> str | None:
+ row = await fetchone(
+ "SELECT ri.logo_url FROM raw_info ri JOIN remote_source rs USING (source_id)"
+ " WHERE ri.analysis_run_id = %s AND rs.source_type = 'mainpage' LIMIT 1",
+ (analysis_run_id,),
+ )
+ return (row or {}).get("logo_url")
+
+
+async def update_raw_info_merge(info_id: int, patch: dict) -> None:
+ """raw_info.raw_data 를 read-modify-write 로 top-level 머지.
+ 한 source 가 단계별로 (예: branding 의 brandAssets → channelLogos) 키를 덧붙일 때 사용."""
+ row = await fetchone("SELECT raw_data FROM raw_info WHERE info_id = %s", (info_id,))
+ if not row:
+ return
+ raw = row["raw_data"]
+ data = json.loads(raw) if isinstance(raw, str) else (raw or {})
+ data.update(patch)
+ await execute(
+ "UPDATE raw_info SET raw_data = %s, status = 'done' WHERE info_id = %s",
+ (json.dumps(data, ensure_ascii=False), info_id),
+ )
+
+
+async def select_source_by_type(
+ hospital_id: str, source_type: str, language: str | None = None,
+) -> dict | None:
+ sql = "SELECT source_id, url FROM remote_source WHERE hospital_id = %s AND source_type = %s"
+ args: tuple = (hospital_id, source_type)
+ if language:
+ sql += " AND language = %s"
+ args = (*args, language)
+ sql += " LIMIT 1"
+ return await fetchone(sql, args)
+
+
async def select_run_mainpage_url(analysis_run_id: str) -> str:
row = await fetchone(
"SELECT rs.url FROM raw_info ri JOIN remote_source rs USING (source_id)"
diff --git a/app/common/utils.py b/app/common/utils.py
index aca1d13..501d89d 100644
--- a/app/common/utils.py
+++ b/app/common/utils.py
@@ -1,11 +1,101 @@
import os
+import re
import asyncio
+import logging
+from datetime import datetime, timezone
from http import HTTPMethod
import httpx
+logger = logging.getLogger(__name__)
+
REQUEST_TIMEOUT = 60
+def parse_iso_duration_seconds(iso: str) -> int:
+ m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
+ if not m:
+ return 0
+ h, mins, s = (int(x or 0) for x in m.groups())
+ return h * 3600 + mins * 60 + s
+
+
+def format_seconds(seconds: int) -> str:
+ m, s = divmod(seconds, 60)
+ h, m = divmod(m, 60)
+ return f"{h}시간 {m}분" if h else f"{m}분 {s}초"
+
+
+def format_clock(seconds: int) -> str:
+ m, s = divmod(seconds, 60)
+ h, m = divmod(m, 60)
+ return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
+
+
+def calc_avg_video_length(videos: list[dict]) -> str:
+ durations = [parse_iso_duration_seconds(v.get("duration", "")) for v in videos]
+ durations = [d for d in durations if d > 0]
+ if not durations:
+ return ""
+ return format_seconds(sum(durations) // len(durations))
+
+
+def relative_date(date_str: str) -> str:
+ if not date_str:
+ return ""
+ try:
+ past = datetime.fromisoformat(date_str[:10])
+ except ValueError:
+ return ""
+ days = (datetime.now() - past).days
+ if days < 1:
+ return "오늘"
+ if days < 30:
+ return f"{days}일 전"
+ if days < 365:
+ return f"{days // 30}개월 전"
+ return f"{days // 365}년 전"
+
+
+def calc_upload_frequency(videos: list[dict]) -> str:
+ dates = sorted(
+ [v["date"][:10] for v in videos if v.get("date")],
+ reverse=True,
+ )
+ if len(dates) < 2:
+ return ""
+ gaps = [
+ (datetime.fromisoformat(dates[i]) - datetime.fromisoformat(dates[i + 1])).days
+ for i in range(len(dates) - 1)
+ ]
+ avg_days = sum(gaps) // len(gaps)
+ if avg_days <= 7:
+ return f"주 {7 // max(avg_days, 1)}회"
+ if avg_days <= 30:
+ return f"월 {30 // avg_days}회"
+ return f"{avg_days}일에 1회"
+
+
+def parse_ts(v) -> datetime | None:
+ """수집기마다 다른 timestamp 포맷을 통일된 datetime으로 변환.
+ 파싱 실패 시 None.
+ """
+ # 숫자면 epoch (Unix timestamp) — apify가 가끔 epoch로 줌
+ if isinstance(v, (int, float)):
+ return datetime.fromtimestamp(v, tz=timezone.utc)
+ if isinstance(v, str):
+ # 1순위: ISO 8601 (대부분 apify/firecrawl 출력)
+ try:
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
+ except ValueError:
+ pass
+ # 2순위: RFC 2822 (네이버 블로그 RSS 등 — 표준 라이브러리 파서로)
+ try:
+ from email.utils import parsedate_to_datetime
+ return parsedate_to_datetime(v)
+ except (TypeError, ValueError):
+ return None
+ return None
+
def get_env(key: str) -> str:
v = os.environ.get(key, "")
@@ -37,3 +127,48 @@ async def http_request(
print(f" [error] {label} → {e}")
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
diff --git a/app/integrations/apify.py b/app/integrations/apify.py
index 4028a50..f1b77fb 100644
--- a/app/integrations/apify.py
+++ b/app/integrations/apify.py
@@ -1,16 +1,32 @@
+import asyncio
from http import HTTPMethod
from urllib.parse import urlparse
from common.utils import http_request
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:
def __init__(self, token: str, wait_for_finish: int = 120):
self.token = token
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(
HTTPMethod.POST,
url=f"{APIFY_BASE}/acts/{actor_id}/runs",
@@ -26,34 +42,53 @@ class ApifyClient:
items_resp = await http_request(
HTTPMethod.GET,
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}",
)
if not items_resp or not items_resp.is_success:
return []
return items_resp.json()
- async def fetch_instagram_profile(self, url: str) -> dict | None:
- username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
- print(username)
- items = await self._run_actor("apify~instagram-profile-scraper", {"usernames": [username], "resultsLimit": 12})
+ async def fetch_instagram_profile(self, username: str) -> dict | None:
+ items = await self._run_actor(IG_PROFILE_ACTOR, {"usernames": [username]})
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:
- profile = await self.fetch_instagram_profile(url)
- if not profile or profile.get("error"):
+ username = _ig_username(url)
+ # 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
+ 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 {
"username": profile["username"],
+ "profileImage": profile.get("hdProfilePicUrl") or profile.get("profilePicUrl"),
"followers": profile.get("followersCount", 0),
"following": profile.get("followsCount", 0),
"posts": profile.get("postsCount", 0),
"bio": profile.get("biography", ""),
+ "category": profile.get("businessCategoryName") or "",
"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": [
{
- "type": p.get("type"),
+ "type": p.get("mediaType") or p.get("type"),
"likes": p.get("likesCount", 0),
"comments": p.get("commentsCount", 0),
"caption": (p.get("caption") or "")[:500],
@@ -63,87 +98,119 @@ class ApifyClient:
],
}
- async def fetch_instagram_posts(self, url: str, limit: int = 20) -> list[dict]:
- username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
- return await self._run_actor("apify~instagram-post-scraper", {
- "username": [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)
- posts = [
- {
- "id": p["id"],
- "type": p.get("type"),
- "url": p.get("url"),
- "caption": (p.get("caption") or "")[:500],
- "hashtags": p.get("hashtags", []),
- "likesCount": p.get("likesCount", 0),
- "commentsCount": p.get("commentsCount", 0),
- "timestamp": p.get("timestamp"),
- }
- for p in items
- ]
- n = len(posts) or 1
- return {
- "posts": posts,
- "totalPosts": len(posts),
- "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),
- }
+ # 인스타 post 스크래퍼는 현재 파이프라인 미사용 — 비활성화 (필요 시 복구)
+ # async def fetch_instagram_posts(self, url: str, limit: int = 20) -> list[dict]:
+ # username = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url.lstrip("@")
+ # return await self._run_actor("apify~instagram-post-scraper", {
+ # "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)
+ # posts = [
+ # {
+ # "id": p["id"],
+ # "type": p.get("type"),
+ # "url": p.get("url"),
+ # "caption": (p.get("caption") or "")[:500],
+ # "hashtags": p.get("hashtags", []),
+ # "likesCount": p.get("likesCount", 0),
+ # "commentsCount": p.get("commentsCount", 0),
+ # "timestamp": p.get("timestamp"),
+ # }
+ # for p in items
+ # ]
+ # n = len(posts) or 1
+ # return {
+ # "posts": posts,
+ # "totalPosts": len(posts),
+ # "avgLikes": round(sum(p["likesCount"] for p in posts) / n),
+ # "avgComments": round(sum(p["commentsCount"] for p in posts) / n),
+ # }
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
+ 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:
- page = await self.fetch_facebook_page(page_url)
- if not page:
+ # pages·posts 두 task 병렬 호출 (posts 실패해도 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
+ if isinstance(posts, Exception):
+ posts = []
return {
"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),
"followers": page.get("followers", 0),
- "likes": page.get("likes", 0),
+ "following": page.get("followings", 0),
+ "reviews": page.get("ratingCount", 0),
"categories": page.get("categories", []),
- "email": page.get("email"),
- "phone": page.get("phone"),
- "website": page.get("website"),
- "address": page.get("address"),
+ "website": page.get("website") or page.get("websites"),
"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],
}
diff --git a/app/integrations/firecrawl.py b/app/integrations/firecrawl.py
index 7daa866..994daba 100644
--- a/app/integrations/firecrawl.py
+++ b/app/integrations/firecrawl.py
@@ -76,7 +76,7 @@ class FirecrawlClient:
"url": url,
"formats": ["json", "links"],
"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
src, og:image from content, favicon URL)",
"schema": {
"type": "object",
"properties": {
@@ -119,6 +119,7 @@ class FirecrawlClient:
"headingFont": {"type": "string"},
"bodyFont": {"type": "string"},
"logoUrl": {"type": "string"},
+ "ogImage": {"type": "string"},
"faviconUrl": {"type": "string"},
},
},
diff --git a/app/integrations/llm/gemini_vision.py b/app/integrations/llm/gemini_vision.py
new file mode 100644
index 0000000..e8a71a6
--- /dev/null
+++ b/app/integrations/llm/gemini_vision.py
@@ -0,0 +1,334 @@
+"""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": "워드마크 텍스트 그대로. 태그 내용 우선",\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": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\')",\n'
+ ' "logo_colors_hex": ["로고에서 시각적으로 두드러진 색 정확히 5개의 hex 근사값 배열. 예: [\'#1A2B3C\', \'#D4A017\', \'#FFFFFF\', \'#9E5C2A\', \'#1F1F1F\']. 강한 색이 5개 안 되면 음영/명도 차이로 5개 채울 것. 빈 배열 금지."]\n'
+ "}\n"
+ "주의: logo_colors_hex 는 시각 추정이라 정확도 떨어질 수 있음. CSS 추출이 우선이고 이건 fallback/보완 용.\n"
+ "모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)."
+ )
+ result = await self._ask(urls, prompt)
+ if not result:
+ return {}
+ # logo_images는 우리가 직접 채움 (Vision은 묘사만)
+ result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
+ # logo_colors_hex 5개 강제 정규화 — LLM 이 4개나 6개 줄 수도 있어서 길이 fallback.
+ hex_list = [h for h in (result.get("logo_colors_hex") or []) if isinstance(h, str) and h.startswith("#")]
+ if hex_list:
+ while len(hex_list) < 5:
+ hex_list.append(hex_list[-1]) # 마지막 색 복제로 패딩
+ result["logo_colors_hex"] = hex_list[:5]
+ else:
+ result["logo_colors_hex"] = []
+ 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,
+ }
diff --git a/app/integrations/llm/prompt.py b/app/integrations/llm/prompt.py
index e5397d5..18c56c8 100644
--- a/app/integrations/llm/prompt.py
+++ b/app/integrations/llm/prompt.py
@@ -5,9 +5,12 @@ from integrations.llm.schemas.report import (
ReportInput, ReportOutput,
CriticalIssuesInput, CriticalIssuesOutput,
YouTubeDiagnosisInput, YouTubeDiagnosisOutput,
+ InstagramDiagnosisInput, InstagramDiagnosisOutput,
+ FacebookDiagnosisInput, FacebookDiagnosisOutput,
BrandConsistencyInput, BrandConsistencyOutput,
TransformationInput, TransformationProposal,
RoadmapInput, RoadmapOutput,
+ ScoresInput, ScoresOutput,
)
from integrations.llm.schemas.plan import PlanInput, PlanOutput
from integrations.llm.schemas.market import (
@@ -88,6 +91,20 @@ market_target_audience_prompt = Prompt(
output_class=MarketTargetAudienceOutput,
)
+facebook_diagnosis_prompt = Prompt(
+ file_name="facebook_diagnosis_prompt.txt",
+ prompt_model="REPORT_MODEL",
+ input_class=FacebookDiagnosisInput,
+ output_class=FacebookDiagnosisOutput,
+)
+
+instagram_diagnosis_prompt = Prompt(
+ file_name="instagram_diagnosis_prompt.txt",
+ prompt_model="REPORT_MODEL",
+ input_class=InstagramDiagnosisInput,
+ output_class=InstagramDiagnosisOutput,
+)
+
youtube_diagnosis_prompt = Prompt(
file_name="youtube_diagnosis_prompt.txt",
prompt_model="REPORT_MODEL",
@@ -116,6 +133,13 @@ transformation_prompt = Prompt(
output_class=TransformationProposal,
)
+scores_prompt = Prompt(
+ file_name="scores_prompt.txt",
+ prompt_model="REPORT_MODEL",
+ input_class=ScoresInput,
+ output_class=ScoresOutput,
+)
+
roadmap_prompt = Prompt(
file_name="roadmap_prompt.txt",
prompt_model="REPORT_MODEL",
diff --git a/app/integrations/llm/schemas/plan.py b/app/integrations/llm/schemas/plan.py
index a12a957..819842f 100644
--- a/app/integrations/llm/schemas/plan.py
+++ b/app/integrations/llm/schemas/plan.py
@@ -15,6 +15,14 @@ class PlanInput(BaseModel):
market_keywords: str | None = None
market_trend: 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 ---
@@ -51,7 +59,7 @@ class ChannelBrandingRule(BaseModel):
profile_photo: str
banner_spec: str
bio_template: str
- current_status: Literal["correct", "incorrect", "missing"]
+ current_status: Literal["correct", "incorrect", "N/A"]
class BrandPlanInconsistencyValue(BaseModel):
diff --git a/app/integrations/llm/schemas/report.py b/app/integrations/llm/schemas/report.py
index be3be9b..c47689a 100644
--- a/app/integrations/llm/schemas/report.py
+++ b/app/integrations/llm/schemas/report.py
@@ -70,14 +70,14 @@ class RegistryData(BaseModel):
class ClinicSnapshot(BaseModel):
name: str
name_en: str
- staff_count: int
- lead_doctor: LeadDoctor
- overall_rating: float
- total_reviews: int
- certifications: list[str]
+ staff_count: int | None = None
+ lead_doctor: LeadDoctor | None = None
+ overall_rating: float | None = None
+ total_reviews: int | None = None
+ certifications: list[str] = []
location: str
phone: str
- domain: str
+ domain: str | None = None
logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None
source: DataSource | None = None
@@ -191,17 +191,17 @@ class FacebookPage(BaseModel):
linked_domain: str
reviews: int
recent_post_age: str
- has_whatsapp: bool
- post_frequency: str | None = None
+ has_whatsapp: bool | None = None
+ post_frequency: str
top_content_type: str | None = None
- engagement: str | None = None
+ engagement: str
class FacebookAudit(BaseModel):
- pages: list[FacebookPage]
- diagnosis: list[DiagnosisItem]
- brand_inconsistencies: list[BrandInconsistency]
- consolidation_recommendation: str
+ pages: list[FacebookPage] = []
+ diagnosis: list[DiagnosisItem] = []
+ brand_inconsistencies: list[BrandInconsistency] = []
+ consolidation_recommendation: str | None = None
# --- 기타 채널 / 웹사이트 ---
@@ -314,6 +314,14 @@ class ReportInput(BaseModel):
market_keywords: str | None = None
market_trend: 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 ---
@@ -357,6 +365,34 @@ class YouTubeDiagnosisOutput(BaseModel):
diagnosis: list[DiagnosisItem]
+class InstagramDiagnosisInput(BaseModel):
+ accounts: str | None = None
+
+
+class InstagramDiagnosisOutput(BaseModel):
+ diagnosis: list[DiagnosisItem]
+
+
+class FacebookDiagnosisInput(BaseModel):
+ pages: str | None = None
+
+
+class FacebookDiagnosisOutput(BaseModel):
+ diagnosis: list[DiagnosisItem]
+
+
+# --- Scores ---
+
+class ScoresInput(BaseModel):
+ clinic_name: str | None = None
+ data: str | None = None
+
+
+class ScoresOutput(BaseModel):
+ overall_score: int
+ channel_scores: list[ChannelScore]
+
+
# --- Diagnosis ---
class CriticalIssuesInput(BaseModel):
diff --git a/app/integrations/llm/temp-prompt/facebook_diagnosis_prompt.txt b/app/integrations/llm/temp-prompt/facebook_diagnosis_prompt.txt
new file mode 100644
index 0000000..afd4411
--- /dev/null
+++ b/app/integrations/llm/temp-prompt/facebook_diagnosis_prompt.txt
@@ -0,0 +1,8 @@
+다음은 성형외과/피부과의 페이스북 페이지 데이터입니다.
+
+{pages}
+
+위 데이터를 바탕으로 이 병원의 페이스북 마케팅 현황을 진단해줘.
+각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
+
+출처 번호([1], [2] 등)는 포함하지 마.
diff --git a/app/integrations/llm/temp-prompt/instagram_diagnosis_prompt.txt b/app/integrations/llm/temp-prompt/instagram_diagnosis_prompt.txt
new file mode 100644
index 0000000..b4dbe4f
--- /dev/null
+++ b/app/integrations/llm/temp-prompt/instagram_diagnosis_prompt.txt
@@ -0,0 +1,8 @@
+다음은 성형외과/피부과의 인스타그램 계정 데이터입니다.
+
+{accounts}
+
+위 데이터를 바탕으로 이 병원의 인스타그램 마케팅 현황을 진단해줘.
+각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
+
+출처 번호([1], [2] 등)는 포함하지 마.
diff --git a/app/integrations/llm/temp-prompt/plan_prompt.txt b/app/integrations/llm/temp-prompt/plan_prompt.txt
index af7c52e..37e5666 100644
--- a/app/integrations/llm/temp-prompt/plan_prompt.txt
+++ b/app/integrations/llm/temp-prompt/plan_prompt.txt
@@ -32,19 +32,65 @@
## 분석 리포트
{report}
+## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN / 네이버 카페 / 카카오톡)
+아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 인스타그램, 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외.
+
+### 네이버 블로그 (Naver Blog)
+{naver_blog}
+
+### 틱톡 (TikTok)
+{tiktok}
+
+### 인스타그램
+{instagram}
+
+### 페이스북
+{facebook}
+
+### 네이버 카페 (공식 카페 운영 신호)
+{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
-- colors: 병원 아이덴티티에 맞는 컬러 팔레트 3~5개 (hex + 사용 가이드)
+- colors: **brand_assets.color_palette / brand_colors의 hex를 그대로 사용** (홈페이지 CSS 추출값, 지어내기 금지). 3~5개, 각 hex에 name/usage 부여
- 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: 브랜드 성격 키워드, 커뮤니케이션 스타일, 권장/지양 표현 예시
- channelBranding: 리포트에 존재하는 채널별 브랜딩 적용 규칙
- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고
### Section 2: channelStrategies
-- 리포트에 데이터가 있는 채널만 포함
-- 각 채널의 우선순위(P0/P1/P2), 목표, 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성
+- 메인 SNS 채널(Instagram, Facebook, YouTube, TikTok, 네이버 블로그) + 영문 계정(Instagram EN, Facebook EN) + **네이버 카페 / 카카오톡** (URL 있을 때) 카드를 **모두 포함**. 데이터 없는 채널도 빠뜨리지 말 것.
+- **currentStatus**: 데이터 있는 채널은 실제 수치로 서술 (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). **데이터 없는 채널은 "계정 없음"** 으로 표시. `excellent`/`warning`/`good` 같은 등급·평가어 금지.
+- **targetGoal은 모든 채널에 반드시 채울 것** — 구체적 목표 수치(예: "50K 팔로워, Reels 주 5개"). 데이터 없는 채널도 시작 시 권장 목표를 작성하고 비우지 말 것.
+- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 모두 권장값으로 작성 — 데이터 없어도 시작 권장값으로 채울 것.
- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정
### Section 3: contentStrategy
diff --git a/app/integrations/llm/temp-prompt/report_prompt.txt b/app/integrations/llm/temp-prompt/report_prompt.txt
index 34a9086..761168f 100644
--- a/app/integrations/llm/temp-prompt/report_prompt.txt
+++ b/app/integrations/llm/temp-prompt/report_prompt.txt
@@ -12,6 +12,17 @@
- 시술: {services}
- 의료진: {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,10 +54,62 @@
### 강남언니
{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 기준입니다.
+- **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개 이상 작성하세요.
- roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요.
-- kpis는 실제 수집된 수치 기반으로 현실적인 측정 가능 지표로 작성하세요.
+- kpi_dashboard는 코드가 결정적으로 산출해 후처리 강제 치환하므로 LLM 출력 무시됩니다. 빈 배열 또는 placeholder로 두세요.
- conversion_strategy의 actions는 구체적인 실행 방안으로 작성하세요.
diff --git a/app/integrations/llm/temp-prompt/scores_prompt.txt b/app/integrations/llm/temp-prompt/scores_prompt.txt
new file mode 100644
index 0000000..b528e9a
--- /dev/null
+++ b/app/integrations/llm/temp-prompt/scores_prompt.txt
@@ -0,0 +1,16 @@
+다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
+
+{data}
+
+위 데이터를 바탕으로 이 병원의 마케팅 종합 점수를 평가해줘.
+
+1. overall_score: 전체 마케팅 종합 점수 (0~100 정수)
+2. channel_scores: 채널별 점수 목록. 각 항목은 아래 형식:
+ - channel: 채널명 (예: YouTube, Instagram, Facebook, 웹사이트 등)
+ - icon: 채널 아이콘 식별자 (예: youtube, instagram, facebook, website)
+ - score: 해당 채널 점수 (20 ~ 100)(정수)
+ - max_score: 해당 채널 최대 점수 (정수)
+ - status: 심각도 (critical / warning / info)
+ - headline: 한 줄 평가 요약
+
+출처 번호([1], [2] 등)는 포함하지 마.
diff --git a/app/integrations/llm/temp-prompt/transformation_prompt.txt b/app/integrations/llm/temp-prompt/transformation_prompt.txt
index 58beb39..d10aacd 100644
--- a/app/integrations/llm/temp-prompt/transformation_prompt.txt
+++ b/app/integrations/llm/temp-prompt/transformation_prompt.txt
@@ -7,7 +7,7 @@
1. brand_identity: 브랜드 아이덴티티 개선 항목 (area, as_is, to_be)
2. content_strategy: 콘텐츠 전략 개선 항목 (area, as_is, to_be)
-3. platform_strategies: 플랫폼별 전략 (platform, icon, current_metric, target_metric, strategies[{strategy, detail}])
+3. platform_strategies: 플랫폼별 전략 (platform, icon, current_metric, target_metric, strategies: 각 항목은 strategy와 detail 포함)
4. website_improvements: 웹사이트 개선 항목 (area, as_is, to_be)
5. new_channel_proposals: 신규 채널 제안 (channel, priority, rationale)
diff --git a/app/integrations/naver.py b/app/integrations/naver.py
index 168ba47..a4802d9 100644
--- a/app/integrations/naver.py
+++ b/app/integrations/naver.py
@@ -1,4 +1,5 @@
import re
+import httpx
from http import HTTPMethod
from urllib.parse import urlparse
from common.utils import http_request
@@ -64,6 +65,20 @@ class NaverClient:
return None
return resp.text
+ async def fetch_blog_total_count(self, handle: str) -> int | None:
+ """블로그 전체 글 수는 RSS에 없어서 PostList HTML에서 '554개의 글' 패턴 추출.
+ ... 554개의 글
구조."""
+ 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:
blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url
xml = await self.fetch_blog_rss(blog_handle)
@@ -82,10 +97,71 @@ class NaverClient:
"postDate": date.group(1) if date else "",
"description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150],
})
+ # RSS의 totalCount 우선, 없으면 블로그 PostList 페이지에서 "N개의 글" 파싱, 그것도 없으면 RSS 글수
total_match = re.search(r"(\d+)", xml)
+ if total_match:
+ total = int(total_match.group(1))
+ else:
+ total = await self.fetch_blog_total_count(blog_handle) or len(posts)
return {
"officialBlogUrl": f"https://blog.naver.com/{blog_handle}",
"officialBlogHandle": blog_handle,
- "totalResults": int(total_match.group(1)) if total_match else len(posts),
+ "totalResults": total,
"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"(.+?)\s*:\s*네이버 카페", listing.text)
+ if tm:
+ name = re.sub(r"&", "&", tm.group(1)).strip()
+ if "," in name:
+ name = name.split(",", 1)[1].strip()
+ result["cafeName"] = name
+ return result
diff --git a/app/integrations/site_fetcher.py b/app/integrations/site_fetcher.py
new file mode 100644
index 0000000..c9c93b0
--- /dev/null
+++ b/app/integrations/site_fetcher.py
@@ -0,0 +1,66 @@
+"""홈페이지 HTML + 외부 CSS 를 가져오는 fetch 전용 모듈.
+
+오래된 한국 의료 사이트들이 SSL DH_KEY_TOO_SMALL / cipher 약함 / host mismatch 등으로
+표준 fetch 에 차단되는 케이스가 많아 단계별 SSL fallback 으로 받는다.
+파싱·도메인 로직은 들어가지 않음 — 순수 HTTP 응답 본문 반환.
+"""
+import logging
+import re
+import ssl
+from urllib.parse import urljoin
+import httpx
+
+logger = logging.getLogger(__name__)
+
+CSS_LINK = re.compile(
+ r']+rel=["\']stylesheet["\'][^>]+href=["\']([^"\']+)["\']',
+ re.IGNORECASE,
+)
+
+
+def _make_ssl_context() -> ssl.SSLContext:
+ """보안 등급 1로 낮춤 + cert 검증 유지 (옛 한국 의료 사이트 cipher 약함 회피)."""
+ 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 본문 받기. 실패 시 (0, "")."""
+ headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
+ 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)
+ 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)
+ 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, ""
+
+
+async def fetch_html_and_css(homepage_url: str, max_css_files: int = 8) -> tuple[str, list[str]]:
+ """홈페이지 HTML + 외부 CSS(Top N) 한 번에 fetch. 실패 시 ("", [])."""
+ status, html = await fetch_html(homepage_url)
+ if status != 200 or not html:
+ logger.warning("[fetch] 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
diff --git a/app/integrations/youtube.py b/app/integrations/youtube.py
index d76a2b0..f72ccc3 100644
--- a/app/integrations/youtube.py
+++ b/app/integrations/youtube.py
@@ -98,9 +98,12 @@ class YouTubeClient:
ch = raw["channel"]
stats = ch.get("statistics", {})
snippet = ch.get("snippet", {})
+ thumbs = snippet.get("thumbnails", {})
+ print(snippet)
return {
"channelId": raw["channelId"],
"channelName": snippet.get("title"),
+ "profileImage": (thumbs.get("high") or thumbs.get("medium") or thumbs.get("default") or {}).get("url"),
"handle": snippet.get("customUrl"),
"description": snippet.get("description", ""),
"publishedAt": snippet.get("publishedAt"),
diff --git a/app/mock/plan_viewclinic.json b/app/mock/plan_viewclinic.json
index 99e91f0..b61779c 100644
--- a/app/mock/plan_viewclinic.json
+++ b/app/mock/plan_viewclinic.json
@@ -153,7 +153,7 @@
"profile_photo": "보라색+골드 깃털 로고",
"banner_spec": "블로그 상단: 깃털 심볼 + 대표 이미지",
"bio_template": "21년 무사고 VIEW 성형외과 공식 블로그\n가슴성형·안면윤곽·양악·눈코·리프팅",
- "current_status": "missing"
+ "current_status": "N/A"
},
{
"channel": "TikTok",
@@ -161,7 +161,7 @@
"profile_photo": "보라색+골드 깃털 원형 로고",
"banner_spec": "N/A",
"bio_template": "VIEW 성형외과 — 안전이 예술이 되는 곳\n강남 신논현역 | 02-539-1177",
- "current_status": "missing"
+ "current_status": "N/A"
}
],
"brand_inconsistencies": [
diff --git a/app/mock_urls.py b/app/mock_urls.py
new file mode 100644
index 0000000..a1384a2
--- /dev/null
+++ b/app/mock_urls.py
@@ -0,0 +1,1373 @@
+# 프론트가 아직 안 보내는 채널(틱톡/영문 인스타·페북/카카오톡/네이버 카페)을 homepage로 매칭해 보충하는 임시 mock 데이터.
+# 기존 mockUrls.json을 파이썬 모듈로 전환 — 런타임 파일 I/O 없이 직접 import.
+
+MOCK_CLINICS = [
+ {
+ "label": "뷰성형외과",
+ "urls": {
+ "homepage": "viewclinic.com",
+ "youtube": "youtube.com/channel/UCQqqH3Klj2HQSHNNSVug-CQ",
+ "instagram": "instagram.com/viewplastic",
+ "facebook": "facebook.com/viewps1",
+ "naverPlace": "https://naver.me/x9BxGXkK",
+ "naverBlog": "blog.naver.com/viewclinicps",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/189",
+ "tiktok": "tiktok.com/@viewplastic",
+ "tiktokEn": "tiktok.com/@viewplasticsurgery",
+ "instagramEn": "instagram.com/view_plastic_surgery",
+ "facebookEn": "facebook.com/viewclinic",
+ "kakaoTalk": "pf.kakao.com/_xbtVxjl"
+ }
+ },
+ {
+ "label": "바노바기 성형외과",
+ "urls": {
+ "homepage": "banobagi.com",
+ "youtube": "youtube.com/c/banobagips",
+ "instagram": "instagram.com/banobagi_ps",
+ "facebook": "facebook.com/BanobagiPlasticSurgery",
+ "naverPlace": "https://naver.me/xxY2yLr5",
+ "naverBlog": "blog.naver.com/banobagiprs",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/23",
+ "tiktok": "tiktok.com/@banobagi_official",
+ "tiktokEn": "tiktok.com/@banobagi_english",
+ "instagramEn": "instagram.com/english_banobagi",
+ "facebookEn": "facebook.com/englishbanobagi",
+ "kakaoTalk": "pf.kakao.com/_bCMxfE"
+ }
+ },
+ {
+ "label": "ID 성형외과",
+ "urls": {
+ "homepage": "idhospital.com",
+ "youtube": "youtube.com/user/IDhospital",
+ "instagram": "instagram.com/idhospital",
+ "facebook": "facebook.com/idhospital0050",
+ "naverPlace": "https://naver.me/GtURpCEn",
+ "naverBlog": "blog.naver.com/idfacial",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/257",
+ "tiktok": "tiktok.com/@idhospitalkorea",
+ "tiktokEn": "tiktok.com/@idhospital_eng",
+ "instagramEn": "instagram.com/idhospitalkorea",
+ "facebookEn": "facebook.com/idhospital.eng",
+ "kakaoTalk": "pf.kakao.com/_yxlxmxhE"
+ }
+ },
+ {
+ "label": "JK 성형외과",
+ "urls": {
+ "homepage": "jkplastic.com",
+ "youtube": "youtube.com/channel/UC5F8dEt32hdp3cTeFyls4qg",
+ "instagram": "instagram.com/jkplasticsurgery_kr",
+ "facebook": "facebook.com/jkmedicalgroup",
+ "naverPlace": "https://naver.me/x67y6cAc",
+ "naverBlog": "blog.naver.com/jkstory1",
+ "naverCafe": "cafe.naver.com/seoljyps",
+ "gangnamUnni": "",
+ "tiktok": "tiktok.com/@jkplastic",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/jkplasticsurgery",
+ "facebookEn": "facebook.com/jkplastic",
+ "kakaoTalk": "pf.kakao.com/_uCxeRE"
+ }
+ },
+ {
+ "label": "그랜드 성형외과",
+ "urls": {
+ "homepage": "grandsurgery.com",
+ "youtube": "youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ",
+ "instagram": "instagram.com/grand_korea",
+ "facebook": "facebook.com/grandps.korea",
+ "naverPlace": "https://naver.me/Fw7MYKWK",
+ "naverBlog": "blog.naver.com/grandprs",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/62",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/grandps_eng",
+ "facebookEn": "facebook.com/grandplasticsurgery",
+ "kakaoTalk": "pf.kakao.com/_SxixbqV"
+ }
+ },
+ {
+ "label": "BK 성형외과",
+ "urls": {
+ "homepage": "bkhospital.com",
+ "youtube": "youtube.com/channel/UChJONft3hemy5DGbXUveTFg",
+ "instagram": "instagram.com/bkhospital_korea",
+ "facebook": "facebook.com/bkhospitalkorea",
+ "naverPlace": "https://naver.me/517CTH3W",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/english_bkhospital",
+ "facebookEn": "facebook.com/BKPSKoreaE",
+ "kakaoTalk": "pf.kakao.com/_kpxjLV"
+ }
+ },
+ {
+ "label": "톡스앤필",
+ "urls": {
+ "homepage": "www.toxnfill.com",
+ "youtube": "youtube.com/channel/UCFpFZkm7mclD-z_-j7FTUag",
+ "instagram": "instagram.com/toxnfill_official",
+ "facebook": "facebook.com/toxnfill.official",
+ "naverPlace": "https://naver.me/FvEmJIHA",
+ "naverBlog": "blog.naver.com/toxnfill",
+ "naverCafe": "cafe.naver.com/toxnfill",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/3702",
+ "tiktok": "tiktok.com/@toxnfillglobal",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/toxnfill_global",
+ "facebookEn": "facebook.com/p/Toxnfill-Global-61557593068252",
+ "kakaoTalk": "pf.kakao.com/_EKXJxl"
+ }
+ },
+ {
+ "label": "더 압구정 성형외과",
+ "urls": {
+ "homepage": "theclinic.co.kr",
+ "youtube": "youtube.com/user/theplasticsurgery1",
+ "instagram": "instagram.com/the_plasticsurgery",
+ "facebook": "facebook.com/THEPS16445998",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/with_theps",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/30",
+ "tiktok": "tiktok.com/@the_plasticsurgery",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/the_plasticsurgery.en",
+ "facebookEn": "facebook.com/theps.english",
+ "kakaoTalk": "pf.kakao.com/_yUAZE"
+ }
+ },
+ {
+ "label": "오라클 성형외과",
+ "urls": {
+ "homepage": "oracleclinic.com",
+ "youtube": "youtube.com/@oracle_medical_group",
+ "instagram": "instagram.com/oraclemedicalgroup",
+ "facebook": "facebook.com/oracleclinickr",
+ "naverPlace": "https://naver.me/GhbU3VtK",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/125",
+ "tiktok": "tiktok.com/@oracleclinic_usa",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/oracleclinic_global",
+ "facebookEn": "facebook.com/oracleclinicglobal",
+ "kakaoTalk": "pf.kakao.com/_RHjhl"
+ }
+ },
+ {
+ "label": "원진성형외과",
+ "urls": {
+ "homepage": "k-wonjin.co.kr",
+ "youtube": "youtube.com/@wjwonjin",
+ "instagram": "instagram.com/wonjin_official",
+ "facebook": "facebook.com/KwonjinPS",
+ "naverPlace": "https://m.place.naver.com/hospital/11887873",
+ "naverBlog": "blog.naver.com/popokpop",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2500",
+ "tiktok": "tiktok.com/@wonjin_official",
+ "tiktokEn": "tiktok.com/@wonjinglobal",
+ "instagramEn": "instagram.com/wonjinplasticsurgery",
+ "facebookEn": "facebook.com/wonjinplasticsurgery",
+ "kakaoTalk": "pf.kakao.com/_bxflzE"
+ }
+ },
+ {
+ "label": "마인드성형외과",
+ "urls": {
+ "homepage": "mindprs.com",
+ "youtube": "youtube.com/channel/UCzM5tIgkC8Es10YmLI55R_w",
+ "instagram": "instagram.com/mind.prs",
+ "facebook": "facebook.com/mindprs",
+ "naverPlace": "https://m.place.naver.com/hospital/1342923541",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/729",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/mindprs_eng",
+ "facebookEn": "facebook.com/mindprseng",
+ "kakaoTalk": "pf.kakao.com/_kmxodC"
+ }
+ },
+ {
+ "label": "브라운성형외과",
+ "urls": {
+ "homepage": "braunps.co.kr",
+ "youtube": "youtube.com/@BraunPlasticSurgery",
+ "instagram": "instagram.com/braunps_official",
+ "facebook": "facebook.com/braunps",
+ "naverPlace": "https://m.place.naver.com/hospital/13299185",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/215",
+ "tiktok": "tiktok.com/@braunps",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/braunpskr",
+ "facebookEn": "facebook.com/braunpskr",
+ "kakaoTalk": "pf.kakao.com/_gMVxaT"
+ }
+ },
+ {
+ "label": "오메가성형외과",
+ "urls": {
+ "homepage": "http://omegaps.co.kr",
+ "youtube": "youtube.com/channel/UC4C2BB4Dp9L_QyuyD22FL0A",
+ "instagram": "instagram.com/omega_plastic_surgery",
+ "facebook": "facebook.com/오메가성형외과-1682577712065719",
+ "naverPlace": "https://m.place.naver.com/hospital/12840364",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/926",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_JxoeFu"
+ }
+ },
+ {
+ "label": "나나성형외과",
+ "urls": {
+ "homepage": "nanaprs.com",
+ "youtube": "youtube.com/@Nanaprstv",
+ "instagram": "instagram.com/nanaprs",
+ "facebook": "facebook.com/nanaprs2",
+ "naverPlace": "https://m.place.naver.com/hospital/1518147116",
+ "naverBlog": "blog.naver.com/nanaprs1",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/938",
+ "tiktok": "tiktok.com/@nanahospitaleng",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/nanahospital_eng",
+ "facebookEn": "facebook.com/nanahospital.eng",
+ "kakaoTalk": "pf.kakao.com/_ZxgKUC"
+ }
+ },
+ {
+ "label": "노트성형외과",
+ "urls": {
+ "homepage": "http://notebreast.com",
+ "youtube": "youtube.com/channel/UC2OIyb2serotqDyEzQYyCtA",
+ "instagram": "instagram.com/note.prs",
+ "facebook": "facebook.com/noteprs2",
+ "naverPlace": "https://m.place.naver.com/hospital/1572670484",
+ "naverBlog": "blog.naver.com/noteprs01",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2186",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/noteprs_eng",
+ "facebookEn": "facebook.com/Noteprs.English",
+ "kakaoTalk": "pf.kakao.com/_xbvuxaT"
+ }
+ },
+ {
+ "label": "디에이성형외과",
+ "urls": {
+ "homepage": "daprs.com",
+ "youtube": "youtube.com/channel/UC0wlA-w5JIt0G0EeWQf65AQ",
+ "instagram": "instagram.com/da_plastic_surgery",
+ "facebook": "facebook.com/daprs",
+ "naverPlace": "https://m.place.naver.com/hospital/33084820",
+ "naverBlog": "blog.naver.com/daprs",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/250",
+ "tiktok": "tiktok.com/@daprs",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/daplasticsurgery_korea",
+ "facebookEn": "facebook.com/daplasticsurgery",
+ "kakaoTalk": "pf.kakao.com/_Qqrzd"
+ }
+ },
+ {
+ "label": "에이비성형외과",
+ "urls": {
+ "homepage": "abps.co.kr",
+ "youtube": "youtube.com/@abplasticsurgery",
+ "instagram": "instagram.com/ab_plasticsurgery_kr",
+ "facebook": "facebook.com/profile.php?id=100063560996454",
+ "naverPlace": "https://m.place.naver.com/hospital/1304260302",
+ "naverBlog": "blog.naver.com/abps20",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/3004",
+ "tiktok": "tiktok.com/@abplasticsurgeryeng",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/ab_plasticsurgery_eng",
+ "facebookEn": "facebook.com/abplasticsurgeryeng",
+ "kakaoTalk": "pf.kakao.com/_lJnuK"
+ }
+ },
+ {
+ "label": "비아이오성형외과",
+ "urls": {
+ "homepage": "http://biopskorea.com",
+ "youtube": "youtube.com/channel/UC2WCPPw2onOI4UVqt7LIfKw",
+ "instagram": "instagram.com/bio_dr.hongsungpyo",
+ "facebook": "facebook.com/biopskorea",
+ "naverPlace": "https://m.place.naver.com/hospital/21428819",
+ "naverBlog": "blog.naver.com/bioplastics20",
+ "naverCafe": "cafe.naver.com/prsyang",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/bioplasticsurgery",
+ "facebookEn": "",
+ "kakaoTalk": ""
+ }
+ },
+ {
+ "label": "제이준성형외과",
+ "urls": {
+ "homepage": "jjprs.com",
+ "youtube": "youtube.com/channel/UC1p4msJnetDKeOW-B4nlwZA",
+ "instagram": "instagram.com/jayjunps",
+ "facebook": "facebook.com/jayjunps",
+ "naverPlace": "https://m.place.naver.com/hospital/36294945",
+ "naverBlog": "blog.naver.com/gusrlf00",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/139",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/jayjunps_eng",
+ "facebookEn": "facebook.com/jayjuneng",
+ "kakaoTalk": "pf.kakao.com/_XxmSFV"
+ }
+ },
+ {
+ "label": "마블성형외과",
+ "urls": {
+ "homepage": "marbleps.com",
+ "youtube": "youtube.com/@marbleps",
+ "instagram": "instagram.com/marble__ps",
+ "facebook": "facebook.com/marbleps",
+ "naverPlace": "https://m.place.naver.com/hospital/37206762",
+ "naverBlog": "blog.naver.com/marbleplastic",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/141",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/marbleps_english",
+ "facebookEn": "facebook.com/marbleps.eng",
+ "kakaoTalk": "pf.kakao.com/_xefxmxiM"
+ }
+ },
+ {
+ "label": "쥬얼리성형외과",
+ "urls": {
+ "homepage": "jewelryps.kr",
+ "youtube": "youtube.com/@JewelrypsKr1",
+ "instagram": "instagram.com/jewelryps_kr",
+ "facebook": "facebook.com/juelyps",
+ "naverPlace": "https://m.place.naver.com/hospital/13443226",
+ "naverBlog": "blog.naver.com/prsshin2",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/55",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/jewelryps_global",
+ "facebookEn": "facebook.com/jewelrypsglobal",
+ "kakaoTalk": "pf.kakao.com/_SSaRE"
+ }
+ },
+ {
+ "label": "티에스성형외과",
+ "urls": {
+ "homepage": "tsprs.com",
+ "youtube": "youtube.com/channel/UCgnizu8p7lbCnfIs76O8J-A",
+ "instagram": "instagram.com/tsprs_official",
+ "facebook": "facebook.com/tsprs",
+ "naverPlace": "https://m.place.naver.com/hospital/36905792",
+ "naverBlog": "blog.naver.com/tsprs",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/116",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/tsprsglobal",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_Jxhduxd"
+ }
+ },
+ {
+ "label": "유노성형외과",
+ "urls": {
+ "homepage": "yunoprs.com",
+ "youtube": "youtube.com/channel/UCrEe-LfBLXA1-bTGS4bWmtg",
+ "instagram": "instagram.com/doctor.yuno",
+ "facebook": "facebook.com/doctor.yuno",
+ "naverPlace": "https://m.place.naver.com/hospital/38486006",
+ "naverBlog": "blog.naver.com/yuno_blog",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/248",
+ "tiktok": "tiktok.com/@yuno_global",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/yuno.global",
+ "facebookEn": "facebook.com/yuno.global",
+ "kakaoTalk": "pf.kakao.com/_xkWbSu"
+ }
+ },
+ {
+ "label": "리팅성형외과",
+ "urls": {
+ "homepage": "liting.co.kr",
+ "youtube": "youtube.com/@liting_ps",
+ "instagram": "instagram.com/liting_psps",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/38673363",
+ "naverBlog": "blog.naver.com/night140160",
+ "naverCafe": "cafe.naver.com/litingps",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/331",
+ "tiktok": "tiktok.com/@liting_ps",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xfxmiDu"
+ }
+ },
+ {
+ "label": "앤써성형외과",
+ "urls": {
+ "homepage": "http://answer-ps.co.kr",
+ "youtube": "youtube.com/@TV-gf8ms",
+ "instagram": "instagram.com/answerps",
+ "facebook": "facebook.com/answerps4881",
+ "naverPlace": "https://m.place.naver.com/hospital/34138819",
+ "naverBlog": "blog.naver.com/answerps",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/1449",
+ "tiktok": "tiktok.com/@answerplasticsurgery1",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/answerplasticsurgery",
+ "facebookEn": "facebook.com/answerplasticsurgeryseoul",
+ "kakaoTalk": ""
+ }
+ },
+ {
+ "label": "더픽스성형외과",
+ "urls": {
+ "homepage": "http://thefixps.com",
+ "youtube": "youtube.com/channel/UCXVNowmecu1tF-kCYhFE_QQ",
+ "instagram": "instagram.com/thefixps",
+ "facebook": "facebook.com/psdonghoon",
+ "naverPlace": "https://m.place.naver.com/hospital/1310778318",
+ "naverBlog": "blog.naver.com/donghwn",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2196",
+ "tiktok": "tiktok.com/@thefixps2860",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": ""
+ }
+ },
+ {
+ "label": "기린성형외과",
+ "urls": {
+ "homepage": "girinps.com",
+ "youtube": "youtube.com/@girin_official",
+ "instagram": "instagram.com/girin_ps_official",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/21705376",
+ "naverBlog": "blog.naver.com/girinlife",
+ "naverCafe": "cafe.naver.com/girinlife",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/398",
+ "tiktok": "tiktok.com/@girin_korea",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/girin_eng",
+ "facebookEn": "facebook.com/girinplasticsurgery.en",
+ "kakaoTalk": "pf.kakao.com/_mtiyl"
+ }
+ },
+ {
+ "label": "압구정서울성형외과",
+ "urls": {
+ "homepage": "asps.co.kr",
+ "youtube": "youtube.com/channel/UCqNtVCL2u5Xvx74ymboxDyg",
+ "instagram": "instagram.com/asps_no.1",
+ "facebook": "facebook.com/seoulps",
+ "naverPlace": "https://m.place.naver.com/hospital/11531189",
+ "naverBlog": "blog.naver.com/asps0119",
+ "naverCafe": "cafe.naver.com/fox5282",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/213",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/asps_en",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_bMkqxd"
+ }
+ },
+ {
+ "label": "리젠메디컬그룹",
+ "urls": {
+ "homepage": "http://regenskin.co.kr",
+ "youtube": "",
+ "instagram": "instagram.com/regenskin",
+ "facebook": "facebook.com/pages/리젠피부과/785877121503059",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/shinygn1",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/4154",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_jPxgNxd"
+ }
+ },
+ {
+ "label": "코코성형외과",
+ "urls": {
+ "homepage": "http://kodoctor.co.kr",
+ "youtube": "youtube.com/channel/UCc19LE6Elp0OTNo9gDizJMw",
+ "instagram": "instagram.com/koko_ps_official",
+ "facebook": "facebook.com/kokodoctor",
+ "naverPlace": "https://m.place.naver.com/hospital/1023834988",
+ "naverBlog": "blog.naver.com/koko_plastic_surgery",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/623",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_Jntxnb"
+ }
+ },
+ {
+ "label": "오브제성형외과",
+ "urls": {
+ "homepage": "objetps.com",
+ "youtube": "youtube.com/channel/UC2QrFhj-S8oUrbfOp1NXKZA",
+ "instagram": "instagram.com/objet_plastic_surgery",
+ "facebook": "facebook.com/p/오브제성형외과-100064266697249",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/objetps",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2122",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/objetplasticsurgery_eng",
+ "facebookEn": "facebook.com/61584552036644",
+ "kakaoTalk": "pf.kakao.com/_HtZsxb"
+ }
+ },
+ {
+ "label": "리상성형외과",
+ "urls": {
+ "homepage": "theregenps.com",
+ "youtube": "youtube.com/@theregen_ps",
+ "instagram": "instagram.com/theregen_ps",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1125605502",
+ "naverBlog": "blog.naver.com/theps_kor",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/6597",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_eTyqn"
+ }
+ },
+ {
+ "label": "에이트성형외과",
+ "urls": {
+ "homepage": "eightprs.com",
+ "youtube": "youtube.com/channel/UCpTmm84yJLgIBwBcBhjie4Q",
+ "instagram": "instagram.com/eightps8",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/31986276",
+ "naverBlog": "blog.naver.com/eightplasticsurgery",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/166",
+ "tiktok": "tiktok.com/@eighthospital.th",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/eightplasticsurgery",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_zxafjxl"
+ }
+ },
+ {
+ "label": "오페라성형외과",
+ "urls": {
+ "homepage": "http://operasurgery.co.kr",
+ "youtube": "youtube.com/channel/UC2R_jfmmn0zE-dkButFS_ZA",
+ "instagram": "instagram.com/opera_ps",
+ "facebook": "facebook.com/operasurgery",
+ "naverPlace": "https://m.place.naver.com/hospital/10827484",
+ "naverBlog": "blog.naver.com/fasolt2",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/108",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/opera_plastic_surgery_eng",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_iCPwE"
+ }
+ },
+ {
+ "label": "리엔장성형외과",
+ "urls": {
+ "homepage": "ps.lienjang.net",
+ "youtube": "youtube.com/channel/UCxyiPTH9xHqhPVfwyyosXMQ",
+ "instagram": "instagram.com/lienjang_official",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/69",
+ "tiktok": "tiktok.com/@lienjang_ps",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/lienjang_english",
+ "facebookEn": "facebook.com/lienjang.english",
+ "kakaoTalk": "pf.kakao.com/_xlhEyl"
+ }
+ },
+ {
+ "label": "루호성형외과",
+ "urls": {
+ "homepage": "luho.kr",
+ "youtube": "youtube.com/channel/UCZE8dRSsc6CwdORbAhxV2Rw",
+ "instagram": "instagram.com/luho_beauty",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/21868487",
+ "naverBlog": "",
+ "naverCafe": "cafe.naver.com/luho1",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/660",
+ "tiktok": "tiktok.com/@luho_beauty_jp",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/luho_global",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_kvrXj"
+ }
+ },
+ {
+ "label": "쏘울성형외과",
+ "urls": {
+ "homepage": "soulps.kr",
+ "youtube": "",
+ "instagram": "instagram.com/soul_plastic_surgery",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/4244",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_eVmCxj"
+ }
+ },
+ {
+ "label": "에이탑성형외과",
+ "urls": {
+ "homepage": "atopps.com",
+ "youtube": "youtube.com/channel/UCPBCTxCX1hyRnXejA8QfFEQ",
+ "instagram": "instagram.com/atop_instalog",
+ "facebook": "facebook.com/Dr.goodhand",
+ "naverPlace": "https://m.place.naver.com/hospital/877412762",
+ "naverBlog": "blog.naver.com/atopps_",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/300",
+ "tiktok": "tiktok.com/@atopplasticsurgery",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/atop_plasticsurgery",
+ "facebookEn": "facebook.com/atoppsglobal",
+ "kakaoTalk": "pf.kakao.com/_tePHd"
+ }
+ },
+ {
+ "label": "유캔비성형외과",
+ "urls": {
+ "homepage": "ucanb.co.kr",
+ "youtube": "youtube.com/channel/UCegLA3CpLkDOe5h_DnhUpFA",
+ "instagram": "instagram.com/ucanb_plastic_surgery",
+ "facebook": "facebook.com/ucanbps",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/ucanb2338",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/563",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xcwXAxd"
+ }
+ },
+ {
+ "label": "스타성형외과",
+ "urls": {
+ "homepage": "starclinic.co.kr",
+ "youtube": "youtube.com/channel/UCpIF_ffpk4R10_iw0q0tR7g",
+ "instagram": "",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/2092418109",
+ "naverBlog": "",
+ "naverCafe": "cafe.naver.com/starclinic",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xhXPdl"
+ }
+ },
+ {
+ "label": "아이템성형외과",
+ "urls": {
+ "homepage": "ittemps.kr",
+ "youtube": "youtube.com/channel/UCJolLz6ag4m2UfZh56HC2hQ",
+ "instagram": "instagram.com/ittem_ps",
+ "facebook": "facebook.com/잇템성형외과-101634361973942",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/ittem_ps",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/itemps_eng",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_DxmNeK"
+ }
+ },
+ {
+ "label": "리코성형외과",
+ "urls": {
+ "homepage": "licoclinic.com",
+ "youtube": "youtube.com/channel/UC3T_EiEpQr1xE16o69XCqjQ",
+ "instagram": "instagram.com/licobest",
+ "facebook": "facebook.com/licotop",
+ "naverPlace": "https://m.place.naver.com/hospital/20800108",
+ "naverBlog": "blog.naver.com/licops",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_RWpexd"
+ }
+ },
+ {
+ "label": "플레저성형외과",
+ "urls": {
+ "homepage": "http://pleasureps.com",
+ "youtube": "youtube.com/c/PSPS_korea",
+ "instagram": "instagram.com/pleasure_ps",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/2amsomething",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2991",
+ "tiktok": "tiktok.com/@pspskorea",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/pspsenglish",
+ "facebookEn": "facebook.com/pspskorea",
+ "kakaoTalk": "pf.kakao.com/_vCLxdK"
+ }
+ },
+ {
+ "label": "아이웰성형외과",
+ "urls": {
+ "homepage": "iwellps.com",
+ "youtube": "",
+ "instagram": "instagram.com/iwellps",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/58",
+ "tiktok": "tiktok.com/@iwellps",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "facebook.com/iwellpseng",
+ "kakaoTalk": "pf.kakao.com/_Sxlhql"
+ }
+ },
+ {
+ "label": "글로비성형외과",
+ "urls": {
+ "homepage": "glovips.com",
+ "youtube": "youtube.com/channel/UCH4Orbmc3cFqajWx1I6Xs2w",
+ "instagram": "instagram.com/glovips",
+ "facebook": "facebook.com/glovips",
+ "naverPlace": "https://m.place.naver.com/hospital/13359904",
+ "naverBlog": "blog.naver.com/glovips",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/54",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/glovi_eng",
+ "facebookEn": "facebook.com/glovieng",
+ "kakaoTalk": "pf.kakao.com/_cVdhxh"
+ }
+ },
+ {
+ "label": "엘르성형외과",
+ "urls": {
+ "homepage": "http://elleclinic.com",
+ "youtube": "",
+ "instagram": "instagram.com/elleps_korea",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/1181",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xcsxaZl"
+ }
+ },
+ {
+ "label": "원픽성형외과",
+ "urls": {
+ "homepage": "onepeakps.com",
+ "youtube": "",
+ "instagram": "instagram.com/onepeakps",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/3000",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_jwbqK"
+ }
+ },
+ {
+ "label": "탑페이스성형외과",
+ "urls": {
+ "homepage": "topfaceps.com",
+ "youtube": "youtube.com/channel/UCNfZc5aXZ5A1mqX5iIw91EA",
+ "instagram": "instagram.com/topfaceps",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/33283530",
+ "naverBlog": "blog.naver.com/ina3599",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/66",
+ "tiktok": "tiktok.com/@topfaceplasticsurgery",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/topfaceplasticsurgeryclinic",
+ "facebookEn": "facebook.com/topfaceplasticsurgery",
+ "kakaoTalk": "pf.kakao.com/_BqwFxb"
+ }
+ },
+ {
+ "label": "세민성형외과",
+ "urls": {
+ "homepage": "http://semin100.co.kr",
+ "youtube": "youtube.com/channel/UCKaNYEvRqME2h1lUSOYIYew",
+ "instagram": "instagram.com/seminps",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/semin100",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": ""
+ }
+ },
+ {
+ "label": "에이치성형외과",
+ "urls": {
+ "homepage": "3dfit.co.kr",
+ "youtube": "youtube.com/channel/UC6d8i9WwIkm161ueFyVU2PA",
+ "instagram": "",
+ "facebook": "facebook.com/3DFIT100",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/ys100ps",
+ "naverCafe": "cafe.naver.com/baekstage",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_XxdSxmE"
+ }
+ },
+ {
+ "label": "리노보성형외과",
+ "urls": {
+ "homepage": "renovo.co.kr",
+ "youtube": "",
+ "instagram": "instagram.com/renovo.clinic.1",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/13023672",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/4749",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_cxekpxd"
+ }
+ },
+ {
+ "label": "디엘성형외과",
+ "urls": {
+ "homepage": "dlprs.com",
+ "youtube": "youtube.com/@LINEKING_DL",
+ "instagram": "instagram.com/dl_plastic_official",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1998543478",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/5500",
+ "tiktok": "tiktok.com/@dl_plastic_en",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/dl_plastic_int",
+ "facebookEn": "facebook.com/dl.plastic.en",
+ "kakaoTalk": "pf.kakao.com/_xgpMCG"
+ }
+ },
+ {
+ "label": "피알성형외과",
+ "urls": {
+ "homepage": "prprs.co.kr",
+ "youtube": "youtube.com/@pr_ps",
+ "instagram": "instagram.com/prprs_official",
+ "facebook": "facebook.com/PRPRS.official",
+ "naverPlace": "https://m.place.naver.com/hospital/1655749880",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/431",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xjepWxl"
+ }
+ },
+ {
+ "label": "라프린성형외과",
+ "urls": {
+ "homepage": "laprinps.com",
+ "youtube": "youtube.com/channel/UCfMt8TTT8kJklZORjf4nJ2w",
+ "instagram": "instagram.com/laprin_kr",
+ "facebook": "facebook.com/laprinprincess",
+ "naverPlace": "https://m.place.naver.com/hospital/33741139",
+ "naverBlog": "blog.naver.com/ff8kds6t5cmxd",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/laprin_en",
+ "facebookEn": "facebook.com/Laprinprincess.en",
+ "kakaoTalk": "pf.kakao.com/_xieNxmV"
+ }
+ },
+ {
+ "label": "엠제이성형외과",
+ "urls": {
+ "homepage": "mjskinclinic.com",
+ "youtube": "youtube.com/channel/UCFjkFyYDu4HpLjc9axlh6YQ",
+ "instagram": "instagram.com/mjskinclinic",
+ "facebook": "facebook.com/people/MJ피부과/100063928095304",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/mjskinclinic",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/mjskinclinic_en",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xcdgixb"
+ }
+ },
+ {
+ "label": "아우라성형외과",
+ "urls": {
+ "homepage": "http://psaura.com",
+ "youtube": "youtube.com/@auraps2024",
+ "instagram": "instagram.com/auraps_official",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1806079685",
+ "naverBlog": "blog.naver.com/pristor",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/5870",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_HKxkxeG"
+ }
+ },
+ {
+ "label": "하이봄성형외과",
+ "urls": {
+ "homepage": "highvom.com",
+ "youtube": "youtube.com/channel/UCuuH9oWh9h29hVRmH2sHAuA",
+ "instagram": "instagram.com/highvom",
+ "facebook": "facebook.com/highvom1",
+ "naverPlace": "https://m.place.naver.com/hospital/1714219923",
+ "naverBlog": "blog.naver.com/hivom",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/2052",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_xfjyQj"
+ }
+ },
+ {
+ "label": "유앤아이성형외과",
+ "urls": {
+ "homepage": "uni114.co.kr",
+ "youtube": "youtube.com/channel/UCHgZnNk3JIDxnoYcWSWtAJw",
+ "instagram": "instagram.com/skinuni114",
+ "facebook": "facebook.com/uni114",
+ "naverPlace": "https://m.place.naver.com/hospital/60058049",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/4459",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_fCExej"
+ }
+ },
+ {
+ "label": "리본성형외과",
+ "urls": {
+ "homepage": "rebornps.com",
+ "youtube": "youtube.com/channel/UCyFdX9zxfu2e2JVTwPjki5A",
+ "instagram": "instagram.com/rebornps_",
+ "facebook": "facebook.com/reborn3355",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/reborn1999",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/339",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_tBclE"
+ }
+ },
+ {
+ "label": "라이크성형외과",
+ "urls": {
+ "homepage": "http://likeps.com",
+ "youtube": "youtube.com/c/LIKEPLASTICSURGERY",
+ "instagram": "instagram.com/likeps_kr",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "cafe.naver.com/likeps",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/912",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_UxfEmC"
+ }
+ },
+ {
+ "label": "케이플러스성형외과",
+ "urls": {
+ "homepage": "k-clinics.com",
+ "youtube": "youtube.com/@k-plasticsurgery",
+ "instagram": "instagram.com/k_plasticsurgery",
+ "facebook": "facebook.com/kclinics",
+ "naverPlace": "https://m.place.naver.com/hospital/36912372",
+ "naverBlog": "blog.naver.com/kclinics-osh",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/1265",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_zJBPxl"
+ }
+ },
+ {
+ "label": "케이아트성형외과",
+ "urls": {
+ "homepage": "k-artps.com",
+ "youtube": "youtube.com/channel/UCHs9LOYtauBIklXhPPZBAgA",
+ "instagram": "instagram.com/kartps",
+ "facebook": "facebook.com/kartpsseoul",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/kartps",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_pICRM"
+ }
+ },
+ {
+ "label": "이룸성형외과",
+ "urls": {
+ "homepage": "seoulips.com",
+ "youtube": "youtube.com/@seouliplasticsurgery",
+ "instagram": "instagram.com/seoulips",
+ "facebook": "facebook.com/서울아이성형외과-105199207892670",
+ "naverPlace": "https://m.place.naver.com/hospital/1240083198",
+ "naverBlog": "blog.naver.com/seoulips",
+ "naverCafe": "cafe.naver.com/cometrueps",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/839",
+ "tiktok": "tiktok.com/@seoulips_jp",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/seouli_eng",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_pxlYUxb"
+ }
+ },
+ {
+ "label": "서초서울성형외과",
+ "urls": {
+ "homepage": "srprs.co.kr",
+ "youtube": "",
+ "instagram": "instagram.com/saerops",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1692465198",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/5554",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/saerops_en",
+ "facebookEn": "facebook.com/p/SAERO-Plastic-Surgery-61552761680056",
+ "kakaoTalk": "pf.kakao.com/_rMQTG"
+ }
+ },
+ {
+ "label": "서초연세성형외과",
+ "urls": {
+ "homepage": "chaminst.com",
+ "youtube": "youtube.com/channel/UCMxBfZivKz5jjhRiy0mzOXQ",
+ "instagram": "instagram.com/chamin_ps",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/chaminst",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/4212",
+ "tiktok": "tiktok.com/@chaminps",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_afxgpb"
+ }
+ },
+ {
+ "label": "리모성형외과",
+ "urls": {
+ "homepage": "ksh-ps.com",
+ "youtube": "youtube.com/channel/UCCiH9OffJgZTMnkxatK8-5A",
+ "instagram": "instagram.com/sh___ps",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1865533181",
+ "naverBlog": "blog.naver.com/shpsclinic",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/6680",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_exfHsxb"
+ }
+ },
+ {
+ "label": "미호성형외과",
+ "urls": {
+ "homepage": "www.mihops.co.kr",
+ "youtube": "youtube.com/channel/UC8kyWAnMM7f_xguyPxR_gtA",
+ "instagram": "instagram.com/mihops_kr",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/344342362",
+ "naverBlog": "blog.naver.com/qalfuqbjtmnf",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/369",
+ "tiktok": "tiktok.com/@koreaplasticsurgery",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/mihops_en",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_yxmexod"
+ }
+ },
+ {
+ "label": "에비뉴성형외과",
+ "urls": {
+ "homepage": "http://avenueps.com",
+ "youtube": "youtube.com/channel/UCOTVAerYogSEia3L-ERkAFg",
+ "instagram": "instagram.com/avenueps15",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/6204",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": ""
+ }
+ },
+ {
+ "label": "비온성형외과",
+ "urls": {
+ "homepage": "bon-ps.com",
+ "youtube": "youtube.com/channel/UClCjGIfEb3b1N-Q5tCNSU5w",
+ "instagram": "instagram.com/bon_p.s",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1492257313",
+ "naverBlog": "blog.naver.com/miz2199",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_AQZxbu"
+ }
+ },
+ {
+ "label": "아크성형외과",
+ "urls": {
+ "homepage": "arc-ps.com",
+ "youtube": "youtube.com/@-arcisart7955",
+ "instagram": "instagram.com/arc_plastic_surgery",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/1638918034",
+ "naverBlog": "",
+ "naverCafe": "cafe.naver.com/arcps",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/3569",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/arcplasticsurgery_en",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_dxdQxdb"
+ }
+ },
+ {
+ "label": "에버성형외과",
+ "urls": {
+ "homepage": "http://everclinic.com",
+ "youtube": "youtube.com/channel/UCwSusNRTM2B_mvdRd0yNVcw",
+ "instagram": "instagram.com/everclinic0088",
+ "facebook": "facebook.com/everplasticsurgery",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/drblue007",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_IxaxekV"
+ }
+ },
+ {
+ "label": "유앤미성형외과",
+ "urls": {
+ "homepage": "knyounmeclinic.co.kr",
+ "youtube": "youtube.com/channel/UCAROD-B1ZBinpyzql7o_IlA",
+ "instagram": "instagram.com/knyounmeclinic",
+ "facebook": "",
+ "naverPlace": "https://m.place.naver.com/hospital/31068065",
+ "naverBlog": "",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/1178",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/younme_eng",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_yKPmM"
+ }
+ },
+ {
+ "label": "마노성형외과",
+ "urls": {
+ "homepage": "manops.co.kr",
+ "youtube": "youtube.com/@mano_ps",
+ "instagram": "instagram.com/manops_official",
+ "facebook": "",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/tkhr4747",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/3429",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "instagram.com/mano_plastic_surgery_clinic",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_ksJts"
+ }
+ },
+ {
+ "label": "에스엠성형외과",
+ "urls": {
+ "homepage": "sm-ps.co.kr",
+ "youtube": "youtube.com/@smpsclinic",
+ "instagram": "instagram.com/smps_plastic_surgery",
+ "facebook": "facebook.com/thammySM3707",
+ "naverPlace": "https://m.place.naver.com/hospital/32876182",
+ "naverBlog": "blog.naver.com/smps1004",
+ "naverCafe": "",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/413",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_JLxofl"
+ }
+ },
+ {
+ "label": "서울미작성형외과",
+ "urls": {
+ "homepage": "mijakclinic.co.kr",
+ "youtube": "youtube.com/channel/UCld-NgVeydtDRVTt6xQRbmA",
+ "instagram": "instagram.com/mijak2444",
+ "facebook": "facebook.com/mijaknose",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/mijak3444",
+ "naverCafe": "",
+ "gangnamUnni": "",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_SCEJxj"
+ }
+ },
+ {
+ "label": "더원성형외과",
+ "urls": {
+ "homepage": "theoneclinic.co.kr",
+ "youtube": "",
+ "instagram": "instagram.com/theone_plastic_surgery",
+ "facebook": "facebook.com/pages/category/Hospital/더원성형외과-1539415639613021",
+ "naverPlace": "",
+ "naverBlog": "blog.naver.com/brs0714",
+ "naverCafe": "cafe.naver.com/cephalos1.cafe",
+ "gangnamUnni": "www.gangnamunni.com/hospitals/5636",
+ "tiktok": "",
+ "tiktokEn": "",
+ "instagramEn": "",
+ "facebookEn": "",
+ "kakaoTalk": "pf.kakao.com/_eWIqK"
+ }
+ }
+]
diff --git a/app/models/analysis.py b/app/models/analysis.py
index 2b4e7ec..4196065 100644
--- a/app/models/analysis.py
+++ b/app/models/analysis.py
@@ -8,6 +8,11 @@ class Channels(BaseModel):
facebook: str | None = None
naver_blog: 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):
diff --git a/app/models/plan.py b/app/models/plan.py
index 5e67efa..058ec85 100644
--- a/app/models/plan.py
+++ b/app/models/plan.py
@@ -49,7 +49,7 @@ class ChannelBrandingRule(CamelModel):
profile_photo: str
banner_spec: str
bio_template: str
- current_status: Literal["correct", "incorrect", "missing"]
+ current_status: Literal["correct", "incorrect", "N/A"]
class BrandGuide(CamelModel):
diff --git a/app/models/report.py b/app/models/report.py
index a99f59d..fcff74f 100644
--- a/app/models/report.py
+++ b/app/models/report.py
@@ -66,16 +66,18 @@ class RegistryData(CamelModel):
class ClinicSnapshot(CamelModel):
- name: str
- name_en: str
- staff_count: int
- lead_doctor: LeadDoctor
- overall_rating: float
- total_reviews: int
- certifications: list[str]
- location: str
- phone: str
- domain: str
+ # _build_clinic_snapshot은 source 데이터 있을 때만 필드 추가 (`if x:` 가드).
+ # 강남언니/홈페이지 수집 누락된 병원에서 required면 ValidationError로 리포트 전체 실패.
+ name: str | None = None
+ name_en: str | None = None
+ staff_count: int | None = None
+ lead_doctor: LeadDoctor | None = None
+ overall_rating: float | None = None
+ total_reviews: int | None = None
+ certifications: list[str] = []
+ location: str | None = None
+ phone: str | None = None
+ domain: str | None = None
logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None
source: DataSource | None = None
@@ -86,7 +88,7 @@ class ChannelScore(CamelModel):
channel: str
icon: str
score: int
- max_score: int
+ max_score: int = 100
status: Severity
headline: str
@@ -149,8 +151,8 @@ class InstagramAccount(CamelModel):
class InstagramAudit(CamelModel):
- accounts: list[InstagramAccount]
- diagnosis: list[DiagnosisItem]
+ accounts: list[InstagramAccount] = []
+ diagnosis: list[DiagnosisItem] = []
class BrandInconsistencyValue(CamelModel):
@@ -181,17 +183,17 @@ class FacebookPage(CamelModel):
linked_domain: str
reviews: int
recent_post_age: str
- has_whatsapp: bool
- post_frequency: str | None = None
+ has_whatsapp: bool | None = None
+ post_frequency: str
top_content_type: str | None = None
- engagement: str | None = None
+ engagement: str
class FacebookAudit(CamelModel):
- pages: list[FacebookPage]
- diagnosis: list[DiagnosisItem]
- brand_inconsistencies: list[BrandInconsistency]
- consolidation_recommendation: str
+ pages: list[FacebookPage] = []
+ diagnosis: list[DiagnosisItem] = []
+ brand_inconsistencies: list[BrandInconsistency] = []
+ consolidation_recommendation: str | None = None
class OtherChannel(CamelModel):
diff --git a/app/models/status.py b/app/models/status.py
index 95f4afd..de7015e 100644
--- a/app/models/status.py
+++ b/app/models/status.py
@@ -46,6 +46,8 @@ class SourceType(StrEnum):
GANGNAM_UNNI = "gangnam_unni"
KAKAOTALK = "kakaotalk"
NAVER_CAFE = "naver_cafe"
+ # 부가 수집/분석 (HTML/CSS 재크롤 + Vision 로고 매칭) — 한 raw_info entry 에 brandAssets/channelLogos 같이 보관.
+ BRANDING = "branding"
class Language(StrEnum):
diff --git a/app/services/analysis.py b/app/services/analysis.py
index 2774fdc..1a4e059 100644
--- a/app/services/analysis.py
+++ b/app/services/analysis.py
@@ -1,55 +1,29 @@
import json
import logging
-import os
-import re
-from datetime import datetime
from urllib.parse import urlparse
-from common.db.run import select_run, update_run_report, update_run_plan
-from common.db.source import select_run_raw_data, select_run_mainpage_url
+from models.status import SourceType
+from common.utils import parse_iso_duration_seconds, format_seconds, format_clock, calc_avg_video_length, relative_date, calc_upload_frequency
+from common.db.run import update_run_report, update_run_plan, select_run_report_data, select_run
+from common.db.source import select_run_raw_data, select_mainpage_logo_url
from common.db.market import select_market
from integrations.llm.llm_service import LLMService
-from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt
-from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth
+from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt, scores_prompt
+from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore
+from services.branding import analyze_branding
+from services.instagram_audit import build_instagram_audit
+from services.facebook_audit import build_facebook_audit
+from services.kpi_dashboard import build_kpi_dashboard
from integrations.llm.schemas.plan import PlanOutput
logger = logging.getLogger(__name__)
-
-async def generate_report(analysis_run_id: str) -> ReportOutput:
- raw = await select_run_raw_data(analysis_run_id)
- clinic = raw.get("mainpage") or {}
- market = await select_market(analysis_run_id)
-
- def _json(v) -> str | None:
- return json.dumps(v, ensure_ascii=False) if v else None
-
- input_data = {
- "clinic_name": clinic.get("clinicName"),
- "clinic_name_en": clinic.get("clinicNameEn"),
- "address": clinic.get("address"),
- "phone": clinic.get("phone"),
- "slogan": clinic.get("slogan"),
- "services": json.dumps(clinic.get("services", []), ensure_ascii=False),
- "doctors": json.dumps(clinic.get("doctors", []), ensure_ascii=False),
- "market_competitors": _json(market.get("competitors")),
- "market_keywords": _json(market.get("keywords")),
- "market_trend": _json(market.get("trend")),
- "market_target_audience": _json(market.get("target_audience")),
- **{
- source_type: _json(data)
- for source_type, data in raw.items()
- if source_type != "mainpage"
- },
- }
-
- return await LLMService(provider="perplexity").generate(report_prompt, input_data)
-
async def generate_plan(analysis_run_id: str) -> PlanOutput:
- run = await select_run(analysis_run_id)
raw = await select_run_raw_data(analysis_run_id)
- clinic = raw.get("mainpage") or {}
- report_data = run["report_data"]
- report = json.loads(report_data) if isinstance(report_data, str) else report_data
+ clinic = raw.get(SourceType.MAINPAGE) or []
+ clinic = clinic[0]["raw_data"] # if not exist, must error
+ branding = raw.get(SourceType.BRANDING) or []
+ branding = branding[0]["raw_data"] # if not exist, must error
+ report = await select_run_report_data(analysis_run_id)
market = await select_market(analysis_run_id)
def _json(v) -> str | None:
@@ -68,23 +42,30 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")),
+ "tiktok": _json(raw.get(SourceType.TIKTOK)),
+ "instagram_en": _json(raw.get(SourceType.INSTAGRAM)),
+ "facebook_en": _json(raw.get(SourceType.FACEBOOK)),
+ "naver_blog": _json(_naver_blog_summary(raw.get(SourceType.NAVER_BLOG))),
+ "naver_cafe": _json(raw.get(SourceType.NAVER_CAFE)),
+ "kakao_talk": _json(raw.get(SourceType.KAKAOTALK)),
+ "channel_logos": _json(branding.get("channelLogos")),
+ "brand_assets": _json(branding.get("brandAssets")),
}
return await LLMService(provider="perplexity").generate(plan_prompt, input_data)
-def _build_clinic_snapshot(gangnam_unni: dict, hospital: dict) -> dict:
+def _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict:
snapshot: dict = {}
doctors = gangnam_unni.get("doctors", [])
lead = max(doctors, key=lambda d: d.get("reviews", 0)) if doctors else None
- if gangnam_unni.get("name"): snapshot["name"] = gangnam_unni["name"]
- if hospital.get("clinicNameEn"): snapshot["name_en"] = hospital["clinicNameEn"]
- if hospital.get("phone"): snapshot["phone"] = hospital["phone"]
- domain = hospital.get("domain") or urlparse(hospital.get("sourceUrl") or "").netloc
- if domain: snapshot["domain"] = domain
+ snapshot["name"] = mainpage["clinicName"]
+ snapshot["name_en"] = mainpage["clinicNameEn"]
+ snapshot["phone"] = mainpage["phone"]
+ snapshot["location"] = mainpage["address"]
+ snapshot["domain"] = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
if gangnam_unni.get("rating"): snapshot["overall_rating"] = gangnam_unni["rating"]
if gangnam_unni.get("totalReviews"): snapshot["total_reviews"] = gangnam_unni["totalReviews"]
- if gangnam_unni.get("address"): snapshot["location"] = gangnam_unni["address"]
if gangnam_unni.get("badges"): snapshot["certifications"] = gangnam_unni["badges"]
if gangnam_unni.get("totalMajorStaffs"): snapshot["staff_count"] = gangnam_unni["totalMajorStaffs"]
if lead:
@@ -94,81 +75,31 @@ def _build_clinic_snapshot(gangnam_unni: dict, hospital: dict) -> dict:
"rating": lead.get("rating"),
"review_count": lead.get("reviews"),
}
+ # logo URL 은 raw_info.logo_url 컬럼에서, brand_colors 는 JSON 에서 강제 주입. LLM 의 null 처리 차단.
+ if logo_url:
+ snapshot["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
+ if brand_assets.get("brand_colors"): snapshot["brand_colors"] = brand_assets["brand_colors"]
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:
- m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
- if not m:
- return 0
- h, mins, s = (int(x or 0) for x in m.groups())
- return h * 3600 + mins * 60 + s
-
-
-def _format_seconds(seconds: int) -> str:
- m, s = divmod(seconds, 60)
- h, m = divmod(m, 60)
- return f"{h}시간 {m}분" if h else f"{m}분 {s}초"
-
-
-def _format_clock(seconds: int) -> str:
- m, s = divmod(seconds, 60)
- h, m = divmod(m, 60)
- return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
-
-
-def _calc_avg_video_length(videos: list[dict]) -> str:
- durations = [_parse_iso_duration_seconds(v.get("duration", "")) for v in videos]
- durations = [d for d in durations if d > 0]
- if not durations:
- return ""
- return _format_seconds(sum(durations) // len(durations))
-
-
-def _relative_date(date_str: str) -> str:
- if not date_str:
- return ""
- try:
- past = datetime.fromisoformat(date_str[:10])
- except ValueError:
- return ""
- days = (datetime.now() - past).days
- if days < 1:
- return "오늘"
- if days < 30:
- return f"{days}일 전"
- if days < 365:
- return f"{days // 30}개월 전"
- return f"{days // 365}년 전"
-
-
-def _calc_upload_frequency(videos: list[dict]) -> str:
- dates = sorted(
- [v["date"][:10] for v in videos if v.get("date")],
- reverse=True,
- )
- if len(dates) < 2:
- return ""
- gaps = [
- (datetime.fromisoformat(dates[i]) - datetime.fromisoformat(dates[i + 1])).days
- for i in range(len(dates) - 1)
- ]
- avg_days = sum(gaps) // len(gaps)
- if avg_days <= 7:
- return f"주 {7 // max(avg_days, 1)}회"
- if avg_days <= 30:
- return f"월 {30 // avg_days}회"
- return f"{avg_days}일에 1회"
-
-
-async def _build_youtube_audit(youtube: dict) -> dict:
+async def _build_youtube_audit(youtube: dict) -> dict: # 기획상 1개의 input channel, 다중 채널은 기획에 없음.
videos = youtube.get("videos", [])
yt_patch: dict = {
"weekly_view_growth": {"absolute": 0, "percentage": 0.0},
"estimated_monthly_revenue": {"min": 0, "max": 0},
"linked_urls": [],
- "avg_video_length": _calc_avg_video_length(videos),
- "upload_frequency": _calc_upload_frequency(videos),
+ "avg_video_length": calc_avg_video_length(videos),
+ "upload_frequency": calc_upload_frequency(videos),
}
if youtube.get("channelName"): yt_patch["channel_name"] = youtube["channelName"]
if youtube.get("handle"): yt_patch["handle"] = youtube["handle"]
@@ -176,16 +107,16 @@ async def _build_youtube_audit(youtube: dict) -> dict:
if youtube.get("totalVideos"): yt_patch["total_videos"] = youtube["totalVideos"]
if youtube.get("totalViews"): yt_patch["total_views"] = youtube["totalViews"]
if youtube.get("publishedAt"): yt_patch["channel_created_date"] = youtube["publishedAt"][:10]
- if youtube.get("description"): yt_patch["channel_description"] = youtube["description"]
+ yt_patch["channel_description"] = youtube.get("description") or ""
if youtube.get("playlists"): yt_patch["playlists"] = youtube["playlists"]
if videos:
yt_patch["top_videos"] = [
{
"title": v["title"],
"views": v["views"],
- "duration": _format_clock(_parse_iso_duration_seconds(v.get("duration", ""))),
+ "duration": format_clock(parse_iso_duration_seconds(v.get("duration", ""))),
"type": "Short" if "M" not in v.get("duration", "") else "Long",
- "uploaded_ago": _relative_date(v.get("date", "")),
+ "uploaded_ago": relative_date(v.get("date", "")),
}
for v in videos
]
@@ -212,7 +143,7 @@ async def _build_roadmap(analysis_run_id: str, raw: dict) -> list[dict]:
result: RoadmapOutput = await LLMService(provider="perplexity").generate(
roadmap_prompt,
{
- "clinic_name": (raw.get("mainpage") or {}).get("clinicName"),
+ "clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
@@ -223,7 +154,7 @@ async def _build_transformation(analysis_run_id: str, raw: dict) -> dict:
result: TransformationProposal = await LLMService(provider="perplexity").generate(
transformation_prompt,
{
- "clinic_name": (raw.get("mainpage") or {}).get("clinicName"),
+ "clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
@@ -234,155 +165,136 @@ async def _build_critical_issues(analysis_run_id: str, raw: dict) -> list[dict]:
result: CriticalIssuesOutput = await LLMService(provider="perplexity").generate(
critical_issues_prompt,
{
- "clinic_name": (raw.get("mainpage") or {}).get("clinicName"),
+ "clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
return [DiagnosisItem.model_validate(item).model_dump() for item in result.diagnosis]
-async def _build_overrides(analysis_run_id: str) -> dict:
+async def _build_scores(analysis_run_id: str, raw: dict) -> ScoresOutput:
+ return await LLMService(provider="perplexity").generate(
+ scores_prompt,
+ {
+ "clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
+ "data": json.dumps(raw, ensure_ascii=False),
+ },
+ )
+
+
+async def _build_report(analysis_run_id: str) -> dict:
raw = await select_run_raw_data(analysis_run_id)
+ run = await select_run(analysis_run_id)
if not raw:
return {}
- mainpage = raw.get("mainpage", {}) or {}
- instagram = raw.get("instagram", {}) or {}
- facebook = raw.get("facebook", {}) or {}
- youtube = raw.get("youtube", {}) or {}
- gangnam_unni = raw.get("gangnam_unni", {}) or {}
+ mainpage = raw.get(SourceType.MAINPAGE) or []
+ mainpage = mainpage[0]["raw_data"] # 유일
+ branding = raw.get(SourceType.BRANDING) or []
+ branding = branding[0]["raw_data"] # 유일
+ instagram = raw.get(SourceType.INSTAGRAM) or []
+ facebook = raw.get(SourceType.FACEBOOK) or []
+ youtube = raw.get(SourceType.YOUTUBE) or []
+ youtube = youtube[0]["raw_data"] if youtube else None # 유일 (기획상)
+ gangnam_unni = raw.get(SourceType.GANGNAM_UNNI) or []
+ gangnam_unni = gangnam_unni[0]["raw_data"] if gangnam_unni else None# 유일 (기획상)
+ naver_blog = raw.get(SourceType.NAVER_BLOG) or []
+ naver_blog = naver_blog[0]["raw_data"] if naver_blog else None# 유일 (기획상)
+ tiktok = raw.get(SourceType.TIKTOK) or []
+ tiktok = tiktok[0]["raw_data"] if tiktok else None# 유일 (기획상)
+ naver_cafe = raw.get(SourceType.NAVER_CAFE) or []
+ naver_cafe = naver_cafe[0]["raw_data"] if naver_cafe else None# 유일 (기획상)
- snapshot: dict = _build_clinic_snapshot(gangnam_unni, mainpage)
- yt_patch: dict = await _build_youtube_audit(youtube)
+ brand_assets = branding.get("brandAssets") or {}
+ channel_logos = branding.get("channelLogos") or {}
- # ── instagram ─────────────────────────────────────────────────────────────
- ig_patch: dict = {}
- if instagram.get("username"): ig_patch["handle"] = instagram["username"]
- 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 ──────────────────────────────────────────────────────────────
- fb_pages: dict = {}
- if facebook.get("pageUrl"): fb_pages["url"] = facebook["pageUrl"]
- if facebook.get("pageUrl"): fb_pages["link"] = facebook["pageUrl"]
- if facebook.get("pageName"): fb_pages["page_name"] = facebook["pageName"]
- if facebook.get("followers"): fb_pages["followers"] = facebook["followers"]
- if facebook.get("intro"): fb_pages["bio"] = facebook["intro"]
- if facebook.get("categories"): fb_pages["category"] = ", ".join(facebook["categories"])
- if facebook.get("website"): fb_pages["linked_domain"] = facebook["website"]
+ logo_url = await select_mainpage_logo_url(analysis_run_id)
brand = await generate_brand_consistency(analysis_run_id)
- brand_patch = brand.model_dump()["brand_inconsistencies"]
- critical_issues = await _build_critical_issues(analysis_run_id, raw)
- transformation = await _build_transformation(analysis_run_id, raw)
- roadmap = await _build_roadmap(analysis_run_id, raw)
+ brand_patch : list[dict] = brand.model_dump()["brand_inconsistencies"]
+
+ kpi_extras = {
+ "tiktok": tiktok,
+ "naverCafe": naver_cafe,
+ }
- fb_patch: dict = {}
- if fb_pages:
- fb_patch["pages"] = [fb_pages]
- if brand_patch:
- fb_patch["brand_inconsistencies"] = brand_patch
+ scores = (await _build_scores(analysis_run_id, raw)).model_dump()
- overrides: dict = {}
- if snapshot:
- overrides["clinic_snapshot"] = snapshot
- if ig_patch:
- overrides["instagram_audit"] = {"accounts": [ig_patch]}
- if fb_patch:
- overrides["facebook_audit"] = fb_patch
- if yt_patch:
- overrides["youtube_audit"] = yt_patch
- if critical_issues:
- overrides["problem_diagnosis"] = critical_issues
- if transformation:
- overrides["transformation"] = transformation
- if roadmap:
- overrides["roadmap"] = roadmap
- return overrides
+ report = {
+ "id" : analysis_run_id,
+ "created_at" : str(run["created_at"]) if run.get("created_at") else None,
+ "target_url" : mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc,
+ "overall_score" : scores.get("overall_score"),
+ "channel_scores" : scores.get("channel_scores"),
+
+ "clinic_snapshot": _build_clinic_snapshot(mainpage, gangnam_unni, brand_assets, logo_url),
+ "instagram_audit": await build_instagram_audit(instagram, channel_logos),
+ "facebook_audit": await build_facebook_audit(facebook, brand_patch, channel_logos),
+ "youtube_audit": await _build_youtube_audit(youtube),
+
+ "other_channels": "",
+ "website_audit": "",
+
+ "problem_diagnosis": await _build_critical_issues(analysis_run_id, raw),
+ "transformation" : await _build_transformation(analysis_run_id, raw),
+ "roadmap": await _build_roadmap(analysis_run_id, raw),
+ "kpi_dashboard": build_kpi_dashboard(instagram, facebook, youtube, gangnam_unni, kpi_extras, naver_blog),
+ }
+
+ return ReportOutput(**report)
def _deep_merge(base: dict, overrides: dict) -> dict:
+ """dict 끼리 만나면 재귀로 안쪽까지 합치고, 그 외(list/scalar/None) 는 override 값으로 통째 치환."""
for k, v in overrides.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_deep_merge(base[k], v)
- elif isinstance(v, list) and isinstance(base.get(k), list):
- for i, item in enumerate(v):
- if i < len(base[k]) and isinstance(item, dict) and isinstance(base[k][i], dict):
- _deep_merge(base[k][i], item)
else:
base[k] = v
return base
-def _patch_report(result: ReportOutput, overrides: dict) -> ReportOutput:
- base = result.model_dump()
- for key in overrides:
- base.pop(key, None)
- return ReportOutput(**_deep_merge(base, overrides))
-
-
-_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:
- url = await select_run_mainpage_url(analysis_run_id)
- 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 generate_brand_consistency(analysis_run_id: str) -> BrandConsistencyOutput:
raw = await select_run_raw_data(analysis_run_id)
def _json(v) -> str | None:
return json.dumps(v, ensure_ascii=False) if v else None
- mainpage = raw.get("mainpage") or {}
+ mainpage = raw.get(SourceType.MAINPAGE) or []
input_data = {
- "clinic_name": mainpage.get("clinicName"),
+ "clinic_name": (mainpage[0].get("clinicName") if mainpage else None),
"mainpage": _json(mainpage),
- "instagram": _json(raw.get("instagram")),
- "facebook": _json(raw.get("facebook")),
- "youtube": _json(raw.get("youtube")),
- "gangnam_unni": _json(raw.get("gangnam_unni")),
+ "instagram": _json(raw.get(SourceType.INSTAGRAM)),
+ "facebook": _json(raw.get(SourceType.FACEBOOK)),
+ "youtube": _json(raw.get(SourceType.YOUTUBE)),
+ "gangnam_unni": _json(raw.get(SourceType.GANGNAM_UNNI)),
}
return await LLMService(provider="perplexity").generate(brand_consistency_prompt, input_data)
-
-
+
async def run_report_task(analysis_run_id: str) -> None:
logger.info("[report] start run=%s", analysis_run_id)
- if await _is_mock(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))
+ await analyze_branding(analysis_run_id)
+ # result = await generate_report(analysis_run_id)
+ result = await _build_report(analysis_run_id)
await update_run_report(analysis_run_id, result.model_dump())
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:
logger.info("[plan] start run=%s", analysis_run_id)
- if await _is_mock(analysis_run_id):
- logger.info("[plan] mock mode run=%s", analysis_run_id)
- result = _load_mock_plan()
- else:
- result = await generate_plan(analysis_run_id)
+ result = await generate_plan(analysis_run_id)
+ # profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단).
+ raw = await select_run_raw_data(analysis_run_id)
+ branding = raw.get(SourceType.BRANDING) or []
+ logo_desc = (((branding[0] if branding else {}).get("brandAssets") or {}).get("logo_description")) or ""
+ result = _patch_plan(result, logo_desc)
await update_run_plan(analysis_run_id, result.model_dump())
logger.info("[plan] done run=%s", analysis_run_id)
diff --git a/app/services/brand_parser.py b/app/services/brand_parser.py
new file mode 100644
index 0000000..8559e16
--- /dev/null
+++ b/app/services/brand_parser.py
@@ -0,0 +1,172 @@
+"""collect 단계 - HTML/CSS 텍스트에서 brand 로고 URL + 색상 추출"""
+import logging
+import re
+from collections import Counter
+from urllib.parse import urljoin
+
+logger = logging.getLogger(__name__)
+
+
+# ── 로고 URL 추출 ─────────────────────────────────────────────────────────────
+
+
+LOGO_IMG_PATTERNS = [
+ re.compile(r'
]*\bclass=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
+ re.compile(r'
]*\bsrc=["\']([^"\']+)["\'][^>]*\bclass=["\'][^"\']*\blogo\b[^"\']*["\']', re.IGNORECASE),
+ re.compile(r'
]*\bid=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
+ re.compile(r'
]*\balt=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE),
+ re.compile(r'<(?:a|h[1-6]|div|span)[^>]*\b(?:class|id)=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*>(?:[^<]|<(?!img))*
]*\bsrc=["\']([^"\']+)["\']', re.IGNORECASE | re.DOTALL),
+ re.compile(r'<(?:a|div|span|h[1-6])[^>]*\b(?:class|id)=["\'][^"\']*\blogo\b[^"\']*["\'][^>]*\bstyle=["\'][^"\']*background(?:-image)?\s*:\s*url\(\s*["\']?([^"\')\s]+)', re.IGNORECASE),
+ re.compile(r'<(?:a|div|span|h[1-6])[^>]*\bstyle=["\'][^"\']*background(?:-image)?\s*:\s*url\(\s*["\']?([^"\')\s]+)[^"\']*["\'][^>]*\b(?:class|id)=["\'][^"\']*\blogo\b', re.IGNORECASE),
+ re.compile(r'
]*\bsrc=["\']([^"\']*\blogo\b[^"\']*\.(?:png|svg|jpe?g|webp)[^"\']*)["\']', re.IGNORECASE),
+ re.compile(r']*>(?:[^<]|<(?!img))*
]*\bsrc=["\']([^"\']+\.(?:png|svg|jpe?g|webp)[^"\']*)["\']', re.IGNORECASE | re.DOTALL),
+ re.compile(r'