Spaces:
Running
Running
url-product: full desc, all-table specs, correct listPrice
Browse files
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=["'][^"']*
|
|
|
|
|
|
|
| 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 =
|
| 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(/ /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 =
|
| 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(/ /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 |
-
|
| 1645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1675 |
-
for (const b of
|
| 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(/ /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(/ /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));
|