@f-o-t/content-analysis 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +37 -65
- package/dist/index.d.ts +0 -241
- package/dist/index.js +0 -891
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FOT (F-O-T)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,67 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"@biomejs/biome": "2.3.12",
|
|
42
|
-
"@types/bun": "1.3.6",
|
|
43
|
-
"bumpp": "10.4.0",
|
|
44
|
-
"bunup": "0.16.20",
|
|
45
|
-
"typescript": "5.9.3"
|
|
46
|
-
},
|
|
47
|
-
"peerDependencies": {
|
|
48
|
-
"typescript": ">=4.5.0"
|
|
49
|
-
},
|
|
50
|
-
"peerDependenciesMeta": {
|
|
51
|
-
"typescript": {
|
|
52
|
-
"optional": true
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
"license": "MIT",
|
|
56
|
-
"repository": {
|
|
57
|
-
"type": "git",
|
|
58
|
-
"url": "https://github.com/F-O-T/contentta-nx.git"
|
|
59
|
-
},
|
|
60
|
-
"homepage": "https://github.com/F-O-T/contentta-nx/blob/master/libraries/content-analysis",
|
|
61
|
-
"bugs": {
|
|
62
|
-
"url": "https://github.com/F-O-T/contentta-nx/issues"
|
|
63
|
-
},
|
|
64
|
-
"publishConfig": {
|
|
65
|
-
"access": "public"
|
|
66
|
-
}
|
|
2
|
+
"name": "@f-o-t/content-analysis",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./plugins/types": {
|
|
16
|
+
"types": "./dist/plugins/types/index.d.ts",
|
|
17
|
+
"default": "./dist/plugins/types/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "bun x --bun fot build",
|
|
22
|
+
"test": "bun x --bun fot test",
|
|
23
|
+
"lint": "bun x --bun fot lint",
|
|
24
|
+
"format": "bun x --bun fot format",
|
|
25
|
+
"typecheck": "bun x --bun fot typecheck"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@f-o-t/markdown": "^1.0.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@f-o-t/cli": "^1.0.0",
|
|
32
|
+
"@f-o-t/config": "^1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/F-O-T/libraries.git",
|
|
37
|
+
"directory": "libraries/content-analysis"
|
|
38
|
+
}
|
|
67
39
|
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content Analysis Types
|
|
3
|
-
* All type definitions for SEO, readability, structure, and pattern analysis
|
|
4
|
-
*/
|
|
5
|
-
type SeoIssueType = "title" | "meta_description" | "headings" | "keyword_density" | "content_length" | "readability" | "links" | "images" | "quick_answer" | "first_paragraph" | "heading_keywords" | "structure";
|
|
6
|
-
type Severity = "error" | "warning" | "info";
|
|
7
|
-
type SeoIssue = {
|
|
8
|
-
type: SeoIssueType;
|
|
9
|
-
severity: Severity;
|
|
10
|
-
message: string;
|
|
11
|
-
suggestion: string;
|
|
12
|
-
};
|
|
13
|
-
type SeoMetrics = {
|
|
14
|
-
wordCount: number;
|
|
15
|
-
headingCount: number;
|
|
16
|
-
paragraphCount: number;
|
|
17
|
-
linkCount: number;
|
|
18
|
-
imageCount: number;
|
|
19
|
-
hasQuickAnswer: boolean;
|
|
20
|
-
keywordInFirstParagraph: boolean;
|
|
21
|
-
keywordDensity?: Record<string, number>;
|
|
22
|
-
};
|
|
23
|
-
type SeoResult = {
|
|
24
|
-
score: number;
|
|
25
|
-
issues: SeoIssue[];
|
|
26
|
-
recommendations: string[];
|
|
27
|
-
metrics: SeoMetrics;
|
|
28
|
-
};
|
|
29
|
-
type SeoInput = {
|
|
30
|
-
content: string;
|
|
31
|
-
title?: string;
|
|
32
|
-
metaDescription?: string;
|
|
33
|
-
targetKeywords?: string[];
|
|
34
|
-
};
|
|
35
|
-
type TargetAudience = "general" | "technical" | "academic" | "casual";
|
|
36
|
-
type ReadabilityMetrics = {
|
|
37
|
-
sentenceCount: number;
|
|
38
|
-
wordCount: number;
|
|
39
|
-
avgWordsPerSentence: number;
|
|
40
|
-
avgSyllablesPerWord: number;
|
|
41
|
-
complexWordCount: number;
|
|
42
|
-
complexWordPercentage: number;
|
|
43
|
-
};
|
|
44
|
-
type TargetScore = {
|
|
45
|
-
min: number;
|
|
46
|
-
max: number;
|
|
47
|
-
description: string;
|
|
48
|
-
};
|
|
49
|
-
type ReadabilityResult = {
|
|
50
|
-
fleschKincaidReadingEase: number;
|
|
51
|
-
fleschKincaidGradeLevel: number;
|
|
52
|
-
readabilityLevel: string;
|
|
53
|
-
targetScore: TargetScore;
|
|
54
|
-
isOnTarget: boolean;
|
|
55
|
-
suggestions: string[];
|
|
56
|
-
metrics: ReadabilityMetrics;
|
|
57
|
-
};
|
|
58
|
-
type ContentType = "how-to" | "comparison" | "explainer" | "listicle" | "general";
|
|
59
|
-
type StructureIssue = {
|
|
60
|
-
type: string;
|
|
61
|
-
severity: Severity;
|
|
62
|
-
message: string;
|
|
63
|
-
suggestion: string;
|
|
64
|
-
};
|
|
65
|
-
type ContentStructure = {
|
|
66
|
-
hasQuickAnswer: boolean;
|
|
67
|
-
headingHierarchyValid: boolean;
|
|
68
|
-
avgParagraphLength: number;
|
|
69
|
-
hasTableOfContents: boolean;
|
|
70
|
-
hasTables: boolean;
|
|
71
|
-
hasConclusion: boolean;
|
|
72
|
-
headingCount: number;
|
|
73
|
-
wordCount: number;
|
|
74
|
-
};
|
|
75
|
-
type StructureResult = {
|
|
76
|
-
score: number;
|
|
77
|
-
issues: StructureIssue[];
|
|
78
|
-
structure: ContentStructure;
|
|
79
|
-
};
|
|
80
|
-
type BadPatternType = "word_count_mention" | "word_count_in_title" | "meta_commentary" | "engagement_begging" | "endless_introduction" | "vague_instructions" | "clickbait_markers" | "filler_phrases" | "over_formatting" | "wall_of_text" | "keyword_stuffing";
|
|
81
|
-
type BadPattern = {
|
|
82
|
-
pattern: string;
|
|
83
|
-
severity: "error" | "warning";
|
|
84
|
-
locations: string[];
|
|
85
|
-
suggestion: string;
|
|
86
|
-
};
|
|
87
|
-
type BadPatternResult = {
|
|
88
|
-
hasIssues: boolean;
|
|
89
|
-
issueCount: number;
|
|
90
|
-
patterns: BadPattern[];
|
|
91
|
-
};
|
|
92
|
-
type KeywordLocationType = "title" | "heading" | "paragraph" | "first100words" | "last100words";
|
|
93
|
-
type KeywordStatus = "optimal" | "low" | "high" | "missing";
|
|
94
|
-
type KeywordLocation = {
|
|
95
|
-
type: KeywordLocationType;
|
|
96
|
-
index?: number;
|
|
97
|
-
};
|
|
98
|
-
type KeywordAnalysisItem = {
|
|
99
|
-
keyword: string;
|
|
100
|
-
count: number;
|
|
101
|
-
density: number;
|
|
102
|
-
locations: KeywordLocation[];
|
|
103
|
-
status: KeywordStatus;
|
|
104
|
-
suggestion?: string;
|
|
105
|
-
};
|
|
106
|
-
type TopKeyword = {
|
|
107
|
-
keyword: string;
|
|
108
|
-
count: number;
|
|
109
|
-
density: number;
|
|
110
|
-
};
|
|
111
|
-
type KeywordMetrics = {
|
|
112
|
-
totalWordCount: number;
|
|
113
|
-
uniqueWordCount: number;
|
|
114
|
-
avgKeywordDensity: number;
|
|
115
|
-
};
|
|
116
|
-
type KeywordAnalysisResult = {
|
|
117
|
-
analysis: KeywordAnalysisItem[];
|
|
118
|
-
overallScore: number;
|
|
119
|
-
topKeywords: TopKeyword[];
|
|
120
|
-
recommendations: string[];
|
|
121
|
-
metrics: KeywordMetrics;
|
|
122
|
-
};
|
|
123
|
-
type KeywordInput = {
|
|
124
|
-
content: string;
|
|
125
|
-
title?: string;
|
|
126
|
-
targetKeywords: string[];
|
|
127
|
-
};
|
|
128
|
-
type ContentAnalysisResult = {
|
|
129
|
-
seo: SeoResult;
|
|
130
|
-
readability: ReadabilityResult;
|
|
131
|
-
structure: StructureResult;
|
|
132
|
-
badPatterns: BadPatternResult;
|
|
133
|
-
keywords: KeywordAnalysisResult | null;
|
|
134
|
-
analyzedAt: string;
|
|
135
|
-
};
|
|
136
|
-
type AnalysisInput = {
|
|
137
|
-
content: string;
|
|
138
|
-
title?: string;
|
|
139
|
-
description?: string;
|
|
140
|
-
targetKeywords?: string[];
|
|
141
|
-
};
|
|
142
|
-
/**
|
|
143
|
-
* Analyze content for bad patterns
|
|
144
|
-
*/
|
|
145
|
-
declare function analyzeBadPatterns(content: string, title?: string): BadPatternResult;
|
|
146
|
-
/**
|
|
147
|
-
* Analyze keyword usage in content
|
|
148
|
-
*/
|
|
149
|
-
declare function analyzeKeywords(input: KeywordInput): KeywordAnalysisResult;
|
|
150
|
-
/**
|
|
151
|
-
* Analyze content readability
|
|
152
|
-
*/
|
|
153
|
-
declare function analyzeReadability(content: string, targetAudience?: TargetAudience): ReadabilityResult;
|
|
154
|
-
/**
|
|
155
|
-
* Analyze content for SEO optimization
|
|
156
|
-
*/
|
|
157
|
-
declare function analyzeSeo(input: SeoInput): SeoResult;
|
|
158
|
-
/**
|
|
159
|
-
* Analyze content structure
|
|
160
|
-
*/
|
|
161
|
-
declare function analyzeStructure(content: string, contentType?: ContentType): StructureResult;
|
|
162
|
-
/**
|
|
163
|
-
* Shared utility functions for content analysis
|
|
164
|
-
*/
|
|
165
|
-
/**
|
|
166
|
-
* Count syllables in a word using a simplified vowel group algorithm
|
|
167
|
-
*/
|
|
168
|
-
declare function countSyllables(word: string): number;
|
|
169
|
-
/**
|
|
170
|
-
* Calculate Flesch-Kincaid readability metrics
|
|
171
|
-
*/
|
|
172
|
-
declare function calculateFleschKincaid(text: string): {
|
|
173
|
-
readingEase: number;
|
|
174
|
-
gradeLevel: number;
|
|
175
|
-
};
|
|
176
|
-
/**
|
|
177
|
-
* Convert reading ease score to human-readable level
|
|
178
|
-
*/
|
|
179
|
-
declare function getReadabilityLevel(score: number): string;
|
|
180
|
-
/**
|
|
181
|
-
* Find all occurrences of a regex pattern with surrounding context
|
|
182
|
-
*/
|
|
183
|
-
declare function findOccurrences(regex: RegExp, text: string): string[];
|
|
184
|
-
/**
|
|
185
|
-
* Extract words from content
|
|
186
|
-
*/
|
|
187
|
-
declare function extractWords(content: string): string[];
|
|
188
|
-
/**
|
|
189
|
-
* Extract paragraphs from content
|
|
190
|
-
*/
|
|
191
|
-
declare function extractParagraphs(content: string): string[];
|
|
192
|
-
/**
|
|
193
|
-
* Extract headings from markdown content
|
|
194
|
-
*/
|
|
195
|
-
declare function extractHeadings(content: string): Array<{
|
|
196
|
-
level: number;
|
|
197
|
-
text: string;
|
|
198
|
-
index: number;
|
|
199
|
-
}>;
|
|
200
|
-
/**
|
|
201
|
-
* Clamp score between 0 and 100
|
|
202
|
-
*/
|
|
203
|
-
declare function clampScore(score: number): number;
|
|
204
|
-
/**
|
|
205
|
-
* Check if content has a quick answer pattern in the first portion
|
|
206
|
-
*/
|
|
207
|
-
declare function hasQuickAnswerPattern(text: string): boolean;
|
|
208
|
-
/**
|
|
209
|
-
* Check if content has a conclusion section
|
|
210
|
-
*/
|
|
211
|
-
declare function hasConclusionSection(content: string): boolean;
|
|
212
|
-
/**
|
|
213
|
-
* Perform a comprehensive content analysis
|
|
214
|
-
*
|
|
215
|
-
* This function runs all available analyzers and returns a combined result:
|
|
216
|
-
* - SEO analysis (title, meta, keywords, structure)
|
|
217
|
-
* - Readability analysis (Flesch-Kincaid scores)
|
|
218
|
-
* - Structure analysis (headings, paragraphs, quick answers)
|
|
219
|
-
* - Bad pattern detection (filler phrases, clickbait, etc.)
|
|
220
|
-
* - Keyword analysis (density, placement, recommendations)
|
|
221
|
-
*
|
|
222
|
-
* @param input - The content and metadata to analyze
|
|
223
|
-
* @returns Combined analysis results from all analyzers
|
|
224
|
-
*
|
|
225
|
-
* @example
|
|
226
|
-
* ```typescript
|
|
227
|
-
* import { analyzeContent } from '@f-o-t/content-analysis';
|
|
228
|
-
*
|
|
229
|
-
* const result = analyzeContent({
|
|
230
|
-
* content: '## Introduction\n\nThis is my blog post...',
|
|
231
|
-
* title: 'My Blog Post Title',
|
|
232
|
-
* description: 'A short description for SEO',
|
|
233
|
-
* targetKeywords: ['blog', 'tutorial'],
|
|
234
|
-
* });
|
|
235
|
-
*
|
|
236
|
-
* console.log(result.seo.score); // 85
|
|
237
|
-
* console.log(result.readability.fleschKincaidReadingEase); // 65.2
|
|
238
|
-
* ```
|
|
239
|
-
*/
|
|
240
|
-
declare function analyzeContent(input: AnalysisInput): ContentAnalysisResult;
|
|
241
|
-
export { hasQuickAnswerPattern, hasConclusionSection, getReadabilityLevel, findOccurrences, extractWords, extractParagraphs, extractHeadings, countSyllables, clampScore, calculateFleschKincaid, analyzeStructure, analyzeSeo, analyzeReadability, analyzeKeywords, analyzeContent, analyzeBadPatterns, TopKeyword, TargetScore, TargetAudience, StructureResult, StructureIssue, Severity, SeoResult, SeoMetrics, SeoIssueType, SeoIssue, SeoInput, ReadabilityResult, ReadabilityMetrics, KeywordStatus, KeywordMetrics, KeywordLocationType, KeywordLocation, KeywordInput, KeywordAnalysisResult, KeywordAnalysisItem, ContentType, ContentStructure, ContentAnalysisResult, BadPatternType, BadPatternResult, BadPattern, AnalysisInput };
|
package/dist/index.js
DELETED
|
@@ -1,891 +0,0 @@
|
|
|
1
|
-
// src/utils.ts
|
|
2
|
-
function countSyllables(word) {
|
|
3
|
-
const w = word.toLowerCase();
|
|
4
|
-
if (w.length <= 3)
|
|
5
|
-
return 1;
|
|
6
|
-
const vowelGroups = w.match(/[aeiouy]+/g) || [];
|
|
7
|
-
let count = vowelGroups.length;
|
|
8
|
-
if (w.endsWith("e"))
|
|
9
|
-
count--;
|
|
10
|
-
return Math.max(1, count);
|
|
11
|
-
}
|
|
12
|
-
function calculateFleschKincaid(text) {
|
|
13
|
-
const cleanText = text.replace(/[^\w\s.!?]/g, "");
|
|
14
|
-
const sentences = cleanText.split(/[.!?]+/).filter(Boolean);
|
|
15
|
-
const words = cleanText.split(/\s+/).filter(Boolean);
|
|
16
|
-
if (words.length === 0 || sentences.length === 0) {
|
|
17
|
-
return { readingEase: 0, gradeLevel: 0 };
|
|
18
|
-
}
|
|
19
|
-
const totalSyllables = words.reduce((sum, word) => sum + countSyllables(word), 0);
|
|
20
|
-
const avgWordsPerSentence = words.length / sentences.length;
|
|
21
|
-
const avgSyllablesPerWord = totalSyllables / words.length;
|
|
22
|
-
const readingEase = 206.835 - 1.015 * avgWordsPerSentence - 84.6 * avgSyllablesPerWord;
|
|
23
|
-
const gradeLevel = 0.39 * avgWordsPerSentence + 11.8 * avgSyllablesPerWord - 15.59;
|
|
24
|
-
return {
|
|
25
|
-
readingEase: Math.max(0, Math.min(100, Math.round(readingEase * 10) / 10)),
|
|
26
|
-
gradeLevel: Math.max(0, Math.round(gradeLevel * 10) / 10)
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
function getReadabilityLevel(score) {
|
|
30
|
-
if (score >= 90)
|
|
31
|
-
return "Very Easy (5th grade)";
|
|
32
|
-
if (score >= 80)
|
|
33
|
-
return "Easy (6th grade)";
|
|
34
|
-
if (score >= 70)
|
|
35
|
-
return "Fairly Easy (7th grade)";
|
|
36
|
-
if (score >= 60)
|
|
37
|
-
return "Standard (8th-9th grade)";
|
|
38
|
-
if (score >= 50)
|
|
39
|
-
return "Fairly Difficult (10th-12th grade)";
|
|
40
|
-
if (score >= 30)
|
|
41
|
-
return "Difficult (College)";
|
|
42
|
-
return "Very Difficult (College Graduate)";
|
|
43
|
-
}
|
|
44
|
-
function findOccurrences(regex, text) {
|
|
45
|
-
const matches = [];
|
|
46
|
-
const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
|
|
47
|
-
const globalRegex = new RegExp(regex.source, flags);
|
|
48
|
-
let match;
|
|
49
|
-
while ((match = globalRegex.exec(text)) !== null) {
|
|
50
|
-
const start = Math.max(0, match.index - 20);
|
|
51
|
-
const end = Math.min(text.length, match.index + match[0].length + 20);
|
|
52
|
-
const context = text.slice(start, end);
|
|
53
|
-
matches.push(`...${context}...`);
|
|
54
|
-
}
|
|
55
|
-
return matches;
|
|
56
|
-
}
|
|
57
|
-
function extractWords(content) {
|
|
58
|
-
return content.split(/\s+/).filter(Boolean);
|
|
59
|
-
}
|
|
60
|
-
function extractParagraphs(content) {
|
|
61
|
-
return content.split(/\n\n+/).filter(Boolean);
|
|
62
|
-
}
|
|
63
|
-
function extractHeadings(content) {
|
|
64
|
-
const headingMatches = [...content.matchAll(/^(#{1,6})\s+(.+)$/gm)];
|
|
65
|
-
const headings = [];
|
|
66
|
-
for (const match of headingMatches) {
|
|
67
|
-
const hashMarks = match[1];
|
|
68
|
-
const headingText = match[2];
|
|
69
|
-
if (hashMarks && headingText) {
|
|
70
|
-
headings.push({
|
|
71
|
-
level: hashMarks.length,
|
|
72
|
-
text: headingText,
|
|
73
|
-
index: match.index ?? 0
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return headings;
|
|
78
|
-
}
|
|
79
|
-
function clampScore(score) {
|
|
80
|
-
return Math.max(0, Math.min(100, score));
|
|
81
|
-
}
|
|
82
|
-
function hasQuickAnswerPattern(text) {
|
|
83
|
-
return /\*\*quick\s*answer\*\*|>.*quick.*answer|tl;?dr|em\s+resumo|resumindo/i.test(text) || /^.*?\*\*[^*]+\*\*\s+(?:é|is|are|was|were|significa)\s/im.test(text) || /^\|.*\|.*\|$/m.test(text);
|
|
84
|
-
}
|
|
85
|
-
function hasConclusionSection(content) {
|
|
86
|
-
return /##\s*(?:conclus|conclusion|resumo|takeaway|key\s*takeaway|final|wrapping\s*up)/i.test(content);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// src/bad-patterns.ts
|
|
90
|
-
function analyzeBadPatterns(content, title) {
|
|
91
|
-
const patterns = [];
|
|
92
|
-
const wordCountPattern = /\b\d+\+?\s*(?:palavras?|words?)\b|~\s*\d+\s*(?:palavras?|words?)|word\s*count|contagem\s*de\s*palavras/gi;
|
|
93
|
-
const wordCountMatches = findOccurrences(wordCountPattern, content);
|
|
94
|
-
if (wordCountMatches.length > 0) {
|
|
95
|
-
patterns.push({
|
|
96
|
-
pattern: "word_count_mention",
|
|
97
|
-
severity: "warning",
|
|
98
|
-
locations: wordCountMatches,
|
|
99
|
-
suggestion: "Remove word count mentions. Readers don't care about article length."
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
if (title) {
|
|
103
|
-
wordCountPattern.lastIndex = 0;
|
|
104
|
-
const titleWordCountMatch = wordCountPattern.exec(title);
|
|
105
|
-
if (titleWordCountMatch) {
|
|
106
|
-
patterns.push({
|
|
107
|
-
pattern: "word_count_in_title",
|
|
108
|
-
severity: "warning",
|
|
109
|
-
locations: [`Title: "${title}"`],
|
|
110
|
-
suggestion: "Remove word count claims from title. Focus on value, not length."
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const metaCommentaryPatterns = [
|
|
115
|
-
/\b(?:neste\s+artigo|in\s+this\s+(?:article|post|guide))\b/gi,
|
|
116
|
-
/\b(?:como\s+mencionado|as\s+(?:mentioned|discussed|noted)\s+(?:above|earlier|before))\b/gi,
|
|
117
|
-
/\b(?:vamos\s+explorar|let'?s\s+explore|we\s+will\s+(?:discuss|explore|cover))\b/gi,
|
|
118
|
-
/\b(?:conforme\s+vimos|as\s+we\s+(?:saw|discussed|covered))\b/gi
|
|
119
|
-
];
|
|
120
|
-
for (const pattern of metaCommentaryPatterns) {
|
|
121
|
-
const matches = findOccurrences(pattern, content);
|
|
122
|
-
if (matches.length > 0) {
|
|
123
|
-
patterns.push({
|
|
124
|
-
pattern: "meta_commentary",
|
|
125
|
-
severity: "warning",
|
|
126
|
-
locations: matches,
|
|
127
|
-
suggestion: "Remove meta-commentary. Just deliver the information directly."
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const engagementPatterns = [
|
|
132
|
-
/\b(?:não\s+esqueça\s+de|don'?t\s+forget\s+to)\s+(?:curtir|like|subscribe|seguir|compartilhar|share)/gi,
|
|
133
|
-
/\b(?:deixe\s+(?:um\s+)?comentário|leave\s+a\s+comment|comment\s+below)/gi,
|
|
134
|
-
/\b(?:inscreva-se|subscribe|sign\s+up)\s+(?:para|to|for)\s+(?:nossa|my|our|the)\s+(?:newsletter|canal|channel)/gi,
|
|
135
|
-
/\b(?:compartilhe\s+com|share\s+(?:this|with))\s+(?:seus\s+amigos|your\s+friends)/gi,
|
|
136
|
-
/\bsmash\s+(?:that\s+)?(?:like|subscribe)\s+button\b/gi
|
|
137
|
-
];
|
|
138
|
-
for (const pattern of engagementPatterns) {
|
|
139
|
-
const matches = findOccurrences(pattern, content);
|
|
140
|
-
if (matches.length > 0) {
|
|
141
|
-
patterns.push({
|
|
142
|
-
pattern: "engagement_begging",
|
|
143
|
-
severity: "warning",
|
|
144
|
-
locations: matches,
|
|
145
|
-
suggestion: "Remove engagement begging. Let quality content earn engagement naturally."
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const firstH2Index = content.search(/^##\s+/m);
|
|
150
|
-
if (firstH2Index > 0) {
|
|
151
|
-
const introText = content.slice(0, firstH2Index);
|
|
152
|
-
const introWords = extractWords(introText).length;
|
|
153
|
-
if (introWords > 150) {
|
|
154
|
-
patterns.push({
|
|
155
|
-
pattern: "endless_introduction",
|
|
156
|
-
severity: "warning",
|
|
157
|
-
locations: [`Introduction: ~${introWords} words before first H2`],
|
|
158
|
-
suggestion: "Shorten introduction to under 150 words. Get to the point faster."
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const vaguePatterns = [
|
|
163
|
-
/\b(?:configure\s+(?:appropriately|properly|correctly))\b/gi,
|
|
164
|
-
/\b(?:set\s+up\s+(?:as\s+needed|accordingly))\b/gi,
|
|
165
|
-
/\b(?:adjust\s+(?:as\s+necessary|accordingly|as\s+needed))\b/gi,
|
|
166
|
-
/\b(?:use\s+(?:the\s+right|appropriate|suitable)\s+(?:settings|options|values))\b/gi
|
|
167
|
-
];
|
|
168
|
-
for (const pattern of vaguePatterns) {
|
|
169
|
-
const matches = findOccurrences(pattern, content);
|
|
170
|
-
if (matches.length > 0) {
|
|
171
|
-
patterns.push({
|
|
172
|
-
pattern: "vague_instructions",
|
|
173
|
-
severity: "warning",
|
|
174
|
-
locations: matches,
|
|
175
|
-
suggestion: "Be specific. Instead of 'configure appropriately', say exactly what to configure and how."
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const clickbaitPatterns = [
|
|
180
|
-
/\b(?:you\s+won'?t\s+believe|você\s+não\s+vai\s+acreditar)\b/gi,
|
|
181
|
-
/\b(?:this\s+one\s+(?:trick|tip|secret))\b/gi,
|
|
182
|
-
/\b(?:AMAZING|INCREDIBLE|MIND-?BLOWING)\b/g,
|
|
183
|
-
/!!+|\?!+|!{3,}/g
|
|
184
|
-
];
|
|
185
|
-
for (const pattern of clickbaitPatterns) {
|
|
186
|
-
const matches = findOccurrences(pattern, content);
|
|
187
|
-
if (matches.length > 0) {
|
|
188
|
-
patterns.push({
|
|
189
|
-
pattern: "clickbait_markers",
|
|
190
|
-
severity: "warning",
|
|
191
|
-
locations: matches,
|
|
192
|
-
suggestion: "Remove clickbait language. Use accurate, professional language instead."
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const fillerPatterns = [
|
|
197
|
-
/\b(?:it\s+goes\s+without\s+saying|vai\s+sem\s+dizer)\b/gi,
|
|
198
|
-
/\b(?:without\s+further\s+ado|sem\s+mais\s+delongas)\b/gi,
|
|
199
|
-
/\b(?:at\s+the\s+end\s+of\s+the\s+day|no\s+final\s+das\s+contas)\b/gi,
|
|
200
|
-
/\b(?:in\s+today'?s\s+(?:digital\s+)?(?:landscape|world|age))\b/gi,
|
|
201
|
-
/\b(?:(?:as\s+)?a\s+matter\s+of\s+fact)\b/gi,
|
|
202
|
-
/\b(?:needless\s+to\s+say|escusado\s+será\s+dizer)\b/gi,
|
|
203
|
-
/\b(?:in\s+(?:conclusion|summary)|em\s+(?:conclusão|resumo))(?:\s*[,:])\b/gi
|
|
204
|
-
];
|
|
205
|
-
for (const pattern of fillerPatterns) {
|
|
206
|
-
const matches = findOccurrences(pattern, content);
|
|
207
|
-
if (matches.length > 0) {
|
|
208
|
-
patterns.push({
|
|
209
|
-
pattern: "filler_phrases",
|
|
210
|
-
severity: "warning",
|
|
211
|
-
locations: matches,
|
|
212
|
-
suggestion: "Remove filler phrases. They add no value and waste reader's time."
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
const overFormattingPattern = /(\*{1,2}[^*]+\*{1,2}\s*){3,}|(_{1,2}[^_]+_{1,2}\s*){3,}/g;
|
|
217
|
-
const overFormattingMatches = findOccurrences(overFormattingPattern, content);
|
|
218
|
-
if (overFormattingMatches.length > 0) {
|
|
219
|
-
patterns.push({
|
|
220
|
-
pattern: "over_formatting",
|
|
221
|
-
severity: "warning",
|
|
222
|
-
locations: overFormattingMatches,
|
|
223
|
-
suggestion: "Reduce consecutive formatting. Use bold/italic sparingly for emphasis."
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
const paragraphs = extractParagraphs(content);
|
|
227
|
-
const longParagraphs = [];
|
|
228
|
-
for (const paragraph of paragraphs) {
|
|
229
|
-
if (paragraph.startsWith("```") || paragraph.startsWith("#"))
|
|
230
|
-
continue;
|
|
231
|
-
const wordCount = extractWords(paragraph).length;
|
|
232
|
-
if (wordCount > 100) {
|
|
233
|
-
const preview = paragraph.slice(0, 50) + "..." + paragraph.slice(-30);
|
|
234
|
-
longParagraphs.push(`~${wordCount} words: "${preview}"`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (longParagraphs.length > 0) {
|
|
238
|
-
patterns.push({
|
|
239
|
-
pattern: "wall_of_text",
|
|
240
|
-
severity: "warning",
|
|
241
|
-
locations: longParagraphs,
|
|
242
|
-
suggestion: "Break up long paragraphs. Keep paragraphs under 100 words for better readability."
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
const wordsLower = content.toLowerCase();
|
|
246
|
-
const totalWords = extractWords(content).length;
|
|
247
|
-
const phraseCount = {};
|
|
248
|
-
const tokens = wordsLower.match(/\b[a-záàâãéèêíïóôõöúç]{3,}\b/g) || [];
|
|
249
|
-
for (let i = 0;i < tokens.length - 1; i++) {
|
|
250
|
-
const bigram = `${tokens[i]} ${tokens[i + 1]}`;
|
|
251
|
-
phraseCount[bigram] = (phraseCount[bigram] || 0) + 1;
|
|
252
|
-
}
|
|
253
|
-
const stuffedPhrases = [];
|
|
254
|
-
for (const [phrase, count] of Object.entries(phraseCount)) {
|
|
255
|
-
const density = count / totalWords * 100;
|
|
256
|
-
if (density > 3 && count > 5) {
|
|
257
|
-
stuffedPhrases.push(`"${phrase}" appears ${count} times (${density.toFixed(1)}% density)`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if (stuffedPhrases.length > 0) {
|
|
261
|
-
patterns.push({
|
|
262
|
-
pattern: "keyword_stuffing",
|
|
263
|
-
severity: "warning",
|
|
264
|
-
locations: stuffedPhrases,
|
|
265
|
-
suggestion: "Reduce keyword repetition. Use synonyms and natural language variation."
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
return {
|
|
269
|
-
hasIssues: patterns.length > 0,
|
|
270
|
-
issueCount: patterns.length,
|
|
271
|
-
patterns
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
// src/keywords.ts
|
|
275
|
-
function analyzeKeywords(input) {
|
|
276
|
-
const { content, title, targetKeywords } = input;
|
|
277
|
-
const analysis = [];
|
|
278
|
-
const recommendations = [];
|
|
279
|
-
const words = extractWords(content);
|
|
280
|
-
const totalWordCount = words.length;
|
|
281
|
-
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
|
|
282
|
-
const contentLower = content.toLowerCase();
|
|
283
|
-
const titleLower = title?.toLowerCase() || "";
|
|
284
|
-
const headings = content.match(/^#{2,6}\s+(.+)$/gm) || [];
|
|
285
|
-
const headingsText = headings.join(" ").toLowerCase();
|
|
286
|
-
const first100Words = words.slice(0, 100).join(" ").toLowerCase();
|
|
287
|
-
const last100Words = words.slice(-100).join(" ").toLowerCase();
|
|
288
|
-
let totalDensity = 0;
|
|
289
|
-
for (const keyword of targetKeywords) {
|
|
290
|
-
const keywordLower = keyword.toLowerCase();
|
|
291
|
-
const regex = new RegExp(keywordLower, "gi");
|
|
292
|
-
const matches = contentLower.match(regex) || [];
|
|
293
|
-
const count = matches.length;
|
|
294
|
-
const density = totalWordCount > 0 ? count / totalWordCount * 100 : 0;
|
|
295
|
-
totalDensity += density;
|
|
296
|
-
const locations = [];
|
|
297
|
-
if (titleLower.includes(keywordLower)) {
|
|
298
|
-
locations.push({ type: "title" });
|
|
299
|
-
}
|
|
300
|
-
if (headingsText.includes(keywordLower)) {
|
|
301
|
-
locations.push({ type: "heading" });
|
|
302
|
-
}
|
|
303
|
-
if (first100Words.includes(keywordLower)) {
|
|
304
|
-
locations.push({ type: "first100words" });
|
|
305
|
-
}
|
|
306
|
-
if (last100Words.includes(keywordLower)) {
|
|
307
|
-
locations.push({ type: "last100words" });
|
|
308
|
-
}
|
|
309
|
-
let status;
|
|
310
|
-
let suggestion;
|
|
311
|
-
if (count === 0) {
|
|
312
|
-
status = "missing";
|
|
313
|
-
suggestion = `Add "${keyword}" naturally to your content`;
|
|
314
|
-
} else if (density < 0.5) {
|
|
315
|
-
status = "low";
|
|
316
|
-
suggestion = `Consider using "${keyword}" a few more times`;
|
|
317
|
-
} else if (density > 3) {
|
|
318
|
-
status = "high";
|
|
319
|
-
suggestion = `Reduce usage of "${keyword}" - it may appear spammy`;
|
|
320
|
-
} else {
|
|
321
|
-
status = "optimal";
|
|
322
|
-
}
|
|
323
|
-
analysis.push({
|
|
324
|
-
keyword,
|
|
325
|
-
count,
|
|
326
|
-
density: Math.round(density * 100) / 100,
|
|
327
|
-
locations,
|
|
328
|
-
status,
|
|
329
|
-
suggestion
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
let overallScore = 100;
|
|
333
|
-
for (const item of analysis) {
|
|
334
|
-
if (item.status === "missing")
|
|
335
|
-
overallScore -= 15;
|
|
336
|
-
else if (item.status === "low")
|
|
337
|
-
overallScore -= 10;
|
|
338
|
-
else if (item.status === "high")
|
|
339
|
-
overallScore -= 5;
|
|
340
|
-
}
|
|
341
|
-
overallScore = Math.max(0, Math.min(100, overallScore));
|
|
342
|
-
const missingKeywords = analysis.filter((a) => a.status === "missing");
|
|
343
|
-
const lowKeywords = analysis.filter((a) => a.status === "low");
|
|
344
|
-
const highKeywords = analysis.filter((a) => a.status === "high");
|
|
345
|
-
if (missingKeywords.length > 0) {
|
|
346
|
-
recommendations.push(`Add missing keywords: ${missingKeywords.map((k) => k.keyword).join(", ")}`);
|
|
347
|
-
}
|
|
348
|
-
if (lowKeywords.length > 0) {
|
|
349
|
-
recommendations.push(`Increase usage of: ${lowKeywords.map((k) => k.keyword).join(", ")}`);
|
|
350
|
-
}
|
|
351
|
-
if (highKeywords.length > 0) {
|
|
352
|
-
recommendations.push(`Reduce overused keywords: ${highKeywords.map((k) => k.keyword).join(", ")}`);
|
|
353
|
-
}
|
|
354
|
-
const phraseCount = {};
|
|
355
|
-
const tokens = contentLower.match(/\b[a-záàâãéèêíïóôõöúç]{3,}\b/g) || [];
|
|
356
|
-
for (const token of tokens) {
|
|
357
|
-
phraseCount[token] = (phraseCount[token] || 0) + 1;
|
|
358
|
-
}
|
|
359
|
-
const topKeywords = Object.entries(phraseCount).filter(([word]) => word.length > 4 && !["that", "this", "with", "from", "have", "been"].includes(word)).sort(([, a], [, b]) => b - a).slice(0, 10).map(([keyword, count]) => ({
|
|
360
|
-
keyword,
|
|
361
|
-
count,
|
|
362
|
-
density: Math.round(count / totalWordCount * 1e4) / 100
|
|
363
|
-
}));
|
|
364
|
-
const metrics = {
|
|
365
|
-
totalWordCount,
|
|
366
|
-
uniqueWordCount: uniqueWords.size,
|
|
367
|
-
avgKeywordDensity: targetKeywords.length > 0 ? Math.round(totalDensity / targetKeywords.length * 100) / 100 : 0
|
|
368
|
-
};
|
|
369
|
-
return {
|
|
370
|
-
analysis,
|
|
371
|
-
overallScore,
|
|
372
|
-
topKeywords,
|
|
373
|
-
recommendations,
|
|
374
|
-
metrics
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
// src/readability.ts
|
|
378
|
-
var TARGET_SCORES = {
|
|
379
|
-
general: {
|
|
380
|
-
min: 60,
|
|
381
|
-
max: 70,
|
|
382
|
-
description: "Easy to read for general audience"
|
|
383
|
-
},
|
|
384
|
-
technical: {
|
|
385
|
-
min: 40,
|
|
386
|
-
max: 60,
|
|
387
|
-
description: "Technical but accessible"
|
|
388
|
-
},
|
|
389
|
-
academic: {
|
|
390
|
-
min: 30,
|
|
391
|
-
max: 50,
|
|
392
|
-
description: "Academic/professional level"
|
|
393
|
-
},
|
|
394
|
-
casual: {
|
|
395
|
-
min: 70,
|
|
396
|
-
max: 80,
|
|
397
|
-
description: "Very easy, conversational"
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
function analyzeReadability(content, targetAudience = "general") {
|
|
401
|
-
const { readingEase, gradeLevel } = calculateFleschKincaid(content);
|
|
402
|
-
const readabilityLevel = getReadabilityLevel(readingEase);
|
|
403
|
-
const targetScore = TARGET_SCORES[targetAudience];
|
|
404
|
-
const isOnTarget = readingEase >= targetScore.min && readingEase <= targetScore.max;
|
|
405
|
-
const cleanText = content.replace(/[^\w\s.!?]/g, "");
|
|
406
|
-
const sentences = cleanText.split(/[.!?]+/).filter(Boolean);
|
|
407
|
-
const words = cleanText.split(/\s+/).filter(Boolean);
|
|
408
|
-
const complexWords = words.filter((w) => countSyllables(w) >= 3);
|
|
409
|
-
const totalSyllables = words.reduce((sum, word) => sum + countSyllables(word), 0);
|
|
410
|
-
const suggestions = [];
|
|
411
|
-
if (readingEase < targetScore.min) {
|
|
412
|
-
suggestions.push("Simplify your language - use shorter words and sentences");
|
|
413
|
-
const avgWordsPerSentence = words.length / sentences.length;
|
|
414
|
-
if (avgWordsPerSentence > 20) {
|
|
415
|
-
suggestions.push(`Average sentence length is ${Math.round(avgWordsPerSentence)} words. Try to keep it under 20.`);
|
|
416
|
-
}
|
|
417
|
-
if (complexWords.length / words.length > 0.2) {
|
|
418
|
-
suggestions.push("Too many complex words (3+ syllables). Replace with simpler alternatives.");
|
|
419
|
-
}
|
|
420
|
-
suggestions.push("Break long sentences into shorter ones");
|
|
421
|
-
suggestions.push("Use active voice instead of passive voice");
|
|
422
|
-
} else if (readingEase > targetScore.max && targetAudience !== "casual") {
|
|
423
|
-
suggestions.push("Content may be too simple for your target audience");
|
|
424
|
-
suggestions.push("Consider adding more technical depth or detail");
|
|
425
|
-
}
|
|
426
|
-
if (sentences.some((s) => s.split(/\s+/).length > 40)) {
|
|
427
|
-
suggestions.push("Some sentences are very long. Consider breaking them up.");
|
|
428
|
-
}
|
|
429
|
-
const metrics = {
|
|
430
|
-
sentenceCount: sentences.length,
|
|
431
|
-
wordCount: words.length,
|
|
432
|
-
avgWordsPerSentence: sentences.length > 0 ? Math.round(words.length / sentences.length * 10) / 10 : 0,
|
|
433
|
-
avgSyllablesPerWord: words.length > 0 ? Math.round(totalSyllables / words.length * 100) / 100 : 0,
|
|
434
|
-
complexWordCount: complexWords.length,
|
|
435
|
-
complexWordPercentage: words.length > 0 ? Math.round(complexWords.length / words.length * 1000) / 10 : 0
|
|
436
|
-
};
|
|
437
|
-
return {
|
|
438
|
-
fleschKincaidReadingEase: readingEase,
|
|
439
|
-
fleschKincaidGradeLevel: gradeLevel,
|
|
440
|
-
readabilityLevel,
|
|
441
|
-
targetScore,
|
|
442
|
-
isOnTarget,
|
|
443
|
-
suggestions,
|
|
444
|
-
metrics
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
// src/seo.ts
|
|
448
|
-
function analyzeSeo(input) {
|
|
449
|
-
const { content, title, metaDescription, targetKeywords } = input;
|
|
450
|
-
const issues = [];
|
|
451
|
-
const recommendations = [];
|
|
452
|
-
const words = extractWords(content);
|
|
453
|
-
const wordCount = words.length;
|
|
454
|
-
const paragraphs = extractParagraphs(content);
|
|
455
|
-
const headings = content.match(/^#{1,6}\s.+$/gm) || [];
|
|
456
|
-
const h2Headings = content.match(/^##\s.+$/gm) || [];
|
|
457
|
-
const links = content.match(/\[.+?\]\(.+?\)/g) || [];
|
|
458
|
-
const images = content.match(/!\[.+?\]\(.+?\)/g) || [];
|
|
459
|
-
const firstH2Index = content.search(/^##\s/m);
|
|
460
|
-
const firstParagraphText = firstH2Index > 0 ? content.slice(0, firstH2Index) : words.slice(0, 100).join(" ");
|
|
461
|
-
let score = 100;
|
|
462
|
-
if (!title) {
|
|
463
|
-
issues.push({
|
|
464
|
-
type: "title",
|
|
465
|
-
severity: "error",
|
|
466
|
-
message: "Missing title",
|
|
467
|
-
suggestion: "Add a descriptive title (50-60 characters)"
|
|
468
|
-
});
|
|
469
|
-
score -= 15;
|
|
470
|
-
} else if (title.length < 30) {
|
|
471
|
-
issues.push({
|
|
472
|
-
type: "title",
|
|
473
|
-
severity: "warning",
|
|
474
|
-
message: "Title is too short",
|
|
475
|
-
suggestion: "Expand title to 50-60 characters for better SEO"
|
|
476
|
-
});
|
|
477
|
-
score -= 8;
|
|
478
|
-
} else if (title.length > 60) {
|
|
479
|
-
issues.push({
|
|
480
|
-
type: "title",
|
|
481
|
-
severity: "warning",
|
|
482
|
-
message: "Title is too long",
|
|
483
|
-
suggestion: "Shorten to under 60 characters to avoid truncation in search results"
|
|
484
|
-
});
|
|
485
|
-
score -= 5;
|
|
486
|
-
}
|
|
487
|
-
if (title && targetKeywords && targetKeywords.length > 0) {
|
|
488
|
-
const titleLower = title.toLowerCase();
|
|
489
|
-
const hasKeywordInTitle = targetKeywords.some((kw) => titleLower.includes(kw.toLowerCase()));
|
|
490
|
-
if (!hasKeywordInTitle) {
|
|
491
|
-
issues.push({
|
|
492
|
-
type: "title",
|
|
493
|
-
severity: "warning",
|
|
494
|
-
message: "Primary keyword not found in title",
|
|
495
|
-
suggestion: "Include your primary keyword naturally in the title"
|
|
496
|
-
});
|
|
497
|
-
score -= 5;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (!metaDescription) {
|
|
501
|
-
issues.push({
|
|
502
|
-
type: "meta_description",
|
|
503
|
-
severity: "warning",
|
|
504
|
-
message: "Missing meta description",
|
|
505
|
-
suggestion: "Add a meta description (150-160 characters)"
|
|
506
|
-
});
|
|
507
|
-
score -= 10;
|
|
508
|
-
} else if (metaDescription.length < 120) {
|
|
509
|
-
issues.push({
|
|
510
|
-
type: "meta_description",
|
|
511
|
-
severity: "info",
|
|
512
|
-
message: "Meta description could be longer",
|
|
513
|
-
suggestion: "Expand to 150-160 characters"
|
|
514
|
-
});
|
|
515
|
-
score -= 3;
|
|
516
|
-
} else if (metaDescription.length > 160) {
|
|
517
|
-
issues.push({
|
|
518
|
-
type: "meta_description",
|
|
519
|
-
severity: "warning",
|
|
520
|
-
message: "Meta description is too long",
|
|
521
|
-
suggestion: "Shorten to under 160 characters"
|
|
522
|
-
});
|
|
523
|
-
score -= 5;
|
|
524
|
-
}
|
|
525
|
-
if (headings.length === 0) {
|
|
526
|
-
issues.push({
|
|
527
|
-
type: "headings",
|
|
528
|
-
severity: "error",
|
|
529
|
-
message: "No headings found",
|
|
530
|
-
suggestion: "Add H2 and H3 headings to structure your content"
|
|
531
|
-
});
|
|
532
|
-
score -= 15;
|
|
533
|
-
} else if (h2Headings.length < 3 && wordCount > 500) {
|
|
534
|
-
issues.push({
|
|
535
|
-
type: "headings",
|
|
536
|
-
severity: "warning",
|
|
537
|
-
message: "Too few H2 headings for content length",
|
|
538
|
-
suggestion: "Add more H2 subheadings (one every 200-300 words)"
|
|
539
|
-
});
|
|
540
|
-
score -= 5;
|
|
541
|
-
}
|
|
542
|
-
const h1Headings = content.match(/^#\s.+$/gm) || [];
|
|
543
|
-
if (h1Headings.length > 0) {
|
|
544
|
-
issues.push({
|
|
545
|
-
type: "headings",
|
|
546
|
-
severity: "error",
|
|
547
|
-
message: "H1 heading found in content body",
|
|
548
|
-
suggestion: "Remove H1 from content. The title is in frontmatter. Start with H2."
|
|
549
|
-
});
|
|
550
|
-
score -= 10;
|
|
551
|
-
}
|
|
552
|
-
if (targetKeywords && targetKeywords.length > 0 && h2Headings.length > 0) {
|
|
553
|
-
const h2Text = h2Headings.join(" ").toLowerCase();
|
|
554
|
-
const hasKeywordInH2 = targetKeywords.some((kw) => h2Text.includes(kw.toLowerCase()));
|
|
555
|
-
if (!hasKeywordInH2) {
|
|
556
|
-
issues.push({
|
|
557
|
-
type: "heading_keywords",
|
|
558
|
-
severity: "info",
|
|
559
|
-
message: "Target keywords not found in any H2 headings",
|
|
560
|
-
suggestion: "Include keywords naturally in at least one H2 heading"
|
|
561
|
-
});
|
|
562
|
-
score -= 3;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
if (wordCount < 300) {
|
|
566
|
-
issues.push({
|
|
567
|
-
type: "content_length",
|
|
568
|
-
severity: "error",
|
|
569
|
-
message: "Content is too short",
|
|
570
|
-
suggestion: "Aim for at least 600-1000 words for blog posts"
|
|
571
|
-
});
|
|
572
|
-
score -= 10;
|
|
573
|
-
} else if (wordCount < 600) {
|
|
574
|
-
issues.push({
|
|
575
|
-
type: "content_length",
|
|
576
|
-
severity: "warning",
|
|
577
|
-
message: "Content could be longer",
|
|
578
|
-
suggestion: "Consider expanding to 1000+ words for better ranking"
|
|
579
|
-
});
|
|
580
|
-
score -= 5;
|
|
581
|
-
}
|
|
582
|
-
if (links.length === 0 && wordCount > 500) {
|
|
583
|
-
issues.push({
|
|
584
|
-
type: "links",
|
|
585
|
-
severity: "warning",
|
|
586
|
-
message: "No links found",
|
|
587
|
-
suggestion: "Add internal and external links to improve SEO"
|
|
588
|
-
});
|
|
589
|
-
score -= 10;
|
|
590
|
-
} else if (links.length < 3 && wordCount > 1000) {
|
|
591
|
-
issues.push({
|
|
592
|
-
type: "links",
|
|
593
|
-
severity: "info",
|
|
594
|
-
message: "Few links for content length",
|
|
595
|
-
suggestion: "Add more internal links to related content and external links to authoritative sources"
|
|
596
|
-
});
|
|
597
|
-
score -= 3;
|
|
598
|
-
}
|
|
599
|
-
if (images.length === 0 && wordCount > 300) {
|
|
600
|
-
issues.push({
|
|
601
|
-
type: "images",
|
|
602
|
-
severity: "info",
|
|
603
|
-
message: "No images found",
|
|
604
|
-
suggestion: "Add images with descriptive alt text"
|
|
605
|
-
});
|
|
606
|
-
score -= 3;
|
|
607
|
-
}
|
|
608
|
-
const hasQuickAnswer = hasQuickAnswerPattern(firstParagraphText);
|
|
609
|
-
if (!hasQuickAnswer && wordCount > 300) {
|
|
610
|
-
issues.push({
|
|
611
|
-
type: "quick_answer",
|
|
612
|
-
severity: "warning",
|
|
613
|
-
message: "No quick answer detected in first 100 words",
|
|
614
|
-
suggestion: "Add a TL;DR, definition lead, or comparison table early to answer the reader's question immediately"
|
|
615
|
-
});
|
|
616
|
-
score -= 10;
|
|
617
|
-
}
|
|
618
|
-
let keywordInFirstParagraph = false;
|
|
619
|
-
if (targetKeywords && targetKeywords.length > 0) {
|
|
620
|
-
const firstParaLower = firstParagraphText.toLowerCase();
|
|
621
|
-
keywordInFirstParagraph = targetKeywords.some((kw) => firstParaLower.includes(kw.toLowerCase()));
|
|
622
|
-
if (!keywordInFirstParagraph) {
|
|
623
|
-
issues.push({
|
|
624
|
-
type: "first_paragraph",
|
|
625
|
-
severity: "warning",
|
|
626
|
-
message: "Primary keyword not found in first paragraph",
|
|
627
|
-
suggestion: "Include your primary keyword in the first 100 words for better SEO"
|
|
628
|
-
});
|
|
629
|
-
score -= 5;
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
const keywordDensity = {};
|
|
633
|
-
if (targetKeywords && targetKeywords.length > 0) {
|
|
634
|
-
const contentLower = content.toLowerCase();
|
|
635
|
-
for (const keyword of targetKeywords) {
|
|
636
|
-
const regex = new RegExp(keyword.toLowerCase(), "gi");
|
|
637
|
-
const matches = contentLower.match(regex) || [];
|
|
638
|
-
keywordDensity[keyword] = Number((matches.length / wordCount * 100).toFixed(2));
|
|
639
|
-
if (keywordDensity[keyword] === 0) {
|
|
640
|
-
issues.push({
|
|
641
|
-
type: "keyword_density",
|
|
642
|
-
severity: "warning",
|
|
643
|
-
message: `Target keyword "${keyword}" not found`,
|
|
644
|
-
suggestion: `Include "${keyword}" naturally in your content`
|
|
645
|
-
});
|
|
646
|
-
score -= 5;
|
|
647
|
-
} else if (keywordDensity[keyword] > 3) {
|
|
648
|
-
issues.push({
|
|
649
|
-
type: "keyword_density",
|
|
650
|
-
severity: "warning",
|
|
651
|
-
message: `Keyword "${keyword}" may be overused (${keywordDensity[keyword]}%)`,
|
|
652
|
-
suggestion: "Reduce keyword density to 1-2%"
|
|
653
|
-
});
|
|
654
|
-
score -= 3;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
const hasConclusion = hasConclusionSection(content);
|
|
659
|
-
if (!hasConclusion && wordCount > 500) {
|
|
660
|
-
issues.push({
|
|
661
|
-
type: "structure",
|
|
662
|
-
severity: "info",
|
|
663
|
-
message: "No conclusion section detected",
|
|
664
|
-
suggestion: "Add a conclusion with key takeaways and a call-to-action"
|
|
665
|
-
});
|
|
666
|
-
score -= 5;
|
|
667
|
-
}
|
|
668
|
-
if (issues.some((i) => i.type === "content_length")) {
|
|
669
|
-
recommendations.push("Expand your content with more detailed explanations and examples");
|
|
670
|
-
}
|
|
671
|
-
if (issues.some((i) => i.type === "headings")) {
|
|
672
|
-
recommendations.push("Structure your content with clear H2 and H3 headings");
|
|
673
|
-
}
|
|
674
|
-
if (issues.some((i) => i.type === "links")) {
|
|
675
|
-
recommendations.push("Add relevant internal links to other blog posts and external links to authoritative sources");
|
|
676
|
-
}
|
|
677
|
-
if (issues.some((i) => i.type === "quick_answer")) {
|
|
678
|
-
recommendations.push("Start with a quick answer - readers want the answer immediately, not after scrolling");
|
|
679
|
-
}
|
|
680
|
-
if (issues.some((i) => i.type === "first_paragraph")) {
|
|
681
|
-
recommendations.push("Include your primary keyword in the first paragraph for better search visibility");
|
|
682
|
-
}
|
|
683
|
-
const metrics = {
|
|
684
|
-
wordCount,
|
|
685
|
-
headingCount: headings.length,
|
|
686
|
-
paragraphCount: paragraphs.length,
|
|
687
|
-
linkCount: links.length,
|
|
688
|
-
imageCount: images.length,
|
|
689
|
-
hasQuickAnswer,
|
|
690
|
-
keywordInFirstParagraph,
|
|
691
|
-
keywordDensity: Object.keys(keywordDensity).length > 0 ? keywordDensity : undefined
|
|
692
|
-
};
|
|
693
|
-
return {
|
|
694
|
-
score: clampScore(score),
|
|
695
|
-
issues,
|
|
696
|
-
recommendations,
|
|
697
|
-
metrics
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
// src/structure.ts
|
|
701
|
-
function analyzeStructure(content, contentType) {
|
|
702
|
-
const issues = [];
|
|
703
|
-
let score = 100;
|
|
704
|
-
const words = extractWords(content);
|
|
705
|
-
const wordCount = words.length;
|
|
706
|
-
const paragraphs = extractParagraphs(content);
|
|
707
|
-
const headings = extractHeadings(content);
|
|
708
|
-
const hasH1InContent = headings.some((h) => h.level === 1);
|
|
709
|
-
if (hasH1InContent) {
|
|
710
|
-
issues.push({
|
|
711
|
-
type: "heading_h1",
|
|
712
|
-
severity: "error",
|
|
713
|
-
message: "H1 heading found in content body",
|
|
714
|
-
suggestion: "Remove H1 (# heading) from content. The title is in frontmatter. Start content with H2 (##)."
|
|
715
|
-
});
|
|
716
|
-
score -= 15;
|
|
717
|
-
}
|
|
718
|
-
let headingHierarchyValid = true;
|
|
719
|
-
for (let i = 1;i < headings.length; i++) {
|
|
720
|
-
const prevHeading = headings[i - 1];
|
|
721
|
-
const currentHeading = headings[i];
|
|
722
|
-
if (!prevHeading || !currentHeading)
|
|
723
|
-
continue;
|
|
724
|
-
const prevLevel = prevHeading.level;
|
|
725
|
-
const currentLevel = currentHeading.level;
|
|
726
|
-
if (currentLevel > prevLevel + 1) {
|
|
727
|
-
headingHierarchyValid = false;
|
|
728
|
-
issues.push({
|
|
729
|
-
type: "heading_hierarchy",
|
|
730
|
-
severity: "warning",
|
|
731
|
-
message: `Heading level skipped: H${prevLevel} to H${currentLevel} ("${currentHeading.text}")`,
|
|
732
|
-
suggestion: `Don't skip heading levels. Use H${prevLevel + 1} instead of H${currentLevel}.`
|
|
733
|
-
});
|
|
734
|
-
score -= 5;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
const first100Words = words.slice(0, 100).join(" ");
|
|
738
|
-
const hasQuickAnswer = hasQuickAnswerPattern(first100Words);
|
|
739
|
-
if (!hasQuickAnswer && wordCount > 300) {
|
|
740
|
-
issues.push({
|
|
741
|
-
type: "quick_answer",
|
|
742
|
-
severity: "warning",
|
|
743
|
-
message: "No quick answer detected in first 100 words",
|
|
744
|
-
suggestion: "Add a TL;DR box, definition lead, or comparison table early to answer the reader's question immediately."
|
|
745
|
-
});
|
|
746
|
-
score -= 10;
|
|
747
|
-
}
|
|
748
|
-
let totalSentences = 0;
|
|
749
|
-
let longParagraphs = 0;
|
|
750
|
-
for (const paragraph of paragraphs) {
|
|
751
|
-
if (paragraph.startsWith("#") || paragraph.startsWith("```"))
|
|
752
|
-
continue;
|
|
753
|
-
const sentences = paragraph.split(/[.!?]+/).filter(Boolean);
|
|
754
|
-
totalSentences += sentences.length;
|
|
755
|
-
if (sentences.length > 4) {
|
|
756
|
-
longParagraphs++;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
const avgParagraphLength = paragraphs.length > 0 ? totalSentences / paragraphs.length : 0;
|
|
760
|
-
if (longParagraphs > 0) {
|
|
761
|
-
issues.push({
|
|
762
|
-
type: "paragraph_length",
|
|
763
|
-
severity: "info",
|
|
764
|
-
message: `${longParagraphs} paragraph(s) exceed 4 sentences`,
|
|
765
|
-
suggestion: "Break up long paragraphs. Aim for 2-4 sentences per paragraph for better readability."
|
|
766
|
-
});
|
|
767
|
-
score -= Math.min(longParagraphs * 2, 10);
|
|
768
|
-
}
|
|
769
|
-
const h2Headings = headings.filter((h) => h.level === 2);
|
|
770
|
-
const expectedH2Count = Math.floor(wordCount / 250);
|
|
771
|
-
if (h2Headings.length < expectedH2Count && wordCount > 500) {
|
|
772
|
-
issues.push({
|
|
773
|
-
type: "heading_frequency",
|
|
774
|
-
severity: "warning",
|
|
775
|
-
message: `Only ${h2Headings.length} H2 headings for ${wordCount} words (recommended: ${expectedH2Count})`,
|
|
776
|
-
suggestion: "Add more H2 headings to break up content. Aim for one H2 every 200-300 words."
|
|
777
|
-
});
|
|
778
|
-
score -= 5;
|
|
779
|
-
}
|
|
780
|
-
const hasTableOfContents = /##\s*(?:table of contents|sumário|índice|contents)/i.test(content) || /\[.*\]\(#.*\)/.test(content.slice(0, 500));
|
|
781
|
-
if (wordCount > 1500 && !hasTableOfContents) {
|
|
782
|
-
issues.push({
|
|
783
|
-
type: "table_of_contents",
|
|
784
|
-
severity: "info",
|
|
785
|
-
message: "No table of contents detected for long-form content",
|
|
786
|
-
suggestion: "Add a table of contents near the beginning for posts over 1500 words."
|
|
787
|
-
});
|
|
788
|
-
score -= 3;
|
|
789
|
-
}
|
|
790
|
-
const hasTables = /^\|.*\|.*\|$/m.test(content);
|
|
791
|
-
const hasConclusion = hasConclusionSection(content);
|
|
792
|
-
if (!hasConclusion && wordCount > 500) {
|
|
793
|
-
issues.push({
|
|
794
|
-
type: "conclusion",
|
|
795
|
-
severity: "info",
|
|
796
|
-
message: "No conclusion section detected",
|
|
797
|
-
suggestion: "Add a conclusion with key takeaways and a call-to-action."
|
|
798
|
-
});
|
|
799
|
-
score -= 5;
|
|
800
|
-
}
|
|
801
|
-
if (contentType === "how-to") {
|
|
802
|
-
const hasNumberedSteps = /^\d+\.\s+/m.test(content) || /step\s*\d+|passo\s*\d+/i.test(content);
|
|
803
|
-
if (!hasNumberedSteps) {
|
|
804
|
-
issues.push({
|
|
805
|
-
type: "how_to_structure",
|
|
806
|
-
severity: "warning",
|
|
807
|
-
message: "How-to content should have numbered steps",
|
|
808
|
-
suggestion: "Use numbered lists (1. 2. 3.) for step-by-step instructions."
|
|
809
|
-
});
|
|
810
|
-
score -= 5;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
if (contentType === "comparison") {
|
|
814
|
-
if (!hasTables) {
|
|
815
|
-
issues.push({
|
|
816
|
-
type: "comparison_structure",
|
|
817
|
-
severity: "warning",
|
|
818
|
-
message: "Comparison content should include a comparison table",
|
|
819
|
-
suggestion: "Add a table comparing key metrics between the options."
|
|
820
|
-
});
|
|
821
|
-
score -= 5;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
if (contentType === "listicle") {
|
|
825
|
-
const listItemCount = (content.match(/^[-*]\s+/gm) || []).length;
|
|
826
|
-
if (listItemCount < 3) {
|
|
827
|
-
issues.push({
|
|
828
|
-
type: "listicle_structure",
|
|
829
|
-
severity: "warning",
|
|
830
|
-
message: "Listicle should have multiple list items",
|
|
831
|
-
suggestion: "Add more items to your list for a comprehensive listicle."
|
|
832
|
-
});
|
|
833
|
-
score -= 5;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
const structure = {
|
|
837
|
-
hasQuickAnswer,
|
|
838
|
-
headingHierarchyValid,
|
|
839
|
-
avgParagraphLength: Math.round(avgParagraphLength * 10) / 10,
|
|
840
|
-
hasTableOfContents,
|
|
841
|
-
hasTables,
|
|
842
|
-
hasConclusion,
|
|
843
|
-
headingCount: headings.length,
|
|
844
|
-
wordCount
|
|
845
|
-
};
|
|
846
|
-
return {
|
|
847
|
-
score: clampScore(score),
|
|
848
|
-
issues,
|
|
849
|
-
structure
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
// src/index.ts
|
|
853
|
-
function analyzeContent(input) {
|
|
854
|
-
const { content, title, description, targetKeywords } = input;
|
|
855
|
-
const seo = analyzeSeo({
|
|
856
|
-
content,
|
|
857
|
-
title,
|
|
858
|
-
metaDescription: description,
|
|
859
|
-
targetKeywords
|
|
860
|
-
});
|
|
861
|
-
const readability = analyzeReadability(content, "general");
|
|
862
|
-
const structure = analyzeStructure(content);
|
|
863
|
-
const badPatterns = analyzeBadPatterns(content, title);
|
|
864
|
-
const keywords = targetKeywords && targetKeywords.length > 0 ? analyzeKeywords({ content, title, targetKeywords }) : null;
|
|
865
|
-
return {
|
|
866
|
-
seo,
|
|
867
|
-
readability,
|
|
868
|
-
structure,
|
|
869
|
-
badPatterns,
|
|
870
|
-
keywords,
|
|
871
|
-
analyzedAt: new Date().toISOString()
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
export {
|
|
875
|
-
hasQuickAnswerPattern,
|
|
876
|
-
hasConclusionSection,
|
|
877
|
-
getReadabilityLevel,
|
|
878
|
-
findOccurrences,
|
|
879
|
-
extractWords,
|
|
880
|
-
extractParagraphs,
|
|
881
|
-
extractHeadings,
|
|
882
|
-
countSyllables,
|
|
883
|
-
clampScore,
|
|
884
|
-
calculateFleschKincaid,
|
|
885
|
-
analyzeStructure,
|
|
886
|
-
analyzeSeo,
|
|
887
|
-
analyzeReadability,
|
|
888
|
-
analyzeKeywords,
|
|
889
|
-
analyzeContent,
|
|
890
|
-
analyzeBadPatterns
|
|
891
|
-
};
|