@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thomasfosterau/effect-robots
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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. At its core is the **library**, whose API and behaviour are held stable by its acceptance suite; on top of it sit the **crawler side** (fetch, cache and evaluate another site's robots.txt) and the **serving side** (the robots.txt your own site serves, by stage).
|
|
4
|
+
|
|
5
|
+
Invariant ids in doc comments and below (`ROBOTS-005`, `PERF-CACHE-002`, …) name the rule an export enforces; [`SECURITY.md`](https://github.com/thomasfosterau/effect-web/blob/main/SECURITY.md#invariant-ids) states each rule and the exports that enforce it.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @thomasfosterau/effect-robots effect
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer: `effect` (>= 4.0.0-rc.118). One entry, `sideEffects: false`, no top-level statements in `dist/`.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### The library
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import {
|
|
21
|
+
encode,
|
|
22
|
+
getCrawlDelay,
|
|
23
|
+
getSitemaps,
|
|
24
|
+
isAllowed,
|
|
25
|
+
parse,
|
|
26
|
+
} from "@thomasfosterau/effect-robots";
|
|
27
|
+
|
|
28
|
+
const robotsTxt = parse(`
|
|
29
|
+
User-agent: *
|
|
30
|
+
Disallow: /admin
|
|
31
|
+
Allow: /admin/public
|
|
32
|
+
Crawl-delay: 10
|
|
33
|
+
|
|
34
|
+
Sitemap: https://example.com/sitemap.xml
|
|
35
|
+
`);
|
|
36
|
+
|
|
37
|
+
isAllowed(robotsTxt, "MyBot", "/admin"); // false
|
|
38
|
+
isAllowed(robotsTxt, "MyBot", "/admin/public"); // true
|
|
39
|
+
getCrawlDelay(robotsTxt, "MyBot"); // 10
|
|
40
|
+
getSitemaps(robotsTxt); // ["https://example.com/sitemap.xml"]
|
|
41
|
+
encode(robotsTxt); // the canonical text
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Export | What it is |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `Rule`, `RobotGroup`, `RobotsTxt` | `Data.Class` values with structural equality. `RobotGroup.crawlDelay` is `number \| undefined`. |
|
|
47
|
+
| `RuleSchema`, `RobotGroupSchema`, `RobotsTxtSchema` | `Schema.instanceOf` of each class. |
|
|
48
|
+
| `RobotsTxtFromString` | `Schema.String` → `RobotsTxt`: the parser is the decode, the encoder the encode. `Schema.decodeUnknownEffect(RobotsTxtFromString)(text)` for the Effect form. |
|
|
49
|
+
| `parse(text)`, `encode(robotsTxt)` | The synchronous forms. `parse` never throws: unknown lines are ignored, rules before any `User-agent` are dropped, `#` starts a comment, CRLF is accepted. |
|
|
50
|
+
| `isAllowed(robotsTxt, userAgent, path)` | Group selection by the longest matching product token (exact or prefix, case-insensitive; `*` matches everything, last); rule selection by the longest matching pattern, `Allow` winning a tie; `*` wildcards and `$` anchors; a query string or fragment on `path` is stripped. No group or no matching rule means allowed. |
|
|
51
|
+
| `getCrawlDelay`, `getSitemaps` | The selected group's `Crawl-delay` as an `Option`; every `Sitemap:` line. |
|
|
52
|
+
|
|
53
|
+
Group selection follows RFC 9309 §2.2.1: the **product token** matches exactly or as a prefix, never as a **substring** (`userAgent.includes(token)`). A `User-agent: Google` group binds `Googlebot`; a `User-agent: bot` group does not bind `Googlebot`.
|
|
54
|
+
|
|
55
|
+
### The crawler side
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import * as Effect from "effect/Effect";
|
|
59
|
+
import * as Schema from "effect/Schema";
|
|
60
|
+
import { makeRobotsChecker, memoryRobotsCache } from "@thomasfosterau/effect-robots";
|
|
61
|
+
|
|
62
|
+
class CrawlSkipped extends Schema.TaggedError<CrawlSkipped>()("CrawlSkipped", {
|
|
63
|
+
reason: Schema.String,
|
|
64
|
+
}) {}
|
|
65
|
+
|
|
66
|
+
const check = makeRobotsChecker({
|
|
67
|
+
userAgent: "ExampleBot/1.0 (+https://example.com/bot)", // the request header
|
|
68
|
+
userAgentToken: "ExampleBot", // the product token robots.txt names
|
|
69
|
+
cache: memoryRobotsCache(), // or your own KV-backed adapter
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Effect<void, CrawlSkipped, HttpClient>
|
|
73
|
+
const crawl = (url: string) =>
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
const decision = yield* check(url);
|
|
76
|
+
if (!decision.allowed) return yield* new CrawlSkipped({ reason: decision.reason });
|
|
77
|
+
// … fetch the page, honouring decision.crawlDelaySeconds
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Export | What it is |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `fetchRobots(origin, { userAgent, timeout?, maxBytes? })` | `GET <origin>/robots.txt` through `HttpClient`, with RFC 9309 §2.3.1's failure posture: a 2xx body (capped at `MAX_ROBOTS_BYTES`, §2.5), an empty string for 4xx / no file (`ROBOTS_NO_RESTRICTIONS` — crawl freely), and `ROBOTS_UNAVAILABLE_SENTINEL` for 5xx, a timeout or a transport error (treat as disallow). Never fails. |
|
|
84
|
+
| `RobotsCache<R>` | The per-origin cache seam: `get(origin): Effect<Option<string>, never, R>`, `put(origin, value, ttlSeconds)`. `noRobotsCache` fetches every time; `memoryRobotsCache()` is a `Map` for tests; a persistent adapter (for example Workers KV) lives in the consuming application. |
|
|
85
|
+
| `ROBOTS_CACHE_TTL_SECONDS` (24 h), `ROBOTS_UNAVAILABLE_TTL_SECONDS` (1 h) | Cache lifetimes: a body or "no restrictions" lives a day; an unavailable verdict is retried after an hour. |
|
|
86
|
+
| `evaluateRobots(robotsTxt, token, path, { maxCrawlDelaySeconds? })` | The crawl decision with the reasons: `{ allowed, matchedPattern: Option<string>, crawlDelaySeconds: Option<number> }`. The path is matched **with** its query string (a `?tab=` can be disallowed) and without its fragment; the crawl delay is capped at `MAX_CRAWL_DELAY_SECONDS` (30) and dropped when not positive. |
|
|
87
|
+
| `makeRobotsChecker(options)` | `(url) => Effect<RobotsDecision, never, HttpClient \| R>`: cache lookup → fetch on a miss → cache the body / the sentinel with the right TTL → evaluate. `RobotsDecision` is `{ allowed: true, crawlDelaySeconds }` or `{ allowed: false, reason }` (`ROBOTS_UNAVAILABLE_REASON`, or `disallowed by robots.txt rule '<pattern>'`). |
|
|
88
|
+
|
|
89
|
+
The checker runs one `HttpClient` request per origin per cache lifetime; that `HttpClient` should be the SSRF-guarded one (`@thomasfosterau/effect-web/net`) in an application that crawls user-supplied URLs.
|
|
90
|
+
|
|
91
|
+
### The serving side
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// routes/robots.txt/+server.ts
|
|
95
|
+
import { LOW_CHURN_CACHE_CONTROL, robotsBody } from "@thomasfosterau/effect-robots";
|
|
96
|
+
import { PUBLIC_SITE_URL, PUBLIC_STAGE } from "$env/static/public";
|
|
97
|
+
|
|
98
|
+
export const GET = () =>
|
|
99
|
+
new Response(
|
|
100
|
+
robotsBody(PUBLIC_STAGE === "production", {
|
|
101
|
+
disallow: ["/api/", "/auth/"],
|
|
102
|
+
sitemapUrl: `${PUBLIC_SITE_URL}/sitemap.xml`,
|
|
103
|
+
}),
|
|
104
|
+
{
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "text/plain; charset=utf-8",
|
|
107
|
+
"cache-control": LOW_CHURN_CACHE_CONTROL,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Export | What it is |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `NON_PRODUCTION_BODY` | `User-agent: *` / `Disallow: /` — every non-production stage is invisible to crawlers, whatever the policy says (ROBOTS-005). |
|
|
116
|
+
| `productionBody({ disallow, sitemapUrl, comments? })` | The production document: the comment lines, a `User-agent: *` group with each `disallow` prefix, the `Sitemap:` line. Rendered through `encode`, so it round-trips through `parse`. Open to every crawler, the listed prefixes disallowed, an absolute sitemap URL (ROBOTS-002 … -004). |
|
|
117
|
+
| `robotsBody(production, policy)` | The stage switch: `productionBody(policy)` in production, `NON_PRODUCTION_BODY` on every other stage (ROBOTS-005). |
|
|
118
|
+
| `LOW_CHURN_CACHE_CONTROL` | `public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400` — the edge caches the document an hour (PERF-CACHE-002). |
|
|
119
|
+
| `isRobotsBody(value)` | A refinement for a test or a handler. |
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT — see [LICENSE](LICENSE).
|