네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
142 lines
6.0 KiB
Python
142 lines
6.0 KiB
Python
"""발행을 **밖에서 알아채서** 알린다.
|
|
|
|
solution/backend/.venv/bin/python geo/scripts/watch.py (계속 돈다)
|
|
solution/backend/.venv/bin/python geo/scripts/watch.py --once (한 번만)
|
|
solution/backend/.venv/bin/python geo/scripts/watch.py --interval 300
|
|
|
|
★ **어떻게 알아채나 — 루트 사이트맵의 `<lastmod>` 를 본다.** 지난번에 본 것과 달라진
|
|
사이트만 통보한다. 우리 내부(DB·잡 큐·볼륨)를 들여다보지 않는다 — **크롤러가 발행을
|
|
알아채는 방식과 같다.** 그래서 `solution` 을 한 줄도 고치지 않고 끼어들 수 있고,
|
|
"밖에서 본다" 는 이 모듈의 성격도 유지된다.
|
|
|
|
★ 대가: **즉시가 아니다.** 다음 확인 때 알린다(기본 5분). 프리렌더도 2초 폴링으로 도니
|
|
같은 종류의 지연이고, 색인은 어차피 분 단위가 아니다.
|
|
더 빨라야 하면 `site-out` 볼륨의 `payloads/.status/` 를 감시하는 방법이 있는데,
|
|
그러면 내부 파일 구조에 묶여 "밖에서 본다" 가 깨진다. 그 값이 지금은 없다고 봤다.
|
|
|
|
★ 첫 실행은 **전부 새 것으로 보인다.** 그대로 두면 사이트 1,000개를 한꺼번에 통보한다 —
|
|
`--seed` 로 "지금 상태를 이미 아는 것으로" 기록만 하고 넘어갈 수 있다.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import re
|
|
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._http import TIMEOUT_SEC, get # noqa: E402
|
|
from services.site_payload import publish_origin # noqa: E402
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from postflight import run as postflight_run # noqa: E402
|
|
|
|
STATE_NAME = "seen-sitemap"
|
|
DEFAULT_INTERVAL = 300.0
|
|
|
|
# `<url><loc>…</loc><lastmod>…</lastmod></url>` 한 덩이씩. lastmod 는 없을 수 있다.
|
|
_URL_BLOCK = re.compile(r"<url>(.*?)</url>", re.S)
|
|
_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")
|
|
_LASTMOD = re.compile(r"<lastmod>\s*([^<\s]+)\s*</lastmod>")
|
|
|
|
|
|
async def snapshot(client: httpx.AsyncClient, origin: str) -> dict[str, str]:
|
|
"""`{slug: lastmod}`. lastmod 가 없으면 loc 자체를 값으로 둔다(있고 없고만 본다)."""
|
|
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
|
|
if res is None or res.status_code != 200:
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for block in _URL_BLOCK.findall(res.text):
|
|
loc_m = _LOC.search(block)
|
|
if not loc_m or "/s/" not in loc_m.group(1):
|
|
continue
|
|
slug = loc_m.group(1).split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
|
|
if not slug:
|
|
continue
|
|
mod = _LASTMOD.search(block)
|
|
out[slug] = mod.group(1) if mod else loc_m.group(1)
|
|
return out
|
|
|
|
|
|
def changed(previous: dict, current: dict[str, str]) -> list[str]:
|
|
"""새로 생겼거나 lastmod 가 달라진 slug."""
|
|
return sorted(s for s, mod in current.items() if previous.get(s) != mod)
|
|
|
|
|
|
async def tick(origin: str, *, seed: bool = False, verbose: bool = True) -> list[str]:
|
|
seen = state.load(STATE_NAME)
|
|
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
|
|
current = await snapshot(client, origin)
|
|
if not current:
|
|
if verbose:
|
|
print(f"[{_now()}] 사이트맵을 읽지 못했다 — 다음 차례에 다시 본다")
|
|
return []
|
|
|
|
todo = changed(seen, current)
|
|
if seed:
|
|
state.save(STATE_NAME, current)
|
|
if verbose:
|
|
print(f"[{_now()}] 현재 {len(current)}개를 '이미 아는 것'으로 기록했다 (통보하지 않음)")
|
|
return []
|
|
if not todo:
|
|
if verbose:
|
|
print(f"[{_now()}] 바뀐 것 없음 (사이트 {len(current)}개)")
|
|
return []
|
|
|
|
if verbose:
|
|
print(f"[{_now()}] 바뀐 사이트 {len(todo)}개 → 통보: {', '.join(todo[:10])}"
|
|
+ (" …" if len(todo) > 10 else ""))
|
|
results = await postflight_run(origin, todo)
|
|
|
|
# ★ 통보에 성공한 것만 '봤다' 고 기록한다. 실패를 기록하면 영영 다시 안 보낸다.
|
|
for r in results:
|
|
if r.get("ok") and r.get("slug") in current:
|
|
seen[r["slug"]] = current[r["slug"]]
|
|
if verbose:
|
|
mark = " ✓" if r.get("ok") else " ✗"
|
|
detail = r.get("error") or f"URL {len(r.get('urls') or [])}개"
|
|
print(f"{mark} {r.get('slug')} — {detail}")
|
|
state.save(STATE_NAME, seen)
|
|
return [r["slug"] for r in results if r.get("ok")]
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S")
|
|
|
|
|
|
async def main() -> int:
|
|
parser = argparse.ArgumentParser(description="발행을 알아채서 알린다")
|
|
parser.add_argument("--origin", default=None)
|
|
parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="초 (기본 300)")
|
|
parser.add_argument("--once", action="store_true", help="한 번만 보고 끝낸다")
|
|
parser.add_argument("--seed", action="store_true",
|
|
help="지금 상태를 '이미 아는 것'으로 기록만 한다 — 첫 실행의 대량 통보를 막는다")
|
|
args = parser.parse_args()
|
|
|
|
origin = args.origin or publish_origin()
|
|
print(f"[watch] {origin} · {args.interval:.0f}초마다")
|
|
if args.once or args.seed:
|
|
await tick(origin, seed=args.seed)
|
|
return 0
|
|
while True:
|
|
try:
|
|
await tick(origin)
|
|
except Exception as ex: # 한 번 실패로 감시가 멈추면 안 된다
|
|
print(f"[{_now()}] 예외를 삼킨다 — {type(ex).__name__}: {ex}")
|
|
await asyncio.sleep(args.interval)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(asyncio.run(main()))
|
|
except KeyboardInterrupt:
|
|
print("\n[watch] 종료")
|