o2o-negosium-original/lps/tests/test_coupang_parser.py
민헌 6c7ff5af51 feat(lps): 최저가 오퍼의 신뢰 신호(평점·리뷰) 수집 — 네이버·쿠팡 공통
'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는
재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 —
조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다.

- NormalizedProduct.rating / review_count 추가. 두 소스 모두 카드에 노출하는 값만 담아
  교차 비교가 되게 했다. **없으면 None 유지** — '리뷰 0개'와 '리뷰 정보 없음'은 다른 뜻이다
- 네이버: product_grade 의 <strong>평점</strong><em>리뷰수</em>. 텍스트를 통째로 정규식
  돌리면 '평점4.7473' 이 4.74/73 인지 4.7/473 인지 못 가르므로 노드로 분리해 읽는다.
  '1.7만' 같은 축약은 parse_ko_count 로 푼다(그대로 int() 하면 1 이 된다)
- 쿠팡: 별점은 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다
- price_history.final_rating/final_review_count 추가(+마이그레이션) → "리뷰 0인 최저가가
  몇 %인가"를 SQL 로 물을 수 있다. NULL 과 0 을 구분해야 해서 기본값을 두지 않았다

정렬 점검(사용자 제기): 두 소스 다 정렬 파라미터 없이 **랭킹/추천순**이다(픽스처 가격이
오름차순이 아님으로 확인). 가격순(sort=price_asc)은 차단 없이 동작하고 실측상 더 싼 후보를
찾지만(15,400→10,900), 리뷰·평점 없는 유령상품을 위로 끌어올려 미채택 — 추천순 유지.
신뢰 신호가 쌓이면 "리뷰 N 이상" 가드를 걸고 가격순을 켜는 선택지가 열린다.

e2e: TR-1/TR-2 최저가에 평점 4.89·리뷰 7,314/102,000 이 함께 기록됨. 테스트 5건 추가, 228 passed.
2026-08-05 14:00:12 +09:00

104 lines
4.9 KiB
Python

# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요).
# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함).
from pathlib import Path
from services.search.coupang.parser import parse_search_html
from services.search.coupang.adapter import detect_block
FIXTURE = Path(__file__).parent / "fixtures" / "coupang_search.html"
def test_parse_extracts_valid_products():
items = parse_search_html(FIXTURE.read_text())
assert len(items) >= 3
for p in items:
assert p.source == "coupang"
assert p.name and len(p.name) > 2
# 단위가격('1개당 44,400원')의 '1' 을 가격으로 오인하던 회귀 방지
assert p.price >= 100, f"이상 저가: {p.price} ({p.name})"
assert p.detail_url and p.detail_url.startswith("https://www.coupang.com/vp/products/")
assert p.external_id # vendorItemId(data-id)
def test_parse_price_is_sale_not_unit_price():
# fixture 첫 카드의 판매가는 44,400원(단위가격도 '1개당 44,400원'이라 값은 같지만
# 파서가 정가(del)나 '%'가 아닌 판매가를 정확히 집는지 확인)
items = parse_search_html(FIXTURE.read_text())
assert items[0].price == 44400
def test_parse_shipping_from_fixture():
# fixture 4카드: [0,1]=로켓뱃지+무료배송, [2]=무료배송 텍스트만, [3]=판매자로켓 뱃지만
items = parse_search_html(FIXTURE.read_text())
assert (items[0].shipping_fee, items[0].shipping_type) == (0, "rocket")
assert (items[1].shipping_fee, items[1].shipping_type) == (0, "rocket")
assert (items[2].shipping_fee, items[2].shipping_type) == (0, "free")
# 판매자로켓은 조건부 무료 → 명시 텍스트 없으면 배송비 미확인(None)
assert (items[3].shipping_fee, items[3].shipping_type) == (None, "rocket_merchant")
def _card(name: str, price: str, extra: str = "") -> str:
return f"""<li class="ProductUnit_productUnit__x1" data-id="1">
<a href="/vp/products/1"><figure><img alt="{name}" src="i.jpg"/></figure>
<div class="ProductUnit_productNameV2__x1">{name}</div>
<div class="PriceArea_priceArea__x1"><strong>{price}</strong></div>{extra}</a></li>"""
def test_parse_shipping_paid_fee():
# 명시 배송비('배송비 3,000원') → paid + 금액
html = f"<ul>{_card('AAA 건전지 20개입', '5,880원', '<span>배송비 3,000원</span>')}</ul>"
p = parse_search_html(html)[0]
assert (p.shipping_fee, p.shipping_type) == (3000, "paid")
def test_parse_shipping_name_free_text_no_false_positive():
# 상품명에 '무료배송' 이 들어가도 배송 무료로 오인하지 않는다
html = f"<ul>{_card('무료배송 텀블러 굿즈', '12,000원')}</ul>"
p = parse_search_html(html)[0]
assert (p.shipping_fee, p.shipping_type) == (None, None)
# --- 차단 감지(detect_block): 여러 flavor 를 모두 blocked 로 잡아야 IP 회전이 걸린다 ---
def test_detect_block_none_when_products_exist():
# 상품이 파싱됐으면 HTML 내용과 무관하게 차단 아님
assert detect_block("Access Denied", product_count=3) is None
def test_detect_block_akamai_challenge():
assert detect_block("<div id='sec-if-cpt-container'>" + "x" * 20000, 0) == "sec-if-cpt-container"
def test_detect_block_edge_access_denied():
# 실제로 잡힌 303B edge deny
html = ('<html><head><title>Access Denied</title></head><body><h1>Access Denied</h1>'
"You don't have permission to access ... errors.edgesuite.net</body></html>")
assert detect_block(html, 0) in ("errors.edgesuite.net", "You don't have permission to access")
def test_detect_block_permission_restricted_page():
html = "<html><body>쿠팡을 찾아주신 고객님, 입력하신 페이지주소는 사용권한이 제한된 페이지입니다.</body></html>"
assert detect_block(html, 0) in ("사용권한이 제한된", "쿠팡을 찾아주신 고객님")
def test_detect_block_short_html_fallback():
# 마커 없어도 비정상적으로 짧은 0건 응답은 미지의 차단으로 폴백
marker = detect_block("<html><body>oops</body></html>", 0)
assert marker is not None and marker.startswith("short_html(")
def test_detect_block_genuine_empty_large_page_not_blocked():
# 정상 '검색결과 없음'(전체 chrome 포함, 큰 HTML)은 차단 아님 → not_found 로 흘러야 함
big = "<html><body>검색결과가 없습니다" + "x" * 20000 + "</body></html>"
assert detect_block(big, 0) is None
def test_trust_signals_are_captured():
"""쿠팡은 별점이 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다."""
ps = parse_search_html(FIXTURE.read_text())
rated = [p for p in ps if p.review_count]
assert rated
assert all(0 < p.rating <= 5 for p in rated)
assert max(p.review_count for p in rated) > 1000 # '41,448' 처럼 콤마가 섞여도 파싱된다