네이버 쪽에는 창이 없었다. 서치어드바이저 소유확인이 안 붙어 사이트맵 제출·수집 요청·
진단을 쓸 수 없었고, 유일한 자동 통로인 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
91 lines
4.2 KiB
Python
91 lines
4.2 KiB
Python
"""네이버 웹문서 검색 — 우리 사이트가 네이버에 잡히는지 보는 데만 쓴다.
|
|
|
|
★ **왜 `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
|