hanzio 1.1.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.
- package/README.md +832 -219
- package/dist/api-wrapper/builders.d.ts +15 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +40 -2
- package/dist/api-wrapper/headers.d.ts +2 -1
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +12 -10
- package/dist/api-wrapper/types.d.ts +104 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +45 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- package/dist/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1100
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +863 -2809
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +261 -1377
- package/dist/src/secrets/vite.js +6 -17
- package/dist/src/secrets/worker.js +12 -0
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +347 -174
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +37 -26
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +87 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +84 -52
- package/src/api-wrapper/create-client.ts +246 -96
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +117 -2
- package/src/api-wrapper/headers.ts +19 -1
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +43 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +68 -74
- package/src/api-wrapper/types.ts +186 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +235 -83
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- package/src/secrets/core.ts +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +134 -103
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/infisical.js +0 -1194
- package/dist/zod/index.js +0 -210
package/src/sitemap/index.ts
CHANGED
|
@@ -1,89 +1,259 @@
|
|
|
1
1
|
import PQueue from '../p-queue'
|
|
2
|
+
import { escapeRegExp } from '../string'
|
|
3
|
+
import { normalizeDomain } from '../url'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
95
|
-
* @param domain - The root domain (e.g., 'https://example.com')
|
|
96
|
-
* @
|
|
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
|
|
268
|
+
export const getDomainSitemapEntries = async (
|
|
100
269
|
domain: string,
|
|
101
270
|
options: SitemapOptions = {}
|
|
102
|
-
): Promise<
|
|
271
|
+
): Promise<SitemapEntry[]> => {
|
|
103
272
|
const {
|
|
104
273
|
filterIndexes,
|
|
274
|
+
filterUrls,
|
|
105
275
|
concurrency = 10,
|
|
106
276
|
maxDepth = 5,
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
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
|
|
124
|
-
|
|
125
|
-
visited: new Set<string>()
|
|
126
|
-
}
|
|
305
|
+
const entries = new Map<string, SitemapEntry>()
|
|
306
|
+
const visited = new Set<string>()
|
|
127
307
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
149
|
-
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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 [...
|
|
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)
|