Skip to content

Commit 3dbb818

Browse files
committed
Resolve sitemap locations by XML namespace
1 parent 2e1d38b commit 3dbb818

3 files changed

Lines changed: 77 additions & 4 deletions

File tree

docs/tutorial-corpus.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ In order to gather web documents it can be useful to download the portions of a
2828

2929
A comprehensive overview of the available documents can be obtained faster and more efficiently using sitemaps and feeds than by systematically crawling. These formats are machine-readable and can reveal content that may not be reachable through the browsable interface. However, link inspection and filtering prior to download is recommended to avoid undesired content — see `link filtering`_ below.
3030

31+
XML sitemap locations are resolved by namespace, so default and prefixed sitemap namespaces are supported. Location tags in extension namespaces (such as image sitemaps) are ignored. Legacy XML without a namespace is also supported.
32+
3133
In addition, Trafilatura supports multilingual and multinational sitemaps, for example when a site targets different languages through paths like ``/en/…`` and ``/de/…``.
3234

3335
.. hint::

tests/sitemaps_tests.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,63 @@ def test_whole():
220220
trafilatura.settings.MAX_SITEMAPS_SEEN = 1
221221
results = sitemaps.sitemap_search("https://www.sitemaps.org", target_lang="de")
222222
assert len(results) == 8
223+
224+
225+
@pytest.mark.parametrize("prefix", ["", "sm:", "site-map:"])
226+
@pytest.mark.parametrize("declaration", ["", '<?xml version="1.0" encoding="UTF-8"?>'])
227+
@pytest.mark.parametrize("index", [False, True])
228+
def test_sitemap_namespaces(prefix, declaration, index):
229+
"""Namespace prefixes do not change page or nested sitemap discovery."""
230+
root, child = ("sitemapindex", "sitemap") if index else ("urlset", "url")
231+
namespace = f'xmlns{":" + prefix[:-1] if prefix else ""}="http://www.sitemaps.org/schemas/sitemap/0.9"'
232+
url = "https://example.org/nested.xml" if index else "https://example.org/page"
233+
sitemap = sitemaps.SitemapObject("https://example.org", "example.org", [])
234+
sitemap.current_url = "https://example.org/sitemap.xml"
235+
sitemap.content = (
236+
f"{declaration}<{prefix}{root} {namespace}>"
237+
f"<{prefix}{child}><{prefix}loc><![CDATA[{url}]]></{prefix}loc></{prefix}{child}>"
238+
f"</{prefix}{root}>"
239+
)
240+
sitemap.process()
241+
assert (sitemap.sitemap_urls, sitemap.urls) == (([url], []) if index else ([], [url]))
242+
243+
244+
def test_sitemap_namespace_scope():
245+
"""Ignore extension locations even when a prefix is rebound locally."""
246+
sitemap = sitemaps.SitemapObject("https://example.org", "example.org", [])
247+
sitemap.current_url = "https://example.org/sitemap.xml"
248+
sitemap.content = (
249+
'<s:urlset xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="urn:image">'
250+
"<s:url><s:loc>https://example.org/page</s:loc>"
251+
"<image:loc>https://example.org/image</image:loc>"
252+
'<s:loc xmlns:s="urn:other">https://example.org/other</s:loc>'
253+
"</s:url></s:urlset>"
254+
)
255+
sitemap.process()
256+
assert sitemap.urls == ["https://example.org/page"]
257+
258+
259+
@pytest.mark.parametrize("location", ["&external;", "https://example.org/&external;", "https://example.org/<nested/>"])
260+
def test_sitemap_locations_do_not_expand_entities(location):
261+
"""Location extraction does not resolve entities or concatenate child markup."""
262+
sitemap = sitemaps.SitemapObject("https://example.org", "example.org", [])
263+
sitemap.content = (
264+
'<!DOCTYPE urlset [<!ENTITY external SYSTEM "file:///not-a-sitemap-resource">]>'
265+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
266+
f"<url><loc>{location}</loc></url></urlset>"
267+
)
268+
sitemap.extract_sitemap_links()
269+
assert not sitemap.urls
270+
271+
272+
def test_sitemap_namespace_link_limit(monkeypatch):
273+
"""Namespaced extraction keeps the existing maximum-location bound."""
274+
monkeypatch.setattr(sitemaps, "MAX_LINKS", 1)
275+
sitemap = sitemaps.SitemapObject("https://example.org", "example.org", [])
276+
sitemap.content = (
277+
'<s:urlset xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9">'
278+
"<s:url><s:loc>https://example.org/first?a=1&amp;b=2</s:loc></s:url>"
279+
"<s:url><s:loc>https://example.org/second</s:loc></s:url></s:urlset>"
280+
)
281+
sitemap.extract_sitemap_links()
282+
assert sitemap.urls == ["https://example.org/first?a=1&b=2"]

trafilatura/sitemaps.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,22 @@
1717
get_hostinfo,
1818
lang_filter,
1919
)
20+
from lxml import etree
2021

2122
from .deduplication import is_similar_domain
2223
from .downloads import fetch_url, is_live_page
2324
from .settings import MAX_LINKS, MAX_SITEMAPS_SEEN
2425

2526
LOGGER = logging.getLogger(__name__)
2627

27-
LINK_REGEX = re.compile(r"<loc>(?:<!\[CDATA\[)?(http.+?)(?:\]\]>)?</loc>")
28+
SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
2829
XHTML_REGEX = re.compile(r"<xhtml:link.+?>", re.DOTALL)
2930
HREFLANG_REGEX = re.compile(r'href=["\'](.+?)["\']')
3031
WHITELISTED_PLATFORMS = re.compile(
3132
r"(?:blogger|blogpost|ghost|hubspot|livejournal|medium|typepad|squarespace|tumblr|weebly|wix|wordpress)\."
3233
)
3334

34-
SITEMAP_FORMAT = re.compile(r"^.{0,5}<\?xml|<sitemap|<urlset")
35+
SITEMAP_FORMAT = re.compile(r"^.{0,5}<\?xml|<(?:[\w.-]+:)?(?:sitemapindex|sitemap|urlset)\b")
3536
DETECT_SITEMAP_LINK = re.compile(r"\.xml(\..{2,4})?$|\.xml[?#]")
3637
DETECT_LINKS = re.compile(r'https?://[^\s<"]+')
3738
SCRUB_REGEX = re.compile(r"\?.*$|#.*$")
@@ -141,8 +142,18 @@ def handle_lang_link(attrs: str) -> None:
141142
self.extract_links(XHTML_REGEX, 0, handle_lang_link)
142143

143144
def extract_sitemap_links(self) -> None:
144-
"Extract sitemap links and web page links from a sitemap file."
145-
self.extract_links(LINK_REGEX, 1, self.handle_link) # process middle part of the match tuple
145+
"Extract locations in the sitemap namespace (or legacy unnamespaced XML)."
146+
parser = etree.XMLParser(encoding="utf-8", resolve_entities=False, no_network=True, recover=True)
147+
try:
148+
tree = etree.fromstring(self.content.encode("utf-8"), parser)
149+
except etree.XMLSyntaxError:
150+
return
151+
if tree is None:
152+
return
153+
for element in islice(tree.iter("loc", f"{{{SITEMAP_NAMESPACE}}}loc"), MAX_LINKS):
154+
# Entity references and nested markup are not part of a location URL.
155+
if len(element) == 0 and element.text and element.text.startswith("http"):
156+
self.handle_link(element.text)
146157

147158
def process(self) -> None:
148159
"Download a sitemap and extract the links it contains."

0 commit comments

Comments
 (0)