Compare commits

...

1 Commits

Author SHA1 Message Date
민헌
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
22 changed files with 1876 additions and 3 deletions

View File

@ -31,6 +31,24 @@ PERPLEXITY_API_KEY=
COLLECT_USE_PERPLEXITY=0
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# 네이버 서치어드바이저 소유확인 토큰 = 준 파일명에서 `.html` 을 뺀 값(예: naver1234abcd).
# ★ 네이버는 DNS TXT 를 안 받는다 — 구글처럼 DNS 로 끝낼 수 없다.
# ★ **실제로 내주는 곳은 `nginx/site.conf`** 다(주석 처리된 블록을 풀어 쓴다).
# 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것" — **두 곳이다.**
# nginx 가 env 를 못 읽고 site.conf 는 .example 만 커밋되기 때문이고, 어긋나면 아래 점검이
# 잡는다. 그게 두 곳을 감수하는 근거다(geo/README.md '제약').
# ★ 프론트 재빌드는 필요 없다 — 번들에 안 들어간다. nginx reload 로 끝난다.
# ★ 등록 안 하면 사이트맵 제출·수집 요청·색인 진단을 아예 쓸 수 없다(docs/DEPLOY.md 2-2단계).
# 확인(레포 루트에서): solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py
NAVER_SITE_VERIFICATION=
# ★ 알리기(IndexNow 통보)를 geo 가 맡는다 — 백엔드 쪽이 고장 나 0건이기 때문이다.
# ⚠️ **담당이 두 곳이 되면 안 된다.** 백엔드 indexnow 를 고치는 날 여기를 0 으로 끈다.
# (그대로 두면 같은 URL 이 두 번 나가고 429 대상이 된다)
# 실행: geo/scripts/postflight.py <slug> · 자동: geo/scripts/watch.py
GEO_NOTIFY_ENABLED=1
# geo 가 "무엇을 이미 알렸나" 를 두는 자리. 비우면 geo/state/ 다(git 에 안 올라간다).
GEO_STATE_DIR=
# 미발급. 없으면 네이버 지역검색을 쓴다
KAKAO_REST_API_KEY=
GEMINI_API_KEY=

4
.gitignore vendored
View File

@ -47,6 +47,10 @@ dist/
# OS
.DS_Store
# geo 가 "무엇을 이미 알렸나" 를 기억하는 자리. 재생성물이라 커밋하지 않는다 —
# 잃으면 전부 다시 통보할 뿐이고, 그건 규격상 정상(변경 통보)이다.
geo/state/
# ── 에이전트 지침은 커밋한다 ──────────────────────────────────────────────
# AGENTS.md / CLAUDE.md 는 팀과 모든 에이전트가 공유하는 규약이라 반드시 커밋한다.
# 커밋 안 하면 클론한 사람이 "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.

View File

@ -11,6 +11,7 @@
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| **네이버**에서 탐색되게 하려면 (구글과 다르다) | [docs/NAVER_EO.md](docs/NAVER_EO.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
@ -100,6 +101,7 @@
```
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
@ -108,6 +110,21 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src`
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
**`geo` 도 한 방향이다 — `geo``solution/backend`.** `admin` 과 같은 방식으로
`solution/backend` 를 PYTHONPATH 로 얹어 쓴다(네이버 API 쿼터 카운터 · `publish_origin`).
**`solution` 이 `geo` 를 import 하면 순환이다** — 그래서 라우터를 solution 의 라우터 트리에
끼우지 않고 진입점이 마운트한다. 워커 핸들러도 같은 이유로 등록만 진입점에서 한다.
**`geo/naver/checks.py` 는 DB 를 보지 않는다** (세션을 인자로도 받지 않는다).
"우리 DB 가 그렇다" 와 "밖에서 그렇게 보인다" 를 한 함수에 섞으면 어긋났을 때 어느 쪽이
틀렸는지 말할 수 없고, 그 어긋남을 찾으려고 만든 모듈이 쓸모를 잃는다([geo/README.md](geo/README.md)).
**`geo``solution/`·`admin/` 의 파일을 고치지 않는다 — import 만 한다.**
그래서 원래 저쪽에 있어야 할 것 셋이 자리를 옮겼고, 대가가 남았다
([geo/README.md](geo/README.md) '제약' 절). 밟기 쉬운 것 둘:
⚠️ **네이버 쿼터 카운터가 둘로 갈렸다**(합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다),
⚠️ **소유확인 토큰이 `nginx/site.conf` 와 루트 `.env` 두 곳에 산다**(어긋나면 점검이 잡는다).
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
| | 포트 | 진입점 | 권한 |
@ -220,6 +237,7 @@ crash 로 굳은 페이지는 content() 가 CDP 응답을 상한 없이 기다
```
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
@ -228,6 +246,21 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src`
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
**`geo` 도 한 방향이다 — `geo``solution/backend`.** `admin` 과 같은 방식으로
`solution/backend` 를 PYTHONPATH 로 얹어 쓴다(네이버 API 쿼터 카운터 · `publish_origin`).
**`solution` 이 `geo` 를 import 하면 순환이다** — 그래서 라우터를 solution 의 라우터 트리에
끼우지 않고 진입점이 마운트한다. 워커 핸들러도 같은 이유로 등록만 진입점에서 한다.
**`geo/naver/checks.py` 는 DB 를 보지 않는다** (세션을 인자로도 받지 않는다).
"우리 DB 가 그렇다" 와 "밖에서 그렇게 보인다" 를 한 함수에 섞으면 어긋났을 때 어느 쪽이
틀렸는지 말할 수 없고, 그 어긋남을 찾으려고 만든 모듈이 쓸모를 잃는다([geo/README.md](geo/README.md)).
**`geo``solution/`·`admin/` 의 파일을 고치지 않는다 — import 만 한다.**
그래서 원래 저쪽에 있어야 할 것 셋이 자리를 옮겼고, 대가가 남았다
([geo/README.md](geo/README.md) '제약' 절). 밟기 쉬운 것 둘:
⚠️ **네이버 쿼터 카운터가 둘로 갈렸다**(합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다),
⚠️ **소유확인 토큰이 `nginx/site.conf` 와 루트 `.env` 두 곳에 산다**(어긋나면 점검이 잡는다).
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
| | 포트 | 진입점 | 권한 |

View File

@ -47,13 +47,15 @@ solution/ 사장님 — 사이트 만들기·관리
admin/ 우리 — 전체 사이트 운영
backend/ 진입점만(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
frontend/ 운영 화면. `@` 별칭이 solution/frontend/src 를 가리킨다
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈.
admin 처럼 solution/backend 를 PYTHONPATH 로 얹어 쓴다
docs/ 아래 표
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋)
postgres-init/ 스키마 DDL
```
의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다.
근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
의존 방향은 `admin → solution` · `geo → solution` 두 줄이고 **둘 다 한 방향**이다.
반대가 생기면 가른 의미가 사라진다. 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
## 문서 지도
@ -66,6 +68,7 @@ postgres-init/ 스키마 DDL
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |
| [docs/COLLECTION_SEO_AEO_FLOW.md](docs/COLLECTION_SEO_AEO_FLOW.md) | 수집→LLM→SEO/AEO 현재 구현 |
| [docs/NAVER_EO.md](docs/NAVER_EO.md) | **네이버는 구조가 다르다** — 무엇을 목표로 잡나 (조사·설계) |
| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 |
## 문서 규칙

View File

@ -116,9 +116,55 @@ o2o-web4ai/
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
│ └─ frontend/ 내부 운영 화면
├─ geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO)
│ ├─ naver/checks.py 밖에서 HTTP 로 본다 — DB 를 보지 않는다
│ └─ scripts/ 사람이 읽는 출력
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
```
### `geo/` — 최상단 모듈이면서 백엔드 코드를 쓴다 (2026-09-11)
검색·AI 엔진이 **밖에서** 우리 사이트를 어떻게 보는지 다룬다. `solution`(만든다)·
`admin`(운영한다)과 대상이 달라서 폴더를 가르고, 동시에 도메인 코드를 복제하지 않는다 —
**`admin/backend` 와 같은 방식**으로 `solution/backend` 를 PYTHONPATH 로 얹는다.
```
admin → solution/backend (place·fact 를 두 번 구현하지 않는다)
geo → solution/backend (네이버 API 쿼터 카운터 · publish_origin)
```
복제하면 조용히 틀리는 값만 빌려 쓴다 — 네이버 지역검색 호출기, `publish_origin()`,
외부 API 설정 객체. **읽어 쓰기만 한다: `solution/``admin/` 의 파일은 고치지 않는다**
(2026-09-11 제약). import 는 수정이 아니다.
**`solution``geo` 를 import 하지 않는다.** 그래서 라우터를 solution 의 라우터 트리에
끼우면 순환이다 — 마운트는 **진입점**이 한다(`admin/backend/app.py`, 또는 geo 자신의 진입점).
워커 핸들러도 같다: 껍데기는 `geo` 에 두고 등록만 진입점에서 한다.
이게 최상단으로 가른 대가이고, 동시에 경계가 지켜지는지 **import 한 줄로 드러나는** 이유다.
⚠️ 그 제약의 대가가 셋 있다. **웹문서검색 호출기가 `geo` 안에 따로 생겨 네이버 쿼터
카운터가 둘로 갈렸고**(앱당 일 25,000회를 공유하는데 백엔드 `call_counts()` 에 안 들어간다),
**`geo/` 가 백엔드 이미지에 없어 컨테이너에서 돌지 않으며**(레포 체크아웃 + 백엔드 venv 로만),
**소유확인이 랜딩 메타태그 대신 `nginx/site.conf` 의 파일 응답**이라 토큰이 두 곳에 산다
(그 대신 프론트 재빌드가 없다). 셋 다 [geo/README.md](../geo/README.md) '제약' 절에
옮길 자리까지 적어 뒀다.
**라우터는 아직 없다.** 공개하는 것은 `await naver.probe(...)` 하나이고 `Finding` 목록만
돌려준다 — 어디서 쓸지는 화면을 만들 때 정한다. 권고는 **:9801(어드민)** 이다
(소유확인·토큰은 우리 일이고, 위 포트 분리 근거가 그대로 적용된다).
⚠️ **모듈 안에서 두 출처를 섞지 않는다.** `naver/checks.py` 는 밖에서 HTTP 로만 보고 **DB 를
보지 않는다**(세션을 인자로도 받지 않는다). "우리 DB 가 그렇다고 한다" 와 "밖에서 실제로
그렇게 보인다" 가 한 함수에 섞이면 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 —
그 어긋남을 찾는 것이 이 모듈의 존재 이유다.
⚠️ **소유확인처럼 "심는" 일은 이 모듈이 하지 않는다.** HTML 을 만드는 쪽(`solution/frontend`
랜딩 `<head>`)이 심고, 이 모듈은 그것이 밖에서 실제로 보이는지만 본다. 한 곳에 두면
"내가 심었으니 있다" 를 확인이라고 부르게 된다.
경계와 아직 안 만든 것(표·잡·GEO 측정)은 [geo/README.md](../geo/README.md).
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,

View File

@ -178,7 +178,7 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
|---|---|---|
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
| 네이버 서치어드바이저 | 메타태그 / HTML 파일 (**DNS TXT 를 안 받는다**) | 아직 안 붙였다 |
| 네이버 서치어드바이저 | **HTML 파일** (DNS TXT 를 안 받는다) | `nginx/site.conf` 가 직접 내준다 (**git 에 없다** — 서버 로컬) |
**파일 방식은 재배포가 필요하다.** `public/``solution-site` 이미지에 구워지므로
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
@ -192,6 +192,41 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
```
#### 네이버 — nginx 가 파일을 내준다
네이버는 구글처럼 DNS 로 끝낼 수 없다(**DNS TXT 를 안 받는다**). 남은 것은 메타태그와
HTML 파일 둘이고, **파일 + nginx** 로 간다.
메타태그를 안 쓰는 이유: 랜딩 `<head>``solution/frontend` 가 만들고 값이 `VITE_*`
**번들에 구워진다** — 토큰을 바꿀 때마다 `./deploy.sh solution-site` 재빌드가 필요하다.
`nginx/site.conf` 는 **바인드 마운트**라 고치고 reload 하면 끝이고, `solution/` 을 건드리지
않는다.
★★ **그냥 파일을 올리는 것으로는 안 된다.** 오리진 루트의 `*.html` 은 nginx 맨 아래
`location /` 의 SPA 폴백으로 떨어져 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다
(전용 블록이 있는 건 `.txt` 뿐이다 — IndexNow 키). 그래서 **`location =` 블록이 필수**다.
1. 서치어드바이저 > 사이트 관리 > 소유확인에서 **HTML 파일** 방식을 골라 파일명을 확인
(`naver1234abcd.html` 꼴)
2. 서버의 `nginx/site.conf` 에서 "네이버 서치어드바이저 소유확인" 블록의 주석을 풀고
`<토큰>` 두 자리를 파일명(`.html` 제외)으로 바꾼다
3. 루트 `.env``NAVER_SITE_VERIFICATION=<토큰>` — 점검이 대조할 기대값이다
(★ 두 곳이다. nginx 가 env 를 못 읽어서 그렇고, 어긋나면 4번이 잡는다)
4. `docker compose restart solution-site` (또는 nginx reload). **재빌드는 필요 없다**
5. 레포 루트에서 `solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py`
**상태코드가 아니라 내용으로** 본다. "200 이지만 빌더 앱 HTML 이 나온다" 면 2번의 블록이
없거나 파일명이 다른 것이다
6. 서치어드바이저에서 [소유확인]
**등록은 한 번이 끝이 아니다.** 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이
사라진 시점에 등록이 풀리면서 **아무 알림도 오지 않는다.**
`site.conf` 는 git 에 없으므로 **서버를 새로 세우면 2번을 다시 해야 한다** — 빼먹으면
며칠 뒤 조용히 풀린다. 판정 코드는 `geo/naver/checks.py` 한 곳이다.
**등록 전에는 창이 없다.** 사이트맵 제출·웹페이지 수집 요청·색인 진단이 전부 등록된
사이트에만 열린다. IndexNow 통보는 등록 없이도 동작하지만, **통보가 먹었는지 볼 방법이
없다** — 그래서 이 단계가 네이버 쪽 나머지 작업의 전제다.
---
## 4. 나중에 — Azure Blob 을 켤 때

View File

@ -5,6 +5,109 @@
---
## 2026-09-11 — 네이버에서 탐색될 준비 · `geo/` 모듈
**무슨 일** — AEO·SEO 는 발행물 쪽이 촘촘한데 **네이버 쪽은 창이 없었다.** 서치어드바이저
소유확인이 안 붙어 있어서(이전 DEPLOY.md 2-2: "아직 안 붙였다") 사이트맵 제출·수집 요청·
색인 진단을 아예 쓸 수 없고, 통보가 먹었는지 볼 방법도 없었다.
**점검하다 찾은 고장 — IndexNow 가 한 건도 안 나가고 있다.**
```
indexnow.py:52 <out>/s/<slug>/sitemap.xml 을 읽는다
prerender.ts:369 "★ 사이트별 sitemap.xml 을 없앴다" — 더는 굽지 않는다
```
사이트가 한 장이 되면서 사이트별 사이트맵을 루트 한 장으로 합쳤고, nginx 와
`check_search_ready.py` 는 따라갔는데 `indexnow.py` 만 안 따라갔다. 파일이 없으면
`site_urls()` 가 빈 목록을 돌려주고 경고 한 줄만 남긴 뒤 **발행 잡은 성공한다.**
네이버로 가는 유일한 자동 경로가 이것이다. 테스트는 `tmp_path` 에 사이트맵을 손으로 만들어
넣고 시작하므로(`test_indexnow.py:25`) 실물에 그 파일이 없다는 사실이 검증 범위 밖이었다.
→ 백엔드를 못 고치므로 **`geo` 가 알리기를 대신 맡는다**(`geo/naver/notify.py`).
필요한 게 "알릴 주소"와 "키" 둘뿐이고 **DB 가 필요 없어서** 가능하다. 주소는 **루트
사이트맵에서 읽는다** — 규칙을 또 만들면 사이트맵에 없는 URL 을 통보하게 되고 그건 404
통보다. ⚠️ **담당이 두 곳이 되면 안 된다**: 백엔드를 고치는 날 `GEO_NOTIFY_ENABLED=0`.
**발행 전/후를 가른 구조**
```
preflight.py 발행 전 · 오리진 소유확인 · robots · 루트 사이트맵 · IndexNow 키 파일
postflight.py 발행 후 · 사이트 200 확인 → 알린다 → 성공분만 기록
watch.py 계속 루트 사이트맵 lastmod 변화 → 바뀐 것만 postflight
```
★★ **알리기를 발행 전에 하면 안 된다.** 없는 주소를 통보하면 404 를 받고, 그건 알리지 않은
것보다 나쁘다 — 헛주소를 보내는 호스트로 기록된다. 그래서 `postflight` 가 **200 을 확인한
뒤에만** 보낸다.
`watch.py`**루트 사이트맵만 본다.** DB·잡 큐·볼륨을 안 들여다본다 — 크롤러가 발행을
알아채는 방식과 같아서 `solution` 을 고치지 않고 끼어들 수 있고, "밖에서 본다" 성격도 지킨다.
대가는 즉시성(기본 5분)이다.
**모듈 자리 — 최상단 `geo/`, 다만 백엔드 코드를 쓴다**
처음 그은 경계는 **표 안 만듦 · 잡 큐 안 씀 · 모델 import 안 함** 이었다. 그 경계가 곧
기능을 막았다 — 소유확인 주기 재확인은 스케줄러가, 통보 재시도는 잡 큐가, 상태 추적은
표가 필요하다. **밖에서 HTTP 만 보는 경계로는 점검까지만 된다.**
→ 폴더는 최상단에 두고(대상이 `solution`·`admin` 과 다르다), **의존만 허용했다**:
`geo``solution/backend` 를 PYTHONPATH 로 얹는다. `admin/backend` 가 이미 같은 방식이다.
빌려 쓰는 것은 복제하면 조용히 틀리는 값 둘뿐이다 — 네이버 API **쿼터 카운터**(지역검색과
웹문서검색이 앱당 일 25,000회를 공유한다)와 `publish_origin()`(새 env 로 두면 canonical 과
갈린다).
→ 경계는 폴더가 아니라 **파일과 import 방향**으로 지킨다: `checks.py` 는 DB 를 보지 않고
세션을 인자로도 받지 않는다. 그리고 **`solution``geo` 를 import 하지 않는다** — 라우터·
워커 핸들러는 solution 의 등록표가 아니라 **진입점**이 마운트해야 순환이 안 생긴다.
**제약 — `solution/``admin/` 은 한 줄도 고치지 않는다.**
import 는 자유롭지만 수정은 안 된다. 그래서 세 가지가 이상적인 자리에 없고, **대가를 적어
두고** 갔다(geo/README.md '제약' 절, 옮길 자리까지 명시).
- `NaverLocalClient.search_web()` 대신 `geo/naver/web_search.py` — ⚠️ **쿼터는 하나인데
카운터가 둘이다.** 앱당 일 25,000회를 지역검색과 공유하는데 백엔드 `call_counts()` 에는
여기 호출이 안 들어간다. 합계는 `geo.naver.web_search_call_count()` 를 같이 읽어야 한다
- `COPY geo` 를 못 넣어 **컨테이너에서 안 돈다** — 레포 체크아웃 + 백엔드 venv 로만 돈다.
정기 실행이 필요해지면 `geo/Dockerfile` 로 자기 이미지를 갖는 것이 이 제약 아래서의 길이다
- 소유확인을 랜딩 메타태그(`solution/frontend/src/root.tsx`)로 심으려 했다가 **`nginx/site.conf`
가 파일을 내주는 방식**으로 옮겼다. ⚠️ 토큰이 두 곳에 산다(`site.conf` = 나가는 값,
루트 `.env` = 점검이 대조할 기대값 — nginx 가 env 를 못 읽는다). **어긋나면 점검이 잡는다.**
덤으로 얻은 것: `VITE_*` 가 아니라서 **프론트 재빌드가 없다**. 그래서 이 줄은 제약이
풀려도 되돌리는 게 이득인지 다시 따져야 한다
- 지역검색·`publish_origin`·설정 객체는 기존 코드를 **그대로 읽어 쓴다**(수정 없음)
**★★ 소유확인에서 가장 밟기 쉬운 것** — 오리진 루트의 `*.html` 은 nginx 맨 아래 `location /`
의 SPA 폴백으로 떨어져 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다(전용 블록이 있는 건
`.txt` 뿐이다 — IndexNow 키). 파일을 올바로 올려도 검색엔진은 빌더 HTML 을 받고 "확인 실패"
만 뱉는다. 그래서 `location =` 블록이 필수이고, 점검은 **상태코드가 아니라 내용**으로 한다.
**한 일**
- `geo/naver/checks.py`: 소유확인 메타태그 · Yeti 로 랜딩 · IndexNow 통보 URL 재현 ·
웹문서 색인 · 스마트플레이스 역방향 링크. **`Finding` 목록만 돌려준다** — 라우터·워커 잡·
CLI 가 같은 함수를 쓰게. 화면에 찍는 일은 `geo/scripts/check_naver_eo.py` 가 한다.
`check_search_ready.py` 가 이미 보는 것(robots 의 Yeti, Yeti 의 발행본 접근)은 **다시
보지 않는다** — 한쪽만 고쳐지는 날이 온다.
- **라우터는 안 붙였다.** 어디서 쓸지는 화면을 만들 때 정한다. 붙일 때 권고와 주의는
[geo/README.md](../geo/README.md).
- `external/naver.py``search_web()` 추가. **같은 클라이언트에 둔 이유는 쿼터가 하나**라서다
— 지역검색과 웹문서검색이 앱당 일 25,000회를 공유하므로, 호출기를 따로 만들면 카운터가
갈려 아무도 정확히 못 센다. `_get``url`·`api` 를 받게 한 것이 그 때문이다.
- `nginx/site.conf.example` 에 소유확인 `location =` 블록(주석 처리 + 근거)과
`.env.example``NAVER_SITE_VERIFICATION`. `site.conf` 는 **바인드 마운트**라 재빌드 없이
reload 로 반영된다. ⚠️ 다만 그 파일은 git 에 없다 — **서버를 새로 세우면 다시 넣어야 하고**,
빼먹으면 며칠 뒤 소유확인이 조용히 풀린다. 절차는 [DEPLOY.md 2-2단계](DEPLOY.md).
**작업 중 잡은 것 (둘)**
- `NaverPlace` 의 필드는 `link` 가 아니라 `place_url` 이다. 응답 키 이름을
그대로 쓴 초안은 역방향 링크가 **항상 "없다"** 로 나왔다.
- `robots.txt` 그룹 경계를 빈 줄로만 끊었더니, 규칙 뒤에 붙은 `User-agent:` 가 앞 그룹에
합쳐져 **`*` 의 Disallow 가 Yeti 에도 적용됐다.** RFC 9309 상 규칙 뒤의 `User-agent`
새 그룹이다. 단위 테스트에서 "Yeti 가 전체 차단" 이 잘못 뜨면서 드러났다.
**안 한 것** — GEO 측정(Brand AEO B1~B5). 자리는 `geo/engines/` 이고 먼저 정할 것은
비용이다. 설계서 기본값 100문항×4엔진×3회 = 테넌트당 주 1,200회 호출로 사이트당 $1 상한과
정면으로 부딪친다([DEVELOPMENT_DIRECTION.md 3-2](DEVELOPMENT_DIRECTION.md)).
**검증** — 가짜 사이트맵·가짜 IndexNow 서버로 **7가지 시나리오**를 돌렸다:
slug 경계(`joy` 가 `joy-cafe` 를 안 끌고 온다) · dry-run(안 보낸다) · 실제 통보(본문의 host·
keyLocation·urlList) · 첫 tick(전부 새 것) · 두 번째 tick(안 보낸다) · lastmod 변경분만 ·
**200 이 아니면 통보 안 함**. preflight 은 정상/고장 두 상태로. robots 판정은 단위로.
그 밖에 문법·import 표면 확인. **실제 도메인 점검과 `pytest`·`tsc`·빌드는 못 돌렸다**
이 클론에 `.venv`·`node_modules`·`.env` 가 없다.
---
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,

215
docs/NAVER_EO.md Normal file
View File

@ -0,0 +1,215 @@
# NAVER_EO — 네이버에서 탐색되게 만들기 (조사와 설계)
> 조사일 2026-09-11. 구현 현황은 [geo/README.md](../geo/README.md),
> 제품 판단은 [PRODUCT.md](PRODUCT.md), 발행 절차는 [DEPLOY.md](DEPLOY.md).
**이 문서를 쓴 이유.** 우리 AEO·SEO 는 구글·AI 크롤러를 겨냥해 만들어졌다. 네이버는
구조가 달라서 같은 노력이 같은 결과를 내지 않는다. 무엇이 다르고, 그래서 **무엇을 목표로
잡아야 하는지**를 먼저 정한다.
---
## 0. 한 문장으로
> 네이버에서 우리 목표는 **"AI 답변에 인용되기"가 아니라, "플레이스와 공식 홈페이지가 한
> 업소로 묶이고 웹문서 검색에 잡히기"** 다.
[PRODUCT.md 1절](PRODUCT.md)이 말하는 "AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게"는
ChatGPT·Perplexity·Gemini 에는 그대로 통한다. **네이버에서는 경로가 다르다** — 아래 2절이 근거다.
이 차이를 모른 채 같은 전략을 밀면, 되지 않는 일에 시간을 쓰고 **될 일(플레이스 결합)을 놓친다.**
---
## 1. 조사 — 사실과 출처
**출처 성격을 같이 적는다.** 네이버는 랭킹 요소를 공개하지 않아서 업계 관측이 많이 섞인다.
관측을 공식처럼 인용하면 그 위에 쌓은 설계가 조용히 틀린다.
| # | 사실 | 성격 |
|---|---|---|
| 1 | 네이버 검색은 **통합검색 → 스마트블록(에어서치) → AI 브리핑** 층으로 답을 조립한다. 하나의 키워드를 의도 단위 블록으로 쪼갠다 | 공식 + 관측 |
| 2 | **AI 브리핑**이 생성형 AI 검색의 본류다. 2025-03 도입, 답변 근거로 쓴 **출처 콘텐츠를 함께 제시**한다. 실험 서비스 `Cue:` 는 **2026-04-09 종료** | 공식(보도) |
| 3 | ★★ **AI 브리핑의 출처는 네이버 생태계(블로그·카페·지식iN)와 뉴스·공식문서 비중이 높다** | 업계 관측 |
| 4 | AI 브리핑에 인용되는 조건으로 관측되는 것 넷: **정의형·Q&A 구조 · 경험 기반 수치 · 주제 세분화(주제당 1문서) · 신뢰 신호(작성자·자격, Schema 마크업)** | 업계 관측 |
| 5 | **C-Rank**(출처의 신뢰도) · **D.I.A / D.I.A+**(문서가 질의 의도에 얼마나 맞나)는 **블로그 중심** 알고리즘이다. 웹문서/사이트에 그대로 적용된다는 근거는 없다 | 관측 |
| 6 | 웹문서·사이트 노출은 **보장되지 않는다.** 콘텐츠 품질과 이용자 선호를 종합해 네이버가 판단한다 | 공식 |
| 7 | ★ **사이트명·사이트설명·Open Graph 제목·Open Graph 설명이 품질 판단 기준에 들어간다** | 공식 |
| 8 | **복사·붙여넣기한 내용은 "유사 문서"로 판단해 노출에서 제외**한다. 스팸이 아니어도 품질을 떨어뜨린다고 보면 노출되지 않는다 | 공식 |
| 9 | 신규 사이트는 **수집·노출까지 약 2~14일** | 공식 |
| 10 | **Yeti** 는 JS 영향도를 측정·해석하지만 **SSR 을 권장**한다. robots.txt 로 JS·CSS 리소스를 막으면 **그 페이지가 수집되지 않는다** | 공식 |
| 11 | 서치어드바이저 기능: **소유확인 · 수집 요청 · 사이트맵/RSS 제출 · 웹페이지 최적화 진단 · 노출·클릭 통계** | 공식 |
| 12 | 진단이 보는 필수 항목: **`<title>` · `meta description` · `<h1>` · 이미지 `alt`** (+ 프로토콜 불일치 내부링크, 접근 차단 리소스 등 10여 유형) | 공식 |
| 13 | 통계는 **최대 90일**, 플랫폼별 노출·클릭, **검색 키워드 top10 · 검색 문서 top10** | 공식 |
| 14 | 소유확인은 **HTML 파일 업로드 또는 `<head>` 메타태그**. DNS TXT 를 받지 않는다 | 공식 |
| 15 | **IndexNow 지원 (2023-07)** — 새 페이지·수정·삭제를 통보할 수 있다 | 공식 |
| 16 | 플레이스 순위는 **정보 충실도보다 행동 데이터**(저장·예약·주문·길찾기·리뷰·재방문)가 무겁다. **사업자 인증으로 등록한 업체가 우선**되는 경향 | 업계 관측 |
---
## 2. 그래서 우리 제품에 무엇을 의미하나
### 2-1. ★ 가장 중요한 판단 — 네이버에서는 "인용"을 목표로 두지 않는다
조사 3·4 가 핵심이다. AI 브리핑은 **자기 생태계 문서를 우선 인용**하고, 인용 조건으로 관측된
것들(주제당 1문서, 경험 수치, 작성자 신뢰)은 **블로그 운영 전략**이다. 우리 산출물은 사장님
한 곳당 정적 한 장이고, 블로그를 운영하지 않는다.
**네이버 AI 브리핑 인용을 성공 기준으로 잡으면 안 된다.** 잡으면 두 가지가 따라온다:
① 되지 않는 일(생태계 밖 문서를 인용원으로 밀기)에 비용을 쓰고,
② "주제당 1문서" 를 따르려고 **[사이트 하나 = 한 장](ARCHITECTURE.md) 결정을 흔든다** —
그 결정은 페이지가 얇아지면 색인에서 버려진다는 근거로 내린 것이다(2026-08-31).
### 2-2. 네이버에서 우리가 실제로 가질 수 있는 자리 셋
| | 무엇 | 근거 | 지금 |
|---|---|---|---|
| **A. 웹문서 검색 노출** | "상호명" 질의에 공식 홈페이지가 잡힌다 | 조사 6·7·8·10 | 정적 HTML·OG 완비로 **조건은 이미 충족**. 등록이 안 돼 있다 |
| **B. 플레이스 ↔ 홈페이지 결합** | 스마트플레이스 "홈페이지" 칸이 우리 주소를 가리킨다 | 조사 16 | **비어 있다.** 사장님이 직접 넣어야 하고, 안내하는 자리가 없다 |
| **C. 공식 출처 지위** | 뉴스·공식문서 층에서 "그 업소의 1차 출처" 로 취급 | 조사 3·7 | 판단 근거가 약하다. 관측 대상 |
**A 와 B 가 이번 설계의 목표다.** C 는 A·B 가 서면 따라올 수 있는 것이지 직접 만들 수 없다.
### 2-3. 이미 하고 있어서 안 할 일
조사 7·8·10·12 가 요구하는 것은 **우리가 이미 다른 이유로 하고 있다.**
| 네이버가 보는 것 | 우리 쪽 이미 있는 자리 |
|---|---|
| SSR / 정적 HTML (조사 10) | 발행물이 정적 HTML — 제품 원칙 1번 |
| OG 제목·설명 (조사 7) | `seo/head.ts` 의 og:* + `og:locale ko_KR` |
| `<title>` · description · `h1` · `alt` (조사 12) | `seo/meta.ts`(길이 50~160자 보정) · 게이트가 alt 없는 사진을 안 싣는다 |
| 유사문서 회피 (조사 8) | **게이트 규칙 2 — 고유 콘텐츠 0건이면 발행 거부** |
| robots 로 JS·CSS 를 막지 않기 (조사 10) | `seo/robots.ts``Allow: /` 이고 앱 경로만 막는다 |
**네이버용으로 새로 만들 문서 최적화는 거의 없다.** 빈 것은 **등록·결합·측정**이다.
이게 이 조사의 결론이고, 아래 설계가 그 셋만 다루는 이유다.
---
## 3. 설계 — 네 층, 순서가 곧 우선순위
### L0. 등록 (전제 — 이게 없으면 나머지가 관측 불가)
```
소유확인 → 사이트맵 제출 → 수집 요청 → 진단 확인 → 통계 열림
```
- 소유확인은 **메타태그 또는 HTML 파일**뿐이다(조사 14). 우리 구성에서의 함정과 절차는
[DEPLOY.md 2-2단계](DEPLOY.md) — 루트의 `*.html` 은 nginx SPA 폴백으로 떨어져 **404 가 아니라
빌더 앱 HTML 이 200 으로** 나간다
- ★ **등록 전에는 창이 아예 없다.** 사이트맵 제출·수집 요청·진단·통계가 전부 등록된 사이트에만
열린다. IndexNow 는 등록 없이도 동작하지만 **먹었는지 볼 방법이 없다**
- 오리진이 하나라 **루트에서 한 번** 하면 `/s/<slug>` 전부가 딸려온다
### L1. 문서 품질 — 진단 항목과 1:1 로 맞춘다
조사 12 의 필수 4항목은 우리가 이미 채운다(2-3절). 여기서 할 일은 **만드는 것이 아니라
어긋남을 잡는 것**이다. `geo/naver/checks.py` 가 보는 자리:
- `<title>` 존재·길이, `meta description` 존재·길이, `h1` 1개, 이미지 `alt` 누락 수
- 프로토콜 불일치 내부링크 — TLS 를 앞단 Apache 가 끊어 nginx `$scheme` 가 늘 `http`
이 구성에서 **실제로 밟을 수 있는 함정**이다([AGENTS.md](../AGENTS.md))
- ⚠️ **진단은 네이버가 자기 기준으로 다시 본다.** 우리 점검이 통과해도 진단이 지적할 수 있다 —
점검은 "명백히 빠진 것" 을 미리 잡는 것이고, 확정 판정은 서치어드바이저다
### L2. 엔티티 결합 — 여기가 네이버에서 가장 값어치 있는 자리
| | 방향 | 지금 |
|---|---|---|
| 우리 → 네이버 | `sameAs` 에 확정된 플레이스·예약 URL | **있다**(`seo/jsonld.ts`, 확정 채널만) |
| **네이버 → 우리** | 스마트플레이스 "홈페이지" 칸 = 발행본 주소 | ★ **없다. 이번 설계의 핵심 공백** |
| 값 일치 | 상호·주소·전화가 플레이스와 같아야 한다 | 수집이 플레이스에서 오므로 대개 같다. **틀어진 것을 잡는 점검이 없다** |
**역방향이 왜 중요한가.** 조사 16 에 따르면 플레이스는 사업자 인증 업체를 우선하고, 순위는
행동 데이터로 움직인다. **우리는 행동 데이터를 만들 수 없다.** 우리가 줄 수 있는 건
"이 업소의 공식 홈페이지가 여기다" 라는 **결합 신호 하나**이고, 그건 사장님이 스마트플레이스에
주소를 넣는 것으로만 생긴다. 비용 0, 우리가 통제 불가, 효과는 가장 큼 →
**제품이 해야 할 일은 "사장님이 그걸 하도록 만드는 것"** 이다(발행 완료 화면의 안내 한 줄).
### L3. AEO — 네이버판은 "인용" 이 아니라 "질문에 답하는 형태"만 가져온다
조사 4 의 넷 중 **우리 구조에 맞는 둘만** 취한다.
| 조건 | 취하나 | 왜 |
|---|---|---|
| 정의형·Q&A 구조 | **취한다** | 우리 FAQ·핵심정보 블록이 이미 그 형태다. `llms.txt` 도 같다 |
| 경험 기반 수치 | **취한다** | 확인된 fact(체크인 시각·주차 대수·요금)가 곧 수치다. **지어내지 않는다**는 규칙과 충돌하지 않는다 |
| 주제 세분화(주제당 1문서) | ❌ **안 한다** | "사이트 하나 = 한 장" 결정과 정면 충돌. 쪼개면 페이지가 얇아진다([ARCHITECTURE.md 5절](ARCHITECTURE.md)) |
| 작성자·자격 신뢰 신호 | **부분** | 사업자 정보·검증 시각은 있다. **개인 작성자 자격은 우리 제품에 없는 개념**이다 |
### L4. 측정 — 확정 창은 서치어드바이저 하나뿐이고 API 가 없다
| 무엇 | 어떻게 | 한계 |
|---|---|---|
| 확정 노출·클릭·키워드 top10 | 서치어드바이저 화면 | ★ **공개 API 가 없다.** 사람이 보는 수밖에 없다 |
| 색인 여부(근사) | **웹문서 검색 API**로 우리 호스트가 잡히나 | 통합검색 색인과 **같지 않다.** 잡히면 확실, 없으면 **미확정** |
| 역방향 링크 | **지역검색 API**의 `place_url` | 후보 5건·전화번호 없음 → 동명 업소 판별이 약하다. 단정하지 않는다 |
| 통보가 나갔나 | IndexNow 응답 + 통보 URL 존재 여부 | 먹었는지는 등록 후 서치어드바이저로만 |
⚠️ **쿼터.** 네이버 검색 API 는 앱당 **일 25,000회**이고 지역검색·웹문서검색이 **공유**한다.
사이트가 1,000개면 점검 1회에 2,000회다 — **전수 점검을 매일 돌릴 수 없다.**
→ 설계에 넣을 것: **표본 점검 + 변경분 우선**, 그리고 호출 예산을 코드가 알고 멈추는 상한.
---
## 4. 구현 계획 — 무엇을 언제
### 이미 있는 것 (`geo/naver/checks.py`)
소유확인 파일 · Yeti 로 랜딩 · IndexNow 통보 URL 재현 · 웹문서 색인(근사) · 역방향 링크.
### P1 — 지금 할 것 (제약 안에서 가능)
0. ~~알리기~~**완료.** `geo` 가 맡는다(발행 후 `postflight.py`, 자동은 `watch.py`)
1. **L1 진단 항목 점검 추가**`<title>`·description·`h1`·`alt`·프로토콜 불일치 링크.
발행본 HTML 을 받아 세는 것뿐이라 외부 호출이 0이다
2. **값 일치 점검** — 플레이스의 상호·주소·전화 vs 발행본 JSON-LD. 지역검색 1회로 본다
3. **호출 예산** — 점검 1회당 네이버 API 상한을 코드가 알고 넘으면 멈춘다(위 쿼터)
### P2 — 제약이 풀려야 하는 것
| | 왜 막혀 있나 |
|---|---|
| ~~IndexNow 고장 수정~~ | **우회했다**`geo/naver/notify.py` 가 루트 사이트맵을 읽어 대신 보낸다. 백엔드를 고치는 날 `GEO_NOTIFY_ENABLED=0` 으로 여기를 끈다 |
| 스마트플레이스 안내 UI | 발행 완료 화면은 `solution/frontend` 다. **L2 의 핵심 공백이 여기 걸려 있다** |
| 주기 재확인 잡 | `JobType`·`worker/handlers.py` 등록표가 백엔드에 있다. 우회하려면 `geo/Dockerfile` + 자기 스케줄러 |
| 점검 결과 저장·추이 | 표가 필요하고, **읽는 화면이 생긴 뒤에 만든다**([geo/README.md](../geo/README.md)) |
**P2 의 첫 줄이 가장 급하다.** L0~L2 를 다 해도 IndexNow 가 0건이면 네이버에 **알릴 통로가
없다** — 수집을 2~14일(조사 9) 기다리는 것과 즉시 통보의 차이다.
---
## 5. 아직 안 정한 것
- **L3 의 "주제 세분화" 를 영구히 거절할 것인가.** 지금은 거절이고 근거는 "한 장" 결정이다.
네이버 스마트블록에서 세분화가 실제로 얼마나 유리한지는 **우리 데이터로 측정한 적이 없다**
- **서치어드바이저 통계를 사람이 보는 것 말고 방법이 있나.** API 가 없다. 화면 캡처·수동 입력은
사람 손이 든다. 그럴 값어치가 있는지는 사이트 수가 늘어난 뒤 판단
- **네이버 AI 브리핑 인용이 정말 불가능한가.** 조사 3 은 **업계 관측**이다. 반증이 나오면
2-1 절을 다시 쓴다 — 그때 이 문서에 날짜와 함께 적는다
## 6. 하지 않는 것
| 안 한다 | 왜 |
|---|---|
| 블로그·카페 대량 발행으로 인용 노리기 | 유사문서 판정(조사 8) 대상이고, [PRODUCT.md 6절](PRODUCT.md) non-goal 이다 |
| 플레이스 행동 데이터 만들기(저장·리뷰 유도) | 어뷰징이다. 우리가 줄 것은 결합 신호뿐이다 |
| 네이버 검색창 긁어 순위 보기 | 봇 탐지 우회 영구 금지([DECISIONS.md](DECISIONS.md) 1-1). 공식 API 만 쓴다 |
| 키워드를 노린 문서 자동 증식 | 게이트 규칙 2 를 우회하는 짓이다. 게이트가 제품이다 |
---
## 출처
조사일 2026-09-11. 공식 문서(서치어드바이저·웹마스터 도구 안내)는 도구 화면 안에 있어 직접
링크가 어려운 것이 있고, 그 경우 이를 인용한 2차 자료를 적었다.
- 네이버 IndexNow 지원 — <https://news.hada.io/topic?id=19225>
- 네이버 AI 브리핑 · `Cue:` 종료 — <https://www.i-boss.co.kr/ab-2877-16898>
- AI 브리핑 인용 조건(관측) — <https://blog.oneplan.co.kr/naver-ai-search-optimization/>
- C-Rank · D.I.A(관측) — <https://locaposting.com/blog/naver-crank-dia-algorithm>
- 웹마스터 도구 노출 조건·품질 기준(공식 인용) — <https://www.imweb.me/faq?mode=view&category=29&category2=35&idx=623>
- 서치어드바이저 기능·진단 항목 — <https://www.interad.com/insights/naver-search-advisor-update>
- 소유확인·사이트맵 제출 절차 — <https://help.sixshop.com/learn-sixshop/store-manager/add-ons/naver-webmaster>
- 플레이스 순위 요소(관측) — <https://bbima.kr/blog/naver-place-ranking-2026>

157
geo/README.md Normal file
View File

@ -0,0 +1,157 @@
# 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, 그리고 **랜딩**(그 스크립트는 발행본만 본다).

10
geo/__init__.py Normal file
View File

@ -0,0 +1,10 @@
"""GEO — 검색·AI 엔진에 우리 사이트가 어떻게 보이는지.
최상단 프로젝트이면서 **백엔드 코드를 쓰는 모듈**이다. `admin/backend` 같은 방식으로,
도메인 코드를 복제하지 않고 `solution/backend` PYTHONPATH 얹어 쓴다
(`services.external.naver` 쿼터 카운터 · `services.site_payload.publish_origin`).
**의존은 `geo` `solution/backend` 방향이다.** `solution` `geo` import 하면
순환이 된다 라우터를 붙일 때는 solution 라우터 트리가 아니라 **진입점** 마운트한다
(`admin/backend/app.py`, 또는 geo 자신의 진입점). 근거는 README.md.
"""

40
geo/naver/__init__.py Normal file
View File

@ -0,0 +1,40 @@
"""네이버 탐색 최적화(Naver EO).
공개 면은 probe 하나다 라우터·워커 ·CLI **같은 함수** 부른다.
경계와 아직 만든 것은 ../README.md.
"""
from geo.naver.checks import (
FAIL,
OK,
SKIP,
WARN,
Finding,
NaverEoReport,
probe,
verification_token,
)
# ★ 쿼터 실사용은 이 값과 백엔드의 `services.external.naver.call_counts()` 를 **합쳐야**
# 나온다 — 네이버 검색 API 는 앱당 일 25,000회를 지역검색·웹문서검색이 공유하는데
# 호출기가 두 벌이라 카운터도 둘이다(web_search.py 머리주석).
from geo.naver.web_search import call_count as web_search_call_count
# 알리기. ★ 담당이 두 곳이 되면 안 된다 — 백엔드의 indexnow 가 고쳐지는 날
# `GEO_NOTIFY_ENABLED=0` 으로 여기를 끈다(notify.py 머리주석).
from geo.naver.notify import NotifyResult, notify_site
from geo.naver.notify import enabled as notify_enabled
__all__ = [
"probe",
"Finding",
"NaverEoReport",
"verification_token",
"web_search_call_count",
"notify_site",
"notify_enabled",
"NotifyResult",
"OK",
"WARN",
"FAIL",
"SKIP",
]

24
geo/naver/_http.py Normal file
View File

@ -0,0 +1,24 @@
"""이 패키지가 밖으로 나갈 때 공통으로 쓰는 것.
모듈 (checks·robots·notify) 같은 UA·타임아웃·실패 처리를 쓴다. 각자 두면
"점검은 Yeti 로 받았는데 통보는 브라우저 UA" 같은 어긋남이 생기고, 그건 재현이 된다.
"""
import httpx
# 네이버 검색 로봇의 실제 UA. 이걸로 받아 봐야 CDN·WAF 가 네이버만 막는 상태가 보인다.
YETI_UA = "Mozilla/5.0 (compatible; Yeti/1.1; +http://naver.me/spd)"
BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36"
TIMEOUT_SEC = 15.0
async def get(client: httpx.AsyncClient, url: str, ua: str = BROWSER_UA) -> httpx.Response | None:
"""실패를 예외로 올리지 않는다 — 한 항목이 죽어도 나머지 점검은 끝까지 돈다."""
try:
return await client.get(url, headers={"User-Agent": ua})
except httpx.HTTPError:
return None
def host(origin: str) -> str:
return origin.split("//", 1)[-1].rstrip("/")

330
geo/naver/checks.py Normal file
View File

@ -0,0 +1,330 @@
"""네이버가 우리 사이트를 어떻게 보는지 **밖에서** 확인한다.
파일명이 `probe.py` 아닌 이유: 공개 함수가 `probe()` 라서 패키지에서 이름이 겹친다.
`from geo.naver import probe` 모듈이 아니라 함수를 주게 되고, `geo.naver.probe`
모듈 속성에 닿으려던 코드가 조용히 함수를 집는다(실제로 걸렸다).
파일은 **DB 보지 않는다.** 세션을 인자로도 받지 않는다.
"우리 DB 가 그렇다고 한다" "밖에서 실제로 그렇게 보인다" 함수에 섞으면,
둘이 어긋났을 어느 쪽이 틀렸는지 말할 없다 ** 어긋남을 찾는 것이 모듈의
존재 이유**. 저장·판정 이력은 같은 패키지의 다른 파일이 맡는다(../README.md).
화면에 찍지 않는다. `Finding` 목록만 돌려준다 라우터·워커 ·CLI 같은 결과를
쓰게 하려는 것이다. 사람이 읽는 출력은 `geo/scripts/check_naver_eo.py` 만든다.
공식 API 쓴다. 네이버 검색창을 긁지 않는다 탐지 우회는 결론과 무관하게 영구
금지다(docs/DECISIONS.md 1-1).
네이버만 따로 보나 소상공인 검색 트래픽의 주력이면서, 우리가 가진 자동 경로가
IndexNow 하나뿐이다. 구글은 Search Console, Bing 웹마스터도구가 상태를 보여 주지만
네이버는 **서치어드바이저에 등록되기 전까지 아무 창도 없다.** 창이 없는 동안 잘못돼도
방법이 없어서, 지금 당장 확인할 있는 것만 본다.
"""
import os
import re
from dataclasses import asdict, dataclass, field
import httpx
from geo.naver._http import TIMEOUT_SEC, YETI_UA, get as _get, host as _host
from geo.naver.web_search import NaverWebSearchUnavailable, search_web
from geo.naver.web_search import enabled as web_search_enabled
from services.external.naver import NaverLocalClient, NaverNotConfigured, NaverRequestFailed
# 소유확인 토큰 = 서치어드바이저가 준 **파일명에서 `.html` 을 뺀 값**(예: naver1234abcd).
# ★ 이 값은 `nginx/site.conf` 에도 적힌다 — **두 곳이다.** nginx 가 env 를 못 읽고, 그 파일은
# `.example` 만 커밋되기 때문이다. 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것"
# 이고 **어긋나면 이 점검이 잡는다.** 그게 두 곳을 감수하는 근거다(geo/README.md '제약').
# ★ pydantic 설정이 아니라 env 를 직접 읽는다 — site_payload.SITE_HOST_ENV·indexnow.key() 와
# 같은 계열의 값이고, 발행 계열 env 는 그 관행이다.
VERIFICATION_ENV = "NAVER_SITE_VERIFICATION"
OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip"
@dataclass(frozen=True)
class Finding:
"""점검 한 건. `fix` 는 **사람이 다음에 할 일**이다 — 없으면 할 일이 없다는 뜻이다."""
id: str
label: str
status: str
detail: str
fix: str | None = None
@dataclass
class NaverEoReport:
origin: str
findings: list[Finding] = field(default_factory=list)
@property
def failed(self) -> list[Finding]:
return [f for f in self.findings if f.status == FAIL]
@property
def warned(self) -> list[Finding]:
return [f for f in self.findings if f.status == WARN]
@property
def ok(self) -> bool:
"""실패가 없으면 True. **주의(WARN)는 실패로 세지 않는다** — 미확정이 섞여 있다
(네이버 색인은 등록 전에는 확정할 방법이 없다)."""
return not self.failed
def as_dict(self) -> dict:
return {
"origin": self.origin,
"ok": self.ok,
"findings": [asdict(f) for f in self.findings],
}
def verification_token() -> str:
return os.environ.get(VERIFICATION_ENV, "").strip()
# ── 소유확인 ─────────────────────────────────────────────────────────────
async def check_verification(client: httpx.AsyncClient, origin: str, token: str | None = None) -> Finding:
"""소유확인 파일이 오리진 루트에서 열리는가 — `https://<host>/<token>.html`.
**메타태그가 아니라 파일 방식이다.** 메타태그는 랜딩 `<head>` 들어가야 하는데 그건
`solution/frontend` 만든다 `solution/` 고치지 않기로 했다(2026-09-11).
그래서 nginx 직접 내준다(`nginx/site.conf` `location = /<token>.html`).
덤으로 얻은 : **프론트를 다시 구울 필요가 없다.** 값이 번들에 들어간다.
**상태코드로 판단하면 된다.** nginx 아래 `location /` SPA 폴백을 주므로,
블록이 없거나 파일명이 글자 틀리면 **404 아니라 빌더 HTML 200 으로** 나간다.
검색엔진은 "확인 실패" 뱉고 이유를 알려주는데, 눈으로는 파일이 있는 것처럼 보인다.
그래서 **내용**으로 본다. `docs/DEPLOY.md` Bing 파일에 대해 하는 경고와 같은 것이다.
통과했다고 끝이 아니다. 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이 사라진
시점에 등록이 풀리면서 **아무 알림도 오지 않는다.**
"""
token = verification_token() if token is None else token.strip()
if not token:
return Finding(
"verification", "소유확인 파일", WARN,
f"{VERIFICATION_ENV} 가 비었다 — 서치어드바이저 등록 전이다",
"docs/DEPLOY.md 2-2단계",
)
path = f"/{token}.html"
res = await _get(client, origin + path)
if res is None:
return Finding("verification", "소유확인 파일", FAIL, f"{path} 에 연결하지 못했다")
if res.status_code != 200:
return Finding(
"verification", "소유확인 파일", FAIL,
f"{path} 이 HTTP {res.status_code}",
"nginx/site.conf 에 location = " + path + " 블록이 있는지 확인해라",
)
if token not in res.text:
# 여기가 이 점검의 핵심이다 — 200 인데 내용이 다르면 거의 항상 SPA 폴백이다.
shell = "id=\"root\"" in res.text or len(res.text) > 2000
why = "빌더 앱 HTML 이 200 으로 나온다" if shell else "내용에 토큰이 없다"
return Finding(
"verification", "소유확인 파일", FAIL,
f"{path} 이 200 이지만 {why} ({len(res.text)}바이트)",
"location 블록이 없거나 파일명이 다르다 — nginx/site.conf 를 확인하고 reload 해라",
)
return Finding("verification", "소유확인 파일", OK, f"{path} — 토큰이 내용에 있다 ({len(res.text)}바이트)")
async def check_yeti_landing(client: httpx.AsyncClient, origin: str) -> Finding:
"""랜딩을 **Yeti UA 로** 받아 본다.
`scripts/check_search_ready.py` 발행본(`/s/<slug>`) 본다. 랜딩은 아무도 보는데,
소유확인이 여기 붙고 네이버가 사이트를 처음 여는 자리도 여기다.
글자 수를 세는 이유: 랜딩은 프리렌더 대상이다(`react-router.config.ts` `prerender`).
설정이 빠지면 200 그대로인데 본문이 0자가 된다 실측(2026-09-07) 3,021바이트에
`<a>` 0·본문 0자였다. 상태코드로는 보이는 종류다.
"""
res = await _get(client, origin + "/", ua=YETI_UA)
if res is None:
return Finding("yeti_landing", "Yeti 로 랜딩", FAIL, "연결하지 못했다")
if res.status_code != 200:
return Finding(
"yeti_landing", "Yeti 로 랜딩", FAIL,
f"HTTP {res.status_code} — CDN·WAF 가 네이버 로봇을 막고 있다",
"봇 차단 규칙에서 Yeti 를 빼라",
)
words = _visible_chars(res.text)
if words < 300:
return Finding(
"yeti_landing", "Yeti 로 랜딩", FAIL,
f"본문 {words}자 — CSR 로 돌아갔다",
"react-router.config.ts 의 prerender 목록을 확인해라",
)
return Finding("yeti_landing", "Yeti 로 랜딩", OK, f"HTTP 200 · 본문 {words}")
# ── 색인 통보 경로 ───────────────────────────────────────────────────────
async def check_indexnow_path(client: httpx.AsyncClient, origin: str, slug: str | None = None) -> list[Finding]:
"""백엔드가 통보 URL 을 뽑는 파일이 **그 자리에 있는가.**
`services/indexnow.py site_urls()` `<out>/s/<slug>/sitemap.xml` 읽어 `<loc>`
모은다. 없으면 목록을 돌려주고 경고 줄만 남긴 **발행 잡은 성공한다.**
네이버로 가는 자동 경로가 이것뿐이라, 끊기면 통째로 끊긴다.
로컬 `out/` 대신 HTTP 본다. 프리렌더가 쓰는 볼륨과 nginx 읽는 볼륨이 같아서,
밖에서 404 백엔드가 읽을 파일도 없다. 그리고 로컬 경로를 아는 것은 백엔드 몫이라
여기서 다시 조립하면 규칙이 군데가 된다.
"""
out: list[Finding] = []
res = await _get(client, origin + "/sitemap.xml")
if res is None or res.status_code != 200:
code = res.status_code if res else "연결실패"
out.append(Finding(
"root_sitemap", "루트 사이트맵", FAIL,
f"HTTP {code} — 통보할 URL 목록의 출처가 없다",
))
return out
locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text)
site_locs = [loc for loc in locs if "/s/" in loc]
out.append(Finding(
"root_sitemap", "루트 사이트맵", OK,
f"URL {len(locs)}개 (발행 사이트 {len(site_locs)}개)",
))
if not slug:
found = re.search(r"/s/([^/?#]+)", site_locs[0]) if site_locs else None
slug = found.group(1) if found else None
if not slug:
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", SKIP, "확인할 사이트가 없다"))
return out
res = await _get(client, f"{origin}/s/{slug}/sitemap.xml")
if res is None:
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", FAIL, "연결하지 못했다"))
elif res.status_code == 200 and "<loc>" in res.text:
n = len(re.findall(r"<loc>", res.text))
out.append(Finding("indexnow_urls", "IndexNow 통보 URL", OK, f"/s/{slug}/sitemap.xml — URL {n}"))
else:
out.append(Finding(
"indexnow_urls", "IndexNow 통보 URL", FAIL,
f"/s/{slug}/sitemap.xml 이 HTTP {res.status_code} — indexnow.site_urls() 가 빈 목록을 "
"돌려준다. 네이버·Bing 통보가 조용히 0건이다",
"indexnow.site_urls() 가 루트 사이트맵을 읽게 고쳐라",
))
return out
# ── 네이버에서 보이는가 ──────────────────────────────────────────────────
async def check_web_index(origin: str, place_name: str) -> Finding:
"""웹문서 검색에 우리 URL 이 잡히는가.
없을 FAIL 아니라 WARN 이다 API 결과는 통합검색 색인과 같지 않아서
"안 잡혔다" "색인 안 됐다" 단정할 없다(web_search.py 머리주석).
확정 판정을 보려면 서치어드바이저 등록이 필요하고, 그게 소유확인이 전제인 이유다.
지역검색(`check_place_backlink`) 백엔드 호출기를 그대로 쓰는데 여기만 geo 안의
호출기를 쓴다 백엔드를 고치지 않기로 했기 때문이다. 쿼터가 갈리는 대가는
`web_search.py` 머리주석에 적어 뒀다.
"""
if not web_search_enabled():
return Finding("web_index", "네이버 웹문서 색인", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
try:
items = await search_web(place_name)
except NaverWebSearchUnavailable as ex:
return Finding("web_index", "네이버 웹문서 색인", FAIL, str(ex))
host = _host(origin)
hits = [i for i in items if host in (i.get("link") or "")]
if hits:
return Finding("web_index", "네이버 웹문서 색인", OK, f'"{place_name}" 검색 {len(items)}건 중 우리 사이트 {len(hits)}')
return Finding(
"web_index", "네이버 웹문서 색인", WARN,
f'"{place_name}" 검색 {len(items)}건에 우리 사이트가 없다 — 미색인이거나 이 API 범위 밖이다',
"서치어드바이저에 등록해 색인 진단을 봐라",
)
async def check_place_backlink(origin: str, place_name: str, client: NaverLocalClient | None = None) -> Finding:
"""네이버 플레이스가 **우리 사이트를 가게 홈페이지로 가리키는가.**
중요한가 우리는 `sameAs` 네이버를 가리키지만 그건 우리네이버 방향이다.
네이버가 홈페이지를 업소의 공식 사이트로 **인정하는** 신호는 반대 방향,
스마트플레이스의 "홈페이지" 칸에 우리 주소가 들어가는 것이다. 사장님이 직접 넣어야
하고, 비용이 0 이면서 가장 강한 신호다.
보는 필드는 `NaverPlace.place_url` 이다. 값은 응답의 `link` 인데 **네이버 플레이스
페이지가 아니라 업체 자체 홈페이지**(external/naver.py 실측 주석) 그래서 역방향
연결을 여기서 있다. 이름이 `link` 아니라는 것에 주의한다.
후보는 최대 5건이고 전화번호가 와서 동명 업소를 가릴 근거가 약하다. 그래서
"일치/불일치" 단정하지 않고 **후보의 link 그대로 담는다** 판단은 사람이 한다.
"""
api = client or NaverLocalClient()
if not api.enabled:
return Finding("place_backlink", "스마트플레이스 역방향 링크", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다")
try:
candidates = await api.search_local(place_name)
except (NaverNotConfigured, NaverRequestFailed) as ex:
return Finding("place_backlink", "스마트플레이스 역방향 링크", FAIL, f"{type(ex).__name__}: {ex}")
if not candidates:
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", WARN,
f'"{place_name}" 로 네이버 지역검색 결과가 없다',
)
host = _host(origin)
for cand in candidates:
# name 은 from_item 에서 이미 <b> 태그를 벗긴 값이다 — 다시 벗기지 않는다.
if host in (cand.place_url or ""):
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", OK,
f"{cand.name}{cand.place_url}",
)
seen = " · ".join(f"{c.name}={c.place_url or '(없음)'}" for c in candidates)
return Finding(
"place_backlink", "스마트플레이스 역방향 링크", WARN,
f"우리 주소가 없다 — 후보: {seen}",
"스마트플레이스 '홈페이지' 칸에 발행본 주소를 넣도록 사장님께 안내해라",
)
# ── 한 번에 ──────────────────────────────────────────────────────────────
async def probe(
origin: str,
*,
place_name: str | None = None,
slug: str | None = None,
token: str | None = None,
naver: NaverLocalClient | None = None,
) -> NaverEoReport:
"""전체 점검. 라우터·워커 잡·CLI 가 **같이 부르는 자리**다.
`place_name` 없으면 네이버 검색이 필요한 항목을 건너뛴다 상호명 없이는
질의를 만들 없고, 호스트명으로 던지면 결과가 의미를 갖지 않는다."""
report = NaverEoReport(origin=origin.rstrip("/"))
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
report.findings.append(await check_verification(client, report.origin, token))
report.findings.append(await check_yeti_landing(client, report.origin))
report.findings.extend(await check_indexnow_path(client, report.origin, slug))
if place_name:
# 웹문서검색은 geo 안의 호출기, 지역검색은 백엔드 호출기다(check_web_index 주석).
report.findings.append(await check_web_index(report.origin, place_name))
api = naver or NaverLocalClient()
try:
report.findings.append(await check_place_backlink(report.origin, place_name, api))
finally:
if naver is None:
await api.aclose()
else:
report.findings.append(
Finding("naver_search", "색인·역방향 링크", SKIP, "상호명을 주면 확인한다")
)
return report
# ── 내부 ─────────────────────────────────────────────────────────────────
def _visible_chars(html: str) -> int:
"""JS 실행 없이 읽히는 글자 수. check_search_ready.py 와 같은 셈이다."""
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S)
return len(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body)).strip())

150
geo/naver/notify.py Normal file
View File

@ -0,0 +1,150 @@
"""발행본 주소를 검색엔진에 **알린다** — 크롤러가 지나가길 기다리지 않는다.
어디에 닿고 어디에 닿나 이게 파일의 존재 이유다.
닿는다 네이버(2023-07~) · Bing · Yandex · Seznam.
네이버는 **여기 말고 자동 통로가 없다.** 서치어드바이저는 사람이 눌러야 한다.
닿는다 구글. IndexNow 채택하지 않았다 사이트맵 제출이 유일한 자동화다.
** `geo` 일을 하나.**
원래 담당은 `solution/backend/services/indexnow.py` . 그런데 코드가 읽는 파일
(`<out>/s/<slug>/sitemap.xml`) 프리렌더가 **더는 굽지 않는다** 사이트가 장이 되면서
루트 사이트맵 장으로 합쳤기 때문이다. 파일이 없으면 목록을 돌려주고 경고 줄만
남긴 **발행 잡은 성공한다.** 통보가 조용히 0건이다.
백엔드를 고치지 않기로 해서(2026-09-11), `geo` **루트 사이트맵을 읽어** 대신 보낸다.
**담당이 곳이 되면 된다.** 지금은 백엔드가 0건이라 중복이 없지만, 누가 백엔드를
고치면 **같은 URL 나간다**(429 과다 요청 대상). 그때는 하나를 꺼야 한다
모듈은 `GEO_NOTIFY_ENABLED=0` 으로 끈다.
보낼 URL 여기서 조립하지 않는다. **사이트맵에 있는 것만 보낸다** 거기 있는 것이
실제로 구워진 페이지다. 라우트 규칙을 두면 사이트맵에 없는 URL 통보하게 되고,
그건 404 통보라 신뢰만 깎인다.
실패해도 발행을 되돌리지 않는다. 정적 파일은 이미 올라가 있어서 되돌릴 것이 없다.
대신 **조용히 지나가지 않게** 결과를 돌려준다 그게 지금 고장의 재발 방지다.
"""
import os
import re
from dataclasses import asdict, dataclass
import httpx
from geo.naver._http import TIMEOUT_SEC, get
ENDPOINT = "https://api.indexnow.org/indexnow"
# 규격 상한은 한 번에 10,000개다. 사이트 하나는 한 장이라 넉넉하다.
MAX_URLS = 10_000
KEY_ENV = "INDEXNOW_KEY"
ENABLED_ENV = "GEO_NOTIFY_ENABLED"
@dataclass
class NotifyResult:
slug: str
urls: list[str]
sent: bool
status: int | None = None
error: str | None = None
@property
def ok(self) -> bool:
# 200 OK · 202 Accepted 가 정상이다. 그 밖은 규격상 원인이 정해져 있다:
# 400 형식 · 403 키 불일치 · 422 호스트 불일치 · 429 과다 요청
return self.sent and self.status in (200, 202)
def as_dict(self) -> dict:
return {**asdict(self), "ok": self.ok}
def key() -> str:
return os.environ.get(KEY_ENV, "").strip()
def enabled() -> bool:
"""키가 있고, 꺼져 있지 않을 때만 보낸다.
스위치를 이유는 머리주석의 "담당이 두 곳" 경고 때문이다. 백엔드 쪽이 고쳐지는
여기를 꺼야 하는데, 코드를 지우는 것보다 환경변수 하나가 되돌리기 쉽다."""
return bool(key()) and os.environ.get(ENABLED_ENV, "1").strip() != "0"
async def root_sitemap_locs(client: httpx.AsyncClient, origin: str) -> list[str]:
"""루트 사이트맵의 `<loc>` 전부. **이 호스트가 실제로 발행한 주소 목록**이다.
XML 파서를 쓰지 않고 정규식으로 뽑는다. 우리가 굽는 파일이라 형태가 고정이고,
파서를 쓰면 글자 깨졌을 전부를 잃는다 통보는 부분 성공이 낫다."""
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
if res is None or res.status_code != 200:
return []
return [u.strip() for u in re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", res.text) if u.strip()]
def site_urls(locs: list[str], slug: str) -> list[str]:
"""이 사이트에 속한 주소만 고른다.
`/s/<slug>` 시작하는 것만 본다. `startswith` 아니라 경계까지 보는 이유:
`/s/joy` 거르면 `/s/joy-cafe` 까지 끌려온다."""
marker = f"/s/{slug}"
picked = []
for loc in locs:
idx = loc.find(marker)
if idx < 0:
continue
rest = loc[idx + len(marker):]
if rest in ("", "/") or rest.startswith(("/", "?", "#")):
picked.append(loc)
return picked[:MAX_URLS]
async def submit(client: httpx.AsyncClient, urls: list[str]) -> NotifyResult | None:
"""한 호스트분을 보낸다. 호출측이 slug 를 채워 돌려받는다."""
host = urls[0].split("//", 1)[-1].split("/", 1)[0]
body = {
"host": host,
"key": key(),
# 키 파일은 오리진 루트에 있다(프리렌더가 굽는다). 검색엔진이 이걸 열어
# 같은 키가 있는지 보고 "이 호스트를 제어하는 쪽이 보냈다" 를 확인한다.
# 비밀이 아니다 — 공개되어야 작동하는 값이다.
"keyLocation": f"https://{host}/{key()}.txt",
# 한 요청의 URL 은 전부 같은 호스트여야 한다(규격). 섞이면 422 다.
"urlList": [u for u in urls if u.split("//", 1)[-1].split("/", 1)[0] == host],
}
try:
res = await client.post(ENDPOINT, json=body, timeout=TIMEOUT_SEC)
except httpx.HTTPError as ex:
return NotifyResult("", body["urlList"], sent=True, error=f"{type(ex).__name__}: {ex}")
return NotifyResult("", body["urlList"], sent=True, status=res.status_code)
async def notify_site(client: httpx.AsyncClient, origin: str, slug: str, locs: list[str] | None = None) -> NotifyResult:
"""사이트 하나를 알린다. `locs` 를 주면 사이트맵을 다시 읽지 않는다(여러 건 처리용)."""
if not enabled():
return NotifyResult(slug, [], sent=False, error=f"{KEY_ENV} 가 없거나 {ENABLED_ENV}=0")
if locs is None:
locs = await root_sitemap_locs(client, origin)
urls = site_urls(locs, slug)
if not urls:
return NotifyResult(slug, [], sent=False, error="루트 사이트맵에 이 사이트 주소가 없다")
result = await submit(client, urls)
result.slug = slug
return result
async def check_key_file(client: httpx.AsyncClient, origin: str) -> tuple[bool, str]:
"""키 파일이 열리는가 — **이게 없으면 통보가 403 으로 전부 거절된다.**
발행 전에 봐야 하는 항목이다. 통보를 보내고 나서 알면 이미 늦다."""
k = key()
if not k:
return False, f"{KEY_ENV} 가 비어 있다 — 통보가 꺼져 있다"
res = await get(client, f"{origin.rstrip('/')}/{k}.txt")
if res is None:
return False, f"/{k}.txt 에 연결하지 못했다"
if res.status_code != 200:
return False, f"/{k}.txt 이 HTTP {res.status_code} — 통보가 403 으로 거절된다"
if res.text.strip() != k:
return False, f"/{k}.txt 내용이 키와 다르다"
return True, f"/{k}.txt"

117
geo/naver/robots.py Normal file
View File

@ -0,0 +1,117 @@
"""`robots.txt` 를 **네이버 관점으로** 읽는다.
파일이 보는 것과 `solution/backend/scripts/check_search_ready.py` 보는 것은 다르다.
겹치면 한쪽만 고쳐지는 날이 오므로 경계를 적어 둔다.
check_search_ready : 호스트가 **AI 크롤러 전반**(GPTBot·ClaudeBot·PerplexityBot)
열려 있나 구글·AI 검색 관점
여기 : **네이버가 수집할 있나** Yeti·Daumoa 허용, 사이트맵 지시,
그리고 **JS·CSS 리소스를 막지 않았나**
마지막 항목이 네이버 고유다. 네이버 가이드는 robots.txt JS·CSS 리소스를 막으면
** 페이지 자체가 수집되지 않는다** 명시한다. 우리 발행본은 정적 HTML 이라 JS 없이도
읽히지만, 자산이 막히면 네이버는 페이지를 "덜 읽은" 것이 아니라 **아예 가져간다.**
robots `/assets` 류를 막는 줄이 끼어드는 순간 조용히 전부 빠진다.
robots.txt **오리진 루트에서만** 읽힌다(RFC 9309). `/s/<slug>/robots.txt` 아무도
본다 그래서 여기서도 루트만 본다.
"""
import re
import httpx
from geo.naver._http import get
# 네이버·다음 검색 로봇. 둘 다 이름으로 명시돼 있어야 안전하다 —
# 일부 로봇은 와일드카드보다 자기 이름 규칙을 우선으로 본다.
NAVER_BOTS = ("Yeti", "Daumoa")
# 막히면 네이버가 페이지를 통째로 안 가져가는 자산 경로.
ASSET_PREFIXES = ("/assets", "/fonts", "/static", "/_next", "/builder-assets")
def _blocks(body: str) -> dict[str, list[str]]:
"""`User-agent` 별 Disallow 목록. 한 그룹에 UA 가 여럿일 수 있다(규격).
그룹은 줄로만 끊기는 아니다. **규칙(Disallow) 뒤에 오는 `User-agent`
그룹의 시작**이다(RFC 9309). 줄만 보고 끊으면 아래가 그룹이
`*` 규칙이 Yeti 에도 붙는다 실제로 그렇게 잘못 읽었다.
User-agent: *
Disallow: /assets
User-agent: Yeti 여기서 그룹
Disallow: /
"""
out: dict[str, list[str]] = {}
agents: list[str] = []
after_rule = False
for raw in body.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
agents, after_rule = [], False
continue
key, _, value = line.partition(":")
key, value = key.strip().lower(), value.strip()
if key == "user-agent":
if after_rule:
agents, after_rule = [], False
agents.append(value)
out.setdefault(value, [])
elif key in ("disallow", "allow") and agents:
after_rule = True
if key == "disallow":
for a in agents:
out.setdefault(a, []).append(value)
return out
def _disallows_for(blocks: dict[str, list[str]], bot: str) -> list[str]:
"""그 봇에 적용되는 Disallow. 이름 규칙이 있으면 그것만, 없으면 `*` 를 따른다."""
for name, rules in blocks.items():
if name.lower() == bot.lower():
return rules
return blocks.get("*", [])
def judge(body: str) -> list[tuple[str, str, str]]:
"""`(status, label, detail)` 목록. status 는 checks.py 와 같은 문자열을 쓴다.
여기서 `Finding` 만들지 않는다 모듈이 checks import 하면 파일이
서로를 부르게 된다. 판정 결과만 돌려주고 `Finding` 조립은 부르는 쪽이 한다."""
out: list[tuple[str, str, str]] = []
blocks = _blocks(body)
missing = [b for b in NAVER_BOTS if not any(n.lower() == b.lower() for n in blocks)]
if missing:
out.append(("warn", "네이버 로봇 명시 허용", f"이름이 없다: {', '.join(missing)} — 와일드카드에 기댄다"))
else:
out.append(("ok", "네이버 로봇 명시 허용", " · ".join(NAVER_BOTS)))
for bot in NAVER_BOTS:
rules = _disallows_for(blocks, bot)
if "/" in rules:
out.append(("fail", f"{bot} 수집 차단", "`Disallow: /` — 이 호스트 전체가 막혀 있다"))
continue
hit = [r for r in rules if any(r.startswith(p) for p in ASSET_PREFIXES)]
if hit:
out.append((
"fail", f"{bot} 자산 차단",
f"JS·CSS 경로를 막고 있다({', '.join(hit)}) — 네이버는 그 페이지를 통째로 안 가져간다",
))
if re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body):
loc = re.search(r"(?im)^\s*sitemap\s*:\s*(\S+)", body).group(1)
out.append(("ok", "사이트맵 지시", loc))
else:
out.append(("fail", "사이트맵 지시", "없다 — 크롤러가 사이트맵 위치를 알 방법이 없다"))
return out
async def fetch_and_judge(client: httpx.AsyncClient, origin: str) -> list[tuple[str, str, str]]:
res = await get(client, origin.rstrip("/") + "/robots.txt")
if res is None:
return [("fail", "루트 robots.txt", "연결하지 못했다")]
if res.status_code != 200:
return [("fail", "루트 robots.txt", f"HTTP {res.status_code} — 크롤러가 읽는 유일한 자리다")]
return [("ok", "루트 robots.txt", f"{len(res.text)}바이트"), *judge(res.text)]

90
geo/naver/web_search.py Normal file
View File

@ -0,0 +1,90 @@
"""네이버 웹문서 검색 — 우리 사이트가 네이버에 잡히는지 보는 데만 쓴다.
** `services/external/naver.py` 아니라 여기인가.**
파일이 지역검색 호출기이고 자격증명·오류 규칙·**쿼터 카운터** 이미 갖고 있어서
웹문서검색도 거기 얹는 것이 맞다. 다만 **백엔드 코드는 고치지 않는다** 제약이 있어
(2026-09-11) 모듈 안에 따로 둔다.
대가가 하나 있다: **쿼터는 하나인데 카운터가 둘이다.** 네이버 검색 API
애플리케이션당 25,000회이고 지역검색과 웹문서검색이 한도를 **공유**한다.
백엔드의 `services.external.naver.call_counts()` 에는 여기 호출이 **들어가지 않는다**
값만 보고 "아직 여유 있다" 판단하면 틀린다. 합계가 필요하면 `call_count()`
같이 읽어야 한다.
백엔드를 고칠 있게 되면 `NaverLocalClient.search_web()` 으로 옮기고 파일을 지운다.
자격증명은 백엔드 설정 객체를 **읽어** 쓴다(고치지 않는다). env 이름을 적으면
한쪽만 바뀌는 날이 온다.
공식 API . 네이버 검색창을 긁지 않는다 탐지 우회는 영구 금지(docs/DECISIONS.md 1-1).
"""
from collections import Counter
import httpx
from config.server_configs import external_api_config
WEBKR_URL = "https://openapi.naver.com/v1/search/webkr.json"
# 규격 상한. 지역검색의 "5건" 제약은 여기 없다.
MAX_DISPLAY = 100
TIMEOUT_SEC = 10.0
_CALL_COUNTS: Counter = Counter()
class NaverWebSearchUnavailable(RuntimeError):
"""자격증명이 없거나 호출이 실패했다 — 이 점검만 건너뛴다(발행과 무관)."""
def call_count() -> int:
"""이 프로세스가 웹문서검색을 부른 횟수.
백엔드의 `call_counts()` **합쳐서** 봐야 쿼터 실사용이 나온다(머리주석)."""
return _CALL_COUNTS["webkr"]
def enabled() -> bool:
cfg = external_api_config
return bool(cfg.naver_client_id and cfg.naver_client_secret)
async def search_web(query: str, display: int = 10, *, client: httpx.AsyncClient | None = None) -> list[dict]:
"""항목을 **그대로** 돌려준다(`title` `link` `description`).
한계를 먼저 적는다: ** API 결과는 네이버 통합검색 색인과 같지 않다.**
잡히면 색인된 것이 확실하지만, 잡혀도 "색인 안 됨" 이라고 단정할 없다.
확정 판정은 서치어드바이저에 등록해야 있다(docs/DEPLOY.md 2-2단계).
부르는 쪽은 없을 실패가 아니라 **미확정**으로 다뤄야 한다.
"""
cfg = external_api_config
if not enabled():
raise NaverWebSearchUnavailable("NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 가 설정되지 않았다")
params = {"query": query, "display": max(1, min(display, MAX_DISPLAY))}
headers = {
"X-Naver-Client-Id": cfg.naver_client_id,
"X-Naver-Client-Secret": cfg.naver_client_secret,
}
_CALL_COUNTS["webkr"] += 1
own = client is None
http = client or httpx.AsyncClient(timeout=httpx.Timeout(TIMEOUT_SEC, connect=5.0))
try:
res = await http.get(WEBKR_URL, params=params, headers=headers)
except httpx.HTTPError as ex:
raise NaverWebSearchUnavailable(f"웹문서검색 요청 실패: {type(ex).__name__}: {ex}") from ex
finally:
if own:
await http.aclose()
if res.status_code in (401, 403):
# 키가 있지만 잘못됐거나 권한이 없다 — 설정 문제라 재시도해도 소용없다.
raise NaverWebSearchUnavailable(
f"웹문서검색 인증 실패({res.status_code}) — 클라이언트 ID/Secret 과 검색 API "
f"사용 설정을 확인해라: {res.text[:200]}"
)
if res.status_code != 200:
raise NaverWebSearchUnavailable(f"웹문서검색 응답 오류 status={res.status_code} body={res.text[:200]}")
try:
return list(res.json().get("items") or [])
except ValueError as ex:
raise NaverWebSearchUnavailable(f"웹문서검색 응답 파싱 실패: {ex}") from ex

View File

@ -0,0 +1,77 @@
"""네이버 탐색 준비 상태를 **사람이 읽게** 찍는다.
레포 루트에서 (백엔드 venv 쓴다 httpx·pydantic 거기 있다):
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --place "스테이,머뭄"
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --slug butter
solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py --json (도구에 물릴 )
**컨테이너 안에서는 돈다.** `geo/` 백엔드 이미지에 들어가지 않는다 넣으려면
`solution/backend/Dockerfile` 고쳐야 하고, 백엔드 코드는 고치지 않기로 했다(2026-09-11).
서버에서도 레포 체크아웃 + 백엔드 venv 돌린다. 정기 실행이 필요해지면 `geo/Dockerfile`
자기 이미지를 갖는 것이 제약 아래서의 길이다(geo/README.md).
판정은 여기서 하지 않는다 `geo.naver.probe()` 한다. 파일은 결과를 줄로 바꾸는
일만 한다. 판정을 화면 코드에 두면 라우터가 붙을 기준이 둘로 갈린다.
오리진 기본값은 `site_payload.publish_origin()` 이다. 발행 호스트를 env 두면
canonical 갈린다(AGENTS.md '발행 호스트는 두 곳에 있고 같아야 한다').
"""
import argparse
import asyncio
import json
import os
import sys
# ★ 두 자리를 얹는다: 레포 루트(`geo` 패키지) + `solution/backend`(도메인 코드).
# admin/backend 가 PYTHONPATH 로 같은 일을 한다 — geo 도 백엔드 코드를 복제하지 않는다.
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _path in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _path not in sys.path:
sys.path.insert(0, _path)
# ★ APP_ENV=test 면 .env 를 읽지 않는다(실키가 테스트로 새는 경로를 막아 뒀다).
# 이 스크립트는 실제 네이버 API 를 부르므로 local 이어야 한다 — 다른 스크립트도 같다.
os.environ.setdefault("APP_ENV", "local")
from geo.naver import FAIL, OK, SKIP, WARN, probe # noqa: E402
from services.site_payload import publish_origin # noqa: E402
MARK = {OK: "", WARN: " !", FAIL: "", SKIP: " ·"}
async def main() -> int:
parser = argparse.ArgumentParser(description="네이버 탐색 준비 상태 점검")
parser.add_argument("origin", nargs="?", default=None, help=f"확인할 오리진(기본 {publish_origin()})")
parser.add_argument("--place", help="상호명 — 색인·역방향 링크 점검에 쓴다")
parser.add_argument("--slug", help="통보 경로를 확인할 사이트(기본: 사이트맵의 첫 사이트)")
parser.add_argument("--json", action="store_true", help="사람이 아니라 도구가 읽을 형태로")
args = parser.parse_args()
report = await probe(
args.origin or publish_origin(),
place_name=args.place,
slug=args.slug,
)
if args.json:
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
return 0 if report.ok else 1
print(f"[네이버 점검] {report.origin}\n")
for f in report.findings:
print(f"{MARK[f.status]} {f.label}" + (f"{f.detail}" if f.detail else ""))
passed = [f for f in report.findings if f.status == OK]
print(f"\n[결과] 통과 {len(passed)} · 주의 {len(report.warned)} · 실패 {len(report.failed)}")
for title, rows in (("먼저 고칠 것", report.failed), ("확인이 필요한 것", report.warned)):
if not rows:
continue
print(f"\n{title}:")
for f in rows:
print(f" - {f.label}: {f.detail}" + (f"\n{f.fix}" if f.fix else ""))
return 0 if report.ok else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

117
geo/scripts/postflight.py Normal file
View File

@ -0,0 +1,117 @@
"""**발행한 뒤에** — 알리고, 제대로 나갔는지 본다.
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug>
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --place "스테이,머뭄"
solution/backend/.venv/bin/python geo/scripts/postflight.py --all (사이트맵의 전부)
solution/backend/.venv/bin/python geo/scripts/postflight.py <slug> --dry-run
** "발행 전" 아니라 "발행 후" 인가.** 아직 없는 주소를 통보하면 검색엔진이 404
받는다. 알리지 않은 것보다 나쁘다 헛주소를 보내는 호스트로 기록된다.
그래서 통보는 **구워진 것을 확인한 **에만 보낸다(`_live` 확인이다).
순서가 뜻을 갖는다:
1) 살아 있나 200 아니면 통보하지 않는다
2) 알린다 루트 사이트맵에서 사이트 주소를 골라 IndexNow
3) 기록한다 **성공한 것만.** 실패를 성공으로 기억하면 영영 다시 보낸다
4) 본다 소유확인·색인·역방향 링크 (선택)
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _p not in sys.path:
sys.path.insert(0, _p)
os.environ.setdefault("APP_ENV", "local")
import httpx # noqa: E402
from geo import state # noqa: E402
from geo.naver import notify # noqa: E402
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
from services.site_payload import publish_origin # noqa: E402
STATE_NAME = "indexnow"
async def _live(client: httpx.AsyncClient, url: str) -> bool:
"""통보 전에 실제로 열리는지 본다. **404 통보를 막는 유일한 방어다.**"""
res = await get(client, url)
return res is not None and res.status_code == 200
async def run(origin: str, slugs: list[str] | None, *, dry_run: bool = False) -> list[dict]:
origin = origin.rstrip("/")
results: list[dict] = []
seen = state.load(STATE_NAME)
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
locs = await notify.root_sitemap_locs(client, origin)
if not locs:
return [{"slug": None, "ok": False, "error": "루트 사이트맵을 읽지 못했다"}]
if not slugs:
# 사이트맵에서 slug 를 뽑는다 — 주소 규칙을 여기서 다시 만들지 않는다.
slugs = sorted({loc.split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
for loc in locs if "/s/" in loc})
for slug in slugs:
urls = notify.site_urls(locs, slug)
if not urls:
results.append({"slug": slug, "ok": False, "error": "사이트맵에 이 사이트 주소가 없다"})
continue
if not await _live(client, urls[0]):
results.append({"slug": slug, "ok": False, "error": f"{urls[0]} 이 아직 200 이 아니다 — 통보하지 않는다"})
continue
if dry_run:
results.append({"slug": slug, "ok": True, "urls": urls, "dry_run": True})
continue
res = await notify.notify_site(client, origin, slug, locs=locs)
row = res.as_dict()
results.append(row)
if res.ok:
# ★ 성공한 것만 기록한다(머리주석 3번).
seen[slug] = {"at": datetime.now(timezone.utc).isoformat(), "urls": len(res.urls)}
if not dry_run:
state.save(STATE_NAME, seen)
return results
async def main() -> int:
parser = argparse.ArgumentParser(description="발행 후 — 알리고 확인한다")
parser.add_argument("slug", nargs="*", help="알릴 사이트(여럿 가능). 비우면 --all 이 필요하다")
parser.add_argument("--all", action="store_true", help="사이트맵에 있는 전부")
parser.add_argument("--origin", default=None)
parser.add_argument("--dry-run", action="store_true", help="보내지 않고 무엇을 보낼지만 본다")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
if not args.slug and not args.all:
parser.error("slug 를 주거나 --all 을 써라")
origin = args.origin or publish_origin()
results = await run(origin, args.slug or None, dry_run=args.dry_run)
if args.json:
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
head = "[발행 후] " + origin + (" (dry-run)" if args.dry_run else "")
print(head + "\n")
for r in results:
mark = "" if r.get("ok") else ""
n = len(r.get("urls") or [])
detail = r.get("error") or f"URL {n}" + (f" · HTTP {r['status']}" if r.get("status") else "")
print(f"{mark} {r.get('slug')}{detail}")
bad = [r for r in results if not r.get("ok")]
print(f"\n[결과] 통보 {len(results) - len(bad)} · 실패 {len(bad)}")
return 1 if any(not r.get("ok") for r in results) else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

91
geo/scripts/preflight.py Normal file
View File

@ -0,0 +1,91 @@
"""**발행하기 전에** — 지금 발행하면 네이버에 닿는가.
solution/backend/.venv/bin/python geo/scripts/preflight.py
solution/backend/.venv/bin/python geo/scripts/preflight.py --json
사이트 하나를 보는 아니라 **통로** 본다. 오리진이 하나라서 여기서 확인하면
`/s/<slug>` 전부에 해당한다 사장님이 1,000명이 돼도 반복하지 않는다.
여기서 빨간불이면 **발행해도 네이버에 닿는다.** 2 뒤에 "왜 색인이 안 되지"
알게 되는 것과, 발행 전에 아는 것의 차이다.
점검 :
1. 소유확인 파일 없으면 서치어드바이저에 등록조차 한다
2. robots.txt Yeti·Daumoa 허용 · 사이트맵 지시 · JS·CSS 막지 않았나
3. 루트 사이트맵 통보할 URL 목록의 출처다
4. IndexNow 파일 없으면 통보가 403 으로 전부 거절된다
"""
import argparse
import asyncio
import json
import os
import sys
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _p not in sys.path:
sys.path.insert(0, _p)
os.environ.setdefault("APP_ENV", "local")
import httpx # noqa: E402
from geo.naver import FAIL, OK, SKIP, WARN, Finding, NaverEoReport # noqa: E402
from geo.naver import checks, notify, robots # noqa: E402
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
from services.site_payload import publish_origin # noqa: E402
MARK = {OK: "", WARN: " !", FAIL: "", SKIP: " ·"}
async def preflight(origin: str) -> NaverEoReport:
report = NaverEoReport(origin=origin.rstrip("/"))
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
report.findings.append(await checks.check_verification(client, report.origin))
for status, label, detail in await robots.fetch_and_judge(client, report.origin):
report.findings.append(Finding("robots", label, status, detail))
res = await get(client, report.origin + "/sitemap.xml")
if res is not None and res.status_code == 200 and "<urlset" in res.text:
n = res.text.count("<loc>")
report.findings.append(Finding("root_sitemap", "루트 사이트맵", OK, f"URL {n}"))
else:
code = res.status_code if res else "연결실패"
report.findings.append(Finding(
"root_sitemap", "루트 사이트맵", FAIL, f"HTTP {code} — 통보할 URL 의 출처가 없다",
))
ok, detail = await notify.check_key_file(client, report.origin)
report.findings.append(Finding(
"indexnow_key", "IndexNow 키 파일",
OK if ok else (WARN if not notify.key() else FAIL), detail,
None if ok else "이게 없으면 통보가 403 으로 전부 거절된다",
))
return report
async def main() -> int:
parser = argparse.ArgumentParser(description="발행 전 — 네이버로 가는 통로가 뚫렸나")
parser.add_argument("origin", nargs="?", default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
report = await preflight(args.origin or publish_origin())
if args.json:
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
return 0 if report.ok else 1
print(f"[발행 전 점검] {report.origin}\n")
for f in report.findings:
print(f"{MARK[f.status]} {f.label}" + (f"{f.detail}" if f.detail else ""))
print(f"\n[결과] 통과 {len([f for f in report.findings if f.status == OK])} · "
f"주의 {len(report.warned)} · 실패 {len(report.failed)}")
if report.failed:
print("\n★ 지금 발행하면 네이버에 닿지 않는다. 먼저 고칠 것:")
for f in report.failed:
print(f" - {f.label}: {f.detail}" + (f"\n{f.fix}" if f.fix else ""))
return 0 if report.ok else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

141
geo/scripts/watch.py Normal file
View File

@ -0,0 +1,141 @@
"""발행을 **밖에서 알아채서** 알린다.
solution/backend/.venv/bin/python geo/scripts/watch.py (계속 돈다)
solution/backend/.venv/bin/python geo/scripts/watch.py --once ( 번만)
solution/backend/.venv/bin/python geo/scripts/watch.py --interval 300
**어떻게 알아채나 루트 사이트맵의 `<lastmod>` 본다.** 지난번에 것과 달라진
사이트만 통보한다. 우리 내부(DB· ·볼륨) 들여다보지 않는다 **크롤러가 발행을
알아채는 방식과 같다.** 그래서 `solution` 줄도 고치지 않고 끼어들 있고,
"밖에서 본다" 모듈의 성격도 유지된다.
대가: **즉시가 아니다.** 다음 확인 알린다(기본 5). 프리렌더도 2 폴링으로 도니
같은 종류의 지연이고, 색인은 어차피 단위가 아니다.
빨라야 하면 `site-out` 볼륨의 `payloads/.status/` 감시하는 방법이 있는데,
그러면 내부 파일 구조에 묶여 "밖에서 본다" 깨진다. 값이 지금은 없다고 봤다.
실행은 **전부 것으로 보인다.** 그대로 두면 사이트 1,000개를 한꺼번에 통보한다
`--seed` "지금 상태를 이미 아는 것으로" 기록만 하고 넘어갈 있다.
"""
import argparse
import asyncio
import os
import re
import sys
from datetime import datetime, timezone
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")):
if _p not in sys.path:
sys.path.insert(0, _p)
os.environ.setdefault("APP_ENV", "local")
import httpx # noqa: E402
from geo import state # noqa: E402
from geo.naver._http import TIMEOUT_SEC, get # noqa: E402
from services.site_payload import publish_origin # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from postflight import run as postflight_run # noqa: E402
STATE_NAME = "seen-sitemap"
DEFAULT_INTERVAL = 300.0
# `<url><loc>…</loc><lastmod>…</lastmod></url>` 한 덩이씩. lastmod 는 없을 수 있다.
_URL_BLOCK = re.compile(r"<url>(.*?)</url>", re.S)
_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")
_LASTMOD = re.compile(r"<lastmod>\s*([^<\s]+)\s*</lastmod>")
async def snapshot(client: httpx.AsyncClient, origin: str) -> dict[str, str]:
"""`{slug: lastmod}`. lastmod 가 없으면 loc 자체를 값으로 둔다(있고 없고만 본다)."""
res = await get(client, origin.rstrip("/") + "/sitemap.xml")
if res is None or res.status_code != 200:
return {}
out: dict[str, str] = {}
for block in _URL_BLOCK.findall(res.text):
loc_m = _LOC.search(block)
if not loc_m or "/s/" not in loc_m.group(1):
continue
slug = loc_m.group(1).split("/s/", 1)[1].split("/", 1)[0].split("?")[0]
if not slug:
continue
mod = _LASTMOD.search(block)
out[slug] = mod.group(1) if mod else loc_m.group(1)
return out
def changed(previous: dict, current: dict[str, str]) -> list[str]:
"""새로 생겼거나 lastmod 가 달라진 slug."""
return sorted(s for s, mod in current.items() if previous.get(s) != mod)
async def tick(origin: str, *, seed: bool = False, verbose: bool = True) -> list[str]:
seen = state.load(STATE_NAME)
async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client:
current = await snapshot(client, origin)
if not current:
if verbose:
print(f"[{_now()}] 사이트맵을 읽지 못했다 — 다음 차례에 다시 본다")
return []
todo = changed(seen, current)
if seed:
state.save(STATE_NAME, current)
if verbose:
print(f"[{_now()}] 현재 {len(current)}개를 '이미 아는 것'으로 기록했다 (통보하지 않음)")
return []
if not todo:
if verbose:
print(f"[{_now()}] 바뀐 것 없음 (사이트 {len(current)}개)")
return []
if verbose:
print(f"[{_now()}] 바뀐 사이트 {len(todo)}개 → 통보: {', '.join(todo[:10])}"
+ ("" if len(todo) > 10 else ""))
results = await postflight_run(origin, todo)
# ★ 통보에 성공한 것만 '봤다' 고 기록한다. 실패를 기록하면 영영 다시 안 보낸다.
for r in results:
if r.get("ok") and r.get("slug") in current:
seen[r["slug"]] = current[r["slug"]]
if verbose:
mark = "" if r.get("ok") else ""
detail = r.get("error") or f"URL {len(r.get('urls') or [])}"
print(f"{mark} {r.get('slug')}{detail}")
state.save(STATE_NAME, seen)
return [r["slug"] for r in results if r.get("ok")]
def _now() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S")
async def main() -> int:
parser = argparse.ArgumentParser(description="발행을 알아채서 알린다")
parser.add_argument("--origin", default=None)
parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="초 (기본 300)")
parser.add_argument("--once", action="store_true", help="한 번만 보고 끝낸다")
parser.add_argument("--seed", action="store_true",
help="지금 상태를 '이미 아는 것'으로 기록만 한다 — 첫 실행의 대량 통보를 막는다")
args = parser.parse_args()
origin = args.origin or publish_origin()
print(f"[watch] {origin} · {args.interval:.0f}초마다")
if args.once or args.seed:
await tick(origin, seed=args.seed)
return 0
while True:
try:
await tick(origin)
except Exception as ex: # 한 번 실패로 감시가 멈추면 안 된다
print(f"[{_now()}] 예외를 삼킨다 — {type(ex).__name__}: {ex}")
await asyncio.sleep(args.interval)
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main()))
except KeyboardInterrupt:
print("\n[watch] 종료")

47
geo/state.py Normal file
View File

@ -0,0 +1,47 @@
"""무엇을 이미 알렸는지 기억한다 — **DB 가 아니라 파일이다.**
표를 만들지 않은 이유: 모듈은 `solution/backend` 고치지 않는다는 제약 아래 있고
(geo/README.md '제약'), 스키마는 `init.sql` 마이그레이션 **양쪽** 고쳐야 한다.
그리고 여기 담기는 "마지막으로 통보한 시각" 하나라, 표가 필요해지는 종류가 아니다.
추이·리포트가 요구사항이 되면 그때 표를 정한다.
잃어도 치명적이지 않게 설계한다. 파일이 사라지면 **전부 다시 통보** 뿐이다
같은 URL 다시 알리는 규격상 정상이고(변경 통보), 손해는 호출 번이다.
반대로 "안 알린 것을 알렸다고 기억" 하는 쪽이 위험해서, **통보 성공 뒤에만 기록한다.**
자리는 `GEO_STATE_DIR`. 기본은 `geo/state/` 이고 git 올리지 않는다.
"""
import json
import os
from pathlib import Path
STATE_DIR_ENV = "GEO_STATE_DIR"
def state_dir() -> Path:
raw = os.environ.get(STATE_DIR_ENV, "").strip()
return Path(raw) if raw else Path(__file__).resolve().parent / "state"
def _path(name: str) -> Path:
return state_dir() / f"{name}.json"
def load(name: str) -> dict:
path = _path(name)
if not path.is_file():
return {}
try:
return json.loads(path.read_text("utf-8"))
except (OSError, ValueError):
# 깨진 파일 때문에 통보가 멈추면 안 된다 — 빈 상태로 보고 다시 알린다.
return {}
def save(name: str, data: dict) -> None:
path = _path(name)
path.parent.mkdir(parents=True, exist_ok=True)
# 같은 디렉토리에 쓰고 바꿔치기한다 — 쓰는 중에 죽어도 반쪽 파일이 남지 않는다.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(path)

View File

@ -141,6 +141,31 @@ server {
try_files $uri =404;
}
# ── 네이버 서치어드바이저 소유확인 ─────────────────────────────
# 등록할 때 이 블록의 주석을 풀고 <토큰> 을 서치어드바이저가 준 파일명(.html 제외)으로
# 바꾼다. 두 자리를 같은 값으로 바꿔야 한다(location 경로 · 응답 본문).
#
# ★ **여기서 내주는 이유.** 네이버는 DNS TXT 를 안 받는다. 남은 건 메타태그와 파일인데,
# 메타태그는 랜딩 <head> 라 `solution/frontend` 를 고쳐야 하고 `VITE_*` 로 번들에
# 구워져 값을 바꿀 때마다 재빌드가 필요하다. 이 파일은 **바인드 마운트**라
# 고치고 reload 하면 끝이다.
#
# ★★ **이 블록이 없으면 404 가 아니라 빌더 앱 HTML 이 200 으로 나간다** — 루트의
# `*.html` 은 맨 아래 `location /` 의 SPA 폴백으로 떨어진다. 검색엔진은 "확인 실패" 만
# 뱉고 이유를 안 알려주는데, 눈으로는 파일이 있는 것처럼 보인다.
# → 확인은 상태코드가 아니라 **내용**으로 한다:
# geo/scripts/check_naver_eo.py 가 그 검사를 한다(루트 .env 의 NAVER_SITE_VERIFICATION
# 과 대조). 이 파일과 .env 두 곳에 같은 값이 사는 대가를 그 점검이 막는다.
#
# ★ 이 파일은 git 에 없다(`.example` 만 커밋된다). 서버를 새로 세우면 **다시 넣어야 한다** —
# 빼먹으면 며칠 뒤 소유확인이 조용히 풀린다(검색엔진이 주기적으로 재확인한다).
#
# location = /<토큰>.html {
# default_type text/html;
# add_header Cache-Control "public, max-age=300, must-revalidate";
# return 200 'naver-site-verification: <토큰>.html';
# }
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다