seo-crawl-audit 0.1.0

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/src/sitemap.js ADDED
@@ -0,0 +1,209 @@
1
+ import {
2
+ isCrawlableUrl,
3
+ isSameOrigin,
4
+ normalizeUrl,
5
+ } from "./urls.js";
6
+
7
+ const MAX_SITEMAP_BYTES = 10 * 1024 * 1024;
8
+ const MAX_SITEMAPS = 100;
9
+ const DEFAULT_SITEMAP_LIMIT = 50_000;
10
+
11
+ function decodeXml(value) {
12
+ return value
13
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) =>
14
+ String.fromCodePoint(Number.parseInt(code, 16)),
15
+ )
16
+ .replace(/&#([0-9]+);/g, (_, code) =>
17
+ String.fromCodePoint(Number.parseInt(code, 10)),
18
+ )
19
+ .replaceAll("&", "&")
20
+ .replaceAll("&lt;", "<")
21
+ .replaceAll("&gt;", ">")
22
+ .replaceAll("&quot;", '"')
23
+ .replaceAll("&apos;", "'");
24
+ }
25
+
26
+ function extractLocations(xml) {
27
+ return [...xml.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc>/gi)]
28
+ .map((match) => decodeXml(match[1].trim()))
29
+ .filter(Boolean);
30
+ }
31
+
32
+ async function fetchSitemap(url, options) {
33
+ const response = await fetch(url, {
34
+ headers: {
35
+ accept: "application/xml,text/xml,text/plain;q=0.9,*/*;q=0.1",
36
+ "user-agent": options.userAgent,
37
+ },
38
+ redirect: "follow",
39
+ signal: AbortSignal.timeout(options.timeout),
40
+ });
41
+
42
+ if (!response.ok) {
43
+ await response.body?.cancel();
44
+ throw new Error(`sitemap returned HTTP ${response.status}: ${url}`);
45
+ }
46
+
47
+ const bytes = Buffer.from(await response.arrayBuffer());
48
+ if (bytes.byteLength > MAX_SITEMAP_BYTES) {
49
+ throw new Error(`sitemap exceeds ${MAX_SITEMAP_BYTES} bytes: ${url}`);
50
+ }
51
+
52
+ return bytes.toString("utf8");
53
+ }
54
+
55
+ async function isSitemapResponse(url, options) {
56
+ try {
57
+ const response = await fetch(url, {
58
+ method: "GET",
59
+ headers: {
60
+ accept: "application/xml,text/xml,text/plain;q=0.9,*/*;q=0.1",
61
+ "user-agent": options.userAgent,
62
+ },
63
+ redirect: "follow",
64
+ signal: AbortSignal.timeout(options.timeout),
65
+ });
66
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
67
+ const reader = response.body?.getReader();
68
+ const firstChunk = reader ? await reader.read() : { value: null };
69
+ await reader?.cancel();
70
+ const beginning = firstChunk.value
71
+ ? Buffer.from(firstChunk.value).toString("utf8", 0, 512)
72
+ : "";
73
+
74
+ return (
75
+ response.ok &&
76
+ (contentType.includes("xml") ||
77
+ /<(?:\?xml|urlset|sitemapindex)\b/i.test(beginning))
78
+ );
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ export async function discoverSitemapUrl(inputUrl, options) {
85
+ const startUrl = normalizeUrl(inputUrl);
86
+ if (!startUrl) {
87
+ throw new Error(`invalid start URL: ${inputUrl}`);
88
+ }
89
+
90
+ const origin = new URL(startUrl).origin;
91
+ const robotsUrl = new URL("/robots.txt", origin).href;
92
+
93
+ try {
94
+ const response = await fetch(robotsUrl, {
95
+ headers: {
96
+ accept: "text/plain,*/*;q=0.1",
97
+ "user-agent": options.userAgent,
98
+ },
99
+ redirect: "follow",
100
+ signal: AbortSignal.timeout(options.timeout),
101
+ });
102
+
103
+ if (response.ok) {
104
+ const body = await response.text();
105
+ for (const match of body.matchAll(/^sitemap:\s*(\S+)\s*$/gim)) {
106
+ const candidate = normalizeUrl(match[1], robotsUrl, {
107
+ includeQuery: true,
108
+ });
109
+ if (candidate && isSameOrigin(candidate, origin)) {
110
+ return candidate;
111
+ }
112
+ }
113
+ } else {
114
+ await response.body?.cancel();
115
+ }
116
+ } catch {
117
+ // Standard sitemap locations are checked below.
118
+ }
119
+
120
+ for (const path of ["/sitemap.xml", "/sitemap_index.xml"]) {
121
+ const candidate = new URL(path, origin).href;
122
+ if (await isSitemapResponse(candidate, options)) {
123
+ return candidate;
124
+ }
125
+ }
126
+
127
+ return null;
128
+ }
129
+
130
+ export async function loadSitemapUrls(inputUrl, options) {
131
+ const sitemapUrl = normalizeUrl(inputUrl, undefined, { includeQuery: true });
132
+ if (!sitemapUrl) {
133
+ throw new Error(`invalid sitemap URL: ${inputUrl}`);
134
+ }
135
+
136
+ const sitemapOrigin = new URL(sitemapUrl).origin;
137
+ const siteOrigin = options.siteOrigin;
138
+ const sitemapQueue = [sitemapUrl];
139
+ const visitedSitemaps = new Set();
140
+ const pageUrls = [];
141
+ const pageSet = new Set();
142
+ let truncated = false;
143
+
144
+ while (
145
+ sitemapQueue.length > 0 &&
146
+ visitedSitemaps.size < MAX_SITEMAPS &&
147
+ pageUrls.length < (options.maxUrls ?? DEFAULT_SITEMAP_LIMIT)
148
+ ) {
149
+ const currentUrl = sitemapQueue.shift();
150
+ if (visitedSitemaps.has(currentUrl)) {
151
+ continue;
152
+ }
153
+ visitedSitemaps.add(currentUrl);
154
+
155
+ const xml = await fetchSitemap(currentUrl, options);
156
+ const locations = extractLocations(xml);
157
+ const isIndex = /<sitemapindex\b/i.test(xml);
158
+
159
+ if (isIndex) {
160
+ for (const location of locations) {
161
+ const child = normalizeUrl(location, currentUrl, {
162
+ includeQuery: true,
163
+ });
164
+ if (
165
+ child &&
166
+ isSameOrigin(child, sitemapOrigin) &&
167
+ !visitedSitemaps.has(child)
168
+ ) {
169
+ sitemapQueue.push(child);
170
+ }
171
+ }
172
+ continue;
173
+ }
174
+
175
+ for (const location of locations) {
176
+ const pageUrl = normalizeUrl(location, currentUrl, {
177
+ includeQuery: options.includeQuery,
178
+ });
179
+
180
+ if (
181
+ !pageUrl ||
182
+ pageSet.has(pageUrl) ||
183
+ !isSameOrigin(pageUrl, siteOrigin) ||
184
+ !isCrawlableUrl(pageUrl)
185
+ ) {
186
+ continue;
187
+ }
188
+
189
+ pageSet.add(pageUrl);
190
+ pageUrls.push(pageUrl);
191
+
192
+ if (pageUrls.length >= (options.maxUrls ?? DEFAULT_SITEMAP_LIMIT)) {
193
+ truncated = true;
194
+ break;
195
+ }
196
+ }
197
+ }
198
+
199
+ if (sitemapQueue.length > 0 || visitedSitemaps.size >= MAX_SITEMAPS) {
200
+ truncated = true;
201
+ }
202
+
203
+ return {
204
+ url: sitemapUrl,
205
+ urls: pageUrls,
206
+ sitemapCount: visitedSitemaps.size,
207
+ truncated,
208
+ };
209
+ }
package/src/target.js ADDED
@@ -0,0 +1,35 @@
1
+ export function mapUrlToTarget(url, baselineStartUrl, targetStartUrl) {
2
+ if (!url) {
3
+ return url;
4
+ }
5
+
6
+ const mapped = new URL(url);
7
+ const baselineOrigin = new URL(baselineStartUrl).origin;
8
+ const targetOrigin = new URL(targetStartUrl).origin;
9
+
10
+ if (mapped.origin === baselineOrigin) {
11
+ const target = new URL(targetOrigin);
12
+ mapped.protocol = target.protocol;
13
+ mapped.host = target.host;
14
+ }
15
+
16
+ return mapped.href;
17
+ }
18
+
19
+ export function mapUrlToBaseline(url, baselineStartUrl, targetStartUrl) {
20
+ if (!url) {
21
+ return url;
22
+ }
23
+
24
+ const mapped = new URL(url);
25
+ const baselineOrigin = new URL(baselineStartUrl).origin;
26
+ const targetOrigin = new URL(targetStartUrl).origin;
27
+
28
+ if (mapped.origin === targetOrigin) {
29
+ const baseline = new URL(baselineOrigin);
30
+ mapped.protocol = baseline.protocol;
31
+ mapped.host = baseline.host;
32
+ }
33
+
34
+ return mapped.href;
35
+ }
package/src/urls.js ADDED
@@ -0,0 +1,45 @@
1
+ const SKIPPED_EXTENSIONS =
2
+ /\.(?:avif|bmp|css|csv|docx?|eot|gif|gz|ico|jpe?g|js|json|map|mp3|mp4|mov|ogg|otf|pdf|png|pptx?|rar|rss|svg|tar|tiff?|ttf|txt|wav|webm|webp|woff2?|xlsx?|xml|zip)$/i;
3
+
4
+ export function normalizeUrl(input, base, { includeQuery = false } = {}) {
5
+ let url;
6
+
7
+ try {
8
+ url = base ? new URL(input, base) : new URL(input);
9
+ } catch {
10
+ return null;
11
+ }
12
+
13
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
14
+ return null;
15
+ }
16
+
17
+ url.hash = "";
18
+
19
+ if (!includeQuery) {
20
+ url.search = "";
21
+ }
22
+
23
+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
24
+ url.pathname = url.pathname.replace(/\/+$/, "");
25
+ }
26
+
27
+ return url.href;
28
+ }
29
+
30
+ export function isSameOrigin(url, origin) {
31
+ try {
32
+ return new URL(url).origin === origin;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export function isCrawlableUrl(url) {
39
+ try {
40
+ const parsed = new URL(url);
41
+ return !SKIPPED_EXTENSIONS.test(parsed.pathname);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }