- scripts/gate/rules.mjs: 운영자 어휘·구조 지문·화법·근거 type·규제 원문·결론 문장·수치 한정·확인일 일관성·검토자/검토일·영상 제목·표/체크리스트 (순수 함수) - scripts/check.mjs: dist + 소스 frontmatter + 데이터 검사 러너, --update-layout, error/warn 분리 - scripts/gate/test.mjs + fixtures 9개: 실패 재현 + 18편 정답지 회귀 + 부정문 통과 - corrections.json 「답변엔진」 표현 고객 화면에서 제거, 반말 문장 1건 수정 - audit_aeo_geo.py: 표본 URL 5개 미만 경고(§3-10) - 미결: 「채널 인기 전체」 탭은 경고만 (haewon 결정 대기) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1094 lines
54 KiB
Python
1094 lines
54 KiB
Python
#!/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_<id>.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 <path> --json <path> --force --skip-ts
|
||
|
||
운영 플로우: 스크립트 초안 → 사람이 semi/manual 보정 + keyFindings/actions 작성
|
||
→ discoveryResults.ts에 1줄 추가 → /discovery/<id> 리포트 자동 생성
|
||
"""
|
||
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] = [] # <link> 태그
|
||
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"<lastmod>([^<]+)</lastmod>", xml)
|
||
info["url_count"] += len(re.findall(r"<url>", xml))
|
||
return re.findall(r"<sitemap>\s*<loc>([^<]+)</loc>", 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 <table> 없음. 비교 정보는 이미지로 추정 (이미지 표 여부는 수동 확인)")
|
||
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/<id>)")
|
||
ap.add_argument("--name", required=True, help="상호 (예: 뷰성형외과)")
|
||
ap.add_argument("--industry", default="", help='업종·지역 (예: "성형외과 (강남 신논현)")')
|
||
ap.add_argument("--out", help="TS 초안 출력 경로 (기본 src/data/discovery_<id>.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())
|