544 lines
12 KiB
TypeScript
544 lines
12 KiB
TypeScript
/**
|
|
* Internal Link Configuration for SEO Tool
|
|
* Defines rules for internal link optimization and analysis
|
|
*/
|
|
|
|
import { PageInfo } from "./seo-utils"
|
|
|
|
/**
|
|
* Priority link configuration
|
|
* These links will be prioritized when optimizing internal links
|
|
*/
|
|
export interface PriorityLink {
|
|
targetRoute: string
|
|
keywords: string[]
|
|
maxLinks: number
|
|
anchorTexts: string[]
|
|
priority: number // 1-10, where 10 is highest priority
|
|
}
|
|
|
|
/**
|
|
* Keyword to page mapping
|
|
* Auto-magically link specific keywords to target pages
|
|
*/
|
|
export interface KeywordMapping {
|
|
keyword: string
|
|
targetRoute: string
|
|
variations?: string[]
|
|
useNaturalAnchorText?: boolean
|
|
}
|
|
|
|
/**
|
|
* Page-specific configuration
|
|
*/
|
|
export interface PageConfig {
|
|
excludeSections?: string[]
|
|
maxLinksPerPage?: number
|
|
minWordsBetweenLinks?: number
|
|
preferredAnchorText?: string
|
|
}
|
|
|
|
/**
|
|
* Link density configuration
|
|
*/
|
|
export interface LinkDensityConfig {
|
|
maxLinksPerPage: number
|
|
minWordsBetweenLinks: number
|
|
maxLinksPerParagraph: number
|
|
maxPercentagePerPage: number
|
|
}
|
|
|
|
/**
|
|
* SEO scoring weights
|
|
*/
|
|
export interface ScoringWeights {
|
|
title: number
|
|
description: number
|
|
internalLinks: number
|
|
contentLength: number
|
|
keywordOptimization: number
|
|
structure: number
|
|
imageOptimization: number
|
|
}
|
|
|
|
/**
|
|
* Master configuration
|
|
*/
|
|
export interface InternalLinkConfig {
|
|
// General settings
|
|
businessName: string
|
|
websiteUrl: string
|
|
|
|
// Priority links
|
|
priorityLinks: PriorityLink[]
|
|
|
|
// Keyword mappings
|
|
keywordMappings: KeywordMapping[]
|
|
|
|
// Page-specific configurations
|
|
pageConfigs: Record<string, PageConfig>
|
|
|
|
// Link density settings
|
|
linkDensity: LinkDensityConfig
|
|
|
|
// SEO scoring weights
|
|
scoringWeights: ScoringWeights
|
|
|
|
// Excluded pages
|
|
excludedPages: string[]
|
|
|
|
// Excluded sections within pages
|
|
excludedSections: string[]
|
|
|
|
// Anchor text preferences
|
|
anchorTextPreferences: {
|
|
preferPageTitle: boolean
|
|
useNaturalLanguage: boolean
|
|
avoidGenericText: string[]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default configuration
|
|
*/
|
|
export const defaultConfig: InternalLinkConfig = {
|
|
businessName: "Rocky Mountain Vending",
|
|
websiteUrl: "https://rockymountainvending.com",
|
|
|
|
priorityLinks: [
|
|
{
|
|
targetRoute: "services/repairs",
|
|
keywords: [
|
|
"repair",
|
|
"repairs",
|
|
"fix",
|
|
"broken",
|
|
"maintenance",
|
|
"service",
|
|
"fixing",
|
|
],
|
|
maxLinks: 3,
|
|
anchorTexts: [
|
|
"vending machine repairs",
|
|
"repair services",
|
|
"vending machine repair",
|
|
"fixing",
|
|
],
|
|
priority: 10,
|
|
},
|
|
{
|
|
targetRoute: "vending-machines/machines-for-sale",
|
|
keywords: [
|
|
"buy",
|
|
"purchase",
|
|
"sale",
|
|
"for sale",
|
|
"machine",
|
|
"equipment",
|
|
"acquire",
|
|
],
|
|
maxLinks: 2,
|
|
anchorTexts: [
|
|
"vending machines for sale",
|
|
"buy vending machines",
|
|
"vending machine sales",
|
|
"purchase",
|
|
],
|
|
priority: 9,
|
|
},
|
|
{
|
|
targetRoute: "vending-machines/machines-we-use",
|
|
keywords: ["machine", "equipment", "vending", "use", "utilize", "employ"],
|
|
maxLinks: 2,
|
|
anchorTexts: [
|
|
"vending machines we use",
|
|
"our machines",
|
|
"vending equipment",
|
|
"vending systems",
|
|
],
|
|
priority: 8,
|
|
},
|
|
{
|
|
targetRoute: "about-us",
|
|
keywords: ["about", "company", "us", "we", "our", "team", "business"],
|
|
maxLinks: 1,
|
|
anchorTexts: ["about us", "about Rocky Mountain Vending", "our company"],
|
|
priority: 7,
|
|
},
|
|
{
|
|
targetRoute: "contact-us",
|
|
keywords: [
|
|
"contact",
|
|
"reach",
|
|
"call",
|
|
"phone",
|
|
"email",
|
|
"get in touch",
|
|
"reach out",
|
|
],
|
|
maxLinks: 2,
|
|
anchorTexts: ["contact us", "get in touch", "reach out", "call us"],
|
|
priority: 8,
|
|
},
|
|
{
|
|
targetRoute: "food-and-beverage/healthy-options",
|
|
keywords: [
|
|
"healthy",
|
|
"wellness",
|
|
"nutrition",
|
|
"snack",
|
|
"beverage",
|
|
"options",
|
|
],
|
|
maxLinks: 2,
|
|
anchorTexts: [
|
|
"healthy vending",
|
|
"healthy options",
|
|
"wellness vending",
|
|
"healthy snacks",
|
|
],
|
|
priority: 7,
|
|
},
|
|
{
|
|
targetRoute: "food-and-beverage/traditional-options",
|
|
keywords: [
|
|
"traditional",
|
|
"classic",
|
|
"standard",
|
|
"conventional",
|
|
"regular",
|
|
],
|
|
maxLinks: 2,
|
|
anchorTexts: [
|
|
"traditional vending",
|
|
"classic vending",
|
|
"standard options",
|
|
],
|
|
priority: 6,
|
|
},
|
|
{
|
|
targetRoute: "services/parts",
|
|
keywords: [
|
|
"parts",
|
|
"component",
|
|
"replacement",
|
|
"spare",
|
|
"accessories",
|
|
"supplies",
|
|
],
|
|
maxLinks: 2,
|
|
anchorTexts: [
|
|
"vending machine parts",
|
|
"parts and support",
|
|
"replacement parts",
|
|
"spare parts",
|
|
],
|
|
priority: 7,
|
|
},
|
|
],
|
|
|
|
keywordMappings: [
|
|
{
|
|
keyword: "warehouse",
|
|
targetRoute: "warehouses",
|
|
variations: ["warehouses"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "auto repair",
|
|
targetRoute: "auto-repair",
|
|
variations: ["auto-repair", "automotive repair"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "gym",
|
|
targetRoute: "gyms",
|
|
variations: ["gyms", "fitness center"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "community center",
|
|
targetRoute: "community-centers",
|
|
variations: ["community centers", "community facility"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "dance studio",
|
|
targetRoute: "dance-studios",
|
|
variations: ["dance studios", "dance facility"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "car wash",
|
|
targetRoute: "car-washes",
|
|
variations: ["car washes", "car washing"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "suppliers",
|
|
targetRoute: "food-and-beverage/suppliers",
|
|
variations: ["supplier", "wholesale", "distributor"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
{
|
|
keyword: "faq",
|
|
targetRoute: "about/faqs",
|
|
variations: ["faqs", "frequently asked", "questions"],
|
|
useNaturalAnchorText: false,
|
|
},
|
|
{
|
|
keyword: "manuals",
|
|
targetRoute: "manuals",
|
|
variations: ["manual", "guide", "documentation"],
|
|
useNaturalAnchorText: true,
|
|
},
|
|
],
|
|
|
|
pageConfigs: {
|
|
"": {
|
|
// Home page
|
|
maxLinksPerPage: 8,
|
|
minWordsBetweenLinks: 100,
|
|
},
|
|
"about-us": {
|
|
maxLinksPerPage: 3,
|
|
minWordsBetweenLinks: 150,
|
|
},
|
|
"contact-us": {
|
|
maxLinksPerPage: 2,
|
|
minWordsBetweenLinks: 200,
|
|
},
|
|
services: {
|
|
maxLinksPerPage: 6,
|
|
minWordsBetweenLinks: 80,
|
|
},
|
|
"vending-machines": {
|
|
maxLinksPerPage: 5,
|
|
minWordsBetweenLinks: 90,
|
|
},
|
|
manuals: {
|
|
maxLinksPerPage: 4,
|
|
minWordsBetweenLinks: 120,
|
|
},
|
|
},
|
|
|
|
linkDensity: {
|
|
maxLinksPerPage: 10,
|
|
minWordsBetweenLinks: 50,
|
|
maxLinksPerParagraph: 2,
|
|
maxPercentagePerPage: 3,
|
|
},
|
|
|
|
scoringWeights: {
|
|
title: 0.2,
|
|
description: 0.15,
|
|
internalLinks: 0.25,
|
|
contentLength: 0.15,
|
|
keywordOptimization: 0.15,
|
|
structure: 0.05,
|
|
imageOptimization: 0.05,
|
|
},
|
|
|
|
excludedPages: ["api", "robots", "sitemap"],
|
|
|
|
excludedSections: ["header", "footer", "navigation", "sidebar", "menu"],
|
|
|
|
anchorTextPreferences: {
|
|
preferPageTitle: true,
|
|
useNaturalLanguage: true,
|
|
avoidGenericText: [
|
|
"click here",
|
|
"read more",
|
|
"link",
|
|
"learn more",
|
|
"visit",
|
|
],
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Get configuration for a specific page
|
|
*/
|
|
export function getPageConfig(
|
|
route: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): PageConfig {
|
|
// Check exact match first
|
|
if (config.pageConfigs[route]) {
|
|
return config.pageConfigs[route]
|
|
}
|
|
|
|
// Check partial matches (e.g., services/*)
|
|
for (const [key, pageConfig] of Object.entries(config.pageConfigs)) {
|
|
if (key !== "" && route.startsWith(key)) {
|
|
return pageConfig
|
|
}
|
|
}
|
|
|
|
// Return default config
|
|
return {
|
|
maxLinksPerPage: config.linkDensity.maxLinksPerPage,
|
|
minWordsBetweenLinks: config.linkDensity.minWordsBetweenLinks,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all keywords that should be linked to a target page
|
|
*/
|
|
export function getKeywordsForTarget(
|
|
targetRoute: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): string[] {
|
|
const keywords: string[] = []
|
|
|
|
// Add priority link keywords
|
|
const priorityLink = config.priorityLinks.find(
|
|
(pl) => pl.targetRoute === targetRoute
|
|
)
|
|
if (priorityLink) {
|
|
keywords.push(...priorityLink.keywords)
|
|
}
|
|
|
|
// Add keyword mapping keywords
|
|
for (const mapping of config.keywordMappings) {
|
|
if (mapping.targetRoute === targetRoute) {
|
|
keywords.push(mapping.keyword)
|
|
if (mapping.variations) {
|
|
keywords.push(...mapping.variations)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove duplicates
|
|
return [...new Set(keywords)]
|
|
}
|
|
|
|
/**
|
|
* Get anchor text options for a target page
|
|
*/
|
|
export function getAnchorTexts(
|
|
targetRoute: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): string[] {
|
|
// Get from priority links
|
|
const priorityLink = config.priorityLinks.find(
|
|
(pl) => pl.targetRoute === targetRoute
|
|
)
|
|
if (priorityLink && priorityLink.anchorTexts.length > 0) {
|
|
return priorityLink.anchorTexts
|
|
}
|
|
|
|
// Default to page title
|
|
return [targetRoute.split("/").pop() || targetRoute]
|
|
}
|
|
|
|
/**
|
|
* Check if a route should be excluded from linking
|
|
*/
|
|
export function isExcludedRoute(
|
|
route: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): boolean {
|
|
return config.excludedPages.some(
|
|
(excluded) => route === excluded || route.startsWith(excluded + "/")
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Calculate maximum links allowed for a page
|
|
*/
|
|
export function getMaxLinksForPage(
|
|
route: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): number {
|
|
const pageConfig = getPageConfig(route, config)
|
|
return pageConfig.maxLinksPerPage || config.linkDensity.maxLinksPerPage
|
|
}
|
|
|
|
/**
|
|
* Calculate minimum words between links
|
|
*/
|
|
export function getMinWordsBetweenLinks(
|
|
route: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): number {
|
|
const pageConfig = getPageConfig(route, config)
|
|
return (
|
|
pageConfig.minWordsBetweenLinks || config.linkDensity.minWordsBetweenLinks
|
|
)
|
|
}
|
|
|
|
/**
|
|
* SEO scoring function
|
|
*/
|
|
export function calculatePageScore(
|
|
route: string,
|
|
content: string,
|
|
internalLinks: { to: string; text: string }[],
|
|
config: InternalLinkConfig = defaultConfig
|
|
): number {
|
|
const weights = config.scoringWeights
|
|
let score = 0
|
|
|
|
// Title score (20%)
|
|
const hasTitle = content.match(/title:/i) || content.match(/Head>/i)
|
|
score += hasTitle ? weights.title : 0
|
|
|
|
// Description score (15%)
|
|
const hasDescription =
|
|
content.match(/description:/i) || content.match(/Head>/i)
|
|
score += hasDescription ? weights.description : 0
|
|
|
|
// Internal links score (25%)
|
|
const maxLinks = getMaxLinksForPage(route, config)
|
|
const linkScore =
|
|
Math.min(internalLinks.length / maxLinks, 1) * weights.internalLinks
|
|
score += linkScore
|
|
|
|
// Content length score (15%)
|
|
const wordCount = content.split(/\s+/).length
|
|
const contentScore = Math.min(wordCount / 500, 1) * weights.contentLength
|
|
score += contentScore
|
|
|
|
// Keyword optimization score (15%)
|
|
const keywordsFound = findKeywordsInContent(content, config).length
|
|
const keywordScore =
|
|
Math.min(keywordsFound / 10, 1) * weights.keywordOptimization
|
|
score += keywordScore
|
|
|
|
// Structure score (5%)
|
|
const hasGoodStructure = content.match(/<h[1-6]>/i) && content.match(/<p>/i)
|
|
score += hasGoodStructure ? weights.structure : 0
|
|
|
|
// Image optimization score (5%)
|
|
const imageCount = (content.match(/<img[^>]*>/gi) || []).length
|
|
const imageScore = Math.min(imageCount / 3, 1) * weights.imageOptimization
|
|
score += imageScore
|
|
|
|
return Math.round(score * 100) / 100
|
|
}
|
|
|
|
/**
|
|
* Find keywords in content for SEO analysis
|
|
*/
|
|
export function findKeywordsInContent(
|
|
content: string,
|
|
config: InternalLinkConfig = defaultConfig
|
|
): string[] {
|
|
const keywords: string[] = []
|
|
const allKeywords = [
|
|
...config.priorityLinks.flatMap((pl) => pl.keywords),
|
|
...config.keywordMappings.map((km) => km.keyword),
|
|
...config.keywordMappings.flatMap((km) => km.variations || []),
|
|
]
|
|
|
|
const contentLower = content.toLowerCase()
|
|
|
|
for (const keyword of allKeywords) {
|
|
if (contentLower.includes(keyword.toLowerCase())) {
|
|
keywords.push(keyword)
|
|
}
|
|
}
|
|
|
|
return [...new Set(keywords)]
|
|
}
|