#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { DOMParser } = require('xmldom'); /** * Validates products.xml against Google Shopping RSS 2.0 requirements */ class ProductsXmlValidator { constructor(xmlFilePath) { this.xmlFilePath = xmlFilePath; this.errors = []; this.warnings = []; this.stats = { totalItems: 0, validItems: 0, invalidItems: 0 }; } addError(message, itemId = null) { this.errors.push({ message, itemId, type: 'error' }); } addWarning(message, itemId = null) { this.warnings.push({ message, itemId, type: 'warning' }); } validateXmlStructure(xmlContent) { try { const parser = new DOMParser({ errorHandler: { warning: (msg) => this.addWarning(`XML Warning: ${msg}`), error: (msg) => this.addError(`XML Error: ${msg}`), fatalError: (msg) => this.addError(`XML Fatal Error: ${msg}`) } }); const doc = parser.parseFromString(xmlContent, 'text/xml'); // Check for parsing errors const parserErrors = doc.getElementsByTagName('parsererror'); if (parserErrors.length > 0) { this.addError('XML parsing failed - invalid XML structure'); return null; } return doc; } catch (error) { this.addError(`Failed to parse XML: ${error.message}`); return null; } } validateRootStructure(doc) { // Check RSS root element const rssElement = doc.getElementsByTagName('rss')[0]; if (!rssElement) { this.addError('Missing required root element'); return false; } // Check RSS version const version = rssElement.getAttribute('version'); if (version !== '2.0') { this.addError(`Invalid RSS version: expected "2.0", got "${version}"`); } // Check Google namespace const googleNamespace = rssElement.getAttribute('xmlns:g'); if (googleNamespace !== 'http://base.google.com/ns/1.0') { this.addError(`Missing or invalid Google namespace: expected "http://base.google.com/ns/1.0", got "${googleNamespace}"`); } // Check channel element const channelElement = doc.getElementsByTagName('channel')[0]; if (!channelElement) { this.addError('Missing required element'); return false; } return true; } validateChannelInfo(doc) { const channel = doc.getElementsByTagName('channel')[0]; const requiredChannelElements = ['title', 'link', 'description']; requiredChannelElements.forEach(elementName => { const element = channel.getElementsByTagName(elementName)[0]; if (!element || !element.textContent.trim()) { this.addError(`Missing or empty required channel element: <${elementName}>`); } }); // Check language const language = channel.getElementsByTagName('language')[0]; if (!language || !language.textContent.trim()) { this.addWarning('Missing element in channel'); } else if (!language.textContent.match(/^[a-z]{2}(-[A-Z]{2})?$/)) { this.addWarning(`Invalid language format: ${language.textContent} (should be like "de-DE")`); } } validateItem(item, index) { const itemId = this.getItemId(item, index); this.stats.totalItems++; // Required Google Shopping attributes const requiredAttributes = [ 'g:id', 'g:title', 'g:description', 'g:link', 'g:image_link', 'g:condition', 'g:availability', 'g:price' ]; let hasErrors = false; requiredAttributes.forEach(attr => { const element = item.getElementsByTagName(attr)[0]; if (!element || !element.textContent.trim()) { this.addError(`Missing required attribute: <${attr}>`, itemId); hasErrors = true; } }); // Validate specific attribute formats this.validatePrice(item, itemId); this.validateCondition(item, itemId); this.validateAvailability(item, itemId); this.validateUrls(item, itemId); this.validateGtin(item, itemId); this.validateShippingWeight(item, itemId); if (hasErrors) { this.stats.invalidItems++; } else { this.stats.validItems++; } } getItemId(item, index) { const idElement = item.getElementsByTagName('g:id')[0]; return idElement ? idElement.textContent.trim() : `item-${index + 1}`; } validatePrice(item, itemId) { const priceElement = item.getElementsByTagName('g:price')[0]; if (priceElement) { const priceText = priceElement.textContent.trim(); // Price should be in format "XX.XX EUR" or similar if (!priceText.match(/^\d+(\.\d{2})?\s+[A-Z]{3}$/)) { this.addError(`Invalid price format: "${priceText}" (should be "XX.XX EUR")`, itemId); } } } validateCondition(item, itemId) { const conditionElement = item.getElementsByTagName('g:condition')[0]; if (conditionElement) { const condition = conditionElement.textContent.trim(); const validConditions = ['new', 'refurbished', 'used']; if (!validConditions.includes(condition)) { this.addError(`Invalid condition: "${condition}" (must be: ${validConditions.join(', ')})`, itemId); } } } validateAvailability(item, itemId) { const availabilityElement = item.getElementsByTagName('g:availability')[0]; if (availabilityElement) { const availability = availabilityElement.textContent.trim(); const validAvailability = ['in stock', 'out of stock', 'preorder', 'backorder']; if (!validAvailability.includes(availability)) { this.addError(`Invalid availability: "${availability}" (must be: ${validAvailability.join(', ')})`, itemId); } } } validateUrls(item, itemId) { const urlElements = ['g:link', 'g:image_link']; urlElements.forEach(elementName => { const element = item.getElementsByTagName(elementName)[0]; if (element) { const url = element.textContent.trim(); try { new URL(url); if (!url.startsWith('https://')) { this.addWarning(`URL should use HTTPS: ${url}`, itemId); } } catch (error) { this.addError(`Invalid URL in <${elementName}>: ${url}`, itemId); } } }); } validateGtin(item, itemId) { const gtinElement = item.getElementsByTagName('g:gtin')[0]; if (gtinElement) { const gtin = gtinElement.textContent.trim(); // GTIN should be 8, 12, 13, or 14 digits if (!gtin.match(/^\d{8}$|^\d{12,14}$/)) { this.addError(`Invalid GTIN format: "${gtin}" (should be 8, 12, 13, or 14 digits)`, itemId); } } else { this.addWarning(`Missing GTIN - recommended for better product matching`, itemId); } } validateShippingWeight(item, itemId) { const weightElement = item.getElementsByTagName('g:shipping_weight')[0]; if (weightElement) { const weight = weightElement.textContent.trim(); // Weight should be in format "XX.XX g" or similar if (!weight.match(/^\d+(\.\d+)?\s+[a-zA-Z]+$/)) { this.addError(`Invalid shipping weight format: "${weight}" (should be "XX.XX g")`, itemId); } } else { this.addWarning(`Missing shipping weight`, itemId); } } validateGoogleProductCategory(item, itemId) { const categoryElement = item.getElementsByTagName('g:google_product_category')[0]; if (categoryElement) { const category = categoryElement.textContent.trim(); // Should be a numeric category ID if (!category.match(/^\d+$/)) { this.addError(`Invalid Google product category: "${category}" (should be numeric)`, itemId); } } } async validate() { console.log(`šŸ” Validating products.xml: ${this.xmlFilePath}`); // Check if file exists if (!fs.existsSync(this.xmlFilePath)) { this.addError(`File not found: ${this.xmlFilePath}`); return this.getResults(); } // Read and parse XML const xmlContent = fs.readFileSync(this.xmlFilePath, 'utf8'); const doc = this.validateXmlStructure(xmlContent); if (!doc) { return this.getResults(); } // Validate root structure if (!this.validateRootStructure(doc)) { return this.getResults(); } // Validate channel information this.validateChannelInfo(doc); // Validate all items const items = doc.getElementsByTagName('item'); console.log(`šŸ“¦ Found ${items.length} product items to validate`); for (let i = 0; i < items.length; i++) { this.validateItem(items[i], i); } return this.getResults(); } getResults() { const hasErrors = this.errors.length > 0; const hasWarnings = this.warnings.length > 0; return { valid: !hasErrors, stats: this.stats, errors: this.errors, warnings: this.warnings, summary: { totalIssues: this.errors.length + this.warnings.length, errorCount: this.errors.length, warningCount: this.warnings.length, validationPassed: !hasErrors } }; } printResults(results) { console.log('\nšŸ“Š Validation Results:'); console.log(` - Total items: ${results.stats.totalItems}`); console.log(` - Valid items: ${results.stats.validItems}`); console.log(` - Invalid items: ${results.stats.invalidItems}`); if (results.errors.length > 0) { console.log(`\nāŒ Errors (${results.errors.length}):`); results.errors.forEach((error, index) => { const itemInfo = error.itemId ? ` [${error.itemId}]` : ''; console.log(` ${index + 1}. ${error.message}${itemInfo}`); }); } if (results.warnings.length > 0) { console.log(`\nāš ļø Warnings (${results.warnings.length}):`); results.warnings.slice(0, 10).forEach((warning, index) => { const itemInfo = warning.itemId ? ` [${warning.itemId}]` : ''; console.log(` ${index + 1}. ${warning.message}${itemInfo}`); }); if (results.warnings.length > 10) { console.log(` ... and ${results.warnings.length - 10} more warnings`); } } if (results.valid) { console.log('\nāœ… Validation passed! products.xml is valid for Google Shopping.'); } else { console.log('\nāŒ Validation failed! Please fix the errors above.'); } return results.valid; } } // CLI usage if (require.main === module) { const xmlFilePath = process.argv[2] || path.join(__dirname, '../dist/products.xml'); const validator = new ProductsXmlValidator(xmlFilePath); validator.validate().then(results => { const isValid = validator.printResults(results); process.exit(isValid ? 0 : 1); }).catch(error => { console.error('āŒ Validation failed:', error.message); process.exit(1); }); } module.exports = ProductsXmlValidator;