@djangocfg/seo 2.1.50
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/README.md +192 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +3780 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/crawler/index.d.ts +88 -0
- package/dist/crawler/index.mjs +610 -0
- package/dist/crawler/index.mjs.map +1 -0
- package/dist/google-console/index.d.ts +95 -0
- package/dist/google-console/index.mjs +539 -0
- package/dist/google-console/index.mjs.map +1 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.mjs +3236 -0
- package/dist/index.mjs.map +1 -0
- package/dist/link-checker/index.d.ts +76 -0
- package/dist/link-checker/index.mjs +326 -0
- package/dist/link-checker/index.mjs.map +1 -0
- package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
- package/dist/reports/index.d.ts +24 -0
- package/dist/reports/index.mjs +836 -0
- package/dist/reports/index.mjs.map +1 -0
- package/dist/routes/index.d.ts +69 -0
- package/dist/routes/index.mjs +372 -0
- package/dist/routes/index.mjs.map +1 -0
- package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +114 -0
- package/src/analyzer.ts +256 -0
- package/src/cli/commands/audit.ts +260 -0
- package/src/cli/commands/content.ts +180 -0
- package/src/cli/commands/crawl.ts +32 -0
- package/src/cli/commands/index.ts +12 -0
- package/src/cli/commands/inspect.ts +60 -0
- package/src/cli/commands/links.ts +41 -0
- package/src/cli/commands/robots.ts +36 -0
- package/src/cli/commands/routes.ts +126 -0
- package/src/cli/commands/sitemap.ts +48 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/types.ts +40 -0
- package/src/config.ts +207 -0
- package/src/content/index.ts +51 -0
- package/src/content/link-checker.ts +182 -0
- package/src/content/link-fixer.ts +188 -0
- package/src/content/scanner.ts +200 -0
- package/src/content/sitemap-generator.ts +321 -0
- package/src/content/types.ts +140 -0
- package/src/crawler/crawler.ts +425 -0
- package/src/crawler/index.ts +10 -0
- package/src/crawler/robots-parser.ts +171 -0
- package/src/crawler/sitemap-validator.ts +204 -0
- package/src/google-console/analyzer.ts +317 -0
- package/src/google-console/auth.ts +100 -0
- package/src/google-console/client.ts +281 -0
- package/src/google-console/index.ts +9 -0
- package/src/index.ts +144 -0
- package/src/link-checker/index.ts +461 -0
- package/src/reports/claude-context.ts +149 -0
- package/src/reports/generator.ts +244 -0
- package/src/reports/index.ts +27 -0
- package/src/reports/json-report.ts +320 -0
- package/src/reports/markdown-report.ts +246 -0
- package/src/reports/split-report.ts +252 -0
- package/src/routes/analyzer.ts +324 -0
- package/src/routes/index.ts +25 -0
- package/src/routes/scanner.ts +298 -0
- package/src/types/index.ts +222 -0
- package/src/utils/index.ts +154 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/seo - Report Generator
|
|
3
|
+
* Main report generation orchestrator
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import consola from 'consola';
|
|
9
|
+
import { generateJsonReport, exportJsonReport } from './json-report.js';
|
|
10
|
+
import { generateMarkdownReport, generateAiSummary } from './markdown-report.js';
|
|
11
|
+
import { generateSplitReports } from './split-report.js';
|
|
12
|
+
import { generateClaudeContext } from './claude-context.js';
|
|
13
|
+
import type {
|
|
14
|
+
SeoReport,
|
|
15
|
+
SeoIssue,
|
|
16
|
+
UrlInspectionResult,
|
|
17
|
+
CrawlResult,
|
|
18
|
+
IssueCategory,
|
|
19
|
+
IssueSeverity,
|
|
20
|
+
} from '../types/index.js';
|
|
21
|
+
|
|
22
|
+
export interface ReportGeneratorOptions {
|
|
23
|
+
outputDir: string;
|
|
24
|
+
formats: ('json' | 'markdown' | 'ai-summary' | 'split')[];
|
|
25
|
+
includeRawData?: boolean;
|
|
26
|
+
timestamp?: boolean;
|
|
27
|
+
/** Clear output directory before generating reports */
|
|
28
|
+
clearOutputDir?: boolean;
|
|
29
|
+
/** Maximum URLs to show per issue group (default: 10) */
|
|
30
|
+
maxUrlsPerIssue?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GeneratedReports {
|
|
34
|
+
report: SeoReport;
|
|
35
|
+
files: {
|
|
36
|
+
json?: string;
|
|
37
|
+
markdown?: string;
|
|
38
|
+
aiSummary?: string;
|
|
39
|
+
split?: {
|
|
40
|
+
index: string;
|
|
41
|
+
categories: string[];
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate and save SEO reports
|
|
48
|
+
*/
|
|
49
|
+
export async function generateAndSaveReports(
|
|
50
|
+
siteUrl: string,
|
|
51
|
+
data: {
|
|
52
|
+
issues: SeoIssue[];
|
|
53
|
+
urlInspections?: UrlInspectionResult[];
|
|
54
|
+
crawlResults?: CrawlResult[];
|
|
55
|
+
},
|
|
56
|
+
options: ReportGeneratorOptions
|
|
57
|
+
): Promise<GeneratedReports> {
|
|
58
|
+
const {
|
|
59
|
+
outputDir,
|
|
60
|
+
formats,
|
|
61
|
+
includeRawData = false,
|
|
62
|
+
timestamp = true,
|
|
63
|
+
clearOutputDir = true,
|
|
64
|
+
maxUrlsPerIssue = 10,
|
|
65
|
+
} = options;
|
|
66
|
+
|
|
67
|
+
// Clear output directory if requested
|
|
68
|
+
if (clearOutputDir && existsSync(outputDir)) {
|
|
69
|
+
try {
|
|
70
|
+
const files = readdirSync(outputDir);
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (file.startsWith('seo-')) {
|
|
73
|
+
rmSync(join(outputDir, file), { force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore errors
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Ensure output directory exists
|
|
82
|
+
if (!existsSync(outputDir)) {
|
|
83
|
+
mkdirSync(outputDir, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generate the report
|
|
87
|
+
const report = generateJsonReport(siteUrl, data, { includeRawData, maxUrlsPerIssue });
|
|
88
|
+
|
|
89
|
+
const result: GeneratedReports = {
|
|
90
|
+
report,
|
|
91
|
+
files: {},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Generate timestamp for filenames
|
|
95
|
+
const ts = timestamp
|
|
96
|
+
? `-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
97
|
+
: '';
|
|
98
|
+
const siteName = new URL(siteUrl).hostname.replace(/\./g, '-');
|
|
99
|
+
|
|
100
|
+
// Generate JSON report
|
|
101
|
+
if (formats.includes('json')) {
|
|
102
|
+
const filename = `seo-report-${siteName}${ts}.json`;
|
|
103
|
+
const filepath = join(outputDir, filename);
|
|
104
|
+
const content = exportJsonReport(report, true);
|
|
105
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
106
|
+
result.files.json = filepath;
|
|
107
|
+
consola.success(`JSON report saved: ${filepath}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Generate Markdown report
|
|
111
|
+
if (formats.includes('markdown')) {
|
|
112
|
+
const filename = `seo-report-${siteName}${ts}.md`;
|
|
113
|
+
const filepath = join(outputDir, filename);
|
|
114
|
+
const content = generateMarkdownReport(report, {
|
|
115
|
+
includeRawIssues: true,
|
|
116
|
+
includeUrls: true,
|
|
117
|
+
});
|
|
118
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
119
|
+
result.files.markdown = filepath;
|
|
120
|
+
consola.success(`Markdown report saved: ${filepath}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Generate AI summary
|
|
124
|
+
if (formats.includes('ai-summary')) {
|
|
125
|
+
const filename = `seo-ai-summary-${siteName}${ts}.md`;
|
|
126
|
+
const filepath = join(outputDir, filename);
|
|
127
|
+
const content = generateAiSummary(report);
|
|
128
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
129
|
+
result.files.aiSummary = filepath;
|
|
130
|
+
consola.success(`AI summary saved: ${filepath}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Generate split reports (AI-optimized, max 1000 lines each)
|
|
134
|
+
if (formats.includes('split')) {
|
|
135
|
+
const splitResult = generateSplitReports(report, {
|
|
136
|
+
outputDir,
|
|
137
|
+
clearOutputDir: false, // Already cleared above
|
|
138
|
+
});
|
|
139
|
+
result.files.split = {
|
|
140
|
+
index: join(outputDir, splitResult.indexFile),
|
|
141
|
+
categories: splitResult.categoryFiles.map(f => join(outputDir, f)),
|
|
142
|
+
};
|
|
143
|
+
consola.success(`Split reports saved: ${splitResult.totalFiles} files (index + ${splitResult.categoryFiles.length} categories)`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Always generate CLAUDE.md for AI context
|
|
147
|
+
const claudeContent = generateClaudeContext(report);
|
|
148
|
+
const claudeFilepath = join(outputDir, 'CLAUDE.md');
|
|
149
|
+
writeFileSync(claudeFilepath, claudeContent, 'utf-8');
|
|
150
|
+
consola.success(`AI context saved: ${claudeFilepath}`);
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Print report summary to console
|
|
157
|
+
*/
|
|
158
|
+
export function printReportSummary(report: SeoReport): void {
|
|
159
|
+
consola.box(
|
|
160
|
+
`SEO Report: ${report.siteUrl}\n` +
|
|
161
|
+
`Health Score: ${report.summary.healthScore}/100\n` +
|
|
162
|
+
`Total URLs: ${report.summary.totalUrls}\n` +
|
|
163
|
+
`Indexed: ${report.summary.indexedUrls} | Not Indexed: ${report.summary.notIndexedUrls}\n` +
|
|
164
|
+
`Issues: ${report.issues.length}`
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (report.summary.issuesBySeverity.critical) {
|
|
168
|
+
consola.error(`Critical issues: ${report.summary.issuesBySeverity.critical}`);
|
|
169
|
+
}
|
|
170
|
+
if (report.summary.issuesBySeverity.error) {
|
|
171
|
+
consola.warn(`Errors: ${report.summary.issuesBySeverity.error}`);
|
|
172
|
+
}
|
|
173
|
+
if (report.summary.issuesBySeverity.warning) {
|
|
174
|
+
consola.info(`Warnings: ${report.summary.issuesBySeverity.warning}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
consola.log('');
|
|
178
|
+
consola.info('Top recommendations:');
|
|
179
|
+
for (const rec of report.recommendations.slice(0, 3)) {
|
|
180
|
+
consola.log(` ${rec.priority}. ${rec.title} (${rec.affectedUrls.length} URLs)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Merge multiple reports into one
|
|
186
|
+
*/
|
|
187
|
+
export function mergeReports(reports: SeoReport[]): SeoReport {
|
|
188
|
+
if (reports.length === 0) {
|
|
189
|
+
throw new Error('Cannot merge empty reports array');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const merged: SeoReport = {
|
|
193
|
+
id: `merged-${Date.now().toString(36)}`,
|
|
194
|
+
siteUrl: reports.map((r) => r.siteUrl).join(', '),
|
|
195
|
+
generatedAt: new Date().toISOString(),
|
|
196
|
+
summary: {
|
|
197
|
+
totalUrls: reports.reduce((sum, r) => sum + r.summary.totalUrls, 0),
|
|
198
|
+
indexedUrls: reports.reduce((sum, r) => sum + r.summary.indexedUrls, 0),
|
|
199
|
+
notIndexedUrls: reports.reduce((sum, r) => sum + r.summary.notIndexedUrls, 0),
|
|
200
|
+
issuesByCategory: {} as any,
|
|
201
|
+
issuesBySeverity: {} as any,
|
|
202
|
+
healthScore: Math.round(
|
|
203
|
+
reports.reduce((sum, r) => sum + r.summary.healthScore, 0) / reports.length
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
issues: reports.flatMap((r) => r.issues),
|
|
207
|
+
urlInspections: reports.flatMap((r) => r.urlInspections),
|
|
208
|
+
crawlResults: reports.flatMap((r) => r.crawlResults),
|
|
209
|
+
recommendations: [],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Merge issue counts
|
|
213
|
+
for (const report of reports) {
|
|
214
|
+
for (const [category, count] of Object.entries(report.summary.issuesByCategory)) {
|
|
215
|
+
const cat = category as IssueCategory;
|
|
216
|
+
merged.summary.issuesByCategory[cat] =
|
|
217
|
+
(merged.summary.issuesByCategory[cat] || 0) + count;
|
|
218
|
+
}
|
|
219
|
+
for (const [severity, count] of Object.entries(report.summary.issuesBySeverity)) {
|
|
220
|
+
const sev = severity as IssueSeverity;
|
|
221
|
+
merged.summary.issuesBySeverity[sev] =
|
|
222
|
+
(merged.summary.issuesBySeverity[sev] || 0) + count;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Merge and deduplicate recommendations
|
|
227
|
+
const recMap = new Map<string, typeof merged.recommendations[0]>();
|
|
228
|
+
for (const report of reports) {
|
|
229
|
+
for (const rec of report.recommendations) {
|
|
230
|
+
const key = `${rec.category}:${rec.title}`;
|
|
231
|
+
if (recMap.has(key)) {
|
|
232
|
+
const existing = recMap.get(key)!;
|
|
233
|
+
existing.affectedUrls.push(...rec.affectedUrls);
|
|
234
|
+
} else {
|
|
235
|
+
recMap.set(key, { ...rec, affectedUrls: [...rec.affectedUrls] });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
merged.recommendations = Array.from(recMap.values()).sort(
|
|
240
|
+
(a, b) => a.priority - b.priority
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return merged;
|
|
244
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/seo - Reports Module
|
|
3
|
+
* Report generation tools
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
generateAndSaveReports,
|
|
8
|
+
printReportSummary,
|
|
9
|
+
mergeReports,
|
|
10
|
+
} from './generator.js';
|
|
11
|
+
export type { ReportGeneratorOptions, GeneratedReports } from './generator.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
generateJsonReport,
|
|
15
|
+
exportJsonReport,
|
|
16
|
+
AI_REPORT_SCHEMA,
|
|
17
|
+
} from './json-report.js';
|
|
18
|
+
export type { JsonReportOptions } from './json-report.js';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
generateMarkdownReport,
|
|
22
|
+
generateAiSummary,
|
|
23
|
+
} from './markdown-report.js';
|
|
24
|
+
export type { MarkdownReportOptions } from './markdown-report.js';
|
|
25
|
+
|
|
26
|
+
export { generateSplitReports } from './split-report.js';
|
|
27
|
+
export type { SplitReportOptions, SplitReportResult } from './split-report.js';
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/seo - JSON Report Generator
|
|
3
|
+
* Generate AI-friendly JSON reports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
SeoReport,
|
|
8
|
+
SeoIssue,
|
|
9
|
+
UrlInspectionResult,
|
|
10
|
+
CrawlResult,
|
|
11
|
+
ReportSummary,
|
|
12
|
+
Recommendation,
|
|
13
|
+
IssueCategory,
|
|
14
|
+
IssueSeverity,
|
|
15
|
+
} from '../types/index.js';
|
|
16
|
+
|
|
17
|
+
export interface JsonReportOptions {
|
|
18
|
+
includeRawData?: boolean;
|
|
19
|
+
prettyPrint?: boolean;
|
|
20
|
+
/** Maximum URLs to include per issue group (default: 10) */
|
|
21
|
+
maxUrlsPerIssue?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate a comprehensive JSON report
|
|
26
|
+
*/
|
|
27
|
+
export function generateJsonReport(
|
|
28
|
+
siteUrl: string,
|
|
29
|
+
data: {
|
|
30
|
+
issues: SeoIssue[];
|
|
31
|
+
urlInspections?: UrlInspectionResult[];
|
|
32
|
+
crawlResults?: CrawlResult[];
|
|
33
|
+
},
|
|
34
|
+
options: JsonReportOptions = {}
|
|
35
|
+
): SeoReport {
|
|
36
|
+
const { issues, urlInspections = [], crawlResults = [] } = data;
|
|
37
|
+
const maxUrlsPerIssue = options.maxUrlsPerIssue ?? 10;
|
|
38
|
+
|
|
39
|
+
// Limit issues to maxUrlsPerIssue per title group
|
|
40
|
+
const limitedIssues = limitIssuesByTitle(issues, maxUrlsPerIssue);
|
|
41
|
+
|
|
42
|
+
const report: SeoReport = {
|
|
43
|
+
id: generateReportId(),
|
|
44
|
+
siteUrl,
|
|
45
|
+
generatedAt: new Date().toISOString(),
|
|
46
|
+
summary: generateSummary(issues, urlInspections, crawlResults), // Use original for accurate counts
|
|
47
|
+
issues: sortIssues(limitedIssues),
|
|
48
|
+
urlInspections: options.includeRawData ? urlInspections.slice(0, 100) : [],
|
|
49
|
+
crawlResults: options.includeRawData ? crawlResults.slice(0, 100) : [],
|
|
50
|
+
recommendations: generateRecommendations(issues, maxUrlsPerIssue),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Limit issues to maxUrls per issue title group
|
|
58
|
+
*/
|
|
59
|
+
function limitIssuesByTitle(issues: SeoIssue[], maxUrls: number): SeoIssue[] {
|
|
60
|
+
const byTitle = new Map<string, SeoIssue[]>();
|
|
61
|
+
|
|
62
|
+
for (const issue of issues) {
|
|
63
|
+
const existing = byTitle.get(issue.title) || [];
|
|
64
|
+
existing.push(issue);
|
|
65
|
+
byTitle.set(issue.title, existing);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const limited: SeoIssue[] = [];
|
|
69
|
+
for (const [, group] of byTitle) {
|
|
70
|
+
const sorted = group.sort((a, b) => {
|
|
71
|
+
const severityOrder = { critical: 0, error: 1, warning: 2, info: 3 };
|
|
72
|
+
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
73
|
+
});
|
|
74
|
+
limited.push(...sorted.slice(0, maxUrls));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return limited;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate report summary
|
|
82
|
+
*/
|
|
83
|
+
function generateSummary(
|
|
84
|
+
issues: SeoIssue[],
|
|
85
|
+
urlInspections: UrlInspectionResult[],
|
|
86
|
+
crawlResults: CrawlResult[]
|
|
87
|
+
): ReportSummary {
|
|
88
|
+
const totalUrls = Math.max(
|
|
89
|
+
urlInspections.length,
|
|
90
|
+
crawlResults.length,
|
|
91
|
+
new Set(issues.map((i) => i.url)).size
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const indexedUrls = urlInspections.filter(
|
|
95
|
+
(r) => r.indexStatusResult.coverageState === 'SUBMITTED_AND_INDEXED'
|
|
96
|
+
).length;
|
|
97
|
+
|
|
98
|
+
const notIndexedUrls = urlInspections.filter(
|
|
99
|
+
(r) =>
|
|
100
|
+
r.indexStatusResult.coverageState === 'NOT_INDEXED' ||
|
|
101
|
+
r.indexStatusResult.coverageState === 'CRAWLED_CURRENTLY_NOT_INDEXED' ||
|
|
102
|
+
r.indexStatusResult.coverageState === 'DISCOVERED_CURRENTLY_NOT_INDEXED'
|
|
103
|
+
).length;
|
|
104
|
+
|
|
105
|
+
const issuesByCategory = issues.reduce(
|
|
106
|
+
(acc, issue) => {
|
|
107
|
+
acc[issue.category] = (acc[issue.category] || 0) + 1;
|
|
108
|
+
return acc;
|
|
109
|
+
},
|
|
110
|
+
{} as Record<IssueCategory, number>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const issuesBySeverity = issues.reduce(
|
|
114
|
+
(acc, issue) => {
|
|
115
|
+
acc[issue.severity] = (acc[issue.severity] || 0) + 1;
|
|
116
|
+
return acc;
|
|
117
|
+
},
|
|
118
|
+
{} as Record<IssueSeverity, number>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Calculate health score (0-100)
|
|
122
|
+
const healthScore = calculateHealthScore(issues, totalUrls);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
totalUrls,
|
|
126
|
+
indexedUrls,
|
|
127
|
+
notIndexedUrls,
|
|
128
|
+
issuesByCategory,
|
|
129
|
+
issuesBySeverity,
|
|
130
|
+
healthScore,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Calculate overall SEO health score
|
|
136
|
+
*/
|
|
137
|
+
function calculateHealthScore(issues: SeoIssue[], totalUrls: number): number {
|
|
138
|
+
if (totalUrls === 0) return 100;
|
|
139
|
+
|
|
140
|
+
const severityWeights: Record<IssueSeverity, number> = {
|
|
141
|
+
critical: 10,
|
|
142
|
+
error: 5,
|
|
143
|
+
warning: 2,
|
|
144
|
+
info: 0.5,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const totalPenalty = issues.reduce(
|
|
148
|
+
(sum, issue) => sum + severityWeights[issue.severity],
|
|
149
|
+
0
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Max penalty per URL is 20 points
|
|
153
|
+
const maxPenalty = totalUrls * 20;
|
|
154
|
+
const penaltyRatio = Math.min(totalPenalty / maxPenalty, 1);
|
|
155
|
+
|
|
156
|
+
return Math.round((1 - penaltyRatio) * 100);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generate prioritized recommendations
|
|
161
|
+
*/
|
|
162
|
+
function generateRecommendations(issues: SeoIssue[], maxUrls: number = 10): Recommendation[] {
|
|
163
|
+
const recommendations: Recommendation[] = [];
|
|
164
|
+
const issueGroups = new Map<string, SeoIssue[]>();
|
|
165
|
+
|
|
166
|
+
// Group issues by title (similar issues)
|
|
167
|
+
for (const issue of issues) {
|
|
168
|
+
const key = `${issue.category}:${issue.title}`;
|
|
169
|
+
if (!issueGroups.has(key)) {
|
|
170
|
+
issueGroups.set(key, []);
|
|
171
|
+
}
|
|
172
|
+
issueGroups.get(key)!.push(issue);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Create recommendations for each group
|
|
176
|
+
for (const [, groupedIssues] of issueGroups) {
|
|
177
|
+
const firstIssue = groupedIssues[0];
|
|
178
|
+
if (!firstIssue) continue;
|
|
179
|
+
const severity = firstIssue.severity;
|
|
180
|
+
|
|
181
|
+
const priority: 1 | 2 | 3 | 4 | 5 =
|
|
182
|
+
severity === 'critical'
|
|
183
|
+
? 1
|
|
184
|
+
: severity === 'error'
|
|
185
|
+
? 2
|
|
186
|
+
: severity === 'warning'
|
|
187
|
+
? 3
|
|
188
|
+
: 4;
|
|
189
|
+
|
|
190
|
+
const impact: 'high' | 'medium' | 'low' =
|
|
191
|
+
priority <= 2 ? 'high' : priority === 3 ? 'medium' : 'low';
|
|
192
|
+
|
|
193
|
+
const allUrls = groupedIssues.map((i) => i.url);
|
|
194
|
+
const totalCount = allUrls.length;
|
|
195
|
+
const limitedUrls = allUrls.slice(0, maxUrls);
|
|
196
|
+
|
|
197
|
+
recommendations.push({
|
|
198
|
+
priority,
|
|
199
|
+
category: firstIssue.category,
|
|
200
|
+
title: firstIssue.title,
|
|
201
|
+
description: totalCount > maxUrls
|
|
202
|
+
? `${firstIssue.description} (showing ${maxUrls} of ${totalCount} URLs)`
|
|
203
|
+
: firstIssue.description,
|
|
204
|
+
affectedUrls: limitedUrls,
|
|
205
|
+
estimatedImpact: impact,
|
|
206
|
+
actionItems: [firstIssue.recommendation],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Sort by priority
|
|
211
|
+
return recommendations.sort((a, b) => a.priority - b.priority);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Sort issues by severity and category
|
|
216
|
+
*/
|
|
217
|
+
function sortIssues(issues: SeoIssue[]): SeoIssue[] {
|
|
218
|
+
const severityOrder: Record<IssueSeverity, number> = {
|
|
219
|
+
critical: 0,
|
|
220
|
+
error: 1,
|
|
221
|
+
warning: 2,
|
|
222
|
+
info: 3,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return [...issues].sort((a, b) => {
|
|
226
|
+
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity];
|
|
227
|
+
if (severityDiff !== 0) return severityDiff;
|
|
228
|
+
return a.category.localeCompare(b.category);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Generate unique report ID
|
|
234
|
+
*/
|
|
235
|
+
function generateReportId(): string {
|
|
236
|
+
const timestamp = Date.now().toString(36);
|
|
237
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
238
|
+
return `seo-report-${timestamp}-${random}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Export report as JSON string
|
|
243
|
+
*/
|
|
244
|
+
export function exportJsonReport(report: SeoReport, pretty = true): string {
|
|
245
|
+
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Generate AI-optimized schema for the report
|
|
250
|
+
* This schema helps AI understand the report structure
|
|
251
|
+
*/
|
|
252
|
+
export const AI_REPORT_SCHEMA = {
|
|
253
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
254
|
+
title: 'SEO Report',
|
|
255
|
+
description: 'AI-friendly SEO analysis report with issues and recommendations',
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {
|
|
258
|
+
id: { type: 'string', description: 'Unique report identifier' },
|
|
259
|
+
siteUrl: { type: 'string', description: 'Analyzed site URL' },
|
|
260
|
+
generatedAt: { type: 'string', format: 'date-time' },
|
|
261
|
+
summary: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
description: 'Quick overview of SEO health',
|
|
264
|
+
properties: {
|
|
265
|
+
totalUrls: { type: 'number' },
|
|
266
|
+
indexedUrls: { type: 'number' },
|
|
267
|
+
notIndexedUrls: { type: 'number' },
|
|
268
|
+
healthScore: {
|
|
269
|
+
type: 'number',
|
|
270
|
+
minimum: 0,
|
|
271
|
+
maximum: 100,
|
|
272
|
+
description: '0-100 score, higher is better',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
issues: {
|
|
277
|
+
type: 'array',
|
|
278
|
+
description: 'List of detected SEO issues sorted by severity',
|
|
279
|
+
items: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
severity: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
enum: ['critical', 'error', 'warning', 'info'],
|
|
285
|
+
},
|
|
286
|
+
category: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
enum: [
|
|
289
|
+
'indexing',
|
|
290
|
+
'crawling',
|
|
291
|
+
'content',
|
|
292
|
+
'technical',
|
|
293
|
+
'mobile',
|
|
294
|
+
'performance',
|
|
295
|
+
'structured-data',
|
|
296
|
+
'security',
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
title: { type: 'string' },
|
|
300
|
+
description: { type: 'string' },
|
|
301
|
+
recommendation: { type: 'string' },
|
|
302
|
+
url: { type: 'string' },
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
recommendations: {
|
|
307
|
+
type: 'array',
|
|
308
|
+
description: 'Prioritized action items',
|
|
309
|
+
items: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
priority: { type: 'number', minimum: 1, maximum: 5 },
|
|
313
|
+
title: { type: 'string' },
|
|
314
|
+
affectedUrls: { type: 'array', items: { type: 'string' } },
|
|
315
|
+
actionItems: { type: 'array', items: { type: 'string' } },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|