UNPKG

@devmehq/open-graph-extractor

Version:

Fast, lightweight Open Graph, Twitter Card, and structured data extractor for Node.js with caching and validation

491 lines (490 loc) 15.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.validateOpenGraph = validateOpenGraph; exports.validateTwitterCard = validateTwitterCard; exports.generateSocialScore = generateSocialScore; /** * Validate Open Graph data */ function validateOpenGraph(data) { const errors = []; const warnings = []; const recommendations = []; // Check required Open Graph properties if (!data.ogTitle) { errors.push({ code: "OG_MISSING_TITLE", message: "Missing required property: og:title", severity: "critical", field: "ogTitle", suggestion: "Add <meta property='og:title' content='Your Title'>", timestamp: new Date(), }); } if (!data.ogType) { errors.push({ code: "OG_MISSING_TYPE", message: "Missing required property: og:type", severity: "critical", field: "ogType", suggestion: "Add <meta property='og:type' content='website'>", timestamp: new Date(), }); } if (!data.ogImage) { errors.push({ code: "OG_MISSING_IMAGE", message: "Missing required property: og:image", severity: "critical", field: "ogImage", suggestion: "Add <meta property='og:image' content='https://example.com/image.jpg'>", timestamp: new Date(), }); } if (!data.ogUrl) { errors.push({ code: "OG_MISSING_URL", message: "Missing required property: og:url", severity: "critical", field: "ogUrl", suggestion: "Add <meta property='og:url' content='https://example.com/page'>", timestamp: new Date(), }); } // Check recommended properties if (!data.ogDescription) { warnings.push({ code: "OG_MISSING_DESCRIPTION", message: "Missing recommended property: og:description", field: "ogDescription", suggestion: "Add <meta property='og:description' content='Page description'>", }); } if (!data.ogSiteName) { warnings.push({ code: "OG_MISSING_SITE_NAME", message: "Missing recommended property: og:site_name", field: "ogSiteName", suggestion: "Add <meta property='og:site_name' content='Your Site Name'>", }); } // Validate og:type value if (data.ogType && !isValidOGType(data.ogType)) { warnings.push({ code: "OG_INVALID_TYPE", message: `Invalid og:type value: ${data.ogType}`, field: "ogType", suggestion: "Use a valid og:type value like 'website', 'article', 'video', etc.", }); } // Validate image dimensions if (data.ogImage) { const images = Array.isArray(data.ogImage) ? data.ogImage : [data.ogImage]; for (const image of images) { if (typeof image === "object") { if (!image.width || !image.height) { warnings.push({ code: "OG_IMAGE_MISSING_DIMENSIONS", message: "Image missing width or height dimensions", field: "ogImage", suggestion: "Add og:image:width and og:image:height meta tags", }); } else { const width = Number.parseInt(String(image.width), 10); const height = Number.parseInt(String(image.height), 10); if (width < 200 || height < 200) { warnings.push({ code: "OG_IMAGE_TOO_SMALL", message: `Image dimensions too small: ${width}x${height}. Minimum recommended: 200x200`, field: "ogImage", }); } if (width > 5000 || height > 5000) { warnings.push({ code: "OG_IMAGE_TOO_LARGE", message: `Image dimensions too large: ${width}x${height}. Maximum recommended: 5000x5000`, field: "ogImage", }); } } } } } // Add recommendations if (!data.twitterCard) { recommendations.push("Add Twitter Card meta tags for better Twitter sharing"); } if (!data.favicon) { recommendations.push("Add a favicon for better branding"); } if (!data.ogLocale) { recommendations.push("Add og:locale for language specification"); } if (data.ogType === "article" && !data.articlePublishedTime) { recommendations.push("Add article:published_time for article pages"); } if (!data.canonical) { recommendations.push("Add canonical URL to prevent duplicate content issues"); } // Calculate score const score = calculateValidationScore(errors, warnings); return { valid: errors.length === 0, errors, warnings, score, recommendations, }; } /** * Validate Twitter Card data */ function validateTwitterCard(data) { const errors = []; const warnings = []; const recommendations = []; if (!data.twitterCard) { warnings.push({ code: "TWITTER_MISSING_CARD", message: "Missing Twitter Card type", field: "twitterCard", suggestion: "Add <meta name='twitter:card' content='summary_large_image'>", }); } else if (!isValidTwitterCardType(data.twitterCard)) { errors.push({ code: "TWITTER_INVALID_CARD_TYPE", message: `Invalid Twitter Card type: ${data.twitterCard}`, severity: "error", field: "twitterCard", suggestion: "Use a valid type: summary, summary_large_image, app, or player", timestamp: new Date(), }); } if (!data.twitterTitle && !data.ogTitle) { warnings.push({ code: "TWITTER_MISSING_TITLE", message: "Missing Twitter title (no twitter:title or og:title)", field: "twitterTitle", }); } if (!data.twitterDescription && !data.ogDescription) { warnings.push({ code: "TWITTER_MISSING_DESCRIPTION", message: "Missing Twitter description (no twitter:description or og:description)", field: "twitterDescription", }); } if (!data.twitterImage && !data.ogImage) { warnings.push({ code: "TWITTER_MISSING_IMAGE", message: "Missing Twitter image (no twitter:image or og:image)", field: "twitterImage", }); } // Card-specific validation if (data.twitterCard === "summary_large_image") { if (data.twitterImage || data.ogImage) { const image = data.twitterImage || data.ogImage; const images = Array.isArray(image) ? image : [image]; for (const img of images) { if (typeof img === "object" && img.width && img.height) { const width = Number.parseInt(String(img.width), 10); const height = Number.parseInt(String(img.height), 10); if (width < 300 || height < 157) { warnings.push({ code: "TWITTER_IMAGE_TOO_SMALL", message: `Image too small for summary_large_image card. Minimum: 300x157, Current: ${width}x${height}`, field: "twitterImage", }); } } } } } if (data.twitterCard === "player") { if (!data.twitterPlayer) { errors.push({ code: "TWITTER_PLAYER_MISSING_URL", message: "Player card requires twitter:player URL", severity: "error", field: "twitterPlayer", timestamp: new Date(), }); } if (!data.twitterPlayerWidth || !data.twitterPlayerHeight) { warnings.push({ code: "TWITTER_PLAYER_MISSING_DIMENSIONS", message: "Player card should include width and height", field: "twitterPlayer", }); } } const score = calculateValidationScore(errors, warnings); return { valid: errors.length === 0, errors, warnings, score, recommendations, }; } /** * Generate social sharing score */ function generateSocialScore(data) { const ogValidation = validateOpenGraph(data); const twitterValidation = validateTwitterCard(data); // Calculate Open Graph score const ogScore = { score: ogValidation.score, present: [], missing: [], issues: [], }; // Track present OG fields if (data.ogTitle) { ogScore.present.push("title"); } if (data.ogDescription) { ogScore.present.push("description"); } if (data.ogImage) { ogScore.present.push("image"); } if (data.ogUrl) { ogScore.present.push("url"); } if (data.ogType) { ogScore.present.push("type"); } if (data.ogSiteName) { ogScore.present.push("site_name"); } // Track missing OG fields if (!data.ogTitle) { ogScore.missing.push("title"); } if (!data.ogDescription) { ogScore.missing.push("description"); } if (!data.ogImage) { ogScore.missing.push("image"); } if (!data.ogUrl) { ogScore.missing.push("url"); } if (!data.ogType) { ogScore.missing.push("type"); } // Add issues ogScore.issues = ogValidation.errors.map((e) => e.message); // Calculate Twitter score const twitterScore = { score: twitterValidation.score, present: [], missing: [], issues: [], }; // Track present Twitter fields if (data.twitterCard) { twitterScore.present.push("card"); } if (data.twitterTitle || data.ogTitle) { twitterScore.present.push("title"); } if (data.twitterDescription || data.ogDescription) { twitterScore.present.push("description"); } if (data.twitterImage || data.ogImage) { twitterScore.present.push("image"); } if (data.twitterSite) { twitterScore.present.push("site"); } // Track missing Twitter fields if (!data.twitterCard) { twitterScore.missing.push("card"); } if (!data.twitterTitle && !data.ogTitle) { twitterScore.missing.push("title"); } if (!data.twitterDescription && !data.ogDescription) { twitterScore.missing.push("description"); } if (!data.twitterImage && !data.ogImage) { twitterScore.missing.push("image"); } // Add issues twitterScore.issues = twitterValidation.errors.map((e) => e.message); // Calculate Schema.org score const schemaScore = { score: 0, present: [], missing: ["JSON-LD", "Microdata"], issues: ["No structured data found"], }; // Calculate SEO score const seoScore = { score: 0, present: [], missing: [], issues: [], }; if (data.ogTitle) { seoScore.score += 20; } if (data.ogDescription) { seoScore.score += 20; } if (data.canonical) { seoScore.score += 20; seoScore.present.push("canonical"); } else { seoScore.missing.push("canonical"); } if (data.favicon) { seoScore.score += 10; seoScore.present.push("favicon"); } else { seoScore.missing.push("favicon"); } if (data.robots) { seoScore.score += 10; seoScore.present.push("robots"); } if (data.viewport) { seoScore.score += 10; seoScore.present.push("viewport"); } if (data.charset) { seoScore.score += 10; seoScore.present.push("charset"); } // Calculate overall score const overall = Math.round((ogScore.score + twitterScore.score + schemaScore.score + seoScore.score) / 4); // Generate recommendations const recommendations = []; const missingRequired = []; const missingRecommended = []; // Add missing required fields if (!data.ogTitle) { missingRequired.push("og:title"); } if (!data.ogType) { missingRequired.push("og:type"); } if (!data.ogImage) { missingRequired.push("og:image"); } if (!data.ogUrl) { missingRequired.push("og:url"); } // Add missing recommended fields if (!data.ogDescription) { missingRecommended.push("og:description"); } if (!data.ogSiteName) { missingRecommended.push("og:site_name"); } if (!data.twitterCard) { missingRecommended.push("twitter:card"); } if (!data.canonical) { missingRecommended.push("canonical URL"); } // Generate recommendations based on score if (overall < 50) { recommendations.push("Critical: Add basic Open Graph meta tags immediately"); } if (ogScore.score < 70) { recommendations.push("Improve Open Graph implementation for better social sharing"); } if (twitterScore.score < 70) { recommendations.push("Add Twitter Card meta tags for better Twitter engagement"); } if (schemaScore.score === 0) { recommendations.push("Implement JSON-LD structured data for better SEO"); } if (!data.ogImage || (Array.isArray(data.ogImage) && data.ogImage.length === 0)) { recommendations.push("Add high-quality images (1200x630px recommended for Facebook)"); } if (data.ogDescription && data.ogDescription.length < 50) { recommendations.push("Write longer, more descriptive meta descriptions (150-160 characters)"); } return { overall, openGraph: ogScore, twitter: twitterScore, schema: schemaScore, seo: seoScore, recommendations, missingRequired, missingRecommended, }; } /** * Check if a string is a valid Open Graph type */ function isValidOGType(type) { const validTypes = [ "article", "book", "books.author", "books.book", "books.genre", "business.business", "fitness.course", "music.album", "music.playlist", "music.radio_station", "music.song", "place", "product", "product.group", "product.item", "profile", "restaurant.menu", "restaurant.menu_item", "restaurant.menu_section", "restaurant.restaurant", "video.episode", "video.movie", "video.other", "video.tv_show", "website", ]; return validTypes.includes(type); } /** * Check if a string is a valid Twitter Card type */ function isValidTwitterCardType(type) { const validTypes = ["summary", "summary_large_image", "app", "player"]; return validTypes.includes(type); } /** * Calculate validation score based on errors and warnings */ function calculateValidationScore(errors, warnings) { let score = 100; // Deduct points for errors for (const error of errors) { if (error.severity === "critical") { score -= 20; } else if (error.severity === "error") { score -= 10; } else { score -= 5; } } // Deduct points for warnings score -= warnings.length * 3; // Ensure score is between 0 and 100 return Math.max(0, Math.min(100, score)); }