From b7fc327779a7abfed0d5802c38b85d2f9ac88abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 5 Aug 2026 13:26:10 +0900 Subject: [PATCH] =?UTF-8?q?refactor(lps):=20=EC=82=AC=EB=AC=B8=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=98=A4=ED=94=88API?= =?UTF-8?q?=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20+=20?= =?UTF-8?q?=EB=82=A1=EC=9D=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **낡은 테스트**: 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·프론트 계약은 구현 교체와 무관하다. --- lps/README.md | 2 +- lps/config/config.local.toml.example | 16 ---- lps/config/config.prod.toml.example | 5 -- lps/config/config_models.py | 18 ----- lps/config/server_configs.py | 3 +- lps/docs/api.md | 8 +- lps/docs/architecture.md | 2 +- lps/docs/operations.md | 4 +- lps/services/search/naver/adapter.py | 105 ------------------------- lps/services/search/naver/transform.py | 52 ------------ lps/tests/test_alerts.py | 14 +++- lps/tests/test_naver_transform.py | 19 ----- lps/tests/test_price_history.py | 15 +++- 13 files changed, 33 insertions(+), 230 deletions(-) delete mode 100644 lps/services/search/naver/adapter.py delete mode 100644 lps/services/search/naver/transform.py delete mode 100644 lps/tests/test_naver_transform.py diff --git a/lps/README.md b/lps/README.md index 6113879..5f144ae 100644 --- a/lps/README.md +++ b/lps/README.md @@ -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 # 오픈마켓 공용 카드 파서 diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index b1b83b6..661d707 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -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 = "" -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 = "" diff --git a/lps/config/config.prod.toml.example b/lps/config/config.prod.toml.example index 4d67570..c4ef0f2 100644 --- a/lps/config/config.prod.toml.example +++ b/lps/config/config.prod.toml.example @@ -64,11 +64,6 @@ block_sessions_6h = 1 # ── 시크릿 ── -[NaverConfig] -[[NaverConfig.keys]] -id = "" -secret = "" - [OpenAIConfig] api_key = "" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다 model = "gpt-4o-mini" diff --git a/lps/config/config_models.py b/lps/config/config_models.py index c4b6e1f..2a54a72 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -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 의 후속. diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 9411cf6..fdf37af 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, 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() diff --git a/lps/docs/api.md b/lps/docs/api.md index accc3a8..1825272 100644 --- a/lps/docs/api.md +++ b/lps/docs/api.md @@ -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... diff --git a/lps/docs/architecture.md b/lps/docs/architecture.md index fc79bdc..a49a178 100644 --- a/lps/docs/architecture.md +++ b/lps/docs/architecture.md @@ -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/터널)** 도 사이트 차단과 구분해 회전. diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 55ddc5c..9293a5e 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -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 키·스케일)을 담는다(미커밋). diff --git a/lps/services/search/naver/adapter.py b/lps/services/search/naver/adapter.py deleted file mode 100644 index 799ef4a..0000000 --- a/lps/services/search/naver/adapter.py +++ /dev/null @@ -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 diff --git a/lps/services/search/naver/transform.py b/lps/services/search/naver/transform.py deleted file mode 100644 index 07b4380..0000000 --- a/lps/services/search/naver/transform.py +++ /dev/null @@ -1,52 +0,0 @@ -"""네이버 쇼핑 API 응답 items → NormalizedProduct (순수 함수). - -네트워크 무관 — 저장된/모의 JSON 으로 결정론적 테스트 가능. -title 의 강조 태그·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 diff --git a/lps/tests/test_alerts.py b/lps/tests/test_alerts.py index 2850eb1..9fe27b8 100644 --- a/lps/tests/test_alerts.py +++ b/lps/tests/test_alerts.py @@ -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 — 기록 전에도 안전 diff --git a/lps/tests/test_naver_transform.py b/lps/tests/test_naver_transform.py deleted file mode 100644 index 5d49322..0000000 --- a/lps/tests/test_naver_transform.py +++ /dev/null @@ -1,19 +0,0 @@ -"""네이버 응답 변환 테스트 (순수, 네트워크 불필요).""" - -from services.search.naver.transform import transform_items - - -def test_transform_strips_tags_and_keeps_catalog(): - items = [ - {"title": "로지텍 무선 마우스 & 키보드", "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 == "로지텍 무선 마우스 & 키보드" # 제거 + 엔티티 복원 - 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) 최저가 유지 diff --git a/lps/tests/test_price_history.py b/lps/tests/test_price_history.py index 00def63..70eccf4 100644 --- a/lps/tests/test_price_history.py +++ b/lps/tests/test_price_history.py @@ -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 # 검색을 안 했으니 가격도 없다