브랜드 자산(로고/색상)·채널 로고 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
parent
9817b53be1
commit
843ccdb806
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue