o2o-negosium-original/lps/services/search/card_parser.py
민헌 f9714c3f06 feat(lps): 오픈마켓 폴백 오케스트레이션 + 몰 dedup
사용자 규칙 '네이버로 그 몰 값 확보 성공→그 값, 실패(몰 없음)→실사이트 크롤' 구현.

- handler: found 라운드 뒤 _enrich_with_fallback — 네이버 매칭이 커버 못 한 타깃 몰만
  (canonical_mall 로 판정) 크롤 어댑터로 검색→같은상품 판정→병합. 크롤 실패는 격리(무시).
  fallback_crawl STAGE 기록.
- summarize_by_mall: 키를 (source,mall)→몰명(canonical)으로 — 네이버노출 vs 직접크롤 같은 몰
  중복을 최저가 1건으로 병합(dedup).
- card_parser: MALL_BY_SOURCE + canonical_mall() 추가.
- worker_main: gmarket/auction/st11 폴백 어댑터 등록(lazy 기동, 리소스차단 OFF).
- 테스트: 커버몰 크롤생략·미커버 크롤·실패격리·몰 dedup 4종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:39 +09:00

75 lines
2.6 KiB
Python

"""오픈마켓 검색결과 공용 카드 파서(순수 함수, 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