refactor(lps): 사문화된 네이버 오픈API 어댑터 제거 + 낡은 테스트 계약 갱신
**낡은 테스트**: test_handler_skips_record_on_negative_cache_hit 는 '캐시 히트면 이력을 남기지 않는다'를 검증했는데, 그 동작은 실측 버그였다 — 잡은 DONE 인데 price_history 에 새 행이 없어 이를 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다. 코드는 이미 '캐시 히트도 이 잡의 결과이므로 기록한다'로 고쳐져 있었고 테스트만 남아 있었다. → 현재 계약(not_found 스냅샷 1건 기록, 가격은 null)을 검증하도록 다시 씀. 전체 220 passed·0 failed. **오픈API 어댑터 제거**: shop.json 이 2026-07-31 종료돼 404 SE05 만 반환하고, 파이프라인은 naver_shop(크롤)로 옮겨 갔다. 되살릴 수 없는 코드를 남겨두면 다음 사람이 "키를 넣으면 되나" 하고 시간을 쓴다. - services/search/naver/ (adapter·transform) 삭제 - NaverConfig 모델·로더·설정 섹션 3개 파일에서 제거(죽은 키) - test_naver_transform 삭제, test_alerts 는 NaverAdapter 대신 스텁 사용 (검증 대상인 recent_stats/_note_result 는 베이스 SearchAdapter 계약이라 무관) **문서 정합화**: architecture(네이버 안티봇=WTM, 통과 3조건) · api(배송비가 이제 채워짐, 가격은 즉시판매가·쿠폰가 제외) · operations(kr_host·naver_ip_request_budget) · README 트리. source 이름 "naver" 는 그대로다 — price_history·by_mall·프론트 계약은 구현 교체와 무관하다.
This commit is contained in:
parent
18fe18dd39
commit
b7fc327779
@ -132,7 +132,7 @@ lps/
|
||||
├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계)
|
||||
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session)
|
||||
├── services/
|
||||
│ ├── search/ # 소스 어댑터 (coupang, naver, esm=G마켓·옥션, st11=11번가)
|
||||
│ ├── search/ # 소스 어댑터 (coupang, naver_shop=네이버 크롤, esm=G마켓·옥션, st11=11번가)
|
||||
│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측)
|
||||
│ │ ├── proxy.py # DECODO(IP 회전·포트 쿨다운·프리플라이트)
|
||||
│ │ └── card_parser.py # 오픈마켓 공용 카드 파서
|
||||
|
||||
@ -77,22 +77,6 @@ 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>"
|
||||
secret = "<NAVER_CLIENT_SECRET>"
|
||||
# 추가 키는 아래처럼 블록을 더 넣으면 됨:
|
||||
# [[NaverConfig.keys]]
|
||||
# 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>"
|
||||
|
||||
@ -64,11 +64,6 @@ block_sessions_6h = 1
|
||||
|
||||
# ── 시크릿 ──
|
||||
|
||||
[NaverConfig]
|
||||
[[NaverConfig.keys]]
|
||||
id = "<NAVER_CLIENT_ID>"
|
||||
secret = "<NAVER_CLIENT_SECRET>"
|
||||
|
||||
[OpenAIConfig]
|
||||
api_key = "<OPENAI_API_KEY>" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config.config_loader import ConfigModel
|
||||
|
||||
|
||||
@ -51,22 +49,6 @@ class MainDBConfig(ConfigModel):
|
||||
# 배포는 이 파일을 마운트하거나(권장), 환경별로 바뀌는 값만 env override 한다(DB_HOST 등).
|
||||
|
||||
|
||||
class NaverKey(BaseModel):
|
||||
id: str = ""
|
||||
secret: str = ""
|
||||
|
||||
|
||||
class NaverConfig(ConfigModel):
|
||||
"""네이버 쇼핑 오픈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 의 후속.
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import os
|
||||
|
||||
from config.config_loader import Configs
|
||||
from config.config_models import (
|
||||
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverApiHubConfig, OpenAIConfig, DecodoConfig,
|
||||
WebServerConfig, LogConfig, MainDBConfig, NaverApiHubConfig, OpenAIConfig, DecodoConfig,
|
||||
WorkerConfig, AlertConfig,
|
||||
)
|
||||
|
||||
@ -33,7 +33,6 @@ web_server_config: WebServerConfig = configs.get(WebServerConfig)
|
||||
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()
|
||||
|
||||
@ -103,9 +103,11 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
|
||||
> - `price` 는 **상품가**입니다. 배송비 포함 여부는 `shipping_fee`/`shipping_type` 으로 판단합니다.
|
||||
> - **쿠팡**: 검색 화면의 배송 신호를 파싱해 채웁니다 —
|
||||
> `shipping_type`: `rocket`(로켓배송, 와우 무료/일반 19,800원↑ 무료) · `rocket_merchant`(판매자로켓) · `free`(명시 무료) · `paid`(유료, `shipping_fee`에 금액) · `null`(미확인)
|
||||
> - **네이버**: 오픈API `lprice` 는 **배송비 제외** 상품가라 둘 다 항상 `null` 입니다.
|
||||
> 가격비교(카탈로그) 화면의 기본 표시는 "**배송비포함** 최저가"라서 **API 값과 다르게 보이는 것이 정상**입니다
|
||||
> (예: API 5,880원 vs 화면 7,520원). 카탈로그 페이지 자동 수집은 네이버 캡차로 차단되어 미지원.
|
||||
> - **네이버**: 모바일 검색 화면을 그대로 파싱하므로 **배송비도 채워집니다**(`free`/`paid`).
|
||||
> 옛 오픈API(`lprice`)는 배송비 제외 상품가라 화면과 값이 달랐는데, 그 불일치가 사라졌습니다.
|
||||
> (오픈API 는 2026-07-31 종료 — 네이버는 크롤 경로만 남았습니다)
|
||||
> - 가격은 **즉시 판매가**입니다. 쿠폰할인가는 조건부(1인 1회·선착순)라 쓰지 않습니다 —
|
||||
> 쓰면 실구매가보다 낮게 잡혀 최저가가 왜곡됩니다.
|
||||
|
||||
```bash
|
||||
curl localhost:9600/v1/lps/jobs/c885...
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
| **쿠팡** | Akamai(JS 챌린지, 여러 flavor: 챌린지·Edge Access Denied·권한제한) | 실제 Chrome 통과 + 다종 마커 감지→IP 회전. 리소스 차단 OK(대역폭↓) |
|
||||
| **G마켓·옥션**(ESM) | **Cloudflare Turnstile**('사람인지 확인' 체크박스) | patchright가 콜드 ~12초에 **자동 통과**, `cf_clearance` 쿠키로 이후 요청은 웜(~5초). 인터랙티브 체크박스는 best-effort 클릭 |
|
||||
| **11번가** | 경량(모바일은 robot 차단→PC 사용) | PC 크롤. 지연 로딩 → 스크롤 트리거 |
|
||||
| **네이버** | 없음(공식 오픈API) | httpx 직접 호출 + 키 로테이션 |
|
||||
| **네이버** | **WTM 캡차** (모바일 msearch. PC 는 405/418 로 아예 막힘) | 실제 Chrome + **한국 IP 필수**(kr.decodo.com) + `ko-KR` 로케일 + **리소스 차단 금지**(route 를 걸면 즉시 캡차 — 셋 중 하나만 빠져도 막힘). 해외 IP 는 회전 무효인 하드차단이라 즉시 실패시킨다 |
|
||||
|
||||
**핵심 메커니즘**
|
||||
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
|
||||
|
||||
@ -14,9 +14,9 @@ cp config/config.local.toml.example config/config.local.toml
|
||||
| 섹션 | 값 |
|
||||
|------|-----|
|
||||
| `[MainDBConfig]` | DB 접속(host/port/id/pw, name=lps_db) |
|
||||
| `[NaverConfig].keys` | 네이버 쇼핑 API 키(id/secret). 여러 개면 자동 로테이션 |
|
||||
| `[NaverApiHubConfig]` | NCP 네이버 API 허브(검색·트렌드·쇼핑인사이트). **쇼핑 검색은 없음** — 최저가엔 미사용 |
|
||||
| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) |
|
||||
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) |
|
||||
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용). `kr_host`=네이버용 한국 게이트웨이(비우면 네이버가 막힌다), `naver_ip_request_budget`=IP 당 요청 예산(실측 12회 무차단 → 10) |
|
||||
|
||||
> **설정 소스는 `config.local.toml` 하나다**(도메인 backend·negodata·agent 와 동일). 환경 구분이 없다 —
|
||||
> 호스트 실행·Docker·prod 서버 모두 같은 파일명을 쓰고, **서버마다 그 서버의 값**(시크릿·guard 키·스케일)을 담는다(미커밋).
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
"""네이버 쇼핑 검색 어댑터.
|
||||
|
||||
공식 오픈 API(https://openapi.naver.com/v1/search/shop.json)라 크롤링/브라우저 불필요.
|
||||
레퍼런스의 핵심 자산인 **키 로테이션**을 이식: 429/403(쿼터/차단) 시 다음 키로 순환 재시도.
|
||||
키는 config.local.toml [NaverConfig].keys 에서 로드(여러 개면 로테이션).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import naver_config
|
||||
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
|
||||
from services.search.rate_limiter import RateLimiter
|
||||
from services.search.naver.transform import transform_items
|
||||
|
||||
_API = "https://openapi.naver.com/v1/search/shop.json"
|
||||
_MAX_START = 1000 # 네이버 start 상한
|
||||
_MAX_DISPLAY = 100
|
||||
|
||||
|
||||
def load_naver_keys() -> list[tuple[str, str]]:
|
||||
"""(client_id, client_secret) 쌍 목록. TOML [NaverConfig].keys 에서 로드(여러 개면 로테이션)."""
|
||||
return [(k.id, k.secret) for k in naver_config.keys if k.id and k.secret]
|
||||
|
||||
|
||||
class NaverAdapter(SearchAdapter):
|
||||
source = "naver"
|
||||
|
||||
def __init__(self, keys: list[tuple[str, str]] | None = None, rate_limiter: RateLimiter | None = None, timeout: float = 10.0):
|
||||
self._keys = keys if keys is not None else load_naver_keys()
|
||||
self._idx = 0
|
||||
self._rl = rate_limiter or RateLimiter(0.1, 0.3) # 공식 API — 짧은 간격
|
||||
self._timeout = timeout
|
||||
self._ok = 0
|
||||
self._blocked = 0
|
||||
self.last_bytes = 0 # 직전 search 의 응답 바이트(계측용)
|
||||
|
||||
uses_proxy = False # 네이버는 오픈API 직접 호출(프록시 미경유) — DECODO 대역폭 비용 없음
|
||||
|
||||
def _headers(self) -> dict:
|
||||
cid, csec = self._keys[self._idx]
|
||||
return {"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": csec}
|
||||
|
||||
def _rotate(self):
|
||||
self._idx = (self._idx + 1) % len(self._keys)
|
||||
|
||||
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
|
||||
if not self._keys:
|
||||
raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source)
|
||||
|
||||
self.last_bytes = 0
|
||||
collected: list[dict] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
start = 1
|
||||
while len(collected) < limit and start <= _MAX_START:
|
||||
display = min(_MAX_DISPLAY, limit - len(collected))
|
||||
data = await self._request(client, {
|
||||
"query": query, "display": display, "start": start,
|
||||
"sort": "sim", "exclude": "used:rental:cbshop",
|
||||
})
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
collected.extend(items)
|
||||
start += display
|
||||
if len(items) < display:
|
||||
break
|
||||
except Exception:
|
||||
self._note_result(False) # 장기 실패 알림용 윈도우 카운터(쿼터 소진·네트워크 포함)
|
||||
raise
|
||||
|
||||
products = transform_items(collected, self.source)
|
||||
self._ok += 1
|
||||
self._note_result(True)
|
||||
LOG.d(f"[naver] query={query!r} → {len(products)}건 (limit {limit})")
|
||||
return products[:limit]
|
||||
|
||||
async def _request(self, client: httpx.AsyncClient, params: dict) -> dict:
|
||||
"""키 개수만큼 재시도. 429/403 이면 다음 키로 로테이션."""
|
||||
last_status = None
|
||||
for _ in range(max(1, len(self._keys))):
|
||||
await self._rl.wait()
|
||||
r = await client.get(_API, params=params, headers=self._headers())
|
||||
if r.status_code == 200:
|
||||
self.last_bytes += len(r.content)
|
||||
return r.json()
|
||||
if r.status_code in (429, 403):
|
||||
self._blocked += 1
|
||||
last_status = r.status_code
|
||||
LOG.w(f"[naver] {r.status_code} → 키 로테이션(idx {self._idx})")
|
||||
self._rotate()
|
||||
continue
|
||||
raise AdapterError(f"네이버 API 오류 {r.status_code}: {r.text[:200]}", source=self.source)
|
||||
raise AdapterError(f"네이버 API 쿼터/차단(모든 키 소진, last={last_status})", source=self.source, blocked=True)
|
||||
|
||||
async def health(self) -> AdapterHealth:
|
||||
total = self._ok + self._blocked
|
||||
rate = (self._ok / total) if total else 0.0
|
||||
return AdapterHealth(source=self.source, ok=(self._blocked == 0 or rate > 0.5),
|
||||
recent_success_rate=rate, blocked_rate=(self._blocked / total) if total else 0.0)
|
||||
|
||||
async def close(self):
|
||||
"""httpx 클라이언트를 search 마다 생성/정리하므로 별도 정리 불필요(수명주기 통일용 no-op)."""
|
||||
return
|
||||
@ -1,52 +0,0 @@
|
||||
"""네이버 쇼핑 API 응답 items → NormalizedProduct (순수 함수).
|
||||
|
||||
네트워크 무관 — 저장된/모의 JSON 으로 결정론적 테스트 가능.
|
||||
title 의 <b> 강조 태그·HTML 엔티티를 제거하고, 가격비교(catalog) 페이지는 제외한다.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
from services.search.contract import NormalizedProduct
|
||||
|
||||
_TAG = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
return html.unescape(_TAG.sub("", text or "")).strip()
|
||||
|
||||
|
||||
def transform_items(items: list[dict], source: str = "naver") -> list[NormalizedProduct]:
|
||||
products: list[NormalizedProduct] = []
|
||||
for it in items:
|
||||
link = it.get("link", "") or ""
|
||||
# 가격비교(catalog, productType=1) 페이지의 lprice 는 '여러 판매자 중 최저가'라
|
||||
# 최저가 솔루션에는 오히려 핵심 신호 → 제외하지 않고 그대로 취한다.
|
||||
# 단, lprice 는 **배송비 제외** 상품가(API 에 배송비 필드 없음) — 카탈로그 화면의
|
||||
# '배송비포함 최저가'와 다를 수 있다. 카탈로그 크롤링은 WTM 캡차로 차단됨(2026-07 스파이크)
|
||||
# → shipping_fee/shipping_type 은 None(미확인)으로 남긴다.
|
||||
|
||||
try:
|
||||
price = int(it.get("lprice")) # lprice = 최저가
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
name = _clean(it.get("title"))
|
||||
if not name:
|
||||
continue
|
||||
|
||||
products.append(
|
||||
NormalizedProduct(
|
||||
source=source,
|
||||
name=name,
|
||||
price=price,
|
||||
image_url=it.get("image") or None,
|
||||
detail_url=link or None,
|
||||
mall_name=it.get("mallName") or None,
|
||||
manufacturer=(it.get("maker") or it.get("brand")) or None,
|
||||
external_id=str(it["productId"]) if it.get("productId") else None,
|
||||
)
|
||||
)
|
||||
return products
|
||||
@ -2,7 +2,15 @@
|
||||
|
||||
from common.alerts import AlertManager
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from services.search.naver.adapter import NaverAdapter
|
||||
from services.search.contract import NormalizedProduct, SearchAdapter
|
||||
|
||||
|
||||
class _StubAdapter(SearchAdapter):
|
||||
"""윈도우 카운터(_note_result/recent_stats)는 베이스 계약이라 아무 어댑터로도 검증된다."""
|
||||
source = "stub"
|
||||
|
||||
async def search(self, query, limit=40) -> list[NormalizedProduct]:
|
||||
return []
|
||||
|
||||
|
||||
class _Clock:
|
||||
@ -77,7 +85,7 @@ async def test_sender_failure_does_not_raise():
|
||||
# ---- 윈도우 성공/실패 카운터(소스별 장기 실패 룰의 데이터) ----------------
|
||||
|
||||
def test_recent_stats_counts_within_window():
|
||||
ad = NaverAdapter(keys=[("id", "sec")])
|
||||
ad = _StubAdapter()
|
||||
ad._note_result(True)
|
||||
ad._note_result(False)
|
||||
ad._note_result(False)
|
||||
@ -86,7 +94,7 @@ def test_recent_stats_counts_within_window():
|
||||
|
||||
|
||||
def test_recent_stats_empty_adapter():
|
||||
ad = NaverAdapter(keys=[("id", "sec")])
|
||||
ad = _StubAdapter()
|
||||
assert ad.recent_stats(1800) == (0, 0) # lazy init — 기록 전에도 안전
|
||||
|
||||
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
"""네이버 응답 변환 테스트 (순수, 네트워크 불필요)."""
|
||||
|
||||
from services.search.naver.transform import transform_items
|
||||
|
||||
|
||||
def test_transform_strips_tags_and_keeps_catalog():
|
||||
items = [
|
||||
{"title": "로지텍 <b>무선</b> 마우스 & 키보드", "link": "https://search.shopping.naver.com/catalog/1",
|
||||
"image": "img1", "lprice": "13500", "mallName": "네이버", "maker": "로지텍", "productId": "1"},
|
||||
{"title": "0원상품", "link": "https://x", "lprice": "0"}, # 가격 0 → 제외
|
||||
{"title": "가격없음", "link": "https://y", "lprice": "abc"}, # 파싱 실패 → 제외
|
||||
]
|
||||
out = transform_items(items)
|
||||
assert len(out) == 1
|
||||
p = out[0]
|
||||
assert p.name == "로지텍 무선 마우스 & 키보드" # <b> 제거 + 엔티티 복원
|
||||
assert p.price == 13500 and p.source == "naver"
|
||||
assert p.mall_name == "네이버" and p.manufacturer == "로지텍" and p.external_id == "1"
|
||||
assert "catalog/1" in p.detail_url # 가격비교(catalog) 최저가 유지
|
||||
@ -86,8 +86,17 @@ async def test_handler_records_found_snapshot():
|
||||
assert e["final_lowest"] == 1800 and e["final_source"] == "coupang"
|
||||
|
||||
|
||||
async def test_handler_skips_record_on_negative_cache_hit():
|
||||
async def test_handler_records_on_negative_cache_hit():
|
||||
"""캐시 히트도 '이 잡의 결과'라 이력을 남겨야 한다.
|
||||
|
||||
예전엔 남기지 않았는데, 그러면 잡은 DONE 인데 price_history 에 새 행이 없어
|
||||
이걸 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다(실측 버그).
|
||||
검색을 생략했을 뿐 결과는 not_found 로 확정된 것이므로 기록이 맞다."""
|
||||
rec = _Rec()
|
||||
adapters = {"naver": _FakeAdapter("naver", [_np("naver", 100)])}
|
||||
await build_search_handler(adapters, neg_cache=_Neg(True), history=rec)(_job())
|
||||
assert rec.events == [] # 캐시 히트 → 새 관측 없음 → 미기록
|
||||
out = await build_search_handler(adapters, neg_cache=_Neg(True), history=rec)(_job())
|
||||
assert out["outcome"] == "not_found" and out["cached"] is True
|
||||
assert len(rec.events) == 1
|
||||
e = rec.events[0]
|
||||
assert e["product_code"] == "PC1" and e["outcome"] == "not_found"
|
||||
assert e["final_lowest"] is None and e["matched_count"] == 0 # 검색을 안 했으니 가격도 없다
|
||||
|
||||
Loading…
Reference in New Issue
Block a user