/* ============================================================================= * Prology – Related Products API (Node.js / Express) * ----------------------------------------------------------------------------- * Server riêng đứng giữa blog WordPress và Magento, để: * - Tự login Magento lấy admin token (KHÔNG lộ ra trình duyệt) + cache token * - Chọn store theo vị trí client: AU -> 'au', còn lại -> 'us' * - Tìm sản phẩm liên quan theo keyword, lọc: status=1 + có ảnh + CÒN HÀNG * - Random và trả về mảng đã chuẩn hóa {name,image,url,price,qty} * - Bật CORS để trang blog gọi được, không dính CORS * - Cache kết quả ngắn hạn để giảm tải Magento * * CHẠY: * cd server && npm install && cp .env.example .env (sửa .env nếu cần) * npm start # mặc định http://localhost:8787 * * Yêu cầu: Node.js >= 18 (dùng global fetch). * * Endpoint: * GET /api/related-products?q=Catalyst%209300,Cisco&count=16&store=au * - store: client tự phát hiện (qua Cloudflare /cdn-cgi/trace) rồi gửi lên. * Nếu thiếu, server fallback theo header CF-IPCountry, rồi tới DEFAULT_STORE. * ========================================================================== */ 'use strict'; const express = require('express'); require('dotenv').config(); const { PORT = 8787, MAGENTO_BASE = 'https://prology.net', // Danh sách store hợp lệ + store mặc định khi không phát hiện được STORES = 'au,us', DEFAULT_STORE = 'us', MAGENTO_USER = 'admin', MAGENTO_PASS = 'Work1234', // Origin được phép gọi (vd: https://prology.net). '*' = mọi nơi (chỉ nên dùng khi test) ALLOWED_ORIGIN = '*', POOL = '40', // số sản phẩm lấy về để lọc/random PRODUCTS_TTL = '300', // cache danh sách sản phẩm (giây) STOCK_TTL = '60', // cache tồn kho (giây) } = process.env; const STORE_LIST = STORES.split(',').map((s) => s.trim()).filter(Boolean); const apiBase = (store) => `${MAGENTO_BASE}/${store}/rest/V1`; const mediaBase = (store) => `${MAGENTO_BASE}/${store}/media/catalog/product`; const app = express(); app.set('trust proxy', true); // ----------------------------- CORS ---------------------------------------- app.use((req, res, next) => { res.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN); res.set('Vary', 'Origin'); res.set('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.set('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); // ------------------- Chọn store theo vị trí client ------------------------- function pickStore(req) { // 1) client tự phát hiện (Cloudflare trace) rồi gửi ?store= const q = String(req.query.store || '').toLowerCase(); if (STORE_LIST.includes(q)) return q; // 2) fallback: nếu server đứng sau Cloudflare -> có header CF-IPCountry const cc = String(req.get('CF-IPCountry') || '').toUpperCase(); if (cc === 'AU') return 'au'; if (cc && cc !== 'XX' && STORE_LIST.includes('us')) return 'us'; // 3) mặc định return DEFAULT_STORE; } // ------------------------- Token (cache + tự gia hạn) ----------------------- let tokenCache = { value: null, exp: 0 }; async function getToken(force = false) { const now = Date.now(); if (!force && tokenCache.value && now < tokenCache.exp) return tokenCache.value; // Token admin dùng chung cho mọi store; login qua store mặc định const r = await fetch(`${apiBase(DEFAULT_STORE)}/integration/admin/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: MAGENTO_USER, password: MAGENTO_PASS }), }); if (!r.ok) throw new Error(`Magento login failed: ${r.status} ${await r.text()}`); const token = (await r.json()); // Token Magento mặc định sống ~1h; cache 50 phút cho an toàn tokenCache = { value: token, exp: now + 50 * 60 * 1000 }; return token; } // Gọi Magento (theo store) kèm Bearer, tự refetch token 1 lần nếu gặp 401 async function mget(store, path) { let token = await getToken(); let r = await fetch(`${apiBase(store)}${path}`, { headers: { Authorization: `Bearer ${token}` } }); if (r.status === 401) { token = await getToken(true); r = await fetch(`${apiBase(store)}${path}`, { headers: { Authorization: `Bearer ${token}` } }); } if (!r.ok) throw new Error(`Magento ${store}${path} -> ${r.status}`); return r.json(); } // ------------------------------ Cache nhỏ (TTL) ---------------------------- function makeCache(ttlSec) { const m = new Map(); return { get(k) { const e = m.get(k); if (e && Date.now() < e.exp) return e.val; m.delete(k); return null; }, set(k, val) { m.set(k, { val, exp: Date.now() + ttlSec * 1000 }); return val; }, }; } const prodCache = makeCache(Number(PRODUCTS_TTL)); const stockCache = makeCache(Number(STOCK_TTL)); // ------------------------------- Helpers ----------------------------------- const attr = (it, code) => (it.custom_attributes || []).find((c) => c.attribute_code === code)?.value ?? null; function imageOf(it, store) { let f = attr(it, 'image') || attr(it, 'small_image'); if (!f || f === 'no_selection') { const g = (it.media_gallery_entries || []).find((e) => e.media_type === 'image' && !e.disabled); f = g ? g.file : null; } return f ? mediaBase(store) + f : null; } function urlOf(it, store) { const k = attr(it, 'url_key'); return k ? `${MAGENTO_BASE}/${store}/${k}` : `${MAGENTO_BASE}/${store}/`; } function priceOf(it, store) { const v = Number(it.price || 0); if (!v) return ''; const locale = store === 'au' ? 'en-AU' : 'en-US'; const currency = store === 'au' ? 'AUD' : 'USD'; try { return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(v); } catch { return '$' + v; } } function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [a[i], a[j]] = [a[j], a[i]]; } return a; } // ------------------------- Magento data access ----------------------------- async function searchByName(store, keyword) { const ck = `${store}|${keyword}`; const cached = prodCache.get(ck); if (cached) return cached; const p = new URLSearchParams(); p.set('searchCriteria[filterGroups][0][filters][0][field]', 'name'); p.set('searchCriteria[filterGroups][0][filters][0][value]', `%${keyword}%`); p.set('searchCriteria[filterGroups][0][filters][0][conditionType]', 'like'); p.set('searchCriteria[filterGroups][1][filters][0][field]', 'status'); p.set('searchCriteria[filterGroups][1][filters][0][value]', '1'); p.set('searchCriteria[filterGroups][1][filters][0][conditionType]', 'eq'); p.set('searchCriteria[pageSize]', POOL); p.set('searchCriteria[currentPage]', '1'); p.set('searchCriteria[sortOrders][0][field]', 'updated_at'); p.set('searchCriteria[sortOrders][0][direction]', 'DESC'); const data = await mget(store, `/products?${p.toString()}`); return prodCache.set(ck, data.items || []); } async function inStock(store, sku) { const ck = `${store}|${sku}`; const cached = stockCache.get(ck); if (cached !== null) return cached; try { const s = await mget(store, `/stockItems/${encodeURIComponent(sku)}`); return stockCache.set(ck, { in_stock: !!s.is_in_stock, qty: s.qty }); } catch { return stockCache.set(ck, { in_stock: false, qty: 0 }); } } // ----------------------------- Endpoint ------------------------------------ app.get('/api/related-products', async (req, res) => { try { const store = pickStore(req); const keywords = String(req.query.q || 'Cisco') .split(',').map((s) => s.trim()).filter(Boolean); const count = Math.min(Math.max(parseInt(req.query.count, 10) || 16, 1), 20); // 1) Gom ứng viên theo nhiều keyword (cụ thể -> tổng quát), khử trùng SKU const seen = new Set(); const candidates = []; for (const kw of keywords) { if (candidates.length >= Number(POOL) * 1.5) break; let items = []; try { items = await searchByName(store, kw); } catch (_) { /* bỏ qua keyword lỗi */ } for (const it of items) { if (!seen.has(it.sku)) { seen.add(it.sku); candidates.push(it); } } } // 2) Lọc cơ bản: có ảnh + đang bật + không phải "not visible individually" const usable = shuffle(candidates.filter( (it) => imageOf(it, store) && Number(it.status) === 1 && Number(it.visibility) !== 1 )); // 3) Random + check tồn kho lazy cho tới khi đủ count const picked = []; for (const it of usable) { if (picked.length >= count) break; const st = await inStock(store, it.sku); if (st.in_stock) { picked.push({ sku: it.sku, name: it.name, image: imageOf(it, store), url: urlOf(it, store), price: priceOf(it, store), qty: st.qty, }); } } res.set('Cache-Control', 'public, max-age=60'); res.json(picked); } catch (err) { console.error('[related-products]', err); res.status(500).json({ error: 'failed', message: String(err.message || err) }); } }); app.get('/health', (_req, res) => res.json({ ok: true, stores: STORE_LIST })); app.listen(PORT, () => { console.log(`Prology related-products API: http://localhost:${PORT}`); console.log(`Stores: ${STORE_LIST.join(', ')} (default: ${DEFAULT_STORE})`); console.log(`Try: http://localhost:${PORT}/api/related-products?q=Catalyst%209300,Cisco&count=16&store=au`); });