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