o2o-site-AEO/geo
민헌 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
..
naver [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행 2026-09-14 10:17:28 +09:00
scripts [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행 2026-09-14 10:17:28 +09:00
__init__.py [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행 2026-09-14 10:17:28 +09:00
README.md [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행 2026-09-14 10:17:28 +09:00
state.py [feat] geo,docs,nginx: 네이버 탐색 모듈 geo 추가 — 발행 전/후 점검 · IndexNow 알리기 대행 2026-09-14 10:17:28 +09:00

geo/ — 검색·AI 엔진에 우리 사이트가 어떻게 보이는가

최상단 프로젝트이면서, 백엔드 코드를 쓰는 모듈이다. 이 두 가지가 같이 성립하는 이유가 이 문서의 대부분이다.

geo/
  naver/
    checks.py           판정 — 밖에서 HTTP 로 본다. DB 를 보지 않는다
    robots.py           robots.txt 를 **네이버 관점**으로 (Yeti·자산 차단·사이트맵 지시)
    notify.py           ★ 알리기 (IndexNow). 원래 백엔드 담당인데 고장 나서 여기가 맡는다
    web_search.py       네이버 웹문서 검색 (여기 있는 이유는 '제약')
    _http.py            공통 UA·타임아웃·실패 처리
  scripts/
    preflight.py        ① 발행 전 — 통로가 뚫렸나 (오리진 단위)
    postflight.py       ② 발행 후 — 알리고 확인 (사이트 단위)
    watch.py            ③ 발행을 알아채서 ②를 자동 실행
    check_naver_eo.py   전체 점검 (사람이 읽는 출력)
  state.py  state/      무엇을 이미 알렸나 (git 에 안 올린다)
  engines/              (아직 없음) GEO 측정 B1~B5 가 붙는 자리

언제 무엇을 도나

발행 전과 후는 하는 일이 아예 다르다. 섞으면 없는 주소를 통보하거나(404 통보 = 신뢰 손실), 매번 해야 할 일을 한 번만 하고 끝낸다.

언제 무엇 단위
preflight.py 발행 소유확인 · robots(Yeti·자산·사이트맵) · 루트 사이트맵 · IndexNow 키 파일 오리진
postflight.py 발행 살아 있나 확인 → 알린다 → 성공분만 기록 사이트
watch.py 계속 루트 사이트맵의 lastmod 변화를 보고 바뀐 것만 ② 오리진

★★ 알리기는 발행 전에 하면 안 된다. 아직 없는 주소를 통보하면 검색엔진이 404 를 받고, 그건 알리지 않은 것보다 나쁘다. postflight200 을 확인한 뒤에만 보내는 이유다.

watch.py루트 사이트맵만 본다 — DB·잡 큐·볼륨을 들여다보지 않는다. 크롤러가 발행을 알아채는 방식과 같아서, solution 을 한 줄도 고치지 않고 끼어들 수 있다. 대가는 즉시성이다(기본 5분). 첫 실행은 전부 새 것으로 보이므로 --seed 로 한 번 재워 둔다.

무엇을 목표로 잡는지는 docs/NAVER_EO.md 가 단일 출처다. 결론만 옮기면: 네이버에서 우리 목표는 "AI 답변에 인용되기" 가 아니고, 플레이스와 공식 홈페이지가 한 업소로 묶이고 웹문서 검색에 잡히는 것이다 — AI 브리핑의 출처가 네이버 생태계에 쏠려 있기 때문이다. 이 모듈의 점검 항목이 그 목표에서 나온다.

왜 최상단이면서 백엔드를 쓰나

최상단은 프로젝트 단위다(o2o-negosium 과 같은 규약 — ARCHITECTURE.md 4절). geosolution(사이트를 만든다)·admin(그걸 운영한다)과 다루는 대상이 다르다 — 검색엔진과 AI 엔진이 밖에서 무엇을 보는지를 다룬다. 그래서 폴더를 가른다.

동시에 도메인 코드를 복제하지 않는다. admin/backend 가 이미 같은 처지이고 같은 방법을 쓴다 — solution/backend 를 PYTHONPATH 로 얹는다.

geo → solution/backend      (services.external.naver · services.site_payload)

쓰는 것이 구체적으로 셋이다. 전부 복제하면 조용히 틀리는 값이다.

쓰는 것 복제하면
services.external.naver.NaverLocalClient (지역검색) 동일 업소 판정 근거와 쿼터 카운터가 갈린다
services.site_payload.publish_origin() 발행 호스트를 새 env 로 또 두면 canonical·사이트맵과 갈린다(AGENTS.md '발행 호스트는 두 곳')
config.server_configs.external_api_config env 이름(NAVER_CLIENT_ID …)을 두 번 적으면 한쪽만 바뀌는 날이 온다

읽어 쓰기만 한다 — solution/backend 의 파일은 고치지 않는다. import 는 수정이 아니다.

의존은 한 방향이다. solutiongeo 를 import 하지 않는다. 그래서 라우터를 붙일 때 solution 의 라우터 트리에 끼우면 순환이 된다 — 마운트는 진입점이 한다(admin/backend/app.py, 또는 geo 자신의 진입점). 이게 최상단으로 가른 대가이고, 동시에 경계가 지켜지는지 import 한 줄로 드러나는 이유다.

제약 — solution/admin/ 은 고치지 않는다 (2026-09-11)

두 폴더의 파일은 한 줄도 고치지 않는다. import 는 자유롭지만 수정은 안 된다. 그래서 세 가지가 이상적인 자리에 없다. 전부 대가를 적어 두고 간다.

원래 있어야 할 곳 지금 있는 곳 대가
NaverLocalClient.search_web() geo/naver/web_search.py ⚠️ 쿼터는 하나인데 카운터가 둘이다. 앱당 일 25,000회를 지역검색·웹문서검색이 공유하는데, 백엔드의 call_counts() 에는 여기 호출이 안 들어간다. 합계는 geo.naver.web_search_call_count() 를 같이 읽어야 한다
services/indexnow.py 가 알리기 geo/naver/notify.py ⚠️ 담당이 두 곳이 되면 안 된다. 지금은 백엔드가 0건이라 중복이 없지만, 백엔드를 고치면 같은 URL 이 두 번 나간다(429 대상). 그날 GEO_NOTIFY_ENABLED=0 으로 여기를 끈다
solution/backend/DockerfileCOPY geo 없음 ⚠️ 컨테이너에서 못 돈다. 레포 체크아웃 + 백엔드 venv 로만 돈다(서버에서도). 정기 실행이 필요해지면 geo/Dockerfile자기 이미지를 갖는 것이 이 제약 아래서의 길이다
소유확인을 랜딩 <head> 메타태그로 (solution/frontend) nginx/site.conf 가 파일을 내준다 ⚠️ 토큰이 두 곳에 산다site.conf(실제로 나가는 값)와 루트 .env(점검이 대조할 기대값). nginx 가 env 를 못 읽고 site.conf.example 만 커밋되기 때문이다. 어긋나면 checks.check_verification 이 잡는다 — 그게 두 곳을 감수하는 근거다. 덤: 프론트 재빌드가 필요 없다

→ 제약이 풀리면 위 표의 왼쪽으로 옮기고 이 절을 지운다. → ★ 세 번째 줄은 바꾸는 게 이득이 아닐 수도 있다. 메타태그로 가면 토큰이 한 곳으로 모이지만 값을 바꿀 때마다 ./deploy.sh solution-site 재빌드가 붙는다. 옮기기 전에 "토큰이 실제로 얼마나 바뀌나" 를 먼저 본다.

실행

# 레포 루트에서. 백엔드 venv 를 쓴다(httpx·pydantic 이 거기 있다)
PY=solution/backend/.venv/bin/python

$PY geo/scripts/preflight.py                    # 발행 전 — 통로가 뚫렸나
$PY geo/scripts/postflight.py <slug> --dry-run  # 무엇을 보낼지만 본다
$PY geo/scripts/postflight.py <slug>            # 알린다
$PY geo/scripts/watch.py --seed                 # 첫 실행: 현재 상태를 재워 둔다
$PY geo/scripts/watch.py                        # 이후: 바뀐 것만 알린다
$PY geo/scripts/check_naver_eo.py --place "스테이,머뭄"   # 전체 점검

읽는 환경변수는 루트 .env 다 — SITE_PUBLIC_HOST · NAVER_SITE_VERIFICATION · NAVER_CLIENT_ID · NAVER_CLIENT_SECRET. 자기 .env 를 두지 않는다.

지금 공개하는 것

from geo import naver

report = await naver.probe(origin, place_name="스테이,머뭄", slug="butter")
report.ok          # 실패가 없으면 True (WARN 은 실패로 세지 않는다)
report.findings    # Finding(id, label, status, detail, fix)
report.as_dict()   # 라우터가 그대로 내보낼 수 있는 형태

라우터는 붙이지 않았다. 어디서 쓸지는 화면을 만들 때 정한다 — 그래서 이 모듈은 Finding 목록만 돌려주고 화면에 찍지 않는다.

★ 붙일 때의 권고는 :9801(어드민) 이다. 소유확인·토큰·서치어드바이저는 우리 일이고, :9801 은 앱 전체에 role >= DEVELOPER 가 걸리며 127.0.0.1 에만 열린다 — 사장님이 닿는 서버에 이 엔드포인트가 아예 없다. 사장님에게도 열 거라면 같은 router 를 :9800 에 다시 마운트하는 방식이고(admin 이 이미 그 방식이다), 그때 주의할 것: 상태가 "미색인" 일 때 사장님이 할 수 있는 일이 없으면 불안만 만들고 문의가 온다. 사장님에게 보일 것은 "스마트플레이스에 주소 넣기" 처럼 본인이 할 수 있는 것으로 추린다.

경계 — 지켜야 하는 선 (2026-09-11)

규칙
checks.py DB 를 보지 않는다. 세션을 인자로도 받지 않는다 "우리 DB 가 그렇다고 한다" 와 "밖에서 실제로 그렇게 보인다" 를 한 함수에 섞으면, 둘이 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 — 그 어긋남을 찾는 것이 이 모듈의 존재 이유
의존 geo → solution/backend 한 방향 반대가 생기면 순환이다. 라우터는 진입점이 마운트한다(위)
수집 공식 API 만. 네이버 검색창을 긁지 않는다 봇 탐지 우회는 결론과 무관하게 영구 금지(DECISIONS 1-1)
외부 호출 지역검색은 services/external/naver.py 를 통해서만. 웹문서검색만 예외 예외의 이유와 대가는 '제약' 절
심는 일 하지 않는다 소유확인 파일은 nginx/site.conf 가 내준다. 심는 쪽과 확인하는 쪽이 같으면 "내가 심었으니 있다" 를 확인이라고 부르게 된다
solution/·admin/ 파일을 고치지 않는다. import 만 한다 위 '제약' 절

아직 안 만든 것 — 붙이는 자리

기능을 얹을 때 여기부터 읽고 위 표를 갱신한다. 백엔드 코드를 읽을 수 있으므로 DB·모델까지 닿지만, 고칠 수 없다는 제약이 붙는 자리가 있다 — 아래 주의 칸이 그것이다.

어디에 주의
점검 결과 저장·추이 새 표 init.sql postgres-init/migrations/ 둘 다 고친다. 그리고 읽는 화면이 생긴 뒤에 만든다ai_check_results 가 아무도 안 읽는 표로 남았다가 마이그레이션 0006 에 떼였다
소유확인 주기 재확인 geo 자기 스케줄러(또는 크론) 검색엔진은 소유확인을 주기적으로 재확인한다. 태그가 사라지면 알림 없이 등록이 풀린다. ⚠️ 백엔드의 JobType·worker/handlers.py 에 얹으려면 그 파일들을 고쳐야 한다 — 지금 제약에서 불가다. 그래서 이 제약이 풀리기 전까지는 geo/Dockerfile + compose 서비스가 유일한 길이다
통보 실패 재시도 잡 큐 통보는 발행의 부수 효과다 — 실패가 발행을 되돌리지 않는다는 indexnow.py 의 규칙을 깨지 않는다
GEO 측정 (Brand AEO B1~B5) geo/engines/ 먼저 정할 것은 비용이다. 설계서 기본값 100문항×4엔진×3회 = 테넌트당 주 1,200회로 사이트당 $1 상한과 부딪친다(DEVELOPMENT_DIRECTION 3-2)

여기서 다시 보지 않는 것

solution/backend/scripts/check_search_ready.py이미 robots 의 Yeti 항목과 Yeti UA 의 발행본 접근을 본다. 같은 것을 두 군데서 보면 한쪽만 고쳐지는 날이 온다. 이 모듈은 그 스크립트가 안 보는 것만 본다 — 소유확인, 네이버 색인, 역방향 링크, IndexNow 통보 URL, 그리고 랜딩(그 스크립트는 발행본만 본다).