349 lines
12 KiB
Python
349 lines
12 KiB
Python
import base64
|
|
import gzip
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from yarl import URL
|
|
|
|
from crawlee._utils.sitemap import Sitemap, SitemapUrl, discover_valid_sitemaps, parse_sitemap
|
|
from crawlee.http_clients._base import HttpClient, HttpResponse
|
|
|
|
BASIC_SITEMAP = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
<url>
|
|
<loc>http://not-exists.com/</loc>
|
|
<lastmod>2005-02-03</lastmod>
|
|
<changefreq>monthly</changefreq>
|
|
<priority>0.8</priority>
|
|
</url>
|
|
<url>
|
|
<loc>http://not-exists.com/catalog?item=12&desc=vacation_hawaii</loc>
|
|
<changefreq>weekly</changefreq>
|
|
</url>
|
|
<url>
|
|
<loc>http://not-exists.com/catalog?item=73&desc=vacation_new_zealand</loc>
|
|
<lastmod>2004-12-23</lastmod>
|
|
<changefreq>weekly</changefreq>
|
|
</url>
|
|
<url>
|
|
<loc>http://not-exists.com/catalog?item=74&desc=vacation_newfoundland</loc>
|
|
<lastmod>2004-12-23T18:00:15+00:00</lastmod>
|
|
<priority>0.3</priority>
|
|
</url>
|
|
<url>
|
|
<loc>http://not-exists.com/catalog?item=83&desc=vacation_usa</loc>
|
|
<lastmod>2004-11-23</lastmod>
|
|
</url>
|
|
</urlset>
|
|
""".strip()
|
|
|
|
BASIC_RESULTS = {
|
|
'http://not-exists.com/',
|
|
'http://not-exists.com/catalog?item=12&desc=vacation_hawaii',
|
|
'http://not-exists.com/catalog?item=73&desc=vacation_new_zealand',
|
|
'http://not-exists.com/catalog?item=74&desc=vacation_newfoundland',
|
|
'http://not-exists.com/catalog?item=83&desc=vacation_usa',
|
|
}
|
|
|
|
|
|
def _make_mock_client(url_map: dict[str, tuple[int, bytes]]) -> AsyncMock:
|
|
async def send_request(url: str, **_kwargs: Any) -> HttpResponse:
|
|
status, body = 404, b''
|
|
for pattern, (s, b) in url_map.items():
|
|
if pattern in url:
|
|
status, body = s, b
|
|
break
|
|
response = MagicMock(spec=HttpResponse)
|
|
response.status_code = status
|
|
response.read = AsyncMock(return_value=body)
|
|
return response
|
|
|
|
client = AsyncMock(spec=HttpClient)
|
|
client.send_request.side_effect = send_request
|
|
return client
|
|
|
|
|
|
def compress_gzip(data: str) -> bytes:
|
|
"""Compress a string using gzip."""
|
|
return gzip.compress(data.encode())
|
|
|
|
|
|
def encode_base64(data: bytes) -> str:
|
|
"""Encode bytes to a base64 string."""
|
|
return base64.b64encode(data).decode('utf-8')
|
|
|
|
|
|
async def test_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a basic sitemap."""
|
|
sitemap_url = (server_url / 'sitemap.xml').with_query(
|
|
base64=encode_base64(BASIC_SITEMAP.encode()), c_type='application/xml; charset=utf-8'
|
|
)
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_extract_metadata_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test extracting item metadata from a sitemap."""
|
|
sitemap_url = (server_url / 'sitemap.xml').with_query(
|
|
base64=encode_base64(BASIC_SITEMAP.encode()), c_type='application/xml; charset=utf-8'
|
|
)
|
|
|
|
items = [item async for item in parse_sitemap([{'type': 'url', 'url': str(sitemap_url)}], http_client=http_client)]
|
|
assert len(items) == 5
|
|
assert items[0] == SitemapUrl(
|
|
loc='http://not-exists.com/',
|
|
priority=0.8,
|
|
changefreq='monthly',
|
|
lastmod=datetime.fromisoformat('2005-02-03'),
|
|
origin_sitemap_url=str(sitemap_url),
|
|
)
|
|
|
|
|
|
async def test_gzipped_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a gzipped sitemap with correct type and .xml.gz url."""
|
|
gzipped_data = encode_base64(compress_gzip(BASIC_SITEMAP))
|
|
sitemap_url = (server_url / 'sitemap.xml.gz').with_query(base64=gzipped_data, c_type='application/gzip')
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_gzipped_sitemap_with_invalid_data(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a invalid gzipped sitemap with correct type and .xml.gz url."""
|
|
compress_data = compress_gzip(BASIC_SITEMAP)
|
|
invalid_gzipped_data = encode_base64(compress_data[:30])
|
|
sitemap_url = (server_url / 'sitemap.xml.gz').with_query(base64=invalid_gzipped_data, c_type='application/gzip')
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 0
|
|
assert sitemap.urls == []
|
|
|
|
|
|
async def test_gz_sitemap_with_non_gzipped(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a sitemap with gzip type and .xml.gz url, but without gzipped data."""
|
|
sitemap_url = (server_url / 'sitemap.xml.gz').with_query(
|
|
base64=encode_base64(BASIC_SITEMAP.encode()), c_type='application/gzip'
|
|
)
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_gzipped_sitemap_with_bad_type(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a gzipped sitemap with bad type and .xml.gz url."""
|
|
gzipped_data = encode_base64(compress_gzip(BASIC_SITEMAP))
|
|
sitemap_url = (server_url / 'sitemap.xml.gz').with_query(
|
|
base64=gzipped_data, c_type='application/xml; charset=utf-8'
|
|
)
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_xml_sitemap_with_gzipped_data(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a gzipped sitemap with correct type and .xml url."""
|
|
gzipped_data = encode_base64(compress_gzip(BASIC_SITEMAP))
|
|
sitemap_url = (server_url / 'sitemap.xml').with_query(base64=gzipped_data, c_type='application/gzip')
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_parent_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a parent sitemap that references child sitemaps."""
|
|
parent_sitemap = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
<sitemap>
|
|
<loc>{child_sitemap}</loc>
|
|
<lastmod>2004-12-23</lastmod>
|
|
</sitemap>
|
|
<sitemap>
|
|
<loc>{child_sitemap_2}</loc>
|
|
<lastmod>2004-12-23</lastmod>
|
|
</sitemap>
|
|
</sitemapindex>
|
|
""".strip()
|
|
child_sitemap = (server_url / 'sitemap.xml').with_query(base64=encode_base64(BASIC_SITEMAP.encode()))
|
|
child_sitemap_2 = (server_url / 'sitemap.xml.gz').with_query(base64=encode_base64(compress_gzip(BASIC_SITEMAP)))
|
|
parent_sitemap_content = parent_sitemap.format(child_sitemap=child_sitemap, child_sitemap_2=child_sitemap_2)
|
|
encoded_parent_sitemap_content = encode_base64(parent_sitemap_content.encode())
|
|
parent_sitemap_url = (server_url / 'sitemap.xml').with_query(base64=encoded_parent_sitemap_content)
|
|
|
|
sitemap = await Sitemap.load(str(parent_sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 10
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_non_sitemap_url(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a URL that does not point to a sitemap."""
|
|
sitemap = await Sitemap.load(str(server_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 0
|
|
assert sitemap.urls == []
|
|
|
|
|
|
async def test_cdata_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a sitemap with CDATA sections."""
|
|
cdata_sitemap = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
<url>
|
|
<loc><![CDATA[http://not-exists.com/catalog]]></loc>
|
|
</url>
|
|
</urlset>
|
|
""".strip()
|
|
sitemap_url = (server_url / 'sitemap.xml').with_query(
|
|
base64=encode_base64(cdata_sitemap.encode()), c_type='application/xml; charset=utf-8'
|
|
)
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 1
|
|
assert sitemap.urls == ['http://not-exists.com/catalog']
|
|
|
|
|
|
async def test_txt_sitemap(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a plain text sitemap."""
|
|
urls = [
|
|
'http://not-exists.com/catalog?item=78&desc=vacation_crete',
|
|
'http://not-exists.com/catalog?item=79&desc=vacation_somalia',
|
|
]
|
|
txt_sitemap_content = '\n'.join(urls)
|
|
|
|
sitemap_url = (server_url / 'sitemap.txt').with_query(base64=encode_base64(txt_sitemap_content.encode()))
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 2
|
|
assert set(sitemap.urls) == {
|
|
'http://not-exists.com/catalog?item=78&desc=vacation_crete',
|
|
'http://not-exists.com/catalog?item=79&desc=vacation_somalia',
|
|
}
|
|
|
|
|
|
async def test_sitemap_pretty(server_url: URL, http_client: HttpClient) -> None:
|
|
"""Test loading a pretty-printed sitemap."""
|
|
pretty_sitemap = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
<url>
|
|
<loc>
|
|
http://not-exists.com/catalog?item=80&desc=vacation_turkey
|
|
</loc>
|
|
<lastmod>
|
|
2005-02-03
|
|
</lastmod>
|
|
<changefreq>
|
|
|
|
monthly
|
|
</changefreq>
|
|
<priority>
|
|
0.8
|
|
</priority>
|
|
</url>
|
|
</urlset>
|
|
""".strip()
|
|
sitemap_url = (server_url / 'sitemap.xml').with_query(
|
|
base64=encode_base64(pretty_sitemap.encode()), c_type='application/xml; charset=utf-8'
|
|
)
|
|
sitemap = await Sitemap.load(str(sitemap_url), http_client=http_client)
|
|
|
|
assert len(sitemap.urls) == 1
|
|
assert sitemap.urls == ['http://not-exists.com/catalog?item=80&desc=vacation_turkey']
|
|
|
|
|
|
async def test_sitemap_from_string() -> None:
|
|
"""Test creating a Sitemap instance from an XML string."""
|
|
sitemap = await Sitemap.from_xml_string(BASIC_SITEMAP)
|
|
|
|
assert len(sitemap.urls) == 5
|
|
assert set(sitemap.urls) == BASIC_RESULTS
|
|
|
|
|
|
async def test_discover_sitemap_from_robots_txt() -> None:
|
|
"""Sitemap URL found in robots.txt is yielded."""
|
|
robots_content = b'User-agent: *\nSitemap: http://example.com/custom-sitemap.xml'
|
|
http_client = _make_mock_client({'robots.txt': (200, robots_content)})
|
|
|
|
urls = [url async for url in discover_valid_sitemaps(['http://example.com/page'], http_client=http_client)]
|
|
|
|
assert urls == ['http://example.com/custom-sitemap.xml']
|
|
|
|
|
|
async def test_discover_sitemap_from_common_paths() -> None:
|
|
"""Sitemap is found at common paths when robots.txt has none."""
|
|
http_client = _make_mock_client(
|
|
{'/sitemap.xml': (200, b''), '/sitemap.txt': (200, b''), '/sitemap_index.xml': (200, b'')}
|
|
)
|
|
|
|
urls = [url async for url in discover_valid_sitemaps(['http://example.com/page'], http_client=http_client)]
|
|
|
|
assert urls == [
|
|
'http://example.com/sitemap.xml',
|
|
'http://example.com/sitemap.txt',
|
|
'http://example.com/sitemap_index.xml',
|
|
]
|
|
|
|
|
|
async def test_discover_sitemap_from_input_url() -> None:
|
|
"""Input URL that is already a sitemap is yielded directly without checking common paths."""
|
|
http_client = _make_mock_client({'/sitemap.txt': (200, b'')})
|
|
|
|
urls = [url async for url in discover_valid_sitemaps(['http://example.com/sitemap.xml'], http_client=http_client)]
|
|
|
|
assert urls == ['http://example.com/sitemap.xml']
|
|
|
|
|
|
async def test_discover_sitemap_deduplication() -> None:
|
|
"""Sitemap URL found in robots.txt is not yielded again from common paths check."""
|
|
robots_content = b'User-agent: *\nSitemap: http://example.com/sitemap.xml'
|
|
http_client = _make_mock_client(
|
|
{
|
|
'robots.txt': (200, robots_content),
|
|
'/sitemap.xml': (200, b''),
|
|
}
|
|
)
|
|
|
|
urls = [url async for url in discover_valid_sitemaps(['http://example.com/page'], http_client=http_client)]
|
|
|
|
assert urls == ['http://example.com/sitemap.xml']
|
|
|
|
|
|
async def test_discover_sitemaps_multiple_domains() -> None:
|
|
"""Sitemaps from multiple domains are all discovered."""
|
|
http_client = _make_mock_client(
|
|
{
|
|
'domain-a.com/sitemap.xml': (200, b''),
|
|
'domain-b.com/sitemap.xml': (200, b''),
|
|
}
|
|
)
|
|
|
|
urls = [
|
|
url
|
|
async for url in discover_valid_sitemaps(
|
|
['http://domain-a.com/page', 'http://domain-b.com/page'],
|
|
http_client=http_client,
|
|
)
|
|
]
|
|
|
|
assert set(urls) == {
|
|
'http://domain-a.com/sitemap.xml',
|
|
'http://domain-b.com/sitemap.xml',
|
|
}
|
|
|
|
|
|
async def test_discover_sitemap_url_without_host_skipped() -> None:
|
|
"""URLs without a host are skipped."""
|
|
http_client = _make_mock_client({})
|
|
|
|
urls = [url async for url in discover_valid_sitemaps(['not-a-valid-url'], http_client=http_client)]
|
|
|
|
assert urls == []
|