'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 — 조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다. - 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.
104 lines
4.9 KiB
Python
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' 처럼 콤마가 섞여도 파싱된다
|