@aexol/spectral 0.9.43 → 0.9.44
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/dist/extensions/seo/analyzers/content-optimize.d.ts +3 -0
- package/dist/extensions/seo/analyzers/content-optimize.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/content-optimize.js +190 -0
- package/dist/extensions/seo/analyzers/crawler.d.ts +4 -0
- package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/crawler.js +288 -0
- package/dist/extensions/seo/analyzers/keywords.d.ts +9 -0
- package/dist/extensions/seo/analyzers/keywords.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/keywords.js +183 -0
- package/dist/extensions/seo/analyzers/performance.d.ts +6 -0
- package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/performance.js +257 -0
- package/dist/extensions/seo/analyzers/readability.d.ts +4 -0
- package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/readability.js +121 -0
- package/dist/extensions/seo/index.d.ts.map +1 -1
- package/dist/extensions/seo/index.js +47 -9
- package/dist/extensions/seo/tools/batch.d.ts +4 -0
- package/dist/extensions/seo/tools/batch.d.ts.map +1 -0
- package/dist/extensions/seo/tools/batch.js +193 -0
- package/dist/extensions/seo/tools/crawl.d.ts +4 -0
- package/dist/extensions/seo/tools/crawl.d.ts.map +1 -0
- package/dist/extensions/seo/tools/crawl.js +166 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +1 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/keywords.d.ts +4 -0
- package/dist/extensions/seo/tools/keywords.d.ts.map +1 -0
- package/dist/extensions/seo/tools/keywords.js +234 -0
- package/dist/extensions/seo/tools/performance.d.ts +4 -0
- package/dist/extensions/seo/tools/performance.d.ts.map +1 -0
- package/dist/extensions/seo/tools/performance.js +201 -0
- package/dist/extensions/seo/types.d.ts +156 -0
- package/dist/extensions/seo/types.d.ts.map +1 -1
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
- package/dist/extensions/seo/utils/url-safety.js +7 -5
- package/dist/server/handlers/paths-pick-directory.d.ts +0 -27
- package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
- package/dist/server/handlers/paths-pick-directory.js +86 -92
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/batch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAiB9C,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA0P7E"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { auditTechnical } from "../analyzers/technical.js";
|
|
2
|
+
import { analyzeContentQuality } from "../analyzers/content.js";
|
|
3
|
+
import { fetchPage } from "../utils/fetcher.js";
|
|
4
|
+
import { parsePage, extractText } from "../utils/parser.js";
|
|
5
|
+
import { analyzeKeywords } from "../analyzers/keywords.js";
|
|
6
|
+
import { analyzeReadability } from "../analyzers/readability.js";
|
|
7
|
+
import { errorResult, textResult, truncate } from "./helpers.js";
|
|
8
|
+
export function registerBatchTools(ext, config) {
|
|
9
|
+
// === seo_audit_batch ===
|
|
10
|
+
const batchTool = {
|
|
11
|
+
name: "seo_audit_batch",
|
|
12
|
+
label: "SEO Batch Audit",
|
|
13
|
+
description: "Run SEO audits on multiple URLs, typically from a sitemap. " +
|
|
14
|
+
"Audits each URL for technical SEO, content quality, and schema markup. " +
|
|
15
|
+
"Returns a comparison table with scores for each page. " +
|
|
16
|
+
"Use this after seo_crawl to audit all discovered subpages at once.",
|
|
17
|
+
promptSnippet: "`seo_audit_batch { urls[] }` — audit multiple URLs and compare scores",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
urls: {
|
|
22
|
+
type: "array",
|
|
23
|
+
items: { type: "string" },
|
|
24
|
+
description: "List of URLs to audit.",
|
|
25
|
+
},
|
|
26
|
+
maxPages: {
|
|
27
|
+
type: "number",
|
|
28
|
+
description: "Max URLs to audit (limits batch size). Default: 20.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ["urls"],
|
|
32
|
+
},
|
|
33
|
+
async execute(_toolCallId, params) {
|
|
34
|
+
const { urls, maxPages } = params;
|
|
35
|
+
try {
|
|
36
|
+
const toAudit = urls.slice(0, maxPages ?? 20);
|
|
37
|
+
const results = [];
|
|
38
|
+
for (const url of toAudit) {
|
|
39
|
+
try {
|
|
40
|
+
const fetchResult = await fetchPage(url, config);
|
|
41
|
+
const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
|
|
42
|
+
const technical = await auditTechnical(url, config, pageData, fetchResult);
|
|
43
|
+
const text = extractText(fetchResult.content);
|
|
44
|
+
const content = analyzeContentQuality(text, fetchResult.finalUrl);
|
|
45
|
+
results.push({
|
|
46
|
+
url: fetchResult.finalUrl,
|
|
47
|
+
status: fetchResult.statusCode,
|
|
48
|
+
technicalScore: technical.score,
|
|
49
|
+
contentScore: content.overallQuality,
|
|
50
|
+
title: pageData.title ?? "(no title)",
|
|
51
|
+
wordCount: pageData.wordCount,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
results.push({
|
|
56
|
+
url,
|
|
57
|
+
status: 0,
|
|
58
|
+
technicalScore: 0,
|
|
59
|
+
contentScore: 0,
|
|
60
|
+
title: "(error)",
|
|
61
|
+
wordCount: 0,
|
|
62
|
+
error: err instanceof Error ? err.message : String(err),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const avgTech = Math.round(results.filter((r) => !r.error).reduce((s, r) => s + r.technicalScore, 0) /
|
|
67
|
+
Math.max(1, results.filter((r) => !r.error).length));
|
|
68
|
+
const avgContent = Math.round(results.filter((r) => !r.error).reduce((s, r) => s + r.contentScore, 0) /
|
|
69
|
+
Math.max(1, results.filter((r) => !r.error).length));
|
|
70
|
+
const lines = [
|
|
71
|
+
`✓ Batch Audit: ${results.length} pages`,
|
|
72
|
+
``,
|
|
73
|
+
`Average Technical Score: ${avgTech}/100`,
|
|
74
|
+
`Average Content Score: ${avgContent}/100`,
|
|
75
|
+
``,
|
|
76
|
+
`| # | URL | Status | Tech | Content | Title |`,
|
|
77
|
+
`|---|-----|--------|------|---------|-------|`,
|
|
78
|
+
];
|
|
79
|
+
results.forEach((r, i) => {
|
|
80
|
+
lines.push(`| ${i + 1} | ${truncate(r.url, 60)} | ${r.status || "ERR"} | ${r.technicalScore} | ${r.contentScore} | ${truncate(r.title, 40)} |`);
|
|
81
|
+
});
|
|
82
|
+
return textResult(lines.join("\n"), { results, avgTech, avgContent });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
// === seo_competitor_gap ===
|
|
90
|
+
const gapTool = {
|
|
91
|
+
name: "seo_competitor_gap",
|
|
92
|
+
label: "SEO Competitor Gap Analysis",
|
|
93
|
+
description: "Compare your page against a competitor's page. Analyzes differences in: " +
|
|
94
|
+
"keyword usage (which keywords do they use that you don't?), content length, " +
|
|
95
|
+
"readability, heading structure, schema markup, and technical signals. " +
|
|
96
|
+
"Helps identify content gaps and optimization opportunities.",
|
|
97
|
+
promptSnippet: "`seo_competitor_gap { yourUrl, competitorUrl }` — compare pages for content/SEO gaps",
|
|
98
|
+
parameters: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
yourUrl: { type: "string", description: "Your page URL." },
|
|
102
|
+
competitorUrl: { type: "string", description: "Competitor page URL to compare against." },
|
|
103
|
+
},
|
|
104
|
+
required: ["yourUrl", "competitorUrl"],
|
|
105
|
+
},
|
|
106
|
+
async execute(_toolCallId, params) {
|
|
107
|
+
const { yourUrl, competitorUrl } = params;
|
|
108
|
+
try {
|
|
109
|
+
const [yourFetch, compFetch] = await Promise.all([
|
|
110
|
+
fetchPage(yourUrl, config),
|
|
111
|
+
fetchPage(competitorUrl, config),
|
|
112
|
+
]);
|
|
113
|
+
const yourData = parsePage(yourFetch.content, yourFetch.finalUrl);
|
|
114
|
+
const compData = parsePage(compFetch.content, compFetch.finalUrl);
|
|
115
|
+
const yourText = extractText(yourFetch.content);
|
|
116
|
+
const compText = extractText(compFetch.content);
|
|
117
|
+
const yourKw = analyzeKeywords(yourText, yourData.url);
|
|
118
|
+
const compKw = analyzeKeywords(compText, compData.url);
|
|
119
|
+
const yourReadability = analyzeReadability(yourText, yourData.url);
|
|
120
|
+
const compReadability = analyzeReadability(compText, compData.url);
|
|
121
|
+
// Find keywords competitor uses that you don't
|
|
122
|
+
const yourKeywordSet = new Set(yourKw.keywords.map((k) => k.keyword));
|
|
123
|
+
const compKeywordSet = new Set(compKw.keywords.map((k) => k.keyword));
|
|
124
|
+
const competitorOnlyKeywords = compKw.keywords
|
|
125
|
+
.filter((k) => !yourKeywordSet.has(k.keyword))
|
|
126
|
+
.slice(0, 10);
|
|
127
|
+
const sharedKeywords = compKw.keywords
|
|
128
|
+
.filter((k) => yourKeywordSet.has(k.keyword))
|
|
129
|
+
.slice(0, 10);
|
|
130
|
+
const yourSchemaTypes = new Set(yourData.schemaBlocks.map((s) => s.type));
|
|
131
|
+
const compSchemaTypes = new Set(compData.schemaBlocks.map((s) => s.type));
|
|
132
|
+
const compOnlySchemas = [...compSchemaTypes].filter((t) => !yourSchemaTypes.has(t));
|
|
133
|
+
const lines = [
|
|
134
|
+
`✓ Competitor Gap Analysis`,
|
|
135
|
+
``,
|
|
136
|
+
`Your page: ${yourData.url}`,
|
|
137
|
+
`Competitor: ${compData.url}`,
|
|
138
|
+
``,
|
|
139
|
+
`## Content Comparison`,
|
|
140
|
+
``,
|
|
141
|
+
`| Metric | Yours | Competitor |`,
|
|
142
|
+
`|--------|-------|------------|`,
|
|
143
|
+
`| Word count | ${yourData.wordCount} | ${compData.wordCount} |`,
|
|
144
|
+
`| Headings | H1:${yourData.h1Count} H2:${yourData.h2Count} H3:${yourData.h3Count} | H1:${compData.h1Count} H2:${compData.h2Count} H3:${compData.h3Count} |`,
|
|
145
|
+
`| Internal links | ${yourData.internalLinkCount} | ${compData.internalLinkCount} |`,
|
|
146
|
+
`| Images | ${yourData.images.length} | ${compData.images.length} |`,
|
|
147
|
+
`| Schema types | ${yourData.schemaBlocks.length} | ${compData.schemaBlocks.length} |`,
|
|
148
|
+
`| Readability (grade) | ${yourReadability.gradeAverage} | ${compReadability.gradeAverage} |`,
|
|
149
|
+
`| Readability (ease) | ${yourReadability.fleschReadingEase} | ${compReadability.fleschReadingEase} |`,
|
|
150
|
+
``,
|
|
151
|
+
];
|
|
152
|
+
if (competitorOnlyKeywords.length > 0) {
|
|
153
|
+
lines.push(`## Keywords Competitor Uses That You Don't`, ``, ...competitorOnlyKeywords.map((k) => ` • "${k.keyword}" — ${k.count}x, density ${k.density}%, TF-IDF ${k.tfidf}`), ``);
|
|
154
|
+
}
|
|
155
|
+
if (sharedKeywords.length > 0) {
|
|
156
|
+
lines.push(`## Shared Keywords`, ``, ...sharedKeywords.map((k) => ` • "${k.keyword}" — you: ${yourKw.keywords.find((yk) => yk.keyword === k.keyword)?.count ?? 0}x, competitor: ${k.count}x`), ``);
|
|
157
|
+
}
|
|
158
|
+
if (compOnlySchemas.length > 0) {
|
|
159
|
+
lines.push(`## Schema Types Competitor Has That You Don't`, ``, ...compOnlySchemas.map((t) => ` • ${t}`), ``);
|
|
160
|
+
}
|
|
161
|
+
// Content gap recommendations
|
|
162
|
+
lines.push(`## Recommendations`);
|
|
163
|
+
if (compData.wordCount > yourData.wordCount * 1.5) {
|
|
164
|
+
lines.push(` • Content gap: Competitor has ${compData.wordCount - yourData.wordCount} more words. Consider expanding your content.`);
|
|
165
|
+
}
|
|
166
|
+
if (compData.h2Count > yourData.h2Count) {
|
|
167
|
+
lines.push(` • Structure gap: Competitor uses more H2 subheadings (${compData.h2Count} vs ${yourData.h2Count}). Break your content into more sections.`);
|
|
168
|
+
}
|
|
169
|
+
if (compData.schemaBlocks.length > yourData.schemaBlocks.length) {
|
|
170
|
+
lines.push(` • Schema gap: Competitor has ${compData.schemaBlocks.length - yourData.schemaBlocks.length} more schema types. Consider adding: ${compOnlySchemas.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
if (competitorOnlyKeywords.length > 0) {
|
|
173
|
+
lines.push(` • Keyword gap: Competitor ranks for ${competitorOnlyKeywords.length} keywords you don't cover. Consider addressing: ${competitorOnlyKeywords.map((k) => `"${k.keyword}"`).join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
if (lines[lines.length - 1] === `## Recommendations`) {
|
|
176
|
+
lines.push(` • No significant gaps detected. Your content appears competitive.`);
|
|
177
|
+
}
|
|
178
|
+
return textResult(lines.join("\n"), {
|
|
179
|
+
yourUrl: yourData.url,
|
|
180
|
+
competitorUrl: compData.url,
|
|
181
|
+
competitorOnlyKeywords,
|
|
182
|
+
sharedKeywords,
|
|
183
|
+
compOnlySchemas,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
ext.registerTool(batchTool);
|
|
192
|
+
ext.registerTool(gapTool);
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawl.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/crawl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAK9C,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA4F7E"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { crawlSite } from "../analyzers/crawler.js";
|
|
2
|
+
import { errorResult, textResult, truncate } from "./helpers.js";
|
|
3
|
+
export function registerCrawlTools(ext, config) {
|
|
4
|
+
const crawlTool = {
|
|
5
|
+
name: "seo_crawl",
|
|
6
|
+
label: "SEO Site Crawler",
|
|
7
|
+
description: "Crawl an entire website starting from a URL, following internal links up to a configurable depth and page limit. " +
|
|
8
|
+
"Discovers all subpages, builds an internal link graph, detects broken links (4xx/5xx), " +
|
|
9
|
+
"finds duplicate content (same title/description/H1 across pages), and identifies orphan pages. " +
|
|
10
|
+
"Respects robots.txt and rate-limits requests. Returns per-page details and a site-wide summary. " +
|
|
11
|
+
"Use this before seo_audit to discover all pages, then run seo_audit on key subpages.",
|
|
12
|
+
promptSnippet: "`seo_crawl { url, maxPages?, maxDepth?, concurrency? }` — crawl a site and discover all subpages, broken links, and duplicate content",
|
|
13
|
+
promptGuidelines: [
|
|
14
|
+
"Use seo_crawl to discover all pages on a site before running targeted audits on key subpages.",
|
|
15
|
+
"The crawler respects robots.txt and rate-limits requests automatically.",
|
|
16
|
+
"Start with maxPages=50 for a quick scan, increase to 200-500 for a full site audit.",
|
|
17
|
+
],
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
url: { type: "string", description: "The starting URL to crawl." },
|
|
22
|
+
maxPages: { type: "number", description: "Maximum pages to crawl. Default: 200." },
|
|
23
|
+
maxDepth: { type: "number", description: "Maximum crawl depth from start URL. Default: 5." },
|
|
24
|
+
concurrency: { type: "number", description: "Concurrent fetches. Default: 3." },
|
|
25
|
+
},
|
|
26
|
+
required: ["url"],
|
|
27
|
+
},
|
|
28
|
+
async execute(_toolCallId, params) {
|
|
29
|
+
const { url, maxPages, maxDepth, concurrency } = params;
|
|
30
|
+
try {
|
|
31
|
+
const crawlConfig = {};
|
|
32
|
+
if (maxPages !== undefined)
|
|
33
|
+
crawlConfig.maxPages = maxPages;
|
|
34
|
+
if (maxDepth !== undefined)
|
|
35
|
+
crawlConfig.maxDepth = maxDepth;
|
|
36
|
+
if (concurrency !== undefined)
|
|
37
|
+
crawlConfig.concurrency = concurrency;
|
|
38
|
+
const result = await crawlSite(url, config, crawlConfig);
|
|
39
|
+
const summary = formatCrawlSummary(result);
|
|
40
|
+
return textResult(summary, {
|
|
41
|
+
pagesCrawled: result.pagesCrawled,
|
|
42
|
+
brokenLinks: result.brokenLinks.length,
|
|
43
|
+
duplicateGroups: result.duplicateGroups.length,
|
|
44
|
+
orphanPages: result.orphanPages.length,
|
|
45
|
+
urls: result.pages.map((p) => p.url),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const crawlReportTool = {
|
|
54
|
+
name: "seo_crawl_report",
|
|
55
|
+
label: "SEO Crawl Report",
|
|
56
|
+
description: "Generate a detailed report from a crawl result. " +
|
|
57
|
+
"Shows broken links with source pages, duplicate content groups (title/description/H1), " +
|
|
58
|
+
"orphan pages, link graph statistics, and per-page status codes. " +
|
|
59
|
+
"Use this after seo_crawl to get actionable insights about site structure problems.",
|
|
60
|
+
promptSnippet: "`seo_crawl_report { crawlResult }` — generate detailed crawl analysis report",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
crawlResult: {
|
|
65
|
+
type: "object",
|
|
66
|
+
description: "The CrawlResult object from seo_crawl.",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ["crawlResult"],
|
|
70
|
+
},
|
|
71
|
+
async execute(_toolCallId, params) {
|
|
72
|
+
const { crawlResult } = params;
|
|
73
|
+
try {
|
|
74
|
+
const report = generateCrawlReport(crawlResult);
|
|
75
|
+
return textResult(report, {
|
|
76
|
+
pagesCrawled: crawlResult.pagesCrawled,
|
|
77
|
+
brokenLinks: crawlResult.brokenLinks.length,
|
|
78
|
+
duplicateGroups: crawlResult.duplicateGroups.length,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
ext.registerTool(crawlTool);
|
|
87
|
+
ext.registerTool(crawlReportTool);
|
|
88
|
+
}
|
|
89
|
+
function formatCrawlSummary(result) {
|
|
90
|
+
const lines = [
|
|
91
|
+
`✓ Crawl Complete: ${result.baseUrl}`,
|
|
92
|
+
``,
|
|
93
|
+
`Pages crawled: ${result.pagesCrawled}`,
|
|
94
|
+
`Total pages discovered: ${result.totalPagesFound}`,
|
|
95
|
+
`Max depth reached: ${result.maxDepthReached}`,
|
|
96
|
+
`Broken links: ${result.brokenLinks.length}`,
|
|
97
|
+
`Duplicate content groups: ${result.duplicateGroups.length}`,
|
|
98
|
+
`Orphan pages: ${result.orphanPages.length}`,
|
|
99
|
+
`Errors: ${result.errors.length}`,
|
|
100
|
+
``,
|
|
101
|
+
`Crawled URLs (${result.pages.length}):`,
|
|
102
|
+
];
|
|
103
|
+
for (const page of result.pages) {
|
|
104
|
+
const statusIcon = page.statusCode >= 400 ? "❌" : page.statusCode >= 300 ? "↪" : "✓";
|
|
105
|
+
const title = page.pageData?.title ?? "(no title)";
|
|
106
|
+
lines.push(` ${statusIcon} [${page.statusCode}] depth=${page.depth} ${truncate(page.url, 100)} — ${truncate(title, 60)}`);
|
|
107
|
+
}
|
|
108
|
+
if (result.brokenLinks.length > 0) {
|
|
109
|
+
lines.push(``, `Broken Links (${result.brokenLinks.length}):`);
|
|
110
|
+
for (const bl of result.brokenLinks) {
|
|
111
|
+
lines.push(` ❌ ${bl.targetUrl} → ${bl.statusCode} (from ${bl.sourcePage})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (result.duplicateGroups.length > 0) {
|
|
115
|
+
lines.push(``, `Duplicate Content (${result.duplicateGroups.length} groups):`);
|
|
116
|
+
for (const dg of result.duplicateGroups) {
|
|
117
|
+
lines.push(` [${dg.type}] "${truncate(dg.value, 80)}" — ${dg.pageUrls.length} pages`);
|
|
118
|
+
for (const url of dg.pageUrls.slice(0, 5)) {
|
|
119
|
+
lines.push(` • ${url}`);
|
|
120
|
+
}
|
|
121
|
+
if (dg.pageUrls.length > 5)
|
|
122
|
+
lines.push(` ... and ${dg.pageUrls.length - 5} more`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (result.errors.length > 0) {
|
|
126
|
+
lines.push(``, `Errors (${result.errors.length}):`);
|
|
127
|
+
for (const err of result.errors.slice(0, 10)) {
|
|
128
|
+
lines.push(` ⚠ ${err}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
function generateCrawlReport(result) {
|
|
134
|
+
const lines = [
|
|
135
|
+
`# SEO Crawl Report`,
|
|
136
|
+
``,
|
|
137
|
+
`**Base URL:** ${result.baseUrl}`,
|
|
138
|
+
`**Pages crawled:** ${result.pagesCrawled}`,
|
|
139
|
+
`**Max depth:** ${result.maxDepthReached}`,
|
|
140
|
+
`**Errors:** ${result.errors.length}`,
|
|
141
|
+
``,
|
|
142
|
+
`## Page Status Summary`,
|
|
143
|
+
``,
|
|
144
|
+
`| URL | Status | Depth | Title |`,
|
|
145
|
+
`|-----|--------|-------|-------|`,
|
|
146
|
+
...result.pages.map((p) => `| ${p.url} | ${p.statusCode} | ${p.depth} | ${truncate(p.pageData?.title ?? "", 50)} |`),
|
|
147
|
+
``,
|
|
148
|
+
];
|
|
149
|
+
if (result.brokenLinks.length > 0) {
|
|
150
|
+
lines.push(`## Broken Links (${result.brokenLinks.length})`, ``, `| Source | Target | Status |`, `|--------|--------|--------|`, ...result.brokenLinks.map((bl) => `| ${bl.sourcePage} | ${bl.targetUrl} | ${bl.statusCode} |`), ``);
|
|
151
|
+
}
|
|
152
|
+
if (result.duplicateGroups.length > 0) {
|
|
153
|
+
lines.push(`## Duplicate Content (${result.duplicateGroups.length} groups)`, ``);
|
|
154
|
+
for (const dg of result.duplicateGroups) {
|
|
155
|
+
lines.push(`### ${dg.type.toUpperCase()}: "${truncate(dg.value, 100)}"`, ``, `**${dg.pageUrls.length} pages** share this ${dg.type}:`, ...dg.pageUrls.map((u) => `- ${u}`), ``);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (result.linkGraph.length > 0) {
|
|
159
|
+
const avgInlinks = result.linkGraph.reduce((s, n) => s + n.internalInlinks, 0) / result.linkGraph.length;
|
|
160
|
+
lines.push(`## Link Graph`, ``, `**Average internal inlinks per page:** ${avgInlinks.toFixed(1)}`, ``, `### Top Pages by Internal Inlinks`, ``, `| URL | Inlinks | Outlinks | Depth |`, `|-----|---------|----------|-------|`, ...result.linkGraph
|
|
161
|
+
.sort((a, b) => b.internalInlinks - a.internalInlinks)
|
|
162
|
+
.slice(0, 20)
|
|
163
|
+
.map((n) => `| ${n.url} | ${n.internalInlinks} | ${n.internalOutlinks} | ${n.depth} |`), ``);
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
@@ -6,5 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
|
|
8
8
|
import type { SeoConfig } from "../config.js";
|
|
9
|
+
export type AuditCategory = "technical" | "content" | "schema" | "images" | "preload" | "agentUx";
|
|
9
10
|
export declare function registerFullAuditTools(ext: ExtensionAPI, config: SeoConfig): void;
|
|
10
11
|
//# sourceMappingURL=full-audit.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAa9C,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAIlG,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA+DjF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keywords.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/keywords.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAQ9C,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAqP/E"}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { fetchPage } from "../utils/fetcher.js";
|
|
2
|
+
import { parsePage, extractText } from "../utils/parser.js";
|
|
3
|
+
import { analyzeKeywords, analyzeKeywordDensity } from "../analyzers/keywords.js";
|
|
4
|
+
import { analyzeReadability, interpretReadability } from "../analyzers/readability.js";
|
|
5
|
+
import { optimizeContent } from "../analyzers/content-optimize.js";
|
|
6
|
+
import { errorResult, textResult } from "./helpers.js";
|
|
7
|
+
export function registerKeywordTools(ext, config) {
|
|
8
|
+
// === seo_keywords ===
|
|
9
|
+
const keywordsTool = {
|
|
10
|
+
name: "seo_keywords",
|
|
11
|
+
label: "SEO Keyword Analysis",
|
|
12
|
+
description: "Extract keywords from page content using TF-IDF analysis. " +
|
|
13
|
+
"Returns top keywords with density scores, n-gram phrases (bigrams and trigrams), " +
|
|
14
|
+
"and search intent classification (informational, commercial, transactional). " +
|
|
15
|
+
"Detects keyword stuffing when keyword density exceeds 3%. " +
|
|
16
|
+
"All analysis is offline — no external API keys required.",
|
|
17
|
+
promptSnippet: "`seo_keywords { url? | text? }` — extract keywords, n-grams, and search intent from content",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
url: { type: "string", description: "URL to fetch and analyze. If provided, text is extracted from the page." },
|
|
22
|
+
text: { type: "string", description: "Raw text to analyze. Used if 'url' is not provided." },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
async execute(_toolCallId, params) {
|
|
26
|
+
const { url, text } = params;
|
|
27
|
+
try {
|
|
28
|
+
let content = text;
|
|
29
|
+
let resolvedUrl = url ?? "";
|
|
30
|
+
if (url) {
|
|
31
|
+
const result = await fetchPage(url, config);
|
|
32
|
+
content = extractText(result.content);
|
|
33
|
+
resolvedUrl = result.finalUrl;
|
|
34
|
+
}
|
|
35
|
+
if (!content) {
|
|
36
|
+
return errorResult("Either 'url' or 'text' must be provided.");
|
|
37
|
+
}
|
|
38
|
+
const analysis = analyzeKeywords(content, resolvedUrl);
|
|
39
|
+
const lines = [
|
|
40
|
+
`✓ Keyword Analysis: ${resolvedUrl || "(text)"}`,
|
|
41
|
+
``,
|
|
42
|
+
`Total words: ${analysis.totalWords}`,
|
|
43
|
+
`Search intent: ${analysis.searchIntent} (confidence: ${(analysis.intentConfidence * 100).toFixed(0)}%)`,
|
|
44
|
+
``,
|
|
45
|
+
`Top Keywords (by TF-IDF):`,
|
|
46
|
+
];
|
|
47
|
+
for (const kw of analysis.keywords.slice(0, 15)) {
|
|
48
|
+
lines.push(` "${kw.keyword}" — ${kw.count}x, density ${kw.density}%, TF-IDF ${kw.tfidf}` +
|
|
49
|
+
(kw.variants.length > 0 ? `, variants: ${kw.variants.join(", ")}` : ""));
|
|
50
|
+
}
|
|
51
|
+
if (analysis.topNgrams.length > 0) {
|
|
52
|
+
lines.push(``, `Top Phrases (n-grams):`);
|
|
53
|
+
for (const ng of analysis.topNgrams.slice(0, 10)) {
|
|
54
|
+
lines.push(` "${ng.ngram}" — ${ng.count}x`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (analysis.keywordStuffingFlags.length > 0) {
|
|
58
|
+
lines.push(``, `⚠ Keyword Stuffing Warnings:`);
|
|
59
|
+
for (const flag of analysis.keywordStuffingFlags) {
|
|
60
|
+
lines.push(` ${flag}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return textResult(lines.join("\n"), { analysis });
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
// === seo_keyword_density ===
|
|
71
|
+
const densityTool = {
|
|
72
|
+
name: "seo_keyword_density",
|
|
73
|
+
label: "SEO Keyword Density Check",
|
|
74
|
+
description: "Check the density of specific target keywords on a page. " +
|
|
75
|
+
"Provides count, density percentage, and a verdict for each keyword " +
|
|
76
|
+
"(too low, optimal, acceptable, or too high/possible stuffing). " +
|
|
77
|
+
"Use this to verify your target keywords are present at appropriate levels.",
|
|
78
|
+
promptSnippet: "`seo_keyword_density { url? | text?, keywords[] }` — check density of target keywords",
|
|
79
|
+
parameters: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
url: { type: "string", description: "URL to fetch and analyze." },
|
|
83
|
+
text: { type: "string", description: "Raw text to analyze. Used if 'url' is not provided." },
|
|
84
|
+
keywords: {
|
|
85
|
+
type: "array",
|
|
86
|
+
items: { type: "string" },
|
|
87
|
+
description: "Target keywords to check density for.",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
required: ["keywords"],
|
|
91
|
+
},
|
|
92
|
+
async execute(_toolCallId, params) {
|
|
93
|
+
const { url, text, keywords } = params;
|
|
94
|
+
try {
|
|
95
|
+
let content = text;
|
|
96
|
+
let resolvedUrl = url ?? "";
|
|
97
|
+
if (url) {
|
|
98
|
+
const result = await fetchPage(url, config);
|
|
99
|
+
content = extractText(result.content);
|
|
100
|
+
resolvedUrl = result.finalUrl;
|
|
101
|
+
}
|
|
102
|
+
if (!content)
|
|
103
|
+
return errorResult("Either 'url' or 'text' must be provided.");
|
|
104
|
+
if (!keywords || keywords.length === 0)
|
|
105
|
+
return errorResult("At least one keyword is required.");
|
|
106
|
+
const results = analyzeKeywordDensity(content, keywords);
|
|
107
|
+
const lines = [
|
|
108
|
+
`✓ Keyword Density: ${resolvedUrl || "(text)"}`,
|
|
109
|
+
``,
|
|
110
|
+
];
|
|
111
|
+
for (const r of results) {
|
|
112
|
+
lines.push(` "${r.keyword}": ${r.count}x, ${r.density}% — ${r.verdict}`);
|
|
113
|
+
}
|
|
114
|
+
return textResult(lines.join("\n"), { results });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
// === seo_readability ===
|
|
122
|
+
const readabilityTool = {
|
|
123
|
+
name: "seo_readability",
|
|
124
|
+
label: "SEO Readability Analysis",
|
|
125
|
+
description: "Analyze text readability using multiple formulas: Flesch-Kincaid Grade Level, " +
|
|
126
|
+
"Flesch Reading Ease, SMOG Index, Automated Readability Index (ARI), and Coleman-Liau Index. " +
|
|
127
|
+
"Returns grade-level averages and SEO-specific readability advice. " +
|
|
128
|
+
"Helps ensure content targets the right audience level.",
|
|
129
|
+
promptSnippet: "`seo_readability { url? | text? }` — analyze content readability",
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
url: { type: "string", description: "URL to fetch and analyze." },
|
|
134
|
+
text: { type: "string", description: "Raw text to analyze. Used if 'url' is not provided." },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async execute(_toolCallId, params) {
|
|
138
|
+
const { url, text } = params;
|
|
139
|
+
try {
|
|
140
|
+
let content = text;
|
|
141
|
+
let resolvedUrl = url ?? "";
|
|
142
|
+
if (url) {
|
|
143
|
+
const result = await fetchPage(url, config);
|
|
144
|
+
content = extractText(result.content);
|
|
145
|
+
resolvedUrl = result.finalUrl;
|
|
146
|
+
}
|
|
147
|
+
if (!content)
|
|
148
|
+
return errorResult("Either 'url' or 'text' must be provided.");
|
|
149
|
+
const result = analyzeReadability(content, resolvedUrl);
|
|
150
|
+
const interpretation = interpretReadability(result);
|
|
151
|
+
const lines = [
|
|
152
|
+
`✓ Readability Analysis: ${resolvedUrl || "(text)"}`,
|
|
153
|
+
``,
|
|
154
|
+
`Words: ${content.split(/\s+/).filter(Boolean).length}`,
|
|
155
|
+
`Sentences: ${result.sentenceCount}`,
|
|
156
|
+
`Syllables: ${result.syllableCount}`,
|
|
157
|
+
`Complex words (3+ syllables): ${result.complexWordCount}`,
|
|
158
|
+
``,
|
|
159
|
+
`Readability Scores:`,
|
|
160
|
+
` Flesch Reading Ease: ${result.fleschReadingEase}`,
|
|
161
|
+
` Flesch-Kincaid Grade: ${result.fleschKincaid}`,
|
|
162
|
+
` SMOG Index: ${result.smog}`,
|
|
163
|
+
` ARI (Automated Readability): ${result.ari}`,
|
|
164
|
+
` Coleman-Liau: ${result.colemanLiau}`,
|
|
165
|
+
` Average Grade Level: ${result.gradeAverage}`,
|
|
166
|
+
``,
|
|
167
|
+
`Interpretation: ${interpretation}`,
|
|
168
|
+
];
|
|
169
|
+
return textResult(lines.join("\n"), { result });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
// === seo_content_optimize ===
|
|
177
|
+
const optimizeTool = {
|
|
178
|
+
name: "seo_content_optimize",
|
|
179
|
+
label: "SEO Content Optimization",
|
|
180
|
+
description: "Analyze on-page content optimization opportunities. Checks title length (ideal 50-60 chars), " +
|
|
181
|
+
"meta description length (ideal 150-160 chars), heading hierarchy (H1→H2→H3 structure), " +
|
|
182
|
+
"content length (minimum 300 words), duplicate title/description, and suspicious headings. " +
|
|
183
|
+
"Returns a score for each category with specific, actionable recommendations.",
|
|
184
|
+
promptSnippet: "`seo_content_optimize { url }` — content optimization audit with actionable fixes",
|
|
185
|
+
parameters: {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
url: { type: "string", description: "The URL to analyze." },
|
|
189
|
+
},
|
|
190
|
+
required: ["url"],
|
|
191
|
+
},
|
|
192
|
+
async execute(_toolCallId, params) {
|
|
193
|
+
const { url } = params;
|
|
194
|
+
try {
|
|
195
|
+
const fetchResult = await fetchPage(url, config);
|
|
196
|
+
const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
|
|
197
|
+
const result = optimizeContent(pageData);
|
|
198
|
+
const lines = [
|
|
199
|
+
`✓ Content Optimization: ${result.url}`,
|
|
200
|
+
``,
|
|
201
|
+
`Overall Score: ${result.overallScore}/100`,
|
|
202
|
+
``,
|
|
203
|
+
`Category Scores:`,
|
|
204
|
+
` Title: ${result.titleScore}/100`,
|
|
205
|
+
` Meta Description: ${result.descriptionScore}/100`,
|
|
206
|
+
` Heading Structure: ${result.headingStructureScore}/100`,
|
|
207
|
+
` Content Length: ${result.contentLengthScore}/100`,
|
|
208
|
+
``,
|
|
209
|
+
];
|
|
210
|
+
if (result.issues.length > 0) {
|
|
211
|
+
const sorted = result.issues.sort((a, b) => {
|
|
212
|
+
const rank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
213
|
+
return (rank[a.severity] ?? 4) - (rank[b.severity] ?? 4);
|
|
214
|
+
});
|
|
215
|
+
lines.push(`Issues (${result.issues.length}):`);
|
|
216
|
+
for (const issue of sorted) {
|
|
217
|
+
lines.push(` [${issue.severity.toUpperCase()}] ${issue.category}: ${issue.issue}`, ` → ${issue.recommendation}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
lines.push(`No issues found — content is well-optimized!`);
|
|
222
|
+
}
|
|
223
|
+
return textResult(lines.join("\n"), { result });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
ext.registerTool(keywordsTool);
|
|
231
|
+
ext.registerTool(densityTool);
|
|
232
|
+
ext.registerTool(readabilityTool);
|
|
233
|
+
ext.registerTool(optimizeTool);
|
|
234
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performance.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/performance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAW9C,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA4NnF"}
|