The framework ships a small, well-behaved web stack — Jsoup for HTML, Apache Tika for multi-format content extraction (PDF, DOC, PPT, EPUB, RTF, plain text, …), and crawler-commons for robots.txt + sitemap.xml. All optional dependencies; none load unless you opt in.
The goal is "StormCrawler's capabilities, without StormCrawler's Storm dependency." You get parsing + politeness + multi-format support; you don't get a Storm topology.
Add to your downstream pom:
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<dependency>
<groupId>com.github.crawler-commons</groupId>
<artifactId>crawler-commons</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>2.9.2</version>
</dependency>
<!-- Tika needs commons-io 2.15+; older versions on the Flink classpath break it. -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.16.1</version>
</dependency>The framework's pom marks all five as <optional>true</optional> so users
who don't run a crawler pay nothing transitively.
| Class | Role |
|---|---|
WebToolkitOptions |
User agent, fetch timeout, max page bytes, max depth, robots policy, redirect policy. |
RobotsCache |
Per-host BaseRobotRules cache with 24h TTL. Falls open on fetch failure. |
Fetcher |
HTTP GET via java.net.http.HttpClient. Honours robots.txt + body-size cap + redirects. |
DocumentExtractor |
Jsoup for HTML (title, body text, absolute links); Tika for everything else. |
WebFetchTool |
ToolExecutor: GET a URL, return title + text + links. |
ExtractLinksTool |
Cheaper variant: links only, no body. |
CrawlerCore |
Multi-source fetch + extract loop. Consumes from any Channel<UrlRequest>s. |
CrawlerCore.builder() takes one or more Channel<UrlRequest>s. They're
unioned into a single input stream. The crawler doesn't own its inputs:
CrawlerCore.builder()
.frontier(
seedChannel, // static URLs
agentCrawlChannel, // ToolInvocationChannel — LLM-driven
new KafkaChannel<>(brokers, "crawl-requests", // external producers
"agent", UrlRequest.class))
.options(WebToolkitOptions.defaults().withMaxDepth(2))
.open(env);Add or remove channels without touching the crawler operator. That's how the agent and an external producer can target the same crawler — they're just different channels into the same union.
Default: enabled. Each host's /robots.txt is fetched once, cached for 24h,
and parsed via crawler-commons' SimpleRobotRulesParser. Behaviour mirrors
RFC 9309 + the StormCrawler convention:
- 2xx → parse and apply rules.
- 4xx → fall-open (allow all).
- 5xx → deny all until next refresh (24h TTL is overridden by the failure state).
- Network error → fall-open with a debug log.
Disable with options.withRespectRobots(false).
WebToolkitOptions.maxPageBytes (default 10 MB) caps the body before
extraction. Tika streams what it gets, so you don't pay extraction cost on
the truncated tail; titles, the first N pages of a PDF, and the visible
text of an oversized HTML page all still extract correctly.
For very-large-document corpora (>10 MB single files) raise the cap or pre- process upstream of the framework — Tika can stream PDFs but holding a 500 MB report in operator memory is rarely a good idea.
The framework's IngestionPipeline consumes DataStream<CrawledPage>; for
non-web sources, drop the crawler and feed CrawledPage records directly:
DataStream<CrawledPage> docs = filesChannel.open(env)
.map(localPath -> {
byte[] bytes = Files.readAllBytes(localPath);
DocumentExtractor.ExtractedDocument doc =
new DocumentExtractor().extract("file://" + localPath, bytes, "auto");
return new CrawledPage(localPath, localPath, "auto",
doc.getTitle(), doc.getText(), List.of(),
System.currentTimeMillis(), 0, doc.getMetadata());
});
IngestionPipeline.from(docs)
.chunk(new RecursiveTextChunker(512))
.embed(djlEmbeddings)
.into(corpus)
.build();The point: the crawler is one source of CrawledPages. Any other source
that produces them feeds the same downstream pipeline unchanged.