Skip to content

Commit c6f69d7

Browse files
Merge pull request #8 from OpenElementsLabs/feat/004-sitemap-crawler
Feat/004 sitemap crawler
2 parents 5aa0b3f + 68ccf28 commit c6f69d7

6 files changed

Lines changed: 447 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
3838
│ ├── ContentSource.java / SourceType.java # typed, declarative source model (website|git)
3939
│ ├── ContentSourceProperties.java # @ConfigurationProperties("open-elements.content")
4040
│ ├── UrlMatcher.java # Ant-glob include/exclude matching against URL paths
41-
│ └── ContentLocaleResolver.java # path-prefix locale rule (/de -> German, else English)
41+
│ ├── ContentLocaleResolver.java # path-prefix locale rule (/de -> German, else English)
42+
│ ├── DiscoveredItem.java # a discovered URL + lastmod change marker
43+
│ └── SitemapCrawler.java # sitemap/index discovery + bounded fallback crawl
4244
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
4345
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
4446
└── docs/
@@ -68,7 +70,10 @@ each source's Ant-glob `urlInclude`/`urlExclude` against URL paths; the typed `C
6870
`ContentDocument` is the canonical shape of one indexed page (stable `id` = sanitized source +
6971
SHA-256 of the URL; `toMap()` for indexing); `ContentConfig` registers the Meilisearch
7072
`IndexSettings` bean (searchable `title>excerpt>body`, filterable by source/locale/author/categories/date,
71-
sortable by date), which the library's initializer applies to the index at startup.
73+
sortable by date), which the library's initializer applies to the index at startup. `SitemapCrawler`
74+
(the first stage of the ingestion pipeline) discovers a source's URLs from its sitemaps (recursing
75+
into sitemap indexes) — or, when none are configured, via a bounded same-host fallback crawl — and
76+
returns `DiscoveredItem`s filtered by `UrlMatcher`; it is fault-tolerant per sitemap.
7277

7378
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
7479
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Implementation Steps: Sitemap Crawler
2+
3+
## Step 1: `DiscoveredItem` record
4+
5+
- [x] Record `DiscoveredItem(String url, String lastmod)` (lastmod nullable)
6+
7+
**Acceptance criteria:**
8+
- [x] Project builds
9+
10+
**Related behaviors:** Missing lastmod is tolerated
11+
12+
---
13+
14+
## Step 2: `SitemapCrawler`
15+
16+
- [x] `@Component` taking `RestClient.Builder` (builds its own client) + `UrlMatcher`
17+
- [x] Parse `<urlset>` entries (loc + optional lastmod) with jsoup XML parser
18+
- [x] Follow `<sitemapindex>` children recursively (visited-set + depth guard against self-reference)
19+
- [x] Filter every discovered URL through `UrlMatcher`; dedupe preserving order
20+
- [x] Fault tolerance: unreachable or malformed sitemap is logged and skipped, never aborts the source
21+
- [x] No-sitemap fallback: bounded, same-host link-following crawl from `baseUrl` (depth + page caps, visited-set)
22+
23+
**Acceptance criteria:**
24+
- [x] Project builds; behaviors verified by tests
25+
26+
**Related behaviors:** all sitemap parsing, filtering, fallback, and error scenarios
27+
28+
---
29+
30+
## Step 3: Tests
31+
32+
- [x] `SitemapCrawlerTest` using `MockRestServiceServer` (no real network): urlset, index recursion, missing lastmod, include/exclude filtering, unreachable-sitemap tolerance, malformed-XML tolerance, bounded fallback crawl, cycle termination
33+
34+
**Acceptance criteria:**
35+
- [x] All tests pass (`mvn test`)
36+
37+
**Related behaviors:** all
38+
39+
---
40+
41+
## Behavior Coverage
42+
43+
| Scenario | Layer | Covered in Step |
44+
|----------|-------|-----------------|
45+
| Flat urlset is collected | Backend | Step 3 (`SitemapCrawlerTest.flatUrlsetIsCollected`) |
46+
| Sitemap index is followed | Backend | Step 3 (`SitemapCrawlerTest.sitemapIndexIsFollowed`) |
47+
| Missing lastmod is tolerated | Backend | Step 3 (`SitemapCrawlerTest.missingLastmodIsTolerated`) |
48+
| Only included URLs are returned | Backend | Step 3 (`SitemapCrawlerTest.onlyIncludedUrlsAreReturned`) |
49+
| Excluded URLs are dropped | Backend | Step 3 (`SitemapCrawlerTest.excludedUrlsAreDropped`) |
50+
| No sitemap triggers bounded crawl | Backend | Step 3 (`SitemapCrawlerTest.noSitemapTriggersBoundedCrawl`) |
51+
| Crawl terminates on cycles | Backend | Step 3 (`SitemapCrawlerTest.fallbackCrawlTerminatesOnCycles`) |
52+
| Unreachable sitemap | Backend | Step 3 (`SitemapCrawlerTest.unreachableSitemapDoesNotAbortDiscovery`) |
53+
| Malformed XML | Backend | Step 3 (`SitemapCrawlerTest.malformedXmlContributesNothing`) |
54+
55+
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
@@ -9,7 +9,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
99
| 001 | 001-project-skeleton | Project skeleton | build, architecture, mcp | Standalone Spring Boot app that pulls in `spring-services` and imports its MCP + search config | #1 | done |
1010
| 002 | 002-content-source-config | Content source config | backend, architecture, configuration | `ContentSource` abstraction + `@ConfigurationProperties` + Ant-glob URL matching | #3 | done |
1111
| 003 | 003-content-document-index | Content document & index | backend, search, data-model | `ContentDocument` record + Meilisearch `IndexSettings` bean | #5 | done |
12-
| 004 | 004-sitemap-crawler | Sitemap crawler | backend, crawler | `SitemapCrawler` — sitemap discovery of URLs + lastmod | | open |
12+
| 004 | 004-sitemap-crawler | Sitemap crawler | backend, crawler | `SitemapCrawler` — sitemap discovery of URLs + lastmod | #7 | done |
1313
| 005 | 005-page-fetcher | Page fetcher | backend, crawler | `PageFetcher` — robust HTTP fetch (ETag/IMS, rate limit, retry) || open |
1414
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction || open |
1515
| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` || open |
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.openelements.content;
2+
3+
/**
4+
* A single URL discovered for a {@link ContentSource}, together with its change marker.
5+
*
6+
* <p>Produced by discovery (the {@link SitemapCrawler} for websites, and the Git strategy in
7+
* spec 016) and consumed by the fetch stage (spec 005). The {@code lastmod} is the sitemap
8+
* {@code <lastmod>} value when available and {@code null} otherwise (e.g. for pages found by the
9+
* fallback crawl) — a {@code null} marker means the fetch stage cannot skip via a diff and will
10+
* always fetch the page.
11+
*
12+
* @param url the absolute URL of the discovered item
13+
* @param lastmod the last-modified marker (ISO datetime), or {@code null} if unknown
14+
*/
15+
public record DiscoveredItem(String url, String lastmod) {
16+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package com.openelements.content;
2+
3+
import java.net.URI;
4+
import java.util.ArrayDeque;
5+
import java.util.Deque;
6+
import java.util.HashSet;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.Set;
11+
import org.jsoup.Jsoup;
12+
import org.jsoup.nodes.Document;
13+
import org.jsoup.nodes.Element;
14+
import org.jsoup.parser.Parser;
15+
import org.slf4j.Logger;
16+
import org.slf4j.LoggerFactory;
17+
import org.springframework.stereotype.Component;
18+
import org.springframework.web.client.RestClient;
19+
20+
/**
21+
* Discovers the candidate URLs of a {@code website} {@link ContentSource}.
22+
*
23+
* <p>The primary strategy reads the source's configured sitemaps: each sitemap is fetched and, if it
24+
* is a {@code <sitemapindex>}, its child sitemaps are followed recursively; {@code <urlset>} entries
25+
* contribute their {@code <loc>} and optional {@code <lastmod>}. Every discovered URL is filtered
26+
* through the source's Ant-glob include/exclude rules via {@link UrlMatcher}.
27+
*
28+
* <p>When a source declares no sitemaps, discovery falls back to a bounded, same-host link-following
29+
* crawl from the {@code baseUrl}: it follows links up to {@link #MAX_FALLBACK_DEPTH} levels deep,
30+
* guarded by a visited-set (so cycles terminate) and a total-page cap.
31+
*
32+
* <p>Discovery is fault-tolerant: a sitemap that cannot be fetched or parsed is logged and skipped;
33+
* it never aborts discovery of the other sitemaps or the source as a whole.
34+
*/
35+
@Component
36+
public class SitemapCrawler {
37+
38+
private static final Logger log = LoggerFactory.getLogger(SitemapCrawler.class);
39+
40+
/** Maximum link-following depth for the no-sitemap fallback crawl. */
41+
static final int MAX_FALLBACK_DEPTH = 3;
42+
43+
/** Safety cap on the number of pages fetched during a fallback crawl. */
44+
static final int MAX_FALLBACK_PAGES = 200;
45+
46+
/** Guard against pathologically nested (or self-referential) sitemap indexes. */
47+
private static final int MAX_SITEMAP_DEPTH = 10;
48+
49+
private final RestClient restClient;
50+
private final UrlMatcher urlMatcher;
51+
52+
public SitemapCrawler(RestClient.Builder restClientBuilder, UrlMatcher urlMatcher) {
53+
this.restClient = restClientBuilder.build();
54+
this.urlMatcher = urlMatcher;
55+
}
56+
57+
/**
58+
* Discovers the in-scope URLs of a source.
59+
*
60+
* @param source the source to crawl
61+
* @return the discovered items whose URL path is included by the source (order preserved, deduplicated)
62+
*/
63+
public List<DiscoveredItem> discover(ContentSource source) {
64+
if (source.sitemaps().isEmpty()) {
65+
return fallbackCrawl(source);
66+
}
67+
68+
Map<String, DiscoveredItem> collected = new LinkedHashMap<>();
69+
Set<String> visitedSitemaps = new HashSet<>();
70+
for (String sitemapPath : source.sitemaps()) {
71+
String sitemapUrl = resolve(source.baseUrl(), sitemapPath);
72+
collectSitemap(sitemapUrl, collected, visitedSitemaps, 0);
73+
}
74+
75+
return collected.values().stream()
76+
.filter(item -> urlMatcher.matches(source, item.url()))
77+
.toList();
78+
}
79+
80+
private void collectSitemap(String sitemapUrl, Map<String, DiscoveredItem> collected,
81+
Set<String> visitedSitemaps, int depth) {
82+
if (depth > MAX_SITEMAP_DEPTH || !visitedSitemaps.add(sitemapUrl)) {
83+
return;
84+
}
85+
String xml;
86+
try {
87+
xml = fetch(sitemapUrl);
88+
} catch (Exception e) {
89+
log.warn("Skipping unreachable sitemap {}: {}", sitemapUrl, e.toString());
90+
return;
91+
}
92+
try {
93+
Document doc = Jsoup.parse(xml, sitemapUrl, Parser.xmlParser());
94+
var childSitemaps = doc.select("sitemapindex > sitemap > loc");
95+
if (!childSitemaps.isEmpty()) {
96+
for (Element loc : childSitemaps) {
97+
String childUrl = loc.text().trim();
98+
if (!childUrl.isEmpty()) {
99+
collectSitemap(childUrl, collected, visitedSitemaps, depth + 1);
100+
}
101+
}
102+
return;
103+
}
104+
for (Element urlEntry : doc.select("urlset > url")) {
105+
Element loc = urlEntry.selectFirst("loc");
106+
if (loc == null || loc.text().trim().isEmpty()) {
107+
continue;
108+
}
109+
String url = loc.text().trim();
110+
Element lastmod = urlEntry.selectFirst("lastmod");
111+
String lastmodValue = lastmod == null || lastmod.text().trim().isEmpty()
112+
? null : lastmod.text().trim();
113+
collected.putIfAbsent(url, new DiscoveredItem(url, lastmodValue));
114+
}
115+
} catch (Exception e) {
116+
log.warn("Skipping malformed sitemap {}: {}", sitemapUrl, e.toString());
117+
}
118+
}
119+
120+
private List<DiscoveredItem> fallbackCrawl(ContentSource source) {
121+
String host = hostOf(source.baseUrl());
122+
Map<String, DiscoveredItem> results = new LinkedHashMap<>();
123+
Set<String> visited = new HashSet<>();
124+
Deque<CrawlEntry> queue = new ArrayDeque<>();
125+
queue.add(new CrawlEntry(source.baseUrl(), 0));
126+
127+
while (!queue.isEmpty() && visited.size() < MAX_FALLBACK_PAGES) {
128+
CrawlEntry entry = queue.poll();
129+
if (!visited.add(entry.url())) {
130+
continue;
131+
}
132+
String html;
133+
try {
134+
html = fetch(entry.url());
135+
} catch (Exception e) {
136+
log.warn("Skipping unreachable page {}: {}", entry.url(), e.toString());
137+
continue;
138+
}
139+
Document doc = Jsoup.parse(html, entry.url());
140+
for (Element anchor : doc.select("a[href]")) {
141+
String link = stripFragment(anchor.absUrl("href"));
142+
if (link.isEmpty() || !host.equalsIgnoreCase(hostOf(link))) {
143+
continue;
144+
}
145+
if (urlMatcher.matches(source, link)) {
146+
results.putIfAbsent(link, new DiscoveredItem(link, null));
147+
}
148+
if (entry.depth() < MAX_FALLBACK_DEPTH && !visited.contains(link)) {
149+
queue.add(new CrawlEntry(link, entry.depth() + 1));
150+
}
151+
}
152+
}
153+
if (visited.size() >= MAX_FALLBACK_PAGES) {
154+
log.warn("Fallback crawl of {} hit the {}-page cap; discovery may be incomplete",
155+
source.baseUrl(), MAX_FALLBACK_PAGES);
156+
}
157+
return List.copyOf(results.values());
158+
}
159+
160+
private String fetch(String url) {
161+
return restClient.get().uri(URI.create(url)).retrieve().body(String.class);
162+
}
163+
164+
private static String resolve(String baseUrl, String path) {
165+
return URI.create(baseUrl).resolve(path).toString();
166+
}
167+
168+
private static String hostOf(String url) {
169+
try {
170+
String host = URI.create(url).getHost();
171+
return host == null ? "" : host;
172+
} catch (IllegalArgumentException e) {
173+
return "";
174+
}
175+
}
176+
177+
private static String stripFragment(String url) {
178+
int hash = url.indexOf('#');
179+
return hash >= 0 ? url.substring(0, hash) : url;
180+
}
181+
182+
private record CrawlEntry(String url, int depth) {
183+
}
184+
}

0 commit comments

Comments
 (0)