/** * EMEX VIN Scraper for sase.tr * * This script queries emexdwc.ae with a VIN number and returns vehicle data. * Designed for integration with sase.tr VIN decode service. * * Supports two modes: * - Standalone (CLI): launches its own browser * - Managed (NestJS): receives a pre-created page via { page } option * * Usage: node emex-vin-scraper.js * Example: node emex-vin-scraper.js WBAEY710X5FT11264 */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); // Configuration const CONFIG = { baseUrl: 'https://emexdwc.ae', searchUrl: 'https://emexdwc.ae/Search.aspx', timeout: 60000, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', // DataImpulse Proxy Configuration proxy: { enabled: process.env.EMEX_USE_PROXY === 'true' || false, host: '74.81.81.81', portStart: 10000, portEnd: 10099, username: '1726bbe361918676d44e', password: 'f11c7b6128cc86c6', }, // Catalog codes for major brands (WMI prefix -> catalog code) catalogMap: { 'WBA': 'BMW202501', // BMW 'WBS': 'BMW202501', // BMW M 'WBY': 'BMW202501', // BMW i 'WDB': 'MB201810', // Mercedes-Benz 'WDD': 'MB201810', // Mercedes-Benz 'WDC': 'MB201810', // Mercedes-Benz 'WDF': 'MB201810', // Mercedes-Benz 'WAU': 'AU1587', // Audi 'WVW': 'VW1587', // Volkswagen 'WVG': 'VW1587', // Volkswagen 'VF1': 'RENAULT201910', // Renault 'VF7': 'CPSA01', // Peugeot 'VF3': 'CPSA01', // Peugeot 'ZFA': 'CFIAT84', // Fiat 'ZAR': 'RFIAT84', // Alfa Romeo 'WF0': 'FORD202201', // Ford 'NM0': 'FORD202201', // Ford Turkey 'JTD': 'TOYOTA00', // Toyota 'JTE': 'TOYOTA00', // Toyota 'SHH': 'HONDA00', // Honda 'KNM': 'HYUNDAI00', // Hyundai 'KNA': 'KIA00', // Kia 'JF1': 'SUBARU201802', // Subaru 'JF2': 'SUBARU201802', // Subaru 'WP0': 'PO799', // Porsche 'WP1': 'PO799', // Porsche (Cayenne/Macan) 'JMZ': 'MAZDA2020', // Mazda (Japan/EU) 'JM1': 'MAZDA2020', // Mazda (Japan/US) 'JM3': 'MAZDA2020', // Mazda (Japan/US SUVs) } }; // Get catalog code from VIN function getCatalogCode(vin) { const wmi = vin.substring(0, 3); return CONFIG.catalogMap[wmi] || null; } // Parse year from VIN (10th character) function getYearFromVIN(vin) { const yearChar = vin.charAt(9); const yearMap = { '1': 2001, '2': 2002, '3': 2003, '4': 2004, '5': 2005, '6': 2006, '7': 2007, '8': 2008, '9': 2009, 'A': 2010, 'B': 2011, 'C': 2012, 'D': 2013, 'E': 2014, 'F': 2015, 'G': 2016, 'H': 2017, 'J': 2018, 'K': 2019, 'L': 2020, 'M': 2021, 'N': 2022, 'P': 2023, 'R': 2024, 'S': 2025, 'T': 2026, 'V': 2027, 'W': 2028, 'X': 2029, 'Y': 2030 }; return yearMap[yearChar.toUpperCase()] || null; } // Get random proxy port function getRandomProxyPort() { const { portStart, portEnd } = CONFIG.proxy; return Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart; } // Main scraper class class EmexVinScraper { /** * @param {object} [options] * @param {import('playwright').Page} [options.page] - Pre-created page (managed mode) * @param {boolean} [options.useProxy] * @param {number} [options.proxyPort] */ constructor(options = {}) { this.browser = null; this.context = null; this.page = options.page || null; this.managed = !!options.page; // true when NestJS provides the page this.sessionCookie = null; this.useProxy = options.useProxy !== undefined ? options.useProxy : CONFIG.proxy.enabled; this.proxyPort = options.proxyPort || getRandomProxyPort(); } async init() { // In managed mode the page is already provided — skip browser launch if (this.managed) { this._setupResponseCapture(); return; } console.log('Initializing browser...'); const launchOptions = { headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', ], }; // Add proxy if enabled if (this.useProxy) { const proxyUrl = `http://${CONFIG.proxy.host}:${this.proxyPort}`; launchOptions.proxy = { server: proxyUrl, username: CONFIG.proxy.username, password: CONFIG.proxy.password, }; console.log(`Using proxy: ${proxyUrl}`); } this.browser = await chromium.launch(launchOptions); this.context = await this.browser.newContext({ viewport: { width: 1920, height: 1080 }, userAgent: CONFIG.userAgent, }); this.page = await this.context.newPage(); this._setupResponseCapture(); console.log('Browser initialized.'); } _setupResponseCapture() { // Capture network responses this.apiResponses = []; this.page.on('response', async (response) => { const url = response.url(); if (url.includes('/api/') && url.includes('.svc/')) { try { const json = await response.json(); this.apiResponses.push({ url: url, data: json }); } catch (e) { // Not JSON } } }); } async close() { // In managed mode, don't close anything — the caller owns the page if (this.managed) return; if (this.browser) { await this.browser.close(); } } // Search by VIN using multiple strategies async searchByVIN(vin) { console.log(`\nSearching for VIN: ${vin}`); const catalogCode = getCatalogCode(vin); console.log(`Detected catalog: ${catalogCode || 'NONE (will use VIN URL fallback)'}`); // First, establish a session console.log('Establishing session...'); await this.page.goto(CONFIG.baseUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout }); // Get session cookie const cookies = await this.page.context().cookies(); this.sessionCookie = cookies.find(c => c.name === 'ASP.NET_SessionId')?.value || ''; console.log(`Session: ${this.sessionCookie ? 'established' : 'none'}`); // Strategy 1: Try API-based VIN search (requires catalog code) if (catalogCode) { console.log('\nTrying API-based VIN search...'); const apiResult = await this.tryAPIVinSearch(vin, catalogCode); if (apiResult && apiResult.success) { return apiResult; } } // Strategy 2: Try direct VIN URL search (works without catalog code) console.log('\nTrying direct VIN URL search...'); const vinUrlResult = await this.searchViaVinUrl(vin); if (vinUrlResult && vinUrlResult.success) { return vinUrlResult; } // Strategy 3: Fall back to wizard-based approach (requires catalog code) if (catalogCode) { console.log('\nVIN URL search failed, trying wizard approach...'); return await this.searchViaWizard(vin, catalogCode); } // No catalog code and VIN URL failed return this.createBasicResponse(vin, catalogCode || 'UNKNOWN', 'Brand not in catalog, VIN URL search returned no results'); } // Try API-based VIN search async tryAPIVinSearch(vin, catalogCode) { // First get catalog info to check VIN support const catalogInfo = await this.page.evaluate(async (code) => { try { const res = await fetch(`/api/Catalog.svc/GetCatalogInfo?catalogCode=${code}&_tstamp=${Date.now()}`, { headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' } }); if (res.ok) { return await res.json(); } } catch (e) { console.log('Catalog info error:', e.message); } return null; }, catalogCode); console.log('Catalog info:', JSON.stringify(catalogInfo, null, 2)); if (!catalogInfo) { return { success: false, error: 'Could not get catalog info' }; } // Try various VIN search API endpoints const vinSearchResults = await this.page.evaluate(async ({ code, vinNumber }) => { const endpoints = [ `/api/Catalog.svc/FindVehicleByVin?catalogCode=${code}&vin=${vinNumber}`, `/api/Catalog.svc/FindByVin?catalogCode=${code}&vin=${vinNumber}`, `/api/Catalog.svc/SearchByVin?catalogCode=${code}&vin=${vinNumber}`, `/api/Catalog.svc/GetVehicleByVin?catalogCode=${code}&vin=${vinNumber}`, `/api/Catalog.svc/FindVehiclesByVIN?catalogCode=${code}&vin=${vinNumber}`, `/api/Catalog.svc/IdentifyByVin?catalogCode=${code}&vin=${vinNumber}` ]; for (const endpoint of endpoints) { try { const res = await fetch(endpoint + `&_tstamp=${Date.now()}`, { headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' } }); if (res.ok) { const data = await res.json(); if (data && (data.length > 0 || Object.keys(data).length > 0)) { return { endpoint, data }; } } } catch (e) { // Continue to next endpoint } } return null; }, { code: catalogCode, vinNumber: vin }); if (vinSearchResults) { console.log(`VIN search successful via: ${vinSearchResults.endpoint}`); return this.formatApiResponse(vinSearchResults.data, vin, catalogCode); } return { success: false, error: 'No VIN search API found' }; } // Search via direct VIN URL (mimics Search.aspx VIN tab behavior) async searchViaVinUrl(vin) { const vinUrl = `${CONFIG.baseUrl}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`; console.log(`Navigating to: ${vinUrl}`); await this.page.goto(vinUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout }); await new Promise(r => setTimeout(r, 2000)); // Extract vehicle links from the results page const vehicleLinks = await this.page.evaluate(() => { const results = []; // Look for links to Vehicle.aspx (vehicle detail pages) const links = document.querySelectorAll('a[href*="Vehicle.aspx"], a[href*="QuickGroups.aspx"]'); links.forEach(link => { const href = link.href; const name = link.textContent.trim(); if (name && !results.some(r => r.name === name)) { // Extract catalog code from URL const cMatch = href.match(/[?&]c=([^&]+)/); const ssdMatch = href.match(/[?&]ssd=([^&]+)/); results.push({ name, href, catalogCode: cMatch ? cMatch[1] : '', ssd: ssdMatch ? decodeURIComponent(ssdMatch[1]) : '', isQuickGroups: href.includes('QuickGroups.aspx'), isVehicle: href.includes('Vehicle.aspx'), }); } }); return results; }); console.log(`Found ${vehicleLinks.length} vehicle links via VIN URL`); if (vehicleLinks.length === 0) { return { success: false, error: 'No results from VIN URL search' }; } // Use the first vehicle result const firstVehicle = vehicleLinks[0]; const catalogCode = firstVehicle.catalogCode || getCatalogCode(vin); console.log(`Vehicle: ${firstVehicle.name}, Catalog: ${catalogCode}`); // Build QuickGroups URL from the vehicle link params let quickGroupsUrl = null; const qgLink = vehicleLinks.find(l => l.isQuickGroups); if (qgLink) { quickGroupsUrl = qgLink.href; } else if (firstVehicle.isVehicle && firstVehicle.ssd) { // Construct QuickGroups URL from Vehicle.aspx params const vidMatch = firstVehicle.href.match(/[?&]vid=([^&]+)/); const vid = vidMatch ? vidMatch[1] : '0'; quickGroupsUrl = `${CONFIG.baseUrl}/QuickGroups.aspx?c=${catalogCode}&vid=${vid}&ssd=${encodeURIComponent(firstVehicle.ssd)}`; } console.log(`QuickGroups URL: ${quickGroupsUrl ? 'found' : 'none'}`); return { success: true, source: 'emexdwc.ae', method: 'vin_url', vin: vin, catalogCode: catalogCode, ssd: firstVehicle.ssd, vehicle: { brand: this.extractBrandFromCatalog(catalogCode), model: firstVehicle.name || 'Unknown', year: getYearFromVIN(vin), series: null, bodyType: null, engineCode: null, engineType: null, engineVolume: null, transmission: null, driveType: null, }, allVehicles: vehicleLinks.map(v => ({ name: v.name, engine: '', options: '', quickGroupsUrl: v.isQuickGroups ? v.href : '' })), quickGroupsUrl: quickGroupsUrl, timestamp: new Date().toISOString() }; } // Search via wizard approach - navigate step by step async searchViaWizard(vin, catalogCode) { console.log('\nStarting wizard-based search...'); // Get initial wizard data const wizardData = await this.navigateWizard(catalogCode, vin); if (wizardData.vehicles && wizardData.vehicles.length > 0) { // Found vehicles, get details return this.formatWizardResponse(wizardData, vin, catalogCode); } // If still no results, try navigating to Vehicles.aspx page console.log('\nTrying HTML-based vehicle search...'); return await this.searchViaHTML(vin, catalogCode, wizardData.ssd); } // Navigate wizard to get vehicle configurations async navigateWizard(catalogCode, vin) { let ssd = ''; let wizardSteps = []; let finalSSD = ''; // Get initial wizard steps const initialSteps = await this.page.evaluate(async (code) => { try { const res = await fetch(`/api/Catalog.svc/GetWizard2?catalogCode=${code}&ssd=&_tstamp=${Date.now()}`, { headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' } }); if (res.ok) { return await res.json(); } } catch (e) { console.log('Wizard error:', e.message); } return null; }, catalogCode); console.log('Initial wizard steps:', initialSteps?.length || 0); if (!initialSteps || initialSteps.length === 0) { return { ssd: '', vehicles: [] }; } wizardSteps = initialSteps; // Try to find a matching model based on VIN (if pattern is recognizable) // For BMW, the 4th-8th characters indicate model const modelCode = vin.substring(3, 7); console.log(`Model code from VIN: ${modelCode}`); // Navigate wizard by selecting options for (let i = 0; i < 5; i++) { // Max 5 iterations const currentStep = wizardSteps.find(s => !s.determined && s.options?.length > 0); if (!currentStep) { console.log('All steps determined or no more options'); break; } console.log(`\nWizard Step: ${currentStep.name}`); console.log(`Options: ${currentStep.options.length}`); // Find best matching option let selectedOption = currentStep.options[0]; // Default to first // Try to match based on VIN patterns // For BMW VIN WBAEY710X5FT11264: // WBA = BMW AG, E = 3 Series, Y7 = body type (sedan E90), 10 = engine const stepNameLower = currentStep.name.toLowerCase(); if (stepNameLower.includes('series') || stepNameLower.includes('model')) { // BMW VIN 4th character: E = 3 series const vinSeriesChar = vin.charAt(3); const seriesMap = { 'A': '1', 'B': '2', 'C': '3', 'D': '4', 'E': '3', 'F': '5', 'G': '6', 'H': '7', 'J': '5', 'K': '6', 'L': '7', 'M': '8', 'N': '2', 'P': 'X', 'R': 'Z', 'S': 'X', 'T': 'X', 'U': 'X', 'V': 'X', 'W': 'i', 'X': 'X', 'Y': 'X', 'Z': 'Z' }; const targetSeries = seriesMap[vinSeriesChar] || '3'; console.log(`VIN char: ${vinSeriesChar}, Target series: ${targetSeries}`); for (const opt of currentStep.options) { const optValue = opt.value.toLowerCase(); // Look for matching series (3', 3 series, E90, E46, etc.) if (optValue.includes(`${targetSeries}'`) || optValue.includes(`${targetSeries} series`) || optValue.includes(`${targetSeries}-series`) || (targetSeries === '3' && (optValue.includes('e90') || optValue.includes('e46') || optValue.includes('f30'))) || optValue.includes('318') || optValue.includes('320') || optValue.includes('325') || optValue.includes('330')) { selectedOption = opt; console.log(`Matched option: ${opt.value}`); break; } } } ssd = selectedOption.key; finalSSD = ssd; console.log(`Selected: ${selectedOption.value}`); // Get next wizard steps wizardSteps = await this.page.evaluate(async ({ code, ssdValue }) => { try { const res = await fetch(`/api/Catalog.svc/GetWizard2?catalogCode=${code}&ssd=${encodeURIComponent(ssdValue)}&_tstamp=${Date.now()}`, { headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' } }); if (res.ok) { return await res.json(); } } catch (e) { console.log('Wizard step error:', e.message); } return []; }, { code: catalogCode, ssdValue: ssd }); // Check if we can list vehicles now const canListVehicles = wizardSteps.some(s => s.allowlistvehicles); if (canListVehicles) { console.log('Can list vehicles now!'); break; } } return { ssd: finalSSD, steps: wizardSteps, vehicles: [] }; } // Search via HTML page parsing async searchViaHTML(vin, catalogCode, ssd) { if (!ssd) { return this.createBasicResponse(vin, catalogCode, 'No SSD obtained from wizard'); } // Navigate to Vehicles.aspx const vehiclesUrl = `${CONFIG.baseUrl}/Vehicles.aspx?ft=findByWizard2&c=${catalogCode}&ssd=${encodeURIComponent(ssd)}`; console.log(`Navigating to: ${vehiclesUrl}`); await this.page.goto(vehiclesUrl, { waitUntil: 'networkidle', timeout: CONFIG.timeout }); await new Promise(r => setTimeout(r, 3000)); // Take a screenshot for debugging await this.page.screenshot({ path: path.join(__dirname, 'vehicles-page.png'), fullPage: true }); console.log('Screenshot saved to vehicles-page.png'); // Extract vehicle data from HTML const vehicles = await this.page.evaluate(() => { const results = []; // Try multiple table selectors const tables = document.querySelectorAll('table'); tables.forEach(table => { const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 2) { const links = row.querySelectorAll('a[href*="QuickGroups"]'); let quickGroupsUrl = ''; if (links.length > 0) { quickGroupsUrl = links[0].href; } const name = cells[0]?.textContent?.trim() || ''; const engine = cells[1]?.textContent?.trim() || ''; const options = cells[2]?.textContent?.trim() || ''; if (name || engine) { results.push({ name, engine, options, quickGroupsUrl }); } } }); }); // If no table found, try other selectors if (results.length === 0) { const vehicleElements = document.querySelectorAll('[class*="vehicle"], [class*="car"], [data-vehicle]'); vehicleElements.forEach(el => { results.push({ name: el.textContent?.trim()?.substring(0, 100) || '', engine: '', options: '', quickGroupsUrl: '' }); }); } return results; }); console.log(`Found ${vehicles.length} vehicles on page`); if (vehicles.length > 0) { return this.formatHtmlResponse(vehicles, vin, catalogCode, ssd); } // Get page HTML for debugging const pageHtml = await this.page.content(); fs.writeFileSync(path.join(__dirname, 'vehicles-page.html'), pageHtml); console.log('HTML saved to vehicles-page.html'); return this.createBasicResponse(vin, catalogCode, 'No vehicles found in HTML'); } // Format API response formatApiResponse(apiData, vin, catalogCode) { const data = Array.isArray(apiData) ? apiData[0] : apiData; return { success: true, source: 'emexdwc.ae', method: 'api', vin: vin, catalogCode: catalogCode, vehicle: { brand: this.extractBrandFromCatalog(catalogCode), model: data.name || data.model || data.description || 'Unknown', year: data.year || getYearFromVIN(vin), series: data.series || null, bodyType: data.bodyType || data.vehicleType || null, engineCode: data.engine || data.engineCode || null, engineType: data.engineType || data.fuelType || null, engineVolume: data.engineVolume || data.displacement || null, transmission: data.transmission || data.gearbox || null, driveType: data.driveType || null, }, rawResponse: apiData, timestamp: new Date().toISOString() }; } // Format wizard response formatWizardResponse(wizardData, vin, catalogCode) { return { success: true, source: 'emexdwc.ae', method: 'wizard', vin: vin, catalogCode: catalogCode, ssd: wizardData.ssd, vehicle: { brand: this.extractBrandFromCatalog(catalogCode), model: 'Multiple configurations found', year: getYearFromVIN(vin), }, wizardSteps: wizardData.steps, timestamp: new Date().toISOString() }; } // Format HTML-parsed response formatHtmlResponse(vehicles, vin, catalogCode, ssd) { const vehicle = vehicles[0] || {}; // Parse engine code and options const engineMatch = vehicle.engine?.match(/([A-Z0-9]+)/i); const optionsText = vehicle.options || ''; // Extract options const options = {}; optionsText.split(';').forEach(opt => { const parts = opt.split(':').map(s => s?.trim()); if (parts.length === 2) { options[parts[0].toLowerCase().replace(/\s+/g, '_')] = parts[1]; } }); return { success: true, source: 'emexdwc.ae', method: 'html_parse', vin: vin, catalogCode: catalogCode, ssd: ssd, vehicle: { brand: this.extractBrandFromCatalog(catalogCode), model: vehicle.name || 'Unknown', year: getYearFromVIN(vin), series: null, bodyType: options.vehicle_type || null, engineCode: vehicle.engine || engineMatch?.[1] || null, engineType: options.engine_type || null, engineVolume: null, transmission: options.gearbox_type || null, driveType: null, }, allVehicles: vehicles, quickGroupsUrl: vehicle.quickGroupsUrl || null, parsedOptions: options, timestamp: new Date().toISOString() }; } // Create basic response createBasicResponse(vin, catalogCode, message) { return { success: false, source: 'emexdwc.ae', method: 'fallback', vin: vin, catalogCode: catalogCode, vehicle: { brand: this.extractBrandFromCatalog(catalogCode), model: null, year: getYearFromVIN(vin), }, message: message, timestamp: new Date().toISOString() }; } // Extract brand name from catalog code extractBrandFromCatalog(catalogCode) { const brandMap = { 'BMW202501': 'BMW', 'MB201810': 'Mercedes-Benz', 'AU1587': 'Audi', 'VW1587': 'Volkswagen', 'RENAULT201910': 'Renault', 'CPSA01': 'Peugeot', 'CFIAT84': 'Fiat', 'RFIAT84': 'Alfa Romeo', 'FORD202201': 'Ford', 'TOYOTA00': 'Toyota', 'HONDA00': 'Honda', 'HYUNDAI00': 'Hyundai', 'KIA00': 'Kia', 'PO799': 'Porsche', 'SUBARU201802': 'Subaru', 'MAZDA2020': 'Mazda' }; return brandMap[catalogCode] || catalogCode; } // Get part categories for a vehicle async getCategories(quickGroupsUrl) { if (!quickGroupsUrl) return []; console.log(`\nFetching categories from: ${quickGroupsUrl}`); // domcontentloaded + selector wait shaves ~3-5s vs networkidle (which // waits for analytics/ads to settle and adds nothing for our extraction). await this.page.goto(quickGroupsUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.timeout }); await this.page.waitForSelector('a[href*="QuickDetails.aspx"], a[href*="QuickGroups.aspx"]', { timeout: 8000 }).catch(() => null); // Screenshot for debugging await this.page.screenshot({ path: path.join(__dirname, 'categories-page.png'), fullPage: true }); const categories = await this.page.evaluate(() => { const cats = []; // Method 1: Links to QuickDetails const links = document.querySelectorAll('a[href*="QuickDetails"], a[href*="gid="]'); links.forEach(link => { const href = link.href; const gidMatch = href.match(/gid=(\d+)/); if (gidMatch) { cats.push({ gid: gidMatch[1], name: link.textContent.trim(), url: href }); } }); // Method 2: List items with category data if (cats.length === 0) { const items = document.querySelectorAll('li[data-gid], .category-item, .group-item, [class*="category"]'); items.forEach(item => { const gid = item.dataset?.gid || item.getAttribute('data-gid'); const name = item.textContent?.trim() || ''; if (gid || name) { cats.push({ gid: gid || '', name: name.substring(0, 100), url: null }); } }); } // Method 3: Tables if (cats.length === 0) { const rows = document.querySelectorAll('table tr'); rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 1) { const link = row.querySelector('a'); const gidMatch = link?.href?.match(/gid=(\d+)/); if (gidMatch || cells[0]?.textContent) { cats.push({ gid: gidMatch?.[1] || '', name: cells[0]?.textContent?.trim() || '', url: link?.href || null }); } } }); } return cats; }); console.log(`Found ${categories.length} categories`); return categories; } // Get hierarchical category tree from QuickGroups.aspx // Parses the nested