Skip to content

Commit c0fcd54

Browse files
afourneySanHsien
authored andcommitted
fix(youtube): fall back to HTML when no video content is extracted (microsoft#2469)
(cherry picked from commit 5640da7)
1 parent 4421e9c commit c0fcd54

3 files changed

Lines changed: 183 additions & 9 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from .._base_converter import DocumentConverter, DocumentConverterResult
99
from .._stream_info import StreamInfo
10+
from ._html_converter import HtmlConverter
1011

1112
# Optional YouTube transcription support
1213
try:
@@ -35,7 +36,7 @@
3536

3637

3738
class YouTubeConverter(DocumentConverter):
38-
"""Handle YouTube specially, focusing on the video title, description, and transcript."""
39+
"""Extract YouTube metadata and transcripts, or fall back to HTML."""
3940

4041
def _get_video_id(self, url: str) -> Union[str, None]:
4142
"""Extract a YouTube video ID from supported URL formats."""
@@ -92,6 +93,7 @@ def convert(
9293
**kwargs: Any, # Options to pass to the converter
9394
) -> DocumentConverterResult:
9495
# Parse the stream
96+
start_position = file_stream.tell()
9597
encoding = "utf-8" if stream_info.charset is None else stream_info.charset
9698
soup = bs4.BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)
9799

@@ -134,7 +136,7 @@ def convert(
134136
pass
135137

136138
# Start preparing the page
137-
webpage_text = "# YouTube\n"
139+
webpage_text = ""
138140

139141
title = self._get(metadata, ["title", "og:title", "name"]) or ""
140142

@@ -203,8 +205,12 @@ def convert(
203205
if transcript_text:
204206
webpage_text += f"\n### Transcript\n{transcript_text}\n"
205207

208+
if not webpage_text:
209+
file_stream.seek(start_position)
210+
return HtmlConverter().convert(file_stream, stream_info, **kwargs)
211+
206212
return DocumentConverterResult(
207-
markdown=webpage_text,
213+
markdown="# YouTube\n" + webpage_text,
208214
title=title,
209215
)
210216

‎packages/markitdown/tests/test_module_misc.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,7 +1339,7 @@ def test_pptx_chart_with_title_text_frame() -> None:
13391339

13401340

13411341
def test_youtube_converter_missing_title_metadata() -> None:
1342-
"""Test that YouTubeConverter converts streams with and without title metadata without raising AssertionError."""
1342+
"""Missing titles fall back to HTML when no video content is extracted."""
13431343
from unittest.mock import patch
13441344
from markitdown.converters._youtube_converter import YouTubeConverter
13451345

@@ -1358,24 +1358,24 @@ def test_youtube_converter_missing_title_metadata() -> None:
13581358
html_content_no_title = b"<html><head></head><body>Video Content</body></html>"
13591359
stream_no_title = io.BytesIO(html_content_no_title)
13601360
result_no_title = converter.convert(stream_no_title, stream_info)
1361-
assert result_no_title.title == ""
1362-
assert "# YouTube" in result_no_title.markdown
1361+
assert result_no_title.title is None
1362+
assert result_no_title.markdown == "Video Content"
13631363

13641364
# Case 2: Stream with an empty <title> tag
13651365
html_content_empty_title = (
13661366
b"<html><head><title></title></head><body>Video Content</body></html>"
13671367
)
13681368
stream_empty_title = io.BytesIO(html_content_empty_title)
13691369
result_empty_title = converter.convert(stream_empty_title, stream_info)
1370-
assert result_empty_title.title == ""
1371-
assert "# YouTube" in result_empty_title.markdown
1370+
assert result_empty_title.title is None
1371+
assert result_empty_title.markdown == "Video Content"
13721372

13731373
# Case 3: Stream whose title is only available from the <title> tag
13741374
html_content_title_tag = b"<html><head><title>Fallback Title</title></head><body>Video Content</body></html>"
13751375
stream_title_tag = io.BytesIO(html_content_title_tag)
13761376
result_title_tag = converter.convert(stream_title_tag, stream_info)
13771377
assert result_title_tag.title == "Fallback Title"
1378-
assert "# YouTube" in result_title_tag.markdown
1378+
assert result_title_tag.markdown == "# YouTube\n\n## Fallback Title\n"
13791379

13801380

13811381
def test_zip_duplicate_filenames_preserve_each_entry() -> None:
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import io
2+
from types import SimpleNamespace
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
7+
from markitdown import MarkItDown, StreamInfo
8+
from markitdown.converters import HtmlConverter, YouTubeConverter
9+
import markitdown.converters._youtube_converter as youtube_module
10+
11+
12+
@pytest.fixture
13+
def transcript_api(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
14+
api = MagicMock()
15+
api.return_value.list.return_value = []
16+
api.return_value.fetch.return_value = []
17+
monkeypatch.setattr(youtube_module, "YouTubeTranscriptApi", api, raising=False)
18+
monkeypatch.setattr(youtube_module, "IS_YOUTUBE_TRANSCRIPT_CAPABLE", False)
19+
return api
20+
21+
22+
@pytest.mark.parametrize("head", ["", "<title></title>"])
23+
@pytest.mark.parametrize("use_dispatcher", [False, True])
24+
@pytest.mark.parametrize("transcript_capable", [False, True])
25+
def test_youtube_empty_extraction_preserves_html(
26+
head: str,
27+
use_dispatcher: bool,
28+
transcript_capable: bool,
29+
transcript_api: MagicMock,
30+
monkeypatch: pytest.MonkeyPatch,
31+
) -> None:
32+
monkeypatch.setattr(
33+
youtube_module, "IS_YOUTUBE_TRANSCRIPT_CAPABLE", transcript_capable
34+
)
35+
html = (
36+
f"<html><head>{head}"
37+
"<script>do_not_render_script()</script>"
38+
"<style>.do_not_render_style { color: red; }</style></head><body>"
39+
"<h1>Saved transcript</h1>"
40+
"<table><tr><th>Time</th><th>Text</th></tr>"
41+
"<tr><td>00:01</td><td>caf\u00e9</td></tr></table></body></html>"
42+
).encode("windows-1252")
43+
prefix = b"<p>Ignored stream prefix</p>"
44+
stream = io.BytesIO(prefix + html)
45+
stream.seek(len(prefix))
46+
stream_info = StreamInfo(
47+
mimetype="text/html",
48+
extension=".html",
49+
charset="windows-1252",
50+
url="https://www.youtube.com/watch?v=12345",
51+
)
52+
53+
if use_dispatcher:
54+
result = MarkItDown().convert_stream(
55+
stream, stream_info=stream_info, heading_style="ATX_CLOSED"
56+
)
57+
else:
58+
result = YouTubeConverter().convert(
59+
stream, stream_info, heading_style="ATX_CLOSED"
60+
)
61+
62+
expected = HtmlConverter().convert(
63+
io.BytesIO(html), stream_info, heading_style="ATX_CLOSED"
64+
)
65+
assert result.markdown == expected.markdown
66+
assert result.title == expected.title
67+
assert "# Saved transcript #" in result.markdown
68+
assert "| 00:01 | caf\u00e9 |" in result.markdown
69+
assert "Ignored stream prefix" not in result.markdown
70+
assert "do_not_render" not in result.markdown
71+
if transcript_capable:
72+
transcript_api.return_value.fetch.assert_called_once()
73+
else:
74+
transcript_api.assert_not_called()
75+
76+
77+
@pytest.mark.parametrize(
78+
("head", "title", "content"),
79+
[
80+
("<title>Video title</title>", "Video title", "\n## Video title\n"),
81+
(
82+
'<meta property="og:title" content="Video title">',
83+
"Video title",
84+
"\n## Video title\n",
85+
),
86+
(
87+
'<meta itemprop="name" content="Video title">',
88+
"Video title",
89+
"\n## Video title\n",
90+
),
91+
(
92+
'<meta name="description" content="Video description">',
93+
"",
94+
"\n### Description\nVideo description\n",
95+
),
96+
(
97+
'<meta property="og:description" content="Video description">',
98+
"",
99+
"\n### Description\nVideo description\n",
100+
),
101+
(
102+
'<script>var ytInitialData = {"attributedDescriptionBodyText": '
103+
'{"content": "Video description"}};</script>',
104+
"",
105+
"\n### Description\nVideo description\n",
106+
),
107+
(
108+
'<meta itemprop="interactionCount" content="42">'
109+
'<meta name="keywords" content="example">'
110+
'<meta itemprop="duration" content="PT1M">',
111+
"",
112+
"\n### Video Metadata\n"
113+
"- **Views:** 42\n- **Keywords:** example\n- **Runtime:** PT1M\n\n",
114+
),
115+
],
116+
)
117+
def test_youtube_preserves_metadata_output(
118+
head: str, title: str, content: str, transcript_api: MagicMock
119+
) -> None:
120+
html = f"<html><head>{head}</head><body>Unused HTML body</body></html>"
121+
result = YouTubeConverter().convert(
122+
io.BytesIO(html.encode("utf-8")),
123+
StreamInfo(
124+
mimetype="text/html",
125+
charset="utf-8",
126+
url="https://www.youtube.com/watch?v=12345",
127+
),
128+
)
129+
130+
assert result.title == title
131+
assert result.markdown == "# YouTube\n" + content
132+
transcript_api.assert_not_called()
133+
134+
135+
@pytest.mark.parametrize("title", ["", "Video title"])
136+
def test_youtube_preserves_library_transcript(
137+
title: str,
138+
transcript_api: MagicMock,
139+
monkeypatch: pytest.MonkeyPatch,
140+
) -> None:
141+
monkeypatch.setattr(youtube_module, "IS_YOUTUBE_TRANSCRIPT_CAPABLE", True)
142+
transcript_api.return_value.list.return_value = [
143+
SimpleNamespace(language_code="fr")
144+
]
145+
transcript_api.return_value.fetch.return_value = [
146+
SimpleNamespace(text="Bonjour"),
147+
SimpleNamespace(text="tout le monde"),
148+
]
149+
head = f"<title>{title}</title>" if title else ""
150+
html = f"<html><head>{head}</head><body>Unused HTML body</body></html>"
151+
152+
result = YouTubeConverter().convert(
153+
io.BytesIO(html.encode("utf-8")),
154+
StreamInfo(
155+
mimetype="text/html",
156+
charset="utf-8",
157+
url="https://www.youtube.com/watch?v=12345",
158+
),
159+
youtube_transcript_languages=["fr"],
160+
)
161+
162+
title_section = f"\n## {title}\n" if title else ""
163+
assert result.title == title
164+
assert result.markdown == (
165+
f"# YouTube\n{title_section}\n### Transcript\nBonjour tout le monde\n"
166+
)
167+
transcript_api.return_value.list.assert_called_once_with("12345")
168+
transcript_api.return_value.fetch.assert_called_once_with("12345", languages=["fr"])

0 commit comments

Comments
 (0)