feat(ai): 補抓 PChome 待比對商品
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s

This commit is contained in:
OoO
2026-05-01 13:40:37 +08:00
parent 9f9e0727e7
commit 043a7dc915
7 changed files with 160 additions and 21 deletions

View File

@@ -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
---

4
app.py
View File

@@ -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 防護函數

View File

@@ -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 商品。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -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():

View File

@@ -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直接測試爬蟲 + 比對邏輯)

View File

@@ -23,6 +23,9 @@
<button class="btn btn-outline-primary btn-sm" id="btnPickList" onclick="generatePickList()">
<i class="fas fa-wand-magic-sparkles me-1"></i>產生挑品清單
</button>
<button class="btn btn-outline-warning btn-sm" id="btnBackfill" onclick="backfillPchomeMatches()">
<i class="fas fa-magnifying-glass-chart me-1"></i>補抓待比對
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadDashboard()">
<i class="fas fa-redo me-1"></i>重新整理
</button>
@@ -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 = '<span class="spinner-border spinner-border-sm me-1"></span>補抓中...';
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
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
: `<i class="fas fa-times-circle me-1"></i>${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 = '<i class="fas fa-times-circle me-1"></i>補抓失敗:' + 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 = '<i class="fas fa-magnifying-glass-chart me-1"></i>補抓待比對';
}
}
// ── 手動觸發分析 ────────────────────────────────────
async function triggerAnalysis() {
const btn = document.getElementById('btnTrigger');

View File

@@ -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