1150 lines
30 KiB
JavaScript
1150 lines
30 KiB
JavaScript
#!/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 <command> [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 <file> Output file path")
|
||
console.log(" --format <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 `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>SEO Link Analysis Report</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||
h1, h2 { color: #333; }
|
||
.summary { background: #f5f5f5; padding: 15px; border-radius: 5px; }
|
||
.issue { color: #d32f2f; }
|
||
.warning { color: #f57c00; }
|
||
.success { color: #388e3c; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>SEO Link Analysis Report</h1>
|
||
<p>Generated: ${new Date().toISOString()}</p>
|
||
|
||
<div class="summary">
|
||
<h2>Summary</h2>
|
||
<p><strong>Total Pages:</strong> ${results.totalPages}</p>
|
||
<p><strong>Internal Links:</strong> ${results.internalLinks}</p>
|
||
<p><strong>Orphaned Pages:</strong> ${results.orphanedPages.length}</p>
|
||
<p><strong>Broken Links:</strong> ${results.brokenLinks.length}</p>
|
||
<p><strong>Average Link Density:</strong> ${results.averageLinkDensity.toFixed(2)}</p>
|
||
</div>
|
||
|
||
${
|
||
results.orphanedPages.length > 0
|
||
? `
|
||
<div>
|
||
<h2 class="warning">Orphaned Pages</h2>
|
||
<ul>
|
||
${results.orphanedPages.map((page) => `<li class="warning">${page}</li>`).join("")}
|
||
</ul>
|
||
</div>`
|
||
: ""
|
||
}
|
||
|
||
${
|
||
results.brokenLinks.length > 0
|
||
? `
|
||
<div>
|
||
<h2 class="issue">Broken Links</h2>
|
||
<ul>
|
||
${results.brokenLinks.map((link) => `<li class="issue">${link}</li>`).join("")}
|
||
</ul>
|
||
</div>`
|
||
: ""
|
||
}
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
/**
|
||
* 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 = /<a[^>]+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 = `<a href="${url}">${anchorText}</a>`
|
||
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 `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>SEO Report</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||
h1, h2 { color: #333; }
|
||
.page { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; }
|
||
.score { font-size: 24px; font-weight: bold; }
|
||
.excellent { color: #388e3c; }
|
||
.good { color: #f57c00; }
|
||
.poor { color: #d32f2f; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>SEO Report</h1>
|
||
<p>Generated: ${report.timestamp}</p>
|
||
|
||
<h2>Summary</h2>
|
||
<p><strong>Total Pages:</strong> ${report.totalPages}</p>
|
||
<p><strong>Average Score:</strong> ${report.averageScore.toFixed(1)}/100</p>
|
||
|
||
<h2>Page Scores</h2>
|
||
${report.pages
|
||
.map((page, index) => {
|
||
const status =
|
||
page.score >= 80 ? "excellent" : page.score >= 60 ? "good" : "poor"
|
||
return `
|
||
<div class="page">
|
||
<h3>${index + 1}. ${page.title} (${page.route})</h3>
|
||
<p class="score ${status}">${page.score.toFixed(1)}/100</p>
|
||
<p>Internal Links: ${page.internalLinks}</p>
|
||
<p>Priority: ${page.priority}</p>
|
||
<p>Last Modified: ${new Date(page.lastModified).toLocaleDateString()}</p>
|
||
</div>`
|
||
})
|
||
.join("")}
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
/**
|
||
* 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()
|
||
}
|