"""오픈마켓 검색결과 공용 카드 파서(순수 함수, selectolax). G마켓·옥션·11번가처럼 '카드 목록 → 카드별 이름/가격/링크/배송' 구조가 같은 소스를 하나로 파싱한다. 사이트별 셀렉터·소스명·베이스URL 만 주입한다. 브라우저/네트워크 무관 — 저장 HTML 로 단위 테스트 가능. """ from dataclasses import dataclass from urllib.parse import urljoin from selectolax.parser import HTMLParser from services.search.contract import NormalizedProduct from services.search.util import extract_price, clean_name, shipping_from_text # 소스 코드 → 표시 몰명(canonical). 폴백 커버리지 판정·dedup 에 쓴다. MALL_BY_SOURCE = {"naver": "네이버", "coupang": "쿠팡", "gmarket": "G마켓", "auction": "옥션", "st11": "11번가"} def canonical_mall(product) -> str: """상품의 판매몰 정체성(몰명 우선, 없으면 소스 매핑).""" return product.mall_name or MALL_BY_SOURCE.get(product.source, product.source) @dataclass(frozen=True) class CardConfig: source: str # naver 외 크롤 소스 코드 (gmarket|auction|st11) mall_name: str # 표시 몰명 (G마켓|옥션|11번가) base_url: str # 상대경로 링크 절대화용 card: str name: str price: str link: str = "a[href]" def parse_cards(html: str, cfg: CardConfig) -> list[NormalizedProduct]: tree = HTMLParser(html) products: list[NormalizedProduct] = [] seen: set[str] = set() for card in tree.css(cfg.card): name_el = card.css_first(cfg.name) name = clean_name(name_el.text(strip=True)) if name_el else None if not name: continue price_el = card.css_first(cfg.price) price = extract_price(price_el.text(strip=True)) if price_el else None if price is None or price <= 0: continue # 광고/헤더/추천 슬롯 등 유효 상품 아님 a = card.css_first(cfg.link) href = a.attributes.get("href") if a else None detail_url = urljoin(cfg.base_url, href) if href else None key = detail_url or name # 동일 링크/광고 반복 제거 if key in seen: continue seen.add(key) shipping_fee, shipping_type = shipping_from_text(card.text(separator=" "), name) products.append( NormalizedProduct( source=cfg.source, name=name, price=price, detail_url=detail_url, mall_name=cfg.mall_name, shipping_fee=shipping_fee, shipping_type=shipping_type, ) ) return products