#!/usr/bin/env node import fs from "fs" import path from "path" import { fileURLToPath } from "url" import { execSync } from "child_process" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) // Get the project root const PROJECT_ROOT = path.join(__dirname, "..") const APP_DIR = path.join(PROJECT_ROOT, "app") // Import utilities import { discoverPages, generateSitemapXml, analyzeInternalLinks, } from "../lib/seo-utils.js" import { defaultConfig as defaultLinkConfig, calculatePageScore, getKeywordsForTarget, getAnchorTexts, isExcludedRoute, getMaxLinksForPage, getMinWordsBetweenLinks, findKeywordsInContent, } from "../lib/internal-link-config.js" // Import readline only when needed let readline /** * Display colored output in console */ function colorize(text, color) { const colors = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", white: "\x1b[37m", } return `${colors[color]}${text}${colors.reset}` } /** * Display section header */ function displayHeader(title) { console.log(`\n${colorize("=".repeat(50), "cyan")}`) console.log(colorize(` ${title}`, "cyan")) console.log(colorize("=".repeat(50), "cyan")) } /** * Display success message */ function displaySuccess(message) { console.log(colorize(`āœ“ ${message}`, "green")) } /** * Display error message */ function displayError(message) { console.log(colorize(`āœ— ${message}`, "red")) } /** * Display warning message */ function displayWarning(message) { console.log(colorize(`⚠ ${message}`, "yellow")) } /** * Display info message */ function displayInfo(message) { console.log(colorize(`ℹ ${message}`, "blue")) } /** * Show help information */ function showHelp() { displayHeader("SEO Internal Link Tool - Help") console.log(colorize("Usage:", "white")) console.log(" node seo-internal-link-tool.js [options]") console.log("") console.log(colorize("Commands:", "white")) console.log(" sitemap Generate sitemap from React pages") console.log(" analyze Analyze internal links") console.log(" optimize Optimize internal links") console.log(" report Generate comprehensive SEO report") console.log(" json-ld Generate JSON-LD structured data") console.log(" interactive Start interactive mode") console.log("") console.log(colorize("Options:", "white")) console.log(" --output Output file path") console.log(" --format Output format (json, html, csv, markdown)") console.log(" --verbose Enable verbose output") console.log(" --help, -h Show help information") console.log("") console.log(colorize("Examples:", "white")) console.log(" node seo-internal-link-tool.js sitemap") console.log(" node seo-internal-link-tool.js analyze --output report.json") console.log(" node seo-internal-link-tool.js interactive") } /** * Read file content with error handling */ function readFileContent(filePath) { try { return fs.readFileSync(filePath, "utf8") } catch (error) { return "" } } /** * Extract content from page file */ function extractPageContent(page) { return readFileContent(page.filePath) } /** * Generate JSON-LD structured data for a page */ function generatePageJsonLD(page, content, linkConfig = defaultLinkConfig) { const businessConfig = { name: linkConfig.businessName, websiteUrl: linkConfig.websiteUrl, description: extractPageDescription(content) || `${linkConfig.businessName} - ${page.title}`, } const jsonLD = { "@context": "https://schema.org", "@type": getSchemaTypeForRoute(page.route), name: page.title, description: businessConfig.description, url: `${businessConfig.websiteUrl}${page.url}`, datePublished: page.lastModified.toISOString(), dateModified: page.lastModified.toISOString(), publisher: { "@type": "Organization", name: businessConfig.name, url: businessConfig.websiteUrl, }, } // Add route-specific structured data if (page.route === "") { jsonLD["@type"] = "WebSite" jsonLD.about = { "@type": "LocalBusiness", name: businessConfig.name, description: businessConfig.description, url: businessConfig.websiteUrl, } } else if (page.route.includes("service") || page.route.includes("repair")) { jsonLD["@type"] = "Service" jsonLD.serviceType = page.title jsonLD.provider = { "@type": "LocalBusiness", name: businessConfig.name, url: businessConfig.websiteUrl, } } else if (page.route.includes("contact-us")) { jsonLD["@type"] = "ContactPage" } else if (page.route.includes("about")) { jsonLD["@type"] = "AboutPage" } return jsonLD } /** * Extract page description from content */ function extractPageDescription(content) { const descMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/) return descMatch ? descMatch[1] : null } /** * Get schema type based on route */ function getSchemaTypeForRoute(route) { if (route === "") return "WebSite" if (route.includes("service") || route.includes("repair")) return "Service" if (route.includes("contact-us")) return "ContactPage" if (route.includes("about")) return "AboutPage" if (route.includes("product") || route.includes("vending")) return "Product" return "WebPage" } /** * Generate sitemap from React pages */ async function runSitemapGeneration(options) { displayHeader("Generating Sitemap from React Pages") try { // Discover pages displayInfo("Discovering React pages...") const pages = discoverPages(APP_DIR) displaySuccess(`Found ${pages.length} pages`) // Generate sitemap XML displayInfo("Generating sitemap XML...") const sitemapXml = generateSitemapXml(pages) // Output results if (options.output) { fs.writeFileSync(options.output, sitemapXml) displaySuccess(`Sitemap saved to: ${options.output}`) } else { console.log(colorize("\nGenerated Sitemap:", "white")) console.log(sitemapXml) } // Also save to conventional location const sitemapPath = path.join(PROJECT_ROOT, "out", "sitemap.xml") fs.writeFileSync(sitemapPath, sitemapXml) displaySuccess(`Default sitemap location: ${sitemapPath}`) } catch (error) { displayError(`Failed to generate sitemap: ${error.message}`) throw error } } /** * Analyze internal links */ async function runLinkAnalysis(options) { displayHeader("Analyzing Internal Links") try { // Discover pages displayInfo("Discovering React pages...") const pages = discoverPages(APP_DIR) displaySuccess(`Found ${pages.length} pages`) // Extract content from each page displayInfo("Extracting page content...") const contentByRoute = {} for (const page of pages) { contentByRoute[page.route] = extractPageContent(page) } // Analyze internal links displayInfo("Analyzing internal links...") const analysis = analyzeInternalLinks(pages, contentByRoute) // Display results displayLinkAnalysisResults(analysis, options) // Save results if output file specified if (options.output) { saveAnalysisResults(analysis, options.output, options.format) displaySuccess(`Analysis saved to: ${options.output}`) } } catch (error) { displayError(`Failed to analyze links: ${error.message}`) throw error } } /** * Display link analysis results */ function displayLinkAnalysisResults(analysis, options) { console.log(colorize(`\nšŸ“Š Link Analysis Summary`, "white")) console.log(colorize(`Total Pages: ${analysis.totalPages}`, "cyan")) console.log(colorize(`Internal Links: ${analysis.internalLinks}`, "cyan")) console.log( colorize( `Average Link Density: ${analysis.averageLinkDensity.toFixed(2)} links per page`, "cyan" ) ) if (analysis.orphanedPages.length > 0) { displayWarning(`Orphaned Pages: ${analysis.orphanedPages.length}`) analysis.orphanedPages.forEach((page) => { console.log(colorize(` - ${page}`, "yellow")) }) } else { displaySuccess("No orphaned pages found") } if (analysis.brokenLinks.length > 0) { displayError(`Broken Links: ${analysis.brokenLinks.length}`) analysis.brokenLinks.forEach((link) => { console.log(colorize(` - ${link}`, "red")) }) } else { displaySuccess("No broken links found") } // Page types breakdown console.log(colorize(`\nšŸ“‹ Page Types:`, "white")) for (const [type, count] of Object.entries(analysis.pageTypes)) { console.log(colorize(` ${type}: ${count} pages`, "blue")) } // Pages with issues if (analysis.pagesWithIssues.length > 0) { displayWarning(`Pages with Issues: ${analysis.pagesWithIssues.length}`) analysis.pagesWithIssues.forEach((page) => { console.log(colorize(` - ${page.url}:`, "yellow")) page.issues.forEach((issue) => { console.log(colorize(` * ${issue}`, "yellow")) }) }) } } /** * Save analysis results */ function saveAnalysisResults(results, outputPath, format) { const output = { timestamp: new Date().toISOString(), summary: { totalPages: results.totalPages, internalLinks: results.internalLinks, orphanedPages: results.orphanedPages.length, brokenLinks: results.brokenLinks.length, averageLinkDensity: results.averageLinkDensity, }, details: results, } let content switch (format) { case "json": content = JSON.stringify(output, null, 2) break case "csv": content = convertToCSV(results) break case "markdown": content = convertToMarkdown(results) break case "html": content = convertToHTML(results) break default: content = JSON.stringify(output, null, 2) } fs.writeFileSync(outputPath, content) } /** * Convert analysis results to CSV */ function convertToCSV(results) { let csv = "Page,Route,Links,Issues\n" // Add pages with issues results.pagesWithIssues.forEach((page) => { csv += `"${page.url}","${page.route}","0","${page.issues.join("; ")}"\n` }) // Add pages without issues const allPages = Array.from( new Set([ ...results.orphanedPages, ...results.brokenLinks.map((link) => link.split(" -> ")[0]), ]) ) allPages.forEach((page) => { csv += `"${page}","${page}","0","No issues"\n` }) return csv } /** * Convert analysis results to Markdown */ function convertToMarkdown(results) { let md = "# SEO Link Analysis Report\n\n" md += `*Generated: ${new Date().toISOString()}*\n\n` md += "## Summary\n\n" md += `- Total Pages: ${results.totalPages}\n` md += `- Internal Links: ${results.internalLinks}\n` md += `- Orphaned Pages: ${results.orphanedPages.length}\n` md += `- Broken Links: ${results.brokenLinks.length}\n` md += `- Average Link Density: ${results.averageLinkDensity.toFixed(2)}\n\n` md += "## Page Types\n\n" for (const [type, count] of Object.entries(results.pageTypes)) { md += `- ${type}: ${count} pages\n` } md += "\n" if (results.orphanedPages.length > 0) { md += "## Orphaned Pages\n\n" results.orphanedPages.forEach((page) => { md += `- ${page}\n` }) md += "\n" } if (results.brokenLinks.length > 0) { md += "## Broken Links\n\n" results.brokenLinks.forEach((link) => { md += `- ${link}\n` }) md += "\n" } if (results.pagesWithIssues.length > 0) { md += "## Pages with Issues\n\n" results.pagesWithIssues.forEach((page) => { md += `### ${page.url}\n` page.issues.forEach((issue) => { md += `- ${issue}\n` }) md += "\n" }) } return md } /** * Convert analysis results to HTML */ function convertToHTML(results) { return ` SEO Link Analysis Report

SEO Link Analysis Report

Generated: ${new Date().toISOString()}

Summary

Total Pages: ${results.totalPages}

Internal Links: ${results.internalLinks}

Orphaned Pages: ${results.orphanedPages.length}

Broken Links: ${results.brokenLinks.length}

Average Link Density: ${results.averageLinkDensity.toFixed(2)}

${ results.orphanedPages.length > 0 ? `

Orphaned Pages

    ${results.orphanedPages.map((page) => `
  • ${page}
  • `).join("")}
` : "" } ${ results.brokenLinks.length > 0 ? `

Broken Links

    ${results.brokenLinks.map((link) => `
  • ${link}
  • `).join("")}
` : "" } ` } /** * Optimize internal links */ async function runLinkOptimization(options) { displayHeader("Optimizing Internal Links") try { // Load configuration displayInfo("Loading configuration...") const linkConfig = defaultLinkConfig // Discover pages displayInfo("Discovering React pages...") const pages = discoverPages(APP_DIR) displaySuccess(`Found ${pages.length} pages`) // Extract content from each page displayInfo("Extracting page content...") const contentByRoute = {} for (const page of pages) { contentByRoute[page.route] = extractPageContent(page) } // Optimize links page by page let totalLinksAdded = 0 const optimizationResults = [] for (const page of pages) { if (isExcludedRoute(page.route, linkConfig)) { continue } const maxLinks = getMaxLinksForPage(page.route, linkConfig) const minWordsBetween = getMinWordsBetweenLinks(page.route, linkConfig) const content = contentByRoute[page.route] // Calculate SEO score const internalLinks = extractInternalLinksFromContent(content) const seoScore = calculatePageScore( page.route, content, internalLinks, linkConfig ) // Find keywords to link const keywordsToLink = findKeywordsInContent(content, linkConfig) const linksAdded = addOptimalLinks( content, keywordsToLink, pages, linkConfig ) if (linksAdded > 0) { totalLinksAdded += linksAdded optimizationResults.push({ page: page.route, linksAdded, seoScore: seoScore, keywordsFound: keywordsToLink.length, }) displaySuccess(`${page.route}: Added ${linksAdded} link(s)`) } } displaySuccess(`Total links added: ${totalLinksAdded}`) // Save results if (options.output) { const results = { timestamp: new Date().toISOString(), totalLinksAdded, pagesOptimized: optimizationResults.length, details: optimizationResults, } fs.writeFileSync(options.output, JSON.stringify(results, null, 2)) displaySuccess(`Optimization results saved to: ${options.output}`) } } catch (error) { displayError(`Failed to optimize links: ${error.message}`) throw error } } /** * Extract internal links from content */ function extractInternalLinksFromContent(content) { const links = [] const linkRegex = /]+href="([^"]+)"[^>]*>([^<]+)<\/a>/g let match while ((match = linkRegex.exec(content)) !== null) { if (isInternalLink(match[1])) { links.push({ to: match[1], text: match[2].trim(), }) } } return links } /** * Check if link is internal */ function isInternalLink(href) { return ( !href.startsWith("http") && !href.startsWith("mailto:") && !href.startsWith("tel:") ) } /** * Add optimal links to content */ function addOptimalLinks(content, keywords, pages, config) { let linksAdded = 0 const linkPositions = [] for (const keyword of keywords) { if (linksAdded >= config.linkDensity.maxLinksPerPage) { break } // Find keyword in content const keywordRegex = new RegExp( `\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi" ) let match while ((match = keywordRegex.exec(content)) !== null) { if (linksAdded >= config.linkDensity.maxLinksPerPage) { break } // Check distance from other links const tooClose = linkPositions.some( (pos) => Math.abs(pos - match.index) < config.linkDensity.minWordsBetweenLinks * 5 ) if (tooClose) continue // Add link const targetPage = pages.find( (p) => p.title.toLowerCase().includes(keyword.toLowerCase()) || p.route.toLowerCase().includes(keyword.toLowerCase()) ) if (targetPage && !isExcludedRoute(targetPage.route, config)) { const anchorTexts = getAnchorTexts(targetPage.route, config) const anchorText = anchorTexts[0] || keyword // Insert link (simplified - in real implementation, would need to properly handle HTML) content = insertLink( content, match.index, match.index + match[0].length, targetPage.url, anchorText ) linkPositions.push(match.index) linksAdded++ break // Only link first occurrence per keyword } } } return linksAdded } /** * Insert link into content */ function insertLink(content, startIndex, endIndex, url, anchorText) { const before = content.substring(0, startIndex) const after = content.substring(endIndex) const link = `${anchorText}` return before + link + after } /** * Generate SEO report */ async function runSEOReport(options) { displayHeader("Generating SEO Report") try { // Discover pages displayInfo("Discovering React pages...") const pages = discoverPages(APP_DIR) displaySuccess(`Found ${pages.length} pages`) // Extract content from each page displayInfo("Extracting page content...") const contentByRoute = {} const pageScores = [] for (const page of pages) { contentByRoute[page.route] = extractPageContent(page) // Calculate SEO score const internalLinks = extractInternalLinksFromContent( contentByRoute[page.route] ) const seoScore = calculatePageScore( page.route, contentByRoute[page.route], internalLinks ) pageScores.push({ page: page.route, url: page.url, title: page.title, priority: page.priority, score: seoScore, internalLinks: internalLinks.length, lastModified: page.lastModified.toISOString(), }) } // Sort pages by score pageScores.sort((a, b) => b.score - a.score) // Display report displaySEOResults(pageScores, options) // Save results if (options.output) { const report = { timestamp: new Date().toISOString(), totalPages: pages.length, averageScore: pageScores.reduce((sum, p) => sum + p.score, 0) / pageScores.length, pages: pageScores, } let content switch (options.format) { case "json": content = JSON.stringify(report, null, 2) break case "markdown": content = generateMarkdownReport(report) break case "html": content = generateHTMLReport(report) break default: content = JSON.stringify(report, null, 2) } fs.writeFileSync(options.output, content) displaySuccess(`Report saved to: ${options.output}`) } } catch (error) { displayError(`Failed to generate report: ${error.message}`) throw error } } /** * Display SEO results */ function displaySEOResults(pageScores, options) { console.log(colorize(`\nšŸ“Š SEO Analysis Summary`, "white")) console.log(colorize(`Total Pages Analyzed: ${pageScores.length}`, "cyan")) const averageScore = pageScores.reduce((sum, p) => sum + p.score, 0) / pageScores.length console.log( colorize(`Average SEO Score: ${averageScore.toFixed(1)}/100`, "cyan") ) // Top performing pages console.log(colorize(`\nšŸ† Top Performing Pages:`, "white")) const topPages = pageScores.slice(0, 5) topPages.forEach((page, index) => { const scoreColor = page.score >= 80 ? "green" : page.score >= 60 ? "yellow" : "red" console.log( colorize( `${index + 1}. ${page.title} (${page.route}) - Score: ${page.score.toFixed(1)}`, scoreColor ) ) }) // Pages needing improvement const poorPages = pageScores.filter((p) => p.score < 60) if (poorPages.length > 0) { console.log(colorize(`\n⚠ Pages Needing Improvement:`, "yellow")) poorPages.slice(0, 3).forEach((page) => { console.log( colorize( `- ${page.title} (${page.route}) - Score: ${page.score.toFixed(1)}`, "red" ) ) }) } } /** * Generate markdown report */ function generateMarkdownReport(report) { let md = "# SEO Report\n\n" md += `*Generated: ${report.timestamp}*\n\n` md += "## Summary\n\n" md += `- Total Pages: ${report.totalPages}\n` md += `- Average Score: ${report.averageScore.toFixed(1)}/100\n\n` md += "## Page Scores\n\n" report.pages.forEach((page, index) => { const status = page.score >= 80 ? "Excellent" : page.score >= 60 ? "Good" : "Needs Improvement" const statusColor = page.score >= 80 ? "green" : page.score >= 60 ? "yellow" : "red" md += `${index + 1}. **${page.title}** (${page.route})\n` md += ` - Score: ${page.score.toFixed(1)}/100 (${status})\n` md += ` - Internal Links: ${page.internalLinks}\n` md += ` - Priority: ${page.priority}\n` md += ` - Last Modified: ${new Date(page.lastModified).toLocaleDateString()}\n\n` }) return md } /** * Generate HTML report */ function generateHTMLReport(report) { return ` SEO Report

SEO Report

Generated: ${report.timestamp}

Summary

Total Pages: ${report.totalPages}

Average Score: ${report.averageScore.toFixed(1)}/100

Page Scores

${report.pages .map((page, index) => { const status = page.score >= 80 ? "excellent" : page.score >= 60 ? "good" : "poor" return `

${index + 1}. ${page.title} (${page.route})

${page.score.toFixed(1)}/100

Internal Links: ${page.internalLinks}

Priority: ${page.priority}

Last Modified: ${new Date(page.lastModified).toLocaleDateString()}

` }) .join("")} ` } /** * Generate JSON-LD structured data */ async function runJsonLDGeneration(options) { displayHeader("Generating JSON-LD Structured Data") try { // Discover pages displayInfo("Discovering React pages...") const pages = discoverPages(APP_DIR) displaySuccess(`Found ${pages.length} pages`) // Generate JSON-LD for each page const jsonLDData = {} for (const page of pages) { const content = extractPageContent(page) const jsonLD = generatePageJsonLD(page, content) jsonLDData[page.route] = jsonLD if (options.verbose) { displaySuccess(`Generated JSON-LD for: ${page.route}`) } } // Output results if (options.output) { fs.writeFileSync(options.output, JSON.stringify(jsonLDData, null, 2)) displaySuccess(`JSON-LD data saved to: ${options.output}`) } else { console.log(colorize("\nGenerated JSON-LD Structured Data:", "white")) console.log(JSON.stringify(jsonLDData, null, 2)) } // Also save individual files (skip dynamic routes to avoid filesystem issues) const outputDir = path.join(PROJECT_ROOT, "public", "json-ld") if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }) } for (const [route, jsonLD] of Object.entries(jsonLDData)) { // Skip dynamic routes if (route.includes(":") || route.includes("...")) { continue } let filename if (route === "") { filename = "home.jsonld" } else { filename = `${route.replace(/\//g, "_")}.jsonld` } const filepath = path.join(outputDir, filename) fs.writeFileSync(filepath, JSON.stringify(jsonLD, null, 2)) } displaySuccess(`Individual JSON-LD files saved to: ${outputDir}`) } catch (error) { displayError(`Failed to generate JSON-LD: ${error.message}`) throw error } } /** * Start interactive mode */ async function startInteractiveMode(options) { displayHeader("SEO Internal Link Tool - Interactive Mode") displayInfo('Type "help" for available commands or "exit" to quit') // Import readline only when needed try { readline = await import("readline") } catch (error) { displayError("Interactive mode requires Node.js readline module") process.exit(1) } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }) const question = (query) => new Promise((resolve) => rl.question(query, resolve)) while (true) { const command = await question("\nseo-tool> ") if (command.toLowerCase() === "exit" || command.toLowerCase() === "quit") { break } if (command.toLowerCase() === "help") { showHelp() continue } const args = command.split(" ") const cmd = args[0] const cmdOptions = { output: null, format: "console", verbose: false, } // Parse options for (let i = 1; i < args.length; i++) { const arg = args[i] if (arg === "--output" && i + 1 < args.length) { cmdOptions.output = args[i + 1] i++ } else if (arg === "--format" && i + 1 < args.length) { cmdOptions.format = args[i + 1] i++ } else if (arg === "--verbose" || arg === "-v") { cmdOptions.verbose = true } } try { switch (cmd) { case "sitemap": await runSitemapGeneration(cmdOptions) break case "analyze": await runLinkAnalysis(cmdOptions) break case "optimize": await runLinkOptimization(cmdOptions) break case "report": await runSEOReport(cmdOptions) break case "json-ld": await runJsonLDGeneration(cmdOptions) break default: displayError(`Unknown command: ${cmd}`) showHelp() } } catch (error) { displayError(`Error: ${error.message}`) } } rl.close() displaySuccess("Goodbye!") } /** * Main CLI dispatcher */ function main() { const args = process.argv.slice(2) if (args.length === 0 || args.includes("--help") || args.includes("-h")) { showHelp() return } const command = args[0] const options = { output: null, format: "console", verbose: false, } // Parse options for (let i = 1; i < args.length; i++) { const arg = args[i] if (arg === "--output" && i + 1 < args.length) { options.output = args[i + 1] i++ } else if (arg === "--format" && i + 1 < args.length) { options.format = args[i + 1] i++ } else if (arg === "--verbose" || arg === "-v") { options.verbose = true } } // Check if we need to import readline for interactive mode if (command === "interactive") { try { readline = require("readline") } catch (error) { displayError("Interactive mode requires Node.js readline module") process.exit(1) } } switch (command) { case "sitemap": executeCommand(runSitemapGeneration, options) break case "analyze": executeCommand(runLinkAnalysis, options) break case "optimize": executeCommand(runLinkOptimization, options) break case "report": executeCommand(runSEOReport, options) break case "json-ld": executeCommand(runJsonLDGeneration, options) break case "interactive": startInteractiveMode(options) break default: displayError(`Unknown command: ${command}`) console.log("") showHelp() process.exit(1) } } /** * Execute function and handle errors */ async function executeCommand(commandFn, options) { try { await commandFn(options) } catch (error) { displayError(`Error: ${error.message}`) if (options.verbose) { console.error(error) } process.exit(1) } } // Export functions for external use export { main, executeCommand, displayHeader, displaySuccess, displayError, displayWarning, displayInfo, runSitemapGeneration, runLinkAnalysis, runLinkOptimization, runSEOReport, runJsonLDGeneration, } // Run if called directly if ( import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("seo-internal-link-tool.js") ) { main() }