@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.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 };
|