feat(ai): 補抓 PChome 待比對商品
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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
4
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 防護函數
|
||||
|
||||
@@ -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 商品。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,直接測試爬蟲 + 比對邏輯)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user