o2o-negosium-original/lps/services/search/coupang/parser.py
민헌 e27b73c018 feat(lps): 쿠팡 검색 어댑터 — Akamai 우회(Patchright) + 정규화 파서
소스 어댑터 패턴으로 크롤링을 시작한다. 쿠팡은 Akamai Bot Manager 의
JS 행동 챌린지라 curl_cffi 단독 불가 → Patchright(스텔스 Playwright) +
실제 Chrome(channel=chrome)으로 챌린지를 통과하고 selectolax 로 파싱한다.

- contract: SearchAdapter(ABC)·NormalizedProduct·AdapterHealth·AdapterError
- coupang: adapter(브라우저 재사용, 챌린지 1회)·parser(순수)·selectors(외부화, webpack 해시 prefix 매칭)
- 공용: rate_limiter(2~8s 랜덤)·proxy(무프록시 off 인터페이스)·util.parse_price
- 가격 파싱: 단위가격 '(1개당 44,400원)'의 앞 '1' 오인 방지 — '원' 앞 숫자 앵커링
- deps: curl_cffi·selectolax·patchright(nodriver 는 py3.14 버그로 대체)
- tests: 파서 회귀(소형 fixture) — 라이브 2회 검색 + 파서 3/3 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:59:48 +09:00

73 lines
2.4 KiB
Python

"""쿠팡 검색결과 HTML → NormalizedProduct[] (순수 함수, selectolax).
브라우저/네트워크와 무관. 저장된 HTML 로 결정론적 단위 테스트가 가능하다.
"""
import re
from selectolax.parser import HTMLParser
from services.search.contract import NormalizedProduct
from services.search.coupang.selectors import SELECTORS as S
BASE = "https://www.coupang.com"
# '원' 바로 앞의 숫자만 가격으로 인식('(1개당 44,400원)'의 앞 '1' 오인 방지).
_WON = re.compile(r"([\d,]+)\s*원")
def _sale_price(price_area) -> int | None:
"""판매가 추출. 정가(del)·단위가격('~당', 괄호)·할인율(%)은 제외하고
본문 판매가 노드(문서 순서상 먼저 오는 '원' 값)를 취한다."""
if price_area is None:
return None
for node in price_area.css("span, div, strong"):
if node.tag == "del":
continue
text = node.text(strip=True) or ""
if "원" not in text or "당" in text or text.startswith("("):
continue # 단위가격/부가문구 제외
m = _WON.search(text)
if m:
return int(m.group(1).replace(",", ""))
return None
def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProduct]:
tree = HTMLParser(html)
products: list[NormalizedProduct] = []
for card in tree.css(S.card):
name_el = card.css_first(S.name)
name = name_el.text(strip=True) if name_el else None
img = card.css_first(S.image)
if not name and img:
name = img.attributes.get("alt")
image_url = img.attributes.get("src") if img else None
price_area = card.css_first(S.price_area)
price = _sale_price(price_area)
# 이름/가격이 없으면 유효 상품이 아니므로 스킵(광고 슬롯 등)
if not name or price is None:
continue
a = card.css_first(S.link)
href = a.attributes.get("href") if a else None
detail_url = (BASE + href) if href and href.startswith("/") else href
products.append(
NormalizedProduct(
source=source,
name=name,
price=price,
image_url=image_url,
detail_url=detail_url,
mall_name="쿠팡",
external_id=card.attributes.get(S.data_id_attr),
)
)
return products