"""네이버 웹문서 검색 — 우리 사이트가 네이버에 잡히는지 보는 데만 쓴다. ★ **왜 `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