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:
민헌 2026-08-04 11:23:26 +09:00
parent 3c3f60c7d4
commit 005ebc3d76
7 changed files with 503 additions and 2 deletions

View File

@ -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 = "<NAVER_CLIENT_ID>"
@ -87,6 +89,16 @@ secret = "<NAVER_CLIENT_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 = "<NCP_CLIENT_ID>"
client_secret = "<NCP_CLIENT_SECRET>"
timeout_sec = 10.0
# AI 유사도 판정/검색어 생성 (OpenAI)
[OpenAIConfig]
api_key = "<OPENAI_API_KEY>"

View File

@ -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."""

View File

@ -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()

View 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",
]

View 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)

View 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
View 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)