feat: show product identity in ai recommendations
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
ogt
2026-06-26 18:33:11 +08:00
parent 1dfeee0506
commit c268b5cc02
8 changed files with 226 additions and 25 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ========================================== # ==========================================
# 系統版本與路徑 # 系統版本與路徑
# ========================================== # ==========================================
SYSTEM_VERSION = "V10.713" SYSTEM_VERSION = "V10.714"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示 public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-06-26 (台北時間) > **最後更新**: 2026-06-26 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立 > **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
> **適用版本**: V10.713 > **適用版本**: V10.714
--- ---
@@ -798,3 +798,4 @@ POSTGRES_HOST=momo-db
| 2026-06-26 | 前台不得用爬蟲當使用者主語 | V10.711 起 PChome、設定、舊入口、market intel 與任務確認文字統一使用「商品監控 / 資料擷取 / 監控來源」不再把「爬蟲」當頁首、導覽、CTA 或提示主語;內部 route/key 可保留以降低部署風險。 | | 2026-06-26 | 前台不得用爬蟲當使用者主語 | V10.711 起 PChome、設定、舊入口、market intel 與任務確認文字統一使用「商品監控 / 資料擷取 / 監控來源」不再把「爬蟲」當頁首、導覽、CTA 或提示主語;內部 route/key 可保留以降低部署風險。 |
| 2026-06-26 | 匯入與設定頁不得回吐 raw 後端錯誤 | V10.712 起自動匯入與系統設定頁的未知錯誤 fallback 只顯示使用者可執行的下一步Google Drive、格式、同步與資料處理異常可分類成營運文案但不得把 SQL、snapshot、資料表欄位、driver error 或原始 exception 直接顯示在前台。 | | 2026-06-26 | 匯入與設定頁不得回吐 raw 後端錯誤 | V10.712 起自動匯入與系統設定頁的未知錯誤 fallback 只顯示使用者可執行的下一步Google Drive、格式、同步與資料處理異常可分類成營運文案但不得把 SQL、snapshot、資料表欄位、driver error 或原始 exception 直接顯示在前台。 |
| 2026-06-26 | 正式容器不得繼承互動式 Google Drive OAuth | V10.713 起 `momo-app``momo-scheduler``momo-telegram-bot``GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH` 固定為 `false`,不再允許 `.env` 在主機重啟後把排程帶回瀏覽器 OAuth人工授權只可用一次性腳本完成正式匯入缺 token 時必須 fail-closed 並回可處理訊息。 | | 2026-06-26 | 正式容器不得繼承互動式 Google Drive OAuth | V10.713 起 `momo-app``momo-scheduler``momo-telegram-bot``GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH` 固定為 `false`,不再允許 `.env` 在主機重啟後把排程帶回瀏覽器 OAuth人工授權只可用一次性腳本完成正式匯入缺 token 時必須 fail-closed 並回可處理訊息。 |
| 2026-06-26 | AI 銷售建議商品區塊必須商品身份優先 | V10.714 起 `/ai_recommend` 熱銷商品參考改為商品卡,後端 bestseller API 回傳平台、商品 ID、賣場連結與圖片欄位前端必須顯示縮圖、平台、商品 ID、價格與開賣場操作缺圖/缺連結需顯示待補狀態,並把商品 ID / URL 帶入銷售建議上下文。 |

View File

@@ -773,11 +773,16 @@ def get_momo_bestsellers(category: str, limit: int = 5) -> Tuple[bool, str, List
result = [] result = []
for p in products[:limit]: for p in products[:limit]:
result.append({ result.append({
'id': p.product_id,
'product_id': p.product_id,
'platform': 'momo',
'name': p.name, 'name': p.name,
'price': p.price, 'price': p.price,
'original_price': p.original_price, 'original_price': p.original_price,
'discount': p.discount, 'discount': p.discount,
'url': p.product_url, 'url': p.product_url,
'product_url': p.product_url,
'image_url': p.image_url,
'image': p.image_url 'image': p.image_url
}) })
return True, f"成功取得 {len(result)} 個熱銷商品", result return True, f"成功取得 {len(result)} 個熱銷商品", result

View File

@@ -563,11 +563,16 @@ def get_pchome_bestsellers(category: str, limit: int = 5) -> Tuple[bool, str, Li
result = [] result = []
for p in products[:limit]: for p in products[:limit]:
result.append({ result.append({
'id': p.product_id,
'product_id': p.product_id,
'platform': 'pchome',
'name': p.name, 'name': p.name,
'price': p.price, 'price': p.price,
'original_price': p.original_price, 'original_price': p.original_price,
'discount': p.discount, 'discount': p.discount,
'url': p.product_url, 'url': p.product_url,
'product_url': p.product_url,
'image_url': p.image_url,
'image': p.image_url 'image': p.image_url
}) })
return True, f"成功取得 {len(result)} 個熱銷商品", result return True, f"成功取得 {len(result)} 個熱銷商品", result

View File

@@ -61,7 +61,7 @@
<ul class="dropdown-menu dropdown-menu-end ar-dropdown"> <ul class="dropdown-menu dropdown-menu-end ar-dropdown">
<li><h6 class="dropdown-header">點選分類快速填入</h6></li> <li><h6 class="dropdown-header">點選分類快速填入</h6></li>
{% for category in product_categories %} {% for category in product_categories %}
<li><a class="dropdown-item" href="#" onclick="setProduct('{{ category }}')">{{ category }}</a></li> <li><a class="dropdown-item" href="#" onclick="setProduct({{ category|tojson }})">{{ category }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -277,14 +277,14 @@
<header class="card-header ar-card__head ar-card__head--soft d-flex justify-content-between align-items-center py-2"> <header class="card-header ar-card__head ar-card__head--soft d-flex justify-content-between align-items-center py-2">
<div> <div>
<h6 class="mb-0"><i class="fas fa-fire-alt me-2"></i>熱銷商品參考</h6> <h6 class="mb-0"><i class="fas fa-fire-alt me-2"></i>熱銷商品參考</h6>
<small class="text-muted">點選商品快速填入</small> <small class="text-muted">商品 ID、圖片與賣場連結可一眼確認</small>
</div> </div>
<div class="d-flex align-items-center gap-1"> <div class="d-flex align-items-center gap-1">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<input type="radio" class="btn-check" name="platform" id="platformPchome" value="pchome" checked> <input type="radio" class="btn-check" name="platform" id="platformPchome" value="pchome" checked>
<label class="btn btn-outline-primary btn-sm px-2" for="platformPchome">PChome</label> <label class="btn btn-outline-primary btn-sm px-2" for="platformPchome">PChome</label>
<input type="radio" class="btn-check" name="platform" id="platformMomo" value="momo" disabled> <input type="radio" class="btn-check" name="platform" id="platformMomo" value="momo">
<label class="btn btn-outline-secondary btn-sm px-2 ar-disabled" for="platformMomo">MOMO</label> <label class="btn btn-outline-primary btn-sm px-2" for="platformMomo">MOMO</label>
</div> </div>
<select class="form-select form-select-sm" id="bestsellersCategory" style="width: 80px;" onchange="loadBestsellers()"> <select class="form-select form-select-sm" id="bestsellersCategory" style="width: 80px;" onchange="loadBestsellers()">
<option value="面膜">面膜</option> <option value="面膜">面膜</option>

View File

@@ -841,11 +841,22 @@ def test_ai_recommend_uses_v2_shell_and_runtime_category_data():
assert "ai-recommend-page" in template assert "ai-recommend-page" in template
assert "{% for category in product_categories[:4] %}" in template assert "{% for category in product_categories[:4] %}" in template
assert "quickWebSearch({{ category|tojson }})" in template assert "quickWebSearch({{ category|tojson }})" in template
assert "setProduct({{ category|tojson }})" in template
assert "quickWebSearch('保濕面膜')" not in template assert "quickWebSearch('保濕面膜')" not in template
assert "fetch('/api/ai/generate_copy'" in page_js assert "fetch('/api/ai/generate_copy'" in page_js
assert "fetch('/api/ai/web_search'" in page_js assert "fetch('/api/ai/web_search'" in page_js
assert "fetch('/api/ai/product_insights'" in page_js assert "fetch('/api/ai/product_insights'" in page_js
assert "fetch('/api/ai/gemini_usage?days=30')" in page_js assert "fetch('/api/ai/gemini_usage?days=30')" in page_js
assert "renderBestsellerCard" in page_js
assert "ar-product-card__img" in page_js
assert "待補圖片" in page_js
assert "商品 ID" in page_js
assert "開賣場" in page_js
assert "product_id: el.dataset.productId" in page_js
assert "Object.assign(window" in page_js
assert "setProductFromCard" in page_js
assert "商品 ID、圖片與賣場連結可一眼確認" in template
assert 'id="platformMomo" value="momo">' in template
assert "mock" not in template.lower() assert "mock" not in template.lower()
assert "假商品" not in template assert "假商品" not in template
assert "PChome 銷售建議" in template assert "PChome 銷售建議" in template
@@ -895,6 +906,8 @@ def test_ai_recommend_uses_v2_shell_and_runtime_category_data():
assert "@ai_bp.route('/api/ai/generate_copy'" in route_source assert "@ai_bp.route('/api/ai/generate_copy'" in route_source
assert "@ai_bp.route('/api/ai/web_search'" in route_source assert "@ai_bp.route('/api/ai/web_search'" in route_source
assert "@ai_bp.route('/api/ai/product_insights'" in route_source assert "@ai_bp.route('/api/ai/product_insights'" in route_source
assert "'product_id': p.product_id" in (ROOT / "services/pchome_crawler.py").read_text(encoding="utf-8")
assert "'product_id': p.product_id" in (ROOT / "services/momo_crawler.py").read_text(encoding="utf-8")
def test_monthly_summary_analysis_uses_v2_shell_and_real_monthly_api(): def test_monthly_summary_analysis_uses_v2_shell_and_real_monthly_api():

View File

@@ -123,6 +123,113 @@
.ai-recommend-page .ar-card--insights { border-color: var(--momo-warm-caramel, #c96442) !important; } .ai-recommend-page .ar-card--insights { border-color: var(--momo-warm-caramel, #c96442) !important; }
.ai-recommend-page .ar-card--trends { border-color: var(--momo-warm-olive, #6f7a4a) !important; } .ai-recommend-page .ar-card--trends { border-color: var(--momo-warm-olive, #6f7a4a) !important; }
/* ── Product identity cards ────────────────────────── */
.ai-recommend-page .ar-product-card {
display: grid;
grid-template-columns: 1.6rem 4.5rem minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: center;
min-height: 5.25rem;
padding: 0.7rem 0.8rem;
border-bottom: 1px solid var(--momo-border-subtle);
cursor: pointer;
}
.ai-recommend-page .ar-product-card:hover {
background: color-mix(in srgb, var(--momo-page-accent) 5%, var(--momo-surface));
}
.ai-recommend-page .ar-product-card__rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
background: var(--momo-text-strong);
color: var(--momo-on-accent, #fff8ef);
font-size: 0.75rem;
font-weight: 800;
}
.ai-recommend-page .ar-product-card__media {
width: 4.5rem;
height: 4.5rem;
}
.ai-recommend-page .ar-product-card__img,
.ai-recommend-page .ar-product-card__missing-img {
width: 100%;
height: 100%;
border: 1px solid var(--momo-border-subtle);
border-radius: var(--momo-radius-sm, 6px);
background: var(--momo-surface-2);
}
.ai-recommend-page .ar-product-card__img {
display: block;
object-fit: contain;
}
.ai-recommend-page .ar-product-card__missing-img {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
color: var(--momo-text-tertiary);
font-size: 0.68rem;
font-weight: 800;
}
.ai-recommend-page .ar-product-card__body {
min-width: 0;
}
.ai-recommend-page .ar-product-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.25rem;
}
.ai-recommend-page .ar-product-card__id {
color: var(--momo-text-tertiary);
font-size: 0.72rem;
font-weight: 800;
}
.ai-recommend-page .ar-product-card__name {
overflow: hidden;
color: var(--momo-text-strong);
font-size: 0.86rem;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-recommend-page .ar-product-card__price {
margin-top: 0.2rem;
color: var(--momo-warm-rose, #a84428);
font-size: 0.86rem;
font-weight: 900;
}
.ai-recommend-page .ar-product-card__actions {
display: flex;
justify-content: flex-end;
min-width: 5.25rem;
}
.ai-recommend-page .ar-product-card__link {
white-space: nowrap;
}
.ai-recommend-page .ar-product-card__pending {
white-space: nowrap;
}
@media (max-width: 640px) {
.ai-recommend-page .ar-product-card {
grid-template-columns: 1.6rem 3.8rem minmax(0, 1fr);
}
.ai-recommend-page .ar-product-card__media {
width: 3.8rem;
height: 3.8rem;
}
.ai-recommend-page .ar-product-card__actions {
grid-column: 2 / -1;
justify-content: flex-start;
}
}
/* ── Dropdown / quick tags / keywords ──────────────── */ /* ── Dropdown / quick tags / keywords ──────────────── */
.ai-recommend-page .ar-dropdown { max-height: 300px; overflow-y: auto; } .ai-recommend-page .ar-dropdown { max-height: 300px; overflow-y: auto; }
.ai-recommend-page .ar-quick-tags { display: flex; flex-wrap: wrap; gap: 6px; } .ai-recommend-page .ar-quick-tags { display: flex; flex-wrap: wrap; gap: 6px; }

View File

@@ -49,7 +49,7 @@
// 載入熱銷商品 // 載入熱銷商品
function loadBestsellers() { function loadBestsellers() {
const category = document.getElementById('bestsellersCategory').value; const category = document.getElementById('bestsellersCategory').value;
const platform = document.querySelector('input[name="platform"]:checked')?.value || 'momo'; const platform = document.querySelector('input[name="platform"]:checked')?.value || 'pchome';
const container = document.getElementById('bestsellersCard'); const container = document.getElementById('bestsellersCard');
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>'; container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
@@ -61,16 +61,8 @@
}) })
.then(data => { .then(data => {
if (data.success && data.data.products?.length) { if (data.success && data.data.products?.length) {
container.innerHTML = data.data.products.map((p, i) => ` container.innerHTML = data.data.products.map((p, i) => renderBestsellerCard(p, i, data.data.platform)).join('')
<div class="d-flex align-items-center px-3 py-2 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')"> + `<div class="text-center text-muted small py-1">${escapeHtml(data.data.source || '')}</div>`;
<span class="badge bg-secondary me-2">${i + 1}</span>
<div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block">${p.name}</small>
<small class="text-muted">$${p.price?.toLocaleString() || 'N/A'}</small>
</div>
<a href="${p.url}" target="_blank" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()" title="前往電商網站查看商品"><i class="fas fa-external-link-alt"></i></a>
</div>
`).join('') + `<div class="text-center text-muted small py-1">${data.data.source}</div>`;
} else { } else {
container.innerHTML = '<p class="text-muted text-center py-3 mb-0">無法載入熱銷商品</p>'; container.innerHTML = '<p class="text-muted text-center py-3 mb-0">無法載入熱銷商品</p>';
} }
@@ -78,6 +70,54 @@
.catch(e => container.innerHTML = '<p class="text-danger text-center py-3 mb-0">載入失敗</p>'); .catch(e => container.innerHTML = '<p class="text-danger text-center py-3 mb-0">載入失敗</p>');
} }
function normalizeBestsellerProduct(product, platform) {
const p = product || {};
return {
name: String(p.name || '').trim(),
productId: String(p.product_id || p.id || p.i_code || p.goodsCode || '').trim(),
platform: String(p.platform || platform || '').toLowerCase(),
price: Number(p.price || 0),
url: String(p.product_url || p.url || '').trim(),
imageUrl: String(p.image_url || p.image || '').trim()
};
}
function platformLabel(platform) {
return String(platform || '').toLowerCase() === 'momo' ? 'MOMO' : 'PChome';
}
function renderProductThumb(product) {
const image = product.imageUrl;
if (image && /^https?:\/\//i.test(image)) {
return `<img class="ar-product-card__img" src="${escapeHtml(image)}" alt="${escapeHtml(product.name)}" loading="lazy" referrerpolicy="no-referrer">`;
}
return `<div class="ar-product-card__missing-img"><i class="fas fa-image"></i><span>待補圖片</span></div>`;
}
function renderBestsellerCard(product, index, platform) {
const p = normalizeBestsellerProduct(product, platform);
const label = platformLabel(p.platform);
const price = p.price > 0 ? `NT$ ${p.price.toLocaleString()}` : '待補價格';
const productId = p.productId || '待補';
const storeAction = p.url
? `<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary ar-product-card__link" onclick="event.stopPropagation()" title="開啟賣場"><i class="fas fa-up-right-from-square me-1"></i>開賣場</a>`
: '<span class="badge bg-light text-muted border ar-product-card__pending">待補連結</span>';
return `
<div class="ar-product-card" data-product-name="${escapeHtml(p.name)}" data-product-id="${escapeHtml(p.productId)}" data-product-url="${escapeHtml(p.url)}" data-platform="${escapeHtml(p.platform)}" data-price="${p.price}" onclick="setProductFromCard(this)">
<div class="ar-product-card__rank">${index + 1}</div>
<div class="ar-product-card__media">${renderProductThumb(p)}</div>
<div class="ar-product-card__body">
<div class="ar-product-card__meta">
<span class="badge bg-light text-dark border">${label}</span>
<span class="ar-product-card__id">商品 ID ${escapeHtml(productId)}</span>
</div>
<div class="ar-product-card__name">${escapeHtml(p.name || '待補商品名稱')}</div>
<div class="ar-product-card__price">${price}</div>
</div>
<div class="ar-product-card__actions">${storeAction}</div>
</div>`;
}
// 載入 COSME 排行榜 // 載入 COSME 排行榜
function loadCosmeRankings() { function loadCosmeRankings() {
const category = document.getElementById('cosmeCategory').value; const category = document.getElementById('cosmeCategory').value;
@@ -93,7 +133,7 @@
.then(data => { .then(data => {
if (data.success && data.data.products?.length) { if (data.success && data.data.products?.length) {
container.innerHTML = data.data.products.map((p, i) => ` container.innerHTML = data.data.products.map((p, i) => `
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')"> <div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" data-product-name="${escapeHtml(p.name)}" onclick="setProductFromCard(this)">
<span class="badge ${i < 3 ? 'bg-warning text-dark' : 'bg-secondary'} me-2" style="font-size: 0.7rem;">${p.rank}</span> <span class="badge ${i < 3 ? 'bg-warning text-dark' : 'bg-secondary'} me-2" style="font-size: 0.7rem;">${p.rank}</span>
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block" style="font-size: 0.8rem;">${p.brand} ${p.name}</small> <small class="text-truncate d-block" style="font-size: 0.8rem;">${p.brand} ${p.name}</small>
@@ -123,7 +163,7 @@
.then(data => { .then(data => {
if (data.success && data.data.articles?.length) { if (data.success && data.data.articles?.length) {
container.innerHTML = data.data.articles.map(a => ` container.innerHTML = data.data.articles.map(a => `
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(a.title)}')"> <div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" data-product-name="${escapeHtml(a.title)}" onclick="setProductFromCard(this)">
<i class="fas fa-star text-success me-2" style="font-size: 0.7rem;"></i> <i class="fas fa-star text-success me-2" style="font-size: 0.7rem;"></i>
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block" style="font-size: 0.8rem;">${a.title}</small> <small class="text-truncate d-block" style="font-size: 0.8rem;">${a.title}</small>
@@ -212,7 +252,7 @@
} }
container.innerHTML = items.slice(0, 20).map(n => ` container.innerHTML = items.slice(0, 20).map(n => `
<div class="d-flex align-items-center px-3 py-2 border-bottom news-item" style="cursor: pointer;" onclick="setProduct('${escapeHtml(n.title || n.query || '')}')"> <div class="d-flex align-items-center px-3 py-2 border-bottom news-item" style="cursor: pointer;" data-product-name="${escapeHtml(n.title || n.query || '')}" onclick="setProductFromCard(this)">
<i class="fas ${icon} ${iconClass} me-2"></i> <i class="fas ${icon} ${iconClass} me-2"></i>
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<small class="text-truncate d-block fw-medium">${n.title || n.query || ''}</small> <small class="text-truncate d-block fw-medium">${n.title || n.query || ''}</small>
@@ -319,6 +359,11 @@
document.getElementById('productName').value = name.substring(0, 100); document.getElementById('productName').value = name.substring(0, 100);
} }
function setProductFromCard(card) {
const name = card?.dataset?.productName || '';
if (name) setProduct(name);
}
// 切換關鍵字 // 切換關鍵字
function toggleKeyword(el) { function toggleKeyword(el) {
el.classList.toggle('is-selected'); el.classList.toggle('is-selected');
@@ -347,12 +392,16 @@
// 取得熱銷商品資訊 // 取得熱銷商品資訊
function getBestsellersForAPI() { function getBestsellersForAPI() {
const items = document.querySelectorAll('#bestsellersCard > div.d-flex'); const items = document.querySelectorAll('#bestsellersCard .ar-product-card');
return Array.from(items).slice(0, 3).map(el => { return Array.from(items).slice(0, 3).map(el => {
const name = el.querySelector('small.text-truncate')?.textContent || ''; const price = parseInt(el.dataset.price || '0', 10) || 0;
const priceText = el.querySelector('small.text-muted')?.textContent || ''; return {
const price = parseInt(priceText.replace(/[^0-9]/g, '')) || 0; name: el.dataset.productName || '',
return { name, price }; price,
product_id: el.dataset.productId || '',
platform: el.dataset.platform || '',
url: el.dataset.productUrl || ''
};
}); });
} }
@@ -1001,6 +1050,27 @@
document.getElementById('productName').value = title; document.getElementById('productName').value = title;
} }
Object.assign(window, {
addKeywordFromInsight,
copyCopyText,
doProductInsights,
doWebSearch,
generateCopy,
loadBestsellers,
loadCosmeRankings,
loadMybestArticles,
loadTrends,
onProviderChange,
quickWebSearch,
refreshTrends,
setProduct,
setProductFromCard,
switchTrendTab,
toggleKeyword,
useTrendForProduct,
useTrendKeyword
});
// 初始化 // 初始化
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initAIProvider(); initAIProvider();