|
1 | 1 | import base64 |
2 | 2 | import gzip |
| 3 | +import logging |
3 | 4 | from contextlib import asynccontextmanager |
4 | 5 | from datetime import datetime, timedelta |
5 | 6 | from typing import TYPE_CHECKING, Any, cast |
6 | | -from unittest.mock import AsyncMock, MagicMock |
| 7 | +from unittest.mock import AsyncMock, MagicMock, patch |
7 | 8 | from xml.sax.expatreader import ExpatParser |
8 | 9 |
|
9 | 10 | import pytest |
|
16 | 17 | SitemapUrl, |
17 | 18 | _TxtSitemapParser, |
18 | 19 | _XMLSaxSitemapHandler, |
| 20 | + _XmlSitemapParser, |
19 | 21 | discover_valid_sitemaps, |
20 | 22 | parse_sitemap, |
21 | 23 | ) |
@@ -319,6 +321,22 @@ async def test_sitemap_from_string() -> None: |
319 | 321 | assert set(sitemap.urls) == get_basic_results() |
320 | 322 |
|
321 | 323 |
|
| 324 | +async def test_malformed_sitemap_keeps_urls() -> None: |
| 325 | + """A parse error must not discard the URLs collected before it.""" |
| 326 | + malformed = ( |
| 327 | + '<?xml version="1.0" encoding="UTF-8"?>\n' |
| 328 | + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' |
| 329 | + f'<url><loc>{DEFAULT_URL}first</loc></url>\n' |
| 330 | + f'<url><loc>{DEFAULT_URL}second</loc></url>\n' |
| 331 | + f'<url><loc>{DEFAULT_URL}third</loc></mismatched>\n' |
| 332 | + '</urlset>' |
| 333 | + ) |
| 334 | + |
| 335 | + sitemap = await Sitemap.from_xml_string(malformed) |
| 336 | + |
| 337 | + assert sitemap.urls == [f'{DEFAULT_URL}first', f'{DEFAULT_URL}second'] |
| 338 | + |
| 339 | + |
322 | 340 | async def test_sitemap_fetch_retries_on_transient_error() -> None: |
323 | 341 | """Transient fetch errors are retried up to `sitemap_retries` times before giving up.""" |
324 | 342 | client, attempts = _make_flaky_stream_client(get_basic_sitemap().encode(), fail_times=2) |
@@ -562,6 +580,38 @@ async def test_txt_parser_flush_clears_buffer() -> None: |
562 | 580 | assert [item['loc'] for item in items] == ['https://a.com/', 'https://b.com/', 'https://c.com/'] |
563 | 581 |
|
564 | 582 |
|
| 583 | +async def test_xml_parser_skips_missing_flush( |
| 584 | + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture |
| 585 | +) -> None: |
| 586 | + """A parser without `flush` is flushed silently, as on CPython before 3.10.14, 3.11.9 and 3.12.3.""" |
| 587 | + monkeypatch.delattr(ExpatParser, 'flush', raising=False) |
| 588 | + parser = _XmlSitemapParser() |
| 589 | + |
| 590 | + with caplog.at_level(logging.WARNING, logger='crawlee._utils.sitemap'): |
| 591 | + items = [item async for item in parser.process_chunk(get_basic_sitemap())] |
| 592 | + items += [item async for item in parser.flush()] |
| 593 | + |
| 594 | + assert {item['loc'] for item in items} == get_basic_results() |
| 595 | + assert caplog.records == [] |
| 596 | + |
| 597 | + |
| 598 | +async def test_xml_parser_keeps_items_on_flush_error(caplog: pytest.LogCaptureFixture) -> None: |
| 599 | + """A failing `flush()` must not discard the items already collected.""" |
| 600 | + parser = _XmlSitemapParser() |
| 601 | + parser._handler.items.append({'type': 'url', 'loc': f'{DEFAULT_URL}page'}) |
| 602 | + |
| 603 | + # `create=True` covers interpreters where `ExpatParser` has no `flush` to replace. |
| 604 | + with ( |
| 605 | + caplog.at_level(logging.WARNING, logger='crawlee._utils.sitemap'), |
| 606 | + patch.object(parser._parser, 'flush', side_effect=RuntimeError('Broken parser'), create=True), |
| 607 | + ): |
| 608 | + items = [item async for item in parser.flush()] |
| 609 | + |
| 610 | + assert items == [{'type': 'url', 'loc': f'{DEFAULT_URL}page'}] |
| 611 | + assert parser._handler.items == [] |
| 612 | + assert 'Failed to parse remaining XML data: Broken parser' in caplog.text |
| 613 | + |
| 614 | + |
565 | 615 | async def test_discover_sitemap_url_without_host_skipped() -> None: |
566 | 616 | """URLs without a host are skipped.""" |
567 | 617 | http_client = _make_mock_client({}) |
|
0 commit comments