Skip to content

Commit 738e75b

Browse files
Merge pull request #28 from OpenElementsLabs/feat/014-ops-robustness
Operations & robustness — robots.txt, crawl-delay, hardening
2 parents 358b1d2 + 446ab42 commit 738e75b

13 files changed

Lines changed: 527 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ The **Open Elements Content MCP** is an MCP server that will crawl, index, and m
1010
searchable the content (primarily blog posts) of Open-Elements-adjacent websites, exposing
1111
that content to AI agents over the standard `/mcp` streamable-HTTP endpoint.
1212

13-
Current state: **project skeleton** (spec `001-project-skeleton`). The app boots and exposes
14-
`/mcp` with no content-specific tools yet. Planned capabilities (see
15-
[`docs/roadmap.md`](docs/roadmap.md)): sitemap crawling, HTTP page fetching, jsoup content
16-
extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
17-
(`search_content`, `list_posts`, `get_post`, `list_categories`).
13+
Current state: **Phase 1 complete** (specs 001–014). The app crawls configured website sources
14+
(sitemap discovery + bounded fallback, robots.txt-aware), fetches politely (conditional GET, per-host
15+
rate limit, retries), extracts content/metadata with jsoup, indexes into Meilisearch (startup
16+
bootstrap + scheduled incremental refresh), and exposes four MCP tools on `/mcp` (`search_content`,
17+
`list_posts`, `get_post`, `list_categories`) backed by a scoped Meilisearch key. Phase 2+ (see
18+
[`docs/roadmap.md`](docs/roadmap.md)): additional website sources, Git/Markdown sources, and search
19+
enhancements.
1820

1921
### Tech Stack
2022

@@ -57,7 +59,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
5759
│ ├── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex
5860
│ ├── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded)
5961
│ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
60-
│ └── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp
62+
│ ├── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp
63+
│ ├── RobotsPolicy.java / HttpRobotsPolicy.java # robots.txt allow/disallow + Crawl-delay
6164
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
6265
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
6366
└── docs/
@@ -127,7 +130,11 @@ as thin adapters over `ContentSearchService`, using the house helpers for schema
127130
`ContentConfig` registers a `ScopedKeySpec` bean so the library exchanges the master key for one scoped
128131
to the content index with the minimal actions the service uses (it both reads and writes); MCP api-key
129132
auth on `/mcp` defaults to **enabled** (`MCP_API_KEY_AUTH_ENABLED`), with the `dev` profile
130-
(`application-dev.yaml`) disabling it for local use.
133+
(`application-dev.yaml`) disabling it for local use. Robustness (spec 014): `HttpRobotsPolicy` honors
134+
each host's `robots.txt` (cached per host) — `SitemapCrawler` drops disallowed URLs and `PageFetcher`
135+
respects `Crawl-delay` via the per-host rate limiter. The pipeline is fault-tolerant end-to-end (one
136+
failing page/source never aborts a run) and logs an `IndexReport` per source; Micrometer metrics are
137+
deferred until a metrics backend is wired.
131138

132139
> **Build note:** `spring-services` is pinned to a specific snapshot timestamp
133140
> (`1.3.0-20260712.175350-13`) in `pom.xml`, not the floating `1.3.0-SNAPSHOT`, because a later
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Implementation Steps: Operations & Robustness
2+
3+
## Step 1: robots.txt
4+
5+
- [x] `RobotsPolicy` interface (`isAllowed`, `crawlDelay`) with an `ALLOW_ALL` no-op for tests
6+
- [x] `HttpRobotsPolicy` (`@Component`): fetches `robots.txt` per host over HTTPS, caches with a TTL (injectable clock), selects the group for our user agent (falling back to `*`), longest-prefix matching with `Allow` winning ties, missing robots → allow-all, parses `Crawl-delay`
7+
- [x] `SitemapCrawler` filters discovered URLs (sitemap + fallback crawl) through `RobotsPolicy`, logging skips
8+
- [x] `PageFetcher` honors `Crawl-delay` via `HostRateLimiter.acquire(host, minInterval)` (stricter of config vs. crawl-delay)
9+
- [x] Hardening: `SitemapCrawler` only follows child sitemaps on the same host (a sitemap index cannot point the crawler at arbitrary/internal hosts) — closes the SSRF surface flagged in earlier specs
10+
11+
**Related behaviors:** disallowed skipped; allowed fetched; cached per host; missing → allow-all; crawl-delay tightens the rate limit
12+
13+
---
14+
15+
## Step 2: Fault tolerance (audit)
16+
17+
- [x] Verified end-to-end (covered by existing tests): one failing page → `SKIP` (`WebsiteSourceStrategyTest`, `ContentIndexerTest.oneFailingPageIsSkipped`); one failing source isolated (`ContentBootstrapStepTest.failingSourceDoesNotBlockOthers`, `ContentRefreshSchedulerTest.oneSourceFailingDoesNotStopOthers`); idempotent upserts (`ContentIndexerTest`)
18+
19+
**Related behaviors:** failing page isolated; failing source isolated
20+
21+
---
22+
23+
## Step 3: Observability (logging; metrics deferred)
24+
25+
- [x] Structured logging: `IndexReport` per source (`ContentIndexer`/`ContentRefreshScheduler`); robots-skip (`SitemapCrawler`/`HttpRobotsPolicy`); non-success Meilisearch task outcomes logged (`MeilisearchContentIndexStore`)
26+
- [ ] Micrometer metrics — **deferred** per the design's open question: no metrics backend is wired (only `micrometer-observation`/`-commons` are on the classpath, not `micrometer-core`/a `MeterRegistry`). Structured logging is shipped now; add counters/timers when a metrics backend exists.
27+
28+
**Related behaviors:** IndexReport logged per source; failed Meilisearch task surfaced (logging half); metrics behaviors deferred
29+
30+
---
31+
32+
## Step 4: Tests
33+
34+
- [x] `HttpRobotsPolicyTest` (allow/disallow/allow-override/agent-group/cache/missing/crawl-delay via `MockRestServiceServer`)
35+
- [x] `HostRateLimiterTest.crawlDelayTightensInterval`
36+
- [x] `SitemapCrawlerTest.robotsDisallowedUrlsAreDropped`
37+
- [x] Existing crawler/fetcher/strategy tests updated to `RobotsPolicy.ALLOW_ALL`
38+
39+
**Acceptance criteria:**
40+
- [x] All tests pass (`mvn test`); build green
41+
42+
---
43+
44+
## Behavior Coverage
45+
46+
| Scenario | Layer | Covered in Step |
47+
|----------|-------|-----------------|
48+
| Disallowed path is skipped | Backend | Step 4 (`HttpRobotsPolicyTest.disallowedPathIsRejected`, `SitemapCrawlerTest.robotsDisallowedUrlsAreDropped`) |
49+
| Allowed path is fetched | Backend | Step 4 (`HttpRobotsPolicyTest.disallowedPathIsRejected` asserts allowed too) |
50+
| robots.txt is cached per host | Backend | Step 4 (`HttpRobotsPolicyTest.robotsIsCachedPerHost`) |
51+
| Missing robots.txt means allow-all | Backend | Step 4 (`HttpRobotsPolicyTest.missingRobotsAllowsAll`) |
52+
| Crawl-delay tightens the rate limit | Backend | Step 4 (`HttpRobotsPolicyTest.crawlDelayIsParsed` + `HostRateLimiterTest.crawlDelayTightensInterval`) |
53+
| Failing page is isolated | Backend | Step 2 (existing `WebsiteSourceStrategyTest`/`ContentIndexerTest`) |
54+
| Failing source is isolated | Backend | Step 2 (existing `ContentBootstrapStepTest`/`ContentRefreshSchedulerTest`) |
55+
| IndexReport is logged per source | Backend | Step 3 (structured logging in `ContentIndexer`/`ContentRefreshScheduler`) |
56+
| Metrics record fetch and index activity | Backend | **Deferred** (Step 3) — no metrics backend wired; design open question |
57+
| Failed Meilisearch task is surfaced | Backend | Step 3 (logged in `MeilisearchContentIndexStore`; metric part deferred) |
58+
59+
All scenarios are backend; there is no frontend in this spec.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
1919
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService``multiSearch` + highlighting facade | #21 | done |
2020
| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | done |
2121
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | #25 | done |
22-
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | | open |
22+
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | #27 | done |
2323
| 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources || open |
2424
| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) || open |
2525
| 017 | 017-search-enhancements | Search enhancements | backend, search | Facets, synonyms/stop words, optional semantic search || open |

src/main/java/com/openelements/content/HostRateLimiter.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.openelements.content;
22

3+
import java.time.Duration;
34
import java.util.HashMap;
45
import java.util.Map;
56
import java.util.function.LongConsumer;
@@ -38,7 +39,20 @@ public HostRateLimiter(double permitsPerSecond, LongSupplier clockNanos, LongCon
3839
* @param host the target host (an empty or {@code null} host shares a single bucket)
3940
*/
4041
public void acquire(String host) {
41-
if (minIntervalNanos == 0L) {
42+
acquire(host, Duration.ZERO);
43+
}
44+
45+
/**
46+
* Like {@link #acquire(String)}, but enforces at least {@code minInterval} between requests to the
47+
* host — used to honor a {@code robots.txt} {@code Crawl-delay}, taking the stricter of the
48+
* configured rate and the crawl delay.
49+
*
50+
* @param host the target host
51+
* @param minInterval a minimum interval to enforce (e.g. from {@code Crawl-delay}); may be zero
52+
*/
53+
public void acquire(String host, Duration minInterval) {
54+
long effectiveIntervalNanos = Math.max(minIntervalNanos, minInterval == null ? 0L : minInterval.toNanos());
55+
if (effectiveIntervalNanos == 0L) {
4256
return;
4357
}
4458
String key = host == null ? "" : host;
@@ -47,7 +61,7 @@ public void acquire(String host) {
4761
long now = clockNanos.getAsLong();
4862
long start = Math.max(now, nextAllowedNanos.getOrDefault(key, now));
4963
waitNanos = start - now;
50-
nextAllowedNanos.put(key, start + minIntervalNanos);
64+
nextAllowedNanos.put(key, start + effectiveIntervalNanos);
5165
}
5266
if (waitNanos > 0) {
5367
sleeperMillis.accept(Math.max(1, Math.round(waitNanos / 1_000_000.0)));
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.openelements.content;
2+
3+
import java.net.URI;
4+
import java.time.Duration;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.Locale;
8+
import java.util.Map;
9+
import java.util.concurrent.ConcurrentHashMap;
10+
import java.util.function.LongSupplier;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.web.client.RestClient;
16+
17+
/**
18+
* {@link RobotsPolicy} that fetches and caches each host's {@code robots.txt}.
19+
*
20+
* <p>Rules are fetched once per host (over HTTPS) and cached with a TTL. The group matching our bot
21+
* {@code User-Agent} is used, falling back to the {@code *} group; a missing or unreachable
22+
* {@code robots.txt} allows everything. Path matching is longest-prefix with {@code Allow} winning
23+
* ties (the common robots semantics). The clock is injectable so cache expiry is testable.
24+
*/
25+
@Component
26+
public class HttpRobotsPolicy implements RobotsPolicy {
27+
28+
private static final Logger log = LoggerFactory.getLogger(HttpRobotsPolicy.class);
29+
private static final Duration CACHE_TTL = Duration.ofHours(1);
30+
31+
private final RestClient restClient;
32+
private final String userAgent;
33+
private final LongSupplier clockNanos;
34+
private final Map<String, CachedRules> cache = new ConcurrentHashMap<>();
35+
36+
@Autowired
37+
public HttpRobotsPolicy(RestClient.Builder restClientBuilder, ContentSourceProperties properties) {
38+
this(restClientBuilder, properties, System::nanoTime);
39+
}
40+
41+
HttpRobotsPolicy(RestClient.Builder restClientBuilder, ContentSourceProperties properties, LongSupplier clockNanos) {
42+
this.restClient = restClientBuilder.build();
43+
this.userAgent = properties.userAgent();
44+
this.clockNanos = clockNanos;
45+
}
46+
47+
@Override
48+
public boolean isAllowed(String url) {
49+
String host = hostOf(url);
50+
if (host.isEmpty()) {
51+
return true;
52+
}
53+
boolean allowed = rulesFor(host).allows(pathOf(url));
54+
if (!allowed) {
55+
log.info("robots.txt disallows {} for {}", url, userAgent);
56+
}
57+
return allowed;
58+
}
59+
60+
@Override
61+
public Duration crawlDelay(String host) {
62+
return host == null || host.isEmpty() ? Duration.ZERO : rulesFor(host).crawlDelay();
63+
}
64+
65+
private RobotsRules rulesFor(String host) {
66+
CachedRules cached = cache.get(host);
67+
if (cached != null && cached.expiryNanos() > clockNanos.getAsLong()) {
68+
return cached.rules();
69+
}
70+
RobotsRules rules = fetchAndParse(host);
71+
cache.put(host, new CachedRules(rules, clockNanos.getAsLong() + CACHE_TTL.toNanos()));
72+
return rules;
73+
}
74+
75+
private RobotsRules fetchAndParse(String host) {
76+
try {
77+
String body = restClient.get()
78+
.uri(URI.create("https://" + host + "/robots.txt"))
79+
.header("User-Agent", userAgent)
80+
.retrieve()
81+
.body(String.class);
82+
return parse(body == null ? "" : body, userAgent);
83+
} catch (Exception e) {
84+
// Missing/unreachable robots.txt (e.g. 404) means allow everything.
85+
log.debug("No usable robots.txt for {} ({}); allowing all", host, e.toString());
86+
return RobotsRules.ALLOW_ALL;
87+
}
88+
}
89+
90+
/** Parses robots.txt, selecting the group for our user agent (falling back to {@code *}). */
91+
static RobotsRules parse(String body, String userAgent) {
92+
List<Group> groups = new ArrayList<>();
93+
Group current = null;
94+
boolean lastWasRule = false;
95+
for (String rawLine : body.split("\n")) {
96+
String line = stripComment(rawLine).trim();
97+
if (line.isEmpty()) {
98+
continue;
99+
}
100+
int colon = line.indexOf(':');
101+
if (colon < 0) {
102+
continue;
103+
}
104+
String field = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
105+
String value = line.substring(colon + 1).trim();
106+
if (field.equals("user-agent")) {
107+
if (current == null || lastWasRule) {
108+
current = new Group();
109+
groups.add(current);
110+
}
111+
current.agents.add(value.toLowerCase(Locale.ROOT));
112+
lastWasRule = false;
113+
} else if (current != null) {
114+
switch (field) {
115+
case "disallow" -> current.disallow.add(value);
116+
case "allow" -> current.allow.add(value);
117+
case "crawl-delay" -> current.crawlDelay = parseDelay(value);
118+
default -> { /* ignore other directives */ }
119+
}
120+
lastWasRule = true;
121+
}
122+
}
123+
return selectGroup(groups, userAgent).toRules();
124+
}
125+
126+
private static Group selectGroup(List<Group> groups, String userAgent) {
127+
String ua = userAgent.toLowerCase(Locale.ROOT);
128+
Group wildcard = null;
129+
for (Group group : groups) {
130+
for (String agent : group.agents) {
131+
if (agent.equals("*")) {
132+
wildcard = group;
133+
} else if (!agent.isEmpty() && ua.contains(agent)) {
134+
return group; // a group naming our agent wins over the wildcard
135+
}
136+
}
137+
}
138+
return wildcard == null ? new Group() : wildcard;
139+
}
140+
141+
private static Duration parseDelay(String value) {
142+
try {
143+
return Duration.ofMillis(Math.round(Double.parseDouble(value) * 1000));
144+
} catch (NumberFormatException e) {
145+
return Duration.ZERO;
146+
}
147+
}
148+
149+
private static String stripComment(String line) {
150+
int hash = line.indexOf('#');
151+
return hash >= 0 ? line.substring(0, hash) : line;
152+
}
153+
154+
private static String hostOf(String url) {
155+
try {
156+
String host = URI.create(url).getHost();
157+
return host == null ? "" : host;
158+
} catch (IllegalArgumentException e) {
159+
return "";
160+
}
161+
}
162+
163+
private static String pathOf(String url) {
164+
try {
165+
String path = URI.create(url).getPath();
166+
return path == null || path.isEmpty() ? "/" : path;
167+
} catch (IllegalArgumentException e) {
168+
return "/";
169+
}
170+
}
171+
172+
private static final class Group {
173+
private final List<String> agents = new ArrayList<>();
174+
private final List<String> disallow = new ArrayList<>();
175+
private final List<String> allow = new ArrayList<>();
176+
private Duration crawlDelay = Duration.ZERO;
177+
178+
private RobotsRules toRules() {
179+
return new RobotsRules(List.copyOf(disallow), List.copyOf(allow), crawlDelay);
180+
}
181+
}
182+
183+
private record CachedRules(RobotsRules rules, long expiryNanos) {
184+
}
185+
186+
/** Parsed robots rules for the applicable group. */
187+
record RobotsRules(List<String> disallow, List<String> allow, Duration crawlDelay) {
188+
189+
static final RobotsRules ALLOW_ALL = new RobotsRules(List.of(), List.of(), Duration.ZERO);
190+
191+
boolean allows(String path) {
192+
int longestDisallow = longestMatch(disallow, path);
193+
if (longestDisallow < 0) {
194+
return true;
195+
}
196+
return longestMatch(allow, path) >= longestDisallow; // Allow wins ties
197+
}
198+
199+
private static int longestMatch(List<String> rules, String path) {
200+
int longest = -1;
201+
for (String rule : rules) {
202+
if (!rule.isEmpty() && path.startsWith(rule) && rule.length() > longest) {
203+
longest = rule.length();
204+
}
205+
}
206+
return longest;
207+
}
208+
}
209+
}

src/main/java/com/openelements/content/PageFetcher.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,22 @@ public class PageFetcher {
3636
private final String userAgent;
3737
private final long maxBodyBytes;
3838
private final HostRateLimiter rateLimiter;
39+
private final RobotsPolicy robotsPolicy;
3940
private final LongConsumer backoffSleeper;
4041

4142
@Autowired
4243
public PageFetcher(RestClient contentRestClient, ContentSourceProperties properties,
43-
HostRateLimiter rateLimiter) {
44-
this(contentRestClient, properties, rateLimiter, PageFetcher::sleep);
44+
HostRateLimiter rateLimiter, RobotsPolicy robotsPolicy) {
45+
this(contentRestClient, properties, rateLimiter, robotsPolicy, PageFetcher::sleep);
4546
}
4647

4748
PageFetcher(RestClient restClient, ContentSourceProperties properties,
48-
HostRateLimiter rateLimiter, LongConsumer backoffSleeper) {
49+
HostRateLimiter rateLimiter, RobotsPolicy robotsPolicy, LongConsumer backoffSleeper) {
4950
this.restClient = restClient;
5051
this.userAgent = properties.userAgent();
5152
this.maxBodyBytes = properties.maxBodyBytes().toBytes();
5253
this.rateLimiter = rateLimiter;
54+
this.robotsPolicy = robotsPolicy;
5355
this.backoffSleeper = backoffSleeper;
5456
}
5557

@@ -83,7 +85,9 @@ public FetchResult fetch(String url, String priorEtag, String priorLastmod) {
8385
}
8486

8587
private FetchResult attemptOnce(String url, String priorEtag, String priorLastmod) {
86-
rateLimiter.acquire(hostOf(url));
88+
String host = hostOf(url);
89+
// Honor robots.txt Crawl-delay for this host, taking the stricter of it and the configured rate.
90+
rateLimiter.acquire(host, robotsPolicy.crawlDelay(host));
8791
return restClient.get()
8892
.uri(URI.create(url))
8993
.header(HttpHeaders.USER_AGENT, userAgent)

0 commit comments

Comments
 (0)