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