o2o-site-AEO/geo/scripts/check_naver_eo.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

78 lines
3.8 KiB
Python

"""네이버 탐색 준비 상태를 **사람이 읽게** 찍는다.
레포 루트에서 (백엔드 venv 를 쓴다 — httpx·pydantic 이 거기 있다):
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --place "스테이,머뭄"
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --slug butter
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --json (도구에 물릴 때)
★ **컨테이너 안에서는 못 돈다.** `geo/` 는 백엔드 이미지에 들어가지 않는다 — 넣으려면
`solution/backend/Dockerfile` 을 고쳐야 하고, 백엔드 코드는 고치지 않기로 했다(2026-09-11).
서버에서도 레포 체크아웃 + 백엔드 venv 로 돌린다. 정기 실행이 필요해지면 `geo/Dockerfile`
로 자기 이미지를 갖는 것이 이 제약 아래서의 길이다(geo/README.md).
★ 판정은 여기서 하지 않는다 — `geo.naver.probe()` 가 한다. 이 파일은 그 결과를 줄로 바꾸는
일만 한다. 판정을 화면 코드에 두면 라우터가 붙을 때 기준이 둘로 갈린다.
★ 오리진 기본값은 `site_payload.publish_origin()` 이다. 발행 호스트를 새 env 로 또 두면
canonical 과 갈린다(AGENTS.md '발행 호스트는 두 곳에 있고 같아야 한다').
"""
import argparse
import asyncio
import json
import os
import sys
# ★ 두 자리를 얹는다: 레포 루트(`geo` 패키지) + `solution/backend`(도메인 코드).
# admin/backend 가 PYTHONPATH 로 같은 일을 한다 — geo 도 백엔드 코드를 복제하지 않는다.
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _path in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _path not in sys.path:
sys.path.insert(0, _path)
# ★ APP_ENV=test 면 .env 를 읽지 않는다(실키가 테스트로 새는 경로를 막아 뒀다).
# 이 스크립트는 실제 네이버 API 를 부르므로 local 이어야 한다 — 다른 스크립트도 같다.
os.environ.setdefault("APP_ENV", "local")
from geo.naver import FAIL, OK, SKIP, WARN, probe # noqa: E402
from services.site_payload import publish_origin # noqa: E402
MARK = {OK: " ✓", WARN: " !", FAIL: " ✗", SKIP: " ·"}
async def main() -> int:
parser = argparse.ArgumentParser(description="네이버 탐색 준비 상태 점검")
parser.add_argument("origin", nargs="?", default=None, help=f"확인할 오리진(기본 {publish_origin()})")
parser.add_argument("--place", help="상호명 — 색인·역방향 링크 점검에 쓴다")
parser.add_argument("--slug", help="통보 경로를 확인할 사이트(기본: 사이트맵의 첫 사이트)")
parser.add_argument("--json", action="store_true", help="사람이 아니라 도구가 읽을 형태로")
args = parser.parse_args()
report = await probe(
args.origin or publish_origin(),
place_name=args.place,
slug=args.slug,
)
if args.json:
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
return 0 if report.ok else 1
print(f"[네이버 점검] {report.origin}\n")
for f in report.findings:
print(f"{MARK[f.status]} {f.label}" + (f" — {f.detail}" if f.detail else ""))
passed = [f for f in report.findings if f.status == OK]
print(f"\n[결과] 통과 {len(passed)} · 주의 {len(report.warned)} · 실패 {len(report.failed)}")
for title, rows in (("먼저 고칠 것", report.failed), ("확인이 필요한 것", report.warned)):
if not rows:
continue
print(f"\n{title}:")
for f in rows:
print(f" - {f.label}: {f.detail}" + (f"\n → {f.fix}" if f.fix else ""))
return 0 if report.ok else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))