"""`robots.txt` 를 **네이버 관점으로** 읽는다. ★ 이 파일이 보는 것과 `solution/backend/scripts/check_search_ready.py` 가 보는 것은 다르다. 겹치면 한쪽만 고쳐지는 날이 오므로 경계를 적어 둔다. check_search_ready : 이 호스트가 **AI 크롤러 전반**(GPTBot·ClaudeBot·PerplexityBot…)에 열려 있나 — 구글·AI 검색 관점 여기 : **네이버가 수집할 수 있나** — Yeti·Daumoa 허용, 사이트맵 지시, 그리고 ★ **JS·CSS 리소스를 막지 않았나** ★★ 마지막 항목이 네이버 고유다. 네이버 가이드는 robots.txt 로 JS·CSS 리소스를 막으면 **그 페이지 자체가 수집되지 않는다**고 명시한다. 우리 발행본은 정적 HTML 이라 JS 없이도 읽히지만, 자산이 막히면 네이버는 페이지를 "덜 읽은" 것이 아니라 **아예 안 가져간다.** robots 에 `/assets` 류를 막는 줄이 끼어드는 순간 조용히 전부 빠진다. ★ robots.txt 는 **오리진 루트에서만** 읽힌다(RFC 9309). `/s//robots.txt` 는 아무도 안 본다 — 그래서 여기서도 루트만 본다. """ import re import httpx from geo.naver._http import get # 네이버·다음 검색 로봇. 둘 다 이름으로 명시돼 있어야 안전하다 — # 일부 로봇은 와일드카드보다 자기 이름 규칙을 우선으로 본다. NAVER_BOTS = ("Yeti", "Daumoa") # 막히면 네이버가 페이지를 통째로 안 가져가는 자산 경로. ASSET_PREFIXES = ("/assets", "/fonts", "/static", "/_next", "/builder-assets") def _blocks(body: str) -> dict[str, list[str]]: """`User-agent` 별 Disallow 목록. 한 그룹에 UA 가 여럿일 수 있다(규격). ★ 그룹은 빈 줄로만 끊기는 게 아니다. **규칙(Disallow…) 뒤에 오는 `User-agent` 는 새 그룹의 시작**이다(RFC 9309). 빈 줄만 보고 끊으면 아래가 한 그룹이 돼 `*` 의 규칙이 Yeti 에도 붙는다 — 실제로 그렇게 잘못 읽었다. User-agent: * Disallow: /assets User-agent: Yeti ← 여기서 새 그룹 Disallow: / """ out: dict[str, list[str]] = {} agents: list[str] = [] after_rule = False for raw in body.splitlines(): line = raw.split("#", 1)[0].strip() if not line: agents, after_rule = [], False continue key, _, value = line.partition(":") key, value = key.strip().lower(), value.strip() if key == "user-agent": if after_rule: agents, after_rule = [], False agents.append(value) out.setdefault(value, []) elif key in ("disallow", "allow") and agents: after_rule = True if key == "disallow": for a in agents: out.setdefault(a, []).append(value) return out def _disallows_for(blocks: dict[str, list[str]], bot: str) -> list[str]: """그 봇에 적용되는 Disallow. 이름 규칙이 있으면 그것만, 없으면 `*` 를 따른다.""" for name, rules in blocks.items(): if name.lower() == bot.lower(): return rules return blocks.get("*", []) def judge(body: str) -> list[tuple[str, str, str]]: """`(status, label, detail)` 목록. status 는 checks.py 와 같은 문자열을 쓴다. ★ 여기서 `Finding` 을 만들지 않는다 — 이 모듈이 checks 를 import 하면 두 파일이 서로를 부르게 된다. 판정 결과만 돌려주고 `Finding` 조립은 부르는 쪽이 한다.""" out: list[tuple[str, str, str]] = [] blocks = _blocks(body) missing = [b for b in NAVER_BOTS if not any(n.lower() == b.lower() for n in blocks)] if missing: out.append(("warn", "네이버 로봇 명시 허용", f"이름이 없다: {', '.join(missing)} — 와일드카드에 기댄다")) else: out.append(("ok", "네이버 로봇 명시 허용", " · ".join(NAVER_BOTS))) for bot in NAVER_BOTS: rules = _disallows_for(blocks, bot) if "/" in rules: out.append(("fail", f"{bot} 수집 차단", "`Disallow: /` — 이 호스트 전체가 막혀 있다")) continue hit = [r for r in rules if any(r.startswith(p) for p in ASSET_PREFIXES)] if hit: out.append(( "fail", f"{bot} 자산 차단", f"JS·CSS 경로를 막고 있다({', '.join(hit)}) — 네이버는 그 페이지를 통째로 안 가져간다", )) if re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body): loc = re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body).group(1) out.append(("ok", "사이트맵 지시", loc)) else: out.append(("fail", "사이트맵 지시", "없다 — 크롤러가 사이트맵 위치를 알 방법이 없다")) return out async def fetch_and_judge(client: httpx.AsyncClient, origin: str) -> list[tuple[str, str, str]]: res = await get(client, origin.rstrip("/") + "/robots.txt") if res is None: return [("fail", "루트 robots.txt", "연결하지 못했다")] if res.status_code != 200: return [("fail", "루트 robots.txt", f"HTTP {res.status_code} — 크롤러가 읽는 유일한 자리다")] return [("ok", "루트 robots.txt", f"{len(res.text)}바이트"), *judge(res.text)]