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:
민헌 2026-08-05 13:26:10 +09:00
parent 18fe18dd39
commit b7fc327779
13 changed files with 33 additions and 230 deletions

View File

@ -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 # 오픈마켓 공용 카드 파서

View File

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

View File

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

View File

@ -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 의 후속.

View File

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

View File

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

View File

@ -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/터널)** 도 사이트 차단과 구분해 회전.

View File

@ -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 키·스케일)을 담는다(미커밋).

View File

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

View File

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

View File

@ -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 — 기록 전에도 안전

View File

@ -1,19 +0,0 @@
"""네이버 응답 변환 테스트 (순수, 네트워크 불필요)."""
from services.search.naver.transform import transform_items
def test_transform_strips_tags_and_keeps_catalog():
items = [
{"title": "로지텍 <b>무선</b> 마우스 &amp; 키보드", "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) 최저가 유지

View File

@ -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 # 검색을 안 했으니 가격도 없다