브랜드 자산(로고/색상)·채널 로고 Vision 분석 추가

- color_extractor: 홈페이지 HTML/CSS에서 로고 URL·브랜드 hex 추출
- vision: Gemini Vision 로고 묘사·채널 로고 일치 평가
- youtube: 채널 profileImage 추출 / firecrawl: clinic_info 추출 보정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
insta-data
Mina Choi 2026-05-27 13:27:39 +09:00
parent 9817b53be1
commit 843ccdb806
4 changed files with 425 additions and 1 deletions

View File

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

View File

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

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

@ -0,0 +1,171 @@
"""Gemini Vision — 로고/브랜드 비주얼 자동 분석 (OpenAI 호환 모드).
정확한 hex 색상은 color_extractor가 CSS에서 직접 뽑음 (Vision은 근사값밖에 ).
Vision은 사람이 봐야 있는 정성 정보 심볼 형태/워드마크/ 담당.
"""
import base64
import json
import logging
import re
import httpx
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)."""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) 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
size = len(resp.content)
if size < 500:
logger.warning("[vision] %s too small (%d bytes) — likely placeholder", url, size)
return None
b64 = base64.b64encode(resp.content).decode("ascii")
return f"data:{mime};base64,{b64}"
except Exception as e:
logger.warning("[vision] fetch error %s: %s", url, e)
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 analyze_brand_assets(
self,
logo_url: str | None,
homepage_url: str | None,
additional_images: list[str] | None = None,
) -> dict:
"""로고 이미지를 보고 정성 분석. 정확한 hex는 color_extractor가 따로 처리하므로 여기선 안 뽑음."""
urls = [u for u in [logo_url] + list(additional_images or []) if u]
if not urls:
return {}
prompt = (
"당신은 브랜드 로고 시각 분석가입니다. 첨부된 이미지(첫 번째가 병원의 대표 로고)를 보고 "
"아래 JSON 스키마로만 응답하세요. 코드펜스 없이 순수 JSON만 출력.\n"
"{\n"
' "logo_description": "로고를 1~2문장으로 설명 (심볼 형태 + 워드마크 + 전반적 톤). 예: \'둥근 잎사귀를 감싼 추상 심볼에 세리프 한글 워드마크, 차분하고 고급스러운 톤\'",\n'
' "logo_style": "minimal | illustrative | typographic | abstract 중 하나",\n'
' "has_symbol": "심볼/아이콘이 있으면 true, 글자만 있으면 false (boolean)",\n'
' "logo_symbol": "심볼이 묘사하는 대상 (예: \'잎사귀\', \'추상 곡선\'). 없으면 빈 문자열",\n'
' "logo_text": "로고에 보이는 워드마크 텍스트 그대로 (한글/영문). 없으면 빈 문자열",\n'
' "logo_colors_desc": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\'). 정확한 hex는 출력하지 말 것"\n'
"}\n"
"주의: 색상 hex 값이나 logo URL 같은 필드는 출력하지 마세요 (별도 추출 로직이 처리)."
)
result = await self._ask(urls, prompt)
if not result:
return {}
# logo_images는 우리가 직접 채움 (Vision은 묘사만)
result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
return result
async def describe_channel_logos(
self,
official_logo_url: str | None,
channel_logos: list[dict],
) -> dict | None:
"""채널별 프로필 이미지(로고)를 보고 각각 설명 + 공식 로고와 일치 여부 평가.
channel_logos: [{"channel": "Instagram", "url": "..."}, ...]
반환: {"channel_logos": [{"channel","logo_description","is_official"}], "inconsistency_summary", "recommendation"}"""
items = [c for c in channel_logos if c.get("url")]
if not items:
return None
# 공식 로고가 있으면 맨 앞에 두고 기준으로 삼음
urls: list[str] = []
if official_logo_url:
urls.append(official_logo_url)
urls.extend(c["url"] for c in items)
channel_order = ", ".join(c.get("channel", "?") for c in items)
if official_logo_url:
header = (
"첨부 이미지 중 **첫 번째가 이 병원의 공식 로고**입니다. "
f"이어지는 이미지들은 채널별 프로필 이미지이며 순서는: {channel_order}.\n"
"각 채널 로고를 1문장으로 설명하고, 공식 로고(첫 번째)와 일치하면 is_official=true, "
"비공식 변형/모델사진/다른 이미지면 false로 평가하세요.\n"
)
else:
header = (
f"첨부 이미지는 한 병원의 채널별 프로필 이미지입니다. 순서: {channel_order}.\n"
"각 채널 로고를 1문장으로 설명하세요 (공식 로고 기준이 없으므로 is_official은 판단 가능하면만).\n"
)
prompt = (
header
+ "아래 JSON으로만 응답 (코드펜스 없이 순수 JSON):\n"
"{\n"
' "channel_logos": [{"channel": "...", "logo_description": "...", "is_official": true}],\n'
' "inconsistency_summary": "채널 간 로고 일관성 1~2문장 요약",\n'
' "recommendation": "통합 권고 1문장"\n'
"}"
)
return await self._ask(urls, prompt)

View File

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