rzo1 opened a new issue, #2083:
URL: https://github.com/apache/stormcrawler/issues/2083
## What happens
`execute()` looks for the sitemaps.org namespace in the first
`sitemap.offset.guess` bytes of every document it receives and, if it finds it,
treats the document as a sitemap. There is no content type test and no way to
switch the sniffing off; the sibling `FeedParserBolt` has `feed.sniffContent`,
defaulting to false, but `SiteMapParserBolt` has no equivalent and ignores the
`sitemap.sniffContent` key that `SiteMapParserBoltTest` sets. The parser is
built as `new SiteMapParser(false)`, which turns off the crawler-commons cross
submission check, so a sitemap on one host may list URLs on any other host and
they are emitted DISCOVERED. Sitemap index children are emitted with
`isSitemap=true`, which is in the default `metadata.persist` list, and if such
a URL later turns out not to be a sitemap the bolt emits `Status.ERROR`.
## Where
`core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java:106-120`
(sniffing), `:338` (parser construction), `:137-151` (error emission),
`:232-240` (`isSitemap=true` on index children). Config keys:
`sitemap.offset.guess`, `sitemap.discovery`, `metadata.persist`, and
`fetchInterval.error`, which the archetype sets to `-1`
(`archetype/src/main/resources/archetype-resources/crawler-conf.yaml:106`).
```java
boolean looksLikeSitemap = sniff(content);
// can force the mimetype as we know it is XML
if (looksLikeSitemap) {
ct = "application/xml";
}
```
```java
parser = new SiteMapParser(false);
```
## Why it matters
A crawled page decides how the pipeline treats it. Any HTML page that
contains the namespace string early enough is reclassified as a sitemap, is
never passed to the parser bolt, and is therefore never indexed. A sitemap can
enrol URLs on hosts it has nothing to do with, and those entries skip
`parser.emitOutlinks.max.per.page` and the robots meta tags that apply on the
HTML path. Because `isSitemap` is persisted, the classification sticks: when
such a URL is fetched and does not parse as a sitemap it becomes
`Status.ERROR`, and with the archetype's `fetchInterval.error: -1` it is never
scheduled again. In an open crawl that lets a third party remove other people's
URLs from the corpus; in a scoped crawl it is limited to hosts already in
scope. `internals.adoc:141` describes the bolt as parsing tuples that carry
`isSitemap=true`, which does not mention the sniffing path.
## Reproduction
Save as
`core/src/test/java/org/apache/stormcrawler/bolt/SiteMapParserBoltCrossHostTest.java`.
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to you under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.stormcrawler.bolt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.apache.stormcrawler.Constants;
import org.apache.stormcrawler.Metadata;
import org.apache.stormcrawler.parse.ParsingTester;
import org.apache.stormcrawler.persistence.Status;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SiteMapParserBoltCrossHostTest extends ParsingTester {
@BeforeEach
void setupParserBolt() {
bolt = new SiteMapParserBolt();
setupParserBolt(bolt);
}
private static byte[] xml(String body) {
return body.getBytes(StandardCharsets.UTF_8);
}
/** A sitemap may only list URLs below its own location. */
@Test
void crossSubmittedUrlsAreNotDiscovered() throws IOException {
prepareParserBolt("test.parsefilters.json");
Metadata metadata = new Metadata();
metadata.setValue(SiteMapParserBolt.isSitemapKey, "true");
parse(
"https://a.example/sitemap.xml",
xml(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<urlset
xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
+
"<url><loc>https://a.example/own-page</loc></url>"
+
"<url><loc>https://b.example/other-page</loc></url>"
+ "</urlset>"),
metadata);
List<List<Object>> emitted =
output.getEmitted(Constants.StatusStreamName);
for (List<Object> t : emitted) {
Assertions.assertFalse(
t.get(0).toString().startsWith("https://b.example/"),
"discovered a URL on another host: " + t.get(0));
}
}
/** Content sniffing must not promote an ordinary HTML page to a
sitemap. */
@Test
void htmlMentioningTheSitemapNamespaceIsNotASitemap() throws IOException
{
prepareParserBolt("test.parsefilters.json");
Metadata metadata = new Metadata();
parse(
"https://a.example/page.html",
xml(
"<html><body><a
href=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
+ "sitemaps</a>"
+ "<urlset
xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
+
"<url><loc>https://b.example/other-page</loc></url></urlset>"
+ "</body></html>"),
metadata);
Assertions.assertEquals(
"false",
metadata.getFirstValue(SiteMapParserBolt.isSitemapKey),
"HTML page classified as a sitemap");
}
/** A page carrying isSitemap=true that does not parse must stay
fetchable. */
@Test
void unparseableSitemapIsNotTerminalError() throws IOException {
prepareParserBolt("test.parsefilters.json");
Metadata metadata = new Metadata();
metadata.setValue(SiteMapParserBolt.isSitemapKey, "true");
parse("https://a.example/page.html",
xml("<html><body>hello</body></html>"), metadata);
List<List<Object>> emitted =
output.getEmitted(Constants.StatusStreamName);
for (List<Object> t : emitted) {
Assertions.assertNotEquals(Status.ERROR, t.get(2), "emitted as
ERROR: " + t.get(0));
}
}
}
```
Run it:
```
mvn -pl core test -Dtest=SiteMapParserBoltCrossHostTest
```
All three tests assert the intended behaviour and fail on main.
```
[ERROR] Tests run: 3, Failures: 3, Errors: 0, Skipped: 0, Time elapsed:
0.745 s <<< FAILURE! -- in
org.apache.stormcrawler.bolt.SiteMapParserBoltCrossHostTest
[ERROR]
SiteMapParserBoltCrossHostTest.crossSubmittedUrlsAreNotDiscovered:60 discovered
a URL on another host: https://b.example/other-page ==> expected: <false> but
was: <true>
[ERROR]
SiteMapParserBoltCrossHostTest.htmlMentioningTheSitemapNamespaceIsNotASitemap:80
HTML page classified as a sitemap ==> expected: <false> but was: <true>
[ERROR]
SiteMapParserBoltCrossHostTest.unparseableSitemapIsNotTerminalError:95 emitted
as ERROR: https://a.example/page.html ==> expected: not equal but was: <ERROR>
```
The third case is driven by crawler-commons, which rejects an HTML body
outright:
```
ERROR org.apache.stormcrawler.bolt.SiteMapParserBolt - Exception while
parsing https://a.example/page.html:
crawlercommons.sitemaps.UnknownFormatException: Failed to detect MediaType of
sitemap 'https://a.example/page.html'
```
## Suggested fix
Construct the parser with `strict=true` in `SiteMapParserBolt.prepare` and
add a config key for operators who need the old behaviour. Add
`sitemap.sniffContent`, defaulting to false, and read it in `prepare`; when
sniffing is on, require a sitemap compatible content type as well as the
namespace clue. In `execute`, on a parse failure of a document that was only
marked as a sitemap through persisted metadata, drop the `isSitemap` key and
emit `Status.FETCHED` or `FETCH_ERROR` rather than `Status.ERROR`, so the URL
stays schedulable. Strict parsing and the sniffing default both change what an
existing crawl discovers, so both need a release note.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]