bep40 commited on
Commit
f8afa2a
·
verified ·
1 Parent(s): 5aded7b

add /api/url-product: server-side malloca/JSON-LD product parser (name, sku, price, images, description, specs, features)

Browse files
Files changed (1) hide show
  1. index.ts +155 -0
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