Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
485 lines
12 KiB
JavaScript
485 lines
12 KiB
JavaScript
/**
|
|
* Structured Data Validation Script
|
|
*
|
|
* Validates JSON-LD structured data in built HTML files
|
|
* Run: node .agents/seo-specialist/validate-structured-data.js
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
import { join, extname } from 'path';
|
|
|
|
const distDir = './dist';
|
|
const errors = [];
|
|
const warnings = [];
|
|
const schemaStats = {};
|
|
|
|
// ANSI color codes for terminal output
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
red: '\x1b[31m',
|
|
cyan: '\x1b[36m',
|
|
bold: '\x1b[1m'
|
|
};
|
|
|
|
function log(message, color = 'reset') {
|
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
}
|
|
|
|
/**
|
|
* Recursively find all HTML files in dist directory
|
|
*/
|
|
function findHtmlFiles(dir) {
|
|
const files = [];
|
|
|
|
try {
|
|
const items = readdirSync(dir);
|
|
|
|
for (const item of items) {
|
|
const fullPath = join(dir, item);
|
|
const stat = statSync(fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
files.push(...findHtmlFiles(fullPath));
|
|
} else if (extname(item) === '.html') {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
errors.push(`Error reading directory ${dir}: ${err.message}`);
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Extract and validate JSON-LD schemas from HTML content
|
|
*/
|
|
function extractSchemas(html, filePath) {
|
|
const schemaRegex = /<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
|
|
const matches = [...html.matchAll(schemaRegex)];
|
|
const schemas = [];
|
|
|
|
for (const match of matches) {
|
|
try {
|
|
const jsonContent = match[1].trim();
|
|
const schema = JSON.parse(jsonContent);
|
|
schemas.push(schema);
|
|
|
|
// Track schema types
|
|
const type = schema['@type'];
|
|
if (type) {
|
|
schemaStats[type] = (schemaStats[type] || 0) + 1;
|
|
}
|
|
|
|
} catch (err) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'PARSE_ERROR',
|
|
message: `Invalid JSON-LD: ${err.message}`,
|
|
content: match[1].substring(0, 100) + '...'
|
|
});
|
|
}
|
|
}
|
|
|
|
return schemas;
|
|
}
|
|
|
|
/**
|
|
* Validate Organization schema
|
|
*/
|
|
function validateOrganization(schema, filePath) {
|
|
const required = ['@context', '@type', 'name', 'url'];
|
|
const recommended = ['logo', 'description', 'contactPoint', 'sameAs', 'address'];
|
|
|
|
// Check required fields
|
|
for (const field of required) {
|
|
if (!schema[field]) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'Organization',
|
|
field
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check recommended fields
|
|
for (const field of recommended) {
|
|
if (!schema[field]) {
|
|
warnings.push({
|
|
file: filePath,
|
|
type: 'MISSING_RECOMMENDED',
|
|
schema: 'Organization',
|
|
field
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate logo is ImageObject
|
|
if (schema.logo && typeof schema.logo === 'object') {
|
|
if (!schema.logo['@type'] || schema.logo['@type'] !== 'ImageObject') {
|
|
warnings.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'Organization',
|
|
message: 'Logo should be an ImageObject'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate BlogPosting schema
|
|
*/
|
|
function validateBlogPosting(schema, filePath) {
|
|
const required = ['@context', '@type', 'headline', 'author', 'datePublished', 'publisher'];
|
|
const recommended = ['image', 'dateModified', 'mainEntityOfPage', 'keywords'];
|
|
|
|
for (const field of required) {
|
|
if (!schema[field]) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'BlogPosting',
|
|
field
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const field of recommended) {
|
|
if (!schema[field]) {
|
|
warnings.push({
|
|
file: filePath,
|
|
type: 'MISSING_RECOMMENDED',
|
|
schema: 'BlogPosting',
|
|
field
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate author is Person
|
|
if (schema.author && typeof schema.author === 'object') {
|
|
if (!schema.author['@type'] || schema.author['@type'] !== 'Person') {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'BlogPosting',
|
|
message: 'Author must be a Person schema'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate publisher is Organization with logo
|
|
if (schema.publisher && typeof schema.publisher === 'object') {
|
|
if (!schema.publisher['@type'] || schema.publisher['@type'] !== 'Organization') {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'BlogPosting',
|
|
message: 'Publisher must be an Organization schema'
|
|
});
|
|
}
|
|
if (!schema.publisher.logo) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'BlogPosting',
|
|
message: 'Publisher must have a logo'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate BreadcrumbList schema
|
|
*/
|
|
function validateBreadcrumbList(schema, filePath) {
|
|
if (!schema.itemListElement || !Array.isArray(schema.itemListElement)) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'BreadcrumbList',
|
|
field: 'itemListElement (must be array)'
|
|
});
|
|
return;
|
|
}
|
|
|
|
schema.itemListElement.forEach((item, index) => {
|
|
if (!item['@type'] || item['@type'] !== 'ListItem') {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'BreadcrumbList',
|
|
message: `Item ${index} must be ListItem`
|
|
});
|
|
}
|
|
|
|
if (!item.position) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'BreadcrumbList',
|
|
message: `Item ${index} missing position`
|
|
});
|
|
}
|
|
|
|
if (!item.name) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'BreadcrumbList',
|
|
message: `Item ${index} missing name`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate FAQPage schema
|
|
*/
|
|
function validateFAQPage(schema, filePath) {
|
|
if (!schema.mainEntity || !Array.isArray(schema.mainEntity)) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'FAQPage',
|
|
field: 'mainEntity (must be array)'
|
|
});
|
|
return;
|
|
}
|
|
|
|
schema.mainEntity.forEach((item, index) => {
|
|
if (!item['@type'] || item['@type'] !== 'Question') {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'FAQPage',
|
|
message: `Item ${index} must be Question`
|
|
});
|
|
}
|
|
|
|
if (!item.name) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'FAQPage',
|
|
message: `Question ${index} missing name`
|
|
});
|
|
}
|
|
|
|
if (!item.acceptedAnswer) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: 'FAQPage',
|
|
message: `Question ${index} missing acceptedAnswer`
|
|
});
|
|
} else if (!item.acceptedAnswer['@type'] || item.acceptedAnswer['@type'] !== 'Answer') {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'INVALID_TYPE',
|
|
schema: 'FAQPage',
|
|
message: `Question ${index} acceptedAnswer must be Answer`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate schema based on type
|
|
*/
|
|
function validateSchema(schema, filePath) {
|
|
const type = schema['@type'];
|
|
|
|
switch (type) {
|
|
case 'Organization':
|
|
validateOrganization(schema, filePath);
|
|
break;
|
|
case 'BlogPosting':
|
|
validateBlogPosting(schema, filePath);
|
|
break;
|
|
case 'BreadcrumbList':
|
|
validateBreadcrumbList(schema, filePath);
|
|
break;
|
|
case 'FAQPage':
|
|
validateFAQPage(schema, filePath);
|
|
break;
|
|
// Other schema types can pass through without specific validation
|
|
default:
|
|
// Just check for @context and @type
|
|
if (!schema['@context']) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_REQUIRED',
|
|
schema: type,
|
|
field: '@context'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for Open Graph tags
|
|
*/
|
|
function validateOpenGraph(html, filePath) {
|
|
const requiredOgTags = ['og:title', 'og:description', 'og:url', 'og:image'];
|
|
|
|
for (const tag of requiredOgTags) {
|
|
const regex = new RegExp(`<meta\\s+property="${tag}"`, 'i');
|
|
if (!regex.test(html)) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_OG_TAG',
|
|
tag
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for Twitter Card tags
|
|
*/
|
|
function validateTwitterCards(html, filePath) {
|
|
const requiredTwitterTags = ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image'];
|
|
|
|
for (const tag of requiredTwitterTags) {
|
|
const regex = new RegExp(`<meta\\s+name="${tag}"`, 'i');
|
|
if (!regex.test(html)) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'MISSING_TWITTER_TAG',
|
|
tag
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main validation function
|
|
*/
|
|
function validateFile(filePath) {
|
|
try {
|
|
const html = readFileSync(filePath, 'utf-8');
|
|
|
|
// Extract and validate JSON-LD schemas
|
|
const schemas = extractSchemas(html, filePath);
|
|
|
|
for (const schema of schemas) {
|
|
validateSchema(schema, filePath);
|
|
}
|
|
|
|
// Validate Open Graph
|
|
validateOpenGraph(html, filePath);
|
|
|
|
// Validate Twitter Cards
|
|
validateTwitterCards(html, filePath);
|
|
|
|
return schemas.length;
|
|
|
|
} catch (err) {
|
|
errors.push({
|
|
file: filePath,
|
|
type: 'FILE_ERROR',
|
|
message: err.message
|
|
});
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Print validation report
|
|
*/
|
|
function printReport(filesProcessed, totalSchemas) {
|
|
console.log('\n' + '='.repeat(80));
|
|
log('STRUCTURED DATA VALIDATION REPORT', 'bold');
|
|
console.log('='.repeat(80) + '\n');
|
|
|
|
log(`Files Processed: ${filesProcessed}`, 'cyan');
|
|
log(`Total Schemas Found: ${totalSchemas}`, 'cyan');
|
|
console.log();
|
|
|
|
// Schema type breakdown
|
|
log('Schema Types:', 'bold');
|
|
for (const [type, count] of Object.entries(schemaStats).sort((a, b) => b[1] - a[1])) {
|
|
log(` ${type}: ${count}`, 'cyan');
|
|
}
|
|
console.log();
|
|
|
|
// Errors
|
|
if (errors.length > 0) {
|
|
log(`❌ ERRORS (${errors.length}):`, 'red');
|
|
errors.forEach((error, index) => {
|
|
console.log(`\n${index + 1}. ${error.file || 'Unknown'}`);
|
|
log(` Type: ${error.type}`, 'red');
|
|
if (error.schema) log(` Schema: ${error.schema}`, 'red');
|
|
if (error.field) log(` Field: ${error.field}`, 'red');
|
|
if (error.tag) log(` Tag: ${error.tag}`, 'red');
|
|
if (error.message) log(` Message: ${error.message}`, 'red');
|
|
if (error.content) log(` Content: ${error.content}`, 'red');
|
|
});
|
|
console.log();
|
|
}
|
|
|
|
// Warnings
|
|
if (warnings.length > 0) {
|
|
log(`⚠️ WARNINGS (${warnings.length}):`, 'yellow');
|
|
warnings.forEach((warning, index) => {
|
|
console.log(`\n${index + 1}. ${warning.file || 'Unknown'}`);
|
|
log(` Type: ${warning.type}`, 'yellow');
|
|
if (warning.schema) log(` Schema: ${warning.schema}`, 'yellow');
|
|
if (warning.field) log(` Field: ${warning.field}`, 'yellow');
|
|
if (warning.message) log(` Message: ${warning.message}`, 'yellow');
|
|
});
|
|
console.log();
|
|
}
|
|
|
|
// Summary
|
|
console.log('='.repeat(80));
|
|
if (errors.length === 0 && warnings.length === 0) {
|
|
log('✅ ALL VALIDATIONS PASSED!', 'green');
|
|
} else if (errors.length === 0) {
|
|
log('✅ NO ERRORS (but some warnings)', 'green');
|
|
} else {
|
|
log('❌ VALIDATION FAILED', 'red');
|
|
}
|
|
console.log('='.repeat(80) + '\n');
|
|
|
|
// Exit code
|
|
process.exit(errors.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
/**
|
|
* Main execution
|
|
*/
|
|
function main() {
|
|
log('\n🔍 Starting Structured Data Validation...\n', 'cyan');
|
|
|
|
// Check if dist directory exists
|
|
try {
|
|
statSync(distDir);
|
|
} catch (err) {
|
|
log('❌ Error: dist directory not found. Run `npm run build` first.', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Find all HTML files
|
|
const htmlFiles = findHtmlFiles(distDir);
|
|
log(`Found ${htmlFiles.length} HTML files to validate\n`, 'cyan');
|
|
|
|
// Validate each file
|
|
let totalSchemas = 0;
|
|
for (const file of htmlFiles) {
|
|
const schemaCount = validateFile(file);
|
|
totalSchemas += schemaCount;
|
|
log(`✓ ${file} (${schemaCount} schemas)`, 'green');
|
|
}
|
|
|
|
// Print report
|
|
printReport(htmlFiles.length, totalSchemas);
|
|
}
|
|
|
|
// Run validation
|
|
main();
|