website_audit 추출 정확도 개선 + Apify 빈값 방어

- website_parser: anchor-only href 추출로 CDN 노이즈 차단, additional_domains 를
  data-lang/서브도메인 prefix 기반 글로벌 사이트 검출로 교체, main_cta 카테고리화
  (전화/카톡/예약, fallback 제거) + 최대 3개 제한
- firecrawl: html → rawHtml 로 변경 (script 태그 보존 → 픽셀 검출 정상화)
- gemini_vision: logo_colors_hex 최대 5개 → 2개
- branding: 기존 brandAssets 보존하면서 logo_* 머지 (collect_brand_basics 결과
  덮어쓰지 않게)
- facebook_audit: _page_patch 가 누락 필드 default 값으로 초기화 (Apify 가 페이지
  데이터 못 받을 때 schema validation 실패 방어)
- schemas: top_videos / bio / linked_domain 옵셔널 처리 (실제 누락 가능 케이스 대응)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
advenced_report
Mina Choi 2026-06-04 22:43:40 +09:00
parent 4ad30d4d36
commit c4cbbaf7e7
9 changed files with 134 additions and 63 deletions

View File

@ -74,7 +74,7 @@ class FirecrawlClient:
headers=self._headers(), headers=self._headers(),
json_body={ json_body={
"url": url, "url": url,
"formats": ["json", "links", "html"], "formats": ["json", "links", "rawHtml"],
"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 from the actual header/main <img> src, og:image from <meta property='og:image'> content, 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": {
@ -147,7 +147,7 @@ class FirecrawlClient:
# "socialMedia": info.get("socialMedia", {}), # "socialMedia": info.get("socialMedia", {}),
"branding": info.get("branding", {}), "branding": info.get("branding", {}),
"siteLinks": data.get("links", []), "siteLinks": data.get("links", []),
"html": data.get("html", ""), # raw HTML — collect 단계에서 tracking 추출 후 raw_data 에는 저장 안 함. "html": data.get("rawHtml", "") or data.get("html", ""), # rawHtml = 가공 전 원본 — <script> 등 보존.
"sourceUrl": url, "sourceUrl": url,
} }

View File

@ -219,7 +219,7 @@ class VisionClient:
' "logo_symbol": "심볼이 묘사하는 대상 (예: \'잎사귀\', \'추상 곡선\'). 없으면 빈 문자열",\n' ' "logo_symbol": "심볼이 묘사하는 대상 (예: \'잎사귀\', \'추상 곡선\'). 없으면 빈 문자열",\n'
' "logo_text": "로고에 보이는 워드마크 텍스트 그대로 (한글/영문). 없으면 빈 문자열",\n' ' "logo_text": "로고에 보이는 워드마크 텍스트 그대로 (한글/영문). 없으면 빈 문자열",\n'
' "logo_colors_desc": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\')",\n' ' "logo_colors_desc": "로고에 쓰인 색감을 사람이 부르는 이름으로 서술 (예: \'딥네이비 + 골드\')",\n'
' "logo_colors_hex": ["로고에서 시각적으로 두드러진 색 정확히 5개의 hex 근사값 배열. 예: [\'#1A2B3C\', \'#D4A017\', \'#FFFFFF\', \'#9E5C2A\', \'#1F1F1F\']. 강한 색이 5개 안 되면 음영/명도 차이로 5개 채울 것. 빈 배열 금지."]\n' ' "logo_colors_hex": ["로고에서 시각적으로 가장 두드러진 색 최대 2개의 hex 근사값 배열. 예: [\'#1A2B3C\', \'#D4A017\']. 색이 1개면 1개만, 강한 색이 1개도 없으면 빈 배열."]\n'
"}\n" "}\n"
"주의: logo_colors_hex 는 시각 추정이라 정확도 떨어질 수 있음. CSS 추출이 우선이고 이건 fallback/보완 용.\n" "주의: logo_colors_hex 는 시각 추정이라 정확도 떨어질 수 있음. CSS 추출이 우선이고 이건 fallback/보완 용.\n"
"모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)." "모든 설명/텍스트 값은 반드시 한국어로 작성하세요 (영어 금지)."
@ -229,14 +229,9 @@ class VisionClient:
return {} return {}
# logo_images는 우리가 직접 채움 (Vision은 묘사만) # logo_images는 우리가 직접 채움 (Vision은 묘사만)
result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None} result["logo_images"] = {"circle": None, "horizontal": logo_url, "korean": None}
# logo_colors_hex 5개 강제 정규화 — LLM 이 4개나 6개 줄 수도 있어서 길이 fallback. # logo_colors_hex 최대 2개로 제한.
hex_list = [h for h in (result.get("logo_colors_hex") or []) if isinstance(h, str) and h.startswith("#")] hex_list = [h for h in (result.get("logo_colors_hex") or []) if isinstance(h, str) and h.startswith("#")]
if hex_list: result["logo_colors_hex"] = hex_list[:2]
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 return result
async def describe_channel_logos( async def describe_channel_logos(

View File

@ -134,7 +134,7 @@ class YouTubeAudit(BaseModel):
channel_description: str channel_description: str
linked_urls: list[LinkedUrl] linked_urls: list[LinkedUrl]
playlists: list[str] playlists: list[str]
top_videos: list[TopVideo] top_videos: list[TopVideo] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem]
@ -184,11 +184,11 @@ class FacebookPage(BaseModel):
followers: int followers: int
following: int following: int
category: str category: str
bio: str bio: str = ""
logo: str logo: str
logo_description: str logo_description: str
link: str link: str
linked_domain: str linked_domain: str = ""
reviews: int reviews: int
recent_post_age: str recent_post_age: str
has_whatsapp: bool | None = None has_whatsapp: bool | None = None
@ -245,7 +245,7 @@ class AdditionalDomain(BaseModel):
class WebsiteAudit(BaseModel): class WebsiteAudit(BaseModel):
primary_domain: str primary_domain: str
additional_domains: list[AdditionalDomain] additional_domains: list[AdditionalDomain] = []
sns_links_on_site: bool sns_links_on_site: bool
sns_links_detail: list[SnsLink] | None = None sns_links_detail: list[SnsLink] | None = None
tracking_pixels: list[TrackingPixel] tracking_pixels: list[TrackingPixel]

View File

@ -130,7 +130,7 @@ class YouTubeAudit(CamelModel):
channel_description: str channel_description: str
linked_urls: list[LinkedUrl] linked_urls: list[LinkedUrl]
playlists: list[str] playlists: list[str]
top_videos: list[TopVideo] top_videos: list[TopVideo] = []
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem]
@ -176,11 +176,11 @@ class FacebookPage(CamelModel):
followers: int followers: int
following: int following: int
category: str category: str
bio: str bio: str = ""
logo: str logo: str
logo_description: str logo_description: str
link: str link: str
linked_domain: str linked_domain: str = ""
reviews: int reviews: int
recent_post_age: str recent_post_age: str
has_whatsapp: bool | None = None has_whatsapp: bool | None = None
@ -222,7 +222,7 @@ class AdditionalDomain(CamelModel):
class WebsiteAudit(CamelModel): class WebsiteAudit(CamelModel):
primary_domain: str primary_domain: str
additional_domains: list[AdditionalDomain] additional_domains: list[AdditionalDomain] = []
sns_links_on_site: bool sns_links_on_site: bool
sns_links_detail: list[SnsLink] | None = None sns_links_detail: list[SnsLink] | None = None
tracking_pixels: list[TrackingPixel] tracking_pixels: list[TrackingPixel]

View File

@ -341,8 +341,9 @@ async def run_plan_task(analysis_run_id: str) -> None:
result = await generate_plan(analysis_run_id) result = await generate_plan(analysis_run_id)
# profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단). # profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단).
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
branding = raw.get(SourceType.BRANDING) or [] branding_list = raw.get(SourceType.BRANDING) or []
logo_desc = (((branding[0] if branding else {}).get("brandAssets") or {}).get("logo_description")) or "" branding_data = branding_list[0]["raw_data"] if branding_list else {}
logo_desc = (branding_data.get("brandAssets") or {}).get("logo_description") or ""
result = _patch_plan(result, logo_desc) result = _patch_plan(result, logo_desc)
await update_run_plan(analysis_run_id, result.model_dump()) await update_run_plan(analysis_run_id, result.model_dump())
logger.info("[plan] done run=%s", analysis_run_id) logger.info("[plan] done run=%s", analysis_run_id)

View File

@ -39,7 +39,11 @@ async def _describe_logo(analysis_run_id: str, info_id: int, vc: VisionClient) -
if result: if result:
break break
if result: if result:
await update_raw_info_merge(info_id, {"brandAssets": result}) # collect_brand_basics 가 미리 채운 brand_colors/color_palette/color_source 보존하면서 logo_* 덧붙이기.
raw = await select_run_raw_data(analysis_run_id)
existing = ((raw.get("branding") or [{}])[0].get("raw_data") or {}).get("brandAssets") or {}
merged = {**existing, **result}
await update_raw_info_merge(info_id, {"brandAssets": merged})
logger.info("[brand_logo] done keys=%s", list(result.keys()) if result else None) logger.info("[brand_logo] done keys=%s", list(result.keys()) if result else None)

View File

@ -86,15 +86,15 @@ async def collect_gangnam_unni(analysis_run_id: str, info_id: int, url: str) ->
logger.info("[gangnam_unni] done run=%s", analysis_run_id) logger.info("[gangnam_unni] done run=%s", analysis_run_id)
def _extract_website_audit(html: str, site_links: list[str], url: str) -> dict: def _extract_website_audit(html: str, url: str) -> dict:
"""raw HTML + siteLinks 에서 main CTA / tracking pixels / SNS / additional domains 정규식 추출.""" """raw HTML 에서 main CTA / tracking pixels / SNS / additional domains 정규식 추출."""
if not html: if not html:
return {} return {}
primary_host = urlparse(url).netloc primary_host = urlparse(url).netloc
result: dict = { result: dict = {
"trackingPixels": extract_tracking_pixels(html), "trackingPixels": extract_tracking_pixels(html),
"snsLinks": extract_sns_links(site_links, html), "snsLinks": extract_sns_links(html),
"additionalDomains": extract_additional_domains(site_links, primary_host), "additionalDomains": extract_additional_domains(html, primary_host),
} }
html_cta = extract_main_cta(html) html_cta = extract_main_cta(html)
if html_cta: if html_cta:
@ -115,7 +115,7 @@ async def collect_mainpage(analysis_run_id: str, info_id: int, hospital_id: str,
html = data.pop("html", "") or "" # raw_data 에는 저장 안 함 — 여기서만 사용하고 버림. html = data.pop("html", "") or "" # raw_data 에는 저장 안 함 — 여기서만 사용하고 버림.
# 홈페이지 URL 자체도 raw_data 에 박아둬야 brand_assets / 분석 단계에서 mainpage URL 재조회 없이 사용 가능. # 홈페이지 URL 자체도 raw_data 에 박아둬야 brand_assets / 분석 단계에서 mainpage URL 재조회 없이 사용 가능.
data = {**data, "sourceUrl": url} data = {**data, "sourceUrl": url}
data.update(_extract_website_audit(html, data.get("siteLinks", []), url)) data.update(_extract_website_audit(html, url))
await update_raw_info(info_id, data) await update_raw_info(info_id, data)
await update_hospital(hospital_id, data, analysis_run_id=analysis_run_id) await update_hospital(hospital_id, data, analysis_run_id=analysis_run_id)

View File

@ -89,7 +89,8 @@ def _logo_data(channel_logos: dict, channel: str) -> dict:
} }
def _page_patch(item: dict, channel_logos) -> dict: def _page_patch(item: dict, channel_logos) -> dict:
p: dict = {} p: dict = {"page_name": "", "followers": 0, "category": "", "reviews": 0,
"following": 0, "recent_post_age": "", "post_frequency": "", "engagement": ""}
fb = item["raw_data"] fb = item["raw_data"]
language = item.get("language") if item.get("language") else "KR" language = item.get("language") if item.get("language") else "KR"
label = "페이스북 " + language label = "페이스북 " + language

View File

@ -1,13 +1,11 @@
"""collect 단계 - HTML / siteLinks 에서 tracking pixels / SNS links / additional domains 추출. """HTML 정규식 기반 deterministic 추출 — tracking pixels / SNS / additional domains / main CTA."""
모두 정규식·도메인 매칭 기반 deterministic 추출 (LLM 미경유)."""
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
# ── tracking pixels ────────────────────────────────────────────────────────── # ── tracking pixels ──────────────────────────────────────────────────────────
# 픽셀별 시그니처: 이름 → 정규식 리스트. installed 판정은 OR (하나라도 매치되면 설치된 것). # 픽셀별 시그니처: 하나라도 매치되면 installed, ID 는 첫 non-empty 그룹.
# ID 캡처는 모든 패턴 시도 후 첫 non-empty 그룹 사용 (signature-only 패턴과 ID-capture 패턴 혼재 OK).
_TRACKING_PIXEL_PATTERNS: dict[str, list[re.Pattern]] = { _TRACKING_PIXEL_PATTERNS: dict[str, list[re.Pattern]] = {
"Google Analytics": [ "Google Analytics": [
re.compile(r"googletagmanager\.com/gtag/js\?id=(G-[A-Z0-9]+)", re.IGNORECASE), re.compile(r"googletagmanager\.com/gtag/js\?id=(G-[A-Z0-9]+)", re.IGNORECASE),
@ -39,8 +37,7 @@ _TRACKING_PIXEL_PATTERNS: dict[str, list[re.Pattern]] = {
def extract_tracking_pixels(html: str) -> list[dict]: def extract_tracking_pixels(html: str) -> list[dict]:
"""HTML 에서 트래킹 픽셀 설치 여부 + ID 추출. 환각 0 — 정규식 매치만 신뢰. """HTML 에서 트래킹 픽셀 설치 여부 + ID 추출."""
모든 패턴 시도 하나라도 매치되면 installed. ID 캡처된 non-empty 그룹 사용."""
if not html: if not html:
return [] return []
pixels: list[dict] = [] pixels: list[dict] = []
@ -66,9 +63,15 @@ def extract_tracking_pixels(html: str) -> list[dict]:
# ── main CTA ───────────────────────────────────────────────────────────────── # ── main CTA ─────────────────────────────────────────────────────────────────
_CTA_KEYWORDS = re.compile( _CTA_KEYWORDS = re.compile(
r"(상담|예약|문의|신청|등록|진료|Book\s*Now|Consult|Reservation|Contact|Apply)", r"(상담|예약|문의|신청|등록|Book\s*Now|Consult|Reservation|Contact|Apply)",
re.IGNORECASE, re.IGNORECASE,
) )
# CTA 카테고리 우선순위 (전화>카톡>예약). 매치 없으면 결과에 포함 X.
_CTA_CATEGORIES: list[tuple[str, re.Pattern]] = [
("전화 상담", re.compile(r"전화|\bCall\b|\bPhone\b", re.IGNORECASE)),
("카카오톡 상담", re.compile(r"카(카오)?톡|Kakao", re.IGNORECASE)),
("온라인 예약", re.compile(r"예약|Reserv|Book", re.IGNORECASE)),
]
_BUTTON_TAG = re.compile(r"<button\b[^>]*>(.*?)</button>", re.IGNORECASE | re.DOTALL) _BUTTON_TAG = re.compile(r"<button\b[^>]*>(.*?)</button>", re.IGNORECASE | re.DOTALL)
_ANCHOR_TAG = re.compile(r"<a\b[^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL) _ANCHOR_TAG = re.compile(r"<a\b[^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL)
@ -78,22 +81,37 @@ def _clean_text(inner: str) -> str:
return re.sub(r"\s+", " ", text).strip() return re.sub(r"\s+", " ", text).strip()
_MAIN_CTA_LIMIT = 3
def _classify_cta(text: str) -> str | None:
"""CTA 텍스트 → 카테고리 (전화/카톡/예약). 매치 없으면 None."""
for label, pat in _CTA_CATEGORIES:
if pat.search(text):
return label
return None
def extract_main_cta(html: str) -> str: def extract_main_cta(html: str) -> str:
"""HTML 에서 primary CTA 텍스트 추출. 우선순위: <button> 첫 매치 → <a> 첫 매치. """<button>·<a> 라벨을 카테고리로 분류 후 우선순위 순서로 최대 _MAIN_CTA_LIMIT 개 join."""
CTA 키워드 매칭 + 길이 1~20 (버튼 라벨 추정)."""
if not html: if not html:
return "" return ""
found: set[str] = set()
for pat in (_BUTTON_TAG, _ANCHOR_TAG): for pat in (_BUTTON_TAG, _ANCHOR_TAG):
for m in pat.finditer(html): for m in pat.finditer(html):
text = _clean_text(m.group(1)) text = _clean_text(m.group(1))
if text and 1 <= len(text) <= 20 and _CTA_KEYWORDS.search(text): if text and 1 <= len(text) <= 20 and _CTA_KEYWORDS.search(text):
return text cat = _classify_cta(text)
return "" if cat:
found.add(cat)
ordered_labels = [label for label, _ in _CTA_CATEGORIES]
result = [label for label in ordered_labels if label in found]
return " + ".join(result[:_MAIN_CTA_LIMIT])
# ── SNS links ──────────────────────────────────────────────────────────────── # ── SNS links ────────────────────────────────────────────────────────────────
# SNS 도메인 → 표준 platform 이름. siteLinks 필터링 + DOM 위치 판정에 공유. # SNS 도메인 → 표준 platform 이름.
_SNS_DOMAINS: dict[str, str] = { _SNS_DOMAINS: dict[str, str] = {
"facebook.com": "Facebook", "facebook.com": "Facebook",
"instagram.com": "Instagram", "instagram.com": "Instagram",
@ -110,11 +128,16 @@ _SNS_DOMAINS: dict[str, str] = {
_FOOTER_BLOCK = re.compile(r"<footer\b[^>]*>(.*?)</footer>", re.IGNORECASE | re.DOTALL) _FOOTER_BLOCK = re.compile(r"<footer\b[^>]*>(.*?)</footer>", re.IGNORECASE | re.DOTALL)
_HEADER_BLOCK = re.compile(r"<header\b[^>]*>(.*?)</header>", re.IGNORECASE | re.DOTALL) _HEADER_BLOCK = re.compile(r"<header\b[^>]*>(.*?)</header>", re.IGNORECASE | re.DOTALL)
# <a> 태그 안의 href 만 매치 — <link>/<script> 등 리소스 호출 제외 (CDN 노이즈 차단).
_ANCHOR_HREF_PATTERN = re.compile(
r"""<a\b[^>]*\bhref\s*=\s*['"](https?://[^'"\s]+)['"]""",
re.IGNORECASE,
)
def _platform_for(url: str) -> str | None: def _platform_for(url: str) -> str | None:
try: try:
host = urlparse(url).netloc.lower().lstrip("www.") host = urlparse(url).netloc.lower().removeprefix("www.")
except Exception: except Exception:
return None return None
for domain, name in _SNS_DOMAINS.items(): for domain, name in _SNS_DOMAINS.items():
@ -124,7 +147,7 @@ def _platform_for(url: str) -> str | None:
def _location_for(url: str, html: str) -> str: def _location_for(url: str, html: str) -> str:
"""url 이 HTML 의 <header>/<footer> 안에 들어있는지로 위치 판정. 둘 다 아니면 'body'.""" """url 이 <header>/<footer> 블록 안에 있는지 판정 — 둘 다 아니면 'body'."""
if not html: if not html:
return "" return ""
needle = re.escape(url) needle = re.escape(url)
@ -137,11 +160,21 @@ def _location_for(url: str, html: str) -> str:
return "body" return "body"
def extract_sns_links(site_links: list[str], html: str = "") -> list[dict]: def _extract_anchor_hrefs(html: str) -> list[str]:
"""siteLinks 에서 SNS 도메인 매칭. location 은 HTML 의 footer/header tag 블록으로 판정. """<a href="http..."> 만 추출 — 순서 보존, 중복 제거."""
중복 platform URL 유지.""" if not html:
return []
seen: dict[str, None] = {}
for url in _ANCHOR_HREF_PATTERN.findall(html):
if url not in seen:
seen[url] = None
return list(seen.keys())
def extract_sns_links(html: str) -> list[dict]:
"""anchor href 에서 SNS 도메인 매칭. 중복 platform 은 첫 URL 만 유지."""
seen: dict[str, dict] = {} seen: dict[str, dict] = {}
for url in site_links or []: for url in _extract_anchor_hrefs(html):
platform = _platform_for(url) platform = _platform_for(url)
if not platform or platform in seen: if not platform or platform in seen:
continue continue
@ -153,26 +186,63 @@ def extract_sns_links(site_links: list[str], html: str = "") -> list[dict]:
return list(seen.values()) return list(seen.values())
# ── additional domains ─────────────────────────────────────────────────────── # ── additional domains (글로벌/다국어 사이트만) ─────────────────────────────────
def extract_additional_domains(site_links: list[str], primary_host: str) -> list[dict]: # 언어 코드 → 한국어 라벨.
"""siteLinks 의 host 중 primary 도메인이 아닌 외부 도메인 모으기. SNS·맵 등 utility 도메인 제외. _LANG_LABEL: dict[str, str] = {
purpose 분류는 LLM 필요 여기선 문자열.""" "en": "영어", "eng": "영어",
skip_domains = set(_SNS_DOMAINS) | { "zh": "중국어", "cn": "중국어", "chn": "중국어",
"map.kakao.com", "map.naver.com", "maps.google.com", "goo.gl", "ja": "일본어", "jp": "일본어",
} "ko": "한국어", "kor": "한국어", "kr": "한국어",
primary = (primary_host or "").lower().lstrip("www.") "vi": "베트남어", "vn": "베트남어",
"th": "태국어", "thai": "태국어",
"ru": "러시아어",
"es": "스페인어",
"mn": "몽골어",
"ar": "아랍어", "arab": "아랍어",
"id": "인도네시아어",
"de": "독일어",
"fr": "프랑스어",
"pt": "포르투갈어",
}
_LANG_LI_PATTERN = re.compile(
r'<li\b[^>]*\bdata-lang\s*=\s*[\'"]([^\'"]+)[\'"][^>]*>(.*?)</li>',
re.IGNORECASE | re.DOTALL,
)
def extract_additional_domains(html: str, primary_host: str) -> list[dict]:
"""글로벌/다국어 사이트 anchor 수집 — data-lang 마커 + URL 서브도메인 prefix 로 식별."""
if not html:
return []
primary = (primary_host or "").lower().removeprefix("www.")
seen: dict[str, dict] = {} seen: dict[str, dict] = {}
for url in site_links or []:
if not url or not url.startswith("http"): def add(host: str, lang_kr: str):
continue host = host.lower().removeprefix("www.")
try:
host = urlparse(url).netloc.lower().lstrip("www.")
except Exception:
continue
if not host or host == primary or host in seen: if not host or host == primary or host in seen:
return
seen[host] = {"domain": host, "purpose": lang_kr}
# 1) <li data-lang="xx"> 안의 anchor
for m in _LANG_LI_PATTERN.finditer(html):
lang_kr = _LANG_LABEL.get(m.group(1).lower())
if not lang_kr:
continue continue
if any(host == d or host.endswith("." + d) for d in skip_domains): a_m = re.search(r'<a[^>]*href\s*=\s*[\'"](https?://[^\'"\s]+)[\'"]', m.group(2), re.IGNORECASE)
continue if a_m:
seen[host] = {"domain": host, "purpose": ""} add(urlparse(a_m.group(1)).netloc, lang_kr)
# 2) URL 서브도메인 prefix 가 언어 코드
for url in _extract_anchor_hrefs(html):
host = urlparse(url).netloc.lower().removeprefix("www.")
parts = host.split(".")
if len(parts) >= 3:
lang_kr = _LANG_LABEL.get(parts[0])
if lang_kr:
add(host, lang_kr)
return list(seen.values()) return list(seen.values())