네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
118 lines
5.3 KiB
Python
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)]
|