o2o-site-AEO/geo/naver/robots.py
민헌 76d51207c6 [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행
네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 IndexNow 는 조용히 0건이었다 —
indexnow.py 가 읽는 <out>/s/<slug>/sitemap.xml 을 프리렌더가 더는 굽지 않는데
(사이트 한 장 → 루트 사이트맵 통합) 발행 잡은 경고 한 줄만 남기고 성공한다.
조사 결과 AI 브리핑 출처는 네이버 생태계 편향이라, 네이버에서의 목표를 "인용" 이 아니라
"플레이스↔홈페이지 결합 + 웹문서 검색 노출" 로 다시 잡았다(docs/NAVER_EO.md).

- geo/: solution·admin 을 고치지 않고 import 만 하는 최상단 모듈. 밖에서 HTTP 로만 본다
  - naver/checks.py: 소유확인(상태코드가 아니라 내용 — SPA 폴백이 200 을 준다) · Yeti 랜딩 ·
    통보 URL 재현 · 웹문서 색인(근사) · 스마트플레이스 역방향 링크
  - naver/robots.py: 네이버 관점 판정 — Yeti·Daumoa · 사이트맵 지시 · JS/CSS 자산 차단
    (RFC 9309 그룹 경계: 규칙 뒤의 User-agent 는 새 그룹)
  - naver/notify.py: 루트 사이트맵에서 주소를 골라 IndexNow 통보. 백엔드가 고쳐지는 날
    GEO_NOTIFY_ENABLED=0 으로 끈다(담당 중복 = 429)
  - scripts/preflight.py(발행 전·오리진) · postflight.py(발행 후·200 확인 뒤에만 통보) ·
    watch.py(사이트맵 lastmod 변화만). 상태는 성공분만 geo/state/ 에 기록
  - naver/web_search.py: 웹문서검색 호출기 — 백엔드를 못 고쳐 여기 있다. 쿼터 카운터가 둘로 갈린다
- nginx/site.conf.example: 소유확인 location = 블록(주석). 메타태그는 solution/frontend 수정이라 제외
- .env.example: NAVER_SITE_VERIFICATION · GEO_NOTIFY_ENABLED · GEO_STATE_DIR
- docs: NAVER_EO.md(조사·설계) · AGENTS·README·ARCHITECTURE 4절·DEPLOY 2-2·DEVLOG

가짜 사이트맵·IndexNow 서버로 통보 7시나리오(slug 경계·dry-run·중복 없음·lastmod 변경분·
비200 미통보) · preflight 정상/고장 · robots 판정 · 소유확인 4분기 통과.
실도메인·pytest 는 미실행(.venv·.env 없음). solution/·admin/ 무변경.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8SMKqBu9N723AxVBJhACW
2026-09-14 10:17:28 +09:00

118 lines
5.3 KiB
Python

"""`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/<slug>/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)]