# 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](../docs/NAVER_EO.md) 가 단일 출처다.** 결론만 옮기면: 네이버에서 우리 목표는 "AI 답변에 인용되기" 가 **아니고**, **플레이스와 공식 홈페이지가 한 업소로 묶이고 웹문서 검색에 잡히는 것**이다 — AI 브리핑의 출처가 네이버 생태계에 쏠려 있기 때문이다. 이 모듈의 점검 항목이 그 목표에서 나온다. ## 왜 최상단이면서 백엔드를 쓰나 최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약 — [ARCHITECTURE.md 4절](../docs/ARCHITECTURE.md)). `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](../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` 로 **자기 이미지**를 갖는 것이 이 제약 아래서의 길이다 | | 소유확인을 랜딩 `
` 메타태그로 (`solution/frontend`) | `nginx/site.conf` 가 파일을 내준다 | ⚠️ **토큰이 두 곳에 산다** — `site.conf`(실제로 나가는 값)와 루트 `.env`(점검이 대조할 기대값). nginx 가 env 를 못 읽고 `site.conf` 는 `.example` 만 커밋되기 때문이다. **어긋나면 `checks.check_verification` 이 잡는다** — 그게 두 곳을 감수하는 근거다. 덤: 프론트 재빌드가 필요 없다 | → 제약이 풀리면 위 표의 왼쪽으로 옮기고 이 절을 지운다. → ★ 세 번째 줄은 **바꾸는 게 이득이 아닐 수도 있다.** 메타태그로 가면 토큰이 한 곳으로 모이지만 값을 바꿀 때마다 `./deploy.sh solution-site` 재빌드가 붙는다. 옮기기 전에 "토큰이 실제로 얼마나 바뀌나" 를 먼저 본다. ## 실행 ```bash # 레포 루트에서. 백엔드 venv 를 쓴다(httpx·pydantic 이 거기 있다) PY=solution/backend/.venv/bin/python $PY geo/scripts/preflight.py # 발행 전 — 통로가 뚫렸나 $PY geo/scripts/postflight.py