diff --git a/docs/AI_DISCOVERY_BUILD2_HANDOVER.md b/docs/AI_DISCOVERY_BUILD2_HANDOVER.md index 1bb4077..33ac77f 100644 --- a/docs/AI_DISCOVERY_BUILD2_HANDOVER.md +++ b/docs/AI_DISCOVERY_BUILD2_HANDOVER.md @@ -77,7 +77,45 @@ E1 서버로그 AI 봇 추출, E2 GA4 AI 채널그룹, E3 GSC, A6 Bing Webmaster 계약 기반 영업 모델(PART III 피봇)과의 정합을 보고 결정한다. - 다음 진단 대상 업종·키워드 -## 5. 하지 않을 것 (정직성 규약, 변경 없음) +## 5. P0 자동 채점기 — 구현 완료 (2026-08-31) + +`scripts/audit_aeo_geo.py` 구현·검증 완료. 표준 라이브러리만 사용(의존성 없음), fetch는 curl 백엔드(macOS 인증서 문제 회피). + +### 사용법 + +```bash +python3 scripts/audit_aeo_geo.py <메인URL> <대표시술페이지URL> \ + --id <라우트id> --name <상호> --industry "<업종 (지역)>" +# 옵션: --out <경로> --json <경로> --skip-ts --force +``` + +→ `src/data/discovery_.ts` 초안 생성. auto 22항목은 판정까지, semi 5항목(C1·C4·C7·D3·D5)은 +`'unverified'` + 수집 증거 메모, manual 9항목은 `'unverified'` + 확인 방법 메모. +판정 근거를 못 얻은 auto 항목(예: meta generator 없음)은 0점이 아니라 unverified로 남긴다 (분모 제외 규칙 준수). + +### 검증 결과 (뷰성형외과 재채점, 2026-08-31) + +auto 22항목 중 **18항목 기존 실측(2026-08-28)과 일치**. 불일치 4건은 전부 스크립트가 아니라 기존 수작업의 한계였다: + +| 항목 | 기존 | 스크립트 | 원인 | +|---|---|---|---| +| A5 | 2 | 3 | 기존 실측은 post-sitemap만 봄(lastmod 2024-05). page/portfolio-sitemap에는 진짜 per-page 갱신일(2026-08) 존재 | +| B4 | 2 | 1 | 시술 페이지에 세 번째 주소 표기("서울시 강남구 봉은사로 107, 뷰성형외과빌딩(논현동…") 실재 — 기존 실측 누락 | +| C6 | 1 | 3 | 시술 페이지에 article:modified_time·dateModified(2026-04-17) 실재 — 기존 실측은 메인만 확인 | +| D6 | unverified | 0 | Wikidata API 조회 자동화됨 (기존은 미실시) | + +주의: `discovery_viewclinic.ts`는 2026-08-28 실측 기록으로 그대로 두었다. 위 4건을 반영해 뷰성형외과 점수를 +갱신할지(48/C가 소폭 오름)는 오너 결정 사항. 갱신하려면 스크립트 재실행 후 semi/manual만 기존 값 이식하면 된다. + +### 새 업체 30분 온보딩 플로우 + +1. 업체 선정(네이버 파워링크 1위 광고주 → 상호 재검색으로 도메인 확인) — 5분 +2. `audit_aeo_geo.py` 실행 → 초안 생성 — 3분 +3. semi 5항목 판정 확정(초안의 증거 메모 참고) + manual 중 공개 페이지 실측 가능한 D1·D2·D4 확인 — 15분 +4. keyFindings 3~5개 + actions P0~P2 작성, `discoveryResults.ts`에 1줄 추가 — 5분 +5. `npm run lint`(AEO-GEO-Audit 워크트리) → git push → `vercel --prod` — 2분 + +## 6. 하지 않을 것 (정직성 규약, 변경 없음) - llms.txt 제작·판매 - "스키마 넣으면 인용된다" 약속, 순위·노출 보장 문구 diff --git a/scripts/audit_aeo_geo.py b/scripts/audit_aeo_geo.py new file mode 100644 index 0000000..77c7aae --- /dev/null +++ b/scripts/audit_aeo_geo.py @@ -0,0 +1,1083 @@ +#!/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() + + 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(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())