@crawlbrulee/sdk 0.6.0 โ 0.7.1
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 +156 -76
- package/dist/index.cjs +778 -617
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +654 -668
- package/dist/index.d.ts +654 -668
- package/dist/index.js +777 -616
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ๐ฎ crawlbrulee js/ts sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@crawlbrulee/sdk)
|
|
4
|
+
[](https://www.npmjs.com/package/@crawlbrulee/sdk)
|
|
5
|
+
[](./LICENSE)
|
|
4
6
|
|
|
5
|
-
-
|
|
7
|
+
the official js/ts sdk for the [crawlbrulee](https://crawlbrulee.com) web-scraping api โ published to npm as [`@crawlbrulee/sdk`](https://www.npmjs.com/package/@crawlbrulee/sdk). you
|
|
8
|
+
send a url, you get back markdown, cleaned html, links, images, metadata, or a screenshot.
|
|
9
|
+
|
|
10
|
+
- fully typed.
|
|
6
11
|
- ESM + CommonJS, ships its own `.d.ts`.
|
|
7
|
-
-
|
|
8
|
-
-
|
|
12
|
+
- zero runtime dependencies โ just `fetch`.
|
|
13
|
+
- works on Node.js 22+, modern Deno, Bun, and runtimes where `fetch` is available.
|
|
14
|
+
|
|
15
|
+
this readme covers the sdk itself โ the client, the types, and the js-side ergonomics. for how
|
|
16
|
+
the api behaves โ endpoints, parameters, and error semantics โ please see our
|
|
17
|
+
[api docs](https://crawlbrulee.com/docs).
|
|
9
18
|
|
|
10
|
-
> **
|
|
19
|
+
> **status:** v0.7.0 (beta). the api surface is stabilizing โ expect minor breaking changes between 0.x releases.
|
|
20
|
+
|
|
21
|
+
**get a free api key** โ [dashboard.crawlbrulee.com](https://dashboard.crawlbrulee.com)
|
|
11
22
|
|
|
12
23
|
---
|
|
13
24
|
|
|
14
|
-
##
|
|
25
|
+
## install
|
|
15
26
|
|
|
16
27
|
```bash
|
|
17
28
|
pnpm add @crawlbrulee/sdk
|
|
@@ -21,7 +32,7 @@ npm install @crawlbrulee/sdk
|
|
|
21
32
|
yarn add @crawlbrulee/sdk
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
##
|
|
35
|
+
## quickstart
|
|
25
36
|
|
|
26
37
|
```ts
|
|
27
38
|
import { Crawlbrulee } from '@crawlbrulee/sdk'
|
|
@@ -41,22 +52,38 @@ console.log(page.metadata?.title) // structured <head> metadata
|
|
|
41
52
|
console.log(page.response_meta.usage.credits, 'credits charged') // usage accounting
|
|
42
53
|
```
|
|
43
54
|
|
|
44
|
-
###
|
|
55
|
+
### authentication
|
|
56
|
+
|
|
57
|
+
every request carries your api key as `Authorization: Bearer <key>`. give it to the sdk one of two ways:
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
| `timeoutMs` | `0` (no timeout) | Per-request timeout (covers headers + body). A per-call `timeoutMs` overrides this. |
|
|
59
|
+
```ts
|
|
60
|
+
// explicit โ pass the key directly
|
|
61
|
+
const crawlbrulee = new Crawlbrulee({ apiKey: 'cwbl_โฆ' })
|
|
50
62
|
|
|
51
|
-
|
|
63
|
+
// from the environment โ reads CRAWLBRULEE_API_KEY
|
|
64
|
+
const crawlbrulee = Crawlbrulee.fromEnv()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`Crawlbrulee.fromEnv(overrides?)` reads the key from `CRAWLBRULEE_API_KEY` and forwards any other option through
|
|
68
|
+
`overrides` (e.g. `Crawlbrulee.fromEnv({ timeoutMs: 30_000 })`). it throws if the variable is unset or empty. keys are
|
|
69
|
+
minted in the dashboard; see [authentication](https://crawlbrulee.com/docs/authentication) for how the api consumes them.
|
|
70
|
+
|
|
71
|
+
### configuration
|
|
72
|
+
|
|
73
|
+
| option | default | description |
|
|
74
|
+
| ----------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `apiKey` | โ | api key, sent as `Authorization: Bearer โฆ`. **required** โ or use `Crawlbrulee.fromEnv()`. |
|
|
76
|
+
| `baseUrl` | `https://api.crawlbrulee.com` | override the target host (local dev / staging). trailing slashes are stripped. |
|
|
77
|
+
| `timeoutMs` | `0` (no timeout) | per-request timeout in milliseconds (covers headers + body). a per-call `timeoutMs` overrides this. |
|
|
52
78
|
|
|
53
79
|
---
|
|
54
80
|
|
|
55
|
-
##
|
|
81
|
+
## api reference
|
|
56
82
|
|
|
57
|
-
|
|
83
|
+
all methods return a `Promise` that resolves to the parsed json response, or rejects with a [`CrawlbruleeError`](#errors)
|
|
84
|
+
subclass.
|
|
58
85
|
|
|
59
|
-
|
|
86
|
+
every method accepts an optional second argument with per-call overrides:
|
|
60
87
|
|
|
61
88
|
```ts
|
|
62
89
|
crawlbrulee.scrape(request, {
|
|
@@ -65,11 +92,11 @@ crawlbrulee.scrape(request, {
|
|
|
65
92
|
})
|
|
66
93
|
```
|
|
67
94
|
|
|
68
|
-
###
|
|
95
|
+
### scraping
|
|
69
96
|
|
|
70
97
|
#### `crawlbrulee.scrape(request, options?)`
|
|
71
98
|
|
|
72
|
-
|
|
99
|
+
scrape a url synchronously. the request blocks until the server is done.
|
|
73
100
|
|
|
74
101
|
```ts
|
|
75
102
|
const page = await crawlbrulee.scrape({
|
|
@@ -93,43 +120,62 @@ const page = await crawlbrulee.scrape({
|
|
|
93
120
|
})
|
|
94
121
|
```
|
|
95
122
|
|
|
96
|
-
|
|
123
|
+
the response carries the extracted content alongside structured `metadata` (the parsed `<head>` tags โ `title`,
|
|
124
|
+
`description`, OG/Twitter fields, โฆ) and a `response_meta` envelope:
|
|
97
125
|
|
|
98
126
|
```ts
|
|
99
127
|
page.metadata?.title // structured <head> metadata (when extract.metadata, on by default)
|
|
100
128
|
|
|
101
129
|
page.response_meta.usage.credits // credits charged โ 0 on a cache hit
|
|
102
|
-
page.response_meta.usage.proxy // the resolved proxy tier actually used: '
|
|
130
|
+
page.response_meta.usage.proxy // the resolved proxy tier actually used: 'basic' | 'advanced' (never 'auto')
|
|
103
131
|
page.response_meta.usage.cache_hit // true when the result was served from cache
|
|
104
132
|
```
|
|
105
133
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- **`proxy
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
notes:
|
|
135
|
+
|
|
136
|
+
- **`proxy`**: defaults to `auto` when omitted โ it starts at the basic tier and escalates to advanced on failure,
|
|
137
|
+
billed at the delivered tier. pass `'basic'` or `'advanced'` to pin a tier. on the response,
|
|
138
|
+
`response_meta.usage.proxy` reports the tier we resolved and used โ never `'auto'`. see
|
|
139
|
+
[proxies & location](https://crawlbrulee.com/docs/proxies) for what each tier does.
|
|
140
|
+
- **`screenshot`**: custom `viewport.width`/`height` are integers in `[16, 10000]` and `device_scale_factor` is in
|
|
141
|
+
`[1, 4]`; out-of-range values are rejected with a `400`. full capture options:
|
|
142
|
+
[screenshots](https://crawlbrulee.com/docs/scrape/screenshots).
|
|
143
|
+
- **`extract.images`**: urls preserve their query string and resolve document-relative `src`s against the full page url
|
|
144
|
+
(browser parity) โ the same rules as `links`. every extract field is documented under
|
|
145
|
+
[extraction](https://crawlbrulee.com/docs/scrape/extraction).
|
|
146
|
+
- **`warnings`**: when we complete a scrape but something is worth flagging โ e.g. `screenshot_truncated` when a long
|
|
147
|
+
page exceeded the scrolling-screenshot height cap โ the codes land on `page.warnings`. they're stable, so you can switch
|
|
148
|
+
on them. fresh scrapes only; cache hits omit warnings.
|
|
149
|
+
- **`unsupported_fields`**: if you request an extract that doesn't apply to the content type (e.g. `markdown` of a pdf),
|
|
150
|
+
that field name comes back on `page.unsupported_fields` and the rest of your payload is still returned.
|
|
151
|
+
|
|
152
|
+
see [`ScrapeRequest`](src/types/scrape.ts) and [`ScrapeResponse`](src/types/scrape.ts) for every field, with inline
|
|
153
|
+
documentation โ and the [scrape endpoint](https://crawlbrulee.com/docs/scrape) reference for the api-side contract those
|
|
154
|
+
types mirror.
|
|
113
155
|
|
|
114
156
|
#### `crawlbrulee.scrapeAsync(request, options?)`
|
|
115
157
|
|
|
116
|
-
|
|
158
|
+
submit a scrape job in the background. returns immediately with a `job_id`.
|
|
117
159
|
|
|
118
160
|
```ts
|
|
119
161
|
const { job_id } = await crawlbrulee.scrapeAsync({ url: 'https://example.com' })
|
|
120
162
|
```
|
|
121
163
|
|
|
164
|
+
pass a `webhook` to be notified on completion instead of polling โ see [webhooks](#webhooks).
|
|
165
|
+
|
|
122
166
|
#### `crawlbrulee.getScrapeStatus(jobId, options?)`
|
|
123
167
|
|
|
124
|
-
|
|
168
|
+
look up the current state of an async job โ `pending`, `running`, `done`, or `failed`. the response carries `job_id` and
|
|
169
|
+
`created_at` (snake_case, straight off the wire). once the job is `done`, it also carries usage accounting on
|
|
170
|
+
`response_meta.usage` (`credits`, `proxy`, `cache_hit`).
|
|
125
171
|
|
|
126
172
|
#### `crawlbrulee.getScrapeResult(jobId, options?)`
|
|
127
173
|
|
|
128
|
-
|
|
174
|
+
fetch the result of a completed async job. throws if the job hasn't finished yet.
|
|
129
175
|
|
|
130
176
|
#### `crawlbrulee.waitForScrape(jobId, options?)`
|
|
131
177
|
|
|
132
|
-
|
|
178
|
+
poll an async job until it reaches a terminal state, then return the scrape result.
|
|
133
179
|
|
|
134
180
|
```ts
|
|
135
181
|
const { job_id } = await crawlbrulee.scrapeAsync({ url: 'https://example.com' })
|
|
@@ -140,13 +186,16 @@ const page = await crawlbrulee.waitForScrape(job_id, {
|
|
|
140
186
|
})
|
|
141
187
|
```
|
|
142
188
|
|
|
143
|
-
|
|
189
|
+
throws a `CrawlbruleeError` with `errorName: 'job_failed'` if the job ends in `failed`, or `errorName: 'request_timeout'`
|
|
190
|
+
if the wait expires. the job lifecycle itself โ states, retention, and when to prefer async over sync โ is documented
|
|
191
|
+
under [async scrape](https://crawlbrulee.com/docs/scrape/async).
|
|
144
192
|
|
|
145
|
-
###
|
|
193
|
+
### mapping
|
|
146
194
|
|
|
147
195
|
#### `crawlbrulee.map(request, options?)`
|
|
148
196
|
|
|
149
|
-
|
|
197
|
+
build (or return a cached) link map for a website. combines sitemap discovery with the freshest cached homepage scrape
|
|
198
|
+
when available.
|
|
150
199
|
|
|
151
200
|
```ts
|
|
152
201
|
const result = await crawlbrulee.map({
|
|
@@ -162,25 +211,41 @@ console.log(result.links.length, 'urls on page 1 of', result.response_meta.pagin
|
|
|
162
211
|
console.log(result.response_meta.usage.credits, 'credits charged') // usage accounting, alongside pagination + truncation
|
|
163
212
|
```
|
|
164
213
|
|
|
165
|
-
|
|
214
|
+
`result.response_meta` carries `usage` (credits / resolved `proxy` / `cache_hit`) alongside the map-specific `pagination`
|
|
215
|
+
and `truncation` blocks. see the [map endpoint](https://crawlbrulee.com/docs/map) for discovery rules and pagination
|
|
216
|
+
semantics.
|
|
217
|
+
|
|
218
|
+
### account
|
|
166
219
|
|
|
167
220
|
#### `crawlbrulee.usage(options?)`
|
|
168
221
|
|
|
169
|
-
|
|
222
|
+
return the current billing-cycle snapshot โ `total_credits`, `used_credits`, `available_credits`, `used_quota_percent`,
|
|
223
|
+
`max_concurrency`, and the `usage_reset` timestamp.
|
|
170
224
|
|
|
171
225
|
#### `crawlbrulee.whoami(options?)`
|
|
172
226
|
|
|
173
|
-
|
|
227
|
+
return the organization name and token identity behind the api key (`organization_name`, `token_name`, and a
|
|
228
|
+
safe-to-display `token_preview`). use it to confirm which key is in play before a destructive operation.
|
|
229
|
+
|
|
230
|
+
what a call costs, and how credits are counted, is documented under
|
|
231
|
+
[credits & pricing](https://crawlbrulee.com/docs/credits-and-pricing).
|
|
174
232
|
|
|
175
233
|
---
|
|
176
234
|
|
|
177
|
-
##
|
|
235
|
+
## webhooks
|
|
236
|
+
|
|
237
|
+
when an async scrape job finishes, crawlbrulee can `POST` a `scrape.complete` webhook to your configured endpoint. the
|
|
238
|
+
sdk ships two helpers for it.
|
|
178
239
|
|
|
179
|
-
|
|
240
|
+
the delivery contract and payload shape live under [webhooks](https://crawlbrulee.com/docs/scrape/webhooks); the
|
|
241
|
+
signature scheme is specified in [webhook verification](https://crawlbrulee.com/docs/webhook-verification). what follows
|
|
242
|
+
is how this sdk helps you consume them.
|
|
180
243
|
|
|
181
|
-
###
|
|
244
|
+
### triggering a webhook (`scrapeAsync`)
|
|
182
245
|
|
|
183
|
-
|
|
246
|
+
pass a `webhook` to `scrapeAsync` to have us deliver a single signed `scrape.complete` `POST` when the job reaches a
|
|
247
|
+
terminal state. this is **async-only** โ the synchronous `scrape()` response _is_ the notification, so it does not accept
|
|
248
|
+
a `webhook`.
|
|
184
249
|
|
|
185
250
|
```ts
|
|
186
251
|
const { job_id } = await crawlbrulee.scrapeAsync({
|
|
@@ -195,11 +260,16 @@ const { job_id } = await crawlbrulee.scrapeAsync({
|
|
|
195
260
|
})
|
|
196
261
|
```
|
|
197
262
|
|
|
198
|
-
|
|
263
|
+
configure the signing secret used for these deliveries in the dashboard (**account โ webhooks**). there is no per-request
|
|
264
|
+
secret - when the delivery arrives, verify it with [`verifyWebhookSignature`](#verifywebhooksignatureoptions) and read your `metadata` back from
|
|
265
|
+
`webhook.data.metadata`. the delivery also carries usage accounting on `webhook.data.response_meta.usage` (`credits`,
|
|
266
|
+
`proxy`, `cache_hit`). see [`AsyncScrapeWebhook`](src/types/scrape.ts) for the full field documentation.
|
|
199
267
|
|
|
200
268
|
### `verifyWebhookSignature(options)`
|
|
201
269
|
|
|
202
|
-
|
|
270
|
+
a standalone, network-free helper (built on Web Crypto, so it runs on Node.js 22+, browsers, Bun, Deno, and edge) that
|
|
271
|
+
verifies the `X-Cwbl-Signature` header. **it returns a result object rather than throwing** โ a failed verification is
|
|
272
|
+
normal control flow.
|
|
203
273
|
|
|
204
274
|
```ts
|
|
205
275
|
import { verifyWebhookSignature } from '@crawlbrulee/sdk'
|
|
@@ -218,11 +288,15 @@ if (result.verified) {
|
|
|
218
288
|
}
|
|
219
289
|
```
|
|
220
290
|
|
|
221
|
-
|
|
291
|
+
during a **signing-secret rotation grace window** we send a second `X-Cwbl-Signature-Rotated` header signed with the
|
|
292
|
+
previous secret. `verifyWebhookSignature` tries your `secret` against the primary header first, then the rotated one,
|
|
293
|
+
and reports which matched via `signedWith` โ so verification keeps working whether you still hold the old secret or have
|
|
294
|
+
already rotated to the new one.
|
|
222
295
|
|
|
223
296
|
### `crawlbrulee.fetchScrapeResultFromWebhook(webhook, options?)`
|
|
224
297
|
|
|
225
|
-
|
|
298
|
+
given a verified `scrape.complete` body, fetch the scrape result. returns `getScrapeResult(job_id)` for a `success` job;
|
|
299
|
+
throws a `CrawlbruleeError` for `failed` (carrying the failure message) or `cancelled` jobs.
|
|
226
300
|
|
|
227
301
|
```ts
|
|
228
302
|
import { Crawlbrulee, verifyWebhookSignature, type ScrapeCompleteWebhook } from '@crawlbrulee/sdk'
|
|
@@ -246,23 +320,25 @@ app.post('/webhooks/crawlbrulee', async (req, res) => {
|
|
|
246
320
|
})
|
|
247
321
|
```
|
|
248
322
|
|
|
249
|
-
|
|
323
|
+
always verify the signature **before** parsing or trusting the body. the `X-Cwbl-Event-Id` header (also
|
|
324
|
+
`webhook.event_id`) is a stable id you can use to de-duplicate deliveries.
|
|
250
325
|
|
|
251
326
|
---
|
|
252
327
|
|
|
253
|
-
##
|
|
328
|
+
## errors
|
|
254
329
|
|
|
255
|
-
|
|
330
|
+
every failure raised by the sdk extends [`CrawlbruleeError`](src/errors.ts). typed subclasses are exported for the most actionable
|
|
331
|
+
cases:
|
|
256
332
|
|
|
257
|
-
|
|
|
333
|
+
| class | when it's raised |
|
|
258
334
|
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
259
|
-
| `AuthenticationError` | 401 / 403 responses (missing, invalid, or unauthorized
|
|
260
|
-
| `RateLimitError` | 429 responses.
|
|
261
|
-
| `UsageAllocationError` |
|
|
335
|
+
| `AuthenticationError` | 401 / 403 responses (missing, invalid, or unauthorized api key). |
|
|
336
|
+
| `RateLimitError` | 429 responses. exposes `retryAfterMs` and `limitedBy` when the server provided them. |
|
|
337
|
+
| `UsageAllocationError` | the org's plan limit was hit. exposes `reason` (`credit_limit`, `concurrency_limit`, โฆ) and `usage`. |
|
|
262
338
|
| `ValidationError` | 4xx caused by a bad request (`invalid_url`, `url_too_long`, `blocked_url`, โฆ). |
|
|
263
339
|
| `NotFoundError` | 404 responses (e.g. unknown async `jobId`). |
|
|
264
|
-
| `TransportError` |
|
|
265
|
-
| `CrawlbruleeError` |
|
|
340
|
+
| `TransportError` | network failures, aborts, non-json responses, request body read failures. |
|
|
341
|
+
| `CrawlbruleeError` | base class โ used for any other api error. always has `status`, `errorName`, `message`. |
|
|
266
342
|
|
|
267
343
|
```ts
|
|
268
344
|
import { Crawlbrulee, RateLimitError, UsageAllocationError } from '@crawlbrulee/sdk'
|
|
@@ -282,26 +358,13 @@ try {
|
|
|
282
358
|
}
|
|
283
359
|
```
|
|
284
360
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
Limits are per-plan, with **separate buckets for sync and async** (requests per minute). Synchronous `scrape()` **and** `map()` count against the sync bucket; `scrapeAsync()` submissions have their own bucket.
|
|
290
|
-
|
|
291
|
-
| Plan | Sync (rpm) | Async (rpm) |
|
|
292
|
-
| -------- | ---------- | ----------- |
|
|
293
|
-
| Free | 50 | 100 |
|
|
294
|
-
| Starter | 100 | 300 |
|
|
295
|
-
| Pro | 350 | 1000 |
|
|
296
|
-
| Advanced | 1000 | 3000 |
|
|
297
|
-
|
|
298
|
-
When you exceed a bucket the API returns `429` and the SDK throws a `RateLimitError` โ read `retryAfterMs` to back off.
|
|
299
|
-
|
|
300
|
-
---
|
|
361
|
+
for exhaustive branching, switch on `err.errorName` โ the literal-typed union is exported as `ApiErrorName`. the
|
|
362
|
+
`isCrawlbruleeError(err)` type guard narrows an `unknown` to the base error. the api docs carry the canonical
|
|
363
|
+
[error reference](https://crawlbrulee.com/docs/errors) โ every `errorName`, what causes it, and how to recover.
|
|
301
364
|
|
|
302
|
-
##
|
|
365
|
+
## cancellation and timeouts
|
|
303
366
|
|
|
304
|
-
|
|
367
|
+
every method accepts an `AbortSignal`:
|
|
305
368
|
|
|
306
369
|
```ts
|
|
307
370
|
const controller = new AbortController()
|
|
@@ -310,11 +373,12 @@ const page = crawlbrulee.scrape({ url: 'https://slow.example.com' }, { signal: c
|
|
|
310
373
|
setTimeout(() => controller.abort(), 5_000)
|
|
311
374
|
```
|
|
312
375
|
|
|
313
|
-
|
|
376
|
+
the per-call `timeoutMs` and the caller's signal are composed โ whichever fires first wins. a fired timeout surfaces as
|
|
377
|
+
a `TransportError` with `errorName: 'request_timeout'`; an aborted signal as `errorName: 'client_closed_request'`.
|
|
314
378
|
|
|
315
379
|
---
|
|
316
380
|
|
|
317
|
-
##
|
|
381
|
+
## development
|
|
318
382
|
|
|
319
383
|
```bash
|
|
320
384
|
pnpm install
|
|
@@ -324,4 +388,20 @@ pnpm lint # eslint
|
|
|
324
388
|
pnpm build # tsup โ dist/
|
|
325
389
|
```
|
|
326
390
|
|
|
327
|
-
|
|
391
|
+
the sdk has zero runtime dependencies on purpose. please keep it that way when contributing.
|
|
392
|
+
|
|
393
|
+
## part of the crawlbrulee toolkit
|
|
394
|
+
|
|
395
|
+
one api, many ways to call it:
|
|
396
|
+
|
|
397
|
+
- **[js/ts sdk](https://github.com/crawlbrulee/crawlbrulee-js)** โ `@crawlbrulee/sdk` (this one)
|
|
398
|
+
- **[python sdk](https://github.com/crawlbrulee/crawlbrulee-py)** โ `crawlbrulee` on pypi
|
|
399
|
+
- **[cli](https://github.com/crawlbrulee/crawlbrulee-cli)** โ `npx crawlbrulee`
|
|
400
|
+
- **[mcp server](https://github.com/crawlbrulee/crawlbrulee-mcp)** โ `@crawlbrulee/mcp`, for ai agents
|
|
401
|
+
- **[agent skills](https://github.com/crawlbrulee/crawlbrulee-skills)** โ for skills-aware coding agents
|
|
402
|
+
|
|
403
|
+
docs: [crawlbrulee.com/docs](https://crawlbrulee.com/docs) ยท dashboard: [dashboard.crawlbrulee.com](https://dashboard.crawlbrulee.com)
|
|
404
|
+
|
|
405
|
+
## license
|
|
406
|
+
|
|
407
|
+
[AGPL-3.0-only](./LICENSE)
|