@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,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/seo - Markdown Report Generator
|
|
3
|
+
* Generate human-readable Markdown reports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SeoReport, SeoIssue, IssueSeverity, IssueCategory } from '../types/index.js';
|
|
7
|
+
|
|
8
|
+
export interface MarkdownReportOptions {
|
|
9
|
+
includeRawIssues?: boolean;
|
|
10
|
+
includeUrls?: boolean;
|
|
11
|
+
maxUrlsPerIssue?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a Markdown report from SEO data
|
|
16
|
+
*/
|
|
17
|
+
export function generateMarkdownReport(
|
|
18
|
+
report: SeoReport,
|
|
19
|
+
options: MarkdownReportOptions = {}
|
|
20
|
+
): string {
|
|
21
|
+
const { includeRawIssues = true, includeUrls = true, maxUrlsPerIssue = 10 } = options;
|
|
22
|
+
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
|
|
25
|
+
// Header
|
|
26
|
+
lines.push(`# SEO Analysis Report`);
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push(`**Site:** ${report.siteUrl}`);
|
|
29
|
+
lines.push(`**Generated:** ${new Date(report.generatedAt).toLocaleString()}`);
|
|
30
|
+
lines.push(`**Report ID:** ${report.id}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
|
|
33
|
+
// Summary
|
|
34
|
+
lines.push('## Summary');
|
|
35
|
+
lines.push('');
|
|
36
|
+
lines.push(`| Metric | Value |`);
|
|
37
|
+
lines.push(`|--------|-------|`);
|
|
38
|
+
lines.push(`| Health Score | ${getHealthScoreEmoji(report.summary.healthScore)} **${report.summary.healthScore}/100** |`);
|
|
39
|
+
lines.push(`| Total URLs | ${report.summary.totalUrls} |`);
|
|
40
|
+
lines.push(`| Indexed URLs | ${report.summary.indexedUrls} |`);
|
|
41
|
+
lines.push(`| Not Indexed | ${report.summary.notIndexedUrls} |`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
|
|
44
|
+
// Issues by Severity
|
|
45
|
+
lines.push('### Issues by Severity');
|
|
46
|
+
lines.push('');
|
|
47
|
+
const severities: IssueSeverity[] = ['critical', 'error', 'warning', 'info'];
|
|
48
|
+
for (const severity of severities) {
|
|
49
|
+
const count = report.summary.issuesBySeverity[severity] || 0;
|
|
50
|
+
lines.push(`- ${getSeverityEmoji(severity)} **${capitalize(severity)}:** ${count}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
|
|
54
|
+
// Issues by Category
|
|
55
|
+
lines.push('### Issues by Category');
|
|
56
|
+
lines.push('');
|
|
57
|
+
const categories = Object.entries(report.summary.issuesByCategory).sort(
|
|
58
|
+
([, a], [, b]) => b - a
|
|
59
|
+
);
|
|
60
|
+
for (const [category, count] of categories) {
|
|
61
|
+
lines.push(`- ${getCategoryEmoji(category as IssueCategory)} **${formatCategory(category)}:** ${count}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
// Recommendations
|
|
66
|
+
lines.push('## Prioritized Recommendations');
|
|
67
|
+
lines.push('');
|
|
68
|
+
|
|
69
|
+
for (const rec of report.recommendations) {
|
|
70
|
+
lines.push(`### ${getPriorityEmoji(rec.priority)} Priority ${rec.priority}: ${rec.title}`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push(`**Category:** ${formatCategory(rec.category)}`);
|
|
73
|
+
lines.push(`**Impact:** ${capitalize(rec.estimatedImpact)}`);
|
|
74
|
+
lines.push(`**Affected URLs:** ${rec.affectedUrls.length}`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push(`${rec.description}`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push('**Action Items:**');
|
|
79
|
+
for (const action of rec.actionItems) {
|
|
80
|
+
lines.push(`- ${action}`);
|
|
81
|
+
}
|
|
82
|
+
lines.push('');
|
|
83
|
+
|
|
84
|
+
if (includeUrls && rec.affectedUrls.length > 0) {
|
|
85
|
+
const urlsToShow = rec.affectedUrls.slice(0, maxUrlsPerIssue);
|
|
86
|
+
lines.push('<details>');
|
|
87
|
+
lines.push(`<summary>Affected URLs (${rec.affectedUrls.length})</summary>`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
for (const url of urlsToShow) {
|
|
90
|
+
lines.push(`- ${url}`);
|
|
91
|
+
}
|
|
92
|
+
if (rec.affectedUrls.length > maxUrlsPerIssue) {
|
|
93
|
+
lines.push(`- ... and ${rec.affectedUrls.length - maxUrlsPerIssue} more`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('</details>');
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
lines.push('---');
|
|
100
|
+
lines.push('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Detailed Issues
|
|
104
|
+
if (includeRawIssues) {
|
|
105
|
+
lines.push('## All Issues');
|
|
106
|
+
lines.push('');
|
|
107
|
+
|
|
108
|
+
const issuesByCategory = groupBy(report.issues, 'category');
|
|
109
|
+
|
|
110
|
+
for (const [category, issues] of Object.entries(issuesByCategory)) {
|
|
111
|
+
lines.push(`### ${getCategoryEmoji(category as IssueCategory)} ${formatCategory(category)}`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
for (const issue of issues) {
|
|
115
|
+
lines.push(
|
|
116
|
+
`#### ${getSeverityEmoji(issue.severity)} ${issue.title}`
|
|
117
|
+
);
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(`**URL:** \`${issue.url}\``);
|
|
120
|
+
lines.push(`**Severity:** ${capitalize(issue.severity)}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push(issue.description);
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(`**Recommendation:** ${issue.recommendation}`);
|
|
125
|
+
lines.push('');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Footer
|
|
131
|
+
lines.push('---');
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('*Report generated by [@djangocfg/seo](https://djangocfg.com)*');
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push('> This report is designed to be processed by AI assistants for automated SEO improvements.');
|
|
136
|
+
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate a concise summary for AI consumption
|
|
142
|
+
*/
|
|
143
|
+
export function generateAiSummary(report: SeoReport): string {
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
|
|
146
|
+
lines.push('# SEO Report Summary for AI Processing');
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push('## Context');
|
|
149
|
+
lines.push(`Site: ${report.siteUrl}`);
|
|
150
|
+
lines.push(`Health Score: ${report.summary.healthScore}/100`);
|
|
151
|
+
lines.push(`Critical Issues: ${report.summary.issuesBySeverity.critical || 0}`);
|
|
152
|
+
lines.push(`Errors: ${report.summary.issuesBySeverity.error || 0}`);
|
|
153
|
+
lines.push(`Warnings: ${report.summary.issuesBySeverity.warning || 0}`);
|
|
154
|
+
lines.push('');
|
|
155
|
+
lines.push('## Top Priority Actions');
|
|
156
|
+
lines.push('');
|
|
157
|
+
|
|
158
|
+
const topRecommendations = report.recommendations.slice(0, 5);
|
|
159
|
+
for (let i = 0; i < topRecommendations.length; i++) {
|
|
160
|
+
const rec = topRecommendations[i];
|
|
161
|
+
if (!rec) continue;
|
|
162
|
+
lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length} URLs)`);
|
|
163
|
+
lines.push(` - ${rec.actionItems[0]}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push('## Issue Categories');
|
|
168
|
+
lines.push('');
|
|
169
|
+
|
|
170
|
+
const sortedCategories = Object.entries(report.summary.issuesByCategory)
|
|
171
|
+
.sort(([, a], [, b]) => b - a);
|
|
172
|
+
|
|
173
|
+
for (const [category, count] of sortedCategories) {
|
|
174
|
+
lines.push(`- ${formatCategory(category)}: ${count} issues`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Helper functions
|
|
181
|
+
function getSeverityEmoji(severity: IssueSeverity): string {
|
|
182
|
+
const emojis: Record<IssueSeverity, string> = {
|
|
183
|
+
critical: '🔴',
|
|
184
|
+
error: '🟠',
|
|
185
|
+
warning: '🟡',
|
|
186
|
+
info: '🔵',
|
|
187
|
+
};
|
|
188
|
+
return emojis[severity];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getCategoryEmoji(category: IssueCategory): string {
|
|
192
|
+
const emojis: Record<IssueCategory, string> = {
|
|
193
|
+
indexing: '📑',
|
|
194
|
+
crawling: '🕷️',
|
|
195
|
+
content: '📝',
|
|
196
|
+
technical: '⚙️',
|
|
197
|
+
mobile: '📱',
|
|
198
|
+
performance: '⚡',
|
|
199
|
+
'structured-data': '🏷️',
|
|
200
|
+
security: '🔒',
|
|
201
|
+
};
|
|
202
|
+
return emojis[category] || '📋';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getPriorityEmoji(priority: number): string {
|
|
206
|
+
const emojis: Record<number, string> = {
|
|
207
|
+
1: '🚨',
|
|
208
|
+
2: '⚠️',
|
|
209
|
+
3: '📌',
|
|
210
|
+
4: '💡',
|
|
211
|
+
5: 'ℹ️',
|
|
212
|
+
};
|
|
213
|
+
return emojis[priority] || '📋';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getHealthScoreEmoji(score: number): string {
|
|
217
|
+
if (score >= 90) return '🟢';
|
|
218
|
+
if (score >= 70) return '🟡';
|
|
219
|
+
if (score >= 50) return '🟠';
|
|
220
|
+
return '🔴';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function capitalize(str: string): string {
|
|
224
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatCategory(category: string): string {
|
|
228
|
+
return category
|
|
229
|
+
.split('-')
|
|
230
|
+
.map(capitalize)
|
|
231
|
+
.join(' ');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
|
|
235
|
+
return array.reduce(
|
|
236
|
+
(acc, item) => {
|
|
237
|
+
const groupKey = String(item[key]);
|
|
238
|
+
if (!acc[groupKey]) {
|
|
239
|
+
acc[groupKey] = [];
|
|
240
|
+
}
|
|
241
|
+
acc[groupKey].push(item);
|
|
242
|
+
return acc;
|
|
243
|
+
},
|
|
244
|
+
{} as Record<string, T[]>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/seo - Split Report Generator
|
|
3
|
+
* Generates AI-friendly split reports (max 1000 lines each)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { SeoReport, SeoIssue, IssueCategory, IssueSeverity } from '../types/index.js';
|
|
9
|
+
|
|
10
|
+
const MAX_LINES = 1000;
|
|
11
|
+
|
|
12
|
+
export interface SplitReportOptions {
|
|
13
|
+
outputDir: string;
|
|
14
|
+
/** Clear output directory before generating */
|
|
15
|
+
clearOutputDir?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SplitReportResult {
|
|
19
|
+
indexFile: string;
|
|
20
|
+
categoryFiles: string[];
|
|
21
|
+
totalFiles: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate split reports for AI processing
|
|
26
|
+
*/
|
|
27
|
+
export function generateSplitReports(
|
|
28
|
+
report: SeoReport,
|
|
29
|
+
options: SplitReportOptions
|
|
30
|
+
): SplitReportResult {
|
|
31
|
+
const { outputDir, clearOutputDir = true } = options;
|
|
32
|
+
|
|
33
|
+
// Clear output directory
|
|
34
|
+
if (clearOutputDir && existsSync(outputDir)) {
|
|
35
|
+
const files = readdirSync(outputDir);
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
if (file.startsWith('seo-') && file.endsWith('.md')) {
|
|
38
|
+
rmSync(join(outputDir, file), { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ensure output directory exists
|
|
44
|
+
if (!existsSync(outputDir)) {
|
|
45
|
+
mkdirSync(outputDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
49
|
+
const siteName = new URL(report.siteUrl).hostname.replace(/\./g, '-');
|
|
50
|
+
const prefix = `seo-${siteName}-${timestamp}`;
|
|
51
|
+
|
|
52
|
+
const categoryFiles: string[] = [];
|
|
53
|
+
|
|
54
|
+
// Group issues by category
|
|
55
|
+
const issuesByCategory = groupIssuesByCategory(report.issues);
|
|
56
|
+
const categories = Object.keys(issuesByCategory) as IssueCategory[];
|
|
57
|
+
|
|
58
|
+
// Generate category files
|
|
59
|
+
for (const category of categories) {
|
|
60
|
+
const issues = issuesByCategory[category] || [];
|
|
61
|
+
if (issues.length === 0) continue;
|
|
62
|
+
|
|
63
|
+
const chunks = splitIntoChunks(issues, category);
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
66
|
+
const suffix = chunks.length > 1 ? `-${i + 1}` : '';
|
|
67
|
+
const filename = `${prefix}-${category}${suffix}.md`;
|
|
68
|
+
const filepath = join(outputDir, filename);
|
|
69
|
+
|
|
70
|
+
const chunk = chunks[i];
|
|
71
|
+
if (!chunk) continue;
|
|
72
|
+
const content = generateCategoryFile(report.siteUrl, category, chunk, {
|
|
73
|
+
part: chunks.length > 1 ? i + 1 : undefined,
|
|
74
|
+
totalParts: chunks.length > 1 ? chunks.length : undefined,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
writeFileSync(filepath, content, 'utf-8');
|
|
78
|
+
categoryFiles.push(filename);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generate index file
|
|
83
|
+
const indexFilename = `${prefix}-index.md`;
|
|
84
|
+
const indexFilepath = join(outputDir, indexFilename);
|
|
85
|
+
const indexContent = generateIndexFile(report, categoryFiles);
|
|
86
|
+
writeFileSync(indexFilepath, indexContent, 'utf-8');
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
indexFile: indexFilename,
|
|
90
|
+
categoryFiles,
|
|
91
|
+
totalFiles: categoryFiles.length + 1,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate compact index file
|
|
97
|
+
*/
|
|
98
|
+
function generateIndexFile(report: SeoReport, categoryFiles: string[]): string {
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
|
|
101
|
+
lines.push('# SEO Report Index');
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(`Site: ${report.siteUrl}`);
|
|
104
|
+
lines.push(`Score: ${report.summary.healthScore}/100`);
|
|
105
|
+
lines.push(`Date: ${report.generatedAt.slice(0, 10)}`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
|
|
108
|
+
// Severity counts
|
|
109
|
+
lines.push('## Issues');
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push('| Severity | Count |');
|
|
112
|
+
lines.push('|----------|-------|');
|
|
113
|
+
const severities: IssueSeverity[] = ['critical', 'error', 'warning', 'info'];
|
|
114
|
+
for (const sev of severities) {
|
|
115
|
+
const count = report.summary.issuesBySeverity[sev] || 0;
|
|
116
|
+
if (count > 0) {
|
|
117
|
+
lines.push(`| ${sev} | ${count} |`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
|
|
122
|
+
// Top 10 recommendations (compact)
|
|
123
|
+
lines.push('## Actions');
|
|
124
|
+
lines.push('');
|
|
125
|
+
const topRecs = report.recommendations.slice(0, 10);
|
|
126
|
+
for (let i = 0; i < topRecs.length; i++) {
|
|
127
|
+
const rec = topRecs[i];
|
|
128
|
+
if (!rec) continue;
|
|
129
|
+
lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length})`);
|
|
130
|
+
lines.push(` ${rec.actionItems[0]}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
|
|
134
|
+
// File index
|
|
135
|
+
lines.push('## Files');
|
|
136
|
+
lines.push('');
|
|
137
|
+
for (const file of categoryFiles) {
|
|
138
|
+
lines.push(`- [${file}](./${file})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Generate compact category file
|
|
146
|
+
*/
|
|
147
|
+
function generateCategoryFile(
|
|
148
|
+
siteUrl: string,
|
|
149
|
+
category: IssueCategory,
|
|
150
|
+
issues: SeoIssue[],
|
|
151
|
+
opts: { part?: number; totalParts?: number }
|
|
152
|
+
): string {
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
const partStr = opts.part ? ` (Part ${opts.part}/${opts.totalParts})` : '';
|
|
155
|
+
|
|
156
|
+
lines.push(`# ${formatCategory(category)}${partStr}`);
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push(`Site: ${siteUrl}`);
|
|
159
|
+
lines.push(`Issues: ${issues.length}`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
|
|
162
|
+
// Group by title for compact display
|
|
163
|
+
const byTitle = new Map<string, SeoIssue[]>();
|
|
164
|
+
for (const issue of issues) {
|
|
165
|
+
const group = byTitle.get(issue.title) || [];
|
|
166
|
+
group.push(issue);
|
|
167
|
+
byTitle.set(issue.title, group);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const [title, groupIssues] of byTitle) {
|
|
171
|
+
const first = groupIssues[0];
|
|
172
|
+
if (!first) continue;
|
|
173
|
+
|
|
174
|
+
lines.push(`## ${title}`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push(`Severity: ${first.severity}`);
|
|
177
|
+
lines.push(`Count: ${groupIssues.length}`);
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push(`> ${first.recommendation}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
// Compact URL list
|
|
183
|
+
lines.push('URLs:');
|
|
184
|
+
for (const issue of groupIssues.slice(0, 20)) {
|
|
185
|
+
lines.push(`- ${issue.url}`);
|
|
186
|
+
}
|
|
187
|
+
if (groupIssues.length > 20) {
|
|
188
|
+
lines.push(`- ... +${groupIssues.length - 20} more`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Split issues into chunks that fit within MAX_LINES
|
|
198
|
+
*/
|
|
199
|
+
function splitIntoChunks(issues: SeoIssue[], category: IssueCategory): SeoIssue[][] {
|
|
200
|
+
// Estimate lines per issue group (title + severity + recommendation + URLs)
|
|
201
|
+
const byTitle = new Map<string, SeoIssue[]>();
|
|
202
|
+
for (const issue of issues) {
|
|
203
|
+
const group = byTitle.get(issue.title) || [];
|
|
204
|
+
group.push(issue);
|
|
205
|
+
byTitle.set(issue.title, group);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const chunks: SeoIssue[][] = [];
|
|
209
|
+
let currentChunk: SeoIssue[] = [];
|
|
210
|
+
let currentLines = 10; // Header overhead
|
|
211
|
+
|
|
212
|
+
for (const [, groupIssues] of byTitle) {
|
|
213
|
+
// Estimate: 6 lines header + min(20, count) URLs + 2 spacing
|
|
214
|
+
const urlCount = Math.min(20, groupIssues.length);
|
|
215
|
+
const groupLines = 8 + urlCount;
|
|
216
|
+
|
|
217
|
+
if (currentLines + groupLines > MAX_LINES && currentChunk.length > 0) {
|
|
218
|
+
chunks.push(currentChunk);
|
|
219
|
+
currentChunk = [];
|
|
220
|
+
currentLines = 10;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
currentChunk.push(...groupIssues);
|
|
224
|
+
currentLines += groupLines;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (currentChunk.length > 0) {
|
|
228
|
+
chunks.push(currentChunk);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return chunks.length > 0 ? chunks : [[]];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function groupIssuesByCategory(issues: SeoIssue[]): Record<IssueCategory, SeoIssue[]> {
|
|
235
|
+
const result: Partial<Record<IssueCategory, SeoIssue[]>> = {};
|
|
236
|
+
|
|
237
|
+
for (const issue of issues) {
|
|
238
|
+
if (!result[issue.category]) {
|
|
239
|
+
result[issue.category] = [];
|
|
240
|
+
}
|
|
241
|
+
result[issue.category]!.push(issue);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return result as Record<IssueCategory, SeoIssue[]>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function formatCategory(category: string): string {
|
|
248
|
+
return category
|
|
249
|
+
.split('-')
|
|
250
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
251
|
+
.join(' ');
|
|
252
|
+
}
|