diff --git a/config.py b/config.py index 46da5d1..fcd6ebe 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.585" +SYSTEM_VERSION = "V10.586" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 2183d30..ab7b8e7 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -48,6 +48,10 @@ BATCH_SIZE = 30 # 每批 DB 寫入筆數 RATE_DELAY = float(os.getenv("PCHOME_FEEDER_RATE_DELAY", "1.0")) # 每次 PChome 請求間隔(秒) TTL_HOURS = int(os.getenv("PCHOME_FEEDER_TTL_HOURS", "48")) # competitor_prices 價格新鮮度有效期 REQUEST_TIMEOUT = float(os.getenv("PCHOME_FEEDER_TIMEOUT", "12")) # 避免外部搜尋 API 長時間卡住排程 +BACKFILL_REQUEST_TIMEOUT = float(os.getenv("PCHOME_FEEDER_BACKFILL_TIMEOUT", str(min(REQUEST_TIMEOUT, 6.0)))) +BACKFILL_MAX_SEARCH_TERMS = int(os.getenv("PCHOME_FEEDER_BACKFILL_MAX_SEARCH_TERMS", "2")) +BACKFILL_SEARCH_MAX_PAGES = int(os.getenv("PCHOME_FEEDER_BACKFILL_SEARCH_MAX_PAGES", "1")) +BACKFILL_MAX_SECONDS_PER_SKU = float(os.getenv("PCHOME_FEEDER_BACKFILL_MAX_SECONDS_PER_SKU", "18")) SEARCH_COVERAGE_RESCUE_ENABLED = os.getenv( "PCHOME_FEEDER_SEARCH_COVERAGE_RESCUE_ENABLED", "true", @@ -885,23 +889,38 @@ def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: return best, score -def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None, momo_price: float = None) -> list: +def _search_pchome_candidates( + crawler, + momo_name: str, + keywords: list = None, + momo_price: float = None, + max_terms: int | None = None, + max_pages: int | None = None, + max_seconds: float | None = None, +) -> list: """以多組搜尋詞擴大 PChome 候選池,只在強同款時提前停止。""" candidates = [] seen_ids = set() - search_limit = SEARCH_LIMIT * max(1, SEARCH_MAX_PAGES) + page_cap = max(1, int(max_pages or SEARCH_MAX_PAGES)) + search_limit = SEARCH_LIMIT * page_cap active_keywords = keywords or _build_search_keywords(momo_name) search_plan = _build_variant_recall_search_plan(momo_name, active_keywords) + if max_terms is not None: + search_plan = search_plan[:max(1, int(max_terms))] + deadline = (time.monotonic() + max_seconds) if max_seconds and max_seconds > 0 else None for keyword, sort in search_plan: + if deadline and time.monotonic() >= deadline: + logger.info("[Feeder] PChome search budget exhausted before keyword=%s", keyword) + break if sort: ok, _, products = crawler.search_products( keyword, limit=search_limit, - max_pages=SEARCH_MAX_PAGES, + max_pages=page_cap, sort=sort, ) else: - ok, _, products = crawler.search_products(keyword, limit=search_limit, max_pages=SEARCH_MAX_PAGES) + ok, _, products = crawler.search_products(keyword, limit=search_limit, max_pages=page_cap) if not ok or not products: continue for product in products: @@ -2641,7 +2660,13 @@ class CompetitorPriceFeeder: return dict(row) if row else None - def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult: + def _run_sku_items( + self, + skus: list, + source: str = "pchome", + label: str = "PChome 競品價格", + bounded_search: bool = False, + ) -> FeederResult: start = time.time() if source != "pchome": @@ -2649,7 +2674,8 @@ class CompetitorPriceFeeder: return FeederResult(0, 0, 0, 0, 0, 0.0) from services.pchome_crawler import PChomeCrawler - crawler = PChomeCrawler(timeout=REQUEST_TIMEOUT, delay=RATE_DELAY) + crawler_timeout = BACKFILL_REQUEST_TIMEOUT if bounded_search else REQUEST_TIMEOUT + crawler = PChomeCrawler(timeout=crawler_timeout, delay=RATE_DELAY) logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 {label}") @@ -2668,7 +2694,18 @@ class CompetitorPriceFeeder: search_terms = _build_search_keywords(momo_name) try: - products = _search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price) + if bounded_search: + products = _search_pchome_candidates( + crawler, + momo_name, + search_terms, + momo_price=momo_price, + max_terms=BACKFILL_MAX_SEARCH_TERMS, + max_pages=BACKFILL_SEARCH_MAX_PAGES, + max_seconds=BACKFILL_MAX_SECONDS_PER_SKU, + ) + else: + products = _search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price) if not products: logger.debug(f"[Feeder] {sku} 無搜尋結果,跳過") browse_diagnostic = self._prepare_browse_diagnostic( @@ -3587,7 +3624,12 @@ class CompetitorPriceFeeder: logger.error(f"[Feeder] 讀取待比對優先商品失敗: {e}") return FeederResult(0, 0, 0, 0, 1, 0.0) - return self._run_sku_items(skus, source=source, label="待比對優先補抓") + return self._run_sku_items( + skus, + source=source, + label="待比對優先補抓", + bounded_search=True, + ) # ───────────────────────────────────────────── diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 321259e..ec9cff0 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -493,8 +493,10 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "MAX_SEARCH_TERMS" in feeder_source assert "_build_search_keywords" in feeder_source assert "_search_pchome_candidates" in feeder_source - assert "search_limit = SEARCH_LIMIT * max(1, SEARCH_MAX_PAGES)" in feeder_source - assert "crawler.search_products(keyword, limit=search_limit, max_pages=SEARCH_MAX_PAGES)" in feeder_source + assert "page_cap = max(1, int(max_pages or SEARCH_MAX_PAGES))" in feeder_source + assert "search_limit = SEARCH_LIMIT * page_cap" in feeder_source + assert "bounded_search=True" in feeder_source + assert "crawler.search_products(keyword, limit=search_limit, max_pages=page_cap)" in feeder_source assert "_fetch_unmatched_priority_skus" in feeder_source assert "_fetch_expired_identity_skus" in feeder_source assert "run_expired_identity_refresh" in feeder_source diff --git a/tests/test_pchome_crawler_search.py b/tests/test_pchome_crawler_search.py index 670eeb9..879e453 100644 --- a/tests/test_pchome_crawler_search.py +++ b/tests/test_pchome_crawler_search.py @@ -218,3 +218,54 @@ def test_feeder_search_candidate_passes_page_cap(monkeypatch): assert candidates == [product] assert calls[0][1]["limit"] == 40 assert calls[0][1]["max_pages"] == 2 + + +def test_feeder_search_candidate_respects_bounded_budget(monkeypatch): + from services.competitor_price_feeder import _search_pchome_candidates + from services.pchome_crawler import PChomeProduct + + product = PChomeProduct( + product_id="DDAB01-FAST", + name="理膚寶水 B5 修復霜 40ml", + price=679, + original_price=799, + discount=15, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-FAST", + stock=20, + store="24h", + rating=4.7, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + calls = [] + + class FakeCrawler: + def search_products(self, keyword, **kwargs): + calls.append((keyword, kwargs)) + return True, "ok", [product] + + monkeypatch.setattr( + "services.marketplace_product_matcher.score_marketplace_match", + lambda *_args, **_kwargs: type( + "Diagnostics", + (), + {"score": 0.80}, + )(), + ) + + candidates = _search_pchome_candidates( + FakeCrawler(), + "理膚寶水 B5 修復霜 40ml", + keywords=["理膚寶水 B5", "理膚寶水 修復霜", "b5 cream"], + momo_price=699, + max_terms=1, + max_pages=1, + max_seconds=30, + ) + + assert candidates == [product] + assert [call[0] for call in calls] == ["理膚寶水 B5"] + assert calls[0][1]["limit"] == 20 + assert calls[0][1]["max_pages"] == 1