o2o-site-AEO/geo/naver/checks.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

331 lines
17 KiB
Python

"""네이버가 우리 사이트를 어떻게 보는지 **밖에서** 확인한다.
★ 파일명이 `probe.py` 가 아닌 이유: 공개 함수가 `probe()` 라서 패키지에서 이름이 겹친다.
`from geo.naver import probe` 가 모듈이 아니라 함수를 주게 되고, `geo.naver.probe` 로
모듈 속성에 닿으려던 코드가 조용히 함수를 집는다(실제로 한 번 걸렸다).
★ 이 파일은 **DB 를 보지 않는다.** 세션을 인자로도 받지 않는다.
"우리 DB 가 그렇다고 한다""밖에서 실제로 그렇게 보인다" 를 한 함수에 섞으면,
둘이 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 — **그 어긋남을 찾는 것이 이 모듈의
존재 이유**다. 저장·판정 이력은 같은 패키지의 다른 파일이 맡는다(../README.md).
★ 화면에 찍지 않는다. `Finding` 목록만 돌려준다 — 라우터·워커 잡·CLI 가 같은 결과를
쓰게 하려는 것이다. 사람이 읽는 출력은 `geo/scripts/check_naver_eo.py` 가 만든다.
★ 공식 API 만 쓴다. 네이버 검색창을 긁지 않는다 — 봇 탐지 우회는 결론과 무관하게 영구
금지다(docs/DECISIONS.md 1-1).
★ 왜 네이버만 따로 보나 — 소상공인 검색 트래픽의 주력이면서, 우리가 가진 자동 경로가
IndexNow 하나뿐이다. 구글은 Search Console, Bing 은 웹마스터도구가 상태를 보여 주지만
네이버는 **서치어드바이저에 등록되기 전까지 아무 창도 없다.** 창이 없는 동안 잘못돼도
알 방법이 없어서, 지금 당장 확인할 수 있는 것만 본다.
"""
import os
import re
from dataclasses import asdict, dataclass, field
import httpx
from geo.naver._http import TIMEOUT_SEC, YETI_UA, get as _get, host as _host
from geo.naver.web_search import NaverWebSearchUnavailable, search_web
from geo.naver.web_search import enabled as web_search_enabled
from services.external.naver import NaverLocalClient, NaverNotConfigured, NaverRequestFailed
# 소유확인 토큰 = 서치어드바이저가 준 **파일명에서 `.html` 을 뺀 값**(예: naver1234abcd).
# ★ 이 값은 `nginx/site.conf` 에도 적힌다 — **두 곳이다.** nginx 가 env 를 못 읽고, 그 파일은
# `.example` 만 커밋되기 때문이다. 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것"
# 이고 **어긋나면 이 점검이 잡는다.** 그게 두 곳을 감수하는 근거다(geo/README.md '제약').
# ★ pydantic 설정이 아니라 env 를 직접 읽는다 — site_payload.SITE_HOST_ENV·indexnow.key() 와
# 같은 계열의 값이고, 발행 계열 env 는 그 관행이다.
VERIFICATION_ENV = "NAVER_SITE_VERIFICATION"
OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip"
@dataclass(frozen=True)
class Finding:
"""점검 한 건. `fix` 는 **사람이 다음에 할 일**이다 — 없으면 할 일이 없다는 뜻이다."""
id: str
label: str
status: str
detail: str
fix: str | None = None
@dataclass
class NaverEoReport:
origin: str
findings: list[Finding] = field(default_factory=list)
@property
def failed(self) -> list[Finding]:
return [f for f in self.findings if f.status == FAIL]
@property
def warned(self) -> list[Finding]:
return [f for f in self.findings if f.status == WARN]
@property
def ok(self) -> bool:
"""실패가 없으면 True. **주의(WARN)는 실패로 세지 않는다** — 미확정이 섞여 있다
(네이버 색인은 등록 전에는 확정할 방법이 없다)."""
return not self.failed
def as_dict(self) -> dict:
return {
"origin": self.origin,
"ok": self.ok,
"findings": [asdict(f) for f in self.findings],
}
def verification_token() -> str:
return os.environ.get(VERIFICATION_ENV, "").strip()
# ── 소유확인 ─────────────────────────────────────────────────────────────
async def check_verification(client: httpx.AsyncClient, origin: str, token: str | None = None) -> Finding:
"""소유확인 파일이 오리진 루트에서 열리는가 — `https://<host>/<token>.html`.
★ **메타태그가 아니라 파일 방식이다.** 메타태그는 랜딩 `<head>` 에 들어가야 하는데 그건
`solution/frontend` 가 만든다 — `solution/` 을 고치지 않기로 했다(2026-09-11).
그래서 nginx 가 직접 내준다(`nginx/site.conf` 의 `location = /<token>.html`).
덤으로 얻은 것: **프론트를 다시 구울 필요가 없다.** 값이 번들에 안 들어간다.
★★ **상태코드로 판단하면 안 된다.** nginx 맨 아래 `location /` 가 SPA 폴백을 주므로,
블록이 없거나 파일명이 한 글자 틀리면 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다.
검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려주는데, 눈으로는 파일이 있는 것처럼 보인다.
→ 그래서 **내용**으로 본다. `docs/DEPLOY.md` 가 Bing 파일에 대해 하는 경고와 같은 것이다.
★ 통과했다고 끝이 아니다. 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이 사라진
시점에 등록이 풀리면서 **아무 알림도 오지 않는다.**
"""
token = verification_token() if token is None else token.strip()
if not token:
return Finding(
"verification", "소유확인 파일", WARN,
f"{VERIFICATION_ENV} 가 비었다 — 서치어드바이저 등록 전이다",
"docs/DEPLOY.md 2-2단계",
)
path = f"/{token}.html"
res = await _get(client, origin + path)
if res is None:
return Finding("verification", "소유확인 파일", FAIL, f"{path} 에 연결하지 못했다")
if res.status_code != 200:
return Finding(
"verification", "소유확인 파일", FAIL,
f"{path} 이 HTTP {res.status_code}",
"nginx/site.conf 에 location = " + path + " 블록이 있는지 확인해라",
)
if token not in res.text:
# 여기가 이 점검의 핵심이다 — 200 인데 내용이 다르면 거의 항상 SPA 폴백이다.
shell = "id=\"root\"" in res.text or len(res.text) > 2000
why = "빌더 앱 HTML 이 200 으로 나온다" if shell else "내용에 토큰이 없다"
return Finding(
"verification", "소유확인 파일", FAIL,
f"{path} 이 200 이지만 {why} ({len(res.text)}바이트)",
"location 블록이 없거나 파일명이 다르다 — nginx/site.conf 를 확인하고 reload 해라",
)
return Finding("verification", "소유확인 파일", OK, f"{path} — 토큰이 내용에 있다 ({len(res.text)}바이트)")
async def check_yeti_landing(client: httpx.AsyncClient, origin: str) -> Finding:
"""랜딩을 **Yeti UA 로** 받아 본다.
★ `scripts/check_search_ready.py` 는 발행본(`/s/<slug>`)만 본다. 랜딩은 아무도 안 보는데,
소유확인이 여기 붙고 네이버가 사이트를 처음 여는 자리도 여기다.
★ 글자 수를 세는 이유: 랜딩은 프리렌더 대상이다(`react-router.config.ts` `prerender`).
그 설정이 빠지면 200 은 그대로인데 본문이 0자가 된다 — 실측(2026-09-07) 3,021바이트에
`<a>` 0개·본문 0자였다. 상태코드로는 안 보이는 종류다.
"""
res = await _get(client, origin + "/", ua=YETI_UA)
if res is None:
return Finding("yeti_landing", "Yeti 로 랜딩", FAIL, "연결하지 못했다")
if res.status_code != 200:
return Finding(
"yeti_landing", "Yeti 로 랜딩", FAIL,
f"HTTP {res.status_code} — CDN·WAF 가 네이버 로봇을 막고 있다",
"봇 차단 규칙에서 Yeti 를 빼라",
)
words = _visible_chars(res.text)
if words < 300:
return Finding(
"yeti_landing", "Yeti 로 랜딩", FAIL,
f"본문 {words}자 — CSR 로 돌아갔다",
"react-router.config.ts 의 prerender 목록을 확인해라",
)
return Finding("yeti_landing", "Yeti 로 랜딩", OK, f"HTTP 200 · 본문 {words}")
# ── 색인 통보 경로 ───────────────────────────────────────────────────────
async def check_indexnow_path(client: httpx.AsyncClient, origin: str, slug: str | None = None) -> list[Finding]:
"""백엔드가 통보 URL 을 뽑는 파일이 **그 자리에 있는가.**
★ `services/indexnow.py site_urls()` 는 `<out>/s/<slug>/sitemap.xml` 을 읽어 `<loc>` 을
모은다. 없으면 빈 목록을 돌려주고 경고 한 줄만 남긴 뒤 **발행 잡은 성공한다.**
네이버로 가는 자동 경로가 이것뿐이라, 끊기면 통째로 끊긴다.
★ 로컬 `out/` 대신 HTTP 로 본다. 프리렌더가 쓰는 볼륨과 nginx 가 읽는 볼륨이 같아서,
밖에서 404 면 백엔드가 읽을 파일도 없다. 그리고 로컬 경로를 아는 것은 백엔드 몫이라
여기서 다시 조립하면 규칙이 두 군데가 된다.
"""
out: list[Finding] = []
res = await _get(client, origin + "/sitemap.xml")
if res is None or res.status_code != 200:
code = res.status_code if res else "연결실패"
out.append(Finding(
"root_sitemap", "루트 사이트맵", FAIL,
f"HTTP {code} — 통보할 URL 목록의 출처가 없다",
))
return out
locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text)
site_locs = [loc for loc in locs if "/s/" in loc]
out.append(Finding(
"root_sitemap", "루트 사이트맵", OK,
f"URL {len(locs)}개 (발행 사이트 {len(site_locs)}개)",
))
if not slug:
found = re.search(r"/s/([^/?#]+)", site_locs[0]) if site_locs else None
slug = found.group(1) if found else None
if not slug:
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", SKIP, "확인할 사이트가 없다"))
return out
res = await _get(client, f"{origin}/s/{slug}/sitemap.xml")
if res is None:
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", FAIL, "연결하지 못했다"))
elif res.status_code == 200 and "<loc>" in res.text:
n = len(re.findall(r"<loc>", res.text))
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", OK, f"/s/{slug}/sitemap.xml — URL {n}"))
else:
out.append(Finding(
"indexnow_urls", "IndexNow 통보 URL", FAIL,
f"/s/{slug}/sitemap.xml 이 HTTP {res.status_code} — indexnow.site_urls() 가 빈 목록을 "
"돌려준다. 네이버·Bing 통보가 조용히 0건이다",
"indexnow.site_urls() 가 루트 사이트맵을 읽게 고쳐라",
))
return out
# ── 네이버에서 보이는가 ──────────────────────────────────────────────────
async def check_web_index(origin: str, place_name: str) -> Finding:
"""웹문서 검색에 우리 URL 이 잡히는가.
★ 없을 때 FAIL 이 아니라 WARN 이다 — 이 API 의 결과는 통합검색 색인과 같지 않아서
"안 잡혔다""색인 안 됐다" 를 단정할 수 없다(web_search.py 머리주석).
확정 판정을 보려면 서치어드바이저 등록이 필요하고, 그게 소유확인이 전제인 이유다.
★ 지역검색(`check_place_backlink`)은 백엔드 호출기를 그대로 쓰는데 여기만 geo 안의
호출기를 쓴다 — 백엔드를 고치지 않기로 했기 때문이다. 쿼터가 갈리는 대가는
`web_search.py` 머리주석에 적어 뒀다.
"""
if not web_search_enabled():
return Finding("web_index", "네이버 웹문서 색인", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
try:
items = await search_web(place_name)
except NaverWebSearchUnavailable as ex:
return Finding("web_index", "네이버 웹문서 색인", FAIL, str(ex))
host = _host(origin)
hits = [i for i in items if host in (i.get("link") or "")]
if hits:
return Finding("web_index", "네이버 웹문서 색인", OK, f'"{place_name}" 검색 {len(items)}건 중 우리 사이트 {len(hits)}')
return Finding(
"web_index", "네이버 웹문서 색인", WARN,
f'"{place_name}" 검색 {len(items)}건에 우리 사이트가 없다 — 미색인이거나 이 API 범위 밖이다',
"서치어드바이저에 등록해 색인 진단을 봐라",
)
async def check_place_backlink(origin: str, place_name: str, client: NaverLocalClient | None = None) -> Finding:
"""네이버 플레이스가 **우리 사이트를 가게 홈페이지로 가리키는가.**
★ 왜 중요한가 — 우리는 `sameAs` 로 네이버를 가리키지만 그건 우리→네이버 한 방향이다.
네이버가 이 홈페이지를 그 업소의 공식 사이트로 **인정하는** 신호는 반대 방향,
스마트플레이스의 "홈페이지" 칸에 우리 주소가 들어가는 것이다. 사장님이 직접 넣어야
하고, 비용이 0 이면서 가장 강한 신호다.
★ 보는 필드는 `NaverPlace.place_url` 이다. 이 값은 응답의 `link` 인데 **네이버 플레이스
페이지가 아니라 업체 자체 홈페이지**다(external/naver.py 실측 주석) — 그래서 역방향
연결을 여기서 볼 수 있다. 이름이 `link` 가 아니라는 것에 주의한다.
★ 후보는 최대 5건이고 전화번호가 안 와서 동명 업소를 가릴 근거가 약하다. 그래서
"일치/불일치" 로 단정하지 않고 **후보의 link 를 그대로 담는다** — 판단은 사람이 한다.
"""
api = client or NaverLocalClient()
if not api.enabled:
return Finding("place_backlink", "스마트플레이스 역방향 링크", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
try:
candidates = await api.search_local(place_name)
except (NaverNotConfigured, NaverRequestFailed) as ex:
return Finding("place_backlink", "스마트플레이스 역방향 링크", FAIL, f"{type(ex).__name__}: {ex}")
if not candidates:
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", WARN,
f'"{place_name}" 로 네이버 지역검색 결과가 없다',
)
host = _host(origin)
for cand in candidates:
# name 은 from_item 에서 이미 <b> 태그를 벗긴 값이다 — 다시 벗기지 않는다.
if host in (cand.place_url or ""):
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", OK,
f"{cand.name}{cand.place_url}",
)
seen = " · ".join(f"{c.name}={c.place_url or '(없음)'}" for c in candidates)
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", WARN,
f"우리 주소가 없다 — 후보: {seen}",
"스마트플레이스 '홈페이지' 칸에 발행본 주소를 넣도록 사장님께 안내해라",
)
# ── 한 번에 ──────────────────────────────────────────────────────────────
async def probe(
origin: str,
*,
place_name: str | None = None,
slug: str | None = None,
token: str | None = None,
naver: NaverLocalClient | None = None,
) -> NaverEoReport:
"""전체 점검. 라우터·워커 잡·CLI 가 **같이 부르는 자리**다.
`place_name` 이 없으면 네이버 검색이 필요한 두 항목을 건너뛴다 — 상호명 없이는
질의를 만들 수 없고, 호스트명으로 던지면 결과가 의미를 갖지 않는다."""
report = NaverEoReport(origin=origin.rstrip("/"))
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
report.findings.append(await check_verification(client, report.origin, token))
report.findings.append(await check_yeti_landing(client, report.origin))
report.findings.extend(await check_indexnow_path(client, report.origin, slug))
if place_name:
# 웹문서검색은 geo 안의 호출기, 지역검색은 백엔드 호출기다(check_web_index 주석).
report.findings.append(await check_web_index(report.origin, place_name))
api = naver or NaverLocalClient()
try:
report.findings.append(await check_place_backlink(report.origin, place_name, api))
finally:
if naver is None:
await api.aclose()
else:
report.findings.append(
Finding("naver_search", "색인·역방향 링크", SKIP, "상호명을 주면 확인한다")
)
return report
# ── 내부 ─────────────────────────────────────────────────────────────────
def _visible_chars(html: str) -> int:
"""JS 실행 없이 읽히는 글자 수. check_search_ready.py 와 같은 셈이다."""
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S)
return len(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body)).strip())