@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.
Files changed (68) hide show
  1. package/README.md +192 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.mjs +3780 -0
  4. package/dist/cli.mjs.map +1 -0
  5. package/dist/crawler/index.d.ts +88 -0
  6. package/dist/crawler/index.mjs +610 -0
  7. package/dist/crawler/index.mjs.map +1 -0
  8. package/dist/google-console/index.d.ts +95 -0
  9. package/dist/google-console/index.mjs +539 -0
  10. package/dist/google-console/index.mjs.map +1 -0
  11. package/dist/index.d.ts +285 -0
  12. package/dist/index.mjs +3236 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/link-checker/index.d.ts +76 -0
  15. package/dist/link-checker/index.mjs +326 -0
  16. package/dist/link-checker/index.mjs.map +1 -0
  17. package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
  18. package/dist/reports/index.d.ts +24 -0
  19. package/dist/reports/index.mjs +836 -0
  20. package/dist/reports/index.mjs.map +1 -0
  21. package/dist/routes/index.d.ts +69 -0
  22. package/dist/routes/index.mjs +372 -0
  23. package/dist/routes/index.mjs.map +1 -0
  24. package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
  25. package/dist/types/index.d.ts +144 -0
  26. package/dist/types/index.mjs +3 -0
  27. package/dist/types/index.mjs.map +1 -0
  28. package/package.json +114 -0
  29. package/src/analyzer.ts +256 -0
  30. package/src/cli/commands/audit.ts +260 -0
  31. package/src/cli/commands/content.ts +180 -0
  32. package/src/cli/commands/crawl.ts +32 -0
  33. package/src/cli/commands/index.ts +12 -0
  34. package/src/cli/commands/inspect.ts +60 -0
  35. package/src/cli/commands/links.ts +41 -0
  36. package/src/cli/commands/robots.ts +36 -0
  37. package/src/cli/commands/routes.ts +126 -0
  38. package/src/cli/commands/sitemap.ts +48 -0
  39. package/src/cli/index.ts +149 -0
  40. package/src/cli/types.ts +40 -0
  41. package/src/config.ts +207 -0
  42. package/src/content/index.ts +51 -0
  43. package/src/content/link-checker.ts +182 -0
  44. package/src/content/link-fixer.ts +188 -0
  45. package/src/content/scanner.ts +200 -0
  46. package/src/content/sitemap-generator.ts +321 -0
  47. package/src/content/types.ts +140 -0
  48. package/src/crawler/crawler.ts +425 -0
  49. package/src/crawler/index.ts +10 -0
  50. package/src/crawler/robots-parser.ts +171 -0
  51. package/src/crawler/sitemap-validator.ts +204 -0
  52. package/src/google-console/analyzer.ts +317 -0
  53. package/src/google-console/auth.ts +100 -0
  54. package/src/google-console/client.ts +281 -0
  55. package/src/google-console/index.ts +9 -0
  56. package/src/index.ts +144 -0
  57. package/src/link-checker/index.ts +461 -0
  58. package/src/reports/claude-context.ts +149 -0
  59. package/src/reports/generator.ts +244 -0
  60. package/src/reports/index.ts +27 -0
  61. package/src/reports/json-report.ts +320 -0
  62. package/src/reports/markdown-report.ts +246 -0
  63. package/src/reports/split-report.ts +252 -0
  64. package/src/routes/analyzer.ts +324 -0
  65. package/src/routes/index.ts +25 -0
  66. package/src/routes/scanner.ts +298 -0
  67. package/src/types/index.ts +222 -0
  68. 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
+ };