bep40 commited on
Commit
fc23d1f
·
verified ·
1 Parent(s): 7bd5286

url-product: full desc, all-table specs, correct listPrice

Browse files
Files changed (1) hide show
  1. index.ts +75 -9
index.ts CHANGED
@@ -253,6 +253,41 @@ function parseTrTable(html: string): Record<string, string> {
253
  }
254
  return out;
255
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  // Find the section that contains 'needle' (Vietnamese headings) and call fn with it.
257
  function findSection(html: string, needles: RegExp, maxLen: number): string {
258
  const m = html.match(needles);
@@ -1575,7 +1610,9 @@ const server = Bun.serve({
1575
  // `<strike>` right after the current price. Parsed so the avatar's
1576
  // add-product preview can show "Giá niêm yết — Giá KM" (priceMode both).
1577
  {
1578
- const oldM = html.match(/class=["'][^"']*(?:old-price|price-old|special-price|old_price)[^"']*["'][^>]*>\s*([^<]{2,40})</i) ||
 
 
1579
  html.match(/<strike[^>]*>\s*([^<]{2,40})<\/strike>/i);
1580
  if (oldM) {
1581
  const num = Number(String(oldM[1]).replace(/[^\d]/g, "")) || 0;
@@ -1614,15 +1651,15 @@ const server = Bun.serve({
1614
  let description = "";
1615
  const descCandidates: string[] = [];
1616
  {
1617
- const art = html.match(/<div[^>]*class=["'][^"']*(?:blog-content|product-content|product-description|box-content|content-detail|description-detail|entry-content|ck-content|page-content|post-content)[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*(?:<\/div>|<!--|\s*<div)/i);
1618
  if (art && art[1]) {
1619
- const inner = art[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<img[^>]*>/gi, " \n ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|tr|div)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ");
1620
  const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim();
1621
  if (t.length > 80) descCandidates.push(t);
1622
  }
1623
- const main = html.match(/<div[^>]*class=["'][^"']*(?:main-content|content-wrapper|single-content)[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*<\/div>/i);
1624
  if (main && main[1]) {
1625
- const inner = main[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ");
1626
  const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim();
1627
  if (t.length > 200) descCandidates.push(t);
1628
  }
@@ -1641,8 +1678,27 @@ const server = Bun.serve({
1641
  // CMSs), then dt/dd, then label:value rows. ──
1642
  const specs: Record<string, string> = {};
1643
  {
1644
- const sec = findSection(html, /(?:Thông số kỹ thuật|Thông số|Thông tin sản phẩm|Thông tin chi tiết|Chi tiết sản phẩm|Đặc điểm nổi bật|Đặc tính|Thông số)/i, 8000);
1645
- if (sec) Object.assign(specs, parseTrTable(sec));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1646
  }
1647
  if (!Object.keys(specs).length) {
1648
  const ddRe = (findSection(html, /(?: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)/i, 6000) || html).match(/<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi) || [];
@@ -1671,11 +1727,21 @@ const server = Bun.serve({
1671
  {
1672
  const featSec = html.match(/(?:Tính năng|Đặc điểm nổi bật|Điểm nổi bật|Ưu điểm)[\s\S]{0,4000}/i);
1673
  if (featSec) {
1674
- const bullets = featSec[0].match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || featSec[0].match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
1675
- for (const b of bullets.slice(0, 12)) {
1676
  const t = decodeHtmlEnt(stripTags(b)).replace(/\s+/g, " ").trim();
1677
  if (t.length > 6 && t.length < 220 && features.indexOf(t) < 0) features.push(t);
1678
  }
 
 
 
 
 
 
 
 
 
 
1679
  }
1680
  if (!features.length && descCandidates.length) {
1681
  const heads = descCandidates[0].split(/\n/).map((l: string) => l.trim()).filter((l: string) => l.length > 8 && l.length < 80 && !/^[\d\s\-•]+$/.test(l));
 
253
  }
254
  return out;
255
  }
256
+
257
+ // Extract the full content of a <div class="..."> block, balancing nested
258
+ // <div> tags so the slice ends at the block's real closing tag.
259
+ function extractDivBlock(html: string, classTokens: RegExp, maxLen = 60000): string {
260
+ const re = new RegExp("<div[^>]*class=[\"'][^\"']*" + classTokens.source + "[^\"']*[\"'][^>]*>", "i");
261
+ const m0 = html.match(re);
262
+ if (!m0 || typeof m0.index !== "number") return "";
263
+ const start = m0.index;
264
+ let i = start + m0[0].length;
265
+ let depth = 1;
266
+ const limit = Math.min(html.length, start + maxLen);
267
+ const opens: number[] = [];
268
+ const closes: number[] = [];
269
+ const openRe = /<div[\s>]/g;
270
+ const closeRe = /<\/div\s*>/g;
271
+ openRe.lastIndex = i;
272
+ let o: RegExpExecArray | null;
273
+ while ((o = openRe.exec(html)) && o.index < limit) opens.push(o.index);
274
+ closeRe.lastIndex = i;
275
+ let c: RegExpExecArray | null;
276
+ while ((c = closeRe.exec(html)) && c.index < limit) closes.push(c.index);
277
+ let oi = 0, ci = 0;
278
+ while (oi < opens.length || ci < closes.length) {
279
+ const po = oi < opens.length ? opens[oi] : Infinity;
280
+ const pc = ci < closes.length ? closes[ci] : Infinity;
281
+ if (pc < po) {
282
+ depth--;
283
+ if (depth === 0) { i = pc; break; }
284
+ ci++;
285
+ } else { depth++; oi++; }
286
+ }
287
+ if (depth !== 0) i = limit;
288
+ return html.slice(start, i);
289
+ }
290
+
291
  // Find the section that contains 'needle' (Vietnamese headings) and call fn with it.
292
  function findSection(html: string, needles: RegExp, maxLen: number): string {
293
  const m = html.match(needles);
 
1610
  // `<strike>` right after the current price. Parsed so the avatar's
1611
  // add-product preview can show "Giá niêm yết — Giá KM" (priceMode both).
1612
  {
1613
+ const oldM = html.match(/<p[^>]*class=["'][^"']*special-price[^"']*["'][^>]*>\s*[^<]*<\/p>\s*<p[^>]*class=["'][^"']*old-price[^"']*["'][^>]*>\s*([^<]{2,40})</i) ||
1614
+ html.match(/<p[^>]*class=["'][^"']*old-price[^"']*["'][^>]*>\s*([^<]{2,40})</i) ||
1615
+ html.match(/class=["'][^"']*(?:old-price|price-old|old_price)[^"']*["'][^>]*>\s*([^<]{2,40})</i) ||
1616
  html.match(/<strike[^>]*>\s*([^<]{2,40})<\/strike>/i);
1617
  if (oldM) {
1618
  const num = Number(String(oldM[1]).replace(/[^\d]/g, "")) || 0;
 
1651
  let description = "";
1652
  const descCandidates: string[] = [];
1653
  {
1654
+ const art = { 1: extractDivBlock(html, /(?:blog-content|product-content|product-description|box-content|content-detail|description-detail|entry-content|ck-content|block-information)/, 60000) };
1655
  if (art && art[1]) {
1656
+ const inner = art[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<table[\s\S]*?<\/table>/gi, " ").replace(/<img[^>]*>/gi, " \n ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|tr|div)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ");
1657
  const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim();
1658
  if (t.length > 80) descCandidates.push(t);
1659
  }
1660
+ const main = { 1: extractDivBlock(html, /(?:main-content|content-wrapper|single-content)/, 60000) };
1661
  if (main && main[1]) {
1662
+ const inner = main[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<table[\s\S]*?<\/table>/gi, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ");
1663
  const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim();
1664
  if (t.length > 200) descCandidates.push(t);
1665
  }
 
1678
  // CMSs), then dt/dd, then label:value rows. ──
1679
  const specs: Record<string, string> = {};
1680
  {
1681
+ // Parse every <tr><td> table on the page, preferring the largest one:
1682
+ // Vietnamese CMSs keep the spec sheet in a plain table that may sit far
1683
+ // from any "Thông số" heading (a section heuristic can match the phrase
1684
+ // "tính năng đa dạng" inside the description instead). Duplicate
1685
+ // desktop/mobile tables collapse because the first label wins.
1686
+ const tblRe = /<table[^>]*>([\s\S]*?)<\/table>/gi;
1687
+ let tm2: RegExpExecArray | null;
1688
+ const tables: Array<{ n: number; s: string }> = [];
1689
+ while ((tm2 = tblRe.exec(html))) {
1690
+ const rows = (tm2[1].match(/<tr[\s>]/gi) || []).length;
1691
+ if (rows >= 4) tables.push({ n: rows, s: tm2[1] });
1692
+ }
1693
+ tables.sort((a, b) => b.n - a.n);
1694
+ const seenLabels = new Set<string>();
1695
+ for (const tb of tables) {
1696
+ const parsed = parseTrTable("<table>" + tb.s + "</table>");
1697
+ for (const k of Object.keys(parsed)) {
1698
+ if (!seenLabels.has(k)) { seenLabels.add(k); specs[k] = parsed[k]; }
1699
+ }
1700
+ if (Object.keys(specs).length >= 12) break;
1701
+ }
1702
  }
1703
  if (!Object.keys(specs).length) {
1704
  const ddRe = (findSection(html, /(?: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)/i, 6000) || html).match(/<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi) || [];
 
1727
  {
1728
  const featSec = html.match(/(?:Tính năng|Đặc điểm nổi bật|Điểm nổi bật|Ưu điểm)[\s\S]{0,4000}/i);
1729
  if (featSec) {
1730
+ const lis = featSec[0].match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
1731
+ for (const b of lis.slice(0, 12)) {
1732
  const t = decodeHtmlEnt(stripTags(b)).replace(/\s+/g, " ").trim();
1733
  if (t.length > 6 && t.length < 220 && features.indexOf(t) < 0) features.push(t);
1734
  }
1735
+ if (!features.length) {
1736
+ // <p> paragraphs only if they look like short feature phrases
1737
+ // (at most 1 sentence) — multi-sentence body paragraphs are description.
1738
+ const ps = featSec[0].match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
1739
+ for (const b of ps.slice(0, 12)) {
1740
+ const t = decodeHtmlEnt(stripTags(b)).replace(/\s+/g, " ").trim();
1741
+ const dots = (t.match(/\./g) || []).length;
1742
+ if (t.length > 6 && t.length <= 160 && dots <= 1 && features.indexOf(t) < 0) features.push(t);
1743
+ }
1744
+ }
1745
  }
1746
  if (!features.length && descCandidates.length) {
1747
  const heads = descCandidates[0].split(/\n/).map((l: string) => l.trim()).filter((l: string) => l.length > 8 && l.length < 80 && !/^[\d\s\-•]+$/.test(l));