"""쿠팡 검색결과 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