@thomasfosterau/effect-robots 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +122 -2
- package/dist/index.d.ts +566 -0
- package/dist/index.js +702 -0
- package/package.json +51 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
7
|
+
//#region src/index.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* A single rule in a robots.txt file: a path pattern and whether it allows.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { Rule } from "@thomasfosterau/effect-robots"
|
|
14
|
+
*
|
|
15
|
+
* const rule = new Rule({ path: "/admin", allow: false })
|
|
16
|
+
* rule.path // "/admin"
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @since 0.1.0
|
|
20
|
+
* @category models
|
|
21
|
+
*/
|
|
22
|
+
export declare class Rule extends Data.Class<{
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly allow: boolean;
|
|
25
|
+
}> {}
|
|
26
|
+
/**
|
|
27
|
+
* A group of rules for one or more user agents.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { RobotGroup, Rule } from "@thomasfosterau/effect-robots"
|
|
32
|
+
*
|
|
33
|
+
* const group = new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })
|
|
34
|
+
* group.crawlDelay // undefined
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @since 0.1.0
|
|
38
|
+
* @category models
|
|
39
|
+
*/
|
|
40
|
+
export declare class RobotGroup extends Data.Class<{
|
|
41
|
+
readonly userAgents: ReadonlyArray<string>;
|
|
42
|
+
readonly rules: ReadonlyArray<Rule>;
|
|
43
|
+
readonly crawlDelay?: number | undefined;
|
|
44
|
+
}> {}
|
|
45
|
+
/**
|
|
46
|
+
* A parsed robots.txt file: its groups and its `Sitemap:` URLs.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { RobotsTxt } from "@thomasfosterau/effect-robots"
|
|
51
|
+
*
|
|
52
|
+
* const robots = new RobotsTxt({ groups: [], sitemaps: ["https://example.com/sitemap.xml"] })
|
|
53
|
+
* robots.sitemaps.length // 1
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @since 0.1.0
|
|
57
|
+
* @category models
|
|
58
|
+
*/
|
|
59
|
+
export declare class RobotsTxt extends Data.Class<{
|
|
60
|
+
readonly groups: ReadonlyArray<RobotGroup>;
|
|
61
|
+
readonly sitemaps: ReadonlyArray<string>;
|
|
62
|
+
}> {}
|
|
63
|
+
/**
|
|
64
|
+
* Validates that a value is a {@link Rule} instance (`Schema.instanceOf`).
|
|
65
|
+
*
|
|
66
|
+
* @since 0.1.0
|
|
67
|
+
* @category schemas
|
|
68
|
+
*/
|
|
69
|
+
export declare const RuleSchema: Schema.instanceOf<Rule, Rule>;
|
|
70
|
+
/**
|
|
71
|
+
* Validates that a value is a {@link RobotGroup} instance (`Schema.instanceOf`).
|
|
72
|
+
*
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
* @category schemas
|
|
75
|
+
*/
|
|
76
|
+
export declare const RobotGroupSchema: Schema.instanceOf<RobotGroup, RobotGroup>;
|
|
77
|
+
/**
|
|
78
|
+
* Validates that a value is a {@link RobotsTxt} instance (`Schema.instanceOf`); use {@link RobotsTxtFromString} to parse text.
|
|
79
|
+
*
|
|
80
|
+
* @since 0.1.0
|
|
81
|
+
* @category schemas
|
|
82
|
+
*/
|
|
83
|
+
export declare const RobotsTxtSchema: Schema.instanceOf<RobotsTxt, RobotsTxt>;
|
|
84
|
+
/**
|
|
85
|
+
* The schema between robots.txt text and {@link RobotsTxt}: decoding parses
|
|
86
|
+
* (RFC 9309 grouping, comments, unknown directives ignored), encoding
|
|
87
|
+
* serialises.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* import * as Schema from "effect/Schema"
|
|
92
|
+
* import { RobotsTxtFromString } from "@thomasfosterau/effect-robots"
|
|
93
|
+
*
|
|
94
|
+
* const robots = Schema.decodeUnknownSync(RobotsTxtFromString)("User-agent: *\nDisallow: /admin")
|
|
95
|
+
* robots.groups.length // 1
|
|
96
|
+
* Schema.encodeSync(RobotsTxtFromString)(robots) // "User-agent: *\nDisallow: /admin\n"
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* @since 0.1.0
|
|
100
|
+
* @category schemas
|
|
101
|
+
*/
|
|
102
|
+
export declare const RobotsTxtFromString: Schema.decodeTo<Schema.instanceOf<RobotsTxt, RobotsTxt>, Schema.String, never, never>;
|
|
103
|
+
/**
|
|
104
|
+
* The decoded side of {@link RobotsTxtFromString}: a {@link RobotsTxt}.
|
|
105
|
+
*
|
|
106
|
+
* @since 0.1.0
|
|
107
|
+
* @category schemas
|
|
108
|
+
*/
|
|
109
|
+
export type RobotsTxtFromString = typeof RobotsTxtFromString.Type;
|
|
110
|
+
/**
|
|
111
|
+
* Parses robots.txt content into a {@link RobotsTxt} (synchronous).
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* import { parse } from "@thomasfosterau/effect-robots"
|
|
116
|
+
*
|
|
117
|
+
* const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public\n\nSitemap: https://example.com/sitemap.xml")
|
|
118
|
+
* robots.groups[0]?.rules.length // 2
|
|
119
|
+
* robots.sitemaps // ["https://example.com/sitemap.xml"]
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* @since 0.1.0
|
|
123
|
+
* @category parsing
|
|
124
|
+
*/
|
|
125
|
+
export declare const parse: (content: string) => RobotsTxt;
|
|
126
|
+
/**
|
|
127
|
+
* Encodes a {@link RobotsTxt} back to robots.txt text (synchronous).
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* import { encode, RobotGroup, RobotsTxt, Rule } from "@thomasfosterau/effect-robots"
|
|
132
|
+
*
|
|
133
|
+
* encode(new RobotsTxt({
|
|
134
|
+
* groups: [new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })],
|
|
135
|
+
* sitemaps: ["https://example.com/sitemap.xml"],
|
|
136
|
+
* }))
|
|
137
|
+
* // "User-agent: *\nDisallow: /admin\n\nSitemap: https://example.com/sitemap.xml"
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @since 0.1.0
|
|
141
|
+
* @category parsing
|
|
142
|
+
*/
|
|
143
|
+
export declare const encode: (robotsTxt: RobotsTxt) => string;
|
|
144
|
+
/**
|
|
145
|
+
* Whether `userAgent` may access `urlPath` under the rules: the most
|
|
146
|
+
* specific matching group, then its longest matching rule, Allow winning
|
|
147
|
+
* ties; no matching group or rule means allowed. The query string and
|
|
148
|
+
* fragment are stripped before matching (the library's documented
|
|
149
|
+
* behaviour; {@link evaluateRobots} matches the query too, as RFC 9309
|
|
150
|
+
* §2.2.2 does).
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* import { isAllowed, parse } from "@thomasfosterau/effect-robots"
|
|
155
|
+
*
|
|
156
|
+
* const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public")
|
|
157
|
+
* isAllowed(robots, "MyBot", "/admin") // false
|
|
158
|
+
* isAllowed(robots, "MyBot", "/admin/public") // true
|
|
159
|
+
* isAllowed(robots, "MyBot", "/public") // true
|
|
160
|
+
* ```
|
|
161
|
+
*
|
|
162
|
+
* @since 0.1.0
|
|
163
|
+
* @category matching
|
|
164
|
+
*/
|
|
165
|
+
export declare const isAllowed: (robotsTxt: RobotsTxt, userAgent: string, urlPath: string) => boolean;
|
|
166
|
+
/**
|
|
167
|
+
* The crawl delay (seconds) for `userAgent`, from its matching group.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* import { getCrawlDelay, parse } from "@thomasfosterau/effect-robots"
|
|
172
|
+
*
|
|
173
|
+
* const robots = parse("User-agent: Googlebot\nCrawl-delay: 5\nDisallow: /x\n\nUser-agent: *\nCrawl-delay: 10\nDisallow: /y")
|
|
174
|
+
* getCrawlDelay(robots, "Googlebot") // 5
|
|
175
|
+
* getCrawlDelay(robots, "OtherBot") // 10
|
|
176
|
+
* ```
|
|
177
|
+
*
|
|
178
|
+
* @since 0.1.0
|
|
179
|
+
* @category matching
|
|
180
|
+
*/
|
|
181
|
+
export declare const getCrawlDelay: (robotsTxt: RobotsTxt, userAgent: string) => number | undefined;
|
|
182
|
+
/**
|
|
183
|
+
* Every `Sitemap:` URL in the file.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```ts
|
|
187
|
+
* import { getSitemaps, parse } from "@thomasfosterau/effect-robots"
|
|
188
|
+
*
|
|
189
|
+
* getSitemaps(parse("Sitemap: https://example.com/sitemap.xml")) // ["https://example.com/sitemap.xml"]
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* @since 0.1.0
|
|
193
|
+
* @category matching
|
|
194
|
+
*/
|
|
195
|
+
export declare const getSitemaps: (robotsTxt: RobotsTxt) => ReadonlyArray<string>;
|
|
196
|
+
/**
|
|
197
|
+
* Upper bound applied to `Crawl-delay` so a hostile value cannot stall a
|
|
198
|
+
* batch.
|
|
199
|
+
*
|
|
200
|
+
* @since 0.1.0
|
|
201
|
+
* @category constants
|
|
202
|
+
*/
|
|
203
|
+
export declare const MAX_CRAWL_DELAY_SECONDS = 30;
|
|
204
|
+
/**
|
|
205
|
+
* The result of evaluating robots.txt rules for one URL.
|
|
206
|
+
*
|
|
207
|
+
* @since 0.1.0
|
|
208
|
+
* @category models
|
|
209
|
+
*/
|
|
210
|
+
export interface RobotsEvaluation {
|
|
211
|
+
readonly allowed: boolean;
|
|
212
|
+
/** The matched rule's path pattern (diagnostics), if any rule matched. */
|
|
213
|
+
readonly matchedPattern: Option.Option<string>;
|
|
214
|
+
/** The selected group's crawl delay, capped; `None` when it declares none. */
|
|
215
|
+
readonly crawlDelaySeconds: Option.Option<number>;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Options for {@link evaluateRobots}.
|
|
219
|
+
*
|
|
220
|
+
* @since 0.1.0
|
|
221
|
+
* @category models
|
|
222
|
+
*/
|
|
223
|
+
export interface EvaluateOptions {
|
|
224
|
+
/** The cap on a reported `Crawl-delay`. Defaults to {@link MAX_CRAWL_DELAY_SECONDS}. */
|
|
225
|
+
readonly maxCrawlDelaySeconds?: number | undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Evaluates a parsed robots.txt for a user-agent token and a path — the
|
|
229
|
+
* crawlers' decision, with its diagnostics. Group selection: the longest
|
|
230
|
+
* matching product token wins, the `*` group is the fallback, none means
|
|
231
|
+
* allowed. Rule precedence: longest matching pattern wins, Allow wins ties.
|
|
232
|
+
* Unlike {@link isAllowed}, the path is matched as given, query included
|
|
233
|
+
* (RFC 9309 §2.2.2), and only a fragment is stripped. The crawl delay is
|
|
234
|
+
* capped and a non-positive one is dropped.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* import * as Option from "effect/Option"
|
|
239
|
+
* import { evaluateRobots, parse } from "@thomasfosterau/effect-robots"
|
|
240
|
+
*
|
|
241
|
+
* const robots = parse("User-agent: *\nCrawl-delay: 3600\nDisallow: /private/\nAllow: /private/public/")
|
|
242
|
+
* evaluateRobots(robots, "mybot", "/private/secret")
|
|
243
|
+
* // { allowed: false, matchedPattern: Option.some("/private/"), crawlDelaySeconds: Option.some(30) }
|
|
244
|
+
* evaluateRobots(robots, "mybot", "/private/public/page").allowed // true
|
|
245
|
+
* ```
|
|
246
|
+
*
|
|
247
|
+
* @since 0.1.0
|
|
248
|
+
* @category evaluation
|
|
249
|
+
*/
|
|
250
|
+
export declare const evaluateRobots: (robotsTxt: RobotsTxt, userAgentToken: string, path: string, options?: EvaluateOptions) => RobotsEvaluation;
|
|
251
|
+
/**
|
|
252
|
+
* robots.txt responses are capped at this many bytes (RFC 9309 §2.5).
|
|
253
|
+
*
|
|
254
|
+
* @since 0.1.0
|
|
255
|
+
* @category constants
|
|
256
|
+
*/
|
|
257
|
+
export declare const MAX_ROBOTS_BYTES: number;
|
|
258
|
+
/**
|
|
259
|
+
* Per-origin cache TTL for a fetched body or a 4xx (24 hours): a directive
|
|
260
|
+
* change is honoured within a day.
|
|
261
|
+
*
|
|
262
|
+
* @since 0.1.0
|
|
263
|
+
* @category constants
|
|
264
|
+
*/
|
|
265
|
+
export declare const ROBOTS_CACHE_TTL_SECONDS: number;
|
|
266
|
+
/**
|
|
267
|
+
* Cache TTL for an unavailable robots.txt (one hour), so a misbehaving
|
|
268
|
+
* origin is not probed on every capture.
|
|
269
|
+
*
|
|
270
|
+
* @since 0.1.0
|
|
271
|
+
* @category constants
|
|
272
|
+
*/
|
|
273
|
+
export declare const ROBOTS_UNAVAILABLE_TTL_SECONDS: number;
|
|
274
|
+
/**
|
|
275
|
+
* The cache value for a robots.txt that could not be read (5xx, transport
|
|
276
|
+
* failure, timeout). The leading character is a deliberate literal NUL
|
|
277
|
+
* byte — it can never collide with a real body. Written as the `\0` escape
|
|
278
|
+
* rather than an embedded raw NUL byte: same runtime string, but a raw NUL
|
|
279
|
+
* in the source makes tools that binary-sniff stop treating the file as
|
|
280
|
+
* text. Changing the value orphans entries already in a persistent cache.
|
|
281
|
+
*
|
|
282
|
+
* @since 0.1.0
|
|
283
|
+
* @category constants
|
|
284
|
+
*/
|
|
285
|
+
export declare const ROBOTS_UNAVAILABLE_SENTINEL = "\0__robots_unavailable__";
|
|
286
|
+
/**
|
|
287
|
+
* The cache value for an origin with no restrictions (a 4xx, 404 included).
|
|
288
|
+
*
|
|
289
|
+
* @since 0.1.0
|
|
290
|
+
* @category constants
|
|
291
|
+
*/
|
|
292
|
+
export declare const ROBOTS_NO_RESTRICTIONS = "";
|
|
293
|
+
/**
|
|
294
|
+
* The pluggable per-origin cache the checker reads through. Both methods
|
|
295
|
+
* are infallible by contract: a cache that can fail degrades to a miss / a
|
|
296
|
+
* dropped write inside its adapter. A persistent adapter, such as Workers
|
|
297
|
+
* KV, lives in the consuming application.
|
|
298
|
+
*
|
|
299
|
+
* @since 0.1.0
|
|
300
|
+
* @category models
|
|
301
|
+
*/
|
|
302
|
+
export interface RobotsCache<R = never> {
|
|
303
|
+
/** The cached value for an origin (`https://host`), or `None`. */
|
|
304
|
+
readonly get: (origin: string) => Effect.Effect<Option.Option<string>, never, R>;
|
|
305
|
+
/** Stores the value with a TTL in seconds. */
|
|
306
|
+
readonly put: (origin: string, value: string, ttlSeconds: number) => Effect.Effect<void, never, R>;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* A cache that stores nothing: every check fetches. The default cache of
|
|
310
|
+
* {@link makeRobotsChecker}.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* import * as Effect from "effect/Effect"
|
|
315
|
+
* import * as Option from "effect/Option"
|
|
316
|
+
* import { noRobotsCache } from "@thomasfosterau/effect-robots"
|
|
317
|
+
*
|
|
318
|
+
* await Effect.runPromise(noRobotsCache.put("https://example.com", "User-agent: *", 60))
|
|
319
|
+
* Option.isNone(await Effect.runPromise(noRobotsCache.get("https://example.com"))) // true
|
|
320
|
+
* ```
|
|
321
|
+
*
|
|
322
|
+
* @since 0.1.0
|
|
323
|
+
* @category caches
|
|
324
|
+
*/
|
|
325
|
+
export declare const noRobotsCache: RobotsCache;
|
|
326
|
+
/**
|
|
327
|
+
* An in-memory cache (a `Map`), for tests and single-process crawlers. TTLs
|
|
328
|
+
* are recorded, not enforced.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* import * as Effect from "effect/Effect"
|
|
333
|
+
* import { memoryRobotsCache } from "@thomasfosterau/effect-robots"
|
|
334
|
+
*
|
|
335
|
+
* const cache = memoryRobotsCache()
|
|
336
|
+
* await Effect.runPromise(cache.put("https://example.com", "User-agent: *\nDisallow: /", 60))
|
|
337
|
+
* cache.entries.get("https://example.com")?.ttlSeconds // 60
|
|
338
|
+
* ```
|
|
339
|
+
*
|
|
340
|
+
* @since 0.1.0
|
|
341
|
+
* @category caches
|
|
342
|
+
*/
|
|
343
|
+
export declare const memoryRobotsCache: () => RobotsCache & {
|
|
344
|
+
readonly entries: Map<string, {
|
|
345
|
+
readonly value: string;
|
|
346
|
+
readonly ttlSeconds: number;
|
|
347
|
+
}>;
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Options for {@link fetchRobots} / {@link makeRobotsChecker}.
|
|
351
|
+
*
|
|
352
|
+
* @since 0.1.0
|
|
353
|
+
* @category models
|
|
354
|
+
*/
|
|
355
|
+
export interface FetchRobotsOptions {
|
|
356
|
+
/** The full `User-Agent` header sent with the request. */
|
|
357
|
+
readonly userAgent: string;
|
|
358
|
+
/** Wall-clock bound on the request. Defaults to 10 seconds. */
|
|
359
|
+
readonly timeout?: Duration.Input | undefined;
|
|
360
|
+
/** The body cap. Defaults to {@link MAX_ROBOTS_BYTES}. */
|
|
361
|
+
readonly maxBytes?: number | undefined;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Fetches `<origin>/robots.txt` through the `HttpClient` in context and
|
|
365
|
+
* folds the outcome into the cache value with RFC 9309 §2.3.1's posture: a
|
|
366
|
+
* 2xx body (capped), {@link ROBOTS_NO_RESTRICTIONS} for a 4xx (404
|
|
367
|
+
* included), and {@link ROBOTS_UNAVAILABLE_SENTINEL} for a 5xx, a transport
|
|
368
|
+
* failure, a timeout or a body that cannot be read — the crawler must
|
|
369
|
+
* assume it is not permitted until it can read the rules. Never fails.
|
|
370
|
+
*
|
|
371
|
+
* Redirect policy is the client's: provide one whose redirects are
|
|
372
|
+
* revalidated against the SSRF block list (`@thomasfosterau/effect-web/net`).
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```ts
|
|
376
|
+
* import * as Effect from "effect/Effect"
|
|
377
|
+
* import * as HttpClient from "effect/http/HttpClient"
|
|
378
|
+
* import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
379
|
+
* import { fetchRobots, ROBOTS_NO_RESTRICTIONS } from "@thomasfosterau/effect-robots"
|
|
380
|
+
*
|
|
381
|
+
* const notFound = HttpClient.make((request) =>
|
|
382
|
+
* Effect.succeed(HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))),
|
|
383
|
+
* )
|
|
384
|
+
* await Effect.runPromise(
|
|
385
|
+
* fetchRobots("https://example.com", { userAgent: "mybot/1.0" }).pipe(
|
|
386
|
+
* Effect.provideService(HttpClient.HttpClient, notFound),
|
|
387
|
+
* ),
|
|
388
|
+
* ) === ROBOTS_NO_RESTRICTIONS // true
|
|
389
|
+
* ```
|
|
390
|
+
*
|
|
391
|
+
* @since 0.1.0
|
|
392
|
+
* @category fetching
|
|
393
|
+
*/
|
|
394
|
+
export declare const fetchRobots: (origin: string, options: FetchRobotsOptions) => Effect.Effect<string, never, HttpClient.HttpClient>;
|
|
395
|
+
/**
|
|
396
|
+
* The decision {@link makeRobotsChecker}'s `checkRobots` returns.
|
|
397
|
+
*
|
|
398
|
+
* @since 0.1.0
|
|
399
|
+
* @category models
|
|
400
|
+
*/
|
|
401
|
+
export type RobotsDecision = {
|
|
402
|
+
readonly allowed: true;
|
|
403
|
+
readonly crawlDelaySeconds: Option.Option<number>;
|
|
404
|
+
} | {
|
|
405
|
+
readonly allowed: false;
|
|
406
|
+
readonly reason: string;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* What one crawler's checker is built from.
|
|
410
|
+
*
|
|
411
|
+
* @since 0.1.0
|
|
412
|
+
* @category models
|
|
413
|
+
*/
|
|
414
|
+
export interface RobotsCheckerOptions<R = never> extends FetchRobotsOptions, EvaluateOptions {
|
|
415
|
+
/** The product token matched against `User-agent:` lines. */
|
|
416
|
+
readonly userAgentToken: string;
|
|
417
|
+
/** The per-origin cache. Defaults to {@link noRobotsCache}. */
|
|
418
|
+
readonly cache?: RobotsCache<R> | undefined;
|
|
419
|
+
/** TTL for a body or a 4xx. Defaults to {@link ROBOTS_CACHE_TTL_SECONDS}. */
|
|
420
|
+
readonly cacheTtlSeconds?: number | undefined;
|
|
421
|
+
/** TTL for an unavailable outcome. Defaults to {@link ROBOTS_UNAVAILABLE_TTL_SECONDS}. */
|
|
422
|
+
readonly unavailableTtlSeconds?: number | undefined;
|
|
423
|
+
/**
|
|
424
|
+
* Whether the query string takes part in matching. Defaults to `true`
|
|
425
|
+
* (`pathname + search`, as RFC 9309 §2.2.2 matches); `false` matches the
|
|
426
|
+
* pathname alone.
|
|
427
|
+
*/
|
|
428
|
+
readonly matchQuery?: boolean | undefined;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The reason a disallowed decision carries for an unavailable robots.txt.
|
|
432
|
+
*
|
|
433
|
+
* @since 0.1.0
|
|
434
|
+
* @category constants
|
|
435
|
+
*/
|
|
436
|
+
export declare const ROBOTS_UNAVAILABLE_REASON = "robots.txt unavailable (server error) — treating as disallow";
|
|
437
|
+
/**
|
|
438
|
+
* Builds a crawler's `checkRobots`: whether it may fetch `url` under the
|
|
439
|
+
* origin's robots.txt, reading the origin's body through the cache and
|
|
440
|
+
* fetching on a miss ({@link fetchRobots}), then {@link evaluateRobots}
|
|
441
|
+
* for the crawler's token. Never fails — robots problems surface as
|
|
442
|
+
* `allowed: false` decisions, and cache problems are the adapter's to
|
|
443
|
+
* degrade. The fetch outcome is cached with the TTL its posture warrants
|
|
444
|
+
* (unavailability shorter), and a failed cache write is not a failed
|
|
445
|
+
* decision.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* import * as Effect from "effect/Effect"
|
|
450
|
+
* import * as HttpClient from "effect/http/HttpClient"
|
|
451
|
+
* import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
452
|
+
* import { makeRobotsChecker, memoryRobotsCache } from "@thomasfosterau/effect-robots"
|
|
453
|
+
*
|
|
454
|
+
* const site = HttpClient.make((request) =>
|
|
455
|
+
* Effect.succeed(HttpClientResponse.fromWeb(request, new Response("User-agent: *\nDisallow: /secret/", { status: 200 }))),
|
|
456
|
+
* )
|
|
457
|
+
* const checkRobots = makeRobotsChecker({
|
|
458
|
+
* userAgent: "mybot/1.0 (+https://example.com)",
|
|
459
|
+
* userAgentToken: "mybot",
|
|
460
|
+
* cache: memoryRobotsCache(),
|
|
461
|
+
* })
|
|
462
|
+
* const decision = await Effect.runPromise(
|
|
463
|
+
* checkRobots("https://origin.example/secret/x").pipe(Effect.provideService(HttpClient.HttpClient, site)),
|
|
464
|
+
* )
|
|
465
|
+
* decision // { allowed: false, reason: "disallowed by robots.txt rule '/secret/'" }
|
|
466
|
+
* ```
|
|
467
|
+
*
|
|
468
|
+
* @since 0.1.0
|
|
469
|
+
* @category checking
|
|
470
|
+
*/
|
|
471
|
+
export declare const makeRobotsChecker: <R = never>(options: RobotsCheckerOptions<R>) => ((url: string) => Effect.Effect<RobotsDecision, never, HttpClient.HttpClient | R>);
|
|
472
|
+
/**
|
|
473
|
+
* What a site's production `robots.txt` says.
|
|
474
|
+
*
|
|
475
|
+
* @since 0.1.0
|
|
476
|
+
* @category models
|
|
477
|
+
*/
|
|
478
|
+
export interface RobotsPolicy {
|
|
479
|
+
/**
|
|
480
|
+
* Non-content URL spaces kept out of the crawl to preserve budget. A `*`
|
|
481
|
+
* wildcard matches across path segments (RFC 9309 §2.2.3), so `/*\/edit`
|
|
482
|
+
* covers `/posts/<uuid>/edit`.
|
|
483
|
+
*/
|
|
484
|
+
readonly disallow: ReadonlyArray<string>;
|
|
485
|
+
/** The absolute, production-canonical sitemap URL. */
|
|
486
|
+
readonly sitemapUrl: string;
|
|
487
|
+
/** Comment lines emitted first, without the `# ` prefix. */
|
|
488
|
+
readonly comments?: ReadonlyArray<string> | undefined;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The non-production body: a full disallow, no sitemap (ROBOTS-005), so a
|
|
492
|
+
* `dev-<branch>` / `ci-<run>` / PR-preview deployment is never crawled.
|
|
493
|
+
*
|
|
494
|
+
* @since 0.1.0
|
|
495
|
+
* @category serving
|
|
496
|
+
*/
|
|
497
|
+
export declare const NON_PRODUCTION_BODY: string;
|
|
498
|
+
/**
|
|
499
|
+
* The production body: open to all crawlers (`User-agent: *`, AI crawlers
|
|
500
|
+
* included), the hygiene disallows, then the absolute sitemap URL
|
|
501
|
+
* (ROBOTS-002 … -004).
|
|
502
|
+
*
|
|
503
|
+
* @example
|
|
504
|
+
* ```ts
|
|
505
|
+
* import { productionBody } from "@thomasfosterau/effect-robots"
|
|
506
|
+
*
|
|
507
|
+
* productionBody({ disallow: ["/login", "/*\/edit"], sitemapUrl: "https://example.com/sitemap.xml", comments: ["All crawlers welcome."] })
|
|
508
|
+
* // "# All crawlers welcome.\nUser-agent: *\nDisallow: /login\nDisallow: /*\/edit\n\nSitemap: https://example.com/sitemap.xml\n"
|
|
509
|
+
* ```
|
|
510
|
+
*
|
|
511
|
+
* @since 0.1.0
|
|
512
|
+
* @category serving
|
|
513
|
+
*/
|
|
514
|
+
export declare const productionBody: (policy: RobotsPolicy) => string;
|
|
515
|
+
/**
|
|
516
|
+
* The `robots.txt` body for a stage: the production policy when
|
|
517
|
+
* `production`, else {@link NON_PRODUCTION_BODY} (ROBOTS-005). Pure, so the
|
|
518
|
+
* stage branch is unit-testable without a runtime; the caller decides the
|
|
519
|
+
* stage (`PUBLIC_STAGE === "production"`, say) and builds the sitemap URL
|
|
520
|
+
* from the canonical base, never the request origin.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```ts
|
|
524
|
+
* import { NON_PRODUCTION_BODY, robotsBody } from "@thomasfosterau/effect-robots"
|
|
525
|
+
*
|
|
526
|
+
* robotsBody(false, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }) === NON_PRODUCTION_BODY // true
|
|
527
|
+
* robotsBody(true, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }).includes("Sitemap: https://example.com/sitemap.xml") // true
|
|
528
|
+
* ```
|
|
529
|
+
*
|
|
530
|
+
* @since 0.1.0
|
|
531
|
+
* @category serving
|
|
532
|
+
*/
|
|
533
|
+
export declare const robotsBody: (production: boolean, policy: RobotsPolicy) => string;
|
|
534
|
+
/**
|
|
535
|
+
* The `Cache-Control` a low-churn generated document (`robots.txt`,
|
|
536
|
+
* `sitemap.xml`, `feed.json`) is served with: the edge caches the whole
|
|
537
|
+
* document, bounding any request-time enumeration behind it to at most one
|
|
538
|
+
* per hour per edge (PERF-CACHE-002). A mutation that could change the
|
|
539
|
+
* document purges its `Cache-Tag` rather than relying on the TTL alone.
|
|
540
|
+
*
|
|
541
|
+
* @since 0.1.0
|
|
542
|
+
* @category serving
|
|
543
|
+
*/
|
|
544
|
+
export declare const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
545
|
+
/**
|
|
546
|
+
* A guard for the strings the checker treats as sentinels: a real body is
|
|
547
|
+
* neither empty nor the unavailable marker.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```ts
|
|
551
|
+
* import {
|
|
552
|
+
* isRobotsBody,
|
|
553
|
+
* ROBOTS_NO_RESTRICTIONS,
|
|
554
|
+
* ROBOTS_UNAVAILABLE_SENTINEL,
|
|
555
|
+
* } from "@thomasfosterau/effect-robots"
|
|
556
|
+
*
|
|
557
|
+
* isRobotsBody("User-agent: *\nDisallow: /admin") // true
|
|
558
|
+
* isRobotsBody(ROBOTS_NO_RESTRICTIONS) // false
|
|
559
|
+
* isRobotsBody(ROBOTS_UNAVAILABLE_SENTINEL) // false
|
|
560
|
+
* ```
|
|
561
|
+
*
|
|
562
|
+
* @since 0.1.0
|
|
563
|
+
* @category predicates
|
|
564
|
+
*/
|
|
565
|
+
export declare const isRobotsBody: (value: string) => boolean;
|
|
566
|
+
//#endregion
|