Files
Scrapling/tests/spiders/test_sitemap.py
T
2026-05-11 02:33:43 +03:00

393 lines
14 KiB
Python

"""Tests for `SitemapParser` and `SitemapSpider`."""
import gzip
import pickle
import pytest
from scrapling.engines.toolbelt.custom import Response
from scrapling.spiders.links import LinkExtractor
from scrapling.spiders.request import Request
from scrapling.spiders.sitemap import SitemapParser, SitemapResult, SitemapSpider, SitemapUrl
from scrapling.spiders.templates import CrawlRule
from scrapling.core._types import Any, AsyncGenerator, Dict, Union
URLSET_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/posts/1</loc>
<lastmod>2026-01-15</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://example.com/posts/2</loc>
<lastmod>2026-02-20</lastmod>
</url>
<url>
<loc>https://example.com/about</loc>
</url>
</urlset>
"""
URLSET_WITH_ALTERNATES = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/en/page</loc>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/page"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/page"/>
</url>
</urlset>
"""
INDEX_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://example.com/posts-sitemap.xml</loc></sitemap>
<sitemap><loc>https://example.com/products-sitemap.xml</loc></sitemap>
<sitemap><loc>https://example.com/skip-sitemap.xml</loc></sitemap>
</sitemapindex>
"""
# Sitemap without the standard namespace (some sites do this)
URLSET_NO_NS = b"""<?xml version="1.0"?>
<urlset>
<url><loc>https://example.com/x</loc></url>
</urlset>
"""
class TestSitemapParserUrlset:
def test_parse_urlset_with_full_metadata(self):
result = SitemapParser().parse(URLSET_XML)
assert len(result.urls) == 3
assert result.sitemaps == []
first = result.urls[0]
assert first.loc == "https://example.com/posts/1"
assert first.lastmod == "2026-01-15"
assert first.changefreq == "daily"
assert first.priority == 0.8
def test_parse_handles_partial_metadata(self):
result = SitemapParser().parse(URLSET_XML)
second = result.urls[1]
assert second.lastmod == "2026-02-20"
assert second.changefreq is None
assert second.priority is None
def test_parse_handles_no_namespace(self):
result = SitemapParser().parse(URLSET_NO_NS)
assert len(result.urls) == 1
assert result.urls[0].loc == "https://example.com/x"
class TestSitemapParserAlternates:
def test_alternate_links_off_by_default(self):
result = SitemapParser().parse(URLSET_WITH_ALTERNATES)
assert result.urls[0].alternates == []
def test_alternate_links_on_collects_them(self):
result = SitemapParser(alternate_links=True).parse(URLSET_WITH_ALTERNATES)
assert result.urls[0].alternates == [
"https://example.com/fr/page",
"https://example.com/de/page",
]
class TestSitemapParserIndex:
def test_parse_sitemapindex_returns_child_sitemaps(self):
result = SitemapParser().parse(INDEX_XML)
assert result.urls == []
assert result.sitemaps == [
"https://example.com/posts-sitemap.xml",
"https://example.com/products-sitemap.xml",
"https://example.com/skip-sitemap.xml",
]
class TestSitemapParserDecompression:
def test_gz_body_via_magic_bytes(self):
compressed = gzip.compress(URLSET_XML)
result = SitemapParser().parse(compressed)
assert len(result.urls) == 3
def test_gz_body_via_content_type_hint(self):
compressed = gzip.compress(URLSET_XML)
result = SitemapParser().parse(compressed, content_type="application/x-gzip")
assert len(result.urls) == 3
def test_corrupt_gz_logged_not_raised(self):
# Body starts with gzip magic but is not valid gzip
body = b"\x1f\x8b" + b"junk data"
result = SitemapParser().parse(body)
assert result == SitemapResult()
class TestSitemapParserMalformed:
def test_invalid_xml_returns_empty_result(self):
result = SitemapParser().parse(b"<not valid xml")
assert result == SitemapResult()
def test_unknown_root_returns_empty_result(self):
result = SitemapParser().parse(b"<?xml version='1.0'?><foo><bar/></foo>")
assert result == SitemapResult()
class TestFromRobotsTxt:
def test_extracts_sitemap_directives(self):
body = """
User-agent: *
Disallow: /admin
Sitemap: https://example.com/sitemap.xml
Sitemap: https://example.com/posts-sitemap.xml
"""
urls = SitemapParser.from_robots_txt(body)
assert urls == [
"https://example.com/sitemap.xml",
"https://example.com/posts-sitemap.xml",
]
def test_ignores_comments_and_blank_lines(self):
body = """
# This is a comment
Sitemap: https://example.com/sitemap.xml # inline comment
# Sitemap: https://commented.example.com/sitemap.xml
"""
urls = SitemapParser.from_robots_txt(body)
assert urls == ["https://example.com/sitemap.xml"]
def test_directive_match_is_case_insensitive(self):
body = "SITEMAP: https://example.com/sitemap.xml\nsitemap: https://example.com/other.xml"
urls = SitemapParser.from_robots_txt(body)
assert urls == [
"https://example.com/sitemap.xml",
"https://example.com/other.xml",
]
def test_returns_empty_when_no_directives(self):
body = "User-agent: *\nDisallow: /admin"
urls = SitemapParser.from_robots_txt(body)
assert urls == []
def _make_response(body: bytes, url: str = "https://example.com/sitemap.xml", headers: dict | None = None) -> Response:
resp = Response(
url=url,
content=body,
status=200,
reason="OK",
cookies={},
headers=headers or {},
request_headers={},
)
resp.request = Request(url, sid="default")
return resp
async def _collect(agen: AsyncGenerator) -> list:
return [item async for item in agen]
class TestSitemapSpiderFlow:
@pytest.mark.asyncio
async def test_urlset_dispatched_through_rules(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
def rules(self):
return [CrawlRule(LinkExtractor(allow=r"/posts/"), callback=self.parse_post)]
async def parse_post(self, response):
yield {"post": response.url}
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(URLSET_XML)))
post_reqs = [r for r in out if "/posts/" in r.url]
about_reqs = [r for r in out if "/about" in r.url]
# Two posts dispatched to parse_post; about falls through (callback inherited from None → None)
assert len(post_reqs) == 2
assert all(r.callback == spider.parse_post for r in post_reqs)
assert len(about_reqs) == 1
assert about_reqs[0].callback is None
@pytest.mark.asyncio
async def test_no_rules_means_all_urls_fall_through(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(URLSET_XML)))
assert len(out) == 3
assert all(r.callback is None for r in out)
@pytest.mark.asyncio
async def test_sitemapindex_descends_into_children(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(INDEX_XML)))
# No urls in this index, just three child sitemap fetches
assert len(out) == 3
assert all(r.callback == spider._parse_sitemap for r in out)
assert {r.url for r in out} == {
"https://example.com/posts-sitemap.xml",
"https://example.com/products-sitemap.xml",
"https://example.com/skip-sitemap.xml",
}
@pytest.mark.asyncio
async def test_sitemap_follow_filters_child_sitemaps(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
sitemap_follow = LinkExtractor(allow=r"posts-sitemap")
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(INDEX_XML)))
assert {r.url for r in out} == {"https://example.com/posts-sitemap.xml"}
@pytest.mark.asyncio
async def test_alternate_links_dispatched_when_enabled(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
sitemap_alternate_links = True
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(URLSET_WITH_ALTERNATES)))
urls = {r.url for r in out}
assert urls == {
"https://example.com/en/page",
"https://example.com/fr/page",
"https://example.com/de/page",
}
@pytest.mark.asyncio
async def test_gzipped_sitemap_handled_via_magic_bytes(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml.gz"]
spider = S()
body = gzip.compress(URLSET_XML)
out = await _collect(spider._parse_sitemap(_make_response(body, url="https://example.com/sitemap.xml.gz")))
assert len(out) == 3
class TestSitemapSpiderStartRequests:
@pytest.mark.asyncio
async def test_start_requests_uses_sitemap_urls(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://a.com/s.xml", "https://b.com/s.xml"]
spider = S()
out = [req async for req in spider.start_requests()]
assert {r.url for r in out} == {"https://a.com/s.xml", "https://b.com/s.xml"}
assert all(r.callback == spider._parse_sitemap for r in out)
@pytest.mark.asyncio
async def test_start_requests_falls_back_to_robots_txt(self):
class S(SitemapSpider):
name = "s"
allowed_domains = {"example.com"}
spider = S()
out = [req async for req in spider.start_requests()]
assert len(out) == 1
assert out[0].url == "https://example.com/robots.txt"
assert out[0].callback == spider._parse_robots
@pytest.mark.asyncio
async def test_start_requests_uses_start_urls_if_no_sitemap_urls(self):
class S(SitemapSpider):
name = "s"
start_urls = ["https://example.com/seed"]
spider = S()
out = [req async for req in spider.start_requests()]
assert len(out) == 1
assert out[0].url == "https://example.com/seed"
# Should NOT have _parse_sitemap as callback (start_urls path treats them as regular pages)
assert out[0].callback is None
@pytest.mark.asyncio
async def test_start_requests_raises_when_nothing_configured(self):
class S(SitemapSpider):
name = "s"
spider = S()
with pytest.raises(RuntimeError, match="needs `sitemap_urls`"):
[req async for req in spider.start_requests()]
class TestParseRobots:
@pytest.mark.asyncio
async def test_parse_robots_yields_sitemap_requests(self):
class S(SitemapSpider):
name = "s"
allowed_domains = {"example.com"}
spider = S()
body = b"User-agent: *\nSitemap: https://example.com/sitemap.xml\n"
resp = _make_response(body, url="https://example.com/robots.txt")
out = await _collect(spider._parse_robots(resp))
assert len(out) == 1
assert out[0].url == "https://example.com/sitemap.xml"
assert out[0].callback == spider._parse_sitemap
@pytest.mark.asyncio
async def test_parse_robots_with_no_directives_warns(self):
# Spider's logger has propagate=False, so we attach our own handler to it.
import logging
class S(SitemapSpider):
name = "s"
allowed_domains = {"example.com"}
spider = S()
records: list[logging.LogRecord] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
spider.logger.addHandler(_Capture())
body = b"User-agent: *\nDisallow: /\n"
resp = _make_response(body, url="https://example.com/robots.txt")
out = await _collect(spider._parse_robots(resp))
assert out == []
assert any("No Sitemap:" in r.getMessage() for r in records if r.levelno == logging.WARNING)
class TestSitemapSpiderPickle:
@pytest.mark.asyncio
async def test_pickle_request_with_bound_method_callback_via_rules(self):
class S(SitemapSpider):
name = "s"
sitemap_urls = ["https://example.com/sitemap.xml"]
def rules(self):
return [CrawlRule(LinkExtractor(allow=r"/posts/"), callback=self.parse_post)]
async def parse_post(self, response):
yield {"post": response.url}
spider = S()
out = await _collect(spider._parse_sitemap(_make_response(URLSET_XML)))
post_req = next(r for r in out if "/posts/" in r.url)
state = post_req.__getstate__()
assert state["_callback_name"] == "parse_post"
# Round-trip
pickled = pickle.dumps(post_req)
restored = pickle.loads(pickled)
fresh = S()
restored._restore_callback(fresh)
assert restored.callback == fresh.parse_post