네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
|
||
|---|---|---|
| .. | ||
| naver | ||
| scripts | ||
| __init__.py | ||
| README.md | ||
| state.py | ||
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 를 받고,
그건 알리지 않은 것보다 나쁘다. postflight 가 200 을 확인한 뒤에만 보내는 이유다.
★ watch.py 는 루트 사이트맵만 본다 — DB·잡 큐·볼륨을 들여다보지 않는다.
크롤러가 발행을 알아채는 방식과 같아서, solution 을 한 줄도 고치지 않고 끼어들 수 있다.
대가는 즉시성이다(기본 5분). 첫 실행은 전부 새 것으로 보이므로 --seed 로 한 번 재워 둔다.
★ 무엇을 목표로 잡는지는 docs/NAVER_EO.md 가 단일 출처다. 결론만 옮기면: 네이버에서 우리 목표는 "AI 답변에 인용되기" 가 아니고, 플레이스와 공식 홈페이지가 한 업소로 묶이고 웹문서 검색에 잡히는 것이다 — AI 브리핑의 출처가 네이버 생태계에 쏠려 있기 때문이다. 이 모듈의 점검 항목이 그 목표에서 나온다.
왜 최상단이면서 백엔드를 쓰나
최상단은 프로젝트 단위다(o2o-negosium 과 같은 규약 — ARCHITECTURE.md 4절).
geo 는 solution(사이트를 만든다)·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 는 수정이 아니다.
★ 의존은 한 방향이다. solution 은 geo 를 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/Dockerfile 의 COPY 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, 그리고 랜딩(그 스크립트는 발행본만 본다).