사용자 규칙 '네이버로 그 몰 값 확보 성공→그 값, 실패(몰 없음)→실사이트 크롤' 구현. - 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>
75 lines
2.6 KiB
Python
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
|