sponsored-prology/server.js

237 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =============================================================================
* 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`);
});