132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
|
|
// Run in the browser console on nssfasteners.co.za after login.
|
||
|
|
// Fetches all Grade 8 (101 items) + A2 (21 items) hex nut DIN934 product pages,
|
||
|
|
// parses the hidden unit prices out of the HTML, builds PosDescription strings,
|
||
|
|
// and triggers a CSV download.
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
const BASE = 'https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html';
|
||
|
|
const FILTERS = [
|
||
|
|
{ grade: '8', gradeId: '771', expectedCount: 101 },
|
||
|
|
{ grade: 'A2', gradeId: '773', expectedCount: 21 },
|
||
|
|
];
|
||
|
|
|
||
|
|
// ---- helpers ----
|
||
|
|
const fetchHtml = async (url) => {
|
||
|
|
const res = await fetch(url, { credentials: 'include' });
|
||
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||
|
|
return await res.text();
|
||
|
|
};
|
||
|
|
|
||
|
|
const parsePage = (html) => {
|
||
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
|
|
const items = Array.from(doc.querySelectorAll('.product-item'));
|
||
|
|
return items.map(item => {
|
||
|
|
const titleEl = item.querySelector('.items-center.justify-center.text-primary');
|
||
|
|
const title = titleEl ? titleEl.textContent.trim() : '';
|
||
|
|
const sku = title.includes(' - ') ? title.split(' - ').pop().trim() : '';
|
||
|
|
const linkEl = item.querySelector('a.product-item-photo, a.product.photo');
|
||
|
|
const url = linkEl ? linkEl.getAttribute('href') : '';
|
||
|
|
|
||
|
|
// pull the description block (Material / Diameter / Thread / Grade / Finish / Pack)
|
||
|
|
const block = (item.textContent || '').replace(/\s+/g, ' ');
|
||
|
|
const get = (label) => {
|
||
|
|
const m = block.match(new RegExp(label + '\\s*:?\\s*([^\\s].*?)(?=\\s+(?:Diameter|Thread|Grade|Finish|Sold|Metric|Hi-Tensile|Stainless|Mild|Brass|$))', 'i'));
|
||
|
|
return m ? m[1].trim() : '';
|
||
|
|
};
|
||
|
|
|
||
|
|
const material = /Hi-Tensile/i.test(block) ? 'Hi-Tensile'
|
||
|
|
: /Stainless Steel/i.test(block) ? 'Stainless Steel'
|
||
|
|
: /Mild Steel/i.test(block) ? 'Mild Steel'
|
||
|
|
: /Brass/i.test(block) ? 'Brass'
|
||
|
|
: '';
|
||
|
|
const diameter = get('Diameter');
|
||
|
|
const thread = get('Thread');
|
||
|
|
const grade = get('Grade');
|
||
|
|
const finish = get('Finish');
|
||
|
|
const pack = /Sold as a Single Piece/i.test(block) ? 'Single Piece'
|
||
|
|
: /Sold as a Box/i.test(block) ? 'Box'
|
||
|
|
: /Sold as a Pack/i.test(block) ? 'Pack'
|
||
|
|
: '';
|
||
|
|
|
||
|
|
// Unit price is in the HTML but hidden with display:none
|
||
|
|
const unitPriceEl = item.querySelector('.unit-price');
|
||
|
|
const priceMatch = unitPriceEl ? unitPriceEl.textContent.match(/R\s*([\d.]+)/) : null;
|
||
|
|
const unitPrice = priceMatch ? priceMatch[1] : '';
|
||
|
|
|
||
|
|
// Description is the parent product type (e.g. "Hex Nut DIN934")
|
||
|
|
const desc = title.includes(' - ') ? title.split(' - ')[0].trim() : '';
|
||
|
|
|
||
|
|
// Build PosDescription like: "M39 NUT 8 PLN"
|
||
|
|
// diameter -> "M{value}", normalised
|
||
|
|
const sizeMatch = diameter.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||
|
|
const size = sizeMatch ? 'M' + sizeMatch[1].replace(/\.0$/, '') : diameter;
|
||
|
|
const gradeShort = grade === 'Stainless' ? '' : grade; // "8" or "A2"
|
||
|
|
const posDescription = [size, 'NUT', gradeShort, finish.toUpperCase() === 'PLAIN' ? 'PLN' : finish.toUpperCase()]
|
||
|
|
.filter(Boolean).join(' ');
|
||
|
|
|
||
|
|
return {
|
||
|
|
sku,
|
||
|
|
posDescription,
|
||
|
|
description: desc,
|
||
|
|
material,
|
||
|
|
grade,
|
||
|
|
diameter,
|
||
|
|
thread,
|
||
|
|
finish,
|
||
|
|
pack,
|
||
|
|
unitPrice: unitPrice ? 'R ' + unitPrice : '',
|
||
|
|
url,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const allRows = [];
|
||
|
|
for (const f of FILTERS) {
|
||
|
|
// First page
|
||
|
|
const firstUrl = `${BASE}?gradematerial=${f.gradeId}`;
|
||
|
|
const firstHtml = await fetchHtml(firstUrl);
|
||
|
|
const firstRows = parsePage(firstHtml);
|
||
|
|
allRows.push(...firstRows);
|
||
|
|
|
||
|
|
// Discover total page count from toolbar
|
||
|
|
const doc = new DOMParser().parseFromString(firstHtml, 'text/html');
|
||
|
|
const m = (doc.querySelector('.toolbar-amount')?.textContent || '').match(/of\s+(\d+)/);
|
||
|
|
const total = m ? parseInt(m[1], 10) : f.expectedCount;
|
||
|
|
const pages = Math.ceil(total / 10);
|
||
|
|
console.log(`Grade ${f.grade}: ${firstRows.length} items on page 1, ${pages} pages total, expected ${total}`);
|
||
|
|
|
||
|
|
// Remaining pages
|
||
|
|
for (let p = 2; p <= pages; p++) {
|
||
|
|
const html = await fetchHtml(`${BASE}?gradematerial=${f.gradeId}&p=${p}`);
|
||
|
|
const rows = parsePage(html);
|
||
|
|
allRows.push(...rows);
|
||
|
|
console.log(`Grade ${f.grade}: page ${p}/${pages} -> ${rows.length} items`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`TOTAL ROWS: ${allRows.length}`);
|
||
|
|
|
||
|
|
// Build CSV
|
||
|
|
const headers = ['SKU','PosDescription','Description','Material','Grade','Diameter','Thread','Finish','Pack','UnitPrice','SourceURL'];
|
||
|
|
const esc = (v) => {
|
||
|
|
const s = (v ?? '').toString().replace(/"/g, '""');
|
||
|
|
return /[",\n]/.test(s) ? `"${s}"` : s;
|
||
|
|
};
|
||
|
|
const csv = [headers.join(',')]
|
||
|
|
.concat(allRows.map(r => headers.map(h => esc(r[h.toLowerCase()])).join(',')))
|
||
|
|
.join('\n');
|
||
|
|
|
||
|
|
// Trigger download
|
||
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||
|
|
const url = URL.createObjectURL(blob);
|
||
|
|
const a = document.createElement('a');
|
||
|
|
a.href = url;
|
||
|
|
a.download = 'nss-hex-nut-din934-grade8-a2.csv';
|
||
|
|
document.body.appendChild(a);
|
||
|
|
a.click();
|
||
|
|
document.body.removeChild(a);
|
||
|
|
URL.revokeObjectURL(url);
|
||
|
|
|
||
|
|
return { count: allRows.length, sample: allRows.slice(0, 3) };
|
||
|
|
})();
|