Skip to content

Commit c729e40

Browse files
committed
fix(html): retain Python 3.10 converters
Keep BeautifulSoup and markdownify on Python 3.10 while using turbohtml on Python 3.11 and later. Preserve the existing plain-text fallback and strict recursion errors on Python 3.10. Select dependencies by Python version so the package remains installable without turbohtml there.
1 parent 1915fa9 commit c729e40

16 files changed

Lines changed: 564 additions & 73 deletions

‎packages/markitdown/pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ classifiers = [
2727
dependencies = [
2828
"beautifulsoup4",
2929
"requests",
30-
"turbohtml>=1.14.1",
30+
"markdownify; python_version < '3.11'",
31+
"turbohtml>=1.14.1; python_version >= '3.11'",
3132
"magika~=0.6.1",
3233
"charset-normalizer",
3334
"defusedxml",

‎packages/markitdown/src/markitdown/converters/_bing_serp_converter.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@
33
import base64
44
import binascii
55
import re
6+
import sys
67
from typing import TYPE_CHECKING, BinaryIO, Final, cast
78
from urllib.parse import parse_qs, urlparse
89

910
from .._base_converter import DocumentConverter, DocumentConverterResult
1011
from .._stream_info import StreamInfo
11-
from ._markdown import _CustomMarkdown, _document_title, _parse_html
12+
13+
if sys.version_info < (3, 11):
14+
from ._legacy_html import convert_bing
15+
else:
16+
from ._markdown import _CustomMarkdown, _document_title, _parse_html
1217

1318
if TYPE_CHECKING:
14-
from ._markdown import _OPTION_VALUE, _MarkdownOptions
19+
from ._markdown_options import _OPTION_VALUE, _MarkdownOptions
1520

1621

1722
_ACCEPTED_MIME_TYPE_PREFIXES: Final = [
@@ -56,6 +61,10 @@ def convert(
5661
stream_info: StreamInfo,
5762
**kwargs: _OPTION_VALUE,
5863
) -> DocumentConverterResult:
64+
if sys.version_info < (3, 11):
65+
return convert_bing(
66+
file_stream, stream_info, cast("_MarkdownOptions", kwargs)
67+
)
5968
assert stream_info.url is not None
6069

6170
query: Final = parse_qs(urlparse(stream_info.url).query).get("q", [""])[0]

‎packages/markitdown/src/markitdown/converters/_html_converter.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
from __future__ import annotations
22

33
import io
4+
import sys
45
from typing import TYPE_CHECKING, BinaryIO, Final, cast
56

6-
import turbohtml
7-
87
from .._base_converter import DocumentConverter, DocumentConverterResult
98
from .._stream_info import StreamInfo
10-
from ._markdown import _CustomMarkdown, _document_title, _parse_html_with_source
9+
10+
if sys.version_info < (3, 11):
11+
from ._legacy_html import convert_html
12+
else:
13+
import turbohtml
14+
15+
from ._markdown import _CustomMarkdown, _document_title, _parse_html_with_source
1116

1217
if TYPE_CHECKING:
13-
from ._markdown import _OPTION_VALUE, _MarkdownOptions
18+
from ._markdown_options import _OPTION_VALUE, _MarkdownOptions
1419

1520

1621
_ACCEPTED_MIME_TYPE_PREFIXES: Final = [
@@ -49,6 +54,10 @@ def convert(
4954
stream_info: StreamInfo,
5055
**kwargs: _OPTION_VALUE,
5156
) -> DocumentConverterResult:
57+
if sys.version_info < (3, 11):
58+
return convert_html(
59+
file_stream, stream_info, cast("_MarkdownOptions", kwargs)
60+
)
5261
doc, source = _parse_html_with_source(file_stream, stream_info)
5362
body_elm: Final = doc.select_one("body")
5463
# Fragment context retains content moved into a synthetic head.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import binascii
5+
import re
6+
import warnings
7+
from typing import TYPE_CHECKING, BinaryIO, Final
8+
from urllib.parse import parse_qs, urljoin, urlparse
9+
10+
from bs4 import BeautifulSoup, Tag
11+
12+
from .._base_converter import DocumentConverterResult
13+
from .._stream_info import StreamInfo
14+
from ._markdownify import _CustomMarkdownify
15+
16+
if TYPE_CHECKING:
17+
from ._markdown_options import _MarkdownOptions
18+
19+
20+
def convert_html(
21+
stream: BinaryIO, info: StreamInfo, options: _MarkdownOptions
22+
) -> DocumentConverterResult:
23+
soup: Final = _parse_html(stream, info)
24+
return DocumentConverterResult(
25+
markdown=_convert(soup.select_one("body") or soup, options).strip(),
26+
title=soup.title.string if soup.title else None,
27+
)
28+
29+
30+
def convert_bing(
31+
stream: BinaryIO, info: StreamInfo, options: _MarkdownOptions
32+
) -> DocumentConverterResult:
33+
soup: Final = BeautifulSoup(
34+
stream, "html.parser", from_encoding=info.charset or "utf-8"
35+
)
36+
for title in soup.select(".tptt"):
37+
if title.string:
38+
title.string += " "
39+
for slug in soup.select(".algoSlug_icon"):
40+
slug.extract()
41+
results: Final[list[str]] = []
42+
for result in soup.select(".b_algo"):
43+
for anchor in result.select("a[href]"):
44+
parameters = parse_qs(urlparse(str(anchor["href"])).query)
45+
# Bing prefixes the Base64URL destination with two characters.
46+
if destination := parameters.get("u"):
47+
try:
48+
anchor["href"] = base64.b64decode(
49+
destination[0][2:].strip() + "==", altchars="-_"
50+
).decode("utf-8")
51+
except (UnicodeDecodeError, binascii.Error):
52+
pass
53+
results.append(
54+
"\n".join(
55+
line.strip()
56+
for line in re.split(
57+
r"\n+", _CustomMarkdownify(**options).convert_soup(result).strip()
58+
)
59+
if line.strip()
60+
)
61+
)
62+
query: Final = parse_qs(urlparse(info.url or "").query).get("q", [""])[0]
63+
return DocumentConverterResult(
64+
markdown=f"## A Bing search for '{query}' found the following results:\n\n"
65+
+ "\n\n".join(results),
66+
title=soup.title.string if soup.title else None,
67+
)
68+
69+
70+
def convert_wikipedia(
71+
stream: BinaryIO, info: StreamInfo, options: _MarkdownOptions
72+
) -> DocumentConverterResult:
73+
soup: Final = _parse_html(stream, info)
74+
body: Final = soup.select_one("div#mw-content-text")
75+
title = soup.title.string if soup.title else None
76+
if body:
77+
if heading := soup.select_one("span.mw-page-title-main") or soup.select_one(
78+
"h1#firstHeading"
79+
):
80+
title = heading.get_text()
81+
title = title.strip() or None if title else None
82+
markdown = (f"# {title}\n\n" if title else "") + _CustomMarkdownify(
83+
**options
84+
).convert_soup(body)
85+
else:
86+
markdown = _CustomMarkdownify(**options).convert_soup(soup)
87+
return DocumentConverterResult(markdown=markdown, title=title)
88+
89+
90+
def convert_feed(content: str, base_url: str, options: _MarkdownOptions) -> str:
91+
soup: Final = BeautifulSoup(content, "html.parser")
92+
pending: Final[list[tuple[Tag, str]]] = [(soup, base_url)]
93+
while pending:
94+
node, inherited = pending.pop()
95+
override = node.get("xml:base")
96+
current = (
97+
_resolve_url(inherited, override)
98+
if isinstance(override, str)
99+
else inherited
100+
)
101+
attributes = (
102+
("href",)
103+
if node.name == "a"
104+
else ("src", "data-src")
105+
if node.name == "img"
106+
else ()
107+
)
108+
for attribute in attributes:
109+
reference = node.get(attribute)
110+
if isinstance(reference, str) and (reference or attribute == "href"):
111+
node[attribute] = _resolve_url(current, reference)
112+
pending.extend(
113+
(child, current) for child in node.children if isinstance(child, Tag)
114+
)
115+
return _convert(soup, options)
116+
117+
118+
def _parse_html(stream: BinaryIO, info: StreamInfo) -> BeautifulSoup:
119+
soup: Final = BeautifulSoup(
120+
stream, "html.parser", from_encoding=info.charset or "utf-8"
121+
)
122+
for element in soup.select("script, style"):
123+
element.extract()
124+
return soup
125+
126+
127+
def _convert(soup: Tag, options: _MarkdownOptions) -> str:
128+
try:
129+
return _CustomMarkdownify(**options).convert_soup(soup)
130+
except RecursionError:
131+
if options.get("strict", False):
132+
raise
133+
warnings.warn(
134+
"HTML content is too deeply nested for markdown conversion (RecursionError). "
135+
"Falling back to plain-text extraction.",
136+
stacklevel=2,
137+
)
138+
return soup.get_text("\n", strip=True)
139+
140+
141+
def _resolve_url(base: str, reference: str) -> str:
142+
try:
143+
return urljoin(base, reference)
144+
except ValueError:
145+
return reference
146+
147+
148+
__all__ = ["convert_bing", "convert_feed", "convert_html", "convert_wikipedia"]

‎packages/markitdown/src/markitdown/converters/_markdown.py‎

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import re
4-
from typing import TYPE_CHECKING, BinaryIO, Final, Literal, TypedDict, TypeVar
4+
from typing import TYPE_CHECKING, BinaryIO, Final, Literal
55
from urllib.parse import quote, urlparse, urlunparse
66

77
import turbohtml
@@ -11,12 +11,11 @@
1111
from .._stream_info import StreamInfo
1212

1313
if TYPE_CHECKING:
14-
from collections.abc import Callable, Sequence
14+
from collections.abc import Callable
1515

1616
from typing_extensions import Unpack
1717

18-
19-
_OPTION_VALUE = TypeVar("_OPTION_VALUE")
18+
from ._markdown_options import _MarkdownOptions
2019

2120
_PERCENT_ENCODED_OCTET: Final = re.compile(r"%[0-9A-Fa-f]{2}")
2221

@@ -30,9 +29,9 @@ class _CustomMarkdown:
3029
def __init__(self, **options: Unpack[_MarkdownOptions]) -> None:
3130
self._code_language: Final[str] = options.get("code_language", "")
3231
self._explicit_code_language: Final[str | None] = options.get("code_language")
33-
self._code_language_callback: Final[Callable[[Tag], str | None] | None] = (
34-
options.get("code_language_callback")
35-
)
32+
self._code_language_callback: Final[
33+
Callable[[Tag], str | None] | None
34+
] = options.get("code_language_callback")
3635
self._strip_pre: Final[str | None] = options.get("strip_pre", "strip")
3736
self._pre_languages: Final[dict[Element, str]] = {}
3837
self._keep_data_uris: Final[bool] = options.get("keep_data_uris", False)
@@ -271,35 +270,8 @@ def _document_title(doc: Document | Element) -> str | None:
271270
)
272271

273272

274-
class _MarkdownOptions(TypedDict, total=False):
275-
code_language: str
276-
code_language_callback: Callable[[Tag], str | None]
277-
strip_pre: str | None
278-
keep_data_uris: bool
279-
autolinks: bool
280-
default_title: bool
281-
strong_em_symbol: str
282-
sub_symbol: str
283-
sup_symbol: str
284-
strip: Sequence[str] | None
285-
convert: Sequence[str] | None
286-
heading_style: str
287-
bullets: str
288-
table_infer_header: bool
289-
escape_misc: bool
290-
escape_asterisks: bool
291-
escape_underscores: bool
292-
wrap_width: int
293-
wrap: bool
294-
newline_style: str
295-
strip_document: Literal["strip", "lstrip", "rstrip"] | None
296-
strict: bool
297-
298-
299273
__all__ = [
300-
"_OPTION_VALUE",
301274
"_CustomMarkdown",
302-
"_MarkdownOptions",
303275
"_document_title",
304276
"_parse_html",
305277
"_parse_html_with_source",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Literal, TypedDict, TypeVar
4+
5+
if TYPE_CHECKING:
6+
from collections.abc import Callable, Sequence
7+
8+
from bs4 import Tag
9+
10+
_OPTION_VALUE = TypeVar("_OPTION_VALUE")
11+
12+
13+
class _MarkdownOptions(TypedDict, total=False):
14+
code_language: str
15+
code_language_callback: Callable[[Tag], str | None]
16+
strip_pre: str | None
17+
keep_data_uris: bool
18+
autolinks: bool
19+
default_title: bool
20+
strong_em_symbol: str
21+
sub_symbol: str
22+
sup_symbol: str
23+
strip: Sequence[str] | None
24+
convert: Sequence[str] | None
25+
heading_style: str
26+
bullets: str
27+
table_infer_header: bool
28+
escape_misc: bool
29+
escape_asterisks: bool
30+
escape_underscores: bool
31+
wrap_width: int
32+
wrap: bool
33+
newline_style: str
34+
strip_document: Literal["strip", "lstrip", "rstrip"] | None
35+
strict: bool
36+
37+
38+
__all__ = ["_OPTION_VALUE", "_MarkdownOptions"]

0 commit comments

Comments
 (0)