#!/usr/bin/env python3 """ audit_aeo_geo.py — INFINITH AI Discovery 자동 채점기 (P0) 병원(업체) 메인 URL + 대표 시술/서비스 페이지 URL을 받아 기준표 v1.0(src/data/aeoGeoRubric.ts)의 auto 22항목(55점)을 자동 채점하고 src/data/discovery_.ts 초안을 생성한다. - semi(5)·manual(9) 항목은 판정하지 않는다: 'unverified' + 수집된 증거 메모만 남긴다. - 판정 근거를 얻지 못한 auto 항목은 0점이 아니라 'unverified'로 남긴다 (분모 제외 규칙). - 실측 조건은 뷰성형외과 기존 실측과 동일: JS 미실행 HTML 기준. 사용: python3 scripts/audit_aeo_geo.py https://www.viewclinic.com https://www.viewclinic.com/breast/main/ \ --id viewclinic --name 뷰성형외과 --industry "성형외과 (강남 신논현)" 옵션: --out --json --force --skip-ts 운영 플로우: 스크립트 초안 → 사람이 semi/manual 보정 + keyFindings/actions 작성 → discoveryResults.ts에 1줄 추가 → /discovery/ 리포트 자동 생성 """ from __future__ import annotations import argparse import datetime as dt import html as html_mod import json import re import shutil import subprocess import sys import urllib.parse from dataclasses import dataclass, field from html.parser import HTMLParser UA_AUDIT = "Mozilla/5.0 (compatible; INFINITH-AEO-Audit/1.0; +https://infinith-demo.vercel.app)" TIMEOUT = 20 # ── 봇 분류 (crawler_audit.py에서 계승) ────────────────────────────────────── # purpose="search" → 막히면 AI 답변 인용 불가. purpose="training" → 인용에 영향 없음. BOTS = [ {"ua": "OAI-SearchBot", "engine": "ChatGPT", "purpose": "search", "critical": True}, {"ua": "ChatGPT-User", "engine": "ChatGPT", "purpose": "user", "critical": True}, {"ua": "GPTBot", "engine": "OpenAI", "purpose": "training", "critical": False}, {"ua": "Googlebot", "engine": "Google", "purpose": "search", "critical": True}, {"ua": "Google-Extended", "engine": "Gemini", "purpose": "training", "critical": False}, {"ua": "bingbot", "engine": "Bing→ChatGPT", "purpose": "search", "critical": True}, {"ua": "PerplexityBot", "engine": "Perplexity", "purpose": "search", "critical": True}, {"ua": "Perplexity-User", "engine": "Perplexity", "purpose": "user", "critical": False}, {"ua": "Claude-SearchBot", "engine": "Claude", "purpose": "search", "critical": True}, {"ua": "ClaudeBot", "engine": "Anthropic", "purpose": "training", "critical": False}, {"ua": "Applebot", "engine": "Apple", "purpose": "search", "critical": False}, {"ua": "meta-externalagent", "engine": "Meta AI", "purpose": "training", "critical": False}, ] # WordPress 주요 버전 출시일 (F2 CMS 최신성 판정용) WP_RELEASES = { "5.0": "2018-12", "5.1": "2019-02", "5.2": "2019-05", "5.3": "2019-11", "5.4": "2020-03", "5.5": "2020-08", "5.6": "2020-12", "5.7": "2021-03", "5.8": "2021-07", "5.9": "2022-01", "6.0": "2022-05", "6.1": "2022-11", "6.2": "2023-03", "6.3": "2023-08", "6.4": "2023-11", "6.5": "2024-04", "6.6": "2024-07", "6.7": "2024-11", "6.8": "2025-04", } # ═════════════════════════════════════════ fetch (curl 기반) ═════════════════ # macOS 시스템 파이썬은 루트 인증서를 못 찾아 SSL 검증에 실패하는 경우가 잦다. # curl은 OS 키체인을 쓰므로 curl을 1순위 백엔드로 쓴다 (crawler_audit.py와 동일 판단). @dataclass class Resp: url: str status: int = 0 headers: dict = field(default_factory=dict) body: str = "" body_bytes: int = 0 ttfb: float = 0.0 final_url: str = "" error: str = "" @property def ok(self) -> bool: return 200 <= self.status < 300 def fetch(url: str, ua: str = UA_AUDIT, follow: bool = True, body: bool = True) -> Resp: if not shutil.which("curl"): return Resp(url=url, error="curl 없음") marker = "\n__META__" cmd = ["curl", "-sS", "--max-time", str(TIMEOUT), "-A", ua, "-D", "-", # 헤더를 stdout에 포함 "-w", f"{marker}%{{http_code}}\t%{{time_starttransfer}}\t%{{url_effective}}\t%{{size_download}}"] if follow: cmd.append("-L") if not body: cmd += ["-o", "/dev/null"] cmd.append(url) try: p = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT + 15) except Exception as e: return Resp(url=url, error=f"{type(e).__name__}: {e}") out = p.stdout.decode("utf-8", errors="replace") if marker not in out: return Resp(url=url, error=(p.stderr.decode("utf-8", "replace").strip() or "curl 실패")[:200]) raw, _, meta = out.rpartition(marker) try: code_s, ttfb_s, final_url, size_s = meta.strip().split("\t") except ValueError: return Resp(url=url, error="curl 메타 파싱 실패") # -D - 는 리다이렉트마다 헤더 블록을 쌓는다. 마지막 블록이 최종 응답 헤더. headers: dict = {} body_text = raw while body_text.lstrip().upper().startswith("HTTP/"): block, sep, rest = body_text.lstrip().partition("\r\n\r\n") if not sep: block, sep, rest = body_text.lstrip().partition("\n\n") if not sep: break hdrs = {} for line in block.splitlines()[1:]: if ":" in line: k, _, v = line.partition(":") hdrs[k.strip().lower()] = v.strip() headers = hdrs body_text = rest return Resp(url=url, status=int(code_s or 0), headers=headers, body=body_text, body_bytes=int(size_s or 0), ttfb=float(ttfb_s or 0), final_url=final_url) def status_only(url: str, ua: str) -> int: r = fetch(url, ua=ua, follow=True, body=False) return r.status # ═════════════════════════════════════════ robots.txt 파싱·판정 ══════════════ def parse_robots_groups(text: str): groups, agents, rules = [], [], [] expecting_agent = True for raw in text.splitlines(): line = raw.split("#", 1)[0].strip() if not line or ":" not in line: continue f, _, v = line.partition(":") f, v = f.strip().lower(), v.strip() if f == "user-agent": if not expecting_agent: groups.append((agents, rules)) agents, rules = [], [] expecting_agent = True agents.append(v) elif f in ("allow", "disallow"): expecting_agent = False rules.append((f, v)) if agents: groups.append((agents, rules)) return groups def _match_len(pattern: str, path: str) -> int: if pattern == "": return -1 anchored = pattern.endswith("$") core = pattern[:-1] if anchored else pattern regex = "^" + "".join(".*" if c == "*" else re.escape(c) for c in core) if anchored: regex += "$" return len(pattern) if re.match(regex, path) else -1 def robots_verdict(text: str, bot_ua: str, path: str = "/") -> dict: groups = parse_robots_groups(text) bot_lc = bot_ua.lower() specific = [g for g in groups if any(a.lower() == bot_lc for a in g[0])] wildcard = [g for g in groups if any(a == "*" for a in g[0])] applied, source = (specific, "명시적 규칙") if specific else (wildcard, "와일드카드(*)") if not applied: return {"allowed": True, "rule": None, "matched_by": "규칙 없음"} best = (-1, True, None) for _, rules in applied: for directive, value in rules: if directive == "disallow" and value == "": if best[0] < 0: best = (0, True, "Disallow: (빈 값 = 전체 허용)") continue n = _match_len(value, path) if n > best[0]: best = (n, directive == "allow", f"{directive.title()}: {value}") if best[0] < 0: return {"allowed": True, "rule": None, "matched_by": source} return {"allowed": best[1], "rule": best[2], "matched_by": source} # ═════════════════════════════════════════ HTML 파서 ═════════════════════════ class PageParser(HTMLParser): """JS 미실행 HTML에서 채점에 필요한 신호를 한 번에 수집한다.""" SKIP_TEXT_IN = {"script", "style", "noscript", "template"} def __init__(self): super().__init__(convert_charrefs=True) self.text_parts: list[str] = [] self.headings: dict[str, list[str]] = {"h1": [], "h2": [], "h3": []} self.images: list[dict] = [] self.ldjson_raw: list[str] = [] self.meta: list[dict] = [] self.links: list[dict] = [] # 태그 self.anchors: list[str] = [] # href 목록 self.table_count = 0 self.has_viewport = False self._stack: list[str] = [] self._in_ldjson = False self._heading: str | None = None def handle_starttag(self, tag, attrs): a = dict(attrs) self._stack.append(tag) if tag == "script": t = (a.get("type") or "").lower() self._in_ldjson = "ld+json" in t if self._in_ldjson: self.ldjson_raw.append("") elif tag in ("h1", "h2", "h3"): self._heading = tag self.headings[tag].append("") elif tag == "img": self.images.append({"src": a.get("src", ""), "alt": a.get("alt")}) elif tag == "meta": self.meta.append(a) if (a.get("name") or "").lower() == "viewport": self.has_viewport = True elif tag == "link": self.links.append(a) elif tag == "a" and a.get("href"): self.anchors.append(a["href"]) elif tag == "table": self.table_count += 1 def handle_endtag(self, tag): while self._stack and self._stack[-1] != tag: self._stack.pop() if self._stack: self._stack.pop() if tag == "script": self._in_ldjson = False if tag in ("h1", "h2", "h3"): self._heading = None def handle_data(self, data): if self._in_ldjson: self.ldjson_raw[-1] += data return if self._stack and self._stack[-1] in self.SKIP_TEXT_IN: return if self._heading and self.headings[self._heading]: self.headings[self._heading][-1] += data t = data.strip() if t: self.text_parts.append(t) # ── 수집 결과 접근자 ── @property def text(self) -> str: return " ".join(self.text_parts) def heading_list(self, tag: str) -> list[str]: return [re.sub(r"\s+", " ", h).strip() for h in self.headings[tag] if re.sub(r"\s+", " ", h).strip()] def parse_page(html: str) -> PageParser: p = PageParser() try: p.feed(html) except Exception: pass # 파서가 중간에 죽어도 그때까지 수집한 것으로 진행 return p def parse_ldjson_blocks(parser: PageParser): """(유효 블록 리스트, 오류 리스트[(index, msg)])""" valid, errors = [], [] for i, raw in enumerate(parser.ldjson_raw): raw = raw.strip() if not raw: continue try: valid.append(json.loads(raw)) except json.JSONDecodeError as e: errors.append((i, f"{e.msg}: line {e.lineno} column {e.colno}")) return valid, errors def iter_schema_nodes(blocks): """@graph·배열·중첩을 평탄화해 모든 스키마 노드를 순회한다.""" stack = list(blocks) while stack: node = stack.pop() if isinstance(node, list): stack.extend(node) elif isinstance(node, dict): yield node if "@graph" in node: stack.append(node["@graph"]) def node_types(node) -> list[str]: t = node.get("@type", []) return [t] if isinstance(t, str) else [x for x in t if isinstance(x, str)] # ═════════════════════════════════════════ 신호 추출 유틸 ════════════════════ PHONE_RE = re.compile(r"(?:0\d{1,2}|1[568]\d\d)[-.\s)]{0,2}\d{3,4}[-.\s]?\d{4}") ADDR_RE = re.compile( r"(?:서울특별시|서울시|서울|부산광역시|부산|대구광역시|대구|인천광역시|인천|광주광역시|" r"대전광역시|대전|울산광역시|울산|세종특별자치시|세종|경기도|경기|강원특별자치도|강원|" r"충청북도|충북|충청남도|충남|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|" r"제주특별자치도|제주)" r"[가-힣\d\s]{1,20}(?:로|길)\s?\d+(?:[-–]\d+)?" r"(?:[,\s]{0,2}[가-힣A-Za-z\d\s()]{0,20})?") ADDR_TRAIL_STOP = re.compile( r"\s*(카카오톡|카카오|공유|약도|지도|전화|문자|팩스|대표|바로가기|오시는\s?길|찾아오|상담|예약|TEL|FAX).*$", re.I) def extract_addresses(text: str) -> list[str]: found = [] for m in ADDR_RE.finditer(text): s = re.sub(r"\s+", " ", m.group(0)).strip(" ,") # 주소 뒤에 이어붙은 UI 텍스트(약도·전화·공유 버튼 라벨)를 잘라낸다 s = ADDR_TRAIL_STOP.sub("", s).strip(" ,") found.append(s[:60]) uniq = [] for s in found: if s not in uniq: uniq.append(s) return uniq def extract_phones(text: str) -> list[str]: uniq = [] for m in PHONE_RE.finditer(text): s = re.sub(r"[\s.)]", "", m.group(0)).replace("–", "-") digits = re.sub(r"\D", "", s) if len(digits) < 9 or len(digits) > 11: continue if digits not in [re.sub(r"\D", "", u) for u in uniq]: uniq.append(s if "-" in s else digits) return uniq def host_of(url: str) -> str: return urllib.parse.urlparse(url).netloc.lower() # ═════════════════════════════════════════ 채점기 본체 ═══════════════════════ class Auditor: def __init__(self, main_url: str, service_url: str, name: str): self.main_url = main_url if "://" in main_url else "https://" + main_url self.service_url = service_url if "://" in service_url else "https://" + service_url self.name = name self.results: list[dict] = [] self.notes: list[str] = [] def add(self, cid: str, level, evidence: str, source: str | None = None): r = {"criterionId": cid, "level": level, "evidence": evidence} if source: r["source"] = source self.results.append(r) lv = level if level != "unverified" else "미검증" print(f" {cid:<4} {str(lv):<6} {evidence[:88]}") # ── 데이터 수집 ── def collect(self): print(f"\n[1/3] 데이터 수집: {self.main_url}") self.main = fetch(self.main_url) self.svc = fetch(self.service_url) if not self.main.ok: sys.exit(f"메인 페이지 요청 실패 ({self.main.status} {self.main.error}) — 감사를 진행할 수 없습니다") if not self.svc.ok: print(f" ⚠️ 시술 페이지 {self.svc.status} {self.svc.error} — 메인 페이지만으로 진행") self.mp = parse_page(self.main.body) self.sp = parse_page(self.svc.body) if self.svc.ok else parse_page("") parsed = urllib.parse.urlparse(self.main.final_url or self.main_url) self.origin = f"{parsed.scheme}://{parsed.netloc}" self.robots = fetch(self.origin + "/robots.txt") # AI 봇 UA 실제 GET (검색봇 2 + 학습봇 1 — 뷰성형외과 실측과 동일 표본) self.bot_gets = {} for ua_name, ua_str in [ ("OAI-SearchBot", "Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)"), ("PerplexityBot", "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"), ("GPTBot", "Mozilla/5.0 (compatible; GPTBot/1.1; +https://openai.com/gptbot)"), ]: self.bot_gets[ua_name] = status_only(self.main_url, ua_str) self.ld_valid_m, self.ld_err_m = parse_ldjson_blocks(self.mp) self.ld_valid_s, self.ld_err_s = parse_ldjson_blocks(self.sp) self.all_nodes = list(iter_schema_nodes(self.ld_valid_m + self.ld_valid_s)) self.sitemap = self._collect_sitemap() def _collect_sitemap(self) -> dict: """robots.txt Sitemap 선언 → 200 → lastmod 수집 (index면 자식 최대 6개)""" declared = [] if self.robots.ok: for line in self.robots.body.splitlines(): if line.strip().lower().startswith("sitemap:"): declared.append(line.split(":", 1)[1].strip()) url = declared[0] if declared else self.origin + "/sitemap.xml" r = fetch(url) info = {"declared": bool(declared), "url": url, "status": r.status, "lastmods": [], "url_count": 0} if not r.ok: return info def scan(xml: str): info["lastmods"] += re.findall(r"([^<]+)", xml) info["url_count"] += len(re.findall(r"", xml)) return re.findall(r"\s*([^<]+)", xml, re.S) children = scan(r.body) for child in children[:6]: cr = fetch(child.strip()) if cr.ok: scan(cr.body) return info def latest_lastmod(self): dates = [] for s in self.sitemap["lastmods"]: m = re.match(r"(\d{4})-(\d{2})-(\d{2})", s.strip()) if m: try: dates.append(dt.date(int(m[1]), int(m[2]), int(m[3]))) except ValueError: pass return max(dates) if dates else None # ── A. 접근성 ── def score_A(self): print("\n[2/3] 자동 채점 (auto 22항목)") rb_text = self.robots.body if self.robots.ok else "" # A1 검색용 AI 봇 허용 rows = [{**b, **robots_verdict(rb_text, b["ua"])} for b in BOTS] search_blocked = [r["ua"] for r in rows if r["purpose"] == "search" and not r["allowed"]] train_blocked = [r["ua"] for r in rows if r["purpose"] != "search" and not r["allowed"]] gets_ok = all(200 <= s < 300 for s in self.bot_gets.values()) gets_str = "·".join(f"{k} {v}" for k, v in self.bot_gets.items()) rb_summary = "" if self.robots.ok: ua_lines = [l.strip() for l in rb_text.splitlines() if l.strip().lower().startswith(("user-agent", "disallow"))][:2] rb_summary = "robots.txt: " + " / ".join(ua_lines) if len(search_blocked) >= 2: self.add("A1", 0, f"검색봇 {len(search_blocked)}개 차단: {', '.join(search_blocked)}. 봇 GET: {gets_str}", rb_summary or None) elif len(search_blocked) == 1: self.add("A1", 1, f"검색봇 1개 차단: {search_blocked[0]}. 봇 GET: {gets_str}", rb_summary or None) elif train_blocked: self.add("A1", 2, f"검색봇 전부 허용, 학습봇 차단: {', '.join(train_blocked)}. 봇 GET: {gets_str}", rb_summary or None) elif gets_ok: self.add("A1", 3, f"{len(BOTS)}개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200", rb_summary or None) else: self.add("A1", 1, f"robots는 전부 허용이나 봇 UA GET 실패: {gets_str}", rb_summary or None) # A2 CDN/WAF AI 차단 없음 server = self.main.headers.get("server", "") is_cf = "cf-ray" in self.main.headers or "cloudflare" in server.lower() cdn_hint = is_cf or any(h in self.main.headers for h in ("x-akamai-transformed", "x-amz-cf-id", "x-cache")) bot_blocked = [k for k, v in self.bot_gets.items() if v in (403, 429, 503)] if bot_blocked and len(bot_blocked) == len(self.bot_gets): self.add("A2", 0, f"AI UA 전부 차단/챌린지: {', '.join(f'{k} {self.bot_gets[k]}' for k in bot_blocked)}") elif bot_blocked: self.add("A2", 1, f"일부 AI UA 차단: {', '.join(f'{k} {self.bot_gets[k]}' for k in bot_blocked)}") elif cdn_hint: self.add("A2", 2, f"CDN 사용({'Cloudflare' if is_cf else server or 'CDN 헤더'}), AI UA 차단 없음(전부 200)") else: self.add("A2", 3, f"Server: {server or '미표기'} 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음") # A3 서버 렌더링 본문 (JS 미실행 텍스트 길이 휴리스틱 — 렌더 후 비율은 미측정) tlen = len(self.mp.text) spa_shell = tlen < 200 and re.search(r'id=["\'](root|app|__next)["\']', self.main.body or "") if spa_shell or tlen < 200: self.add("A3", 0, f"JS 미실행 HTML 본문 텍스트 {tlen:,}자 — SPA 빈 셸로 판단") elif tlen < 800: self.add("A3", 1, f"JS 미실행 HTML 본문 텍스트 {tlen:,}자 — 본문 대부분이 JS 의존으로 추정") elif tlen < 3000: self.add("A3", 2, f"JS 미실행 HTML 본문 텍스트 {tlen:,}자 추출") else: self.add("A3", 3, f"서버 렌더링. JS 미실행 HTML에서 본문 텍스트 {tlen:,}자 추출") # A4 HTTPS·리다이렉트 정합 (4변형 체인 추적) host = host_of(self.main.final_url or self.main_url) bare = host[4:] if host.startswith("www.") else host variants = [f"http://{bare}/", f"http://www.{bare}/", f"https://{bare}/", f"https://www.{bare}/"] finals, codes = [], [] for v in variants: chain = self._redirect_chain(v) if chain: finals.append(chain[-1][1]) codes += [c for c, _ in chain[:-1]] finals_norm = {f.rstrip("/") for f in finals if f} if not finals or any(f.startswith("http://") for f in finals_norm): self.add("A4", 0, f"HTTPS 정본 수렴 실패: 최종 URL {sorted(finals_norm)}") elif len(finals_norm) > 1: self.add("A4", 1, f"변형 간 최종 URL 불일치(중복 콘텐츠 위험): {sorted(finals_norm)}") elif any(c == 302 for c in codes): self.add("A4", 2, f"{variants[0]} → 최종 {finals[0]} 수렴하나 일부 302 사용 (301 권장)") else: self.add("A4", 3, f"{variants[0].rstrip('/')} → 301 → {finals[0]} 정본 수렴 (4변형 모두)") # A5 sitemap.xml 제공 sm = self.sitemap last = self.latest_lastmod() if sm["status"] != 200: self.add("A5", 0, f"sitemap 없음 ({sm['url']} → {sm['status'] or '요청 실패'})") elif not sm["declared"]: self.add("A5", 1, f"/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL {sm['url_count']}개") else: fresh = last and (dt.date.today() - last).days <= 180 ev = f"robots.txt에 sitemap 선언, 200 응답. URL {sm['url_count']}개" ev += f", lastmod 최신 {last}" if last else ", lastmod 없음" self.add("A5", 3 if fresh else 2, ev + ("" if fresh else " (6개월 초과 또는 없음)")) # A7 응답 속도·크롤 예산 ttfb, size = self.main.ttfb, self.main.body_bytes kb = size / 1024 ev = f"TTFB {ttfb:.3f}s / 초기 HTML {kb:,.0f}KB" if self.svc.ok: ev += f". 시술 페이지 {self.svc.body_bytes/1024:,.0f}KB" if ttfb > 2: self.add("A7", 0, ev + " — TTFB 2s 초과") elif ttfb > 1 or size > 1_048_576: self.add("A7", 1, ev + " — TTFB 1s 초과 또는 HTML 1MB 초과") elif size > 307_200: self.add("A7", 2, ev + " (TTFB 양호 / HTML 비대)") elif ttfb < 0.5: self.add("A7", 3, ev + " — 모두 기준 이내") else: self.add("A7", 2, ev) def _redirect_chain(self, url: str, max_hops: int = 6): chain = [] cur = url for _ in range(max_hops): r = fetch(cur, follow=False, body=False) if r.status == 0: return chain loc = r.headers.get("location", "") if 300 <= r.status < 400 and loc: nxt = urllib.parse.urljoin(cur, loc) chain.append((r.status, nxt)) cur = nxt else: chain.append((r.status, cur)) return chain return chain # ── B. 엔티티 ── def score_B(self): n_blocks = len([b for b in self.mp.ldjson_raw + self.sp.ldjson_raw if b.strip()]) n_valid = len(self.ld_valid_m) + len(self.ld_valid_s) errs = self.ld_err_m + self.ld_err_s has_graph = any("@graph" in b for b in self.ld_valid_m + self.ld_valid_s if isinstance(b, dict)) # B1 JSON-LD 문법 유효성 if n_blocks == 0: self.add("B1", 0, "JSON-LD 블록 없음") elif n_valid == 0: self.add("B1", 0, f"JSON-LD {n_blocks}블록 전부 문법 오류로 파싱 실패", errs[0][1] if errs else None) elif errs: self.add("B1", 1, f"JSON-LD {n_blocks}블록 중 {len(errs)}블록 파싱 실패 → 해당 구조화 데이터 무효", errs[0][1]) elif has_graph: self.add("B1", 3, f"JSON-LD {n_blocks}블록 전부 유효 + @graph 연결") else: self.add("B1", 2, f"JSON-LD {n_blocks}블록 전부 유효 (@graph 미사용)") # B2 업종 엔티티 스키마 biz_types = ("MedicalClinic", "MedicalBusiness", "LegalService", "LocalBusiness", "Dentist", "Hospital", "MedicalOrganization") biz = [n for n in self.all_nodes if any(t in biz_types for t in node_types(n))] org = [n for n in self.all_nodes if "Organization" in node_types(n)] types_found = sorted({t for n in self.all_nodes for t in node_types(n)}) if biz: node = biz[0] has_addr = bool(node.get("address")) has_tel = bool(node.get("telephone")) rich = bool(node.get("openingHours") or node.get("openingHoursSpecification")) and \ bool(node.get("medicalSpecialty") or node.get("areaServed") or node.get("geo")) t = node_types(node)[0] if has_addr and has_tel and rich: self.add("B2", 3, f"@type {t} + 주소·전화·openingHours·전문분야/geo 구조화") elif has_addr and has_tel: self.add("B2", 2, f"@type {t} + 주소·전화. openingHours/전문분야/geo 없음") else: self.add("B2", 1, f"@type {t} 존재하나 주소·전화 미구조화 (address={has_addr}, telephone={has_tel})") elif org: self.add("B2", 1, f"{' + '.join(types_found[:4]) or 'Organization'}만 존재. " "MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음") else: self.add("B2", 0, "업종 엔티티 스키마 없음" + (f" (발견 타입: {', '.join(types_found[:4])})" if types_found else "")) # B3 전문가 스키마 pros = [n for n in self.all_nodes if any(t in ("Physician", "Attorney", "Person") and t != "Person" or t in ("Physician", "Attorney") for t in node_types(n))] if not pros: self.add("B3", 0, "Physician/Attorney 스키마 없음. 전문가 정보는 HTML 텍스트·이미지로만 존재") else: node = pros[0] has_spec = bool(node.get("medicalSpecialty") or node.get("knowsAbout")) has_rich = bool(node.get("worksFor") or node.get("sameAs") or node.get("hasCredential")) if has_spec and has_rich: self.add("B3", 3, f"Physician/Attorney 스키마 {len(pros)}개 + 전문분야·소속/sameAs") elif has_spec: self.add("B3", 2, f"Physician/Attorney 스키마 {len(pros)}개 + 전문분야") else: self.add("B3", 1, f"Physician/Attorney 스키마 {len(pros)}개, 이름 수준만") # B4 NAP 사이트 내 일관성 all_text = self.mp.text + " " + self.sp.text addrs = extract_addresses(all_text) phones = extract_phones(all_text) # 도로명+번지가 같은 주소끼리 묶어 '같은 주소의 표기 변형'을 센다 core_groups: dict[str, list[str]] = {} for a in addrs: m = re.search(r"([가-힣]+(?:로|길)\s?\d+)", a) core_groups.setdefault(m.group(1).replace(" ", "") if m else a, []).append(a) main_group = max(core_groups.values(), key=len) if core_groups else [] n_var = len(main_group) if main_group else 0 ph_ev = f"전화 {len(phones)}종: {', '.join(phones[:3])}" if phones else "전화 미표기" if not addrs and not phones: self.add("B4", 0, "주소·전화 텍스트 미표기 (이미지 안에만 있을 가능성)") elif n_var >= 3: self.add("B4", 1, f"주소 {n_var}가지 표기 혼재: " + " / ".join(f'"{a}"' for a in main_group[:3]) + f". {ph_ev}") elif n_var == 2: self.add("B4", 2, f"주소 2가지 표기 혼재: " + " / ".join(f'"{a}"' for a in main_group) + f". {ph_ev}") elif n_var == 1: self.add("B4", 3, f'주소 단일 표기: "{main_group[0]}". {ph_ev}') else: self.add("B4", "unverified", f"주소 자동 추출 실패 (수동 확인 필요). {ph_ev}") # B5 sameAs 연결 same_as = [] for n in org + [x for x in self.all_nodes if x not in org]: sa = n.get("sameAs") if sa: same_as = sa if isinstance(sa, list) else [sa] break footer_sns = sorted({host_of(h) for h in self.mp.anchors if any(s in h for s in ("youtube.com", "instagram.com", "facebook.com", "blog.naver.com", "gangnamunni.com", "tiktok.com"))}) sns_note = f"푸터/본문에 SNS 링크 {len(footer_sns)}종({', '.join(footer_sns)})" if footer_sns else "페이지에 SNS 링크도 없음" n_sa = len(same_as) if n_sa == 0: has_org_schema = bool(org or self.all_nodes) ev = ("Organization.sameAs: [] (빈 배열). " if has_org_schema else "스키마 자체가 없어 sameAs 부재. ") + sns_note + " — 스키마에 미연결" self.add("B5", 0, ev) elif n_sa <= 2: self.add("B5", 1, f"sameAs {n_sa}개: {', '.join(map(str, same_as))}") elif n_sa <= 4: self.add("B5", 2, f"sameAs {n_sa}개: {', '.join(map(str, same_as))}") else: self.add("B5", 3, f"sameAs {n_sa}개 (SNS·버티컬·지도 연결)") # B6 canonical canon = [l.get("href", "") for l in self.mp.links if (l.get("rel") or "").lower() == "canonical"] if not canon: self.add("B6", 0, "rel=canonical 없음") else: self_ref = canon[0].rstrip("/") in {self.main_url.rstrip("/"), (self.main.final_url or "").rstrip("/")} if self_ref: self.add("B6", 2, "rel=canonical self-referencing 존재") else: self.add("B6", 1, f"rel=canonical 존재하나 불일치: {canon[0]}") # B7 다국어 hreflang hreflangs = {(l.get("hreflang") or "").lower() for l in self.mp.links + self.sp.links if (l.get("rel") or "").lower() == "alternate" and l.get("hreflang")} hreflangs.discard("x-default") multilang_hint = None for h in self.mp.anchors: hl = h.lower() if re.search(r"/(en|eng|english|cn|zh|jp|ja)(/|$|\.)", hl) or "english" in hl: if host_of(h) != host_of(self.main_url) or re.search(r"/(en|eng|cn|zh|jp|ja)(/|$)", hl): multilang_hint = h break if not multilang_hint: for t in self.mp.text_parts: if re.fullmatch(r"(ENGLISH|English|中文|日本語|ENG)", t.strip()): multilang_hint = f'텍스트 "{t.strip()}"' break n_hl = len(hreflangs) if n_hl >= 3: self.add("B7", 3, f"hreflang {n_hl}개 언어 연결: {', '.join(sorted(hreflangs))}") elif n_hl == 2: self.add("B7", 2, f"hreflang 2개 언어 연결: {', '.join(sorted(hreflangs))}") elif multilang_hint: self.add("B7", 0, f"다국어 페이지 흔적({multilang_hint}) 있으나 hreflang 없음 — 미연결") else: self.add("B7", 1, "hreflang 없음, 다국어 페이지 흔적도 탐지 안 됨 (단일 언어)") # ── C. 콘텐츠 (auto: C2·C3·C5·C6 / semi: C1·C4·C7) ── def score_C(self): all_text = self.mp.text + " " + self.sp.text heads = (self.mp.heading_list("h1") + self.mp.heading_list("h2") + self.mp.heading_list("h3") + self.sp.heading_list("h1") + self.sp.heading_list("h2") + self.sp.heading_list("h3")) # C1 (semi) 정의문형 첫 문단 — 증거 수집만 first_main = self.mp.text[:200] first_svc = self.sp.text[:200] name_in_first = self.name in first_main self.add("C1", "unverified", f"[semi·사람 판정 필요] 메인 첫 200자: \"{first_main[:100]}…\" / 시술 페이지 첫 200자: \"{first_svc[:80]}…\" " f"(첫 200자 내 상호 {'있음' if name_in_first else '없음'})") # C2 FAQ 섹션 has_faq_schema = any("FAQPage" in node_types(n) for n in self.all_nodes) faq_head = [h for h in heads if re.search(r"FAQ|자주\s?묻는|자주\s?하는\s?질문|궁금", h, re.I)] # 게시판형 Q&A만 잡는다. 상담 폼(counsel)·후기 게시판(review, kboard 범용)은 Q&A가 아니다. board_qna = [h for h in self.mp.anchors + self.sp.anchors if re.search(r"(^|[/._-])(qna|q-?and-?a|faq)([/._?-]|$)", h, re.I)] if has_faq_schema and faq_head: self.add("C2", 3, f"FAQ 섹션({faq_head[0]}) + FAQPage 스키마 존재") elif has_faq_schema: self.add("C2", 3, "FAQPage 스키마 존재 (섹션 헤딩은 미탐지)") elif faq_head: self.add("C2", 2, f"FAQ 섹션 존재: \"{faq_head[0]}\" 외 {len(faq_head)-1}개. FAQPage 스키마 없음") elif board_qna: self.add("C2", 1, f"게시판형 Q&A 링크만 존재 ({board_qna[0][:60]})") else: self.add("C2", 0, "FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·시술 페이지)") # C3 제목 계층 의미성 h1_m = self.mp.heading_list("h1") h2_m = self.mp.heading_list("h2") h1_s = self.sp.heading_list("h1") dup_h2 = len(h2_m) - len(set(h2_m)) def descriptive(h: str) -> bool: return len(h) >= 8 and bool(re.search(r"[가-힣]", h)) and not h.isupper() h1_desc = ", ".join(f'"{h}"' for h in h1_m[:5]) or "없음" svc_note = "" if h1_s else ". 시술 페이지 H1 없음" if len(h1_m) == 0 or len(h1_m) >= 5: self.add("C3", 0, f"메인 H1 {len(h1_m)}개: {h1_desc}" + (f". H2 중복 {dup_h2}건" if dup_h2 else "") + svc_note) elif len(h1_m) >= 2: self.add("C3", 1, f"메인 H1 {len(h1_m)}개(라벨형 다수): {h1_desc}" + svc_note) elif descriptive(h1_m[0]) and dup_h2 == 0: self.add("C3", 3, f'H1 1개 서술형: "{h1_m[0]}". H2 {len(h2_m)}개 중복 없음' + svc_note) else: self.add("C3", 2, f'H1 1개: "{h1_m[0]}"' + (f". H2 중복 {dup_h2}건 또는 라벨형" if dup_h2 or not descriptive(h1_m[0]) else "") + svc_note) # C4 (semi) 구체 수치 — 밀도 집계만 svc_text = self.sp.text or self.mp.text n_price = len(re.findall(r"\d[\d,]*\s*(?:만\s?원|원)", svc_text)) n_time = len(re.findall(r"\d+\s*(?:분|시간)", svc_text)) n_recover = len(re.findall(r"\d+\s*(?:일|주|개월)", svc_text)) n_pct = len(re.findall(r"\d+(?:\.\d+)?\s*%", svc_text)) kw = len(re.findall(r"가격|비용|수술\s?시간|회복", svc_text)) self.add("C4", "unverified", f"[semi·사람 판정 필요] 시술 페이지 수치 밀도: 가격표기 {n_price}회, 시간 {n_time}회, " f"일/주 {n_recover}회, % {n_pct}회, 가격/비용/회복 단어 {kw}회 — 표기 맥락(마케팅 문구 vs 실제 표) 확인 필요") # C5 표·비교 구조 n_tables = self.mp.table_count + self.sp.table_count if n_tables == 0: self.add("C5", 0, "HTML 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)") elif n_tables <= 2: self.add("C5", 2, f"HTML 표 {n_tables}개") else: self.add("C5", 3, f"HTML 표 {n_tables}개") # C6 신선도 신호 last = self.latest_lastmod() mod_meta = next((m.get("content") for m in self.mp.meta + self.sp.meta if (m.get("property") or m.get("name") or "").lower() == "article:modified_time"), None) date_modified = next((n.get("dateModified") for n in self.all_nodes if n.get("dateModified")), None) signals = [] best_date = None for label, val in [("article:modified_time", mod_meta), ("dateModified", date_modified), ("sitemap lastmod", str(last) if last else None)]: if val: m = re.match(r"(\d{4})-(\d{2})-(\d{2})", str(val)) if m: d = dt.date(int(m[1]), int(m[2]), int(m[3])) signals.append(f"{label} {d}") best_date = max(best_date, d) if best_date else d if not signals: self.add("C6", "unverified", "article:modified_time·dateModified·sitemap lastmod 전부 없음 — 갱신일 판정 근거 없음 (수동 확인 필요)") else: age = (dt.date.today() - best_date).days ev = f"최신 갱신 신호 {best_date} ({', '.join(signals)})" has_markup = bool(mod_meta or date_modified) if age > 730: self.add("C6", 0, ev + f" — {age//30}개월 전, 2년 이상 갱신 없음") elif age > 365: self.add("C6", 1, ev + f" — {age//30}개월 전" + ("" if has_markup else ". dateModified 마크업 없음")) elif age > 90 or not has_markup: self.add("C6", 2, ev + ("" if has_markup else ". dateModified 마크업 없음")) else: self.add("C6", 3, ev + " + dateModified 표기") # C7 (semi) 전문가 E-E-A-T — 신호 집계만 n_expert = len(re.findall(r"전문의|원장|의료진|대표변호사|변호사", all_text)) auth_kw = re.findall(r"학회|논문|표창|수상|KOL|자격|인증|위촉|교수", all_text) self.add("C7", "unverified", f"[semi·사람 판정 필요] 전문가 언급 {n_expert}회, 권위 키워드 {len(auth_kw)}회" + (f" ({', '.join(sorted(set(auth_kw))[:5])})" if auth_kw else "") + " — 자격·경력이 텍스트인지 이미지인지 확인 필요") # C8 (manual) self.add("C8", "unverified", "[manual] 핵심 페이지 단락 샘플 10개의 상호·시술명 포함 비율 수동 채점 필요") # ── D. 표면 (auto: D6 / semi: D3·D5 / manual: D1·D2·D4) ── def score_D(self): self.add("D1", "unverified", "[manual] 업종 버티컬(병의원: 강남언니 / 법률: 로톡) 프로필 수동 실측 필요 — 후기 수·의료진·이벤트·갱신일") self.add("D2", "unverified", "[manual] Google Maps 검색으로 GBP 소유·리뷰·NAP 수동 실측 필요") # D3 (semi) 오픈웹 언급 — 자체 페이지에서 보이는 외부 표면만 수집 ext = {} for h in self.mp.anchors + self.sp.anchors: d = host_of(h) if d and d != host_of(self.main_url): ext[d] = ext.get(d, 0) + 1 known = {d: c for d, c in ext.items() if any(s in d for s in ("youtube", "instagram", "facebook", "naver", "gangnamunni", "tiktok", "kakao"))} self.add("D3", "unverified", "[semi·사람 판정 필요] 검색 그라운딩 미실시. 사이트에서 발견된 외부 채널: " + (", ".join(f"{d}({c})" for d, c in sorted(known.items(), key=lambda x: -x[1])[:6]) or "없음") + " — naver.com 제외 오픈웹 언급(티스토리·언론)은 검색으로 별도 집계 필요") self.add("D4", "unverified", "[manual] 홈페이지·버티컬·GBP·플레이스 NAP 대조표 수동 작성 필요") # D5 (semi) 네이버 플레이스 place_link = next((h for h in self.mp.anchors if "place.naver" in h or "map.naver" in h or "naver.me" in h), None) self.add("D5", "unverified", "[semi·사람 판정 필요] " + (f"사이트에 플레이스/지도 링크 발견: {place_link[:70]}" if place_link else "사이트에 네이버 플레이스 링크 없음") + " — 플레이스 존재·홈페이지 역연결·리뷰 수 수동 확인") # D6 위키데이터 (auto) try: q = urllib.parse.quote(self.name) r = fetch(f"https://www.wikidata.org/w/api.php?action=wbsearchentities&search={q}&language=ko&uselang=ko&format=json&limit=5") data = json.loads(r.body) if r.ok else {} hits = [h for h in data.get("search", []) if self.name.replace(" ", "") in (h.get("label", "") + h.get("match", {}).get("text", "")).replace(" ", "")] if not r.ok: self.add("D6", "unverified", f"Wikidata API 조회 실패 ({r.status or r.error})") elif not hits: self.add("D6", 0, f'Wikidata에서 "{self.name}" 검색 결과 없음') else: qid = hits[0]["id"] er = fetch(f"https://www.wikidata.org/wiki/Special:EntityData/{qid}.json") official = False if er.ok: try: claims = json.loads(er.body)["entities"][qid].get("claims", {}) official = "P856" in claims except Exception: pass self.add("D6", 3 if official else 2, f"Wikidata 항목 존재: {qid}" + (" + 공식 사이트(P856) 연결" if official else " (공식 사이트 미연결)"), f"https://www.wikidata.org/wiki/{qid}") except Exception as e: self.add("D6", "unverified", f"Wikidata 조회 오류: {type(e).__name__}") # ── E. 측정 (전부 manual) ── def score_E(self): self.add("E1", "unverified", "[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)") self.add("E2", "unverified", "[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)") self.add("E3", "unverified", "[manual] GSC 접근 필요 (AI Overviews 노출)") self.add("E4", "unverified", "[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)") # ── F. 위생 ── def score_F(self): # F1 이미지 대체텍스트 (뷰성형외과 실측과 동일하게 메인 페이지 기준) content_imgs = [i for i in self.mp.images if i["src"] and not i["src"].startswith("data:")] if not content_imgs: self.add("F1", "unverified", "이미지 태그 미탐지 — 수동 확인 필요") else: missing = [i for i in content_imgs if not (i["alt"] or "").strip()] pct = len(missing) / len(content_imgs) * 100 ev = f"메인 페이지 이미지 {len(content_imgs)}개 중 {len(missing)}개({pct:.0f}%) alt 누락 또는 빈 값" self.add("F1", 0 if pct >= 50 else 1 if pct >= 20 else 2 if pct >= 5 else 3, ev) # F2 CMS 보안·최신성 gen = next((m.get("content") for m in self.mp.meta if (m.get("name") or "").lower() == "generator"), None) if not gen: self.add("F2", "unverified", "meta generator 없음 — CMS 종류·버전 판정 근거 없음 (수동 확인 필요)") else: m = re.search(r"WordPress\s+(\d+\.\d+)", gen) if m: rel = WP_RELEASES.get(m.group(1)) if rel: ry, rm = int(rel[:4]), int(rel[5:7]) age_y = (dt.date.today() - dt.date(ry, rm, 1)).days / 365 lvl = 0 if age_y >= 3 else 1 if age_y >= 1 else 2 latest = max(WP_RELEASES) if m.group(1) == latest: lvl = 3 self.add("F2", lvl, f"meta generator: {gen} ({rel} 출시, {age_y:.0f}년 전)" + (". 보안 패치 미적용 위험" if lvl == 0 else "")) else: self.add("F2", "unverified", f"meta generator: {gen} — 출시일 미상, 수동 판정 필요") else: self.add("F2", "unverified", f"meta generator: {gen} — CMS 버전 판정 기준 없음, 수동 판정 필요") # F3 페이지 무게 kb = self.main.body_bytes / 1024 ev = f"초기 HTML {kb:,.0f}KB" if kb > 2048: self.add("F3", 0, ev + " (2MB 초과)") elif kb > 800: self.add("F3", 1, ev + f" (기준 300KB의 {kb/300:.1f}배)") elif kb > 300: self.add("F3", 2, ev + " (300~800KB)") else: self.add("F3", 3, ev + " (300KB 미만)") # F4 모바일 대응 (CWV 미측정 → 상한 2) m_domain = any(host_of(h).startswith("m.") and host_of(self.main_url).lstrip("www.") in host_of(h) for h in self.mp.anchors) if not self.mp.has_viewport: self.add("F4", 0, "viewport meta 없음") elif m_domain: self.add("F4", 1, "별도 m. 도메인 사용") else: self.add("F4", 2, "viewport meta 존재, 반응형. CWV 미측정") # ── manual A6 ── def score_manual_A(self): self.add("A6", "unverified", "[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)") def run(self): self.collect() self.score_A() self.score_manual_A() self.score_B() self.score_C() self.score_D() self.score_E() self.score_F() order = ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "D1", "D2", "D3", "D4", "D5", "D6", "E1", "E2", "E3", "E4", "F1", "F2", "F3", "F4"] self.results.sort(key=lambda r: order.index(r["criterionId"])) return self.results # ═════════════════════════════════════════ TS 초안 생성 ══════════════════════ SECTION_COMMENTS = {"A1": "A. 접근성", "B1": "B. 엔티티", "C1": "C. 콘텐츠", "D1": "D. 표면", "E1": "E. 측정", "F1": "F. 위생"} NOT_DOING = [ ("llms.txt 제작", "Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다."), ('"스키마 넣으면 AI가 인용합니다" 약속', "인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다."), ("단발 조회로 순위·노출 보고", "LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다."), ("답변엔진 UI 자동 조회", "OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다."), ("네이버 블로그 증량", "blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙."), ] def ts_str(s: str) -> str: return "'" + s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", " ") + "'" def emit_ts(results: list[dict], args, service_path: str) -> str: const = "DISCOVERY_" + re.sub(r"[^A-Za-z0-9]", "_", args.id).upper() today = dt.date.today().isoformat() lines = [ "/**", f" * {args.name} ({host_of(args.main_url)}) AEO/GEO 진단 — 자동 채점 초안", f" * 생성: audit_aeo_geo.py · {today} · JS 미실행 HTML 기준", " *", " * ⚠️ 이 파일은 초안이다. 배포 전 사람이 해야 할 일:", " * 1. [semi·사람 판정 필요] 항목 5개(C1·C4·C7·D3·D5)의 증거를 보고 level 확정", " * 2. [manual] 항목 9개 중 실측 가능한 것(D1·D2·D4) 실측, 나머지는 unverified 유지", " * 3. keyFindings(3~5개) · actions(P0~P2) 작성", " * 4. discoveryResults.ts에 등록: " + f"{args.id}: {const},", " */", "import type { DiscoveryResult } from '../types/discovery';", "", f"export const {const}: DiscoveryResult = {{", f" id: {ts_str(args.id)},", f" clinicName: {ts_str(args.name)},", f" url: {ts_str(args.main_url)},", f" industry: {ts_str(args.industry)},", f" auditedAt: {ts_str(today)},", " rubricVersion: '1.0',", " conditions: [", f" {ts_str(f'실측 대상: 메인 페이지 + {service_path} 2개 URL, JS 미실행 HTML 기준')},", " '크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',", " 'semi/manual 항목은 자동 수집 증거만 첨부하고 미검증 처리, 분모 제외 (사람 보정 후 확정)',", " '답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)',", " ],", " results: [", ] for r in results: cid = r["criterionId"] if cid in SECTION_COMMENTS: lines.append(f" // {SECTION_COMMENTS[cid]}") lvl = f"'{r['level']}'" if r["level"] == "unverified" else r["level"] row = f" {{ criterionId: {ts_str(cid)}, level: {lvl}, evidence: {ts_str(r['evidence'])}" if r.get("source"): row += f", source: {ts_str(r['source'])}" lines.append(row + " },") lines += [ " ],", " keyFindings: [", " // TODO(사람): 채점 결과를 보고 3~5개 작성. severity: 'critical' | 'warning' | 'good'", " ],", " actions: [", " // TODO(사람): P0~P2 액션 작성. effort: '1일' | '1주' | '2~4주' | '지속'", " ],", " notDoing: [", ] for item, reason in NOT_DOING: lines.append(f" {{ item: {ts_str(item)}, reason: {ts_str(reason)} }},") lines += [" ],", "};", ""] return "\n".join(lines) # ═════════════════════════════════════════ main ══════════════════════════════ def main() -> int: ap = argparse.ArgumentParser(description="INFINITH AI Discovery AEO/GEO 자동 채점기 (auto 22항목)") ap.add_argument("main_url", help="업체 메인 URL") ap.add_argument("service_url", help="대표 시술/서비스 페이지 URL") ap.add_argument("--id", required=True, help="리포트 id (라우트 /discovery/)") ap.add_argument("--name", required=True, help="상호 (예: 뷰성형외과)") ap.add_argument("--industry", default="", help='업종·지역 (예: "성형외과 (강남 신논현)")') ap.add_argument("--out", help="TS 초안 출력 경로 (기본 src/data/discovery_.ts)") ap.add_argument("--json", help="채점 결과 JSON 저장 경로") ap.add_argument("--skip-ts", action="store_true", help="TS 파일을 만들지 않고 채점 결과만 출력") ap.add_argument("--force", action="store_true", help="기존 TS 파일 덮어쓰기 허용") args = ap.parse_args() auditor = Auditor(args.main_url, args.service_url, args.name) results = auditor.run() # 실측 규칙(NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §3-10): 표본 URL이 5개 미만이면 사이트 전체로 일반화하지 않는다. # 최소 표본 = 홈 + 시술 상세 3개 + 오시는길 + 블로그 서브도메인. 이 채점기는 아직 2개(홈·대표 시술)만 읽는다. sample_urls = [args.main_url, args.service_url] sample_warning = None if len(sample_urls) < 5: sample_warning = (f"표본 URL {len(sample_urls)}개 (<5). 판정은 홈·대표 시술 페이지 기준이며 사이트 전체로 일반화하지 말 것. " f"시술 상세 3개·오시는길·블로그를 curl+파서로 추가 확인할 것 (2026-08-28 뷰성형외과 오판 3건의 원인)") print(f"\n ⚠ {sample_warning}") auto_scored = [r for r in results if r["level"] != "unverified"] unv = [r for r in results if r["level"] == "unverified"] print(f"\n[3/3] 결과: 자동 판정 {len(auto_scored)}항목 / 미검증 {len(unv)}항목 (semi·manual 포함)") if args.json: with open(args.json, "w", encoding="utf-8") as f: json.dump({"_meta": {"sampleUrls": sample_urls, "sampleWarning": sample_warning}, "results": results} if sample_warning else results, f, ensure_ascii=False, indent=2) print(f" → JSON: {args.json}") if not args.skip_ts: import os out = args.out or os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src", "data", f"discovery_{args.id}.ts") if os.path.exists(out) and not args.force: sys.exit(f" ✋ {out} 이미 존재 — 덮어쓰려면 --force") svc_path = urllib.parse.urlparse(args.service_url).path or "/" with open(out, "w", encoding="utf-8") as f: f.write(emit_ts(results, args, svc_path)) print(f" → TS 초안: {out}") print(f"\n다음 단계:") print(f" 1. 초안의 [semi·사람 판정 필요] 5항목 보정 + keyFindings/actions 작성") print(f" 2. src/data/discoveryResults.ts 에 1줄 추가") print(f" 3. npm run lint → git push → vercel --prod") return 0 if __name__ == "__main__": sys.exit(main())