o2o-site-AEO/geo/scripts/postflight.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.2 KiB
Python

"""**발행한 뒤에** — 알리고, 제대로 나갔는지 본다.
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug>
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --place "스테이,머뭄"
solution/backend/.venv/bin/python geo/scripts/postflight.py --all (사이트맵의 전부)
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --dry-run
★★ **왜 "발행 전" 이 아니라 "발행 후" 인가.** 아직 없는 주소를 통보하면 검색엔진이 404 를
받는다. 알리지 않은 것보다 나쁘다 — 헛주소를 보내는 호스트로 기록된다.
그래서 통보는 **구워진 것을 확인한 뒤**에만 보낸다(`_live` 가 그 확인이다).
★ 순서가 뜻을 갖는다:
1) 살아 있나 200 이 아니면 통보하지 않는다
2) 알린다 루트 사이트맵에서 이 사이트 주소를 골라 IndexNow 로
3) 기록한다 **성공한 것만.** 실패를 성공으로 기억하면 영영 다시 안 보낸다
4) 본다 소유확인·색인·역방향 링크 (선택)
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
_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 import state # noqa: E402
from geo.naver import notify # noqa: E402
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
from services.site_payload import publish_origin # noqa: E402
STATE_NAME = "indexnow"
async def _live(client: httpx.AsyncClient, url: str) -> bool:
"""통보 전에 실제로 열리는지 본다. **404 통보를 막는 유일한 방어다.**"""
res = await get(client, url)
return res is not None and res.status_code == 200
async def run(origin: str, slugs: list[str] | None, *, dry_run: bool = False) -> list[dict]:
origin = origin.rstrip("/")
results: list[dict] = []
seen = state.load(STATE_NAME)
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
locs = await notify.root_sitemap_locs(client, origin)
if not locs:
return [{"slug": None, "ok": False, "error": "루트 사이트맵을 읽지 못했다"}]
if not slugs:
# 사이트맵에서 slug 를 뽑는다 — 주소 규칙을 여기서 다시 만들지 않는다.
slugs = sorted({loc.split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
for loc in locs if "/s/" in loc})
for slug in slugs:
urls = notify.site_urls(locs, slug)
if not urls:
results.append({"slug": slug, "ok": False, "error": "사이트맵에 이 사이트 주소가 없다"})
continue
if not await _live(client, urls[0]):
results.append({"slug": slug, "ok": False, "error": f"{urls[0]} 이 아직 200 이 아니다 — 통보하지 않는다"})
continue
if dry_run:
results.append({"slug": slug, "ok": True, "urls": urls, "dry_run": True})
continue
res = await notify.notify_site(client, origin, slug, locs=locs)
row = res.as_dict()
results.append(row)
if res.ok:
# ★ 성공한 것만 기록한다(머리주석 3번).
seen[slug] = {"at": datetime.now(timezone.utc).isoformat(), "urls": len(res.urls)}
if not dry_run:
state.save(STATE_NAME, seen)
return results
async def main() -> int:
parser = argparse.ArgumentParser(description="발행 후 — 알리고 확인한다")
parser.add_argument("slug", nargs="*", help="알릴 사이트(여럿 가능). 비우면 --all 이 필요하다")
parser.add_argument("--all", action="store_true", help="사이트맵에 있는 전부")
parser.add_argument("--origin", default=None)
parser.add_argument("--dry-run", action="store_true", help="보내지 않고 무엇을 보낼지만 본다")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
if not args.slug and not args.all:
parser.error("slug 를 주거나 --all 을 써라")
origin = args.origin or publish_origin()
results = await run(origin, args.slug or None, dry_run=args.dry_run)
if args.json:
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
head = "[발행 후] " + origin + (" (dry-run)" if args.dry_run else "")
print(head + "\n")
for r in results:
mark = " ✓" if r.get("ok") else " ✗"
n = len(r.get("urls") or [])
detail = r.get("error") or f"URL {n}개" + (f" · HTTP {r['status']}" if r.get("status") else "")
print(f"{mark} {r.get('slug')} — {detail}")
bad = [r for r in results if not r.get("ok")]
print(f"\n[결과] 통보 {len(results) - len(bad)} · 실패 {len(bad)}")
return 1 if any(not r.get("ok") for r in results) else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))