@thomasfosterau/effect-robots 0.0.0 → 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/dist/index.d.ts +566 -0
- package/dist/index.js +702 -0
- package/package.json +2 -2
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
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
3
|
+
import * as Duration from "effect/Duration";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Predicate from "effect/Predicate";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import * as SchemaTransformation from "effect/SchemaTransformation";
|
|
9
|
+
import * as Str from "effect/String";
|
|
10
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
11
|
+
//#region src/index.ts
|
|
12
|
+
/**
|
|
13
|
+
* `@thomasfosterau/effect-robots` — the Robots Exclusion Protocol
|
|
14
|
+
* (RFC 9309) for Effect.
|
|
15
|
+
*
|
|
16
|
+
* Three layers, in one entry:
|
|
17
|
+
*
|
|
18
|
+
* 1. **The library**, its API and behaviour held fixed by the verbatim
|
|
19
|
+
* suite in `tests/library.test.ts`: the `Rule` / `RobotGroup` /
|
|
20
|
+
* `RobotsTxt` data classes, the `RobotsTxtFromString` schema,
|
|
21
|
+
* `parse` / `encode`, and the matcher `isAllowed` / `getCrawlDelay` /
|
|
22
|
+
* `getSitemaps`.
|
|
23
|
+
* 2. **The consumer side** a crawler needs: {@link evaluateRobots} (the
|
|
24
|
+
* decision with its diagnostics), {@link fetchRobots} (RFC 9309 §2.3.1's
|
|
25
|
+
* failure posture over an `HttpClient`), {@link RobotsCache} (the
|
|
26
|
+
* pluggable per-origin cache; a persistent adapter, such as Workers KV,
|
|
27
|
+
* lives in the consuming application) and
|
|
28
|
+
* {@link makeRobotsChecker} (the cached `checkRobots` decision).
|
|
29
|
+
* 3. **The serving side**: {@link robotsBody}, the stage-aware `robots.txt`
|
|
30
|
+
* a site serves.
|
|
31
|
+
*
|
|
32
|
+
* Peer: `effect` only.
|
|
33
|
+
*
|
|
34
|
+
* @since 0.1.0
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* A single rule in a robots.txt file: a path pattern and whether it allows.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { Rule } from "@thomasfosterau/effect-robots"
|
|
42
|
+
*
|
|
43
|
+
* const rule = new Rule({ path: "/admin", allow: false })
|
|
44
|
+
* rule.path // "/admin"
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @since 0.1.0
|
|
48
|
+
* @category models
|
|
49
|
+
*/
|
|
50
|
+
var Rule = class extends Data.Class {};
|
|
51
|
+
/**
|
|
52
|
+
* A group of rules for one or more user agents.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { RobotGroup, Rule } from "@thomasfosterau/effect-robots"
|
|
57
|
+
*
|
|
58
|
+
* const group = new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })
|
|
59
|
+
* group.crawlDelay // undefined
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @since 0.1.0
|
|
63
|
+
* @category models
|
|
64
|
+
*/
|
|
65
|
+
var RobotGroup = class extends Data.Class {};
|
|
66
|
+
/**
|
|
67
|
+
* A parsed robots.txt file: its groups and its `Sitemap:` URLs.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { RobotsTxt } from "@thomasfosterau/effect-robots"
|
|
72
|
+
*
|
|
73
|
+
* const robots = new RobotsTxt({ groups: [], sitemaps: ["https://example.com/sitemap.xml"] })
|
|
74
|
+
* robots.sitemaps.length // 1
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @since 0.1.0
|
|
78
|
+
* @category models
|
|
79
|
+
*/
|
|
80
|
+
var RobotsTxt = class extends Data.Class {};
|
|
81
|
+
/**
|
|
82
|
+
* Validates that a value is a {@link Rule} instance (`Schema.instanceOf`).
|
|
83
|
+
*
|
|
84
|
+
* @since 0.1.0
|
|
85
|
+
* @category schemas
|
|
86
|
+
*/
|
|
87
|
+
const RuleSchema = Schema.instanceOf(Rule, { identifier: "Rule" });
|
|
88
|
+
/**
|
|
89
|
+
* Validates that a value is a {@link RobotGroup} instance (`Schema.instanceOf`).
|
|
90
|
+
*
|
|
91
|
+
* @since 0.1.0
|
|
92
|
+
* @category schemas
|
|
93
|
+
*/
|
|
94
|
+
const RobotGroupSchema = Schema.instanceOf(RobotGroup, { identifier: "RobotGroup" });
|
|
95
|
+
/**
|
|
96
|
+
* Validates that a value is a {@link RobotsTxt} instance (`Schema.instanceOf`); use {@link RobotsTxtFromString} to parse text.
|
|
97
|
+
*
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
* @category schemas
|
|
100
|
+
*/
|
|
101
|
+
const RobotsTxtSchema = Schema.instanceOf(RobotsTxt, { identifier: "RobotsTxt" });
|
|
102
|
+
/**
|
|
103
|
+
* Splits a robots.txt line into its directive field and value, dropping
|
|
104
|
+
* comments; `None` for a blank, comment-only or malformed line.
|
|
105
|
+
*/
|
|
106
|
+
const parseDirectiveLine = (rawLine) => {
|
|
107
|
+
const commentIndex = rawLine.indexOf("#");
|
|
108
|
+
const line = (commentIndex >= 0 ? rawLine.slice(0, commentIndex) : rawLine).trim();
|
|
109
|
+
if (Str.isEmpty(line)) return Option.none();
|
|
110
|
+
const colon = line.indexOf(":");
|
|
111
|
+
if (colon < 0) return Option.none();
|
|
112
|
+
return Option.some({
|
|
113
|
+
field: line.slice(0, colon).trim().toLowerCase(),
|
|
114
|
+
value: line.slice(colon + 1).trim()
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
/** Closes the group being accumulated, if it has any directive. */
|
|
118
|
+
const finishGroup = (state) => {
|
|
119
|
+
if (Arr.isReadonlyArrayNonEmpty(state.userAgents) && state.hasDirectives) state.groups.push(new RobotGroup({
|
|
120
|
+
userAgents: state.userAgents,
|
|
121
|
+
rules: state.rules,
|
|
122
|
+
crawlDelay: state.crawlDelay
|
|
123
|
+
}));
|
|
124
|
+
if (state.hasDirectives) {
|
|
125
|
+
state.userAgents = [];
|
|
126
|
+
state.rules = [];
|
|
127
|
+
state.crawlDelay = void 0;
|
|
128
|
+
state.hasDirectives = false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/** Applies one directive to the parser state (RFC 9309 group rules). */
|
|
132
|
+
const applyDirective = (state, { field, value }) => {
|
|
133
|
+
if (Str.isEmpty(value)) return;
|
|
134
|
+
if (field === "user-agent") {
|
|
135
|
+
if (state.hasDirectives) finishGroup(state);
|
|
136
|
+
state.userAgents.push(value);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (field === "sitemap") {
|
|
140
|
+
state.sitemaps.push(value);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (field === "disallow" || field === "allow") {
|
|
144
|
+
state.rules.push(new Rule({
|
|
145
|
+
path: value,
|
|
146
|
+
allow: field === "allow"
|
|
147
|
+
}));
|
|
148
|
+
state.hasDirectives = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (field === "crawl-delay") {
|
|
152
|
+
const delay = Number.parseFloat(value);
|
|
153
|
+
if (!Number.isNaN(delay) && delay >= 0) {
|
|
154
|
+
state.crawlDelay = delay;
|
|
155
|
+
state.hasDirectives = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Parses robots.txt content per RFC 9309: consecutive `User-agent:` lines
|
|
161
|
+
* share one group; rules belong to the most recent group; `#` starts a
|
|
162
|
+
* comment; unknown directives are ignored; a group with no directive is
|
|
163
|
+
* dropped.
|
|
164
|
+
*/
|
|
165
|
+
const parseRobotsTxt = (content) => {
|
|
166
|
+
const state = {
|
|
167
|
+
groups: [],
|
|
168
|
+
sitemaps: [],
|
|
169
|
+
userAgents: [],
|
|
170
|
+
rules: [],
|
|
171
|
+
crawlDelay: void 0,
|
|
172
|
+
hasDirectives: false
|
|
173
|
+
};
|
|
174
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
175
|
+
const directive = parseDirectiveLine(rawLine);
|
|
176
|
+
if (Option.isSome(directive)) applyDirective(state, directive.value);
|
|
177
|
+
}
|
|
178
|
+
finishGroup(state);
|
|
179
|
+
return new RobotsTxt({
|
|
180
|
+
groups: state.groups,
|
|
181
|
+
sitemaps: state.sitemaps
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
/** Encodes a group as its `User-agent` / rule / `Crawl-delay` lines. */
|
|
185
|
+
const encodeGroup = (group) => [
|
|
186
|
+
...Arr.map(group.userAgents, (userAgent) => `User-agent: ${userAgent}`),
|
|
187
|
+
...Arr.map(group.rules, (rule) => `${rule.allow ? "Allow" : "Disallow"}: ${rule.path}`),
|
|
188
|
+
...group.crawlDelay === void 0 ? [] : [`Crawl-delay: ${group.crawlDelay}`],
|
|
189
|
+
""
|
|
190
|
+
];
|
|
191
|
+
/** Encodes a `RobotsTxt` as text: each group, a blank line, then the sitemaps. */
|
|
192
|
+
const encodeRobotsTxt = (robotsTxt) => [...Arr.flatMap(robotsTxt.groups, encodeGroup), ...Arr.map(robotsTxt.sitemaps, (sitemap) => `Sitemap: ${sitemap}`)].join("\n");
|
|
193
|
+
/**
|
|
194
|
+
* The schema between robots.txt text and {@link RobotsTxt}: decoding parses
|
|
195
|
+
* (RFC 9309 grouping, comments, unknown directives ignored), encoding
|
|
196
|
+
* serialises.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* import * as Schema from "effect/Schema"
|
|
201
|
+
* import { RobotsTxtFromString } from "@thomasfosterau/effect-robots"
|
|
202
|
+
*
|
|
203
|
+
* const robots = Schema.decodeUnknownSync(RobotsTxtFromString)("User-agent: *\nDisallow: /admin")
|
|
204
|
+
* robots.groups.length // 1
|
|
205
|
+
* Schema.encodeSync(RobotsTxtFromString)(robots) // "User-agent: *\nDisallow: /admin\n"
|
|
206
|
+
* ```
|
|
207
|
+
*
|
|
208
|
+
* @since 0.1.0
|
|
209
|
+
* @category schemas
|
|
210
|
+
*/
|
|
211
|
+
const RobotsTxtFromString = Schema.String.pipe(Schema.decodeTo(RobotsTxtSchema, SchemaTransformation.transform({
|
|
212
|
+
decode: parseRobotsTxt,
|
|
213
|
+
encode: encodeRobotsTxt
|
|
214
|
+
})));
|
|
215
|
+
/**
|
|
216
|
+
* Parses robots.txt content into a {@link RobotsTxt} (synchronous).
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* import { parse } from "@thomasfosterau/effect-robots"
|
|
221
|
+
*
|
|
222
|
+
* const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public\n\nSitemap: https://example.com/sitemap.xml")
|
|
223
|
+
* robots.groups[0]?.rules.length // 2
|
|
224
|
+
* robots.sitemaps // ["https://example.com/sitemap.xml"]
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @since 0.1.0
|
|
228
|
+
* @category parsing
|
|
229
|
+
*/
|
|
230
|
+
const parse = (content) => Schema.decodeUnknownSync(RobotsTxtFromString)(content);
|
|
231
|
+
/**
|
|
232
|
+
* Encodes a {@link RobotsTxt} back to robots.txt text (synchronous).
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```ts
|
|
236
|
+
* import { encode, RobotGroup, RobotsTxt, Rule } from "@thomasfosterau/effect-robots"
|
|
237
|
+
*
|
|
238
|
+
* encode(new RobotsTxt({
|
|
239
|
+
* groups: [new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })],
|
|
240
|
+
* sitemaps: ["https://example.com/sitemap.xml"],
|
|
241
|
+
* }))
|
|
242
|
+
* // "User-agent: *\nDisallow: /admin\n\nSitemap: https://example.com/sitemap.xml"
|
|
243
|
+
* ```
|
|
244
|
+
*
|
|
245
|
+
* @since 0.1.0
|
|
246
|
+
* @category parsing
|
|
247
|
+
*/
|
|
248
|
+
const encode = (robotsTxt) => Schema.encodeSync(RobotsTxtFromString)(robotsTxt);
|
|
249
|
+
/** Escapes regex metacharacters (not `*`, expanded separately). */
|
|
250
|
+
const escapeRegex = (value) => value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
251
|
+
/**
|
|
252
|
+
* Whether a rule path matches a URL path: `*` matches any sequence, a
|
|
253
|
+
* trailing `$` anchors the end (RFC 9309 §2.2.3). An empty path and `/`
|
|
254
|
+
* match everything.
|
|
255
|
+
*/
|
|
256
|
+
const matchesPath = (rulePath, urlPath) => {
|
|
257
|
+
if (rulePath === "" || rulePath === "/") return true;
|
|
258
|
+
const anchored = rulePath.endsWith("$");
|
|
259
|
+
const body = (anchored ? rulePath.slice(0, -1) : rulePath).split("*").map(escapeRegex).join(".*");
|
|
260
|
+
return new RegExp(`^${body}${anchored ? "$" : ""}`).test(urlPath);
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* The specificity of a group's claim on a user agent: the length of its
|
|
264
|
+
* longest matching product token (case-insensitive, exact or prefix), `0`
|
|
265
|
+
* for the `*` wildcard, `-1` for no match (RFC 9309 §2.2.1).
|
|
266
|
+
*/
|
|
267
|
+
const groupSpecificity = (group, normalizedUserAgent) => Arr.reduce(group.userAgents, -1, (specificity, userAgent) => {
|
|
268
|
+
const pattern = userAgent.toLowerCase();
|
|
269
|
+
if (pattern === "*") return Math.max(specificity, 0);
|
|
270
|
+
if (normalizedUserAgent.startsWith(pattern)) return Math.max(specificity, pattern.length);
|
|
271
|
+
return specificity;
|
|
272
|
+
});
|
|
273
|
+
/**
|
|
274
|
+
* The group whose user-agent token most specifically matches — the
|
|
275
|
+
* longest matching token wins, ties keep the first group.
|
|
276
|
+
*/
|
|
277
|
+
const findMatchingGroup = (robotsTxt, userAgent) => {
|
|
278
|
+
const normalized = userAgent.toLowerCase();
|
|
279
|
+
return Arr.reduce(robotsTxt.groups, {
|
|
280
|
+
group: Option.none(),
|
|
281
|
+
specificity: -1
|
|
282
|
+
}, (best, group) => {
|
|
283
|
+
const specificity = groupSpecificity(group, normalized);
|
|
284
|
+
return specificity > best.specificity ? {
|
|
285
|
+
group: Option.some(group),
|
|
286
|
+
specificity
|
|
287
|
+
} : best;
|
|
288
|
+
}).group;
|
|
289
|
+
};
|
|
290
|
+
/** True when `rule` takes precedence over `incumbent` for the same path. */
|
|
291
|
+
const outranks = (rule, incumbent) => Option.isNone(incumbent) || rule.path.length > incumbent.value.path.length || rule.path.length === incumbent.value.path.length && rule.allow && !incumbent.value.allow;
|
|
292
|
+
/** The winning rule for `path`: longest match wins, Allow wins ties (RFC 9309 §2.2.2). */
|
|
293
|
+
const findMostSpecificRule = (rules, path) => Arr.reduce(rules, Option.none(), (best, rule) => matchesPath(rule.path, path) && outranks(rule, best) ? Option.some(rule) : best);
|
|
294
|
+
/** The path without query or fragment, made absolute. */
|
|
295
|
+
const normalizePath = (path) => {
|
|
296
|
+
const bare = path.split("?")[0]?.split("#")[0] ?? "";
|
|
297
|
+
return bare.startsWith("/") ? bare : `/${bare}`;
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Whether `userAgent` may access `urlPath` under the rules: the most
|
|
301
|
+
* specific matching group, then its longest matching rule, Allow winning
|
|
302
|
+
* ties; no matching group or rule means allowed. The query string and
|
|
303
|
+
* fragment are stripped before matching (the library's documented
|
|
304
|
+
* behaviour; {@link evaluateRobots} matches the query too, as RFC 9309
|
|
305
|
+
* §2.2.2 does).
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* import { isAllowed, parse } from "@thomasfosterau/effect-robots"
|
|
310
|
+
*
|
|
311
|
+
* const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public")
|
|
312
|
+
* isAllowed(robots, "MyBot", "/admin") // false
|
|
313
|
+
* isAllowed(robots, "MyBot", "/admin/public") // true
|
|
314
|
+
* isAllowed(robots, "MyBot", "/public") // true
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* @since 0.1.0
|
|
318
|
+
* @category matching
|
|
319
|
+
*/
|
|
320
|
+
const isAllowed = (robotsTxt, userAgent, urlPath) => {
|
|
321
|
+
const group = findMatchingGroup(robotsTxt, userAgent);
|
|
322
|
+
if (Option.isNone(group)) return true;
|
|
323
|
+
const rule = findMostSpecificRule(group.value.rules, normalizePath(urlPath));
|
|
324
|
+
return Option.isNone(rule) ? true : rule.value.allow;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* The crawl delay (seconds) for `userAgent`, from its matching group.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* import { getCrawlDelay, parse } from "@thomasfosterau/effect-robots"
|
|
332
|
+
*
|
|
333
|
+
* const robots = parse("User-agent: Googlebot\nCrawl-delay: 5\nDisallow: /x\n\nUser-agent: *\nCrawl-delay: 10\nDisallow: /y")
|
|
334
|
+
* getCrawlDelay(robots, "Googlebot") // 5
|
|
335
|
+
* getCrawlDelay(robots, "OtherBot") // 10
|
|
336
|
+
* ```
|
|
337
|
+
*
|
|
338
|
+
* @since 0.1.0
|
|
339
|
+
* @category matching
|
|
340
|
+
*/
|
|
341
|
+
const getCrawlDelay = (robotsTxt, userAgent) => Option.getOrUndefined(Option.map(findMatchingGroup(robotsTxt, userAgent), (group) => group.crawlDelay));
|
|
342
|
+
/**
|
|
343
|
+
* Every `Sitemap:` URL in the file.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* import { getSitemaps, parse } from "@thomasfosterau/effect-robots"
|
|
348
|
+
*
|
|
349
|
+
* getSitemaps(parse("Sitemap: https://example.com/sitemap.xml")) // ["https://example.com/sitemap.xml"]
|
|
350
|
+
* ```
|
|
351
|
+
*
|
|
352
|
+
* @since 0.1.0
|
|
353
|
+
* @category matching
|
|
354
|
+
*/
|
|
355
|
+
const getSitemaps = (robotsTxt) => robotsTxt.sitemaps;
|
|
356
|
+
/**
|
|
357
|
+
* Upper bound applied to `Crawl-delay` so a hostile value cannot stall a
|
|
358
|
+
* batch.
|
|
359
|
+
*
|
|
360
|
+
* @since 0.1.0
|
|
361
|
+
* @category constants
|
|
362
|
+
*/
|
|
363
|
+
const MAX_CRAWL_DELAY_SECONDS = 30;
|
|
364
|
+
/**
|
|
365
|
+
* Evaluates a parsed robots.txt for a user-agent token and a path — the
|
|
366
|
+
* crawlers' decision, with its diagnostics. Group selection: the longest
|
|
367
|
+
* matching product token wins, the `*` group is the fallback, none means
|
|
368
|
+
* allowed. Rule precedence: longest matching pattern wins, Allow wins ties.
|
|
369
|
+
* Unlike {@link isAllowed}, the path is matched as given, query included
|
|
370
|
+
* (RFC 9309 §2.2.2), and only a fragment is stripped. The crawl delay is
|
|
371
|
+
* capped and a non-positive one is dropped.
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```ts
|
|
375
|
+
* import * as Option from "effect/Option"
|
|
376
|
+
* import { evaluateRobots, parse } from "@thomasfosterau/effect-robots"
|
|
377
|
+
*
|
|
378
|
+
* const robots = parse("User-agent: *\nCrawl-delay: 3600\nDisallow: /private/\nAllow: /private/public/")
|
|
379
|
+
* evaluateRobots(robots, "mybot", "/private/secret")
|
|
380
|
+
* // { allowed: false, matchedPattern: Option.some("/private/"), crawlDelaySeconds: Option.some(30) }
|
|
381
|
+
* evaluateRobots(robots, "mybot", "/private/public/page").allowed // true
|
|
382
|
+
* ```
|
|
383
|
+
*
|
|
384
|
+
* @since 0.1.0
|
|
385
|
+
* @category evaluation
|
|
386
|
+
*/
|
|
387
|
+
const evaluateRobots = (robotsTxt, userAgentToken, path, options = {}) => {
|
|
388
|
+
const group = findMatchingGroup(robotsTxt, userAgentToken);
|
|
389
|
+
if (Option.isNone(group)) return {
|
|
390
|
+
allowed: true,
|
|
391
|
+
matchedPattern: Option.none(),
|
|
392
|
+
crawlDelaySeconds: Option.none()
|
|
393
|
+
};
|
|
394
|
+
const cap = options.maxCrawlDelaySeconds ?? 30;
|
|
395
|
+
const rule = findMostSpecificRule(group.value.rules, path.split("#")[0] ?? "");
|
|
396
|
+
return {
|
|
397
|
+
allowed: Option.isNone(rule) ? true : rule.value.allow,
|
|
398
|
+
matchedPattern: Option.map(rule, (matched) => matched.path),
|
|
399
|
+
crawlDelaySeconds: Option.filter(Option.fromUndefinedOr(group.value.crawlDelay), (delay) => delay > 0).pipe(Option.map((delay) => Math.min(delay, cap)))
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* robots.txt responses are capped at this many bytes (RFC 9309 §2.5).
|
|
404
|
+
*
|
|
405
|
+
* @since 0.1.0
|
|
406
|
+
* @category constants
|
|
407
|
+
*/
|
|
408
|
+
const MAX_ROBOTS_BYTES = 512e3;
|
|
409
|
+
/**
|
|
410
|
+
* Per-origin cache TTL for a fetched body or a 4xx (24 hours): a directive
|
|
411
|
+
* change is honoured within a day.
|
|
412
|
+
*
|
|
413
|
+
* @since 0.1.0
|
|
414
|
+
* @category constants
|
|
415
|
+
*/
|
|
416
|
+
const ROBOTS_CACHE_TTL_SECONDS = 86400;
|
|
417
|
+
/**
|
|
418
|
+
* Cache TTL for an unavailable robots.txt (one hour), so a misbehaving
|
|
419
|
+
* origin is not probed on every capture.
|
|
420
|
+
*
|
|
421
|
+
* @since 0.1.0
|
|
422
|
+
* @category constants
|
|
423
|
+
*/
|
|
424
|
+
const ROBOTS_UNAVAILABLE_TTL_SECONDS = 3600;
|
|
425
|
+
/**
|
|
426
|
+
* The cache value for a robots.txt that could not be read (5xx, transport
|
|
427
|
+
* failure, timeout). The leading character is a deliberate literal NUL
|
|
428
|
+
* byte — it can never collide with a real body. Written as the `\0` escape
|
|
429
|
+
* rather than an embedded raw NUL byte: same runtime string, but a raw NUL
|
|
430
|
+
* in the source makes tools that binary-sniff stop treating the file as
|
|
431
|
+
* text. Changing the value orphans entries already in a persistent cache.
|
|
432
|
+
*
|
|
433
|
+
* @since 0.1.0
|
|
434
|
+
* @category constants
|
|
435
|
+
*/
|
|
436
|
+
const ROBOTS_UNAVAILABLE_SENTINEL = "\0__robots_unavailable__";
|
|
437
|
+
/**
|
|
438
|
+
* The cache value for an origin with no restrictions (a 4xx, 404 included).
|
|
439
|
+
*
|
|
440
|
+
* @since 0.1.0
|
|
441
|
+
* @category constants
|
|
442
|
+
*/
|
|
443
|
+
const ROBOTS_NO_RESTRICTIONS = "";
|
|
444
|
+
/**
|
|
445
|
+
* A cache that stores nothing: every check fetches. The default cache of
|
|
446
|
+
* {@link makeRobotsChecker}.
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* ```ts
|
|
450
|
+
* import * as Effect from "effect/Effect"
|
|
451
|
+
* import * as Option from "effect/Option"
|
|
452
|
+
* import { noRobotsCache } from "@thomasfosterau/effect-robots"
|
|
453
|
+
*
|
|
454
|
+
* await Effect.runPromise(noRobotsCache.put("https://example.com", "User-agent: *", 60))
|
|
455
|
+
* Option.isNone(await Effect.runPromise(noRobotsCache.get("https://example.com"))) // true
|
|
456
|
+
* ```
|
|
457
|
+
*
|
|
458
|
+
* @since 0.1.0
|
|
459
|
+
* @category caches
|
|
460
|
+
*/
|
|
461
|
+
const noRobotsCache = {
|
|
462
|
+
get: () => Effect.succeedNone,
|
|
463
|
+
put: () => Effect.void
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* An in-memory cache (a `Map`), for tests and single-process crawlers. TTLs
|
|
467
|
+
* are recorded, not enforced.
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```ts
|
|
471
|
+
* import * as Effect from "effect/Effect"
|
|
472
|
+
* import { memoryRobotsCache } from "@thomasfosterau/effect-robots"
|
|
473
|
+
*
|
|
474
|
+
* const cache = memoryRobotsCache()
|
|
475
|
+
* await Effect.runPromise(cache.put("https://example.com", "User-agent: *\nDisallow: /", 60))
|
|
476
|
+
* cache.entries.get("https://example.com")?.ttlSeconds // 60
|
|
477
|
+
* ```
|
|
478
|
+
*
|
|
479
|
+
* @since 0.1.0
|
|
480
|
+
* @category caches
|
|
481
|
+
*/
|
|
482
|
+
const memoryRobotsCache = () => {
|
|
483
|
+
const entries = /* @__PURE__ */ new Map();
|
|
484
|
+
return {
|
|
485
|
+
entries,
|
|
486
|
+
get: (origin) => Effect.sync(() => Option.map(Option.fromNullishOr(entries.get(origin)), (entry) => entry.value)),
|
|
487
|
+
put: (origin, value, ttlSeconds) => Effect.sync(() => {
|
|
488
|
+
entries.set(origin, {
|
|
489
|
+
value,
|
|
490
|
+
ttlSeconds
|
|
491
|
+
});
|
|
492
|
+
})
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* Fetches `<origin>/robots.txt` through the `HttpClient` in context and
|
|
497
|
+
* folds the outcome into the cache value with RFC 9309 §2.3.1's posture: a
|
|
498
|
+
* 2xx body (capped), {@link ROBOTS_NO_RESTRICTIONS} for a 4xx (404
|
|
499
|
+
* included), and {@link ROBOTS_UNAVAILABLE_SENTINEL} for a 5xx, a transport
|
|
500
|
+
* failure, a timeout or a body that cannot be read — the crawler must
|
|
501
|
+
* assume it is not permitted until it can read the rules. Never fails.
|
|
502
|
+
*
|
|
503
|
+
* Redirect policy is the client's: provide one whose redirects are
|
|
504
|
+
* revalidated against the SSRF block list (`@thomasfosterau/effect-web/net`).
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```ts
|
|
508
|
+
* import * as Effect from "effect/Effect"
|
|
509
|
+
* import * as HttpClient from "effect/http/HttpClient"
|
|
510
|
+
* import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
511
|
+
* import { fetchRobots, ROBOTS_NO_RESTRICTIONS } from "@thomasfosterau/effect-robots"
|
|
512
|
+
*
|
|
513
|
+
* const notFound = HttpClient.make((request) =>
|
|
514
|
+
* Effect.succeed(HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))),
|
|
515
|
+
* )
|
|
516
|
+
* await Effect.runPromise(
|
|
517
|
+
* fetchRobots("https://example.com", { userAgent: "mybot/1.0" }).pipe(
|
|
518
|
+
* Effect.provideService(HttpClient.HttpClient, notFound),
|
|
519
|
+
* ),
|
|
520
|
+
* ) === ROBOTS_NO_RESTRICTIONS // true
|
|
521
|
+
* ```
|
|
522
|
+
*
|
|
523
|
+
* @since 0.1.0
|
|
524
|
+
* @category fetching
|
|
525
|
+
*/
|
|
526
|
+
const fetchRobots = (origin, options) => Effect.gen(function* () {
|
|
527
|
+
const response = yield* (yield* HttpClient.HttpClient).get(`${origin}/robots.txt`, { headers: {
|
|
528
|
+
"user-agent": options.userAgent,
|
|
529
|
+
accept: "text/plain,*/*;q=0.5"
|
|
530
|
+
} });
|
|
531
|
+
if (response.status >= 200 && response.status < 300) return (yield* response.text).slice(0, options.maxBytes ?? 512e3);
|
|
532
|
+
return response.status >= 500 ? ROBOTS_UNAVAILABLE_SENTINEL : "";
|
|
533
|
+
}).pipe(Effect.timeout(options.timeout ?? Duration.seconds(10)), Effect.orElseSucceed(() => ROBOTS_UNAVAILABLE_SENTINEL));
|
|
534
|
+
/**
|
|
535
|
+
* The reason a disallowed decision carries for an unavailable robots.txt.
|
|
536
|
+
*
|
|
537
|
+
* @since 0.1.0
|
|
538
|
+
* @category constants
|
|
539
|
+
*/
|
|
540
|
+
const ROBOTS_UNAVAILABLE_REASON = "robots.txt unavailable (server error) — treating as disallow";
|
|
541
|
+
/**
|
|
542
|
+
* Builds a crawler's `checkRobots`: whether it may fetch `url` under the
|
|
543
|
+
* origin's robots.txt, reading the origin's body through the cache and
|
|
544
|
+
* fetching on a miss ({@link fetchRobots}), then {@link evaluateRobots}
|
|
545
|
+
* for the crawler's token. Never fails — robots problems surface as
|
|
546
|
+
* `allowed: false` decisions, and cache problems are the adapter's to
|
|
547
|
+
* degrade. The fetch outcome is cached with the TTL its posture warrants
|
|
548
|
+
* (unavailability shorter), and a failed cache write is not a failed
|
|
549
|
+
* decision.
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* ```ts
|
|
553
|
+
* import * as Effect from "effect/Effect"
|
|
554
|
+
* import * as HttpClient from "effect/http/HttpClient"
|
|
555
|
+
* import * as HttpClientResponse from "effect/http/HttpClientResponse"
|
|
556
|
+
* import { makeRobotsChecker, memoryRobotsCache } from "@thomasfosterau/effect-robots"
|
|
557
|
+
*
|
|
558
|
+
* const site = HttpClient.make((request) =>
|
|
559
|
+
* Effect.succeed(HttpClientResponse.fromWeb(request, new Response("User-agent: *\nDisallow: /secret/", { status: 200 }))),
|
|
560
|
+
* )
|
|
561
|
+
* const checkRobots = makeRobotsChecker({
|
|
562
|
+
* userAgent: "mybot/1.0 (+https://example.com)",
|
|
563
|
+
* userAgentToken: "mybot",
|
|
564
|
+
* cache: memoryRobotsCache(),
|
|
565
|
+
* })
|
|
566
|
+
* const decision = await Effect.runPromise(
|
|
567
|
+
* checkRobots("https://origin.example/secret/x").pipe(Effect.provideService(HttpClient.HttpClient, site)),
|
|
568
|
+
* )
|
|
569
|
+
* decision // { allowed: false, reason: "disallowed by robots.txt rule '/secret/'" }
|
|
570
|
+
* ```
|
|
571
|
+
*
|
|
572
|
+
* @since 0.1.0
|
|
573
|
+
* @category checking
|
|
574
|
+
*/
|
|
575
|
+
const makeRobotsChecker = (options) => {
|
|
576
|
+
const cache = options.cache ?? noRobotsCache;
|
|
577
|
+
const loadRobots = (origin) => Effect.gen(function* () {
|
|
578
|
+
const cached = yield* cache.get(origin);
|
|
579
|
+
if (Option.isSome(cached)) return cached.value;
|
|
580
|
+
const fetched = yield* fetchRobots(origin, options);
|
|
581
|
+
const ttl = fetched === "\0__robots_unavailable__" ? options.unavailableTtlSeconds ?? 3600 : options.cacheTtlSeconds ?? 86400;
|
|
582
|
+
yield* cache.put(origin, fetched, ttl);
|
|
583
|
+
return fetched;
|
|
584
|
+
});
|
|
585
|
+
return (url) => Effect.gen(function* () {
|
|
586
|
+
const parsed = new URL(url);
|
|
587
|
+
const body = yield* loadRobots(parsed.origin);
|
|
588
|
+
if (body === "\0__robots_unavailable__") return {
|
|
589
|
+
allowed: false,
|
|
590
|
+
reason: ROBOTS_UNAVAILABLE_REASON
|
|
591
|
+
};
|
|
592
|
+
if (body === "") return {
|
|
593
|
+
allowed: true,
|
|
594
|
+
crawlDelaySeconds: Option.none()
|
|
595
|
+
};
|
|
596
|
+
const path = options.matchQuery === false ? parsed.pathname : parsed.pathname + parsed.search;
|
|
597
|
+
const evaluation = evaluateRobots(parse(body), options.userAgentToken, path, options);
|
|
598
|
+
if (!evaluation.allowed) return {
|
|
599
|
+
allowed: false,
|
|
600
|
+
reason: `disallowed by robots.txt rule '${Option.getOrElse(evaluation.matchedPattern, () => "")}'`
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
allowed: true,
|
|
604
|
+
crawlDelaySeconds: evaluation.crawlDelaySeconds
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* The non-production body: a full disallow, no sitemap (ROBOTS-005), so a
|
|
610
|
+
* `dev-<branch>` / `ci-<run>` / PR-preview deployment is never crawled.
|
|
611
|
+
*
|
|
612
|
+
* @since 0.1.0
|
|
613
|
+
* @category serving
|
|
614
|
+
*/
|
|
615
|
+
const NON_PRODUCTION_BODY = [
|
|
616
|
+
"User-agent: *",
|
|
617
|
+
"Disallow: /",
|
|
618
|
+
""
|
|
619
|
+
].join("\n");
|
|
620
|
+
/**
|
|
621
|
+
* The production body: open to all crawlers (`User-agent: *`, AI crawlers
|
|
622
|
+
* included), the hygiene disallows, then the absolute sitemap URL
|
|
623
|
+
* (ROBOTS-002 … -004).
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* ```ts
|
|
627
|
+
* import { productionBody } from "@thomasfosterau/effect-robots"
|
|
628
|
+
*
|
|
629
|
+
* productionBody({ disallow: ["/login", "/*\/edit"], sitemapUrl: "https://example.com/sitemap.xml", comments: ["All crawlers welcome."] })
|
|
630
|
+
* // "# All crawlers welcome.\nUser-agent: *\nDisallow: /login\nDisallow: /*\/edit\n\nSitemap: https://example.com/sitemap.xml\n"
|
|
631
|
+
* ```
|
|
632
|
+
*
|
|
633
|
+
* @since 0.1.0
|
|
634
|
+
* @category serving
|
|
635
|
+
*/
|
|
636
|
+
const productionBody = (policy) => [
|
|
637
|
+
...Arr.map(policy.comments ?? [], (comment) => `# ${comment}`),
|
|
638
|
+
encode(new RobotsTxt({
|
|
639
|
+
groups: [new RobotGroup({
|
|
640
|
+
userAgents: ["*"],
|
|
641
|
+
rules: Arr.map(policy.disallow, (path) => new Rule({
|
|
642
|
+
path,
|
|
643
|
+
allow: false
|
|
644
|
+
}))
|
|
645
|
+
})],
|
|
646
|
+
sitemaps: [policy.sitemapUrl]
|
|
647
|
+
})),
|
|
648
|
+
""
|
|
649
|
+
].join("\n");
|
|
650
|
+
/**
|
|
651
|
+
* The `robots.txt` body for a stage: the production policy when
|
|
652
|
+
* `production`, else {@link NON_PRODUCTION_BODY} (ROBOTS-005). Pure, so the
|
|
653
|
+
* stage branch is unit-testable without a runtime; the caller decides the
|
|
654
|
+
* stage (`PUBLIC_STAGE === "production"`, say) and builds the sitemap URL
|
|
655
|
+
* from the canonical base, never the request origin.
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* ```ts
|
|
659
|
+
* import { NON_PRODUCTION_BODY, robotsBody } from "@thomasfosterau/effect-robots"
|
|
660
|
+
*
|
|
661
|
+
* robotsBody(false, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }) === NON_PRODUCTION_BODY // true
|
|
662
|
+
* robotsBody(true, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }).includes("Sitemap: https://example.com/sitemap.xml") // true
|
|
663
|
+
* ```
|
|
664
|
+
*
|
|
665
|
+
* @since 0.1.0
|
|
666
|
+
* @category serving
|
|
667
|
+
*/
|
|
668
|
+
const robotsBody = (production, policy) => production ? productionBody(policy) : NON_PRODUCTION_BODY;
|
|
669
|
+
/**
|
|
670
|
+
* The `Cache-Control` a low-churn generated document (`robots.txt`,
|
|
671
|
+
* `sitemap.xml`, `feed.json`) is served with: the edge caches the whole
|
|
672
|
+
* document, bounding any request-time enumeration behind it to at most one
|
|
673
|
+
* per hour per edge (PERF-CACHE-002). A mutation that could change the
|
|
674
|
+
* document purges its `Cache-Tag` rather than relying on the TTL alone.
|
|
675
|
+
*
|
|
676
|
+
* @since 0.1.0
|
|
677
|
+
* @category serving
|
|
678
|
+
*/
|
|
679
|
+
const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
680
|
+
/**
|
|
681
|
+
* A guard for the strings the checker treats as sentinels: a real body is
|
|
682
|
+
* neither empty nor the unavailable marker.
|
|
683
|
+
*
|
|
684
|
+
* @example
|
|
685
|
+
* ```ts
|
|
686
|
+
* import {
|
|
687
|
+
* isRobotsBody,
|
|
688
|
+
* ROBOTS_NO_RESTRICTIONS,
|
|
689
|
+
* ROBOTS_UNAVAILABLE_SENTINEL,
|
|
690
|
+
* } from "@thomasfosterau/effect-robots"
|
|
691
|
+
*
|
|
692
|
+
* isRobotsBody("User-agent: *\nDisallow: /admin") // true
|
|
693
|
+
* isRobotsBody(ROBOTS_NO_RESTRICTIONS) // false
|
|
694
|
+
* isRobotsBody(ROBOTS_UNAVAILABLE_SENTINEL) // false
|
|
695
|
+
* ```
|
|
696
|
+
*
|
|
697
|
+
* @since 0.1.0
|
|
698
|
+
* @category predicates
|
|
699
|
+
*/
|
|
700
|
+
const isRobotsBody = (value) => Predicate.isString(value) && value !== "" && value !== "\0__robots_unavailable__";
|
|
701
|
+
//#endregion
|
|
702
|
+
export { LOW_CHURN_CACHE_CONTROL, MAX_CRAWL_DELAY_SECONDS, MAX_ROBOTS_BYTES, NON_PRODUCTION_BODY, ROBOTS_CACHE_TTL_SECONDS, ROBOTS_NO_RESTRICTIONS, ROBOTS_UNAVAILABLE_REASON, ROBOTS_UNAVAILABLE_SENTINEL, ROBOTS_UNAVAILABLE_TTL_SECONDS, RobotGroup, RobotGroupSchema, RobotsTxt, RobotsTxtFromString, RobotsTxtSchema, Rule, RuleSchema, encode, evaluateRobots, fetchRobots, getCrawlDelay, getSitemaps, isAllowed, isRobotsBody, makeRobotsChecker, memoryRobotsCache, noRobotsCache, parse, productionBody, robotsBody };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package",
|
|
3
3
|
"name": "@thomasfosterau/effect-robots",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"description": "The Robots Exclusion Protocol (RFC 9309) for Effect: parse, encode and match robots.txt, evaluate a crawl decision with the §2.3.1 failure posture over a pluggable cache, and build the robots.txt a site serves.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"effect",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@effect/language-service": "^0.87.4",
|
|
44
|
-
"@types/node": "^26.6.
|
|
44
|
+
"@types/node": "^26.6.5",
|
|
45
45
|
"@vitest/coverage-istanbul": "4.1.11",
|
|
46
46
|
"effect": "4.0.3"
|
|
47
47
|
},
|