[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
This commit is contained in:
parent
a134805b3f
commit
76d51207c6
18
.env.example
18
.env.example
@ -31,6 +31,24 @@ PERPLEXITY_API_KEY=
|
|||||||
COLLECT_USE_PERPLEXITY=0
|
COLLECT_USE_PERPLEXITY=0
|
||||||
NAVER_CLIENT_ID=
|
NAVER_CLIENT_ID=
|
||||||
NAVER_CLIENT_SECRET=
|
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=
|
KAKAO_REST_API_KEY=
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -47,6 +47,10 @@ dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# geo 가 "무엇을 이미 알렸나" 를 기억하는 자리. 재생성물이라 커밋하지 않는다 —
|
||||||
|
# 잃으면 전부 다시 통보할 뿐이고, 그건 규격상 정상(변경 통보)이다.
|
||||||
|
geo/state/
|
||||||
|
|
||||||
# ── 에이전트 지침은 커밋한다 ──────────────────────────────────────────────
|
# ── 에이전트 지침은 커밋한다 ──────────────────────────────────────────────
|
||||||
# AGENTS.md / CLAUDE.md 는 팀과 모든 에이전트가 공유하는 규약이라 반드시 커밋한다.
|
# AGENTS.md / CLAUDE.md 는 팀과 모든 에이전트가 공유하는 규약이라 반드시 커밋한다.
|
||||||
# 커밋 안 하면 클론한 사람이 "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
|
# 커밋 안 하면 클론한 사람이 "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
|
||||||
|
|||||||
33
AGENTS.md
33
AGENTS.md
@ -11,6 +11,7 @@
|
|||||||
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
|
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
|
||||||
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
|
||||||
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
||||||
|
| **네이버**에서 탐색되게 하려면 (구글과 다르다) | [docs/NAVER_EO.md](docs/NAVER_EO.md) |
|
||||||
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
||||||
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
||||||
|
|
||||||
@ -100,6 +101,7 @@
|
|||||||
```
|
```
|
||||||
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
||||||
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
||||||
|
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
|
||||||
```
|
```
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
||||||
@ -108,6 +110,21 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
|||||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
||||||
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
가리키고, 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(계약)
|
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
|
||||||
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
||||||
|
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈
|
||||||
```
|
```
|
||||||
|
|
||||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
||||||
@ -228,6 +246,21 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
|||||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
|
||||||
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
|
가리키고, 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` 두 곳에 산다**(어긋나면 점검이 잡는다).
|
||||||
|
|
||||||
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
|
||||||
|
|
||||||
| | 포트 | 진입점 | 권한 |
|
| | 포트 | 진입점 | 권한 |
|
||||||
|
|||||||
@ -47,13 +47,15 @@ solution/ 사장님 — 사이트 만들기·관리
|
|||||||
admin/ 우리 — 전체 사이트 운영
|
admin/ 우리 — 전체 사이트 운영
|
||||||
backend/ 진입점만(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
|
backend/ 진입점만(:9801). 도메인 코드는 solution/backend 를 PYTHONPATH 로 쓴다
|
||||||
frontend/ 운영 화면. `@` 별칭이 solution/frontend/src 를 가리킨다
|
frontend/ 운영 화면. `@` 별칭이 solution/frontend/src 를 가리킨다
|
||||||
|
geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO). 라우터 없는 독립 모듈.
|
||||||
|
admin 처럼 solution/backend 를 PYTHONPATH 로 얹어 쓴다
|
||||||
docs/ 아래 표
|
docs/ 아래 표
|
||||||
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋)
|
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋)
|
||||||
postgres-init/ 스키마 DDL
|
postgres-init/ 스키마 DDL
|
||||||
```
|
```
|
||||||
|
|
||||||
의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다.
|
의존 방향은 `admin → solution` · `geo → solution` 두 줄이고 **둘 다 한 방향**이다.
|
||||||
근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
반대가 생기면 가른 의미가 사라진다. 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
## 문서 지도
|
## 문서 지도
|
||||||
|
|
||||||
@ -66,6 +68,7 @@ postgres-init/ 스키마 DDL
|
|||||||
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
||||||
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.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/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 상한을 어디서 강제하나 |
|
| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 |
|
||||||
|
|
||||||
## 문서 규칙
|
## 문서 규칙
|
||||||
|
|||||||
@ -116,9 +116,55 @@ o2o-web4ai/
|
|||||||
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
|
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
|
||||||
│ └─ frontend/ 내부 운영 화면
|
│ └─ frontend/ 내부 운영 화면
|
||||||
│
|
│
|
||||||
|
├─ geo/ 검색·AI 엔진이 밖에서 무엇을 보나 (Naver EO)
|
||||||
|
│ ├─ naver/checks.py 밖에서 HTTP 로 본다 — DB 를 보지 않는다
|
||||||
|
│ └─ scripts/ 사람이 읽는 출력
|
||||||
|
│
|
||||||
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
|
├─ 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/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
||||||
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
|
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
|
||||||
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
||||||
|
|||||||
@ -178,7 +178,7 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
|
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
|
||||||
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
|
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
|
||||||
| 네이버 서치어드바이저 | 메타태그 / HTML 파일 (**DNS TXT 를 안 받는다**) | 아직 안 붙였다 |
|
| 네이버 서치어드바이저 | **HTML 파일** (DNS TXT 를 안 받는다) | `nginx/site.conf` 가 직접 내준다 (**git 에 없다** — 서버 로컬) |
|
||||||
|
|
||||||
★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로
|
★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로
|
||||||
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
|
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
|
||||||
@ -192,6 +192,41 @@ docker compose exec solution-worker python scripts/check_search_ready.py https:/
|
|||||||
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
|
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 을 켤 때
|
## 4. 나중에 — Azure Blob 을 켤 때
|
||||||
|
|||||||
103
docs/DEVLOG.md
103
docs/DEVLOG.md
@ -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 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
||||||
|
|
||||||
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
||||||
|
|||||||
215
docs/NAVER_EO.md
Normal file
215
docs/NAVER_EO.md
Normal 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
157
geo/README.md
Normal 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
10
geo/__init__.py
Normal 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
40
geo/naver/__init__.py
Normal 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
24
geo/naver/_http.py
Normal 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
330
geo/naver/checks.py
Normal 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
150
geo/naver/notify.py
Normal 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
117
geo/naver/robots.py
Normal 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
90
geo/naver/web_search.py
Normal 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
|
||||||
77
geo/scripts/check_naver_eo.py
Normal file
77
geo/scripts/check_naver_eo.py
Normal 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
117
geo/scripts/postflight.py
Normal 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
91
geo/scripts/preflight.py
Normal 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
141
geo/scripts/watch.py
Normal 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
47
geo/state.py
Normal 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)
|
||||||
@ -141,6 +141,31 @@ server {
|
|||||||
try_files $uri =404;
|
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` 로 뺐다
|
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user