@aexol/spectral 0.9.43 → 0.9.45
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 +327 -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/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +3 -78
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-optimize.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/content-optimize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,QAAQ,EAAe,MAAM,aAAa,CAAC;AAepF,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,yBAAyB,CAyL7E"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const TITLE_MIN = 30;
|
|
2
|
+
const TITLE_MAX = 60;
|
|
3
|
+
const TITLE_IDEAL_MIN = 50;
|
|
4
|
+
const TITLE_IDEAL_MAX = 60;
|
|
5
|
+
const DESC_MIN = 120;
|
|
6
|
+
const DESC_MAX = 160;
|
|
7
|
+
const DESC_IDEAL_MIN = 150;
|
|
8
|
+
const DESC_IDEAL_MAX = 160;
|
|
9
|
+
const CONTENT_MIN_WORDS = 300;
|
|
10
|
+
const CONTENT_GOOD_WORDS = 1500;
|
|
11
|
+
export function optimizeContent(pageData) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
let titleScore = 100;
|
|
14
|
+
let descriptionScore = 100;
|
|
15
|
+
let headingStructureScore = 100;
|
|
16
|
+
let contentLengthScore = 100;
|
|
17
|
+
// === Title analysis ===
|
|
18
|
+
const title = pageData.title ?? "";
|
|
19
|
+
const titleLen = title.length;
|
|
20
|
+
if (!title) {
|
|
21
|
+
titleScore = 0;
|
|
22
|
+
issues.push({
|
|
23
|
+
category: "Title",
|
|
24
|
+
severity: "critical",
|
|
25
|
+
issue: "Missing page title",
|
|
26
|
+
recommendation: "Add a descriptive <title> tag between 50-60 characters.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else if (titleLen < TITLE_MIN) {
|
|
30
|
+
titleScore -= 30;
|
|
31
|
+
issues.push({
|
|
32
|
+
category: "Title",
|
|
33
|
+
severity: "high",
|
|
34
|
+
issue: `Title too short (${titleLen} chars, minimum ${TITLE_MIN})`,
|
|
35
|
+
recommendation: "Expand title to 50-60 characters. Include primary keyword near the beginning.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else if (titleLen > TITLE_MAX) {
|
|
39
|
+
titleScore -= 25;
|
|
40
|
+
issues.push({
|
|
41
|
+
category: "Title",
|
|
42
|
+
severity: "medium",
|
|
43
|
+
issue: `Title too long (${titleLen} chars, max ${TITLE_MAX})`,
|
|
44
|
+
recommendation: `Trim title to ${TITLE_MAX} characters. Current title will be truncated in SERP.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else if (titleLen < TITLE_IDEAL_MIN) {
|
|
48
|
+
titleScore -= 10;
|
|
49
|
+
issues.push({
|
|
50
|
+
category: "Title",
|
|
51
|
+
severity: "low",
|
|
52
|
+
issue: `Title slightly short (${titleLen} chars, ideal ${TITLE_IDEAL_MIN}-${TITLE_IDEAL_MAX})`,
|
|
53
|
+
recommendation: "Expand title slightly for better SERP visibility.",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// === Meta description analysis ===
|
|
57
|
+
const desc = pageData.metaDescription ?? "";
|
|
58
|
+
const descLen = desc.length;
|
|
59
|
+
if (!desc) {
|
|
60
|
+
descriptionScore = 0;
|
|
61
|
+
issues.push({
|
|
62
|
+
category: "Meta Description",
|
|
63
|
+
severity: "critical",
|
|
64
|
+
issue: "Missing meta description",
|
|
65
|
+
recommendation: "Add a meta description between 150-160 characters with a clear call to action.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (descLen < DESC_MIN) {
|
|
69
|
+
descriptionScore -= 35;
|
|
70
|
+
issues.push({
|
|
71
|
+
category: "Meta Description",
|
|
72
|
+
severity: "high",
|
|
73
|
+
issue: `Meta description too short (${descLen} chars, minimum ${DESC_MIN})`,
|
|
74
|
+
recommendation: "Expand meta description to 150-160 characters. Include primary and secondary keywords naturally.",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else if (descLen > DESC_MAX) {
|
|
78
|
+
descriptionScore -= 20;
|
|
79
|
+
issues.push({
|
|
80
|
+
category: "Meta Description",
|
|
81
|
+
severity: "medium",
|
|
82
|
+
issue: `Meta description too long (${descLen} chars, max ${DESC_MAX})`,
|
|
83
|
+
recommendation: `Trim description to ${DESC_MAX} characters. Google typically truncates at ~160 chars.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (descLen < DESC_IDEAL_MIN) {
|
|
87
|
+
descriptionScore -= 5;
|
|
88
|
+
issues.push({
|
|
89
|
+
category: "Meta Description",
|
|
90
|
+
severity: "low",
|
|
91
|
+
issue: `Meta description slightly short (${descLen} chars, ideal ${DESC_IDEAL_MIN}-${DESC_IDEAL_MAX})`,
|
|
92
|
+
recommendation: "Slightly expand description to fill available SERP space.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Duplicate title/description check
|
|
96
|
+
if (title === desc) {
|
|
97
|
+
titleScore -= 40;
|
|
98
|
+
descriptionScore -= 40;
|
|
99
|
+
issues.push({
|
|
100
|
+
category: "Content",
|
|
101
|
+
severity: "high",
|
|
102
|
+
issue: "Page title and meta description are identical",
|
|
103
|
+
recommendation: "Title and description serve different purposes. Make them distinct — title for keyword targeting, description for CTR.",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// === Heading structure analysis ===
|
|
107
|
+
if (pageData.h1Count === 0) {
|
|
108
|
+
headingStructureScore -= 30;
|
|
109
|
+
issues.push({
|
|
110
|
+
category: "Heading Structure",
|
|
111
|
+
severity: "high",
|
|
112
|
+
issue: "No H1 heading found",
|
|
113
|
+
recommendation: "Add a single, descriptive H1 that includes your primary keyword.",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (pageData.h1Count > 1) {
|
|
117
|
+
headingStructureScore -= 20;
|
|
118
|
+
issues.push({
|
|
119
|
+
category: "Heading Structure",
|
|
120
|
+
severity: "medium",
|
|
121
|
+
issue: `Multiple H1 headings (${pageData.h1Count})`,
|
|
122
|
+
recommendation: "Use exactly one H1 per page. Convert extra H1s to H2s.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (pageData.h2Count === 0 && pageData.wordCount > 200) {
|
|
126
|
+
headingStructureScore -= 15;
|
|
127
|
+
issues.push({
|
|
128
|
+
category: "Heading Structure",
|
|
129
|
+
severity: "medium",
|
|
130
|
+
issue: "No H2 headings for content longer than 200 words",
|
|
131
|
+
recommendation: "Add H2 headings to break content into scannable sections.",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Check heading hierarchy gaps (e.g., H1 → H3 skipping H2)
|
|
135
|
+
const headingLevels = pageData.headings.map((h) => h.level);
|
|
136
|
+
for (let i = 1; i < headingLevels.length; i++) {
|
|
137
|
+
const gap = headingLevels[i] - headingLevels[i - 1];
|
|
138
|
+
if (gap > 1) {
|
|
139
|
+
headingStructureScore -= 10;
|
|
140
|
+
issues.push({
|
|
141
|
+
category: "Heading Structure",
|
|
142
|
+
severity: "low",
|
|
143
|
+
issue: `Heading level skip: H${headingLevels[i - 1]} → H${headingLevels[i]} without intermediate level`,
|
|
144
|
+
recommendation: `Add an H${headingLevels[i - 1] + 1} between these headings for proper hierarchy.`,
|
|
145
|
+
});
|
|
146
|
+
break; // Report once
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Check suspicious headings
|
|
150
|
+
const suspiciousHeadings = pageData.headings.filter((h) => h.suspicious);
|
|
151
|
+
if (suspiciousHeadings.length > 0) {
|
|
152
|
+
headingStructureScore -= 10;
|
|
153
|
+
issues.push({
|
|
154
|
+
category: "Heading Structure",
|
|
155
|
+
severity: "low",
|
|
156
|
+
issue: `${suspiciousHeadings.length} suspicious heading(s) found (e.g., "click here", "learn more")`,
|
|
157
|
+
recommendation: "Use descriptive, keyword-rich headings instead of generic labels.",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// === Content length analysis ===
|
|
161
|
+
if (pageData.wordCount < CONTENT_MIN_WORDS) {
|
|
162
|
+
contentLengthScore -= 40;
|
|
163
|
+
issues.push({
|
|
164
|
+
category: "Content Length",
|
|
165
|
+
severity: "high",
|
|
166
|
+
issue: `Thin content: ${pageData.wordCount} words (minimum ${CONTENT_MIN_WORDS})`,
|
|
167
|
+
recommendation: `Expand content to at least ${CONTENT_MIN_WORDS} words. Pages under ${CONTENT_MIN_WORDS} words are considered thin content by Google.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else if (pageData.wordCount < CONTENT_GOOD_WORDS) {
|
|
171
|
+
contentLengthScore -= 15;
|
|
172
|
+
issues.push({
|
|
173
|
+
category: "Content Length",
|
|
174
|
+
severity: "low",
|
|
175
|
+
issue: `Content below optimal length: ${pageData.wordCount} words (optimal ${CONTENT_GOOD_WORDS}+)`,
|
|
176
|
+
recommendation: "Consider expanding to 1500+ words for competitive topics.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// === Overall score ===
|
|
180
|
+
const overallScore = Math.round((titleScore * 0.25 + descriptionScore * 0.20 + headingStructureScore * 0.25 + contentLengthScore * 0.30));
|
|
181
|
+
return {
|
|
182
|
+
url: pageData.url,
|
|
183
|
+
titleScore,
|
|
184
|
+
descriptionScore,
|
|
185
|
+
headingStructureScore,
|
|
186
|
+
contentLengthScore,
|
|
187
|
+
overallScore,
|
|
188
|
+
issues,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SeoConfig } from "../config.js";
|
|
2
|
+
import type { CrawlConfig, CrawlResult } from "../types.js";
|
|
3
|
+
export declare function crawlSite(startUrl: string, config: SeoConfig, crawlConfig?: Partial<CrawlConfig>): Promise<CrawlResult>;
|
|
4
|
+
//# sourceMappingURL=crawler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawler.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/crawler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAEV,WAAW,EACX,WAAW,EAGZ,MAAM,aAAa,CAAC;AAgHrB,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,SAAS,EACjB,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,WAAW,CAAC,CA+LtB"}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { fetchPage, fetchRobotsTxt } from "../utils/fetcher.js";
|
|
2
|
+
import { parsePage } from "../utils/parser.js";
|
|
3
|
+
import { normalizeUrl } from "../utils/url-safety.js";
|
|
4
|
+
const DEFAULT_CRAWL_CONFIG = {
|
|
5
|
+
maxPages: 200,
|
|
6
|
+
maxDepth: 5,
|
|
7
|
+
concurrency: 3,
|
|
8
|
+
respectRobotsTxt: true,
|
|
9
|
+
crawlDelayMs: 500,
|
|
10
|
+
includeExternal: false,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Strip query parameters and hash for URL identity comparison.
|
|
14
|
+
* Two URLs that differ only by query params or hash should be considered
|
|
15
|
+
* the same page for crawling purposes — prevents re-crawling the same page
|
|
16
|
+
* hundreds of times when query params encode ephemeral UI state.
|
|
17
|
+
*/
|
|
18
|
+
function urlIdentity(url) {
|
|
19
|
+
try {
|
|
20
|
+
const u = new URL(url);
|
|
21
|
+
u.search = "";
|
|
22
|
+
u.hash = "";
|
|
23
|
+
if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
|
|
24
|
+
u.pathname = u.pathname.replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
return u.href;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function extractInternalLinks(html, baseUrl) {
|
|
33
|
+
const links = [];
|
|
34
|
+
const hrefRegex = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
35
|
+
let match;
|
|
36
|
+
while ((match = hrefRegex.exec(html)) !== null) {
|
|
37
|
+
try {
|
|
38
|
+
const resolved = new URL(match[1], baseUrl).href;
|
|
39
|
+
const base = new URL(baseUrl);
|
|
40
|
+
const resolvedUrl = new URL(resolved);
|
|
41
|
+
if (resolvedUrl.hostname === base.hostname &&
|
|
42
|
+
resolvedUrl.protocol.startsWith("http") &&
|
|
43
|
+
!resolved.includes("#") &&
|
|
44
|
+
!resolved.includes("javascript:") &&
|
|
45
|
+
!resolved.includes("mailto:") &&
|
|
46
|
+
!resolved.includes("/cdn-cgi/")) {
|
|
47
|
+
const normalized = normalizeUrl(resolved);
|
|
48
|
+
links.push(normalized);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// skip malformed hrefs
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(links)];
|
|
56
|
+
}
|
|
57
|
+
function parseRobotsTxtForDisallowed(content, userAgent) {
|
|
58
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
59
|
+
const disallowed = [];
|
|
60
|
+
let crawlDelay = null;
|
|
61
|
+
let currentUserAgents = [];
|
|
62
|
+
let inRelevantBlock = false;
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (!line || line.startsWith("#"))
|
|
65
|
+
continue;
|
|
66
|
+
const colonIdx = line.indexOf(":");
|
|
67
|
+
if (colonIdx === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
70
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
71
|
+
if (key === "user-agent") {
|
|
72
|
+
currentUserAgents.push(value.toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
else if (inRelevantBlock ||
|
|
75
|
+
currentUserAgents.some((ua) => ua === "*" || ua === userAgent.toLowerCase())) {
|
|
76
|
+
inRelevantBlock = true;
|
|
77
|
+
if (key === "disallow") {
|
|
78
|
+
if (value)
|
|
79
|
+
disallowed.push(value);
|
|
80
|
+
}
|
|
81
|
+
else if (key === "crawl-delay") {
|
|
82
|
+
const delay = Number.parseFloat(value);
|
|
83
|
+
if (!Number.isNaN(delay))
|
|
84
|
+
crawlDelay = delay;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (key !== "user-agent") {
|
|
88
|
+
if (!inRelevantBlock)
|
|
89
|
+
currentUserAgents = [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { disallowed, crawlDelay };
|
|
93
|
+
}
|
|
94
|
+
function isDisallowed(url, disallowedPaths) {
|
|
95
|
+
try {
|
|
96
|
+
const pathname = new URL(url).pathname;
|
|
97
|
+
for (const pattern of disallowedPaths) {
|
|
98
|
+
if (pattern === "/")
|
|
99
|
+
return true;
|
|
100
|
+
if (pattern && pathname.startsWith(pattern))
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return true; // can't parse → block
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
export async function crawlSite(startUrl, config, crawlConfig) {
|
|
110
|
+
const cfg = { ...DEFAULT_CRAWL_CONFIG, ...crawlConfig };
|
|
111
|
+
const normalizedStart = normalizeUrl(startUrl);
|
|
112
|
+
const baseHost = new URL(normalizedStart).hostname;
|
|
113
|
+
let disallowedPaths = [];
|
|
114
|
+
let robotsCrawlDelay = null;
|
|
115
|
+
if (cfg.respectRobotsTxt) {
|
|
116
|
+
try {
|
|
117
|
+
const robotsResult = await fetchRobotsTxt(normalizedStart, config);
|
|
118
|
+
if (robotsResult.statusCode === 200) {
|
|
119
|
+
const parsed = parseRobotsTxtForDisallowed(robotsResult.content, config.userAgent);
|
|
120
|
+
disallowedPaths = parsed.disallowed;
|
|
121
|
+
robotsCrawlDelay = parsed.crawlDelay;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// robots.txt not available — crawl freely
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const delayMs = robotsCrawlDelay
|
|
129
|
+
? robotsCrawlDelay * 1000
|
|
130
|
+
: cfg.crawlDelayMs;
|
|
131
|
+
const visitedIdentities = new Set();
|
|
132
|
+
const queuedIdentities = new Set();
|
|
133
|
+
const queue = [];
|
|
134
|
+
const startIdentity = urlIdentity(normalizedStart);
|
|
135
|
+
queuedIdentities.add(startIdentity);
|
|
136
|
+
queue.push({ url: normalizedStart, depth: 0 });
|
|
137
|
+
const pages = [];
|
|
138
|
+
const brokenLinks = [];
|
|
139
|
+
const errors = [];
|
|
140
|
+
const allDiscoveredLinks = new Set();
|
|
141
|
+
while (queue.length > 0 &&
|
|
142
|
+
visitedIdentities.size < cfg.maxPages) {
|
|
143
|
+
const batch = [];
|
|
144
|
+
while (batch.length < cfg.concurrency &&
|
|
145
|
+
queue.length > 0 &&
|
|
146
|
+
visitedIdentities.size + batch.length < cfg.maxPages) {
|
|
147
|
+
const next = queue.shift();
|
|
148
|
+
const identity = urlIdentity(next.url);
|
|
149
|
+
if (visitedIdentities.has(identity))
|
|
150
|
+
continue;
|
|
151
|
+
if (isDisallowed(next.url, disallowedPaths))
|
|
152
|
+
continue;
|
|
153
|
+
batch.push(next);
|
|
154
|
+
}
|
|
155
|
+
if (batch.length === 0)
|
|
156
|
+
break;
|
|
157
|
+
const batchResults = await Promise.allSettled(batch.map(async ({ url, depth }) => {
|
|
158
|
+
const pageStart = Date.now();
|
|
159
|
+
try {
|
|
160
|
+
const fetchResult = await fetchPage(url, config);
|
|
161
|
+
const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
|
|
162
|
+
const internalLinks = extractInternalLinks(fetchResult.content, fetchResult.finalUrl);
|
|
163
|
+
return {
|
|
164
|
+
url: fetchResult.finalUrl,
|
|
165
|
+
depth,
|
|
166
|
+
statusCode: fetchResult.statusCode,
|
|
167
|
+
fetchResult,
|
|
168
|
+
pageData,
|
|
169
|
+
linksFound: internalLinks,
|
|
170
|
+
crawlDuration: Date.now() - pageStart,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
errors.push(`Failed to crawl ${url}: ${msg}`);
|
|
176
|
+
return {
|
|
177
|
+
url,
|
|
178
|
+
depth,
|
|
179
|
+
statusCode: 0,
|
|
180
|
+
fetchResult: null,
|
|
181
|
+
pageData: null,
|
|
182
|
+
linksFound: [],
|
|
183
|
+
crawlDuration: Date.now() - pageStart,
|
|
184
|
+
error: msg,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
for (const result of batchResults) {
|
|
189
|
+
if (result.status === "rejected") {
|
|
190
|
+
errors.push(`Batch crawl error: ${String(result.reason)}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const page = result.value;
|
|
194
|
+
const identity = urlIdentity(page.url);
|
|
195
|
+
visitedIdentities.add(identity);
|
|
196
|
+
pages.push(page);
|
|
197
|
+
if (page.pageData) {
|
|
198
|
+
for (const link of page.pageData.links) {
|
|
199
|
+
if (link.type === "external")
|
|
200
|
+
continue;
|
|
201
|
+
allDiscoveredLinks.add(link.href);
|
|
202
|
+
const normalized = normalizeUrl(link.href);
|
|
203
|
+
const linkIdentity = urlIdentity(normalized);
|
|
204
|
+
if (!visitedIdentities.has(linkIdentity) &&
|
|
205
|
+
!queuedIdentities.has(linkIdentity) &&
|
|
206
|
+
linkIdentity !== identity) {
|
|
207
|
+
try {
|
|
208
|
+
const linkUrl = new URL(normalized);
|
|
209
|
+
if (linkUrl.hostname === baseHost &&
|
|
210
|
+
page.depth < cfg.maxDepth) {
|
|
211
|
+
queuedIdentities.add(linkIdentity);
|
|
212
|
+
queue.push({
|
|
213
|
+
url: normalized,
|
|
214
|
+
depth: page.depth + 1,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// skip
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (delayMs > 0) {
|
|
225
|
+
await sleep(delayMs);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Detect broken links (pages crawled with 4xx/5xx status)
|
|
230
|
+
for (const page of pages) {
|
|
231
|
+
if (page.statusCode >= 400) {
|
|
232
|
+
brokenLinks.push({
|
|
233
|
+
sourcePage: normalizedStart,
|
|
234
|
+
targetUrl: page.url,
|
|
235
|
+
statusCode: page.statusCode,
|
|
236
|
+
linkText: "",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const duplicateGroups = detectDuplicates(pages);
|
|
241
|
+
const linkedPages = new Set();
|
|
242
|
+
for (const page of pages) {
|
|
243
|
+
for (const link of page.linksFound) {
|
|
244
|
+
linkedPages.add(link);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const orphanPages = pages
|
|
248
|
+
.filter((p) => p.depth === 0 && !linkedPages.has(p.url))
|
|
249
|
+
.map((p) => p.url);
|
|
250
|
+
const linkGraph = pages.map((p) => ({
|
|
251
|
+
url: p.url,
|
|
252
|
+
internalInlinks: pages.reduce((count, other) => count + (other.linksFound.includes(p.url) ? 1 : 0), 0),
|
|
253
|
+
internalOutlinks: p.linksFound.length,
|
|
254
|
+
externalOutlinks: p.pageData ? p.pageData.externalLinkCount : 0,
|
|
255
|
+
depth: p.depth,
|
|
256
|
+
statusCode: p.statusCode,
|
|
257
|
+
}));
|
|
258
|
+
return {
|
|
259
|
+
baseUrl: normalizedStart,
|
|
260
|
+
pagesCrawled: pages.length,
|
|
261
|
+
totalPagesFound: allDiscoveredLinks.size,
|
|
262
|
+
maxDepthReached: pages.reduce((max, p) => Math.max(max, p.depth), 0),
|
|
263
|
+
brokenLinks,
|
|
264
|
+
duplicateGroups,
|
|
265
|
+
orphanPages,
|
|
266
|
+
pages,
|
|
267
|
+
linkGraph,
|
|
268
|
+
errors,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function detectDuplicates(pages) {
|
|
272
|
+
const groups = [];
|
|
273
|
+
// Use urlIdentity for deduplication so /studio/ and /studio/?pako=...
|
|
274
|
+
// are counted as the same page
|
|
275
|
+
const identitySet = new Set();
|
|
276
|
+
const uniquePages = pages.filter((p) => {
|
|
277
|
+
const id = urlIdentity(p.url);
|
|
278
|
+
if (identitySet.has(id))
|
|
279
|
+
return false;
|
|
280
|
+
identitySet.add(id);
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
const titleMap = new Map();
|
|
284
|
+
const descMap = new Map();
|
|
285
|
+
const h1Map = new Map();
|
|
286
|
+
for (const page of uniquePages) {
|
|
287
|
+
if (!page.pageData)
|
|
288
|
+
continue;
|
|
289
|
+
const title = page.pageData.title?.trim().toLowerCase();
|
|
290
|
+
if (title) {
|
|
291
|
+
if (!titleMap.has(title))
|
|
292
|
+
titleMap.set(title, []);
|
|
293
|
+
titleMap.get(title).push(page.url);
|
|
294
|
+
}
|
|
295
|
+
const desc = page.pageData.metaDescription?.trim().toLowerCase();
|
|
296
|
+
if (desc) {
|
|
297
|
+
if (!descMap.has(desc))
|
|
298
|
+
descMap.set(desc, []);
|
|
299
|
+
descMap.get(desc).push(page.url);
|
|
300
|
+
}
|
|
301
|
+
const h1 = page.pageData.headings
|
|
302
|
+
.filter((h) => h.level === 1)
|
|
303
|
+
.map((h) => h.text.trim().toLowerCase())
|
|
304
|
+
.join(" | ");
|
|
305
|
+
if (h1) {
|
|
306
|
+
if (!h1Map.has(h1))
|
|
307
|
+
h1Map.set(h1, []);
|
|
308
|
+
h1Map.get(h1).push(page.url);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const [value, urls] of titleMap) {
|
|
312
|
+
if (urls.length > 1)
|
|
313
|
+
groups.push({ type: "title", value, pageUrls: urls });
|
|
314
|
+
}
|
|
315
|
+
for (const [value, urls] of descMap) {
|
|
316
|
+
if (urls.length > 1)
|
|
317
|
+
groups.push({ type: "description", value, pageUrls: urls });
|
|
318
|
+
}
|
|
319
|
+
for (const [value, urls] of h1Map) {
|
|
320
|
+
if (urls.length > 1)
|
|
321
|
+
groups.push({ type: "h1", value, pageUrls: urls });
|
|
322
|
+
}
|
|
323
|
+
return groups;
|
|
324
|
+
}
|
|
325
|
+
function sleep(ms) {
|
|
326
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
327
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { KeywordAnalysisResult } from "../types.js";
|
|
2
|
+
export declare function analyzeKeywords(text: string, url?: string): KeywordAnalysisResult;
|
|
3
|
+
export declare function analyzeKeywordDensity(text: string, targetKeywords: string[]): Array<{
|
|
4
|
+
keyword: string;
|
|
5
|
+
count: number;
|
|
6
|
+
density: number;
|
|
7
|
+
verdict: string;
|
|
8
|
+
}>;
|
|
9
|
+
//# sourceMappingURL=keywords.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keywords.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/analyzers/keywords.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAe,MAAM,aAAa,CAAC;AA4GtE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,qBAAqB,CAgEjF;AAeD,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,MAAM,EAAE,GACvB,KAAK,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAkB7E"}
|