Merge branch 'main' into feature/fix-code-comments
This commit is contained in:
@@ -13,6 +13,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from xml.dom import minidom
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse
|
from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse
|
||||||
from warnings import warn, resetwarnings, catch_warnings
|
from warnings import warn, resetwarnings, catch_warnings
|
||||||
@@ -222,6 +223,143 @@ class HtmlConverter(DocumentConverter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RSSConverter(DocumentConverter):
|
||||||
|
"""Convert RSS / Atom type to markdown"""
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self, local_path: str, **kwargs
|
||||||
|
) -> Union[None, DocumentConverterResult]:
|
||||||
|
# Bail if not RSS type
|
||||||
|
extension = kwargs.get("file_extension", "")
|
||||||
|
if extension.lower() not in [".xml", ".rss", ".atom"]:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
doc = minidom.parse(local_path)
|
||||||
|
except BaseException as _:
|
||||||
|
return None
|
||||||
|
result = None
|
||||||
|
if doc.getElementsByTagName("rss"):
|
||||||
|
# A RSS feed must have a root element of <rss>
|
||||||
|
result = self._parse_rss_type(doc)
|
||||||
|
elif doc.getElementsByTagName("feed"):
|
||||||
|
root = doc.getElementsByTagName("feed")[0]
|
||||||
|
if root.getElementsByTagName("entry"):
|
||||||
|
# An Atom feed must have a root element of <feed> and at least one <entry>
|
||||||
|
result = self._parse_atom_type(doc)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# not rss or atom
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_atom_type(
|
||||||
|
self, doc: minidom.Document
|
||||||
|
) -> Union[None, DocumentConverterResult]:
|
||||||
|
"""Parse the type of an Atom feed.
|
||||||
|
|
||||||
|
Returns None if the feed type is not recognized or something goes wrong.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = doc.getElementsByTagName("feed")[0]
|
||||||
|
title = self._get_data_by_tag_name(root, "title")
|
||||||
|
subtitle = self._get_data_by_tag_name(root, "subtitle")
|
||||||
|
entries = root.getElementsByTagName("entry")
|
||||||
|
md_text = f"# {title}\n"
|
||||||
|
if subtitle:
|
||||||
|
md_text += f"{subtitle}\n"
|
||||||
|
for entry in entries:
|
||||||
|
entry_title = self._get_data_by_tag_name(entry, "title")
|
||||||
|
entry_summary = self._get_data_by_tag_name(entry, "summary")
|
||||||
|
entry_updated = self._get_data_by_tag_name(entry, "updated")
|
||||||
|
entry_content = self._get_data_by_tag_name(entry, "content")
|
||||||
|
|
||||||
|
if entry_title:
|
||||||
|
md_text += f"\n## {entry_title}\n"
|
||||||
|
if entry_updated:
|
||||||
|
md_text += f"Updated on: {entry_updated}\n"
|
||||||
|
if entry_summary:
|
||||||
|
md_text += self._parse_content(entry_summary)
|
||||||
|
if entry_content:
|
||||||
|
md_text += self._parse_content(entry_content)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
title=title,
|
||||||
|
text_content=md_text,
|
||||||
|
)
|
||||||
|
except BaseException as _:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_rss_type(
|
||||||
|
self, doc: minidom.Document
|
||||||
|
) -> Union[None, DocumentConverterResult]:
|
||||||
|
"""Parse the type of an RSS feed.
|
||||||
|
|
||||||
|
Returns None if the feed type is not recognized or something goes wrong.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = doc.getElementsByTagName("rss")[0]
|
||||||
|
channel = root.getElementsByTagName("channel")
|
||||||
|
if not channel:
|
||||||
|
return None
|
||||||
|
channel = channel[0]
|
||||||
|
channel_title = self._get_data_by_tag_name(channel, "title")
|
||||||
|
channel_description = self._get_data_by_tag_name(channel, "description")
|
||||||
|
items = channel.getElementsByTagName("item")
|
||||||
|
if channel_title:
|
||||||
|
md_text = f"# {channel_title}\n"
|
||||||
|
if channel_description:
|
||||||
|
md_text += f"{channel_description}\n"
|
||||||
|
if not items:
|
||||||
|
items = []
|
||||||
|
for item in items:
|
||||||
|
title = self._get_data_by_tag_name(item, "title")
|
||||||
|
description = self._get_data_by_tag_name(item, "description")
|
||||||
|
pubDate = self._get_data_by_tag_name(item, "pubDate")
|
||||||
|
content = self._get_data_by_tag_name(item, "content:encoded")
|
||||||
|
|
||||||
|
if title:
|
||||||
|
md_text += f"\n## {title}\n"
|
||||||
|
if pubDate:
|
||||||
|
md_text += f"Published on: {pubDate}\n"
|
||||||
|
if description:
|
||||||
|
md_text += self._parse_content(description)
|
||||||
|
if content:
|
||||||
|
md_text += self._parse_content(content)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
title=channel_title,
|
||||||
|
text_content=md_text,
|
||||||
|
)
|
||||||
|
except BaseException as _:
|
||||||
|
print(traceback.format_exc())
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_content(self, content: str) -> str:
|
||||||
|
"""Parse the content of an RSS feed item"""
|
||||||
|
try:
|
||||||
|
# using bs4 because many RSS feeds have HTML-styled content
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
return _CustomMarkdownify().convert_soup(soup)
|
||||||
|
except BaseException as _:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _get_data_by_tag_name(
|
||||||
|
self, element: minidom.Element, tag_name: str
|
||||||
|
) -> Union[str, None]:
|
||||||
|
"""Get data from first child element with the given tag name.
|
||||||
|
Returns None when no such element is found.
|
||||||
|
"""
|
||||||
|
nodes = element.getElementsByTagName(tag_name)
|
||||||
|
if not nodes:
|
||||||
|
return None
|
||||||
|
fc = nodes[0].firstChild
|
||||||
|
if fc:
|
||||||
|
return fc.data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class WikipediaConverter(DocumentConverter):
|
class WikipediaConverter(DocumentConverter):
|
||||||
"""Handle Wikipedia pages separately, focusing only on the main document content."""
|
"""Handle Wikipedia pages separately, focusing only on the main document content."""
|
||||||
|
|
||||||
@@ -403,6 +541,67 @@ class YouTubeConverter(DocumentConverter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class IpynbConverter(DocumentConverter):
|
||||||
|
"""Converts Jupyter Notebook (.ipynb) files to Markdown."""
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self, local_path: str, **kwargs: Any
|
||||||
|
) -> Union[None, DocumentConverterResult]:
|
||||||
|
# Bail if not ipynb
|
||||||
|
extension = kwargs.get("file_extension", "")
|
||||||
|
if extension.lower() != ".ipynb":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse and convert the notebook
|
||||||
|
result = None
|
||||||
|
with open(local_path, "rt", encoding="utf-8") as fh:
|
||||||
|
notebook_content = json.load(fh)
|
||||||
|
result = self._convert(notebook_content)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _convert(self, notebook_content: dict) -> Union[None, DocumentConverterResult]:
|
||||||
|
"""Helper function that converts notebook JSON content to Markdown."""
|
||||||
|
try:
|
||||||
|
md_output = []
|
||||||
|
title = None
|
||||||
|
|
||||||
|
for cell in notebook_content.get("cells", []):
|
||||||
|
cell_type = cell.get("cell_type", "")
|
||||||
|
source_lines = cell.get("source", [])
|
||||||
|
|
||||||
|
if cell_type == "markdown":
|
||||||
|
md_output.append("".join(source_lines))
|
||||||
|
|
||||||
|
# Extract the first # heading as title if not already found
|
||||||
|
if title is None:
|
||||||
|
for line in source_lines:
|
||||||
|
if line.startswith("# "):
|
||||||
|
title = line.lstrip("# ").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
elif cell_type == "code":
|
||||||
|
# Code cells are wrapped in Markdown code blocks
|
||||||
|
md_output.append(f"```python\n{''.join(source_lines)}\n```")
|
||||||
|
elif cell_type == "raw":
|
||||||
|
md_output.append(f"```\n{''.join(source_lines)}\n```")
|
||||||
|
|
||||||
|
md_text = "\n\n".join(md_output)
|
||||||
|
|
||||||
|
# Check for title in notebook metadata
|
||||||
|
title = notebook_content.get("metadata", {}).get("title", title)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
title=title,
|
||||||
|
text_content=md_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise FileConversionException(
|
||||||
|
f"Error converting .ipynb file: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
class BingSerpConverter(DocumentConverter):
|
class BingSerpConverter(DocumentConverter):
|
||||||
"""
|
"""
|
||||||
Handle Bing results pages (only the organic search results).
|
Handle Bing results pages (only the organic search results).
|
||||||
@@ -1061,6 +1260,7 @@ class MarkItDown:
|
|||||||
# To this end, the most specific converters should appear below the most generic converters
|
# To this end, the most specific converters should appear below the most generic converters
|
||||||
self.register_page_converter(PlainTextConverter())
|
self.register_page_converter(PlainTextConverter())
|
||||||
self.register_page_converter(HtmlConverter())
|
self.register_page_converter(HtmlConverter())
|
||||||
|
self.register_page_converter(RSSConverter())
|
||||||
self.register_page_converter(WikipediaConverter())
|
self.register_page_converter(WikipediaConverter())
|
||||||
self.register_page_converter(YouTubeConverter())
|
self.register_page_converter(YouTubeConverter())
|
||||||
self.register_page_converter(BingSerpConverter())
|
self.register_page_converter(BingSerpConverter())
|
||||||
@@ -1070,6 +1270,7 @@ class MarkItDown:
|
|||||||
self.register_page_converter(WavConverter())
|
self.register_page_converter(WavConverter())
|
||||||
self.register_page_converter(Mp3Converter())
|
self.register_page_converter(Mp3Converter())
|
||||||
self.register_page_converter(ImageConverter())
|
self.register_page_converter(ImageConverter())
|
||||||
|
self.register_page_converter(IpynbConverter())
|
||||||
self.register_page_converter(PdfConverter())
|
self.register_page_converter(PdfConverter())
|
||||||
self.register_page_converter(ZipConverter())
|
self.register_page_converter(ZipConverter())
|
||||||
|
|
||||||
|
|||||||
89
tests/test_files/test_notebook.ipynb
vendored
Normal file
89
tests/test_files/test_notebook.ipynb
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0f61db80",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Test Notebook"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 11,
|
||||||
|
"id": "3f2a5bbd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"markitdown\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"print('markitdown')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "9b9c0468",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Code Cell Below"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 10,
|
||||||
|
"id": "37d8088a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"42\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"# comment in code\n",
|
||||||
|
"print(42)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2e3177bd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"End\n",
|
||||||
|
"\n",
|
||||||
|
"---"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.12.8"
|
||||||
|
},
|
||||||
|
"title": "Test Notebook Title"
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
1
tests/test_files/test_rss.xml
vendored
Normal file
1
tests/test_files/test_rss.xml
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -90,6 +90,13 @@ BLOG_TEST_STRINGS = [
|
|||||||
"an example where high cost can easily prevent a generic complex",
|
"an example where high cost can easily prevent a generic complex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
RSS_TEST_STRINGS = [
|
||||||
|
"The Official Microsoft Blog",
|
||||||
|
"In the case of AI, it is absolutely true that the industry is moving incredibly fast",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
WIKIPEDIA_TEST_URL = "https://en.wikipedia.org/wiki/Microsoft"
|
WIKIPEDIA_TEST_URL = "https://en.wikipedia.org/wiki/Microsoft"
|
||||||
WIKIPEDIA_TEST_STRINGS = [
|
WIKIPEDIA_TEST_STRINGS = [
|
||||||
"Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
|
"Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
|
||||||
@@ -224,6 +231,12 @@ def test_markitdown_local() -> None:
|
|||||||
for test_string in SERP_TEST_STRINGS:
|
for test_string in SERP_TEST_STRINGS:
|
||||||
assert test_string in text_content
|
assert test_string in text_content
|
||||||
|
|
||||||
|
# Test RSS processing
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test_rss.xml"))
|
||||||
|
text_content = result.text_content.replace("\\", "")
|
||||||
|
for test_string in RSS_TEST_STRINGS:
|
||||||
|
assert test_string in text_content
|
||||||
|
|
||||||
## Test non-UTF-8 encoding
|
## Test non-UTF-8 encoding
|
||||||
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test_mskanji.csv"))
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test_mskanji.csv"))
|
||||||
text_content = result.text_content.replace("\\", "")
|
text_content = result.text_content.replace("\\", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user