Files
VoiceStudio/backend/tests/test_longform_import_parse_failure.py
T
5ea8389cd4 fix: return empty text instead of partial garbage when EPUB HTML parse fails (#1161)
* fix: return empty text instead of partial garbage when EPUB HTML parse fails

`_html_to_title_body` used `except Exception: pass` when the
HTMLParser raised. The parser accumulates state incrementally across
`handle_starttag`/`handle_data`/etc. callbacks — so a mid-parse
failure returns whatever partial/corrupted `title` and `_parts`
were accumulated before the error, with zero indication anything went
wrong. EPUB chapter content would be silently garbled.

Now returns ("", "") and logs the parse failure with traceback.

* fix: keep partial chapter text when EPUB HTML parsing fails mid-chapter

The warning log stays — a silent 'except: pass' hid real failures. But
returning empty text made the caller's 'if not body.strip(): continue'
silently drop the whole chapter from the audiobook, trading a possibly-
truncated chapter for a definitely-missing one. Keep whatever the
extractor collected before the failure and log the event. Regression
test builds a real 3-chapter EPUB, injects a mid-chapter parser failure,
and asserts the chapter survives with its pre-failure text (fail-before
verified against the empty-return version).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:19:02 +05:30

95 lines
3.8 KiB
Python

"""A mid-chapter HTML parse failure must not silently drop the chapter.
`_html_to_title_body` swallows parser exceptions. If it returned empty text
on failure, `epub_to_chapter_script`'s `if not body.strip(): continue` would
silently omit that chapter from the audiobook — the user only finds out when
the narration skips from chapter 1 to chapter 3. The contract instead: keep
the partial text extracted before the failure, and log a warning (#1161).
"""
import io
import zipfile
import pytest
from services import longform_import as li
def _make_epub(n_chapters: int = 3) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("mimetype", "application/epub+zip")
z.writestr(
"META-INF/container.xml",
'<?xml version="1.0"?>'
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">'
'<rootfiles><rootfile full-path="OEBPS/content.opf"'
' media-type="application/oebps-package+xml"/></rootfiles></container>',
)
manifest, spine = [], []
for i in range(1, n_chapters + 1):
z.writestr(
f"OEBPS/ch{i}.xhtml",
f"<html><head><title>Chapter {i}</title></head><body>"
f"<h1>Chapter {i}</h1>"
f"<p>Opening paragraph of chapter {i}.</p>"
f"<p>Closing paragraph of chapter {i}.</p></body></html>",
)
manifest.append(f'<item id="c{i}" href="ch{i}.xhtml" media-type="application/xhtml+xml"/>')
spine.append(f'<itemref idref="c{i}"/>')
z.writestr(
"OEBPS/content.opf",
'<?xml version="1.0"?>'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Test Book</dc:title></metadata>'
f"<manifest>{''.join(manifest)}</manifest><spine>{''.join(spine)}</spine></package>",
)
return buf.getvalue()
def test_clean_epub_yields_all_chapters():
script = li.epub_to_chapter_script(_make_epub())
assert script.count("# Chapter") == 3
def test_mid_chapter_parse_failure_keeps_partial_text(monkeypatch, caplog):
"""Parser dies halfway through chapter 2 → chapter 2 stays, with the text
extracted up to the failure point; the failure is logged, not silent."""
real_feed = li._TextExtractor.feed
def poisoned_feed(self, data):
if "chapter 2" in data:
half = data.index("Closing")
real_feed(self, data[:half])
raise ValueError("simulated parser blow-up mid-chapter")
return real_feed(self, data)
monkeypatch.setattr(li._TextExtractor, "feed", poisoned_feed)
with caplog.at_level("WARNING"):
script = li.epub_to_chapter_script(_make_epub())
# All three chapters still present — nothing silently dropped.
assert script.count("# Chapter") == 3
assert "# Chapter 2" in script
# The pre-failure text survived.
assert "Opening paragraph of chapter 2." in script
# And the failure left a trace for diagnosis.
assert any("HTML parsing failed" in r.message for r in caplog.records)
def test_chapter_failing_before_any_text_is_skipped_not_fatal(monkeypatch):
"""If the parser dies before extracting anything, that chapter is empty and
skipped (pre-existing behavior) — but the rest of the book still imports."""
def dead_feed(self, data):
if "chapter 2" in data:
raise ValueError("boom before any text")
return li.HTMLParser.feed(self, data)
monkeypatch.setattr(li._TextExtractor, "feed", dead_feed)
script = li.epub_to_chapter_script(_make_epub())
assert "# Chapter 1" in script and "# Chapter 3" in script
assert "chapter 2" not in script.lower()