diff --git a/README.md b/README.md index 5c5a0ccaa..2dac23d5b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ MarkItDown currently supports the conversion from: - ZIP files (iterates over contents) - YouTube URLs - EPubs +- RSS and Atom feeds (including Atom feeds without entries) - ... and more! ## Why Markdown? diff --git a/packages/markitdown/src/markitdown/converters/_rss_converter.py b/packages/markitdown/src/markitdown/converters/_rss_converter.py index ce768b847..86a5e1e66 100644 --- a/packages/markitdown/src/markitdown/converters/_rss_converter.py +++ b/packages/markitdown/src/markitdown/converters/_rss_converter.py @@ -133,8 +133,9 @@ def _feed_type(self, doc: Document) -> str | None: if root.tagName == "rss": return "rss" if root.localName == "feed" and root.namespaceURI in (None, ATOM_NAMESPACE): - if self._get_children(root, "entry"): - # An Atom feed must have a root element of and at least one + # RFC 4287 permits zero entries. Keep the entry heuristic only for + # legacy feeds that do not declare the Atom namespace. + if root.namespaceURI == ATOM_NAMESPACE or self._get_children(root, "entry"): return "atom" return None diff --git a/packages/markitdown/tests/test_rss_converter.py b/packages/markitdown/tests/test_rss_converter.py index 6aaec65a7..0577e21b7 100644 --- a/packages/markitdown/tests/test_rss_converter.py +++ b/packages/markitdown/tests/test_rss_converter.py @@ -7,6 +7,54 @@ from markitdown.converters import RssConverter +@pytest.mark.parametrize("prefix", ["", "a:"]) +@pytest.mark.parametrize( + "stream_info", + [ + StreamInfo(extension=".xml"), + StreamInfo(extension=".atom"), + StreamInfo(mimetype="application/xml"), + StreamInfo(mimetype="application/atom+xml"), + ], +) +def test_atom_without_entries(prefix: str, stream_info: StreamInfo) -> None: + namespace = "xmlns:a" if prefix else "xmlns" + feed = f"""<{prefix}feed {namespace}="http://www.w3.org/2005/Atom"> + <{prefix}title>Release updates + <{prefix}subtitle>No releases yet. + <{prefix}id>urn:example:releases + <{prefix}updated>2026-09-13T00:00:00Z + <{prefix}author><{prefix}name>Example project +""".encode( + "utf-8" + ) + converter = RssConverter() + stream = io.BytesIO(feed) + + assert converter.accepts(stream, stream_info) + assert stream.tell() == 0 + result = converter.convert(stream, stream_info) + assert result.title == "Release updates" + assert result.markdown == "# Release updates\nNo releases yet.\n" + + converted = MarkItDown().convert_stream(io.BytesIO(feed), stream_info=stream_info) + assert converted.title == result.title + assert converted.markdown == result.markdown + + +@pytest.mark.parametrize("namespace", ["", "urn:example:other"]) +def test_non_atom_feed_without_entries_is_not_accepted(namespace: str) -> None: + feed = f'Other feed'.encode() + converter = RssConverter() + stream_info = StreamInfo(extension=".xml") + stream = io.BytesIO(feed) + + assert not converter.accepts(stream, stream_info) + assert stream.tell() == 0 + with pytest.raises(ValueError, match="Unknown feed type"): + converter.convert(stream, stream_info) + + @pytest.mark.parametrize( "root_prefix, child_prefix", [("", ""), ("a:", "a:"), ("a:", "b:"), ("a:", ""), ("", "a:")],