Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/src/main/java/org/apache/stormcrawler/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public class Constants {
public static final String STATUS_ERROR_SOURCE = "error.source";
public static final String STATUS_ERROR_CAUSE = "error.cause";

/**
* Metadata key holding the robots.txt Crawl-delay in seconds, set by the fetcher bolts when the
* delay exceeds {@code fetcher.max.crawl.delay} and {@code fetcher.max.crawl.delay.force} is
* true, so a frontier-side consumer can enforce it at the source. See #867.
*/
public static final String ROBOTS_CRAWL_DELAY_KEY = "robots.crawl.delay";

public static final String QUEUE_STREAM_NAME = "queue";

public static final String StatusStreamName = "status";
Expand Down
17 changes: 17 additions & 0 deletions core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,9 @@ public void run() {

// https://github.com/apache/stormcrawler/issues/813
metadata.remove("fetch.exception");
metadata.remove(Constants.ROBOTS_CRAWL_DELAY_KEY);

String robotsCrawlDelaySecs = null;

boolean asap = false;

Expand Down Expand Up @@ -677,6 +680,12 @@ public void run() {
msg);
if (force) {
fiq.crawlDelay = maxCrawlDelay;
// report the delay the fetcher is not holding, so a frontier-side
// consumer can enforce it at the source (#867)
robotsCrawlDelaySecs =
Long.toString(1L + ((rules.getCrawlDelay() - 1L) / 1000L));
metadata.setValue(
Constants.ROBOTS_CRAWL_DELAY_KEY, robotsCrawlDelaySecs);
} else {
// pass the info about crawl delay
metadata.setValue(Constants.STATUS_ERROR_CAUSE, "crawl_delay");
Expand Down Expand Up @@ -792,6 +801,14 @@ public void run() {
// metadata persisted or transferred from previous fetches
mergedMetadata.putAll(response.getMetadata(), protocolMetadataPrefix);

// Only the locally parsed robots.txt value may populate this control signal.
// A colliding protocol prefix/header must not pace an unrelated queue.
mergedMetadata.remove(Constants.ROBOTS_CRAWL_DELAY_KEY);
if (robotsCrawlDelaySecs != null) {
mergedMetadata.setValue(
Constants.ROBOTS_CRAWL_DELAY_KEY, robotsCrawlDelaySecs);
}

mergedMetadata.setValue(
"fetch.statusCode", Integer.toString(response.getStatusCode()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ public void execute(Tuple input) {

// https://github.com/apache/stormcrawler/issues/813
metadata.remove("fetch.exception");
metadata.remove(Constants.ROBOTS_CRAWL_DELAY_KEY);

String robotsCrawlDelaySecs = null;

URL url;

Expand Down Expand Up @@ -401,12 +404,16 @@ public void execute(Tuple input) {
// value is negative when not set
long robotsDelay = rules.getCrawlDelay();
if (robotsDelay > 0) {
if (robotsDelay > maxCrawlDelay) {
if (robotsDelay > maxCrawlDelay && maxCrawlDelay >= 0) {
if (maxCrawlDelayForce) {
// cap the value to a maximum
// as some sites specify ridiculous values
LOG.debug("Delay from robots capped at {} for {}", robotsDelay, url);
delay = maxCrawlDelay;
// report the delay the fetcher is not holding, so a frontier-side
// consumer can enforce it at the source (#867)
robotsCrawlDelaySecs = Long.toString(1L + ((robotsDelay - 1L) / 1000L));
metadata.setValue(Constants.ROBOTS_CRAWL_DELAY_KEY, robotsCrawlDelaySecs);
} else {
LOG.debug(
"Skipped URL from queue with overlong crawl-delay ({}): {}",
Expand Down Expand Up @@ -499,6 +506,13 @@ public void execute(Tuple input) {
// persisted or transferred from previous fetches
mergedMetadata.putAll(response.getMetadata(), protocolMetadataPrefix);

// Only the locally parsed robots.txt value may populate this control signal.
// A colliding protocol prefix/header must not pace an unrelated queue.
mergedMetadata.remove(Constants.ROBOTS_CRAWL_DELAY_KEY);
if (robotsCrawlDelaySecs != null) {
mergedMetadata.setValue(Constants.ROBOTS_CRAWL_DELAY_KEY, robotsCrawlDelaySecs);
}

mergedMetadata.setValue("fetch.statusCode", Integer.toString(response.getStatusCode()));

mergedMetadata.setValue("fetch.loadingTime", Long.toString(timeFetching));
Expand Down
7 changes: 5 additions & 2 deletions core/src/main/resources/crawler-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ config:
# skip URLs from this queue to avoid that any overlong
# crawl-delay throttles the crawler
# (if true)
# set the delay to fetcher.max.crawl.delay,
# making fetcher more aggressive than requested
# cap the local delay at fetcher.max.crawl.delay and emit the
# requested value in whole seconds as robots.crawl.delay;
# URLFrontier's QueueRegulatorBolt can forward a capped value when
# its robots crawl-delay integration is explicitly enabled. Without
# that consumer the fetcher remains more aggressive than requested.
fetcher.max.crawl.delay.force: false
# behavior of fetcher when the crawl-delay in the robots.txt
# is smaller (ev. less than one second) than the default delay:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,59 @@ void invalidProxyMetadataEmitsFetchError(WireMockRuntimeInfo wmRuntimeInfo)
Assertions.assertEquals(0, output.getEmitted(Utils.DEFAULT_STREAM_ID).size());
}

private void resetProtocolFactory() throws ReflectiveOperationException {
TestOutputCollector fetch(
WireMockRuntimeInfo wmRuntimeInfo, Map<String, Object> config, String path)
throws ReflectiveOperationException {
resetProtocolFactory();
TestOutputCollector output = new TestOutputCollector();
bolt.prepare(config, TestUtil.getMockedTopologyContext(), new OutputCollector(output));

Tuple tuple = mock(Tuple.class);
when(tuple.getSourceComponent()).thenReturn("source");
when(tuple.getStringByField("url"))
.thenReturn("http://localhost:" + wmRuntimeInfo.getHttpPort() + path);
when(tuple.getValueByField("metadata")).thenReturn(null);
bolt.execute(tuple);

await().atMost(30, TimeUnit.SECONDS)
.until(
() ->
output.getEmitted(Utils.DEFAULT_STREAM_ID).size() > 0
|| output.getEmitted(Constants.StatusStreamName).size()
> 0);
return output;
}

/**
* Fetches a page that is expected to be retrieved successfully (HTTP 200, non-304): the fetcher
* emits it on the default stream (url, content, metadata) for downstream parsing.
*/
Metadata fetchAndGetContentMetadata(
WireMockRuntimeInfo wmRuntimeInfo, Map<String, Object> config, String path)
throws ReflectiveOperationException {
TestOutputCollector output = fetch(wmRuntimeInfo, config, path);
List<List<Object>> contentTuples = output.getEmitted(Utils.DEFAULT_STREAM_ID);
Assertions.assertEquals(1, contentTuples.size());
Assertions.assertEquals(0, output.getEmitted(Constants.StatusStreamName).size());
return (Metadata) contentTuples.get(0).get(2);
}

/**
* Fetches a page that is expected to be rejected before any HTTP request is made (e.g. the
* crawl-delay-too-long guard): the fetcher emits directly on the status stream (url, metadata,
* status).
*/
List<Object> fetchAndGetStatusTuple(
WireMockRuntimeInfo wmRuntimeInfo, Map<String, Object> config, String path)
throws ReflectiveOperationException {
TestOutputCollector output = fetch(wmRuntimeInfo, config, path);
List<List<Object>> statusTuples = output.getEmitted(Constants.StatusStreamName);
Assertions.assertEquals(1, statusTuples.size());
Assertions.assertEquals(0, output.getEmitted(Utils.DEFAULT_STREAM_ID).size());
return statusTuples.get(0);
}

static void resetProtocolFactory() throws ReflectiveOperationException {
Field instance = ProtocolFactory.class.getDeclaredField("single_instance");
instance.setAccessible(true);
instance.set(null, null);
Expand Down
113 changes: 113 additions & 0 deletions core/src/test/java/org/apache/stormcrawler/bolt/FetcherBoltTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,125 @@

package org.apache.stormcrawler.bolt;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.stormcrawler.Constants;
import org.apache.stormcrawler.Metadata;
import org.apache.stormcrawler.persistence.Status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class FetcherBoltTest extends AbstractFetcherBoltTest {

@BeforeEach
void setUpContext() throws Exception {
bolt = new FetcherBolt();
}

@Test
void forcedLongCrawlDelayIsReportedInMetadata(WireMockRuntimeInfo wmRuntimeInfo)
throws ReflectiveOperationException {
// robots.txt with a Crawl-delay above fetcher.max.crawl.delay
stubFor(
get(urlEqualTo("/robots.txt"))
.willReturn(
aResponse()
.withStatus(200)
.withBody("User-agent: *\nCrawl-delay: 120\n")));
stubFor(
get(urlEqualTo("/page"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Robots.Crawl.Delay", "1")
.withBody("hello")));

Map<String, Object> config = new HashMap<>();
config.put("http.agent.name", "this_is_only_a_test");
config.put("fetcher.max.crawl.delay", 30);
config.put("fetcher.max.crawl.delay.force", true);

Metadata md = fetchAndGetContentMetadata(wmRuntimeInfo, config, "/page");
assertEquals("120", md.getFirstValue(Constants.ROBOTS_CRAWL_DELAY_KEY));
}

@Test
void fractionalLongCrawlDelayIsRoundedUp(WireMockRuntimeInfo wmRuntimeInfo)
throws ReflectiveOperationException {
stubFor(
get(urlEqualTo("/robots.txt"))
.willReturn(
aResponse()
.withStatus(200)
.withBody("User-agent: *\nCrawl-delay: 30.5\n")));
stubFor(get(urlEqualTo("/page")).willReturn(aResponse().withStatus(200).withBody("hello")));

Map<String, Object> config = new HashMap<>();
config.put("http.agent.name", "this_is_only_a_test");
config.put("fetcher.max.crawl.delay", 30);
config.put("fetcher.max.crawl.delay.force", true);

Metadata md = fetchAndGetContentMetadata(wmRuntimeInfo, config, "/page");
assertEquals("31", md.getFirstValue(Constants.ROBOTS_CRAWL_DELAY_KEY));
}

@Test
void shortCrawlDelayIsNotReported(WireMockRuntimeInfo wmRuntimeInfo)
throws ReflectiveOperationException {
stubFor(
get(urlEqualTo("/robots.txt"))
.willReturn(
aResponse()
.withStatus(200)
.withBody("User-agent: *\nCrawl-delay: 5\n")));
stubFor(
get(urlEqualTo("/page"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Robots.Crawl.Delay", "120")
.withBody("hello")));

Map<String, Object> config = new HashMap<>();
config.put("http.agent.name", "this_is_only_a_test");
config.put("fetcher.max.crawl.delay", 30);
config.put("fetcher.max.crawl.delay.force", true);

Metadata md = fetchAndGetContentMetadata(wmRuntimeInfo, config, "/page");
assertNull(md.getFirstValue(Constants.ROBOTS_CRAWL_DELAY_KEY));
}

@Test
void unforcedLongCrawlDelayStillEmitsCrawlDelayErrorWithoutMetadata(
WireMockRuntimeInfo wmRuntimeInfo) throws ReflectiveOperationException {
// regression guard: force=false + long delay must keep today's behaviour, i.e. a
// Status.ERROR/crawl_delay emission and no robots.crawl.delay metadata key
stubFor(
get(urlEqualTo("/robots.txt"))
.willReturn(
aResponse()
.withStatus(200)
.withBody("User-agent: *\nCrawl-delay: 120\n")));
stubFor(get(urlEqualTo("/page")).willReturn(aResponse().withStatus(200).withBody("hello")));

Map<String, Object> config = new HashMap<>();
config.put("http.agent.name", "this_is_only_a_test");
config.put("fetcher.max.crawl.delay", 30);
config.put("fetcher.max.crawl.delay.force", false);

List<Object> statusTuple = fetchAndGetStatusTuple(wmRuntimeInfo, config, "/page");
assertEquals(Status.ERROR, statusTuple.get(2));
Metadata md = (Metadata) statusTuple.get(1);
assertEquals("crawl_delay", md.getFirstValue(Constants.STATUS_ERROR_CAUSE));
assertNull(md.getFirstValue(Constants.ROBOTS_CRAWL_DELAY_KEY));
}
}
Loading
Loading