feat(lps): NCP NAVER API HUB 쇼핑 인사이트 클라이언트 추가
네이버가 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 어댑터는 손대지 않았다(사문화 상태 유지).
This commit is contained in:
parent
3c3f60c7d4
commit
005ebc3d76
@ -78,6 +78,8 @@ block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP
|
|||||||
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ──
|
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ──
|
||||||
|
|
||||||
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
|
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
|
||||||
|
# ⚠️ 2026-07-31 네이버가 쇼핑·책·전문자료 검색을 종료(유예·대체 없음) → shop.json 은 404 SE05.
|
||||||
|
# 이 섹션은 사문화 상태로 남겨둔다(어댑터 제거 결정 전까지).
|
||||||
[NaverConfig]
|
[NaverConfig]
|
||||||
[[NaverConfig.keys]]
|
[[NaverConfig.keys]]
|
||||||
id = "<NAVER_CLIENT_ID>"
|
id = "<NAVER_CLIENT_ID>"
|
||||||
@ -87,6 +89,16 @@ secret = "<NAVER_CLIENT_SECRET>"
|
|||||||
# id = "..."
|
# id = "..."
|
||||||
# secret = "..."
|
# 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 = "<NCP_CLIENT_ID>"
|
||||||
|
client_secret = "<NCP_CLIENT_SECRET>"
|
||||||
|
timeout_sec = 10.0
|
||||||
|
|
||||||
# AI 유사도 판정/검색어 생성 (OpenAI)
|
# AI 유사도 판정/검색어 생성 (OpenAI)
|
||||||
[OpenAIConfig]
|
[OpenAIConfig]
|
||||||
api_key = "<OPENAI_API_KEY>"
|
api_key = "<OPENAI_API_KEY>"
|
||||||
|
|||||||
@ -57,11 +57,34 @@ class NaverKey(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NaverConfig(ConfigModel):
|
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] = []
|
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):
|
class OpenAIConfig(ConfigModel):
|
||||||
"""AI 유사도 판정/검색어 생성용 OpenAI."""
|
"""AI 유사도 판정/검색어 생성용 OpenAI."""
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import os
|
|||||||
|
|
||||||
from config.config_loader import Configs
|
from config.config_loader import Configs
|
||||||
from config.config_models import (
|
from config.config_models import (
|
||||||
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig,
|
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverApiHubConfig, OpenAIConfig, DecodoConfig,
|
||||||
WorkerConfig, AlertConfig,
|
WorkerConfig, AlertConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -34,6 +34,7 @@ log_config: LogConfig = configs.get(LogConfig)
|
|||||||
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
||||||
# 섹션이 없으면 기본값(빈/비활성)으로 동작.
|
# 섹션이 없으면 기본값(빈/비활성)으로 동작.
|
||||||
naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig()
|
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()
|
openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig()
|
||||||
decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig()
|
decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig()
|
||||||
worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig()
|
worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig()
|
||||||
|
|||||||
31
lps/services/naver_hub/__init__.py
Normal file
31
lps/services/naver_hub/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
111
lps/services/naver_hub/client.py
Normal file
111
lps/services/naver_hub/client.py
Normal file
@ -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)
|
||||||
160
lps/services/naver_hub/shopping_insight.py
Normal file
160
lps/services/naver_hub/shopping_insight.py
Normal file
@ -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
|
||||||
163
lps/tests/test_naver_hub.py
Normal file
163
lps/tests/test_naver_hub.py
Normal file
@ -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="<html>service unavailable</html>")
|
||||||
|
|
||||||
|
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)
|
||||||
Loading…
Reference in New Issue
Block a user