generated from fahricansecer/boilerplate-be
This commit is contained in:
9
src/modules/seo/index.ts
Normal file
9
src/modules/seo/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// SEO Module - Index exports
|
||||
// Path: src/modules/seo/index.ts
|
||||
|
||||
export * from './seo.module';
|
||||
export * from './seo.service';
|
||||
export * from './seo.controller';
|
||||
export * from './services/keyword-research.service';
|
||||
export * from './services/content-optimization.service';
|
||||
export * from './services/competitor-analysis.service';
|
||||
198
src/modules/seo/seo.controller.ts
Normal file
198
src/modules/seo/seo.controller.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// SEO Controller - API endpoints
|
||||
// Path: src/modules/seo/seo.controller.ts
|
||||
|
||||
import { Controller, Get, Post, Body, Query, Param } from '@nestjs/common';
|
||||
import { SeoService } from './seo.service';
|
||||
import { KeywordResearchService } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService } from './services/competitor-analysis.service';
|
||||
|
||||
@Controller('seo')
|
||||
export class SeoController {
|
||||
constructor(
|
||||
private readonly seoService: SeoService,
|
||||
private readonly keywordService: KeywordResearchService,
|
||||
private readonly optimizationService: ContentOptimizationService,
|
||||
private readonly competitorService: CompetitorAnalysisService,
|
||||
) { }
|
||||
|
||||
// ========== FULL ANALYSIS ==========
|
||||
|
||||
@Post('analyze')
|
||||
analyzeFull(
|
||||
@Body() body: {
|
||||
content: string;
|
||||
targetKeyword: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
competitorDomains?: string[];
|
||||
},
|
||||
) {
|
||||
return this.seoService.analyzeFull(body.content, body.targetKeyword, {
|
||||
title: body.title,
|
||||
metaDescription: body.metaDescription,
|
||||
url: body.url,
|
||||
competitorDomains: body.competitorDomains,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('quick-score')
|
||||
quickScore(
|
||||
@Body() body: { content: string; targetKeyword?: string },
|
||||
) {
|
||||
return { score: this.seoService.quickScore(body.content, body.targetKeyword) };
|
||||
}
|
||||
|
||||
// ========== KEYWORDS ==========
|
||||
|
||||
@Get('keywords/suggest/:topic')
|
||||
suggestKeywords(
|
||||
@Param('topic') topic: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.keywordService.suggestKeywords(topic, {
|
||||
count: count ? parseInt(count, 10) : 20,
|
||||
includeQuestions: true,
|
||||
includeLongTail: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('keywords/long-tail/:keyword')
|
||||
getLongTailKeywords(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.keywordService.generateLongTail(keyword, count ? parseInt(count, 10) : 20);
|
||||
}
|
||||
|
||||
@Get('keywords/lsi/:keyword')
|
||||
getLSIKeywords(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.seoService.getLSIKeywords(keyword, count ? parseInt(count, 10) : 10);
|
||||
}
|
||||
|
||||
@Post('keywords/cluster')
|
||||
clusterKeywords(@Body() body: { keywords: string[] }) {
|
||||
return this.keywordService.clusterKeywords(body.keywords);
|
||||
}
|
||||
|
||||
@Get('keywords/difficulty/:keyword')
|
||||
analyzeKeywordDifficulty(@Param('keyword') keyword: string) {
|
||||
return this.seoService.analyzeKeywordDifficulty(keyword);
|
||||
}
|
||||
|
||||
@Post('keywords/cannibalization')
|
||||
checkCannibalization(
|
||||
@Body() body: { newKeyword: string; existingKeywords: string[] },
|
||||
) {
|
||||
return this.seoService.checkCannibalization(body.newKeyword, body.existingKeywords);
|
||||
}
|
||||
|
||||
// ========== CONTENT OPTIMIZATION ==========
|
||||
|
||||
@Post('optimize')
|
||||
optimizeContent(
|
||||
@Body() body: {
|
||||
content: string;
|
||||
targetKeyword: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
},
|
||||
) {
|
||||
const score = this.optimizationService.analyze(body.content, {
|
||||
targetKeyword: body.targetKeyword,
|
||||
title: body.title,
|
||||
metaDescription: body.metaDescription,
|
||||
url: body.url,
|
||||
});
|
||||
const optimized = this.optimizationService.optimize(body.content, body.targetKeyword);
|
||||
return { score, optimized };
|
||||
}
|
||||
|
||||
@Post('meta/generate')
|
||||
generateMeta(
|
||||
@Body() body: { content: string; targetKeyword: string; brandName?: string },
|
||||
) {
|
||||
return this.optimizationService.generateMeta(body.content, body.targetKeyword, {
|
||||
brandName: body.brandName,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('titles/:keyword')
|
||||
generateTitles(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.optimizationService.generateTitleVariations(
|
||||
keyword,
|
||||
count ? parseInt(count, 10) : 5,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('descriptions')
|
||||
generateDescriptions(
|
||||
@Body() body: { keyword: string; content: string; count?: number },
|
||||
) {
|
||||
return this.optimizationService.generateDescriptionVariations(
|
||||
body.keyword,
|
||||
body.content,
|
||||
body.count || 3,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== COMPETITORS ==========
|
||||
|
||||
@Get('competitors/:domain')
|
||||
analyzeCompetitor(@Param('domain') domain: string) {
|
||||
return this.competitorService.analyzeCompetitor(domain);
|
||||
}
|
||||
|
||||
@Post('competitors/gaps')
|
||||
findContentGaps(
|
||||
@Body() body: { yourKeywords: string[]; competitorDomains: string[] },
|
||||
) {
|
||||
return this.competitorService.findContentGaps(body.yourKeywords, body.competitorDomains);
|
||||
}
|
||||
|
||||
@Get('competitors/content/:keyword')
|
||||
analyzeTopContent(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.competitorService.analyzeTopContent(keyword, count ? parseInt(count, 10) : 10);
|
||||
}
|
||||
|
||||
@Post('competitors/blueprint')
|
||||
generateBlueprint(
|
||||
@Body() body: { keyword: string; competitorDomains: string[] },
|
||||
) {
|
||||
return this.competitorService.analyzeTopContent(body.keyword, 5).then((insights) =>
|
||||
this.competitorService.generateContentBlueprint(body.keyword, insights),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('competitors/patterns')
|
||||
getPublishingPatterns(@Body() body: { domains: string[] }) {
|
||||
return this.competitorService.getPublishingPatterns(body.domains);
|
||||
}
|
||||
|
||||
// ========== OUTLINE GENERATION ==========
|
||||
|
||||
@Post('outline')
|
||||
generateOutline(
|
||||
@Body() body: {
|
||||
keyword: string;
|
||||
competitorDomains?: string[];
|
||||
contentType?: string;
|
||||
},
|
||||
) {
|
||||
return this.seoService.generateOutline(body.keyword, {
|
||||
competitorDomains: body.competitorDomains,
|
||||
contentType: body.contentType,
|
||||
});
|
||||
}
|
||||
}
|
||||
21
src/modules/seo/seo.module.ts
Normal file
21
src/modules/seo/seo.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// SEO Intelligence Module - Keyword research and content optimization
|
||||
// Path: src/modules/seo/seo.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeoService } from './seo.service';
|
||||
import { SeoController } from './seo.controller';
|
||||
import { KeywordResearchService } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService } from './services/competitor-analysis.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
SeoService,
|
||||
KeywordResearchService,
|
||||
ContentOptimizationService,
|
||||
CompetitorAnalysisService,
|
||||
],
|
||||
controllers: [SeoController],
|
||||
exports: [SeoService],
|
||||
})
|
||||
export class SeoModule { }
|
||||
190
src/modules/seo/seo.service.ts
Normal file
190
src/modules/seo/seo.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// SEO Service - Main orchestration service
|
||||
// Path: src/modules/seo/seo.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { KeywordResearchService, Keyword, KeywordCluster } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService, SeoScore, OptimizedMeta } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService, ContentGap, CompetitorProfile } from './services/competitor-analysis.service';
|
||||
|
||||
export interface FullSeoAnalysis {
|
||||
content: {
|
||||
score: SeoScore;
|
||||
meta: OptimizedMeta;
|
||||
};
|
||||
keywords: {
|
||||
main: Keyword;
|
||||
related: Keyword[];
|
||||
clusters: KeywordCluster[];
|
||||
longTail: ReturnType<KeywordResearchService['generateLongTail']>;
|
||||
};
|
||||
competitors: {
|
||||
gaps: ContentGap[];
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SeoService {
|
||||
private readonly logger = new Logger(SeoService.name);
|
||||
|
||||
constructor(
|
||||
private readonly keywordService: KeywordResearchService,
|
||||
private readonly optimizationService: ContentOptimizationService,
|
||||
private readonly competitorService: CompetitorAnalysisService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Full SEO analysis for content
|
||||
*/
|
||||
async analyzeFull(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
competitorDomains?: string[];
|
||||
},
|
||||
): Promise<FullSeoAnalysis> {
|
||||
// Analyze content
|
||||
const score = this.optimizationService.analyze(content, {
|
||||
targetKeyword,
|
||||
title: options?.title,
|
||||
metaDescription: options?.metaDescription,
|
||||
url: options?.url,
|
||||
});
|
||||
|
||||
// Generate optimized meta
|
||||
const meta = this.optimizationService.generateMeta(content, targetKeyword);
|
||||
|
||||
// Research keywords
|
||||
const keywordData = await this.keywordService.suggestKeywords(targetKeyword, {
|
||||
count: 20,
|
||||
includeQuestions: true,
|
||||
includeLongTail: true,
|
||||
});
|
||||
|
||||
// Cluster keywords
|
||||
const allKeywords = [targetKeyword, ...keywordData.related.map((k) => k.term)];
|
||||
const clusters = this.keywordService.clusterKeywords(allKeywords);
|
||||
|
||||
// Long-tail variations
|
||||
const longTail = this.keywordService.generateLongTail(targetKeyword, 15);
|
||||
|
||||
// Content gaps (if competitors provided)
|
||||
let gaps: ContentGap[] = [];
|
||||
if (options?.competitorDomains?.length) {
|
||||
gaps = await this.competitorService.findContentGaps(
|
||||
allKeywords,
|
||||
options.competitorDomains,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: { score, meta },
|
||||
keywords: {
|
||||
main: keywordData.main,
|
||||
related: keywordData.related,
|
||||
clusters,
|
||||
longTail,
|
||||
},
|
||||
competitors: { gaps },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick SEO score check
|
||||
*/
|
||||
quickScore(content: string, targetKeyword?: string): number {
|
||||
const analysis = this.optimizationService.analyze(content, { targetKeyword });
|
||||
return analysis.overall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEO-optimized content outline
|
||||
*/
|
||||
async generateOutline(
|
||||
keyword: string,
|
||||
options?: {
|
||||
competitorDomains?: string[];
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<{
|
||||
title: string;
|
||||
description: string;
|
||||
headings: string[];
|
||||
keywords: string[];
|
||||
estimatedWordCount: number;
|
||||
differentiators: string[];
|
||||
}> {
|
||||
// Get keyword data
|
||||
const keywordData = await this.keywordService.suggestKeywords(keyword);
|
||||
|
||||
// Get title variations
|
||||
const titles = this.optimizationService.generateTitleVariations(keyword, 1);
|
||||
const descriptions = this.optimizationService.generateDescriptionVariations(keyword, '', 1);
|
||||
|
||||
// Generate outline if competitors provided
|
||||
let headings: string[] = [];
|
||||
let differentiators: string[] = [];
|
||||
|
||||
if (options?.competitorDomains?.length) {
|
||||
const competitorContent = await this.competitorService.analyzeTopContent(keyword, 5);
|
||||
const blueprint = this.competitorService.generateContentBlueprint(keyword, competitorContent);
|
||||
headings = blueprint.suggestedHeadings;
|
||||
differentiators = blueprint.differentiators;
|
||||
} else {
|
||||
headings = [
|
||||
`What is ${keyword}?`,
|
||||
`Why ${keyword} is Important`,
|
||||
`How to Use ${keyword}`,
|
||||
`${keyword} Best Practices`,
|
||||
`Common ${keyword} Mistakes`,
|
||||
`${keyword} Tools & Resources`,
|
||||
`FAQs about ${keyword}`,
|
||||
];
|
||||
differentiators = [
|
||||
'Include original research or data',
|
||||
'Add expert insights',
|
||||
'Provide actionable steps',
|
||||
'Include real examples',
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
title: titles[0] || `Complete Guide to ${keyword}`,
|
||||
description: descriptions[0] || `Learn everything about ${keyword} in this comprehensive guide.`,
|
||||
headings,
|
||||
keywords: [keyword, ...keywordData.related.slice(0, 5).map((k) => k.term)],
|
||||
estimatedWordCount: 1500,
|
||||
differentiators,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LSI keywords for semantic SEO
|
||||
*/
|
||||
getLSIKeywords(keyword: string, count: number = 10): string[] {
|
||||
return this.keywordService.generateLSIKeywords(keyword, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze keyword difficulty
|
||||
*/
|
||||
analyzeKeywordDifficulty(keyword: string) {
|
||||
return this.keywordService.analyzeKeywordDifficulty(keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for keyword cannibalization
|
||||
*/
|
||||
checkCannibalization(newKeyword: string, existingKeywords: string[]) {
|
||||
return this.optimizationService.checkCannibalization(newKeyword, existingKeywords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor insights
|
||||
*/
|
||||
async getCompetitorInsights(domain: string): Promise<CompetitorProfile> {
|
||||
return this.competitorService.analyzeCompetitor(domain);
|
||||
}
|
||||
}
|
||||
264
src/modules/seo/services/competitor-analysis.service.ts
Normal file
264
src/modules/seo/services/competitor-analysis.service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// Competitor Analysis Service - Analyze competitor content for SEO insights
|
||||
// Path: src/modules/seo/services/competitor-analysis.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface CompetitorProfile {
|
||||
url: string;
|
||||
domain: string;
|
||||
estimatedAuthority: number; // 0-100
|
||||
contentFrequency: 'daily' | 'weekly' | 'monthly' | 'rarely';
|
||||
topKeywords: string[];
|
||||
contentTypes: string[];
|
||||
avgContentLength: number;
|
||||
socialPresence: {
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
instagram?: string;
|
||||
youtube?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentGap {
|
||||
keyword: string;
|
||||
competitorsCovering: number;
|
||||
difficulty: number;
|
||||
opportunity: 'high' | 'medium' | 'low';
|
||||
suggestedContentType: string;
|
||||
}
|
||||
|
||||
export interface CompetitorContentInsight {
|
||||
title: string;
|
||||
url: string;
|
||||
estimatedTraffic: number;
|
||||
keywords: string[];
|
||||
contentLength: number;
|
||||
backlinks: number;
|
||||
publishedDate?: string;
|
||||
strengths: string[];
|
||||
weaknesses: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CompetitorAnalysisService {
|
||||
private readonly logger = new Logger(CompetitorAnalysisService.name);
|
||||
|
||||
/**
|
||||
* Analyze competitor for SEO insights
|
||||
*/
|
||||
async analyzeCompetitor(domain: string): Promise<CompetitorProfile> {
|
||||
// In production, this would scrape/analyze the competitor
|
||||
// For now, return mock data structure
|
||||
return {
|
||||
url: `https://${domain}`,
|
||||
domain,
|
||||
estimatedAuthority: Math.floor(Math.random() * 50 + 30),
|
||||
contentFrequency: 'weekly',
|
||||
topKeywords: [
|
||||
`${domain} tips`,
|
||||
`best ${domain} practices`,
|
||||
`${domain} guide`,
|
||||
`how to use ${domain}`,
|
||||
`${domain} alternatives`,
|
||||
],
|
||||
contentTypes: ['blog', 'guides', 'case-studies', 'videos'],
|
||||
avgContentLength: Math.floor(Math.random() * 1000 + 1500),
|
||||
socialPresence: {
|
||||
twitter: `@${domain.split('.')[0]}`,
|
||||
linkedin: domain.split('.')[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify content gaps between you and competitors
|
||||
*/
|
||||
async findContentGaps(
|
||||
yourKeywords: string[],
|
||||
competitorDomains: string[],
|
||||
): Promise<ContentGap[]> {
|
||||
const gaps: ContentGap[] = [];
|
||||
|
||||
// Mock gap analysis
|
||||
const potentialGaps = [
|
||||
'beginner guide',
|
||||
'advanced strategies',
|
||||
'case studies',
|
||||
'tool comparison',
|
||||
'industry trends',
|
||||
'best practices 2024',
|
||||
'common mistakes',
|
||||
'step by step tutorial',
|
||||
'video walkthrough',
|
||||
'templates',
|
||||
];
|
||||
|
||||
for (const gap of potentialGaps) {
|
||||
const covering = Math.floor(Math.random() * competitorDomains.length);
|
||||
|
||||
if (!yourKeywords.some((k) => k.toLowerCase().includes(gap))) {
|
||||
gaps.push({
|
||||
keyword: gap,
|
||||
competitorsCovering: covering,
|
||||
difficulty: Math.floor(Math.random() * 60 + 20),
|
||||
opportunity: covering >= 2 ? 'high' : covering === 1 ? 'medium' : 'low',
|
||||
suggestedContentType: this.suggestContentType(gap),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return gaps.sort((a, b) => {
|
||||
const priorityMap = { high: 3, medium: 2, low: 1 };
|
||||
return priorityMap[b.opportunity] - priorityMap[a.opportunity];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze top competitor content for a keyword
|
||||
*/
|
||||
async analyzeTopContent(
|
||||
keyword: string,
|
||||
count: number = 10,
|
||||
): Promise<CompetitorContentInsight[]> {
|
||||
// Mock competitor content analysis
|
||||
const insights: CompetitorContentInsight[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
insights.push({
|
||||
title: `${keyword.charAt(0).toUpperCase() + keyword.slice(1)} - Complete Guide ${i + 1}`,
|
||||
url: `https://example${i + 1}.com/${keyword.replace(/\s+/g, '-')}`,
|
||||
estimatedTraffic: Math.floor(Math.random() * 50000 + 5000),
|
||||
keywords: [
|
||||
keyword,
|
||||
`${keyword} tips`,
|
||||
`best ${keyword}`,
|
||||
`${keyword} examples`,
|
||||
],
|
||||
contentLength: Math.floor(Math.random() * 2000 + 1000),
|
||||
backlinks: Math.floor(Math.random() * 500 + 50),
|
||||
strengths: this.identifyStrengths(i),
|
||||
weaknesses: this.identifyWeaknesses(i),
|
||||
});
|
||||
}
|
||||
|
||||
return insights.sort((a, b) => b.estimatedTraffic - a.estimatedTraffic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content blueprint based on competitor analysis
|
||||
*/
|
||||
generateContentBlueprint(
|
||||
keyword: string,
|
||||
competitorInsights: CompetitorContentInsight[],
|
||||
): {
|
||||
recommendedLength: number;
|
||||
suggestedHeadings: string[];
|
||||
keyPoints: string[];
|
||||
differentiators: string[];
|
||||
callToActions: string[];
|
||||
} {
|
||||
const avgLength = competitorInsights.reduce((sum, c) => sum + c.contentLength, 0) / competitorInsights.length;
|
||||
|
||||
return {
|
||||
recommendedLength: Math.floor(avgLength * 1.2), // 20% longer than competitors
|
||||
suggestedHeadings: [
|
||||
`What is ${keyword}?`,
|
||||
`Why ${keyword} Matters`,
|
||||
`How to Get Started with ${keyword}`,
|
||||
`${keyword} Best Practices`,
|
||||
`Common ${keyword} Mistakes to Avoid`,
|
||||
`${keyword} Tools and Resources`,
|
||||
`${keyword} Case Studies`,
|
||||
`Frequently Asked Questions about ${keyword}`,
|
||||
],
|
||||
keyPoints: [
|
||||
'Comprehensive coverage of all subtopics',
|
||||
'Include original data or research',
|
||||
'Add expert quotes or interviews',
|
||||
'Provide actionable steps',
|
||||
'Include visual elements (images, infographics)',
|
||||
],
|
||||
differentiators: [
|
||||
'Include original case studies',
|
||||
'Add interactive elements or tools',
|
||||
'Provide downloadable resources',
|
||||
'Include video content',
|
||||
'Offer a unique angle or perspective',
|
||||
],
|
||||
callToActions: [
|
||||
`Download our free ${keyword} checklist`,
|
||||
`Try our ${keyword} tool free`,
|
||||
`Subscribe for more ${keyword} tips`,
|
||||
`Get a personalized ${keyword} strategy`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track competitor content publishing
|
||||
*/
|
||||
getPublishingPatterns(
|
||||
competitorDomains: string[],
|
||||
): {
|
||||
domain: string;
|
||||
avgPostsPerWeek: number;
|
||||
bestPublishingDay: string;
|
||||
topContentTypes: string[];
|
||||
engagementLevel: 'low' | 'medium' | 'high';
|
||||
}[] {
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||
|
||||
return competitorDomains.map((domain) => ({
|
||||
domain,
|
||||
avgPostsPerWeek: Math.floor(Math.random() * 5 + 1),
|
||||
bestPublishingDay: days[Math.floor(Math.random() * days.length)],
|
||||
topContentTypes: ['blog', 'guides', 'case-studies'].slice(0, Math.floor(Math.random() * 3 + 1)),
|
||||
engagementLevel: ['low', 'medium', 'high'][Math.floor(Math.random() * 3)] as 'low' | 'medium' | 'high',
|
||||
}));
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private suggestContentType(gap: string): string {
|
||||
if (gap.includes('guide') || gap.includes('tutorial')) return 'long-form guide';
|
||||
if (gap.includes('video')) return 'video content';
|
||||
if (gap.includes('case')) return 'case study';
|
||||
if (gap.includes('comparison') || gap.includes('vs')) return 'comparison article';
|
||||
if (gap.includes('template')) return 'template/resource';
|
||||
return 'blog post';
|
||||
}
|
||||
|
||||
private identifyStrengths(index: number): string[] {
|
||||
const allStrengths = [
|
||||
'Comprehensive coverage',
|
||||
'Well-structured headings',
|
||||
'Good use of visuals',
|
||||
'Strong call-to-actions',
|
||||
'Includes original data',
|
||||
'Expert quotes included',
|
||||
'Mobile-optimized',
|
||||
'Fast page load',
|
||||
'Interactive elements',
|
||||
'Downloadable resources',
|
||||
];
|
||||
|
||||
return allStrengths.slice(index % 3, (index % 3) + 3);
|
||||
}
|
||||
|
||||
private identifyWeaknesses(index: number): string[] {
|
||||
const allWeaknesses = [
|
||||
'Thin content',
|
||||
'Outdated information',
|
||||
'No visual elements',
|
||||
'Poor readability',
|
||||
'Missing key subtopics',
|
||||
'Slow page load',
|
||||
'No internal links',
|
||||
'Weak call-to-actions',
|
||||
'No social proof',
|
||||
'Generic content',
|
||||
];
|
||||
|
||||
return allWeaknesses.slice(index % 3, (index % 3) + 2);
|
||||
}
|
||||
}
|
||||
454
src/modules/seo/services/content-optimization.service.ts
Normal file
454
src/modules/seo/services/content-optimization.service.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
// Content Optimization Service - SEO scoring and optimization
|
||||
// Path: src/modules/seo/services/content-optimization.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface SeoScore {
|
||||
overall: number; // 0-100
|
||||
breakdown: {
|
||||
titleOptimization: number;
|
||||
metaDescription: number;
|
||||
headingStructure: number;
|
||||
keywordDensity: number;
|
||||
contentLength: number;
|
||||
readability: number;
|
||||
internalLinks: number;
|
||||
externalLinks: number;
|
||||
imageOptimization: number;
|
||||
urlStructure: number;
|
||||
};
|
||||
issues: SeoIssue[];
|
||||
suggestions: SeoSuggestion[];
|
||||
}
|
||||
|
||||
export interface SeoIssue {
|
||||
type: 'error' | 'warning' | 'info';
|
||||
category: string;
|
||||
message: string;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
export interface SeoSuggestion {
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
category: string;
|
||||
suggestion: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
export interface OptimizedMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
twitterTitle: string;
|
||||
twitterDescription: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentOptimizationService {
|
||||
private readonly logger = new Logger(ContentOptimizationService.name);
|
||||
|
||||
// Optimal content parameters
|
||||
private readonly optimalParams = {
|
||||
titleLength: { min: 50, max: 60 },
|
||||
metaDescLength: { min: 150, max: 160 },
|
||||
contentWords: { min: 1000, max: 2500 },
|
||||
keywordDensity: { min: 1, max: 3 }, // percentage
|
||||
paragraphLength: { max: 150 }, // words
|
||||
sentenceLength: { max: 20 }, // words
|
||||
h1Count: 1,
|
||||
h2MinCount: 2,
|
||||
imageAltMinPercent: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze content for SEO optimization
|
||||
*/
|
||||
analyze(content: string, options?: {
|
||||
targetKeyword?: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
}): SeoScore {
|
||||
const breakdown = {
|
||||
titleOptimization: this.scoreTitleOptimization(options?.title, options?.targetKeyword),
|
||||
metaDescription: this.scoreMetaDescription(options?.metaDescription, options?.targetKeyword),
|
||||
headingStructure: this.scoreHeadingStructure(content),
|
||||
keywordDensity: this.scoreKeywordDensity(content, options?.targetKeyword),
|
||||
contentLength: this.scoreContentLength(content),
|
||||
readability: this.scoreReadability(content),
|
||||
internalLinks: this.scoreInternalLinks(content),
|
||||
externalLinks: this.scoreExternalLinks(content),
|
||||
imageOptimization: this.scoreImageOptimization(content),
|
||||
urlStructure: this.scoreUrlStructure(options?.url),
|
||||
};
|
||||
|
||||
const overall = Math.round(
|
||||
Object.values(breakdown).reduce((sum, score) => sum + score, 0) / 10
|
||||
);
|
||||
|
||||
const issues = this.identifyIssues(content, options);
|
||||
const suggestions = this.generateSuggestions(breakdown, options);
|
||||
|
||||
return { overall, breakdown, issues, suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimized meta tags
|
||||
*/
|
||||
generateMeta(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
options?: { brandName?: string }
|
||||
): OptimizedMeta {
|
||||
const firstParagraph = content.split('\n\n')[0] || content.substring(0, 200);
|
||||
const brand = options?.brandName || '';
|
||||
|
||||
// Generate title variations
|
||||
const title = this.generateTitle(targetKeyword, brand);
|
||||
const description = this.generateDescription(firstParagraph, targetKeyword);
|
||||
const keywords = this.extractKeywords(content, targetKeyword);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
twitterTitle: this.truncate(title, 70),
|
||||
twitterDescription: this.truncate(description, 200),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize content for target keyword
|
||||
*/
|
||||
optimize(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
): {
|
||||
optimizedContent: string;
|
||||
changes: { type: string; before: string; after: string }[];
|
||||
newScore: number;
|
||||
} {
|
||||
let optimizedContent = content;
|
||||
const changes: { type: string; before: string; after: string }[] = [];
|
||||
|
||||
// Check keyword in first paragraph
|
||||
const firstPara = content.split('\n\n')[0];
|
||||
if (firstPara && !firstPara.toLowerCase().includes(targetKeyword.toLowerCase())) {
|
||||
const newFirstPara = `${targetKeyword} is essential. ${firstPara}`;
|
||||
optimizedContent = optimizedContent.replace(firstPara, newFirstPara);
|
||||
changes.push({
|
||||
type: 'keyword_placement',
|
||||
before: firstPara.substring(0, 50),
|
||||
after: newFirstPara.substring(0, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate new score
|
||||
const newScore = this.analyze(optimizedContent, { targetKeyword }).overall;
|
||||
|
||||
return { optimizedContent, changes, newScore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEO-optimized title variations
|
||||
*/
|
||||
generateTitleVariations(keyword: string, count: number = 5): string[] {
|
||||
const templates = [
|
||||
`${keyword}: The Complete Guide for 2024`,
|
||||
`How to Master ${keyword} in 7 Steps`,
|
||||
`${keyword} 101: Everything You Need to Know`,
|
||||
`The Ultimate ${keyword} Guide (Updated 2024)`,
|
||||
`${keyword}: Tips, Tricks & Best Practices`,
|
||||
`Why ${keyword} Matters + How to Get Started`,
|
||||
`${keyword} Secrets: What Experts Don't Tell You`,
|
||||
`${keyword} Made Simple: A Beginner's Guide`,
|
||||
`10 ${keyword} Strategies That Actually Work`,
|
||||
`The Science Behind Effective ${keyword}`,
|
||||
];
|
||||
|
||||
return templates.slice(0, count).map((t) => this.truncate(t, 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate meta description variations
|
||||
*/
|
||||
generateDescriptionVariations(
|
||||
keyword: string,
|
||||
content: string,
|
||||
count: number = 3,
|
||||
): string[] {
|
||||
const templates = [
|
||||
`Discover how ${keyword} can transform your approach. Learn proven strategies, tips, and best practices in this comprehensive guide.`,
|
||||
`Looking to improve your ${keyword}? This guide covers everything from basics to advanced techniques. Start mastering ${keyword} today.`,
|
||||
`${keyword} doesn't have to be complicated. Our step-by-step guide breaks down everything you need to know for success.`,
|
||||
];
|
||||
|
||||
return templates.slice(0, count).map((t) => this.truncate(t, 160));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check content for keyword cannibalization risk
|
||||
*/
|
||||
checkCannibalization(
|
||||
newKeyword: string,
|
||||
existingKeywords: string[],
|
||||
): { risk: 'low' | 'medium' | 'high'; conflictingKeywords: string[] } {
|
||||
const conflicts: string[] = [];
|
||||
const newWords = new Set(newKeyword.toLowerCase().split(' '));
|
||||
|
||||
for (const existing of existingKeywords) {
|
||||
const existingWords = new Set(existing.toLowerCase().split(' '));
|
||||
const overlap = [...newWords].filter((w) => existingWords.has(w));
|
||||
|
||||
if (overlap.length / newWords.size > 0.5) {
|
||||
conflicts.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
let risk: 'low' | 'medium' | 'high' = 'low';
|
||||
if (conflicts.length > 2) risk = 'high';
|
||||
else if (conflicts.length > 0) risk = 'medium';
|
||||
|
||||
return { risk, conflictingKeywords: conflicts };
|
||||
}
|
||||
|
||||
// Private scoring methods
|
||||
|
||||
private scoreTitleOptimization(title?: string, keyword?: string): number {
|
||||
if (!title) return 0;
|
||||
let score = 50;
|
||||
|
||||
const length = title.length;
|
||||
if (length >= this.optimalParams.titleLength.min &&
|
||||
length <= this.optimalParams.titleLength.max) {
|
||||
score += 25;
|
||||
} else if (length < this.optimalParams.titleLength.min) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (keyword && title.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreMetaDescription(description?: string, keyword?: string): number {
|
||||
if (!description) return 0;
|
||||
let score = 50;
|
||||
|
||||
const length = description.length;
|
||||
if (length >= this.optimalParams.metaDescLength.min &&
|
||||
length <= this.optimalParams.metaDescLength.max) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
if (keyword && description.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreHeadingStructure(content: string): number {
|
||||
let score = 50;
|
||||
const h1Count = (content.match(/^#\s/gm) || []).length;
|
||||
const h2Count = (content.match(/^##\s/gm) || []).length;
|
||||
|
||||
if (h1Count === 1) score += 25;
|
||||
if (h2Count >= 2) score += 25;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreKeywordDensity(content: string, keyword?: string): number {
|
||||
if (!keyword) return 50;
|
||||
|
||||
const words = content.split(/\s+/).length;
|
||||
const kwCount = (content.toLowerCase().match(new RegExp(keyword.toLowerCase(), 'g')) || []).length;
|
||||
const density = (kwCount / words) * 100;
|
||||
|
||||
if (density >= this.optimalParams.keywordDensity.min &&
|
||||
density <= this.optimalParams.keywordDensity.max) {
|
||||
return 100;
|
||||
}
|
||||
if (density < this.optimalParams.keywordDensity.min) {
|
||||
return 40;
|
||||
}
|
||||
return 60; // Over-optimized
|
||||
}
|
||||
|
||||
private scoreContentLength(content: string): number {
|
||||
const words = content.split(/\s+/).length;
|
||||
|
||||
if (words >= this.optimalParams.contentWords.min &&
|
||||
words <= this.optimalParams.contentWords.max) {
|
||||
return 100;
|
||||
}
|
||||
if (words < this.optimalParams.contentWords.min) {
|
||||
return Math.floor((words / this.optimalParams.contentWords.min) * 80);
|
||||
}
|
||||
return 80; // Very long content
|
||||
}
|
||||
|
||||
private scoreReadability(content: string): number {
|
||||
const sentences = content.split(/[.!?]+/).filter(Boolean);
|
||||
const avgLength = sentences.reduce((sum, s) => sum + s.split(/\s+/).length, 0) / sentences.length;
|
||||
|
||||
if (avgLength <= this.optimalParams.sentenceLength.max) {
|
||||
return 100;
|
||||
}
|
||||
return Math.max(50, 100 - (avgLength - this.optimalParams.sentenceLength.max) * 3);
|
||||
}
|
||||
|
||||
private scoreInternalLinks(content: string): number {
|
||||
const internalLinks = (content.match(/\[.*?\]\(\/.*?\)/g) || []).length;
|
||||
if (internalLinks >= 3) return 100;
|
||||
if (internalLinks >= 1) return 70;
|
||||
return 30;
|
||||
}
|
||||
|
||||
private scoreExternalLinks(content: string): number {
|
||||
const externalLinks = (content.match(/\[.*?\]\(https?:\/\/.*?\)/g) || []).length;
|
||||
if (externalLinks >= 2) return 100;
|
||||
if (externalLinks >= 1) return 70;
|
||||
return 50;
|
||||
}
|
||||
|
||||
private scoreImageOptimization(content: string): number {
|
||||
const images = content.match(/!\[.*?\]\(.*?\)/g) || [];
|
||||
if (images.length === 0) return 50;
|
||||
|
||||
const withAlt = images.filter((img) => !/!\[\]/.test(img)).length;
|
||||
return Math.floor((withAlt / images.length) * 100);
|
||||
}
|
||||
|
||||
private scoreUrlStructure(url?: string): number {
|
||||
if (!url) return 50;
|
||||
let score = 50;
|
||||
|
||||
if (url.length < 75) score += 20;
|
||||
if (!url.includes('?')) score += 15;
|
||||
if (url === url.toLowerCase()) score += 15;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private identifyIssues(content: string, options?: {
|
||||
targetKeyword?: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
}): SeoIssue[] {
|
||||
const issues: SeoIssue[] = [];
|
||||
|
||||
if (!options?.title) {
|
||||
issues.push({
|
||||
type: 'error',
|
||||
category: 'title',
|
||||
message: 'Missing title tag',
|
||||
fix: 'Add a compelling title with your target keyword',
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.metaDescription) {
|
||||
issues.push({
|
||||
type: 'error',
|
||||
category: 'meta',
|
||||
message: 'Missing meta description',
|
||||
fix: 'Add a meta description between 150-160 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const words = content.split(/\s+/).length;
|
||||
if (words < 300) {
|
||||
issues.push({
|
||||
type: 'warning',
|
||||
category: 'content',
|
||||
message: 'Content is too short',
|
||||
fix: 'Aim for at least 1000 words for better SEO',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private generateSuggestions(
|
||||
breakdown: SeoScore['breakdown'],
|
||||
options?: { targetKeyword?: string },
|
||||
): SeoSuggestion[] {
|
||||
const suggestions: SeoSuggestion[] = [];
|
||||
|
||||
if (breakdown.keywordDensity < 70 && options?.targetKeyword) {
|
||||
suggestions.push({
|
||||
priority: 'high',
|
||||
category: 'keywords',
|
||||
suggestion: `Increase usage of "${options.targetKeyword}" in your content`,
|
||||
impact: 'Better keyword relevance signals to search engines',
|
||||
});
|
||||
}
|
||||
|
||||
if (breakdown.contentLength < 70) {
|
||||
suggestions.push({
|
||||
priority: 'medium',
|
||||
category: 'content',
|
||||
suggestion: 'Expand your content with more detailed information',
|
||||
impact: 'Longer, comprehensive content typically ranks better',
|
||||
});
|
||||
}
|
||||
|
||||
if (breakdown.internalLinks < 70) {
|
||||
suggestions.push({
|
||||
priority: 'medium',
|
||||
category: 'links',
|
||||
suggestion: 'Add more internal links to related content',
|
||||
impact: 'Improves site structure and helps with crawling',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private generateTitle(keyword: string, brand: string): string {
|
||||
const title = `${keyword}: Complete Guide & Best Practices${brand ? ` | ${brand}` : ''}`;
|
||||
return this.truncate(title, 60);
|
||||
}
|
||||
|
||||
private generateDescription(firstParagraph: string, keyword: string): string {
|
||||
let desc = firstParagraph.substring(0, 150);
|
||||
if (!desc.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
desc = `${keyword} - ${desc}`;
|
||||
}
|
||||
return this.truncate(desc, 160);
|
||||
}
|
||||
|
||||
private extractKeywords(content: string, targetKeyword: string): string[] {
|
||||
const words = content.toLowerCase().split(/\s+/);
|
||||
const frequency: Map<string, number> = new Map();
|
||||
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'for', 'on', 'with']);
|
||||
|
||||
for (const word of words) {
|
||||
const clean = word.replace(/[^a-z]/g, '');
|
||||
if (clean.length > 3 && !stopWords.has(clean)) {
|
||||
frequency.set(clean, (frequency.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...frequency.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const keywords = sorted.slice(0, 10).map(([word]) => word);
|
||||
|
||||
if (!keywords.includes(targetKeyword.toLowerCase())) {
|
||||
keywords.unshift(targetKeyword.toLowerCase());
|
||||
}
|
||||
|
||||
return keywords;
|
||||
}
|
||||
|
||||
private truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
}
|
||||
379
src/modules/seo/services/keyword-research.service.ts
Normal file
379
src/modules/seo/services/keyword-research.service.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
// Keyword Research Service - Keyword discovery and analysis
|
||||
// Path: src/modules/seo/services/keyword-research.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface Keyword {
|
||||
term: string;
|
||||
volume: number; // Monthly search volume (estimated)
|
||||
difficulty: number; // 0-100
|
||||
cpc: number; // Cost per click estimate
|
||||
trend: 'rising' | 'stable' | 'declining';
|
||||
intent: 'informational' | 'navigational' | 'transactional' | 'commercial';
|
||||
related: string[];
|
||||
questions: string[];
|
||||
}
|
||||
|
||||
export interface KeywordCluster {
|
||||
mainKeyword: string;
|
||||
keywords: Keyword[];
|
||||
totalVolume: number;
|
||||
avgDifficulty: number;
|
||||
contentSuggestions: string[];
|
||||
}
|
||||
|
||||
export interface LongTailSuggestion {
|
||||
keyword: string;
|
||||
baseKeyword: string;
|
||||
modifier: string;
|
||||
estimatedVolume: number;
|
||||
competitionLevel: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KeywordResearchService {
|
||||
private readonly logger = new Logger(KeywordResearchService.name);
|
||||
|
||||
// Common keyword modifiers for long-tail generation
|
||||
private readonly modifiers = {
|
||||
prefix: [
|
||||
'how to', 'what is', 'why', 'when to', 'where to', 'who uses',
|
||||
'best', 'top', 'free', 'cheap', 'affordable', 'premium',
|
||||
'easy', 'quick', 'simple', 'ultimate', 'complete', 'beginner',
|
||||
'advanced', 'professional', 'expert', 'step by step',
|
||||
],
|
||||
suffix: [
|
||||
'guide', 'tutorial', 'tips', 'tricks', 'examples', 'templates',
|
||||
'tools', 'software', 'apps', 'services', 'strategies', 'techniques',
|
||||
'for beginners', 'for experts', 'in 2024', 'vs', 'alternatives',
|
||||
'review', 'comparison', 'checklist', 'resources', 'ideas',
|
||||
],
|
||||
questions: [
|
||||
'what is', 'how to', 'why should', 'when to use', 'where can I find',
|
||||
'who needs', 'which is best', 'can you', 'should I', 'does',
|
||||
'is it worth', 'how much does', 'how long does', 'what are the benefits',
|
||||
],
|
||||
};
|
||||
|
||||
// Content type keywords
|
||||
private readonly contentTypeKeywords = {
|
||||
blog: ['guide', 'how to', 'tips', 'best practices', 'examples'],
|
||||
video: ['tutorial', 'walkthrough', 'demo', 'explained', 'in action'],
|
||||
product: ['buy', 'price', 'discount', 'coupon', 'review'],
|
||||
local: ['near me', 'in [city]', 'local', 'best [city]'],
|
||||
comparison: ['vs', 'comparison', 'alternatives', 'versus', 'or'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate keyword suggestions for a topic
|
||||
*/
|
||||
async suggestKeywords(topic: string, options?: {
|
||||
count?: number;
|
||||
includeQuestions?: boolean;
|
||||
includeLongTail?: boolean;
|
||||
}): Promise<{
|
||||
main: Keyword;
|
||||
related: Keyword[];
|
||||
questions: string[];
|
||||
longTail: LongTailSuggestion[];
|
||||
}> {
|
||||
const count = options?.count || 20;
|
||||
|
||||
// Generate main keyword data
|
||||
const main: Keyword = {
|
||||
term: topic.toLowerCase(),
|
||||
volume: this.estimateVolume(topic),
|
||||
difficulty: this.estimateDifficulty(topic),
|
||||
cpc: this.estimateCPC(topic),
|
||||
trend: 'stable',
|
||||
intent: this.detectIntent(topic),
|
||||
related: this.generateRelatedTerms(topic, 5),
|
||||
questions: this.generateQuestions(topic, 5),
|
||||
};
|
||||
|
||||
// Generate related keywords
|
||||
const related = this.generateRelatedKeywords(topic, count);
|
||||
|
||||
// Generate question-based keywords
|
||||
const questions = options?.includeQuestions !== false
|
||||
? this.generateQuestions(topic, 10)
|
||||
: [];
|
||||
|
||||
// Generate long-tail variations
|
||||
const longTail = options?.includeLongTail !== false
|
||||
? this.generateLongTail(topic, 15)
|
||||
: [];
|
||||
|
||||
return { main, related, questions, longTail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate long-tail keyword variations
|
||||
*/
|
||||
generateLongTail(baseKeyword: string, count: number = 20): LongTailSuggestion[] {
|
||||
const suggestions: LongTailSuggestion[] = [];
|
||||
const base = baseKeyword.toLowerCase();
|
||||
|
||||
// Add prefix modifiers
|
||||
for (const prefix of this.modifiers.prefix.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${prefix} ${base}`,
|
||||
baseKeyword: base,
|
||||
modifier: prefix,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.1 * Math.random()),
|
||||
competitionLevel: this.estimateCompetition(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add suffix modifiers
|
||||
for (const suffix of this.modifiers.suffix.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${base} ${suffix}`,
|
||||
baseKeyword: base,
|
||||
modifier: suffix,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.1 * Math.random()),
|
||||
competitionLevel: this.estimateCompetition(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add question modifiers
|
||||
for (const question of this.modifiers.questions.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${question} ${base}`,
|
||||
baseKeyword: base,
|
||||
modifier: question,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.15 * Math.random()),
|
||||
competitionLevel: 'low',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster keywords by topic similarity
|
||||
*/
|
||||
clusterKeywords(keywords: string[]): KeywordCluster[] {
|
||||
// Group similar keywords into clusters
|
||||
const clusters: Map<string, Keyword[]> = new Map();
|
||||
|
||||
for (const kw of keywords) {
|
||||
const mainWord = this.extractMainWord(kw);
|
||||
const existing = clusters.get(mainWord) || [];
|
||||
existing.push({
|
||||
term: kw,
|
||||
volume: this.estimateVolume(kw),
|
||||
difficulty: this.estimateDifficulty(kw),
|
||||
cpc: this.estimateCPC(kw),
|
||||
trend: 'stable',
|
||||
intent: this.detectIntent(kw),
|
||||
related: [],
|
||||
questions: [],
|
||||
});
|
||||
clusters.set(mainWord, existing);
|
||||
}
|
||||
|
||||
return Array.from(clusters.entries()).map(([mainKeyword, kws]) => ({
|
||||
mainKeyword,
|
||||
keywords: kws,
|
||||
totalVolume: kws.reduce((sum, k) => sum + k.volume, 0),
|
||||
avgDifficulty: kws.reduce((sum, k) => sum + k.difficulty, 0) / kws.length,
|
||||
contentSuggestions: this.generateContentSuggestions(mainKeyword),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate LSI (Latent Semantic Indexing) keywords
|
||||
*/
|
||||
generateLSIKeywords(keyword: string, count: number = 10): string[] {
|
||||
const words = keyword.toLowerCase().split(' ');
|
||||
const lsiTerms: string[] = [];
|
||||
|
||||
// Add synonyms and related concepts
|
||||
const synonymMap: Record<string, string[]> = {
|
||||
content: ['posts', 'articles', 'material', 'copy', 'text'],
|
||||
marketing: ['promotion', 'advertising', 'branding', 'outreach'],
|
||||
social: ['community', 'network', 'platform', 'engagement'],
|
||||
media: ['content', 'posts', 'videos', 'images', 'stories'],
|
||||
strategy: ['plan', 'approach', 'tactics', 'framework', 'method'],
|
||||
growth: ['scaling', 'expansion', 'increase', 'improvement'],
|
||||
engagement: ['interaction', 'response', 'participation', 'activity'],
|
||||
audience: ['followers', 'community', 'readers', 'subscribers'],
|
||||
};
|
||||
|
||||
for (const word of words) {
|
||||
const synonyms = synonymMap[word] || [];
|
||||
lsiTerms.push(...synonyms);
|
||||
}
|
||||
|
||||
// Add related terms
|
||||
lsiTerms.push(...this.generateRelatedTerms(keyword, 5));
|
||||
|
||||
return [...new Set(lsiTerms)].slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze keyword difficulty
|
||||
*/
|
||||
analyzeKeywordDifficulty(keyword: string): {
|
||||
score: number;
|
||||
factors: { name: string; impact: 'positive' | 'negative'; description: string }[];
|
||||
recommendation: string;
|
||||
} {
|
||||
const difficulty = this.estimateDifficulty(keyword);
|
||||
const factors: { name: string; impact: 'positive' | 'negative'; description: string }[] = [];
|
||||
|
||||
// Word count factor
|
||||
const wordCount = keyword.split(' ').length;
|
||||
if (wordCount >= 4) {
|
||||
factors.push({
|
||||
name: 'Long-tail keyword',
|
||||
impact: 'positive',
|
||||
description: 'Longer keywords typically have lower competition',
|
||||
});
|
||||
} else if (wordCount === 1) {
|
||||
factors.push({
|
||||
name: 'Single word',
|
||||
impact: 'negative',
|
||||
description: 'Single-word keywords are highly competitive',
|
||||
});
|
||||
}
|
||||
|
||||
// Question format
|
||||
if (this.modifiers.questions.some((q) => keyword.toLowerCase().startsWith(q))) {
|
||||
factors.push({
|
||||
name: 'Question format',
|
||||
impact: 'positive',
|
||||
description: 'Question-based keywords often have lower competition',
|
||||
});
|
||||
}
|
||||
|
||||
// Commercial intent
|
||||
const commercialTerms = ['buy', 'price', 'discount', 'deal', 'sale'];
|
||||
if (commercialTerms.some((t) => keyword.toLowerCase().includes(t))) {
|
||||
factors.push({
|
||||
name: 'Commercial intent',
|
||||
impact: 'negative',
|
||||
description: 'Commercial keywords have higher competition',
|
||||
});
|
||||
}
|
||||
|
||||
let recommendation: string;
|
||||
if (difficulty < 30) {
|
||||
recommendation = 'Great opportunity! This keyword has low competition.';
|
||||
} else if (difficulty < 60) {
|
||||
recommendation = 'Moderate competition. Focus on high-quality content.';
|
||||
} else {
|
||||
recommendation = 'High competition. Consider targeting long-tail variations.';
|
||||
}
|
||||
|
||||
return { score: difficulty, factors, recommendation };
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private generateRelatedKeywords(topic: string, count: number): Keyword[] {
|
||||
const related = this.generateRelatedTerms(topic, count);
|
||||
return related.map((term) => ({
|
||||
term,
|
||||
volume: this.estimateVolume(term),
|
||||
difficulty: this.estimateDifficulty(term),
|
||||
cpc: this.estimateCPC(term),
|
||||
trend: 'stable' as const,
|
||||
intent: this.detectIntent(term),
|
||||
related: [],
|
||||
questions: [],
|
||||
}));
|
||||
}
|
||||
|
||||
private generateRelatedTerms(topic: string, count: number): string[] {
|
||||
const base = topic.toLowerCase();
|
||||
const related: string[] = [];
|
||||
|
||||
// Add common variations
|
||||
related.push(`${base} tips`);
|
||||
related.push(`${base} guide`);
|
||||
related.push(`best ${base}`);
|
||||
related.push(`${base} strategies`);
|
||||
related.push(`${base} examples`);
|
||||
related.push(`${base} tools`);
|
||||
related.push(`${base} for beginners`);
|
||||
related.push(`advanced ${base}`);
|
||||
related.push(`${base} best practices`);
|
||||
related.push(`${base} trends`);
|
||||
|
||||
return related.slice(0, count);
|
||||
}
|
||||
|
||||
private generateQuestions(topic: string, count: number): string[] {
|
||||
const base = topic.toLowerCase();
|
||||
return [
|
||||
`What is ${base}?`,
|
||||
`How to use ${base}?`,
|
||||
`Why is ${base} important?`,
|
||||
`When should I use ${base}?`,
|
||||
`Where can I learn ${base}?`,
|
||||
`Who needs ${base}?`,
|
||||
`What are the benefits of ${base}?`,
|
||||
`How does ${base} work?`,
|
||||
`Is ${base} worth it?`,
|
||||
`What are ${base} best practices?`,
|
||||
].slice(0, count);
|
||||
}
|
||||
|
||||
private generateContentSuggestions(keyword: string): string[] {
|
||||
return [
|
||||
`Ultimate Guide to ${keyword}`,
|
||||
`${keyword}: Everything You Need to Know`,
|
||||
`10 ${keyword} Tips for Beginners`,
|
||||
`How to Master ${keyword} in 2024`,
|
||||
`${keyword} vs Alternatives: Complete Comparison`,
|
||||
];
|
||||
}
|
||||
|
||||
private estimateVolume(keyword: string): number {
|
||||
// Simple estimation based on keyword characteristics
|
||||
const wordCount = keyword.split(' ').length;
|
||||
const baseVolume = 10000;
|
||||
return Math.floor(baseVolume / Math.pow(wordCount, 1.5) * (Math.random() + 0.5));
|
||||
}
|
||||
|
||||
private estimateDifficulty(keyword: string): number {
|
||||
const wordCount = keyword.split(' ').length;
|
||||
const base = 70 - (wordCount * 10);
|
||||
return Math.min(95, Math.max(5, base + Math.floor(Math.random() * 20)));
|
||||
}
|
||||
|
||||
private estimateCPC(keyword: string): number {
|
||||
return Math.round((Math.random() * 5 + 0.5) * 100) / 100;
|
||||
}
|
||||
|
||||
private estimateCompetition(): 'low' | 'medium' | 'high' {
|
||||
const rand = Math.random();
|
||||
if (rand < 0.4) return 'low';
|
||||
if (rand < 0.7) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
private detectIntent(keyword: string): 'informational' | 'navigational' | 'transactional' | 'commercial' {
|
||||
const kw = keyword.toLowerCase();
|
||||
|
||||
if (['buy', 'price', 'discount', 'order', 'purchase'].some((t) => kw.includes(t))) {
|
||||
return 'transactional';
|
||||
}
|
||||
if (['best', 'top', 'review', 'comparison', 'vs'].some((t) => kw.includes(t))) {
|
||||
return 'commercial';
|
||||
}
|
||||
if (['login', 'sign in', 'website', 'official'].some((t) => kw.includes(t))) {
|
||||
return 'navigational';
|
||||
}
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
private extractMainWord(keyword: string): string {
|
||||
const stopWords = ['how', 'to', 'what', 'is', 'the', 'a', 'an', 'for', 'in', 'of', 'and', 'or'];
|
||||
const words = keyword.toLowerCase().split(' ');
|
||||
const mainWords = words.filter((w) => !stopWords.includes(w));
|
||||
return mainWords[0] || words[0] || keyword;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user