네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
151 lines
6.9 KiB
Python
151 lines
6.9 KiB
Python
"""발행본 주소를 검색엔진에 **알린다** — 크롤러가 지나가길 기다리지 않는다.
|
|
|
|
★ 어디에 닿고 어디에 안 닿나 — 이게 이 파일의 존재 이유다.
|
|
닿는다 네이버(2023-07~) · Bing · Yandex · Seznam.
|
|
★ 네이버는 **여기 말고 자동 통로가 없다.** 서치어드바이저는 사람이 눌러야 한다.
|
|
안 닿는다 구글. IndexNow 를 채택하지 않았다 — 사이트맵 제출이 유일한 자동화다.
|
|
|
|
★★ **왜 `geo` 가 이 일을 하나.**
|
|
원래 담당은 `solution/backend/services/indexnow.py` 다. 그런데 그 코드가 읽는 파일
|
|
(`<out>/s/<slug>/sitemap.xml`)을 프리렌더가 **더는 굽지 않는다** — 사이트가 한 장이 되면서
|
|
루트 사이트맵 한 장으로 합쳤기 때문이다. 파일이 없으면 빈 목록을 돌려주고 경고 한 줄만
|
|
남긴 채 **발행 잡은 성공한다.** 즉 통보가 조용히 0건이다.
|
|
백엔드를 고치지 않기로 해서(2026-09-11), `geo` 가 **루트 사이트맵을 읽어** 대신 보낸다.
|
|
|
|
⚠️ **담당이 두 곳이 되면 안 된다.** 지금은 백엔드가 0건이라 중복이 없지만, 누가 백엔드를
|
|
고치면 **같은 URL 이 두 번 나간다**(429 과다 요청 대상). 그때는 둘 중 하나를 꺼야 한다 —
|
|
이 모듈은 `GEO_NOTIFY_ENABLED=0` 으로 끈다.
|
|
|
|
★ 보낼 URL 을 여기서 조립하지 않는다. **사이트맵에 있는 것만 보낸다** — 거기 있는 것이
|
|
실제로 구워진 페이지다. 라우트 규칙을 또 두면 사이트맵에 없는 URL 을 통보하게 되고,
|
|
그건 404 통보라 신뢰만 깎인다.
|
|
|
|
★ 실패해도 발행을 되돌리지 않는다. 정적 파일은 이미 올라가 있어서 되돌릴 것이 없다.
|
|
대신 **조용히 지나가지 않게** 결과를 돌려준다 — 그게 지금 고장의 재발 방지다.
|
|
"""
|
|
import os
|
|
import re
|
|
from dataclasses import asdict, dataclass
|
|
|
|
import httpx
|
|
|
|
from geo.naver._http import TIMEOUT_SEC, get
|
|
|
|
ENDPOINT = "https://api.indexnow.org/indexnow"
|
|
# 규격 상한은 한 번에 10,000개다. 사이트 하나는 한 장이라 넉넉하다.
|
|
MAX_URLS = 10_000
|
|
|
|
KEY_ENV = "INDEXNOW_KEY"
|
|
ENABLED_ENV = "GEO_NOTIFY_ENABLED"
|
|
|
|
|
|
@dataclass
|
|
class NotifyResult:
|
|
slug: str
|
|
urls: list[str]
|
|
sent: bool
|
|
status: int | None = None
|
|
error: str | None = None
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
# 200 OK · 202 Accepted 가 정상이다. 그 밖은 규격상 원인이 정해져 있다:
|
|
# 400 형식 · 403 키 불일치 · 422 호스트 불일치 · 429 과다 요청
|
|
return self.sent and self.status in (200, 202)
|
|
|
|
def as_dict(self) -> dict:
|
|
return {**asdict(self), "ok": self.ok}
|
|
|
|
|
|
def key() -> str:
|
|
return os.environ.get(KEY_ENV, "").strip()
|
|
|
|
|
|
def enabled() -> bool:
|
|
"""키가 있고, 꺼져 있지 않을 때만 보낸다.
|
|
|
|
★ 스위치를 둔 이유는 머리주석의 "담당이 두 곳" 경고 때문이다. 백엔드 쪽이 고쳐지는
|
|
날 여기를 꺼야 하는데, 코드를 지우는 것보다 환경변수 하나가 되돌리기 쉽다."""
|
|
return bool(key()) and os.environ.get(ENABLED_ENV, "1").strip() != "0"
|
|
|
|
|
|
async def root_sitemap_locs(client: httpx.AsyncClient, origin: str) -> list[str]:
|
|
"""루트 사이트맵의 `<loc>` 전부. **이 호스트가 실제로 발행한 주소 목록**이다.
|
|
|
|
★ XML 파서를 쓰지 않고 정규식으로 뽑는다. 우리가 굽는 파일이라 형태가 고정이고,
|
|
파서를 쓰면 한 글자 깨졌을 때 전부를 잃는다 — 통보는 부분 성공이 낫다."""
|
|
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
|
|
if res is None or res.status_code != 200:
|
|
return []
|
|
return [u.strip() for u in re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text) if u.strip()]
|
|
|
|
|
|
def site_urls(locs: list[str], slug: str) -> list[str]:
|
|
"""이 사이트에 속한 주소만 고른다.
|
|
|
|
★ `/s/<slug>` 로 시작하는 것만 본다. `startswith` 가 아니라 경계까지 보는 이유:
|
|
`/s/joy` 로 거르면 `/s/joy-cafe` 까지 끌려온다."""
|
|
marker = f"/s/{slug}"
|
|
picked = []
|
|
for loc in locs:
|
|
idx = loc.find(marker)
|
|
if idx < 0:
|
|
continue
|
|
rest = loc[idx + len(marker):]
|
|
if rest in ("", "/") or rest.startswith(("/", "?", "#")):
|
|
picked.append(loc)
|
|
return picked[:MAX_URLS]
|
|
|
|
|
|
async def submit(client: httpx.AsyncClient, urls: list[str]) -> NotifyResult | None:
|
|
"""한 호스트분을 보낸다. 호출측이 slug 를 채워 돌려받는다."""
|
|
host = urls[0].split("//", 1)[-1].split("/", 1)[0]
|
|
body = {
|
|
"host": host,
|
|
"key": key(),
|
|
# 키 파일은 오리진 루트에 있다(프리렌더가 굽는다). 검색엔진이 이걸 열어
|
|
# 같은 키가 있는지 보고 "이 호스트를 제어하는 쪽이 보냈다" 를 확인한다.
|
|
# 비밀이 아니다 — 공개되어야 작동하는 값이다.
|
|
"keyLocation": f"https://{host}/{key()}.txt",
|
|
# 한 요청의 URL 은 전부 같은 호스트여야 한다(규격). 섞이면 422 다.
|
|
"urlList": [u for u in urls if u.split("//", 1)[-1].split("/", 1)[0] == host],
|
|
}
|
|
try:
|
|
res = await client.post(ENDPOINT, json=body, timeout=TIMEOUT_SEC)
|
|
except httpx.HTTPError as ex:
|
|
return NotifyResult("", body["urlList"], sent=True, error=f"{type(ex).__name__}: {ex}")
|
|
return NotifyResult("", body["urlList"], sent=True, status=res.status_code)
|
|
|
|
|
|
async def notify_site(client: httpx.AsyncClient, origin: str, slug: str, locs: list[str] | None = None) -> NotifyResult:
|
|
"""사이트 하나를 알린다. `locs` 를 주면 사이트맵을 다시 읽지 않는다(여러 건 처리용)."""
|
|
if not enabled():
|
|
return NotifyResult(slug, [], sent=False, error=f"{KEY_ENV} 가 없거나 {ENABLED_ENV}=0")
|
|
|
|
if locs is None:
|
|
locs = await root_sitemap_locs(client, origin)
|
|
urls = site_urls(locs, slug)
|
|
if not urls:
|
|
return NotifyResult(slug, [], sent=False, error="루트 사이트맵에 이 사이트 주소가 없다")
|
|
|
|
result = await submit(client, urls)
|
|
result.slug = slug
|
|
return result
|
|
|
|
|
|
async def check_key_file(client: httpx.AsyncClient, origin: str) -> tuple[bool, str]:
|
|
"""키 파일이 열리는가 — **이게 없으면 통보가 403 으로 전부 거절된다.**
|
|
|
|
발행 전에 봐야 하는 항목이다. 통보를 보내고 나서 알면 이미 늦다."""
|
|
k = key()
|
|
if not k:
|
|
return False, f"{KEY_ENV} 가 비어 있다 — 통보가 꺼져 있다"
|
|
res = await get(client, f"{origin.rstrip('/')}/{k}.txt")
|
|
if res is None:
|
|
return False, f"/{k}.txt 에 연결하지 못했다"
|
|
if res.status_code != 200:
|
|
return False, f"/{k}.txt 이 HTTP {res.status_code} — 통보가 403 으로 거절된다"
|
|
if res.text.strip() != k:
|
|
return False, f"/{k}.txt 내용이 키와 다르다"
|
|
return True, f"/{k}.txt"
|