From 313d882df4eccda81683009d9eb9f59874bdf779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 13:59:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EB=AA=B0=EB=B3=84=20=EC=B5=9C=EC=A0=80=EA=B0=80=20=EB=B6=84?= =?UTF-8?q?=ED=95=B4(by=5Fmall)=20=E2=80=94=20=EC=98=A4=ED=94=88=EB=A7=88?= =?UTF-8?q?=EC=BC=93=20=EA=B0=80=EC=8B=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네이버 오픈API 결과엔 G마켓·옥션·11번가 등이 이미 mallName 으로 들어오는데 최저가 1건만 쓰고 몰 정보를 버리고 있었다. summarize_by_mall() 로 매칭 후보를 (소스, 판매몰)별 최저가로 축약해 result.by_mall(가격 오름차순)에 노출한다. 추가 요청 0건으로 오픈마켓 몰별 최저가를 확보 — 별도 크롤러 불필요. Co-Authored-By: Claude Fable 5 --- lps/services/pipeline/core.py | 20 ++++++++++++++++++++ lps/tests/test_pipeline.py | 26 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lps/services/pipeline/core.py b/lps/services/pipeline/core.py index f85e30f..3bd2f43 100644 --- a/lps/services/pipeline/core.py +++ b/lps/services/pipeline/core.py @@ -51,6 +51,25 @@ def apply_filters( return cur, stages +def summarize_by_mall(products: list[NormalizedProduct]) -> list[dict]: + """같은상품 매칭 후보를 (소스, 판매몰)별 최저가로 분해한다. + 네이버 결과엔 G마켓·옥션·11번가 등이 mall_name 으로 이미 들어오므로, + 추가 요청 없이 '오픈마켓 몰별 최저가'를 노출할 수 있다(가격 오름차순). + 네이버 가격비교(mall_name='네이버')는 여러 판매자 중 최저가 롤업이다.""" + best: dict[tuple[str, str], NormalizedProduct] = {} + for p in products: + key = (p.source, (p.mall_name or "기타")) + cur = best.get(key) + if cur is None or p.price < cur.price: + best[key] = p + rows = sorted(best.values(), key=lambda p: p.price) + return [{ + "source": p.source, "mall_name": p.mall_name, "price": p.price, + "shipping_fee": p.shipping_fee, "shipping_type": p.shipping_type, + "name": p.name, "detail_url": p.detail_url, + } for p in rows] + + def rank_result(products: list[NormalizedProduct], total_found: int, stages: list[dict], top_n: int = 5) -> dict: """최저가순 정렬 + top-N + 결과 봉투 조립(top_n STAGE 포함).""" ranked = sorted(products, key=lambda p: p.price) @@ -61,6 +80,7 @@ def rank_result(products: list[NormalizedProduct], total_found: int, stages: lis "kept": len(ranked), "lowest": top[0].model_dump() if top else None, "top": [p.model_dump() for p in top], + "by_mall": summarize_by_mall(ranked), "stages": stages, } diff --git a/lps/tests/test_pipeline.py b/lps/tests/test_pipeline.py index 6281b2a..4db98e3 100644 --- a/lps/tests/test_pipeline.py +++ b/lps/tests/test_pipeline.py @@ -6,7 +6,7 @@ from services.search.contract import NormalizedProduct from services.search.coupang.parser import parse_search_html from services.pipeline.filters import filter_by_price_band, filter_out_malls from services.pipeline.outliers import remove_price_outliers -from services.pipeline.core import run_price_pipeline +from services.pipeline.core import run_price_pipeline, summarize_by_mall FIXTURE = Path(__file__).parent / "fixtures" / "coupang_search.html" @@ -56,6 +56,30 @@ def test_stages_recorded(): assert r["stages"][-1]["out"] == 2 # top_n 결과 수 +def _pm(source, mall, price): + return NormalizedProduct(source=source, name=f"{mall}상품", price=price, mall_name=mall) + + +def test_summarize_by_mall_lowest_per_mall_sorted(): + # 같은 몰 여러 건 → 몰별 최저가만, 전체 가격 오름차순 + prods = [ + _pm("naver", "G마켓", 12000), _pm("naver", "G마켓", 11000), + _pm("naver", "11번가", 10500), _pm("naver", "네이버", 9800), + _pm("coupang", "쿠팡", 10200), + ] + rows = summarize_by_mall(prods) + # 몰별 최저가 1건씩, 전체 가격 오름차순 + assert [(r["mall_name"], r["price"]) for r in rows] == [ + ("네이버", 9800), ("쿠팡", 10200), ("11번가", 10500), ("G마켓", 11000), + ] + + +def test_summarize_by_mall_in_result(): + r = run_price_pipeline([_pm("naver", "11번가", 5000), _pm("naver", "G마켓", 6000)], remove_outliers=False) + malls = {row["mall_name"] for row in r["by_mall"]} + assert malls == {"11번가", "G마켓"} + + def test_pipeline_on_real_fixture(): products = parse_search_html(FIXTURE.read_text()) r = run_price_pipeline(products, remove_outliers=False, top_n=3)