diff --git a/CONSTITUTION.md b/CONSTITUTION.md
index d229519..491adfd 100644
--- a/CONSTITUTION.md
+++ b/CONSTITUTION.md
@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
-> **當前版本**: V10.47 (Auto-detect sales columns for product picks)
+> **當前版本**: V10.48 (Priority backfill for unmatched PChome products)
> **最後更新**: 2026-05-01
---
diff --git a/app.py b/app.py
index 65cfa50..3c15d0d 100644
--- a/app.py
+++ b/app.py
@@ -95,8 +95,8 @@ except Exception as e:
sys_log.error(f"無法檢測磁碟空間: {e}")
# 🚩 系統版本定義 (備份與顯示用)
-# 🚩 2026-05-01 V10.47: Auto-detect sales columns for product picks
-SYSTEM_VERSION = "V10.47"
+# 🚩 2026-05-01 V10.48: Priority backfill for unmatched PChome products
+SYSTEM_VERSION = "V10.48"
# ==========================================
# 🔒 SQL Injection 防護函數
diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md
index a504e6a..0b7aad4 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -35,6 +35,7 @@ SQL漏斗(~300筆)
- 寫入策略使用 `strategy='product_pick'`,保留在既有 AI 決策表,不新增假頁面或暫存 JSON。
- 後台入口:`POST /api/ai/product-picks/generate`,`/ai_intelligence` 可手動產生清單。
- 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。
+- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|
diff --git a/routes/ai_routes.py b/routes/ai_routes.py
index 4345801..2cb9709 100644
--- a/routes/ai_routes.py
+++ b/routes/ai_routes.py
@@ -1647,6 +1647,46 @@ def api_generate_product_picks():
return jsonify({'success': False, 'error': str(e)}), 500
+@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST'])
+@login_required
+def api_pchome_match_backfill():
+ """背景補抓尚未有有效 PChome 配對的高價商品,提高比價覆蓋率。"""
+ import threading
+
+ payload = request.get_json(silent=True) or {}
+ limit = max(5, min(int(payload.get('limit', 60)), 160))
+
+ def _run_backfill():
+ try:
+ from config import DATABASE_PATH
+ from sqlalchemy import create_engine
+ from services.competitor_price_feeder import CompetitorPriceFeeder
+
+ engine = create_engine(DATABASE_PATH)
+ result = CompetitorPriceFeeder(engine=engine).run_unmatched_priority(limit=limit)
+ logger.info(
+ "[PChomeBackfill] done total=%s matched=%s no=%s low=%s errors=%s history=%s duration=%ss",
+ result.total_skus,
+ result.matched,
+ result.skipped_no_result,
+ result.skipped_low_score,
+ result.errors,
+ result.history_written,
+ result.duration_sec,
+ )
+ except Exception as exc:
+ logger.error(f"[PChomeBackfill] 背景補抓失敗: {exc}")
+
+ thread = threading.Thread(target=_run_backfill, daemon=True)
+ thread.start()
+
+ return jsonify({
+ 'success': True,
+ 'message': f'已啟動 PChome 待比對補抓,優先處理 {limit} 筆高價未配對商品',
+ 'limit': limit,
+ }), 202
+
+
@ai_bp.route('/api/ai/icaim/trigger', methods=['POST'])
@login_required
def api_icaim_trigger():
diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py
index f106b4b..79b17bb 100644
--- a/services/competitor_price_feeder.py
+++ b/services/competitor_price_feeder.py
@@ -358,6 +358,47 @@ class CompetitorPriceFeeder:
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,
@@ -431,16 +472,7 @@ class CompetitorPriceFeeder:
"tags": tags_json,
})
- def run(self, source: str = "pchome") -> FeederResult:
- """
- 執行一輪競品價格抓取與寫入
-
- Args:
- source: 競品來源代碼(目前支援 'pchome')
-
- Returns:
- FeederResult
- """
+ def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult:
start = time.time()
if source != "pchome":
@@ -450,14 +482,7 @@ class CompetitorPriceFeeder:
from services.pchome_crawler import PChomeCrawler
crawler = PChomeCrawler(timeout=30, delay=RATE_DELAY)
- # Step 1: 取得監控清單
- try:
- skus = self._fetch_active_skus()
- except Exception as e:
- logger.error(f"[Feeder] 讀取商品清單失敗: {e}")
- return FeederResult(0, 0, 0, 0, 1, time.time() - start)
-
- logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 PChome 競品價格")
+ logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 {label}")
matched = 0
skipped_no = 0
@@ -529,6 +554,34 @@ class CompetitorPriceFeeder:
history_written=history_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,直接測試爬蟲 + 比對邏輯)
diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html
index e6ced35..5424e88 100644
--- a/templates/ai_intelligence.html
+++ b/templates/ai_intelligence.html
@@ -23,6 +23,9 @@
+
@@ -390,6 +393,41 @@ async function generatePickList() {
}
}
+// ── 補抓 PChome 待比對商品 ─────────────────────────
+async function backfillPchomeMatches() {
+ const btn = document.getElementById('btnBackfill');
+ const toast = document.getElementById('triggerToast');
+ const msg = document.getElementById('triggerToastMsg');
+
+ btn.disabled = true;
+ btn.innerHTML = '補抓中...';
+
+ try {
+ const res = await fetch('/api/ai/pchome-match/backfill', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ limit: 60 })
+ });
+ const data = await res.json();
+
+ msg.innerHTML = data.success
+ ? `${data.message}`
+ : `${data.error || '補抓啟動失敗'}`;
+ toast.className = 'toast align-items-center text-white border-0 ' +
+ (data.success ? 'bg-success' : 'bg-danger');
+ new bootstrap.Toast(toast, { delay: 6000 }).show();
+
+ if (data.success) setTimeout(loadDashboard, 90000);
+ } catch (e) {
+ msg.innerHTML = '補抓失敗:' + e.message;
+ toast.className = 'toast align-items-center text-white border-0 bg-danger';
+ new bootstrap.Toast(toast, { delay: 4000 }).show();
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = '補抓待比對';
+ }
+}
+
// ── 手動觸發分析 ────────────────────────────────────
async function triggerAnalysis() {
const btn = document.getElementById('btnTrigger');
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py
index b5d144f..a7d608a 100644
--- a/tests/test_frontend_v2_assets.py
+++ b/tests/test_frontend_v2_assets.py
@@ -139,6 +139,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
assert "_build_search_keywords" in feeder_source
assert "_search_pchome_candidates" in feeder_source
assert "crawler.search_products(keyword, limit=SEARCH_LIMIT)" in feeder_source
+ assert "_fetch_unmatched_priority_skus" in feeder_source
+ assert "run_unmatched_priority" in feeder_source
assert "generate_product_pick_list" in agent_source
assert "competitor_prices" in agent_source
@@ -155,12 +157,17 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
assert "@ai_bp.route('/api/ai/product-picks/generate', methods=['POST'])" in route_source
assert "generate_product_pick_list(engine" in route_source
+ assert "@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST'])" in route_source
+ assert "run_unmatched_priority(limit=limit)" in route_source
assert "match_rate" in route_source
assert "product_pick_count" in route_source
assert "產生挑品清單" in template
+ assert "補抓待比對" in template
assert "generatePickList" in template
+ assert "backfillPchomeMatches" in template
assert "/api/ai/product-picks/generate" in template
+ assert "/api/ai/pchome-match/backfill" in template
assert "'product_pick':['bg-success'" in template
assert "kpiMatchRate" in template