"""쿠팡 검색결과 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*원") # 카드 내 명시 배송비(예: '배송비 3,000원'). _SHIP_FEE = re.compile(r"배송비\s*([\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 _shipping(card, name: str | None) -> tuple[int | None, str | None]: """카드에서 (배송비, 배송유형) 추출. 유형은 로켓 뱃지(img src)로, 금액은 '무료배송'/'배송비 X원' 텍스트로 판별한다. 로켓 계열은 조건부 무료(와우/최소금액)라 명시 텍스트 없으면 배송비 None 유지. 상품명에 '무료배송' 이 들어간 오탐을 막기 위해 이름 텍스트는 제거 후 매칭.""" badge_type = None for img in card.css(S.rocket_badge): src = img.attributes.get("src") or "" badge_type = "rocket_merchant" if S.rocket_merchant_marker in src else "rocket" if badge_type == "rocket": break # 로켓배송 뱃지가 가장 강한 신호 text = card.text(separator=" ") or "" if name: text = text.replace(name, " ") if "무료배송" in text: return 0, badge_type or "free" m = _SHIP_FEE.search(text) if m: return int(m.group(1).replace(",", "")), badge_type or "paid" return None, badge_type 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 shipping_fee, shipping_type = _shipping(card, name) 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), shipping_fee=shipping_fee, shipping_type=shipping_type, ) ) return products