UNPKG

ai-seo

Version:

AI-native JSON-LD schema utility with automated URL-to-Schema generation, intelligent content analysis, caching, rate limiting, performance monitoring, and AI optimization (ChatGPT, Voice). Complete automation & scale features. Zero runtime dependencies.

524 lines (450 loc) 15 kB
/** * Schema Validator - v1.12.0 * Smart validation with Google Rich Results integration * * Features: * - Google Rich Results validation * - Auto-fix suggestions * - Missing field detection * - Best practice recommendations */ export class SchemaValidator { /** * Validate schema with comprehensive checks * @param {Object} schema - Schema to validate * @param {Object} options - Validation options * @returns {Object} Validation results */ static validate(schema, options = {}) { const { strict = false, checkGoogleGuidelines = true, suggestFixes = true } = options; const results = { valid: true, errors: [], warnings: [], suggestions: [], fixes: [], score: 100 }; if (!schema || typeof schema !== 'object') { results.valid = false; results.errors.push('Schema must be a valid object'); results.score = 0; return results; } // Basic required fields this._validateBasicStructure(schema, results, strict); // Type-specific validation if (schema['@type']) { this._validateTypeSpecific(schema, results, strict); } // Google Rich Results guidelines if (checkGoogleGuidelines) { this._validateGoogleGuidelines(schema, results); } // Generate auto-fix suggestions if (suggestFixes) { this._generateFixes(schema, results); } // Calculate final score results.score = this._calculateScore(results); results.valid = results.errors.length === 0; return results; } /** * Validate basic schema structure * @private */ static _validateBasicStructure(schema, results, strict) { // Check @context if (!schema['@context']) { results.errors.push('Missing required @context field'); } else if (schema['@context'] !== 'https://schema.org') { results.warnings.push('@context should be "https://schema.org"'); } // Check @type if (!schema['@type']) { results.errors.push('Missing required @type field'); } // Check name if (!schema.name && !schema.headline) { results.warnings.push('Missing name or headline - important for identification'); } // Check description if (!schema.description) { if (strict) { results.warnings.push('Missing description - recommended for better visibility'); } } } /** * Validate type-specific requirements * @private */ static _validateTypeSpecific(schema, results, strict) { const type = schema['@type']; switch (type) { case 'Product': this._validateProduct(schema, results, strict); break; case 'Article': case 'BlogPosting': case 'NewsArticle': this._validateArticle(schema, results, strict); break; case 'LocalBusiness': case 'Restaurant': case 'Store': this._validateBusiness(schema, results, strict); break; case 'Event': this._validateEvent(schema, results, strict); break; case 'Recipe': this._validateRecipe(schema, results, strict); break; case 'VideoObject': this._validateVideo(schema, results, strict); break; } } /** * Validate Product schema * @private */ static _validateProduct(schema, results, strict) { // Required: offers if (!schema.offers) { results.errors.push('Product: Missing required offers field'); } else { // Validate offers if (!schema.offers.price) { results.warnings.push('Product: Missing price in offers'); } if (!schema.offers.priceCurrency) { results.warnings.push('Product: Missing priceCurrency in offers'); } if (!schema.offers.availability) { results.suggestions.push('Product: Add availability status for better user experience'); } } // Recommended: image if (!schema.image) { results.warnings.push('Product: Missing image - highly recommended'); } // Recommended: brand if (!schema.brand) { results.suggestions.push('Product: Add brand information'); } // Optional but beneficial: ratings if (!schema.aggregateRating && !schema.review) { results.suggestions.push('Product: Consider adding customer ratings or reviews'); } } /** * Validate Article schema * @private */ static _validateArticle(schema, results, strict) { // Required: headline if (!schema.headline && !schema.name) { results.errors.push('Article: Missing required headline or name'); } // Required: image if (!schema.image) { results.warnings.push('Article: Missing image - required for Google Rich Results'); } // Required: datePublished if (!schema.datePublished) { results.warnings.push('Article: Missing datePublished - important for search results'); } // Required: author if (!schema.author) { results.warnings.push('Article: Missing author information'); } // Required: publisher if (!schema.publisher) { results.warnings.push('Article: Missing publisher - required for Google Rich Results'); } else if (schema.publisher && !schema.publisher.logo) { results.suggestions.push('Article: Add publisher logo for better visibility'); } } /** * Validate LocalBusiness schema * @private */ static _validateBusiness(schema, results, strict) { // Required: address if (!schema.address) { results.errors.push('LocalBusiness: Missing required address'); } else { // Validate address completeness if (!schema.address.streetAddress) { results.warnings.push('LocalBusiness: Missing street address'); } if (!schema.address.addressLocality) { results.warnings.push('LocalBusiness: Missing city (addressLocality)'); } if (!schema.address.addressRegion) { results.suggestions.push('LocalBusiness: Add state/region'); } if (!schema.address.postalCode) { results.suggestions.push('LocalBusiness: Add postal code'); } } // Recommended: telephone if (!schema.telephone) { results.warnings.push('LocalBusiness: Add telephone number'); } // Recommended: openingHours if (!schema.openingHoursSpecification && !schema.openingHours) { results.suggestions.push('LocalBusiness: Add opening hours'); } // Recommended: geo if (!schema.geo) { results.suggestions.push('LocalBusiness: Add geographic coordinates (latitude/longitude)'); } } /** * Validate Event schema * @private */ static _validateEvent(schema, results, strict) { // Required: startDate if (!schema.startDate) { results.errors.push('Event: Missing required startDate'); } // Required: location if (!schema.location) { results.errors.push('Event: Missing required location'); } else { if (!schema.location.name && !schema.location.address) { results.warnings.push('Event: Location should have name or address'); } } // Recommended: offers (for ticketed events) if (!schema.offers) { results.suggestions.push('Event: Add ticket/pricing information if applicable'); } // Recommended: eventStatus if (!schema.eventStatus) { results.suggestions.push('Event: Add eventStatus (scheduled, cancelled, etc.)'); } } /** * Validate Recipe schema * @private */ static _validateRecipe(schema, results, strict) { // Required: image if (!schema.image) { results.warnings.push('Recipe: Missing image - highly recommended'); } // Required: recipeIngredient if (!schema.recipeIngredient || schema.recipeIngredient.length === 0) { results.errors.push('Recipe: Missing required ingredients list'); } // Required: recipeInstructions if (!schema.recipeInstructions || schema.recipeInstructions.length === 0) { results.errors.push('Recipe: Missing required instructions'); } // Recommended: prepTime, cookTime if (!schema.prepTime && !schema.totalTime) { results.suggestions.push('Recipe: Add preparation/cooking time'); } // Recommended: nutrition if (!schema.nutrition) { results.suggestions.push('Recipe: Add nutrition information if available'); } } /** * Validate VideoObject schema * @private */ static _validateVideo(schema, results, strict) { // Required: thumbnailUrl if (!schema.thumbnailUrl) { results.warnings.push('VideoObject: Missing thumbnail URL'); } // Required: uploadDate if (!schema.uploadDate) { results.warnings.push('VideoObject: Missing upload date'); } // Required: description if (!schema.description) { results.warnings.push('VideoObject: Missing description'); } // Recommended: contentUrl or embedUrl if (!schema.contentUrl && !schema.embedUrl) { results.suggestions.push('VideoObject: Add contentUrl or embedUrl'); } // Recommended: duration if (!schema.duration) { results.suggestions.push('VideoObject: Add video duration in ISO 8601 format'); } } /** * Validate against Google Rich Results guidelines * @private */ static _validateGoogleGuidelines(schema, results) { const type = schema['@type']; // General guidelines if (!schema.url) { results.suggestions.push('Google: Add canonical URL for this content'); } // Type-specific Google guidelines switch (type) { case 'Product': // Google requires valid price format if (schema.offers && schema.offers.price) { const price = schema.offers.price.toString(); if (!price.match(/^\d+(\.\d{1,2})?$/)) { results.warnings.push('Google: Price should be in decimal format (e.g., "99.99")'); } } break; case 'Article': case 'BlogPosting': // Google requires structured image if (schema.image && typeof schema.image === 'string') { results.suggestions.push('Google: Image should be structured as ImageObject for best results'); } // Google requires specific publisher structure if (schema.publisher && !schema.publisher['@type']) { results.warnings.push('Google: Publisher should have @type: "Organization"'); } break; case 'Recipe': // Google prefers HowToSection or HowToStep for instructions if (schema.recipeInstructions && Array.isArray(schema.recipeInstructions)) { const hasStructuredInstructions = schema.recipeInstructions.some( inst => typeof inst === 'object' && inst['@type'] ); if (!hasStructuredInstructions) { results.suggestions.push('Google: Use structured HowToStep for recipe instructions'); } } break; } } /** * Generate auto-fix suggestions * @private */ static _generateFixes(schema, results) { // Fix missing @context if (!schema['@context']) { results.fixes.push({ field: '@context', value: 'https://schema.org', reason: 'Required field missing' }); } // Fix missing name if (!schema.name && !schema.headline && schema.description) { results.fixes.push({ field: 'name', value: schema.description.substring(0, 50), reason: 'Use truncated description as name' }); } // Type-specific fixes const type = schema['@type']; if (type === 'Product' && schema.offers && !schema.offers['@type']) { results.fixes.push({ field: 'offers.@type', value: 'Offer', reason: 'Offers should have @type: "Offer"' }); } if (type === 'Article' && schema.publisher && typeof schema.publisher === 'string') { results.fixes.push({ field: 'publisher', value: { '@type': 'Organization', name: schema.publisher }, reason: 'Convert publisher string to structured Organization' }); } if (type === 'LocalBusiness' && schema.address && typeof schema.address === 'string') { results.fixes.push({ field: 'address', value: { '@type': 'PostalAddress', streetAddress: schema.address }, reason: 'Convert address string to structured PostalAddress' }); } } /** * Calculate validation score * @private */ static _calculateScore(results) { let score = 100; // Deduct points for errors score -= results.errors.length * 20; // Deduct points for warnings score -= results.warnings.length * 10; // Deduct small amount for suggestions score -= results.suggestions.length * 2; return Math.max(0, score); } /** * Apply auto-fixes to schema * @param {Object} schema - Original schema * @param {Array} fixes - Fixes to apply * @returns {Object} Fixed schema */ static applyFixes(schema, fixes) { const fixed = JSON.parse(JSON.stringify(schema)); // Deep clone for (const fix of fixes) { const parts = fix.field.split('.'); let current = fixed; // Navigate to the field for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) { current[parts[i]] = {}; } current = current[parts[i]]; } // Apply fix const lastPart = parts[parts.length - 1]; current[lastPart] = fix.value; } return fixed; } /** * Get validation summary * @param {Object} results - Validation results * @returns {string} Human-readable summary */ static getSummary(results) { const lines = []; if (results.valid) { lines.push('✓ Schema is valid'); } else { lines.push('✗ Schema has validation issues'); } lines.push(`Score: ${results.score}/100`); if (results.errors.length > 0) { lines.push(`Errors: ${results.errors.length}`); } if (results.warnings.length > 0) { lines.push(`Warnings: ${results.warnings.length}`); } if (results.suggestions.length > 0) { lines.push(`Suggestions: ${results.suggestions.length}`); } if (results.fixes.length > 0) { lines.push(`Auto-fixes available: ${results.fixes.length}`); } return lines.join('\n'); } }