Spaces:
Running
Running
add /api/url-product: server-side malloca/JSON-LD product parser (name, sku, price, images, description, specs, features)
Browse files
index.ts
CHANGED
|
@@ -1228,6 +1228,161 @@ const server = Bun.serve({
|
|
| 1228 |
return new Response(buf,{headers:{"Content-Type":"image/jpeg","Cache-Control":"public, max-age=3600","Access-Control-Allow-Origin":"*"}});
|
| 1229 |
} catch(e:any){ return new Response("proxy error: "+String(e?.message||e),{status:502}); }
|
| 1230 |
}},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
"/api/img": { GET: async (req: Request) => {
|
| 1232 |
|
| 1233 |
|
|
|
|
| 1228 |
return new Response(buf,{headers:{"Content-Type":"image/jpeg","Cache-Control":"public, max-age=3600","Access-Control-Allow-Origin":"*"}});
|
| 1229 |
} catch(e:any){ return new Response("proxy error: "+String(e?.message||e),{status:502}); }
|
| 1230 |
}},
|
| 1231 |
+
"/api/url-product": { GET: async (req: Request) => {
|
| 1232 |
+
// Server-side product-URL parser: fetch the merchant page directly from Bun
|
| 1233 |
+
// (no CORS, no external proxies) and extract full product info. Supports
|
| 1234 |
+
// malloca.com (primary) + generic JSON-LD/meta fallback for any e-commerce URL.
|
| 1235 |
+
// Returns: { name, sku, model, brand, price, priceText, currency, image, images,
|
| 1236 |
+
// description, summary, specs, features, category, link }
|
| 1237 |
+
try {
|
| 1238 |
+
const raw = new URL(req.url).searchParams.get("url") || "";
|
| 1239 |
+
if (!raw || !/^https?:\/\//i.test(raw)) return new Response("missing url", { status: 400 });
|
| 1240 |
+
const u = new URL(raw);
|
| 1241 |
+
if (["http:", "https:"].indexOf(u.protocol) < 0) return new Response("bad proto", { status: 400 });
|
| 1242 |
+
const resp = await fetch(u.href, {
|
| 1243 |
+
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9" },
|
| 1244 |
+
signal: AbortSignal.timeout(40000),
|
| 1245 |
+
redirect: "follow",
|
| 1246 |
+
});
|
| 1247 |
+
if (!resp.ok) return new Response("upstream " + resp.status, { status: 502 });
|
| 1248 |
+
const html = await resp.text();
|
| 1249 |
+
const out: Record<string, any> = { link: u.href };
|
| 1250 |
+
|
| 1251 |
+
// ── JSON-LD (Product blocks) ──
|
| 1252 |
+
let ldProduct: any = null, ldDesc = "", ldPrice = 0;
|
| 1253 |
+
const ldRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/g;
|
| 1254 |
+
let lm: RegExpExecArray | null;
|
| 1255 |
+
while ((lm = ldRe.exec(html))) {
|
| 1256 |
+
try {
|
| 1257 |
+
const data = JSON.parse(lm[1]);
|
| 1258 |
+
const walk = (n: any) => {
|
| 1259 |
+
if (!n) return;
|
| 1260 |
+
if (Array.isArray(n)) { n.forEach(walk); return; }
|
| 1261 |
+
if (typeof n === "object") {
|
| 1262 |
+
if (n["@type"] && String(n["@type"]).toLowerCase() === "product" && !ldProduct) ldProduct = n;
|
| 1263 |
+
if (Array.isArray(n["@graph"])) n["@graph"].forEach(walk);
|
| 1264 |
+
walk(n["@type"] === "Product" ? n : n["itemListElement"]);
|
| 1265 |
+
}
|
| 1266 |
+
};
|
| 1267 |
+
walk(data);
|
| 1268 |
+
} catch (e) { /* skip malformed */ }
|
| 1269 |
+
}
|
| 1270 |
+
if (ldProduct) {
|
| 1271 |
+
if (!ldDesc && ldProduct.description) ldDesc = String(ldProduct.description).replace(/&[a-z]+;/g, " ").trim();
|
| 1272 |
+
if (!ldPrice && ldProduct.offers && ldProduct.offers.price) ldPrice = Number(ldProduct.offers.price) || 0;
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
// ── <title> / og / meta ──
|
| 1276 |
+
const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1]?.replace(/\s+/g, " ").trim() || "";
|
| 1277 |
+
const meta = (name: string) => {
|
| 1278 |
+
const m = html.match(new RegExp(`<meta[^>]+(?:name|property)=["']${name}["'][^>]+content=["']([^"']*)["']`, "i")) ||
|
| 1279 |
+
html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+(?:name|property)=["']${name}["']`, "i"));
|
| 1280 |
+
return m ? m[1] : "";
|
| 1281 |
+
};
|
| 1282 |
+
const ogTitle = meta("og:title") || "", ogDesc = meta("og:description") || "", ogImage = meta("og:image") || meta("twitter:image");
|
| 1283 |
+
|
| 1284 |
+
// ── name ──
|
| 1285 |
+
let name = "";
|
| 1286 |
+
if (ldProduct && ldProduct.name) name = String(ldProduct.name).trim();
|
| 1287 |
+
if (!name && ogTitle) name = ogTitle.split(/[|–—-]/)[0].trim();
|
| 1288 |
+
if (!name && title) name = title.split(/[|–—-]/)[0].trim();
|
| 1289 |
+
|
| 1290 |
+
// ── sku / model: prefer URL slug token (MS7743SI) then HTML "Mã sản phẩm" then JSON-LD sku ──
|
| 1291 |
+
const slugToken = (u.pathname.split("/").filter(Boolean).pop() || "").match(/[A-Z]{2,}\d{3,}[A-Z]*/i);
|
| 1292 |
+
let sku = slugToken ? slugToken[0].toUpperCase() : "";
|
| 1293 |
+
const msku = html.match(/Mã (?:sản phẩm|SP)[^>]*>?\s*([^<]{2,30})</i) || html.match(/(?:data-sku|itemprop="sku")[^>]*["']([A-Z0-9-]{3,30})["']/i);
|
| 1294 |
+
if (!sku && msku) sku = msku[1].trim();
|
| 1295 |
+
if (!sku && ldProduct && ldProduct.sku) sku = String(ldProduct.sku).trim().slice(0, 20);
|
| 1296 |
+
|
| 1297 |
+
// ── brand ──
|
| 1298 |
+
let brand = "";
|
| 1299 |
+
if (ldProduct && ldProduct.brand) brand = (typeof ldProduct.brand === "string" ? ldProduct.brand : (ldProduct.brand.name || "")).trim();
|
| 1300 |
+
if (!brand) { const mb = html.match(/Malloca/i); if (mb) brand = "Malloca"; }
|
| 1301 |
+
|
| 1302 |
+
// ── price ──
|
| 1303 |
+
let price = 0, priceText = "";
|
| 1304 |
+
const ogPrice = meta("og:price:amount");
|
| 1305 |
+
if (ogPrice) price = Number(String(ogPrice).replace(/[^\d]/g, "")) || 0;
|
| 1306 |
+
if (!price && ldPrice) price = ldPrice;
|
| 1307 |
+
if (!price) {
|
| 1308 |
+
const m = html.match(/(\d{1,3}(?:[.,]\d{3}){1,3})\s*(?:đ|₫|vnd)/i);
|
| 1309 |
+
if (m) price = Number(m[1].replace(/[.,](?=\d{3}\b)/g, "").replace(/\./g, "")) || 0;
|
| 1310 |
+
}
|
| 1311 |
+
if (price) priceText = price.toLocaleString("vi-VN") + "đ";
|
| 1312 |
+
|
| 1313 |
+
// ── images: only real product images (path contains /products/) ──
|
| 1314 |
+
const imgs: string[] = [];
|
| 1315 |
+
const allImgs = html.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp)/gi) || [];
|
| 1316 |
+
for (const im of allImgs) {
|
| 1317 |
+
let c = im.replace(/\\\//g, "/");
|
| 1318 |
+
if (!/\/products\//i.test(c)) continue;
|
| 1319 |
+
c = c.replace(/\/thumb\/(?:grande|large|medium|small|compact)\//, "/");
|
| 1320 |
+
c = c.split("?")[0];
|
| 1321 |
+
if (imgs.indexOf(c) < 0) imgs.push(c);
|
| 1322 |
+
}
|
| 1323 |
+
if (!imgs.length && ogImage) imgs.push(ogImage);
|
| 1324 |
+
const image = imgs[0] || ogImage || "";
|
| 1325 |
+
|
| 1326 |
+
// ── description / summary ──
|
| 1327 |
+
let description = ldDesc || ogDesc || meta("description") || "";
|
| 1328 |
+
description = description.replace(/\s+/g, " ").trim();
|
| 1329 |
+
|
| 1330 |
+
// ── specs: scan "Thông số kỹ thuật" / "Thông số" / "Thông tin" section for label:value rows ──
|
| 1331 |
+
const specs: Record<string, string> = {};
|
| 1332 |
+
const specSection = html.match(/(?:Thông số kỹ thuật|Thông số|Thông tin sản phẩm|Chi tiết sản phẩm|Đặc điểm nổi bật)[\s\S]{0,6000}/i);
|
| 1333 |
+
if (specSection) {
|
| 1334 |
+
const seg = specSection[0];
|
| 1335 |
+
const rows = seg.match(/<[^>]{0,80}?>(?:<[^>]+>)*?\s*([A-ZÀ-Ỹ][^<>]{2,60}?)\s*(?:<\/[^>]+>)?\s*:?\s*<\/[^>]+>\s*(?:<[^>]+>)*?\s*([^<>]{1,200}?)\s*<\/[^>]+>/g) || [];
|
| 1336 |
+
for (const rw of rows) {
|
| 1337 |
+
const clean = rw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
| 1338 |
+
const mm = clean.match(/^(.{2,60}?)\s*[::]\s*(.{1,180})$/);
|
| 1339 |
+
if (mm && mm[1].length <= 60 && specs[mm[1].trim()] === undefined) specs[mm[1].trim()] = mm[2].trim();
|
| 1340 |
+
}
|
| 1341 |
+
}
|
| 1342 |
+
// also try JSON-LD or property rows (dt/dd)
|
| 1343 |
+
const ddRe = html.match(/<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi);
|
| 1344 |
+
if (ddRe && !Object.keys(specs).length) {
|
| 1345 |
+
for (const blk of ddRe.slice(0, 40)) {
|
| 1346 |
+
const c = blk.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
| 1347 |
+
const mm = c.match(/^(.{2,60}?)\s*[::]?\s*(.{1,180})$/);
|
| 1348 |
+
if (mm && specs[mm[1].trim()] === undefined) specs[mm[1].trim()] = mm[2].trim();
|
| 1349 |
+
}
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
// ── features: bullets under "Tính năng" / "Đặc điểm" ──
|
| 1353 |
+
const features: string[] = [];
|
| 1354 |
+
const featSec = html.match(/(?:Tính năng|Đặc điểm nổi bật|Điểm nổi bật)[\s\S]{0,4000}/i);
|
| 1355 |
+
if (featSec) {
|
| 1356 |
+
const bullets = featSec[0].match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || featSec[0].match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
|
| 1357 |
+
for (const b of bullets.slice(0, 12)) {
|
| 1358 |
+
const t = b.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
| 1359 |
+
if (t.length > 6 && t.length < 220 && features.indexOf(t) < 0) features.push(t);
|
| 1360 |
+
}
|
| 1361 |
+
}
|
| 1362 |
+
|
| 1363 |
+
// ── category ──
|
| 1364 |
+
let category = "";
|
| 1365 |
+
const bc = html.match(/<nav[^>]*aria-label=["']breadcrumb["'][\s\S]*?<\/nav>/i) || html.match(/class=["'][^"']*breadcrumb[^"']*["'][\s\S]{0,3000}?<\/nav>/i);
|
| 1366 |
+
if (bc) {
|
| 1367 |
+
const links = bc[0].match(/<a[^>]*>([\s\S]*?)<\/a>/gi) || [];
|
| 1368 |
+
for (const lk of links) {
|
| 1369 |
+
const t = lk.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
| 1370 |
+
if (t && t.length <= 60 && t.toLowerCase().indexOf("trang chủ") < 0 && t.toLowerCase().indexOf("malloca") < 0) category = t;
|
| 1371 |
+
}
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
out.name = name || "Sản phẩm";
|
| 1375 |
+
out.sku = sku; out.model = sku; out.brand = brand;
|
| 1376 |
+
out.price = price; out.priceText = priceText; out.currency = "VND";
|
| 1377 |
+
out.image = image; out.images = imgs;
|
| 1378 |
+
out.description = description; out.summary = description.slice(0, 300);
|
| 1379 |
+
out.specs = specs; out.features = features; out.category = category;
|
| 1380 |
+
const body = JSON.stringify(out);
|
| 1381 |
+
return new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=600", "Access-Control-Allow-Origin": "*" } });
|
| 1382 |
+
} catch (e: any) {
|
| 1383 |
+
return new Response("proxy error: " + String(e?.message || e), { status: 502 });
|
| 1384 |
+
}
|
| 1385 |
+
}},
|
| 1386 |
"/api/img": { GET: async (req: Request) => {
|
| 1387 |
|
| 1388 |
|