o2o-site-AEO/geo/README.md
민헌 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

158 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 로 **자기 이미지**를 갖는 것이 이 제약 아래서의 길이다 |
| 소유확인을 랜딩 `<head>` 메타태그로 (`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 <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` 를 두지 않는다.
## 지금 공개하는 것
```python
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](../docs/DECISIONS.md)) |
| 외부 호출 | 지역검색은 `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](../docs/DEVELOPMENT_DIRECTION.md)) |
## 여기서 다시 보지 않는 것
`solution/backend/scripts/check_search_ready.py` 가 **이미** robots 의 Yeti 항목과 Yeti UA 의
발행본 접근을 본다. 같은 것을 두 군데서 보면 한쪽만 고쳐지는 날이 온다.
이 모듈은 그 스크립트가 **안 보는 것**만 본다 — 소유확인, 네이버 색인, 역방향 링크,
IndexNow 통보 URL, 그리고 **랜딩**(그 스크립트는 발행본만 본다).