hanzio 1.2.0 → 2.0.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.
Files changed (145) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-181e6vfw.js +29 -0
  36. package/dist/index-8r2vdzjg.js +87 -0
  37. package/dist/index-hy7kbb99.js +157 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-mnkcfyx8.js +78 -0
  42. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  43. package/dist/index-rp5pqghz.js +157 -0
  44. package/dist/index-srt06xd2.js +15 -0
  45. package/dist/index-wjyb9bsd.js +263 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/sitemap/index.d.ts +48 -6
  55. package/dist/src/api-wrapper/index.js +60 -1130
  56. package/dist/src/api-wrapper/testing.js +20 -0
  57. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  58. package/dist/src/crypto/index.js +195 -0
  59. package/dist/src/format/index.js +211 -0
  60. package/dist/src/i18n/index.js +36 -0
  61. package/dist/src/index.js +739 -3145
  62. package/dist/src/jwt/index.js +138 -92
  63. package/dist/src/p-queue/index.js +9 -437
  64. package/dist/src/secrets/index.js +44 -1428
  65. package/dist/src/secrets/vite.js +6 -39
  66. package/dist/src/secrets/worker.js +10 -273
  67. package/dist/src/sitemap/index.js +183 -511
  68. package/dist/src/storage/index.js +119 -0
  69. package/dist/src/zod/index.js +340 -167
  70. package/dist/state/index.d.ts +21 -0
  71. package/dist/storage/index.d.ts +58 -0
  72. package/dist/string/index.d.ts +75 -2
  73. package/dist/typedswitch/index.d.ts +31 -24
  74. package/dist/types/index.d.ts +11 -0
  75. package/dist/url/index.d.ts +50 -1
  76. package/dist/zod/index.d.ts +32 -3
  77. package/dist/zod/introspect.d.ts +19 -0
  78. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  79. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  80. package/dist/zod/zodToTypeString.d.ts +8 -3
  81. package/package.json +78 -7
  82. package/src/api-wrapper/actions-impl.ts +11 -72
  83. package/src/api-wrapper/builders.ts +32 -25
  84. package/src/api-wrapper/create-client.ts +171 -41
  85. package/src/api-wrapper/describe.ts +157 -0
  86. package/src/api-wrapper/endpoint.ts +26 -0
  87. package/src/api-wrapper/error-reporting.ts +7 -1
  88. package/src/api-wrapper/errors.ts +107 -2
  89. package/src/api-wrapper/headers.ts +11 -6
  90. package/src/api-wrapper/index.ts +5 -0
  91. package/src/api-wrapper/otel.ts +139 -0
  92. package/src/api-wrapper/paginate.ts +28 -0
  93. package/src/api-wrapper/redaction.ts +42 -0
  94. package/src/api-wrapper/shared.ts +21 -2
  95. package/src/api-wrapper/testing.ts +48 -0
  96. package/src/api-wrapper/transport.ts +41 -43
  97. package/src/api-wrapper/types.ts +185 -18
  98. package/src/array/index.ts +195 -42
  99. package/src/cache/createCache.ts +157 -0
  100. package/src/cache/index.ts +132 -147
  101. package/src/color/contrast.ts +163 -0
  102. package/src/color/index.ts +22 -0
  103. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  104. package/src/cool-console-log/index.ts +128 -41
  105. package/src/crypto/encoding.ts +83 -0
  106. package/src/crypto/hash.ts +170 -0
  107. package/src/crypto/index.ts +26 -0
  108. package/src/crypto/sealer.ts +168 -0
  109. package/src/crypto/webhook.ts +86 -0
  110. package/src/date/index.ts +109 -34
  111. package/src/emitter/index.ts +82 -0
  112. package/src/error/index.ts +63 -0
  113. package/src/format/index.ts +389 -0
  114. package/src/function/index.ts +201 -0
  115. package/src/guard/index.ts +80 -0
  116. package/src/i18n/index.ts +125 -0
  117. package/src/index.ts +11 -7
  118. package/src/jwt/index.ts +199 -125
  119. package/src/math/index.ts +5 -10
  120. package/src/math/linearRegressionTrend.ts +33 -8
  121. package/src/math/scale.ts +17 -0
  122. package/src/object/index.ts +99 -0
  123. package/src/p-queue/index.ts +2 -0
  124. package/src/promise/index.ts +304 -9
  125. package/src/sitemap/index.ts +293 -130
  126. package/src/state/index.ts +133 -81
  127. package/src/storage/index.ts +187 -0
  128. package/src/string/index.ts +261 -23
  129. package/src/typedswitch/index.ts +58 -38
  130. package/src/types/index.ts +14 -0
  131. package/src/url/index.ts +146 -1
  132. package/src/zod/index.ts +105 -9
  133. package/src/zod/introspect.ts +103 -0
  134. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  135. package/src/zod/zodMinMaxFilter.ts +37 -13
  136. package/src/zod/zodToTypeString.ts +184 -181
  137. package/dist/api-wrapper/index.js +0 -250
  138. package/dist/gzip/index.d.ts +0 -2
  139. package/dist/gzip/index.js +0 -16
  140. package/dist/index.js +0 -1823
  141. package/dist/jwt/index.js +0 -19
  142. package/dist/sitemap/index.js +0 -552
  143. package/dist/src/gzip/index.js +0 -16
  144. package/dist/src/secrets/loaders.js +0 -1529
  145. package/dist/zod/index.js +0 -210
@@ -1,89 +1,259 @@
1
1
  import PQueue from '../p-queue'
2
+ import { escapeRegExp } from '../string'
3
+ import { normalizeDomain } from '../url'
2
4
 
3
- interface SitemapOptions {
4
- filterIndexes?: string
5
+ /** Substring, RegExp, or predicate. */
6
+ export type SitemapUrlFilter = string | RegExp | ((url: string) => boolean)
7
+
8
+ export interface SitemapOptions {
9
+ /** Only follow nested sitemaps (from a `<sitemapindex>`) that match. */
10
+ filterIndexes?: SitemapUrlFilter
11
+ /** Only keep page URLs (from a `<urlset>`) that match. */
12
+ filterUrls?: SitemapUrlFilter
5
13
  concurrency?: number
6
14
  maxDepth?: number
15
+ /** Stop once this many URLs have been collected. */
16
+ maxUrls?: number
17
+ /** Per-request timeout in ms. */
7
18
  timeout?: number
19
+ userAgent?: string
20
+ fetch?: typeof fetch
21
+ /** Called for failed robots.txt/sitemap requests. Defaults to `console.warn`. */
22
+ onError?: (url: string, error: unknown) => void
23
+ /** Drop page URLs that robots.txt disallows for `userAgent`. Defaults to `false`. */
24
+ respectRobots?: boolean
8
25
  }
9
26
 
10
- interface ProcessedResult {
11
- links: Set<string>
12
- visited: Set<string>
27
+ /** The robots.txt rules that apply to one user agent. */
28
+ export interface RobotsRules {
29
+ allow: string[]
30
+ disallow: string[]
31
+ /** Every `Sitemap:` line, whatever group it is in. */
32
+ sitemaps: string[]
33
+ crawlDelaySec?: number
13
34
  }
14
35
 
15
- /**
16
- * Creates an AbortController with a timeout
17
- */
18
- const createTimeoutController = (timeout: number): AbortController => {
19
- const controller = new AbortController()
20
- setTimeout(() => controller.abort(), timeout)
21
- return controller
36
+ export interface SitemapEntry {
37
+ loc: string
38
+ lastmod?: string
22
39
  }
23
40
 
41
+ type Request = (url: string, method?: string) => Promise<Response>
42
+
43
+ const COMMON_LOCATIONS = [
44
+ '/sitemap.xml',
45
+ '/sitemap_index.xml',
46
+ '/sitemap/',
47
+ '/sitemaps.xml',
48
+ '/sitemap/sitemap.xml'
49
+ ]
50
+
51
+ const XML_ENTITIES: Record<string, string> = {
52
+ amp: '&',
53
+ lt: '<',
54
+ gt: '>',
55
+ quot: '"',
56
+ apos: "'"
57
+ }
58
+
59
+ const decodeXml = (value: string) =>
60
+ value.replace(
61
+ /&(#[xX][\da-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g,
62
+ (_, entity: string) =>
63
+ entity[0] !== '#'
64
+ ? XML_ENTITIES[entity]!
65
+ : String.fromCodePoint(
66
+ entity[1] === 'x' || entity[1] === 'X'
67
+ ? Number.parseInt(entity.slice(2), 16)
68
+ : Number(entity.slice(1))
69
+ )
70
+ )
71
+
72
+ const matchesFilter = (filter: SitemapUrlFilter | undefined, url: string) => {
73
+ if (filter === undefined) return true
74
+ if (typeof filter === 'string') return url.includes(filter)
75
+ if (typeof filter === 'function') return filter(url)
76
+ return url.search(filter) !== -1
77
+ }
78
+
79
+ const defaultOnError = (url: string, error: unknown) =>
80
+ console.warn(
81
+ `Failed to fetch ${url}:`,
82
+ error instanceof Error ? error.message : 'Unknown error'
83
+ )
84
+
85
+ /** Reads a response as text, gunzipping it if the body is still gzip (e.g. `.xml.gz`). */
86
+ const readBody = async (response: Response): Promise<string> => {
87
+ const bytes = new Uint8Array(await response.arrayBuffer())
88
+ if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) {
89
+ return new TextDecoder().decode(bytes)
90
+ }
91
+ return new Response(
92
+ new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))
93
+ ).text()
94
+ }
95
+
96
+ /** Parses `<url>`/`<sitemap>` entries and whether the document is a sitemap index. */
97
+ const parseSitemap = (xml: string) => ({
98
+ isIndex: /<sitemapindex[\s>]/i.test(xml),
99
+ entries: [...xml.matchAll(/<(url|sitemap)[\s>][\s\S]*?<\/\1>/gi)].flatMap(
100
+ ([block]): SitemapEntry[] => {
101
+ const loc = block.match(
102
+ /<loc>\s*(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*?))\s*<\/loc>/i
103
+ )
104
+ if (!loc) return []
105
+ const lastmod = block.match(/<lastmod>\s*([^<]*?)\s*<\/lastmod>/i)?.[1]
106
+ return [
107
+ {
108
+ loc: (loc[1] ?? decodeXml(loc[2]!)).trim(),
109
+ ...(lastmod ? { lastmod } : {})
110
+ }
111
+ ]
112
+ }
113
+ )
114
+ })
115
+
116
+ type RobotsGroup = Omit<RobotsRules, 'sitemaps'> & { agents: string[] }
117
+
118
+ // RFC 9309 product token: `Googlebot/2.1` and `googlebot` both match `googlebot`.
119
+ const productToken = (userAgent: string) =>
120
+ userAgent.trim() === '*'
121
+ ? '*'
122
+ : (/^[a-z_-]+/i.exec(userAgent.trim())?.[0].toLowerCase() ?? '')
123
+
24
124
  /**
25
- * Discovers sitemap URL(s) from a domain by checking common locations and robots.txt
125
+ * Parses robots.txt for one user agent (RFC 9309): the groups naming its
126
+ * product token (case-insensitive, versions ignored), else the `*` groups.
127
+ * Several matching groups are merged. Empty `Allow`/`Disallow` values are dropped.
26
128
  */
27
- const discoverSitemaps = async (domain: string): Promise<string[]> => {
28
- const normalizedDomain = domain.replace(/\/$/, '')
29
- const commonLocations = [
30
- '/sitemap.xml',
31
- '/sitemap_index.xml',
32
- '/sitemap/',
33
- '/sitemaps.xml',
34
- '/sitemap/sitemap.xml'
35
- ]
36
-
37
- const sitemaps: Set<string> = new Set()
38
- const requestOptions: RequestInit = {
39
- headers: {
40
- 'User-Agent': 'SitemapCrawler/1.0'
41
- },
42
- // biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
43
- signal: createTimeoutController(5000).signal as any
44
- }
129
+ export function parseRobotsTxt(body: string, userAgent = '*'): RobotsRules {
130
+ const groups: RobotsGroup[] = []
131
+ const sitemaps = new Set<string>()
132
+ let group: RobotsGroup | undefined
133
+ let inRules = false
45
134
 
46
- // Check robots.txt first
47
- try {
48
- const robotsTxt = await fetch(`${normalizedDomain}/robots.txt`, {
49
- ...requestOptions
50
- })
135
+ for (const line of body.split(/\r\n|\r|\n/)) {
136
+ const match = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(line.replace(/#.*/, ''))
137
+ if (!match) continue
138
+ const field = match[1]!.toLowerCase()
139
+ const value = match[2]!
51
140
 
52
- if (robotsTxt.ok) {
53
- const text = await robotsTxt.text()
54
- const sitemapMatches = text.match(/Sitemap: (.*)/gi)
55
- if (sitemapMatches) {
56
- sitemapMatches.forEach((match: string) => {
57
- const url = match.replace('Sitemap:', '').trim()
58
- sitemaps.add(url)
59
- })
141
+ if (field === 'sitemap') {
142
+ if (value) sitemaps.add(value)
143
+ } else if (field === 'user-agent') {
144
+ // A user-agent line after rules starts a new group.
145
+ if (!group || inRules) {
146
+ group = { agents: [], allow: [], disallow: [] }
147
+ groups.push(group)
148
+ inRules = false
60
149
  }
150
+ group.agents.push(productToken(value))
151
+ } else if (group && (field === 'allow' || field === 'disallow')) {
152
+ inRules = true
153
+ if (value) group[field].push(value)
154
+ } else if (group && field === 'crawl-delay') {
155
+ inRules = true
156
+ const seconds = Number(value)
157
+ if (value && seconds >= 0) group.crawlDelaySec ??= seconds
158
+ }
159
+ }
160
+
161
+ const token = productToken(userAgent)
162
+ const named = groups.filter((g) => g.agents.includes(token))
163
+ const selected =
164
+ named.length > 0 ? named : groups.filter((g) => g.agents.includes('*'))
165
+ const crawlDelaySec = selected.find(
166
+ (g) => g.crawlDelaySec !== undefined
167
+ )?.crawlDelaySec
168
+ return {
169
+ allow: selected.flatMap((g) => g.allow),
170
+ disallow: selected.flatMap((g) => g.disallow),
171
+ sitemaps: [...sitemaps],
172
+ ...(crawlDelaySec === undefined ? {} : { crawlDelaySec })
173
+ }
174
+ }
175
+
176
+ // ponytail: unbounded growth capped by clearing; fine for a handful of sites per process.
177
+ const patternCache = new Map<string, RegExp>()
178
+
179
+ const matchesRobotsPattern = (pattern: string, path: string) => {
180
+ let regex = patternCache.get(pattern)
181
+ if (!regex) {
182
+ const anchored = pattern.endsWith('$')
183
+ const source = (anchored ? pattern.slice(0, -1) : pattern)
184
+ .split('*')
185
+ .map(escapeRegExp)
186
+ .join('.*')
187
+ regex = new RegExp(`^${source}${anchored ? '$' : ''}`)
188
+ if (patternCache.size >= 1000) patternCache.clear()
189
+ patternCache.set(pattern, regex)
190
+ }
191
+ return regex.test(path)
192
+ }
61
193
 
62
- // If we found sitemaps in robots.txt, return them immediately
63
- if (sitemaps.size > 0) {
64
- return [...sitemaps]
194
+ /**
195
+ * Whether `url` (absolute, or a path) may be crawled under `rules` (RFC 9309):
196
+ * the longest matching pattern wins and `Allow` wins a tie. Patterns support
197
+ * `*` and a trailing `$`, and match the path plus query string.
198
+ */
199
+ export function isAllowedByRobots(
200
+ rules: Pick<RobotsRules, 'allow' | 'disallow'>,
201
+ url: string
202
+ ): boolean {
203
+ let path: string
204
+ try {
205
+ const parsed = new URL(url, 'http://localhost')
206
+ path = parsed.pathname + parsed.search
207
+ } catch {
208
+ return true
209
+ }
210
+ if (path === '/robots.txt') return true
211
+
212
+ // Allows go first, so a disallow of the same length can't replace one.
213
+ let best: { allow: boolean; length: number } | undefined
214
+ for (const [allow, patterns] of [
215
+ [true, rules.allow],
216
+ [false, rules.disallow]
217
+ ] as const) {
218
+ for (const pattern of patterns) {
219
+ if (
220
+ pattern.length > (best?.length ?? -1) &&
221
+ matchesRobotsPattern(pattern, path)
222
+ ) {
223
+ best = { allow, length: pattern.length }
65
224
  }
66
225
  }
226
+ }
227
+ return best?.allow ?? true
228
+ }
229
+
230
+ const fetchRobots = async (
231
+ origin: string,
232
+ request: Request,
233
+ userAgent: string,
234
+ onError: (url: string, error: unknown) => void
235
+ ): Promise<RobotsRules | undefined> => {
236
+ const robotsUrl = `${origin}/robots.txt`
237
+ try {
238
+ const response = await request(robotsUrl)
239
+ if (response.ok) return parseRobotsTxt(await response.text(), userAgent)
67
240
  } catch (error) {
68
- console.warn(
69
- 'Could not fetch robots.txt:',
70
- error instanceof Error ? error.message : 'Unknown error'
71
- )
241
+ onError(robotsUrl, error)
72
242
  }
243
+ return undefined
244
+ }
73
245
 
74
- // Check common locations one by one
75
- for (const location of commonLocations) {
246
+ /** The first common sitemap location that answers a HEAD request. */
247
+ const probeSitemapLocations = async (
248
+ origin: string,
249
+ request: Request
250
+ ): Promise<string[]> => {
251
+ for (const location of COMMON_LOCATIONS) {
76
252
  try {
77
- const response = await fetch(`${normalizedDomain}${location}`, {
78
- ...requestOptions,
79
- method: 'HEAD'
80
- })
81
-
82
- if (response.ok) {
83
- return [`${normalizedDomain}${location}`]
84
- }
85
- } catch (_error) {
86
- // Ignore 404s and other errors
253
+ const response = await request(`${origin}${location}`, 'HEAD')
254
+ if (response.ok) return [`${origin}${location}`]
255
+ } catch {
256
+ // Probes are expected to fail
87
257
  }
88
258
  }
89
259
 
@@ -91,105 +261,98 @@ const discoverSitemaps = async (domain: string): Promise<string[]> => {
91
261
  }
92
262
 
93
263
  /**
94
- * Gets all URLs from a domain by discovering and crawling its sitemaps
95
- * @param domain - The root domain (e.g., 'https://example.com')
96
- * @param options - Configuration options for the crawler
97
- * @returns Promise<string[]> - Array of unique URLs found in the sitemap
264
+ * Gets all entries (`loc` + optional `lastmod`) from a domain by discovering and crawling its sitemaps
265
+ * @param domain - The root domain (e.g., 'example.com' or 'https://example.com')
266
+ * @throws When no sitemap can be discovered
98
267
  */
99
- export const getDomainSitemap = async (
268
+ export const getDomainSitemapEntries = async (
100
269
  domain: string,
101
270
  options: SitemapOptions = {}
102
- ): Promise<string[]> => {
271
+ ): Promise<SitemapEntry[]> => {
103
272
  const {
104
273
  filterIndexes,
274
+ filterUrls,
105
275
  concurrency = 10,
106
276
  maxDepth = 5,
107
- timeout = 10000
277
+ maxUrls = Number.POSITIVE_INFINITY,
278
+ timeout = 10000,
279
+ userAgent = 'SitemapCrawler/1.0',
280
+ fetch: fetchFn = globalThis.fetch,
281
+ onError = defaultOnError,
282
+ respectRobots = false
108
283
  } = options
109
284
 
110
- // Normalize domain
111
- let normalizedDomain = domain.replace(/\/$/, '')
112
- if (!normalizedDomain.startsWith('http')) {
113
- normalizedDomain = `https://${normalizedDomain}`
114
- }
285
+ const request: Request = (url, method = 'GET') =>
286
+ fetchFn(url, {
287
+ method,
288
+ headers: { 'User-Agent': userAgent },
289
+ signal: AbortSignal.timeout(timeout)
290
+ })
115
291
 
116
- // Discover sitemaps
117
- const sitemapUrls = await discoverSitemaps(normalizedDomain)
292
+ const origin = normalizeDomain(domain)
293
+ const robots = await fetchRobots(origin, request, userAgent, onError)
294
+ const sitemapUrls = robots?.sitemaps.length
295
+ ? robots.sitemaps
296
+ : await probeSitemapLocations(origin, request)
297
+ const keepUrl = (url: string) =>
298
+ matchesFilter(filterUrls, url) &&
299
+ (!respectRobots || !robots || isAllowedByRobots(robots, url))
118
300
  if (sitemapUrls.length === 0) {
119
301
  throw new Error('No sitemaps found for the domain')
120
302
  }
121
303
 
122
304
  const queue = new PQueue({ concurrency })
123
- const result: ProcessedResult = {
124
- links: new Set<string>(),
125
- visited: new Set<string>()
126
- }
305
+ const entries = new Map<string, SitemapEntry>()
306
+ const visited = new Set<string>()
127
307
 
128
- const locRegex = /<loc>([^<]+)<\/loc>/g
129
- const isXmlSitemap = (url: string) =>
130
- url.endsWith('.xml') || url.toLowerCase().includes('sitemap')
131
-
132
- /**
133
- * Process a single sitemap URL and extract all links
134
- */
135
- const processSitemap = async (
136
- sitemapUrl: string,
137
- depth = 0
138
- ): Promise<void> => {
139
- if (depth > maxDepth || result.visited.has(sitemapUrl)) {
308
+ const processSitemap = async (sitemapUrl: string, depth = 0) => {
309
+ if (
310
+ depth > maxDepth ||
311
+ visited.has(sitemapUrl) ||
312
+ entries.size >= maxUrls
313
+ ) {
140
314
  return
141
315
  }
142
-
143
- // console.log('Processing sitemap:', sitemapUrl)
144
-
145
- result.visited.add(sitemapUrl)
316
+ visited.add(sitemapUrl)
146
317
 
147
318
  try {
148
- const controller = createTimeoutController(timeout)
149
- const response = await fetch(sitemapUrl, {
150
- headers: {
151
- 'User-Agent': 'SitemapCrawler/1.0'
152
- },
153
- // biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
154
- signal: controller.signal as any
155
- })
156
-
157
- if (!response.ok)
319
+ const response = await request(sitemapUrl)
320
+ if (!response.ok) {
158
321
  throw new Error(`HTTP error! status: ${response.status}`)
322
+ }
159
323
 
160
- const data = await response.text()
161
- const matches = [...data.matchAll(locRegex)]
162
- .map((match) => match[1]!.trim())
163
- .filter((link) => {
164
- if (filterIndexes && isXmlSitemap(link)) {
165
- return link.includes(filterIndexes)
324
+ const { isIndex, entries: found } = parseSitemap(await readBody(response))
325
+ for (const entry of found) {
326
+ if (isIndex) {
327
+ if (matchesFilter(filterIndexes, entry.loc)) {
328
+ queue.add(() => processSitemap(entry.loc, depth + 1))
166
329
  }
167
- return true
168
- })
169
-
170
- for (const link of matches) {
171
- if (isXmlSitemap(link)) {
172
- // Queue nested sitemap for processing
173
- queue.add(() => processSitemap(link, depth + 1))
174
- } else {
175
- result.links.add(link)
330
+ } else if (entries.size >= maxUrls) {
331
+ return
332
+ } else if (keepUrl(entry.loc)) {
333
+ entries.set(entry.loc, entries.get(entry.loc) ?? entry)
176
334
  }
177
335
  }
178
336
  } catch (error) {
179
- console.warn(
180
- `Failed to process sitemap ${sitemapUrl}:`,
181
- error instanceof Error ? error.message : 'Unknown error'
182
- )
337
+ onError(sitemapUrl, error)
183
338
  }
184
339
  }
185
340
 
186
- // Process all discovered sitemaps
187
341
  await Promise.all(
188
342
  sitemapUrls.map((sitemapUrl) => queue.add(() => processSitemap(sitemapUrl)))
189
343
  )
190
-
191
- // Wait for all queued tasks to complete
192
344
  await queue.onIdle()
193
345
 
194
- return [...result.links]
346
+ return [...entries.values()]
195
347
  }
348
+
349
+ /**
350
+ * Gets all URLs from a domain by discovering and crawling its sitemaps
351
+ * @param domain - The root domain (e.g., 'example.com' or 'https://example.com')
352
+ * @returns Array of unique URLs found in the sitemap
353
+ */
354
+ export const getDomainSitemap = async (
355
+ domain: string,
356
+ options: SitemapOptions = {}
357
+ ): Promise<string[]> =>
358
+ (await getDomainSitemapEntries(domain, options)).map((entry) => entry.loc)