네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
158 lines
12 KiB
Markdown
158 lines
12 KiB
Markdown
# 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, 그리고 **랜딩**(그 스크립트는 발행본만 본다).
|