From 23c1c48634ab495b6bf2601926bcf7fabf324844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 5 Aug 2026 09:05:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=80=EA=B0=80=EB=A5=BC=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=ED=81=AC=EB=A1=A4=EB=A1=9C=20=EB=B3=B5=EA=B5=AC(?= =?UTF-8?q?=EC=98=A4=ED=94=88API=20=EC=A2=85=EB=A3=8C=20=EB=8C=80=EC=B2=B4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아 가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환. 경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은 로그인 리다이렉트였는데 그 사이 열렸다. 통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측): - **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이 ([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지) - **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다 (BrowserSearchAdapter.context_options 훅 추가) - **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 → '무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009) 파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다: - 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격) - 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘) - 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미) - **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다 - 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율 source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이 구현(API→크롤) 교체와 무관하게 살아야 한다. 테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed. --- lps/config/config.local.toml.example | 1 + lps/config/config_models.py | 4 + lps/services/search/browser_base.py | 4 + lps/services/search/naver_shop/__init__.py | 6 + lps/services/search/naver_shop/adapter.py | 67 +++++++++ lps/services/search/naver_shop/parser.py | 138 +++++++++++++++++++ lps/services/search/naver_shop/selectors.py | 37 +++++ lps/services/search/proxy.py | 5 +- lps/tests/fixtures/naver_msearch.html | 22 +++ lps/tests/test_browser_base.py | 5 +- lps/tests/test_naver_shop_parser.py | 145 ++++++++++++++++++++ lps/worker_main.py | 13 +- 12 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 lps/services/search/naver_shop/__init__.py create mode 100644 lps/services/search/naver_shop/adapter.py create mode 100644 lps/services/search/naver_shop/parser.py create mode 100644 lps/services/search/naver_shop/selectors.py create mode 100644 lps/tests/fixtures/naver_msearch.html create mode 100644 lps/tests/test_naver_shop_parser.py diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index 5b25350..9a275c0 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -107,6 +107,7 @@ model = "gpt-4o-mini" # DECODO residential 프록시 (쿠팡 전용, 포트기반 sticky). 값 다 채우면 활성(비면 프록시 미사용). [DecodoConfig] host = "" # 예: gate.decodo.com +kr_host = "kr.decodo.com" # 한국 타깃 게이트웨이(네이버용). 비우면 host 사용 username = "" # 대시보드 USERNAME (예: sppd6a3ze3) password = "" # 대시보드 PASSWORD port_start = 0 # 예: 10001 diff --git a/lps/config/config_models.py b/lps/config/config_models.py index 3d10ffb..a26f908 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -96,6 +96,10 @@ class DecodoConfig(ConfigModel): """DECODO residential 프록시(쿠팡 전용, 포트기반 sticky). 값이 다 차야 활성.""" host: str = "" + # 한국 residential 타깃 게이트웨이(kr.decodo.com). 네이버는 해외 IP 를 즉시 하드차단하므로 + # 국내 사이트 전용으로 쓴다(실측 2026-08-04: 해외 IP=2.6KB 차단페이지, KR IP=정상). + # 비우면 host 를 그대로 쓴다(=국가 무지정 월드와이드). + kr_host: str = "" username: str = "" password: str = "" port_start: int = 0 diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index 28d87b1..389aa41 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -71,6 +71,9 @@ class BrowserSearchAdapter(SearchAdapter): blocked_resource_types: set = _BLOCKED_RESOURCES # 차단할 resource_type(사이트별 override — 오픈마켓은 CSS/JS 유지) scroll_steps: int = 0 # >0 이면 렌더 대기 전 스크롤(지연 로딩 트리거, 예: 11번가) max_proxy_retries: int = 2 # 프록시 전송오류(포트 사망) 시 IP 회전 재시도 횟수 + # launch_persistent_context 에 얹을 사이트별 옵션. 네이버 WTM 은 **한국 IP + en-US 로케일** 조합을 + # 봇으로 본다(실측: 같은 IP·같은 브라우저에서 locale 만 ko-KR 로 주면 캡차→정상). 기본은 비움. + context_options: dict = {} def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None, proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1, @@ -159,6 +162,7 @@ class BrowserSearchAdapter(SearchAdapter): if self._pw is None: self._pw = await async_playwright().start() kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) + kwargs.update(self.context_options) # 사이트별 컨텍스트 옵션(예: 네이버 locale/timezone) if _CHROME_EXECUTABLE: kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium # 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지. diff --git a/lps/services/search/naver_shop/__init__.py b/lps/services/search/naver_shop/__init__.py new file mode 100644 index 0000000..a364a0f --- /dev/null +++ b/lps/services/search/naver_shop/__init__.py @@ -0,0 +1,6 @@ +"""네이버 쇼핑 크롤(모바일 msearch) — 종료된 쇼핑 오픈API 의 대체 경로.""" + +from services.search.naver_shop.adapter import NaverShopAdapter, detect_block +from services.search.naver_shop.parser import parse_search_html + +__all__ = ["NaverShopAdapter", "detect_block", "parse_search_html"] diff --git a/lps/services/search/naver_shop/adapter.py b/lps/services/search/naver_shop/adapter.py new file mode 100644 index 0000000..d8f2c6a --- /dev/null +++ b/lps/services/search/naver_shop/adapter.py @@ -0,0 +1,67 @@ +"""네이버 쇼핑 크롤 어댑터 (모바일 msearch). + +배경: 2026-07-31 네이버가 쇼핑 검색 오픈API 를 종료(404 SE05)했고 NCP API HUB 에도 +승계되지 않았다 → 가격을 얻을 공식 경로가 없어 크롤로 돌아왔다. + +경로 선택(2026-08-04 실측): + PC search.shopping.naver.com/search/all 405 + WTM 캡차 + PC /api/search/all 418(봇 차단) + 모바일 msearch.shopping.naver.com **200 · 카드 40건** ← 이 경로 +7/9 스파이크 때 모바일은 로그인 리다이렉트였는데 그 사이 열렸다. 다시 닫힐 수 있으므로 +차단 마커를 넉넉히 잡고, 막히면 IP 회전(쿠팡과 동일한 BrowserSearchAdapter machinery)에 맡긴다. + +프록시: **한국 IP 필수**(worker_main 이 kr.decodo.com 게이트웨이로 주입). 실측 차이가 크다 — + 해외 residential IP 즉시 하드차단(2.6KB 'Access Denied' 계열) + 한국 residential IP 정상. 단일 IP 로 연달아 두드리면 캡차로 넘어가므로 회전은 그대로 필요 +""" + +from urllib.parse import quote + +from services.search.browser_base import BrowserSearchAdapter, detect_block as _detect_block +from services.search.contract import NormalizedProduct +from services.search.naver_shop.parser import parse_search_html +from services.search.naver_shop.selectors import SELECTORS + +_SEARCH_URL = "https://msearch.shopping.naver.com/search/all?query={q}" + +# 차단 flavor: WTM 캡차 페이지 / 접근제한 안내 / 418 teapot 본문. +_BLOCK_MARKERS = ( + "wtm_captcha", "비정상적인 접근", "일시적으로 제한", "자동입력 방지", + "정상적인 서비스 이용", "nid.naver.com/nidlogin", +) +# 정상 결과 페이지는 1.3MB+ 다. 차단 페이지는 실측 48~65KB → 그 사이에 임계를 둔다. +# (0건일 때만 적용되므로 '검색결과 없음'이 커도 오탐하지 않는다) +_MIN_RESULT_HTML = 150_000 + + +def detect_block(html: str, product_count: int) -> str | None: + """0건 응답의 차단 여부 판정(순수 함수 — 브라우저 무관, 단위 테스트 가능).""" + return _detect_block(html, product_count, _BLOCK_MARKERS, _MIN_RESULT_HTML) + + +class NaverShopAdapter(BrowserSearchAdapter): + # source 는 구현(API/크롤)이 아니라 **도메인 정체성**이다. "naver" 를 유지해야 + # price_history 의 naver_lowest/name/url, MALL_BY_SOURCE, 프론트 그래프 계약이 그대로 산다. + source = "naver" + block_markers = _BLOCK_MARKERS + min_result_html = _MIN_RESULT_HTML + ready_selector = SELECTORS.card + ready_timeout_ms = 20000 + # 모바일은 무한스크롤 — 초기 20건, 스크롤하면 40건까지 늘고 그 이상은 안 나온다(실측). + scroll_steps = 3 + # ⚠️ 리소스 차단을 **켜면 안 된다**. route 를 걸면 WTM 이 즉시 캡차로 넘긴다(실측 2026-08-04): + # 차단 없음 → 정상 14건 · 전송 3.07MB + # image/media/font 만 차단 → 차단 · 0.58MB ← CSS 를 살려도 안 통한다 + # image/media/font/css 차단 → 차단 + # 부분 차단조차 막히는 걸 보면 '무엇을 막느냐'가 아니라 **요청 가로채기(CDP Fetch) 자체**가 + # 탐지 신호다. 그래서 대역폭을 포기하고 끈다 — 검색당 ~3MB(DECODO $3/GB 기준 ~$0.009). + block_resources_default = False + # 한국어 로케일/시간대 필수. 한국 IP 로 들어가면서 브라우저가 en-US 면 WTM 이 캡차를 띄운다 + # (실측: 같은 KR 프록시·같은 브라우저에서 이 두 줄 유무로 캡차↔정상이 갈렸다). + context_options = {"locale": "ko-KR", "timezone_id": "Asia/Seoul"} + + def _search_url(self, query: str, limit: int) -> str: + return _SEARCH_URL.format(q=quote(query)) + + def _parse(self, html: str) -> list[NormalizedProduct]: + return parse_search_html(html, source=self.source) diff --git a/lps/services/search/naver_shop/parser.py b/lps/services/search/naver_shop/parser.py new file mode 100644 index 0000000..c2e3ede --- /dev/null +++ b/lps/services/search/naver_shop/parser.py @@ -0,0 +1,138 @@ +"""네이버 모바일 쇼핑 카드 → NormalizedProduct (순수 함수, 저장 HTML 로 테스트). + +최종 목적은 **정확한 상품의 최저가**다. 그래서 파싱은 '많이 긁기'가 아니라 +'가격의 의미가 확실한 것만 남기기' 쪽으로 판단한다. + +카드 종류에 따라 가격의 의미가 다르다 — 둘 다 담되 구분한다: + 판매처 카드 "21,900원" + 몰명 → 그 판매처의 실제 판매가 + 가격비교 카드 "최저7,790원" + 판매처 N곳 → 카탈로그 최저가. 몰명이 없어 mall_name="네이버" + (옛 오픈API 의 lprice 와 같은 의미 — 그래서 버리지 않고 유지한다) + +버리는 것과 이유: + 광고/슈퍼적립/브랜드블록 카드 판매 오퍼가 아니라 노출 상품 — 가격이 멤버십·쿠폰 조건부 + 쿠폰할인가 조건부(1인 1회·선착순) — price 로 쓰면 실구매가보다 싸게 잡힘 + +가격 파싱 함정(전부 실측에서 실제로 밟은 것들): + "21,900원(100ml당 548원)" 단위가격을 먼저 떼지 않으면 548 을 집는다 + "최저7,790원(1개입당 31원)배송비3,900원" 가격 노드가 배송비까지 감싼다 → 마지막 매치를 쓰면 3,900 이 가격이 된다 + "정상가60,000원할인율38%36,840원" 정상가·할인율을 걷어내야 실판매가가 남는다 + "최저7,790원" '최저'와 숫자 사이에 공백이 없다(\\s 를 요구하면 못 잡음) +""" + +import re + +from selectolax.parser import HTMLParser + +from common.logger import LOG +from services.search.contract import NormalizedProduct +from services.search.naver_shop.selectors import SELECTORS as S + +_NUM = re.compile(r"([\d,]{3,})\s*원") +_LOWEST = re.compile(r"최저\s*[\d]") # "최저7,790원" — 공백 없는 표기가 실제로 나온다 +_CATALOG_MALL = "네이버" # 가격비교 카드의 판매처 표기(옛 오픈API 의 mallName 과 동일) + + +def parse_search_html(html: str, source: str = "naver") -> list[NormalizedProduct]: + """검색 결과 HTML → 정규화 상품. 파싱 불가/의미 불명 카드는 조용히 버린다.""" + tree = HTMLParser(html) + out: list[NormalizedProduct] = [] + skipped = {"ad": 0, "no_price": 0, "no_title": 0} + catalog = 0 + + for card in tree.css(S.card): + cls = (card.attributes.get("class") or "").strip() + # adProduct_item_inner 도 'product_item_inner' 를 포함하므로 prefix 로 유기 카드만 남긴다 + if not cls.startswith(S.card_class_prefix): + skipped["ad"] += 1 + continue + if card.css_first(S.ad_marker) is not None: + skipped["ad"] += 1 + continue + + title = _text(card, S.title) + if not title: + skipped["no_title"] += 1 + continue + + price, price_raw, fee, ship_type = _prices(card) + if not price: + skipped["no_price"] += 1 + continue + + is_catalog = bool(_LOWEST.search(price_raw)) + if is_catalog: + catalog += 1 + out.append(NormalizedProduct( + source=source, + name=title, + price=price, + mall_name=_text(card, S.mall) or (_CATALOG_MALL if is_catalog else None), + detail_url=_href(card), + shipping_fee=fee, + shipping_type=ship_type, + )) + + if out or any(skipped.values()): + LOG.d(f"[naver_shop] 파싱 {len(out)}건(가격비교 {catalog}) · 제외 {skipped}") + return out + + +def _text(card, sel: str) -> str: + node = card.css_first(sel) + return re.sub(r"\s+", " ", node.text()).strip() if node else "" + + +def _prices(card) -> tuple[int | None, str, int | None, str | None]: + """(상품가, 가격원문, 배송비, 배송유형)을 한 번에 뽑는다. + + 순서가 중요해서 한 함수에 가뒀다: 가격 노드에서 '가격이 아닌 금액'(단위가격·배송비·쿠폰가· + 정상가)을 decompose 로 떼어내는데, decompose 는 트리에서 노드를 아예 지우므로 + **배송비를 먼저 읽어야** 한다. 밖에서 호출 순서를 지키게 하면 언젠가 반드시 깨진다. + """ + fee, ship_type = _shipping(card) + + node = card.css_first(S.price) + if node is None: + return None, "", fee, ship_type + for sel in (S.unit_price, S.delivery_fee, S.coupon_price, S.org_price): + for junk in node.css(sel): + junk.decompose() + raw = re.sub(r"\s+", " ", node.text()).strip() + return _to_won(raw), raw, fee, ship_type + + +def _to_won(text: str) -> int | None: + """'할인율38% 36,840원' → 36840. '원' 앞 숫자에 앵커링하고 **첫** 매치를 쓴다. + + 첫 매치인 이유: 노드에 금액이 여러 개 남았다면 뒤쪽은 배송비·적립처럼 상품가가 아닌 것들이다 + (할인율 '38%' 는 '원'이 안 붙어 애초에 매치되지 않는다). + """ + matches = _NUM.findall(text) + if not matches: + return None + try: + price = int(matches[0].replace(",", "")) + except ValueError: + return None + return price if price > 0 else None + + +def _shipping(card) -> tuple[int | None, str | None]: + """'배송비무료' → (0, 'free') · '배송비3,000원' → (3000, 'paid') · 없으면 (None, None). + + 옛 오픈API 는 배송비 필드 자체가 없어 전 소스 None 이었다(lprice=배송비 제외 상품가). + 크롤은 화면 그대로라 배송비를 얻는다 — 최저가 비교의 정확도가 올라가는 지점. + """ + text = _text(card, S.delivery_fee) + if not text: + return None, None + if "무료" in text: + return 0, "free" + won = _to_won(text) + return (won, "paid") if won else (None, None) + + +def _href(card) -> str | None: + node = card.css_first(S.link) + href = node.attributes.get("href") if node else None + return href or None diff --git a/lps/services/search/naver_shop/selectors.py b/lps/services/search/naver_shop/selectors.py new file mode 100644 index 0000000..4850b3a --- /dev/null +++ b/lps/services/search/naver_shop/selectors.py @@ -0,0 +1,37 @@ +"""네이버 모바일 쇼핑(msearch) 카드 셀렉터 — 2026-08-04 실측 기준. + +클래스는 webpack 해시(`product_price__O3ZGH`)라 **prefix 매칭**으로만 잡는다(쿠팡과 동일 전략). +해시가 바뀌어도 prefix 는 유지되므로 릴리스마다 깨지지 않는다. + +카드 계열(같은 페이지에 5종이 섞여 있다): + product_item_inner 유기 검색결과 ← 우리가 쓰는 것 + adProduct_item_inner 광고(‘광고’ 배지) ← 제외 + superSavingProduct_item_ 슈퍼적립 프로모션 ← 제외(멤버십 조건부 가격) + productList_product_item 브랜드/기획 블록 ← 제외(브랜드가 고른 상품) + product_items 연관상품 추천 ← 가격 없음 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Selectors: + # 카드 — 유기 결과만. startswith 로 adProduct_item_inner 와 구분한다(둘 다 'product_item_inner' 를 포함). + card: str = "[class*=product_item_inner]" + card_class_prefix: str = "product_item_inner" + + title: str = "[class*=product_info_tit]" + # 즉시 판매가. 내부에 단위가격(product_unit_price)이 끼어 있어 그대로 파싱하면 안 된다. + price: str = "[class*=product_immediate_price]" + unit_price: str = "[class*=product_unit_price]" # "(100ml당 548원)" — 가격 아님, 제거 대상 + org_price: str = "[class*=product_org_price]" # "정상가 60,000원" — 가격 아님 + coupon_price: str = "[class*=product_discount_price]" # 쿠폰할인가(조건부) — price 로 쓰지 않는다 + delivery_fee: str = "[class*=product_delivery_fee]" # "배송비무료" | "배송비3,000원" + mall: str = "[class*=product_mall]" + link: str = "[class*=product_btn_link]" + + # 광고 배지. adProduct 계열에만 존재하지만, 유기 카드에 광고가 섞이는 경우까지 방어한다. + ad_marker: str = "[class*=_link_ad]" + + +SELECTORS = Selectors() diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 3d67bc9..44e54a9 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -19,9 +19,10 @@ from config.server_configs import decodo_config class DecodoProxy: - def __init__(self, cfg=None): + def __init__(self, cfg=None, host: str | None = None): cfg = cfg if cfg is not None else decodo_config - self.host = cfg.host + # host 를 넘기면 그 게이트웨이를 쓴다 — 국가 타깃(kr.decodo.com)용. 포트/자격증명은 동일. + self.host = host or cfg.host self.username = cfg.username self.password = cfg.password self.port_start = cfg.port_start diff --git a/lps/tests/fixtures/naver_msearch.html b/lps/tests/fixtures/naver_msearch.html new file mode 100644 index 0000000..77c6c2a --- /dev/null +++ b/lps/tests/fixtures/naver_msearch.html @@ -0,0 +1,22 @@ +네이버 모바일 쇼핑 검색 — 파서 테스트용 축약 픽스처 + +
다우니 실내건조 섬유유연제 엑스퍼트 초고농축 4L 코스트코 대용량 플러스 향좋은 향기좋은
21,900원
(100ml당 548원)
21,680원쿠폰할인가
배송비3,000원
내일배송
8.5.(수) 도착
평점4.881.7만구매6,252찜933
네이버플러스멤버십
상품 바로가기
사진찜하기
포인트
네이버페이 포인트 최대 219원
배송
오늘 17:00까지 주문 시
+
[오늘출발] 랩신 런드리세니타이저 섬유향균제 섬유항균제 본품 시트러스, 2L, 1개
14,500원
(100ml당 725원)
배송비무료
평점4.7473찜146
네이버톡톡
네이버플러스멤버십
상품 바로가기
찜하기
포인트
네이버페이 포인트 최대 145원
배송방법
택배
롯데택배
+
브랜드스토어
1+1 라비킷 코트너 건조기 섬유유연제 향기시트 40매 비누향 화이트 린넨향
정상가66,000원
할인율45%36,000원
(1매당 450원)
35,500원쿠폰할인가
배송비무료
평점4.861,891구매3,705찜781
공식
네이버플러스멤버십
상품 바로가기
사진찜하기
포인트
네이버페이 포인트 최대 360원
배송방법
택배
CJ대한통운
+
[도착보장] 코스트코 커클랜드 건조기시트 드라이시트 대용량 종이 시트형 섬유유연제 프레쉬, 250매, 1개
11,690원
(10매당 468원)
11,570원쿠폰할인가
배송비3,000원
내일배송
8.5.(수) 도착
평점4.742,472구매3,927찜495
네이버플러스멤버십
상품 바로가기
찜하기
포인트
네이버페이 포인트 최대 116원
배송
오늘 17:00까지 주문 시
+
랩신 섬유항균제 상쾌한솔잎향 본품
44,510원
배송비무료
리뷰192찜1
상품 바로가기
찜하기
+
[도착보장] 샤프란 아우라 건조기시트 드라이시트 대용량 종이 시트형 섬유유연제 프레쉬플로럴, 200매, 1개
20,200원
(1개입당 101원)
19,990원쿠폰할인가
배송비3,000원
내일배송
8.5.(수) 도착
평점4.862,287구매2,504찜298
네이버플러스멤버십
상품 바로가기
사진찜하기
포인트
네이버페이 포인트 최대 202원
배송
오늘 17:00까지 주문 시
+
P&G 커클랜드 울트라 소프트 드라이시트 프레쉬, 250매, 1개
최저7,790원
(1개입당 31원)
배송비3,900원
배송비포함 혜택가 10,490원
평점4.725,145찜41
판매처239
상품 바로가기
찜하기
+
브랜드 카탈로그
스너글 초고농축 블루스파클 플러스, 1.8L, 2개
최저16,860원
배송비3,000원
배송비포함 혜택가 19,850원
평점4.871.2만찜78
판매처172
상품 바로가기
사진찜하기
+
P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개
최저18,640원
(100ml당 370원)
배송비5,000원
배송비포함 혜택가 22,385원
평점4.831.1만찜164
판매처367
상품 바로가기
찜하기
+
P&G 다우니 라이트 향기부스터 오션미스트, 1.13kg, 1개
최저27,510원
배송비3,000원
배송비포함 혜택가 27,800원
평점4.861,165찜48
판매처86
상품 바로가기
찜하기
+
브랜드 카탈로그
P&G 다우니 퍼퓸 섬유유연제 미스티크, 1L, 6개
최저31,050원
(100ml당 517원)
배송비무료
평점4.842.8만찜33
판매처737
상품 바로가기
찜하기
+
브랜드 카탈로그
P&G 다우니 아로마플로럴 고농축 섬유유연제, 8.5L, 1개
최저25,900원
(100ml당 304원)
배송비7,000원
배송비포함 혜택가 30,890원
평점4.811.2만찜86
판매처188
상품 바로가기
사진찜하기
+
슈퍼적립
멤버십 10% 적립
다우니 호텔 컬렉션 미스티크 1L 초고농축 섬유유연제 본품 6개 + (증정) 섬유유연제 200ml 샘플 1개
정상가60,000원
할인율38%36,840원
(100ml당 614원)
배송비무료
멤버십 시작 시 최대 5,525원 적립
내일배송
8.5.(수) 도착
평점4.871.4만구매 1.5만찜 2,554
광고
공식
네이버플러스멤버십
사진
버튼을 눌러서 상품사진·동영상을 확인하세요!
찜하기
상품 바로가기
포인트
네이버페이 포인트 최대 1,368원
배송
내일 11:00까지 주문 시
+
지오클린 아델라 대용량 섬유유연제 로즈 2.5L 4개
정상가18,900원
할인율21%14,900원
(1L당 1,490원)
14,800원쿠폰할인가
배송비무료
평점4.9298찜 79
광고
네이버플러스멤버십
사진찜하기
상품 바로가기
포인트
네이버페이 포인트 최대 149원
배송방법
택배
CJ대한통운
+
구매 1천+
브랜드스토어
다우니 실내건조 프레시 클린 1.35L 초고농축 섬유유연제 리필 3개
정상가34,200원
할인율25%25,500원
(100ml당 630원)
배송비무료
내일배송
8.5.(수) 도착
평점4.892,113찜 258
광고
공식
네이버플러스멤버십
사진찜하기
상품 바로가기
포인트
네이버페이 포인트 최대 255원
배송
내일 11:00까지 주문 시
+
슈퍼적립
멤버십 10% 적립
아우라 윌유메리미 대용량 초고농축 실내건조 섬유유연제 5.5L
정상가29,900원
할인율40%17,900원
(100ml당 325원)
16,900원쿠폰할인가
배송비무료
멤버십 시작 시 최대 2,685원 적립
내일배송
8.5.(수) 도착
평점4.851.5만구매7,053찜1,465
광고
공식
네이버톡톡
네이버플러스멤버십
상품 바로가기
사진찜하기
포인트
네이버페이 포인트 최대 179원
배송
내일 11:00까지 주문 시
+
  • 슈퍼적립
    다우니 호텔 컬렉션 미스티크 1L 초고농축 섬유유연제 본품 6개 + (증정) 섬유유연제 200ml 샘플 1개
    다우니 호텔 컬렉션 미스티크 1L 초고농축 섬유유연제 본품 6개 + (증정) 섬유유연제 200ml 샘플 1개
    36,840원
    배송비무료
    내일배송
    다우니
    다우니 호텔 컬렉션 미스티크 1L 초고농축 섬유유연제 본품 6개 + (증정) 섬유유연제 200ml 샘플 1개 자세히 보기찜하기
  • +
  • 다우니 대용량 아로마 플로럴 8.5L 고농축 섬유유연제 본품 1개
    다우니 대용량 아로마 플로럴 8.5L 고농축 섬유유연제 본품 1개
    31,790원
    배송비무료
    내일배송
    다우니
    다우니 대용량 아로마 플로럴 8.5L 고농축 섬유유연제 본품 1개 자세히 보기찜하기
  • + diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index 8a6beaa..cb4ae13 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -126,10 +126,11 @@ def _live_adapters(): from services.search.coupang.adapter import CoupangAdapter from services.search.esm.adapter import EsmAdapter from services.search.st11.adapter import ElevenStAdapter - from services.search.naver.adapter import NaverAdapter + from services.search.naver_shop.adapter import NaverShopAdapter p = DecodoProxy() return [ - ("naver", NaverAdapter()), + # 네이버는 오픈API 종료(2026-07-31)로 크롤 전환. headless 는 WTM 에 걸리므로 headful. + ("naver", NaverShopAdapter(headless=False)), ("coupang", CoupangAdapter(headless=True, proxy=p)), ("gmarket", EsmAdapter("gmarket", headless=True, proxy=p)), ("auction", EsmAdapter("auction", headless=True, proxy=p)), diff --git a/lps/tests/test_naver_shop_parser.py b/lps/tests/test_naver_shop_parser.py new file mode 100644 index 0000000..bbbe144 --- /dev/null +++ b/lps/tests/test_naver_shop_parser.py @@ -0,0 +1,145 @@ +"""네이버 모바일 쇼핑 파서 테스트 (저장 HTML — 네트워크 불필요). + +fixtures/naver_msearch.html 은 2026-08-04 실측 응답에서 카드만 추린 축약본이다 +(판매처 6 · 가격비교 6 · 광고 3 · 슈퍼적립 1 · 브랜드블록 2). + +가격 함정 회귀 테스트를 합성 HTML 로 따로 둔다 — 픽스처는 네이버가 마크업을 바꾸면 +같이 늙지만, 함정 자체(단위가격·배송비·정상가)는 계속 지켜야 하는 계약이라서다. +""" + +from pathlib import Path + +from services.search.naver_shop.adapter import detect_block +from services.search.naver_shop.parser import parse_search_html + +_FIXTURE = Path(__file__).parent / "fixtures" / "naver_msearch.html" + + +def _fixture() -> str: + return _FIXTURE.read_text() + + +def _card(inner: str, cls: str = "product_item_inner__9nwv0") -> str: + return f'
    {inner}
    ' + + +# ── 픽스처 기반 ────────────────────────────────────────────────────────── +def test_parses_organic_cards_and_drops_ads(): + ps = parse_search_html(_fixture()) + assert len(ps) == 12 # 광고 3 + 슈퍼적립 1 은 제외됨 + assert all(p.source == "naver" for p in ps) + assert all(p.price > 0 and p.name for p in ps) + + +def test_seller_and_catalog_cards_are_both_kept(): + ps = parse_search_html(_fixture()) + catalog = [p for p in ps if p.mall_name == "네이버"] # "최저 N원" = 가격비교 + seller = [p for p in ps if p.mall_name and p.mall_name != "네이버"] + assert len(catalog) == 6 and len(seller) == 6 + # 판매처 카드는 실제 몰명이 들어온다(빈 문자열/None 아님) + assert all(len(p.mall_name) > 1 for p in seller) + + +def test_shipping_is_captured(): + """옛 오픈API 는 배송비 필드가 없어 전 소스 None 이었다 — 크롤의 이점이라 회귀로 막는다.""" + ps = parse_search_html(_fixture()) + assert all(p.shipping_fee is not None for p in ps) + assert {p.shipping_type for p in ps} <= {"free", "paid"} + assert any(p.shipping_type == "free" and p.shipping_fee == 0 for p in ps) + assert any(p.shipping_type == "paid" and p.shipping_fee > 0 for p in ps) + + +def test_price_is_never_the_shipping_fee(): + """실측에서 실제로 밟은 버그: 가격 노드가 배송비까지 감싸 3,900원이 상품가로 잡혔다.""" + ps = parse_search_html(_fixture()) + assert not [p for p in ps if p.shipping_fee and p.price == p.shipping_fee] + + +def test_detail_url_present(): + ps = parse_search_html(_fixture()) + assert all(p.detail_url and p.detail_url.startswith("http") for p in ps) + + +# ── 가격 함정 회귀 (합성) ──────────────────────────────────────────────── +def test_unit_price_is_not_mistaken_for_price(): + """'21,900원(100ml당 548원)' → 21900. 단위가격을 먼저 떼지 않으면 548 을 집는다.""" + html = _card(""" +
    테스트 섬유유연제 4L
    + + 21,900원 + (100ml당 548원) + + + 테스트몰 + 상품 바로가기""") + (p,) = parse_search_html(html) + assert p.price == 21900 + + +def test_delivery_fee_inside_price_node_is_excluded(): + """가격비교 카드 구조: 가격 노드가 배송비를 품는다. 상품가 7,790 / 배송비 3,900 로 갈려야 한다.""" + html = _card(""" +
    커클랜드 드라이시트, 250매, 1개
    + + 최저7,790원 + (1개입당 31원) + 배송비3,900원 + + + 상품 바로가기""") + (p,) = parse_search_html(html) + assert p.price == 7790 + assert p.shipping_fee == 3900 and p.shipping_type == "paid" + assert p.mall_name == "네이버" # 판매처 표기가 없는 가격비교 카드 + + +def test_list_price_and_discount_rate_are_ignored(): + """'정상가60,000원 할인율38% 36,840원' → 36840.""" + html = _card(""" +
    다우니 미스티크 1L 6개
    + + 정상가60,000원 + + 38%36,840원 + + + 공식몰 + 상품 바로가기""") + (p,) = parse_search_html(html) + assert p.price == 36840 + + +def test_coupon_price_is_not_used_as_price(): + """쿠폰할인가는 조건부(1인 1회·선착순)라 실구매가 하한으로 쓰면 최저가가 왜곡된다.""" + html = _card(""" +
    테스트 상품
    + + 21,900원 + 21,680원 + 쿠폰할인가 + + 테스트몰 + 상품 바로가기""") + (p,) = parse_search_html(html) + assert p.price == 21900 + + +def test_ad_card_family_is_dropped(): + html = _card(""" +
    광고 상품
    + 9,900원 + 광고""", cls="adProduct_item_inner__abc") + assert parse_search_html(html) == [] + + +# ── 차단 판정 ──────────────────────────────────────────────────────────── +def test_detect_block_flags_captcha_and_short_html(): + assert detect_block("wtm_captcha", 0) == "wtm_captcha" + assert detect_block("비정상적인 접근입니다", 0) == "비정상적인 접근" + assert detect_block("" + "x" * 5000 + "", 0).startswith("short_html") + # 결과가 있으면 차단이 아니다 — 짧아도 통과 + assert detect_block("x", 3) is None + + +def test_detect_block_passes_real_page(): + assert detect_block(_fixture(), 12) is None diff --git a/lps/worker_main.py b/lps/worker_main.py index aa25f34..6338480 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -21,7 +21,7 @@ from crud.ip_session import IpSessionLog from crud.price_history import PriceHistory from services.search.proxy import DecodoProxy from services.search.coupang.adapter import CoupangAdapter -from services.search.naver.adapter import NaverAdapter +from services.search.naver_shop.adapter import NaverShopAdapter from services.search.esm.adapter import EsmAdapter from services.search.st11.adapter import ElevenStAdapter from services.ai.similarity import SimilarityJudge @@ -53,9 +53,14 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history 프로필 분리(user_data_dir_w{i}) + 워커별 다른 프록시 포트(=다른 IP)로 진짜 병렬을 보장한다.""" # 워커별 프록시(다른 포트=다른 IP). 100포트를 워커 수로 균등 분할해 시작점을 벌린다. proxy = DecodoProxy() + # 네이버 전용 프록시 — 같은 계정·같은 포트지만 **한국 타깃 게이트웨이**를 쓴다. + # 해외 residential IP 로는 msearch 가 즉시 하드차단된다(2026-08-04 실측). kr_host 가 비면 + # 기본 게이트웨이로 떨어지고, 그때는 네이버가 막힐 수 있다(로그의 BOT-DETECTED 로 드러남). + kr_proxy = DecodoProxy(host=decodo_config.kr_host or None) if proxy.enabled and concurrency > 1: n = proxy.port_end - proxy.port_start + 1 proxy.seed_offset(i * max(1, n // concurrency)) + kr_proxy.seed_offset(i * max(1, n // concurrency)) bot_log = BotDetectionLog() ip_log = IpSessionLog() suffix = f"_w{i}" if concurrency > 1 else "" @@ -67,7 +72,11 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history adapters = { "coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, on_detect=bot_log.record, on_session_end=ip_log.record), - "naver": NaverAdapter(), # httpx 직접(프록시 미경유) — 워커별 인스턴스(last_bytes 경합 회피) + # 네이버는 오픈API(shop.json)가 2026-07-31 종료돼 404 SE05 만 돌아온다 → 모바일 크롤로 전환. + # 쿠팡과 같은 프록시를 물린다: 단일 IP 로 연달아 두드리면 WTM 캡차로 넘어간다(실측 — + # 스파이크에서 십수 회 만에 홈 IP 가 탔다). 회전·예산·차단기록은 베이스가 처리한다. + "naver": NaverShopAdapter(headless=False, user_data_dir=_pf("naver"), proxy=kr_proxy, + on_detect=bot_log.record, on_session_end=ip_log.record), } # 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라 # 봇감지 재시도(챌린지 대기 2배)를 끈다(max_block_retries=0) — 빠르게 포기·스킵.