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

92 lines
4.0 KiB
Python

"""**발행하기 전에** — 지금 발행하면 네이버에 닿는가.
solution/backend/.venv/bin/python geo/scripts/preflight.py
solution/backend/.venv/bin/python geo/scripts/preflight.py --json
★ 사이트 하나를 보는 게 아니라 **통로**를 본다. 오리진이 하나라서 여기서 한 번 확인하면
`/s/<slug>` 전부에 해당한다 — 사장님이 1,000명이 돼도 반복하지 않는다.
★ 여기서 빨간불이면 **발행해도 네이버에 안 닿는다.** 2주 뒤에 "왜 색인이 안 되지"
알게 되는 것과, 발행 전에 아는 것의 차이다.
점검 넷:
1. 소유확인 파일 없으면 서치어드바이저에 등록조차 못 한다
2. robots.txt Yeti·Daumoa 허용 · 사이트맵 지시 · ★ JS·CSS 를 막지 않았나
3. 루트 사이트맵 통보할 URL 목록의 출처다
4. IndexNow 키 파일 ★ 없으면 통보가 403 으로 전부 거절된다
"""
import argparse
import asyncio
import json
import os
import sys
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _p not in sys.path:
sys.path.insert(0, _p)
os.environ.setdefault("APP_ENV", "local")
import httpx # noqa: E402
from geo.naver import FAIL, OK, SKIP, WARN, Finding, NaverEoReport # noqa: E402
from geo.naver import checks, notify, robots # noqa: E402
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
from services.site_payload import publish_origin # noqa: E402
MARK = {OK: "", WARN: " !", FAIL: "", SKIP: " ·"}
async def preflight(origin: str) -> NaverEoReport:
report = NaverEoReport(origin=origin.rstrip("/"))
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
report.findings.append(await checks.check_verification(client, report.origin))
for status, label, detail in await robots.fetch_and_judge(client, report.origin):
report.findings.append(Finding("robots", label, status, detail))
res = await get(client, report.origin + "/sitemap.xml")
if res is not None and res.status_code == 200 and "<urlset" in res.text:
n = res.text.count("<loc>")
report.findings.append(Finding("root_sitemap", "루트 사이트맵", OK, f"URL {n}"))
else:
code = res.status_code if res else "연결실패"
report.findings.append(Finding(
"root_sitemap", "루트 사이트맵", FAIL, f"HTTP {code} — 통보할 URL 의 출처가 없다",
))
ok, detail = await notify.check_key_file(client, report.origin)
report.findings.append(Finding(
"indexnow_key", "IndexNow 키 파일",
OK if ok else (WARN if not notify.key() else FAIL), detail,
None if ok else "이게 없으면 통보가 403 으로 전부 거절된다",
))
return report
async def main() -> int:
parser = argparse.ArgumentParser(description="발행 전 — 네이버로 가는 통로가 뚫렸나")
parser.add_argument("origin", nargs="?", default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
report = await preflight(args.origin or publish_origin())
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 ""))
print(f"\n[결과] 통과 {len([f for f in report.findings if f.status == OK])} · "
f"주의 {len(report.warned)} · 실패 {len(report.failed)}")
if report.failed:
print("\n★ 지금 발행하면 네이버에 닿지 않는다. 먼저 고칠 것:")
for f in report.failed:
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()))