#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 競品價格補給線 Worker (Competitor Price Feeder) 角色:獨立背景 Worker(生產者端) 架構位置: [本 Worker — 每 4 小時跑一次] → competitor_prices DB 表(最新快取) → competitor_price_history DB 表(歷史快照) ↓ [AI Pipeline] → fetch_candidates() LEFT JOIN competitor_prices(消費者端) 設計原則: - 與 AI Pipeline 完全解耦:本 Worker 掛了不影響核心大腦 - 自帶重試機制,不阻塞主排程 - 語意化標籤 (tags) 讓 Hermes 獲得更豐富的情境 爬取邏輯: MOMO 商品名稱 → PChome 關鍵字搜尋 → 模糊比對最佳匹配 → 寫入 competitor_prices + competitor_price_history 依賴: services/pchome_crawler.py — 搜尋 + 批量 API services/price_comparison.py — ProductNameParser + 模糊比對 """ import json import logging import re import time from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Optional logger = logging.getLogger(__name__) # ── 比對參數 ───────────────────────────────────────── MIN_MATCH_SCORE = 0.42 # 低於此分數不寫入(避免張冠李戴) SEARCH_LIMIT = 12 # 每個搜尋詞取 PChome 前 N 筆 MAX_SEARCH_TERMS = 3 # 每個 MOMO 商品最多嘗試幾組搜尋詞 BATCH_SIZE = 30 # 每批 DB 寫入筆數 RATE_DELAY = 0.8 # 每次 PChome 請求間隔(秒) TTL_HOURS = 6 # competitor_prices 快取有效期 # ── Feeder 結果 ─────────────────────────────────────── @dataclass class FeederResult: total_skus: int matched: int skipped_no_result: int skipped_low_score: int errors: int duration_sec: float history_written: int = 0 attempts_written: int = 0 def _extract_tags(pchome_product) -> list: """ 從 PChomeProduct 物件提取語意標籤 標籤設計: - "on_sale" — is_on_sale = True - "discount_10pct" — 折扣 10~19% - "discount_20pct" — 折扣 20~29% - "discount_30pct" — 折扣 ≥ 30% - "low_stock" — 庫存 < 10 - "high_rating" — 評分 ≥ 4.5 """ tags = [] if pchome_product.is_on_sale: tags.append("on_sale") try: disc = int(pchome_product.discount or 0) except (ValueError, TypeError): disc = 0 if disc >= 30: tags.append("discount_30pct") elif disc >= 20: tags.append("discount_20pct") elif disc >= 10: tags.append("discount_10pct") try: stock = int(pchome_product.stock) if pchome_product.stock is not None else None if stock is not None and 0 < stock < 10: tags.append("low_stock") except (ValueError, TypeError): pass try: if pchome_product.rating and float(pchome_product.rating) >= 4.5: tags.append("high_rating") except (ValueError, TypeError): pass return tags def _clean_search_text(value: str) -> str: value = re.sub(r'[((][^))]*[))]', ' ', value or '') value = re.sub(r'[【\[].*?[】\]]', ' ', value) value = re.sub(r'[^\w\u4e00-\u9fff]+', ' ', value) return re.sub(r'\s+', ' ', value).strip() def _dedupe_terms(terms: list) -> list: result = [] seen = set() for term in terms: cleaned = _clean_search_text(term) if len(cleaned) < 2: continue key = cleaned.lower() if key in seen: continue seen.add(key) result.append(cleaned[:36]) if len(result) >= MAX_SEARCH_TERMS: break return result def _build_search_keywords(momo_name: str) -> list: """ 用多組真實商品名線索搜尋 PChome,提高命中率,但仍交給相似度門檻把關。 """ cleaned = _clean_search_text(momo_name) terms = [cleaned[:28], cleaned[:18]] try: from services.price_comparison import ProductNameParser, BRAND_ALIASES parser = ProductNameParser() parsed = parser.parse(momo_name, "momo", 0, "", "") if parsed.brand: brand_terms = BRAND_ALIASES.get(parsed.brand, [parsed.brand]) brand_label = next((term for term in brand_terms if any('\u4e00' <= c <= '\u9fff' for c in term)), brand_terms[0]) if parsed.product_type: terms.append(f"{brand_label} {parsed.product_type}") if parsed.specs.get("volume"): terms.append(f"{brand_label} {parsed.specs['volume']}") if parsed.keywords: terms.append(f"{brand_label} {' '.join(parsed.keywords[:3])}") elif parsed.keywords: terms.append(" ".join(parsed.keywords[:4])) except Exception: pass return _dedupe_terms(terms) def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: """ 從 PChome 搜尋結果中找出與 MOMO 商品名稱最接近的一筆 Args: momo_name: MOMO 商品名稱 pchome_products: PChomeProduct 列表 Returns: (PChomeProduct, score) or None """ try: from services.price_comparison import ProductNameParser parser = ProductNameParser() except ImportError: # Fallback:用 difflib 直接比字串 from difflib import SequenceMatcher best, best_score = None, 0.0 for p in pchome_products: score = SequenceMatcher(None, momo_name.lower(), p.name.lower()).ratio() if score > best_score: best, best_score = p, score return (best, best_score) if best else None # 使用 ProductNameParser 的結構化比對 momo_parsed = parser.parse(momo_name, "momo", 0, "", "") best, best_score = None, 0.0 for p in pchome_products: pchome_parsed = parser.parse(p.name, "pchome", p.price, p.product_id, p.product_url) score = _structural_similarity(momo_parsed, pchome_parsed) if score > best_score: best, best_score = p, score return (best, best_score) if best else None def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None) -> list: """以多組搜尋詞擴大 PChome 候選池,找到可信候選後提早停止。""" candidates = [] seen_ids = set() for keyword in keywords or _build_search_keywords(momo_name): ok, _, products = crawler.search_products(keyword, limit=SEARCH_LIMIT) if not ok or not products: continue for product in products: if product.product_id in seen_ids: continue seen_ids.add(product.product_id) candidates.append(product) best = _find_best_match(momo_name, candidates) if best and best[1] >= 0.62: break return candidates def _structural_similarity(momo_p, pchome_p) -> float: """ 結構化相似度計算(品牌 + 規格 + 關鍵字) 權重:品牌匹配 0.4 + 規格匹配 0.3 + 關鍵字相似 0.3 """ from difflib import SequenceMatcher score = 0.0 # 品牌比對 (0.4) if momo_p.brand and pchome_p.brand: if momo_p.brand == pchome_p.brand: score += 0.4 elif momo_p.brand in pchome_p.brand or pchome_p.brand in momo_p.brand: score += 0.2 elif not momo_p.brand and not pchome_p.brand: score += 0.1 # 都沒有品牌,不扣分 # 規格比對 (0.3) — 容量/克重 momo_specs = momo_p.specs or {} pchome_specs = pchome_p.specs or {} if momo_specs and pchome_specs: matching_specs = sum( 1 for k, v in momo_specs.items() if pchome_specs.get(k) == v ) total_specs = max(len(momo_specs), len(pchome_specs), 1) score += 0.3 * (matching_specs / total_specs) elif not momo_specs and not pchome_specs: score += 0.15 # 關鍵字相似度 (0.3) momo_kws = " ".join(momo_p.keywords or []) pchome_kws = " ".join(pchome_p.keywords or []) if momo_kws and pchome_kws: kw_sim = SequenceMatcher(None, momo_kws.lower(), pchome_kws.lower()).ratio() score += 0.3 * kw_sim return round(score, 3) class CompetitorPriceFeeder: """ 競品價格補給線 Worker 用法: feeder = CompetitorPriceFeeder(engine=db_engine) result = feeder.run(source="pchome") """ def __init__(self, engine=None): self.engine = engine self._history_table_ready = False self._attempt_table_ready = False def _ensure_competitor_price_history_table(self, conn): """確保競品價格歷史表存在;排程可自癒補表,不依賴手動 migration。""" if self._history_table_ready: return from sqlalchemy import text if conn.dialect.name == "postgresql": conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_price_history ( id BIGSERIAL PRIMARY KEY, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_price NUMERIC(10,2), price NUMERIC(10,2) NOT NULL, original_price NUMERIC(10,2), discount_pct INTEGER, competitor_product_id VARCHAR(100), competitor_product_name TEXT, match_score NUMERIC(4,3), tags JSONB DEFAULT '[]'::jsonb, crawled_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time ON competitor_price_history (sku, source, crawled_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id ON competitor_price_history (competitor_product_id) """)) else: conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_price NUMERIC(10,2), price NUMERIC(10,2) NOT NULL, original_price NUMERIC(10,2), discount_pct INTEGER, competitor_product_id VARCHAR(100), competitor_product_name TEXT, match_score NUMERIC(4,3), tags TEXT DEFAULT '[]', crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time ON competitor_price_history (sku, source, crawled_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id ON competitor_price_history (competitor_product_id) """)) self._history_table_ready = True def _ensure_competitor_match_attempts_table(self, conn): """確保 PChome 比對嘗試表存在;成功、低分、無結果與錯誤都要留痕。""" if self._attempt_table_ready: return from sqlalchemy import text if conn.dialect.name == "postgresql": conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_attempts ( id BIGSERIAL PRIMARY KEY, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), search_terms JSONB DEFAULT '[]'::jsonb, candidate_count INTEGER DEFAULT 0, attempt_status VARCHAR(30) NOT NULL, best_competitor_product_id VARCHAR(100), best_competitor_product_name TEXT, best_competitor_price NUMERIC(10,2), best_match_score NUMERIC(4,3), error_message TEXT, attempted_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_sku_source_time ON competitor_match_attempts (sku, source, attempted_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_status_time ON competitor_match_attempts (attempt_status, attempted_at DESC) """)) else: conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), search_terms TEXT DEFAULT '[]', candidate_count INTEGER DEFAULT 0, attempt_status VARCHAR(30) NOT NULL, best_competitor_product_id VARCHAR(100), best_competitor_product_name TEXT, best_competitor_price NUMERIC(10,2), best_match_score NUMERIC(4,3), error_message TEXT, attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_sku_source_time ON competitor_match_attempts (sku, source, attempted_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_status_time ON competitor_match_attempts (attempt_status, attempted_at DESC) """)) self._attempt_table_ready = True def _record_match_attempt( self, sku: str, momo_name: str, momo_product_id: int = None, momo_price: float = None, search_terms: list = None, candidate_count: int = 0, attempt_status: str = "unknown", best_product=None, best_score: float = None, error_message: str = None, source: str = "pchome", ) -> None: """追加一筆 PChome 比對嘗試紀錄,讓待比對/低信心也能回溯。""" from sqlalchemy import text with self.engine.begin() as conn: self._ensure_competitor_match_attempts_table(conn) search_terms_expr = "CAST(:search_terms AS jsonb)" if conn.dialect.name == "postgresql" else ":search_terms" conn.execute(text(f""" INSERT INTO competitor_match_attempts (sku, source, momo_product_id, momo_product_name, momo_price, search_terms, candidate_count, attempt_status, best_competitor_product_id, best_competitor_product_name, best_competitor_price, best_match_score, error_message, attempted_at) VALUES (:sku, :source, :momo_product_id, :momo_product_name, :momo_price, {search_terms_expr}, :candidate_count, :attempt_status, :best_id, :best_name, :best_price, :best_score, :error_message, CURRENT_TIMESTAMP) """), { "sku": sku, "source": source, "momo_product_id": momo_product_id, "momo_product_name": momo_name, "momo_price": momo_price, "search_terms": json.dumps(search_terms or [], ensure_ascii=False), "candidate_count": candidate_count, "attempt_status": attempt_status, "best_id": getattr(best_product, "product_id", None), "best_name": (getattr(best_product, "name", None) or "")[:300] or None, "best_price": getattr(best_product, "price", None), "best_score": best_score, "error_message": (error_message or "")[:1000] or None, }) def _fetch_active_skus(self) -> list: """ 從 products 表取得待監控的 ACTIVE 商品清單 Returns: list of {"sku": str, "name": str, "category": str} """ if self.engine is None: raise RuntimeError("需要注入 SQLAlchemy engine") from sqlalchemy import text sql = text(""" SELECT p.id AS product_id, p.i_code AS sku, p.name, p.category, ( SELECT pr.price FROM price_records pr WHERE pr.product_id = p.id ORDER BY pr.timestamp DESC LIMIT 1 ) AS momo_price FROM products p WHERE p.status = 'ACTIVE' AND EXISTS ( SELECT 1 FROM price_records pr WHERE pr.product_id = p.id ) ORDER BY p.i_code """) with self.engine.connect() as conn: rows = conn.execute(sql).fetchall() return [dict(r._mapping) for r in rows] def _fetch_unmatched_priority_skus(self, limit: int = 80) -> list: """ 取得目前沒有有效 PChome 配對的高價 ACTIVE 商品,供補強流程優先處理。 """ if self.engine is None: raise RuntimeError("需要注入 SQLAlchemy engine") from sqlalchemy import text sql = text(""" WITH latest_momo AS ( SELECT p.id AS product_id, p.i_code AS sku, p.name, p.category, pr.price AS momo_price, ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn FROM products p JOIN price_records pr ON pr.product_id = p.id WHERE p.status = 'ACTIVE' ) SELECT lm.product_id, lm.sku, lm.name, lm.category, lm.momo_price FROM latest_momo lm LEFT JOIN competitor_prices cp ON cp.sku = lm.sku AND cp.source = 'pchome' AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) WHERE lm.rn = 1 AND cp.sku IS NULL ORDER BY lm.momo_price DESC NULLS LAST, lm.sku LIMIT :limit """) with self.engine.connect() as conn: rows = conn.execute(sql, {"limit": max(1, min(int(limit), 300))}).fetchall() return [dict(r._mapping) for r in rows] def _upsert_competitor_price( self, sku: str, product, # PChomeProduct match_score: float, tags: list, momo_product_id: int = None, momo_price: float = None, source: str = "pchome", ): """單筆寫入/更新最新快取,並追加一筆歷史快照。""" from sqlalchemy import text _taipei = timezone(timedelta(hours=8)) expires_at = (datetime.now(_taipei) + timedelta(hours=TTL_HOURS)).strftime("%Y-%m-%d %H:%M:%S") tags_json = json.dumps(tags, ensure_ascii=False) with self.engine.begin() as conn: self._ensure_competitor_price_history_table(conn) conn.execute(text(""" INSERT INTO competitor_prices (sku, source, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at, expires_at) VALUES (:sku, :source, :price, :original_price, :discount_pct, :comp_id, :comp_name, :match_score, :tags, CURRENT_TIMESTAMP, :expires_at) ON CONFLICT (sku, source) DO UPDATE SET price = EXCLUDED.price, original_price = EXCLUDED.original_price, discount_pct = EXCLUDED.discount_pct, competitor_product_id = EXCLUDED.competitor_product_id, competitor_product_name = EXCLUDED.competitor_product_name, match_score = EXCLUDED.match_score, tags = EXCLUDED.tags, crawled_at = CURRENT_TIMESTAMP, expires_at = :expires_at """), { "sku": sku, "source": source, "price": product.price, "original_price":product.original_price, "discount_pct": product.discount, "comp_id": product.product_id, "comp_name": product.name[:200], "match_score": match_score, "tags": tags_json, "expires_at": expires_at, }) conn.execute(text(""" INSERT INTO competitor_price_history (sku, source, momo_product_id, momo_price, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at) VALUES (:sku, :source, :momo_product_id, :momo_price, :price, :original_price, :discount_pct, :comp_id, :comp_name, :match_score, :tags, CURRENT_TIMESTAMP) """), { "sku": sku, "source": source, "momo_product_id": momo_product_id, "momo_price": momo_price, "price": product.price, "original_price": product.original_price, "discount_pct": product.discount, "comp_id": product.product_id, "comp_name": product.name[:200], "match_score": match_score, "tags": tags_json, }) def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult: start = time.time() if source != "pchome": logger.warning(f"[Feeder] 尚未支援 source={source},跳過") return FeederResult(0, 0, 0, 0, 0, 0.0) from services.pchome_crawler import PChomeCrawler crawler = PChomeCrawler(timeout=30, delay=RATE_DELAY) logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 {label}") matched = 0 skipped_no = 0 skipped_low = 0 errors = 0 history_written = 0 attempts_written = 0 for item in skus: sku = item["sku"] momo_name = item["name"] momo_product_id = item.get("product_id") momo_price = item.get("momo_price") search_terms = _build_search_keywords(momo_name) try: products = _search_pchome_candidates(crawler, momo_name, search_terms) if not products: logger.debug(f"[Feeder] {sku} 無搜尋結果,跳過") self._record_match_attempt( sku, momo_name, momo_product_id=momo_product_id, momo_price=momo_price, search_terms=search_terms, candidate_count=0, attempt_status="no_result", source=source, ) attempts_written += 1 skipped_no += 1 continue result = _find_best_match(momo_name, products) if not result: self._record_match_attempt( sku, momo_name, momo_product_id=momo_product_id, momo_price=momo_price, search_terms=search_terms, candidate_count=len(products), attempt_status="no_match", source=source, ) attempts_written += 1 skipped_no += 1 continue best_product, score = result if score < MIN_MATCH_SCORE: logger.debug( f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE}),跳過" ) self._record_match_attempt( sku, momo_name, momo_product_id=momo_product_id, momo_price=momo_price, search_terms=search_terms, candidate_count=len(products), attempt_status="low_score", best_product=best_product, best_score=score, source=source, ) attempts_written += 1 skipped_low += 1 continue tags = _extract_tags(best_product) self._upsert_competitor_price( sku, best_product, score, tags, momo_product_id=momo_product_id, momo_price=momo_price, source=source, ) self._record_match_attempt( sku, momo_name, momo_product_id=momo_product_id, momo_price=momo_price, search_terms=search_terms, candidate_count=len(products), attempt_status="matched", best_product=best_product, best_score=score, source=source, ) matched += 1 history_written += 1 attempts_written += 1 logger.debug( f"[Feeder] {sku} → PChome ${best_product.price} " f"score={score:.3f} tags={tags}" ) except Exception as e: logger.error(f"[Feeder] {sku} 處理失敗: {e}") try: self._record_match_attempt( sku, momo_name, momo_product_id=momo_product_id, momo_price=momo_price, search_terms=search_terms, attempt_status="error", error_message=str(e), source=source, ) attempts_written += 1 except Exception as attempt_error: logger.warning(f"[Feeder] {sku} 比對嘗試紀錄寫入失敗: {attempt_error}") errors += 1 duration = round(time.time() - start, 2) logger.info( f"[Feeder] 完成 matched={matched} skipped_no={skipped_no} " f"skipped_low={skipped_low} errors={errors} " f"history_written={history_written} attempts_written={attempts_written} 耗時={duration}s" ) return FeederResult( total_skus=len(skus), matched=matched, skipped_no_result=skipped_no, skipped_low_score=skipped_low, errors=errors, duration_sec=duration, history_written=history_written, attempts_written=attempts_written, ) def run(self, source: str = "pchome") -> FeederResult: """ 執行一輪競品價格抓取與寫入 Args: source: 競品來源代碼(目前支援 'pchome') Returns: FeederResult """ try: skus = self._fetch_active_skus() except Exception as e: logger.error(f"[Feeder] 讀取商品清單失敗: {e}") return FeederResult(0, 0, 0, 0, 1, 0.0) return self._run_sku_items(skus, source=source, label="PChome 競品價格") def run_unmatched_priority(self, limit: int = 80, source: str = "pchome") -> FeederResult: """優先補抓尚未有有效 PChome 配對的高價商品。""" try: skus = self._fetch_unmatched_priority_skus(limit=limit) except Exception as e: logger.error(f"[Feeder] 讀取待比對優先商品失敗: {e}") return FeederResult(0, 0, 0, 0, 1, 0.0) return self._run_sku_items(skus, source=source, label="待比對優先補抓") # ───────────────────────────────────────────── # CLI 測試(不依賴 DB,直接測試爬蟲 + 比對邏輯) # python3 services/competitor_price_feeder.py # ───────────────────────────────────────────── if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") from services.pchome_crawler import PChomeCrawler test_items = [ {"sku": "A003", "name": "舒特膚AD乳液200ml"}, {"sku": "A001", "name": "玻尿酸面膜10片裝"}, {"sku": "A009", "name": "美白化妝水150ml"}, ] crawler = PChomeCrawler(delay=0.8) print("=== Competitor Price Feeder CLI 測試 ===\n") for item in test_items: keyword = item["name"][:20] ok, msg, products = crawler.search_products(keyword, limit=10) if not ok or not products: print(f"[{item['sku']}] 無結果: {msg}") continue result = _find_best_match(item["name"], products) if not result: print(f"[{item['sku']}] 無法比對") continue best, score = result tags = _extract_tags(best) symbol = "✅" if score >= MIN_MATCH_SCORE else "⚠️ 低分" print( f"{symbol} [{item['sku']}] {item['name'][:25]}\n" f" → PChome: {best.name[:40]}\n" f" → 售價 ${best.price} | 分數 {score:.3f} | 標籤 {tags}\n" )