From 005ebc3d76096af91a3d063dc143e4d246ff7821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 4 Aug 2026 11:23:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=20NCP=20NAVER=20API=20HUB=20?= =?UTF-8?q?=EC=87=BC=ED=95=91=20=EC=9D=B8=EC=82=AC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네이버가 2026-07-31 검색 오픈API 중 쇼핑·책·전문자료를 종료(유예·대체 없음)해 shop.json 이 404 SE05 를 반환한다. 후속 플랫폼인 NCP NAVER API HUB 를 붙인다. - NaverApiHubConfig: 게이트웨이 base_url + NCP Client ID/Secret(둘 다 차야 enabled) - services/naver_hub/client.py: X-NCP-APIGW-API-KEY-ID/KEY 인증, 오류 바디 3형식(게이트웨이/Search/인사이트)을 NaverApiHubError 로 정규화(auth_failed·retryable) - services/naver_hub/shopping_insight.py: POST /shopping/v1/categories. 문서 제약(기간 2017-08-01~, 분야 최대 3개, timeUnit·device·gender·ages)을 호출 전에 검증하고 카멜케이스 응답을 타입으로 변환 - tests: MockTransport 로 경로·헤더·오류형식 계약 검증 17건 + LPS_LIVE 스모크 주의: 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 인사이트의 ratio 는 구간 내 최대값 100 기준 상대지표라 최저가 파이프라인 소스로는 쓸 수 없다. 기존 services/search/naver 어댑터는 손대지 않았다(사문화 상태 유지). --- lps/config/config.local.toml.example | 12 ++ lps/config/config_models.py | 25 +++- lps/config/server_configs.py | 3 +- lps/services/naver_hub/__init__.py | 31 ++++ lps/services/naver_hub/client.py | 111 ++++++++++++++ lps/services/naver_hub/shopping_insight.py | 160 ++++++++++++++++++++ lps/tests/test_naver_hub.py | 163 +++++++++++++++++++++ 7 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 lps/services/naver_hub/__init__.py create mode 100644 lps/services/naver_hub/client.py create mode 100644 lps/services/naver_hub/shopping_insight.py create mode 100644 lps/tests/test_naver_hub.py diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index 83b2056..5b25350 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -78,6 +78,8 @@ block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP # ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ── # 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함. +# ⚠️ 2026-07-31 네이버가 쇼핑·책·전문자료 검색을 종료(유예·대체 없음) → shop.json 은 404 SE05. +# 이 섹션은 사문화 상태로 남겨둔다(어댑터 제거 결정 전까지). [NaverConfig] [[NaverConfig.keys]] id = "" @@ -87,6 +89,16 @@ secret = "" # id = "..." # secret = "..." +# NCP NAVER API HUB (https://www.ncloud.com → Application Services > NAVER API HUB). +# 검색(블로그·뉴스·지식iN·이미지·지역·웹문서·백과·카페·오타변환·성인판별)·검색어 트렌드·쇼핑 인사이트. +# 쇼핑 '검색'(상품/가격)은 허브에도 없다 — 최저가 소스로는 쓸 수 없음. +# 키는 NCP 콘솔 > NAVER API HUB > Application > API 관리 > [인증 정보] 에서 발급. +[NaverApiHubConfig] +base_url = "https://naverapihub.apigw.ntruss.com" +client_id = "" +client_secret = "" +timeout_sec = 10.0 + # AI 유사도 판정/검색어 생성 (OpenAI) [OpenAIConfig] api_key = "" diff --git a/lps/config/config_models.py b/lps/config/config_models.py index d4eb292..3d10ffb 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -57,11 +57,34 @@ class NaverKey(BaseModel): class NaverConfig(ConfigModel): - """네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함.""" + """네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함. + + ⚠️ 2026-07-31 네이버가 검색 오픈API 중 쇼핑·책·전문자료를 종료했다(유예·대체 없음). + shop.json 은 정상 키로도 404 SE05 를 반환한다 → services/search/naver 는 사실상 사문화. + 후속인 NCP NAVER API HUB 에도 쇼핑 '검색'은 없다(→ NaverApiHubConfig 는 인사이트/트렌드용). + """ keys: list[NaverKey] = [] +class NaverApiHubConfig(ConfigModel): + """NCP NAVER API HUB(검색·검색어 트렌드·쇼핑 인사이트). 개발자센터 오픈API 의 후속. + + 옛 오픈API 와 인증 방식이 다르다 — X-Naver-Client-Id/Secret 이 아니라 + X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY 헤더를 쓴다(발급처도 NCP 콘솔). + client_id/secret 이 다 차야 활성(enabled). + """ + + base_url: str = "https://naverapihub.apigw.ntruss.com" + client_id: str = "" + client_secret: str = "" + timeout_sec: float = 10.0 + + @property + def enabled(self) -> bool: + return bool(self.base_url and self.client_id and self.client_secret) + + class OpenAIConfig(ConfigModel): """AI 유사도 판정/검색어 생성용 OpenAI.""" diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 1f9032a..9411cf6 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -2,7 +2,7 @@ import os from config.config_loader import Configs from config.config_models import ( - WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig, + WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverApiHubConfig, OpenAIConfig, DecodoConfig, WorkerConfig, AlertConfig, ) @@ -34,6 +34,7 @@ log_config: LogConfig = configs.get(LogConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig) # 섹션이 없으면 기본값(빈/비활성)으로 동작. naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig() +naver_api_hub_config: NaverApiHubConfig = configs.get(NaverApiHubConfig) or NaverApiHubConfig() openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig() decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig() worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig() diff --git a/lps/services/naver_hub/__init__.py b/lps/services/naver_hub/__init__.py new file mode 100644 index 0000000..a5937d0 --- /dev/null +++ b/lps/services/naver_hub/__init__.py @@ -0,0 +1,31 @@ +"""NCP NAVER API HUB 클라이언트. + +네이버 개발자센터 오픈API 의 후속 플랫폼(2026-06-25 출시). 게이트웨이가 앞단에 있어 +인증 헤더·경로·오류 형식이 옛 오픈API 와 다르다 — 자세한 건 client.py 참고. + +⚠️ 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 여기 있는 쇼핑 인사이트는 +클릭 추이의 **상대 지표(0~100)** 라 최저가 파이프라인의 소스로는 쓸 수 없다. +""" + +from services.naver_hub.client import NaverApiHubClient, NaverApiHubError +from services.naver_hub.shopping_insight import ( + ShoppingCategory, + ShoppingInsightClient, + ShoppingInsightResult, + InsightPoint, + InsightSeries, + build_categories_body, + parse_categories_response, +) + +__all__ = [ + "NaverApiHubClient", + "NaverApiHubError", + "ShoppingInsightClient", + "ShoppingCategory", + "ShoppingInsightResult", + "InsightSeries", + "InsightPoint", + "build_categories_body", + "parse_categories_response", +] diff --git a/lps/services/naver_hub/client.py b/lps/services/naver_hub/client.py new file mode 100644 index 0000000..a5b8a5c --- /dev/null +++ b/lps/services/naver_hub/client.py @@ -0,0 +1,111 @@ +"""NAVER API HUB 공통 호출 계층 — 인증 헤더·요청·오류 해석을 한곳에 모은다. + +옛 오픈API(openapi.naver.com) 와 달라진 점: + 엔드포인트 https://naverapihub.apigw.ntruss.com + 인증 헤더 X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY (NCP 콘솔 발급 Client ID/Secret) + 경로 규칙 /search/v1/{type} · /shopping/v1/{...} (옛 /v1/search/{type}.json 과 순서가 반대) + +오류 바디가 계층마다 다르다(공식 문서 'NAVER API HUB 개요' 기준). 호출부가 세 형식을 다 알 +필요는 없으므로 _parse_error 가 하나의 NaverApiHubError 로 정규화한다: + 1) 게이트웨이(인증 실패·라우팅 실패) {"error": {"errorCode", "message", "details"}} + 2) Search API 파라미터 검증 {"errorCode", "errorMessage"} + 3) 검색어 트렌드·쇼핑 인사이트 검증 {"errMsg", "errId"} +""" + +import json +from typing import Any, Optional + +import httpx + +from common.logger import LOG +from config.config_models import NaverApiHubConfig +from config.server_configs import naver_api_hub_config + + +class NaverApiHubError(Exception): + """허브 호출 실패. status/code 로 재시도 가치를 구분한다. + + auth_failed(401·403) 와 not_found(404) 는 재시도해도 그대로다 — 키 설정이나 경로가 틀린 것. + 쿼터 소진(429)·5xx 만 재시도 가치가 있다(retryable). + """ + + def __init__(self, message: str, *, status: int, code: Optional[str] = None, path: str = ""): + super().__init__(message) + self.status = status + self.code = code + self.path = path + + @property + def auth_failed(self) -> bool: + return self.status in (401, 403) + + @property + def not_found(self) -> bool: + return self.status == 404 + + @property + def retryable(self) -> bool: + return self.status == 429 or self.status >= 500 + + +class NaverApiHubClient: + """허브 공통 클라이언트. 개별 API(쇼핑 인사이트 등)는 이 위에 얹는다. + + transport 는 테스트에서 httpx.MockTransport 를 꽂기 위한 주입점이다(네트워크 없이 계약 검증). + """ + + def __init__(self, cfg: Optional[NaverApiHubConfig] = None, *, transport: Any = None): + self._cfg = cfg if cfg is not None else naver_api_hub_config + self._transport = transport + + @property + def enabled(self) -> bool: + return self._cfg.enabled + + def _headers(self) -> dict: + return { + "X-NCP-APIGW-API-KEY-ID": self._cfg.client_id, + "X-NCP-APIGW-API-KEY": self._cfg.client_secret, + "Content-Type": "application/json", + } + + async def post(self, path: str, body: dict) -> dict: + """POST 요청 1건. 성공이면 파싱된 JSON, 실패면 NaverApiHubError.""" + if not self.enabled: + raise NaverApiHubError( + "NAVER API HUB 미설정 — config.local.toml [NaverApiHubConfig] 의 client_id/client_secret 을 채우세요", + status=0, path=path, + ) + + url = f"{self._cfg.base_url.rstrip('/')}{path}" + async with httpx.AsyncClient(timeout=self._cfg.timeout_sec, transport=self._transport) as client: + r = await client.post(url, headers=self._headers(), json=body) + + if r.status_code != 200: + raise self._parse_error(r, path) + return r.json() + + @staticmethod + def _parse_error(r: httpx.Response, path: str) -> NaverApiHubError: + """세 가지 오류 바디 형식을 하나로 정규화. JSON 이 아니면 본문 앞부분을 그대로 싣는다.""" + code = None + message = r.text[:200] + try: + body = r.json() + except (json.JSONDecodeError, ValueError): + body = None + + if isinstance(body, dict): + if isinstance(body.get("error"), dict): # 1) 게이트웨이 + err = body["error"] + code = str(err.get("errorCode") or "") or None + message = " ".join(str(v) for v in (err.get("message"), err.get("details")) if v) + elif "errorMessage" in body or "errorCode" in body: # 2) Search API + code = str(body.get("errorCode") or "") or None + message = str(body.get("errorMessage") or message) + elif "errMsg" in body or "errId" in body: # 3) 트렌드·인사이트 + code = str(body.get("errId") or "") or None + message = str(body.get("errMsg") or message) + + LOG.w(f"[naver-hub] {r.status_code} {path} code={code} {message}") + return NaverApiHubError(message, status=r.status_code, code=code, path=path) diff --git a/lps/services/naver_hub/shopping_insight.py b/lps/services/naver_hub/shopping_insight.py new file mode 100644 index 0000000..c644124 --- /dev/null +++ b/lps/services/naver_hub/shopping_insight.py @@ -0,0 +1,160 @@ +"""쇼핑 인사이트 — 분야별 트렌드 조회 (POST /shopping/v1/categories). + +네이버 데이터랩 쇼핑인사이트의 **분야별 검색 클릭 추이**를 조회한다. +반환값 ratio 는 절대 클릭수가 아니라 **구간 내 최대값을 100 으로 둔 상대 지표**다 +(같은 응답 안에서만 비교 가능 — 다른 조회 결과와 절대 비교하면 안 된다). +⇒ 상품명·가격·판매처가 없으므로 최저가 검색 소스로는 쓸 수 없다. 수요 추이 분석용. + +요청 제약은 전부 호출 전에 검증한다 — 게이트웨이 왕복 없이 바로 틀린 곳을 알려주는 게 낫다. +""" + +from datetime import date +from typing import Optional, Sequence + +from pydantic import BaseModel, Field + +from common.logger import LOG +from services.naver_hub.client import NaverApiHubClient + +_PATH = "/shopping/v1/categories" +_MAX_CATEGORIES = 3 # 문서: 최대 3개 쌍 +_MIN_START = date(2017, 8, 1) # 문서: 2017년 8월 1일부터 조회 가능 +_TIME_UNITS = ("date", "week", "month") +_DEVICES = ("pc", "mo") +_GENDERS = ("m", "f") +_AGES = ("10", "20", "30", "40", "50", "60") + + +class ShoppingCategory(BaseModel): + """조회할 쇼핑 분야. param 은 네이버쇼핑 카테고리 URL 의 cat_id 값.""" + + name: str = Field(description="쇼핑 분야 이름(응답 title 로 되돌아옴)") + param: list[str] = Field(description="쇼핑 분야 코드 목록(cat_id)") + + +class InsightPoint(BaseModel): + period: str = Field(description="구간 시작 날짜(yyyy-mm-dd)") + ratio: float = Field(description="구간별 클릭량의 상대 비율 — 결과 내 최대값이 100") + + +class InsightSeries(BaseModel): + title: str = Field(description="쇼핑 분야 이름") + category: list[str] = Field(default_factory=list, description="쇼핑 분야 코드") + data: list[InsightPoint] = Field(default_factory=list, description="구간별 추이") + + +class ShoppingInsightResult(BaseModel): + start_date: str + end_date: str + time_unit: str + results: list[InsightSeries] = Field(default_factory=list) + + +class ShoppingInsightClient: + """쇼핑 인사이트 조회기. 허브 공통 클라이언트를 감싼다.""" + + def __init__(self, client: Optional[NaverApiHubClient] = None): + self._client = client or NaverApiHubClient() + + @property + def enabled(self) -> bool: + return self._client.enabled + + async def categories( + self, + *, + start_date: str, + end_date: str, + categories: Sequence[ShoppingCategory], + time_unit: str = "date", + device: Optional[str] = None, + gender: Optional[str] = None, + ages: Optional[Sequence[str]] = None, + ) -> ShoppingInsightResult: + """분야별 트렌드 조회. 검증 실패는 ValueError, 호출 실패는 NaverApiHubError.""" + body = build_categories_body( + start_date=start_date, end_date=end_date, categories=categories, + time_unit=time_unit, device=device, gender=gender, ages=ages, + ) + data = await self._client.post(_PATH, body) + result = parse_categories_response(data) + LOG.d(f"[naver-hub] 쇼핑인사이트 {start_date}~{end_date} {time_unit} → {len(result.results)}개 분야") + return result + + +def build_categories_body( + *, + start_date: str, + end_date: str, + categories: Sequence[ShoppingCategory], + time_unit: str = "date", + device: Optional[str] = None, + gender: Optional[str] = None, + ages: Optional[Sequence[str]] = None, +) -> dict: + """요청 바디 생성 + 문서상의 제약 검증(순수 함수 — 네트워크 없이 테스트 가능).""" + start = _parse_date(start_date, "startDate") + end = _parse_date(end_date, "endDate") + if start < _MIN_START: + raise ValueError(f"startDate 는 {_MIN_START.isoformat()} 이후여야 합니다(문서 제약): {start_date}") + if start > end: + raise ValueError(f"startDate 가 endDate 보다 늦습니다: {start_date} > {end_date}") + + if time_unit not in _TIME_UNITS: + raise ValueError(f"timeUnit 은 {_TIME_UNITS} 중 하나여야 합니다: {time_unit!r}") + + if not categories: + raise ValueError("category 는 최소 1개가 필요합니다") + if len(categories) > _MAX_CATEGORIES: + raise ValueError(f"category 는 최대 {_MAX_CATEGORIES}개입니다(요청 {len(categories)}개)") + for c in categories: + if not c.name or not c.param: + raise ValueError(f"category 의 name/param 이 비었습니다: {c!r}") + + body: dict = { + "startDate": start_date, + "endDate": end_date, + "timeUnit": time_unit, + "category": [{"name": c.name, "param": list(c.param)} for c in categories], + } + + # 선택 파라미터는 값이 있을 때만 싣는다 — 빈 값을 보내면 게이트웨이가 검증 오류로 되돌린다. + if device is not None: + if device not in _DEVICES: + raise ValueError(f"device 는 {_DEVICES} 중 하나여야 합니다: {device!r}") + body["device"] = device + if gender is not None: + if gender not in _GENDERS: + raise ValueError(f"gender 는 {_GENDERS} 중 하나여야 합니다: {gender!r}") + body["gender"] = gender + if ages: + bad = [a for a in ages if a not in _AGES] + if bad: + raise ValueError(f"ages 는 {_AGES} 중에서 골라야 합니다: {bad}") + body["ages"] = list(ages) + return body + + +def parse_categories_response(data: dict) -> ShoppingInsightResult: + """응답 → 타입 있는 결과. 응답 키가 카멜케이스라 여기서 한 번만 변환한다.""" + return ShoppingInsightResult( + start_date=data.get("startDate", ""), + end_date=data.get("endDate", ""), + time_unit=data.get("timeUnit", ""), + results=[ + InsightSeries( + title=r.get("title", ""), + category=list(r.get("category") or []), + data=[InsightPoint(period=p.get("period", ""), ratio=float(p.get("ratio", 0))) + for p in (r.get("data") or [])], + ) + for r in (data.get("results") or []) + ], + ) + + +def _parse_date(value: str, field: str) -> date: + try: + return date.fromisoformat(value) + except (TypeError, ValueError): + raise ValueError(f"{field} 형식은 yyyy-mm-dd 여야 합니다: {value!r}") from None diff --git a/lps/tests/test_naver_hub.py b/lps/tests/test_naver_hub.py new file mode 100644 index 0000000..adeb6c2 --- /dev/null +++ b/lps/tests/test_naver_hub.py @@ -0,0 +1,163 @@ +"""NAVER API HUB 쇼핑 인사이트 테스트 (네트워크 불필요 — MockTransport/순수함수). + ++ 라이브 스모크: 실제 NCP 게이트웨이에 붙어 키·경로가 살아있는지 확인. + 키가 필요하고 쿼터를 쓰므로 기본 skip — LPS_LIVE=1 로 명시 실행. +""" + +import os + +import httpx +import pytest + +from config.config_models import NaverApiHubConfig +from services.naver_hub.client import NaverApiHubClient, NaverApiHubError +from services.naver_hub.shopping_insight import ( + ShoppingCategory, ShoppingInsightClient, build_categories_body, parse_categories_response, +) + +_CFG = NaverApiHubConfig(client_id="ID", client_secret="SECRET") +_CAT = [ShoppingCategory(name="패션의류", param=["50000000"])] + +_SAMPLE = { # 공식 문서 응답 예시 축약 + "startDate": "2023-11-01", "endDate": "2023-11-07", "timeUnit": "date", + "results": [{"title": "패션의류", "category": ["50000000"], + "data": [{"period": "2023-11-01", "ratio": 76.30839}, + {"period": "2023-11-02", "ratio": 70.00509}]}], +} + + +def _client(handler) -> ShoppingInsightClient: + return ShoppingInsightClient(NaverApiHubClient(_CFG, transport=httpx.MockTransport(handler))) + + +# ── 요청 바디 검증 (순수) ──────────────────────────────────────────────── +def test_body_has_required_fields_and_omits_empty_optionals(): + body = build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=_CAT) + assert body["startDate"] == "2026-07-01" and body["endDate"] == "2026-07-07" + assert body["timeUnit"] == "date" + assert body["category"] == [{"name": "패션의류", "param": ["50000000"]}] + # 선택 파라미터는 미지정 시 아예 실리지 않는다(빈 값 전송 = 검증 오류) + assert "device" not in body and "gender" not in body and "ages" not in body + + +def test_body_carries_optional_filters(): + body = build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=_CAT, + time_unit="week", device="pc", gender="f", ages=["20", "30"]) + assert body["timeUnit"] == "week" and body["device"] == "pc" + assert body["gender"] == "f" and body["ages"] == ["20", "30"] + + +@pytest.mark.parametrize("kwargs, msg", [ + (dict(start_date="2017-07-31", end_date="2017-08-05"), "2017-08-01"), # 조회 가능 시작일 이전 + (dict(start_date="2026-07-07", end_date="2026-07-01"), "늦습니다"), # 역전 + (dict(start_date="2026/07/01", end_date="2026-07-07"), "yyyy-mm-dd"), # 형식 + (dict(start_date="2026-07-01", end_date="2026-07-07", time_unit="day"), "timeUnit"), + (dict(start_date="2026-07-01", end_date="2026-07-07", device="mobile"), "device"), + (dict(start_date="2026-07-01", end_date="2026-07-07", gender="x"), "gender"), + (dict(start_date="2026-07-01", end_date="2026-07-07", ages=["15"]), "ages"), +]) +def test_body_rejects_invalid_input(kwargs, msg): + with pytest.raises(ValueError, match=msg): + build_categories_body(categories=_CAT, **kwargs) + + +def test_body_rejects_bad_category_count(): + with pytest.raises(ValueError, match="최소 1개"): + build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=[]) + four = [ShoppingCategory(name=f"c{i}", param=[str(i)]) for i in range(4)] + with pytest.raises(ValueError, match="최대 3개"): + build_categories_body(start_date="2026-07-01", end_date="2026-07-07", categories=four) + + +# ── 응답 파싱 (순수) ───────────────────────────────────────────────────── +def test_parse_response_maps_camel_case(): + out = parse_categories_response(_SAMPLE) + assert out.start_date == "2023-11-01" and out.time_unit == "date" + assert len(out.results) == 1 + s = out.results[0] + assert s.title == "패션의류" and s.category == ["50000000"] + assert s.data[0].period == "2023-11-01" and s.data[0].ratio == pytest.approx(76.30839) + + +def test_parse_response_tolerates_missing_sections(): + out = parse_categories_response({}) + assert out.results == [] and out.start_date == "" + + +# ── 호출 계약 (MockTransport) ──────────────────────────────────────────── +@pytest.mark.asyncio +async def test_call_uses_hub_path_and_ncp_headers(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["headers"] = request.headers + seen["method"] = request.method + return httpx.Response(200, json=_SAMPLE) + + out = await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT) + + assert seen["method"] == "POST" + assert seen["url"] == "https://naverapihub.apigw.ntruss.com/shopping/v1/categories" + # 옛 오픈API 헤더가 아니라 NCP 게이트웨이 헤더여야 한다 + assert seen["headers"]["X-NCP-APIGW-API-KEY-ID"] == "ID" + assert seen["headers"]["X-NCP-APIGW-API-KEY"] == "SECRET" + assert "X-Naver-Client-Id" not in seen["headers"] + assert out.results[0].title == "패션의류" + + +@pytest.mark.asyncio +async def test_gateway_error_shape_is_normalized(): + def handler(request): + return httpx.Response(401, json={"error": {"errorCode": "200", "message": "Authentication Failed", + "details": "Authentication information are missing."}}) + + with pytest.raises(NaverApiHubError) as e: + await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT) + assert e.value.status == 401 and e.value.code == "200" + assert e.value.auth_failed and not e.value.retryable + assert "Authentication Failed" in str(e.value) + + +@pytest.mark.asyncio +async def test_insight_error_shape_is_normalized(): + def handler(request): + return httpx.Response(400, json={"errMsg": "잘못된 요청입니다", "errId": "E001"}) + + with pytest.raises(NaverApiHubError) as e: + await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT) + assert e.value.status == 400 and e.value.code == "E001" and "잘못된 요청" in str(e.value) + + +@pytest.mark.asyncio +async def test_5xx_is_retryable_and_non_json_body_survives(): + def handler(request): + return httpx.Response(503, text="service unavailable") + + with pytest.raises(NaverApiHubError) as e: + await _client(handler).categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT) + assert e.value.retryable and e.value.status == 503 + + +@pytest.mark.asyncio +async def test_missing_credentials_fails_before_network(): + def handler(request): # 호출되면 안 됨 + raise AssertionError("자격증명 없이 네트워크 호출이 발생했다") + + client = ShoppingInsightClient(NaverApiHubClient(NaverApiHubConfig(), transport=httpx.MockTransport(handler))) + assert not client.enabled + with pytest.raises(NaverApiHubError, match="NaverApiHubConfig"): + await client.categories(start_date="2023-11-01", end_date="2023-11-07", categories=_CAT) + + +# ── 라이브 스모크 (실제 키 필요) ───────────────────────────────────────── +@pytest.mark.skipif(not os.environ.get("LPS_LIVE"), reason="라이브 스모크 — LPS_LIVE=1 + 실제 NCP 키로 실행") +async def test_live_smoke_shopping_insight(): + client = ShoppingInsightClient() + assert client.enabled, "config.local.toml [NaverApiHubConfig] 의 client_id/client_secret 이 비었습니다" + out = await client.categories( + start_date="2026-07-01", end_date="2026-07-07", time_unit="date", + categories=[ShoppingCategory(name="패션의류", param=["50000000"])], + ) + assert out.results and out.results[0].data, "0건 — 키 권한·분야 코드 확인" + assert all(0 <= p.ratio <= 100 for p in out.results[0].data)