Skip to content

Commit 5f642fe

Browse files
committed
speak: narrate PDFs via --url, not just HTML pages
assembly speak --url now handles PDF URLs in addition to HTML. The fetch reads the full response and dispatches on content type: PDFs (detected by Content-Type or the %PDF- magic bytes, so a mislabeled octet-stream still routes correctly) go through pypdf's text-layer extraction; HTML keeps the trafilatura boilerplate-stripping path. A scanned/image-only PDF (no text layer) and an unparseable PDF both surface a clear UsageError. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Z1o33Ezt9aznmePd4Jc9R
1 parent 3ae8404 commit 5f642fe

6 files changed

Lines changed: 192 additions & 35 deletions

File tree

aai_cli/commands/speak/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def speak(
5454
url: str | None = typer.Option(
5555
None,
5656
"--url",
57-
help="Read a web page aloud: fetch the URL and narrate its main text "
57+
help="Read a web page or PDF aloud: fetch the URL and narrate its main text "
5858
"(boilerplate stripped). Mutually exclusive with the text argument",
5959
),
6060
voice: list[str] = typer.Option(
@@ -82,7 +82,7 @@ def speak(
8282
) -> None:
8383
r"""\[sandbox] Synthesize speech from text with AssemblyAI streaming TTS
8484
85-
Reads text from the argument, piped stdin, or a web page with --url
85+
Reads text from the argument, piped stdin, or a web page or PDF with --url
8686
(its main content is extracted and the boilerplate stripped). Plays the
8787
audio through your speakers by default, or writes a WAV with --out.
8888
Speaker-labeled input (from 'assembly transcribe --speaker-labels') is

aai_cli/core/webpage.py

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
"""Fetch a web page and extract its main article text.
1+
"""Fetch a web page (or PDF) and extract its main readable text.
22
33
Backs ``assembly speak --url``: httpx2 (the project's pinned client) fetches the
4-
HTML and trafilatura strips the boilerplate — nav, sidebars, cookie banners,
5-
footers, comment threads — down to the readable article body, so text-to-speech
6-
narrates the piece rather than the page chrome. trafilatura (and its lxml
7-
backend) is the heavy import, so it is deferred to call time to stay off the
4+
resource, then the body is narrowed to the readable text. For HTML, trafilatura
5+
strips the boilerplate — nav, sidebars, cookie banners, footers, comment threads
6+
— down to the article body; for a PDF (detected by Content-Type or the ``%PDF-``
7+
magic bytes) pypdf pulls the text layer out of every page. Either way text-to-speech
8+
narrates the piece rather than the page chrome. trafilatura (and its lxml backend)
9+
and pypdf are the heavy imports, so both are deferred to call time to stay off the
810
CLI's startup path.
911
"""
1012

@@ -20,40 +22,56 @@
2022
_TIMEOUT = 30.0 # pragma: no mutate -- request timeout; nothing observable to assert
2123
# Browser-like UA: some sites serve a stub or block page to unknown clients.
2224
_USER_AGENT = "Mozilla/5.0 (compatible; assembly-cli; +https://www.assemblyai.com)"
25+
# Every PDF begins with this signature; the robust signal when a server mislabels
26+
# the Content-Type (e.g. application/octet-stream) or the URL has no .pdf suffix.
27+
_PDF_MAGIC = b"%PDF-"
2328

2429

2530
@dataclass(frozen=True)
2631
class Article:
27-
"""The readable content extracted from a web page."""
32+
"""The readable content extracted from a web page or PDF."""
2833

2934
text: str
3035
title: str | None
3136
url: str
3237

3338

3439
def fetch_article(url: str) -> Article:
35-
"""Fetch ``url`` and return its main article text with boilerplate removed.
40+
"""Fetch ``url`` and return its main readable text with boilerplate removed.
3641
37-
Raises a :class:`UsageError` when ``url`` isn't an http(s) address or the
38-
page yields no readable text, and an :class:`APIError` when the fetch itself
42+
HTML pages go through trafilatura; PDFs go through pypdf. Raises a
43+
:class:`UsageError` when ``url`` isn't an http(s) address or the resource
44+
yields no readable text, and an :class:`APIError` when the fetch itself
3945
fails (DNS, timeout, non-2xx).
4046
"""
4147
if not url.startswith(("http://", "https://")):
4248
raise UsageError(
4349
f"Not a web page URL: {url}",
4450
suggestion="Pass an http(s) URL, e.g. assembly speak --url https://example.com/post.",
4551
)
46-
text, title = _extract(_fetch_html(url))
47-
if not text:
48-
raise UsageError(
49-
f"Couldn't find readable text at {url}.",
50-
suggestion="The page may be paywalled, JavaScript-rendered, or not an article.",
52+
response = _fetch(url)
53+
content_type = response.headers.get("content-type", "").lower()
54+
data = response.content
55+
if _is_pdf(data, content_type):
56+
text, title = _extract_pdf(data)
57+
empty_hint = (
58+
"The PDF may be scanned or image-only — there's no text layer to read "
59+
"(that needs OCR, which speak doesn't do)."
5160
)
61+
else:
62+
text, title = _extract(response.text)
63+
empty_hint = "The page may be paywalled, JavaScript-rendered, or not an article."
64+
if not text:
65+
raise UsageError(f"Couldn't find readable text at {url}.", suggestion=empty_hint)
5266
return Article(text=text, title=title, url=url)
5367

5468

55-
def _fetch_html(url: str) -> str:
56-
"""GET the raw HTML for ``url``, mapping any network/HTTP failure to APIError."""
69+
def _fetch(url: str) -> httpx.Response:
70+
"""GET ``url``, mapping any network/HTTP failure to APIError.
71+
72+
Returns the fully-read response so the caller can read it as text (HTML) or
73+
bytes (PDF) depending on the content type.
74+
"""
5775
try:
5876
with httpx.Client(
5977
timeout=_TIMEOUT,
@@ -62,11 +80,16 @@ def _fetch_html(url: str) -> str:
6280
) as client:
6381
response = client.get(url)
6482
response.raise_for_status()
65-
return response.text
83+
return response
6684
except httpx.HTTPError as exc:
6785
raise APIError(f"Couldn't fetch {url}: {exc}") from exc
6886

6987

88+
def _is_pdf(data: bytes, content_type: str) -> bool:
89+
"""True when ``data`` is a PDF, by Content-Type or the ``%PDF-`` magic bytes."""
90+
return "application/pdf" in content_type or data.startswith(_PDF_MAGIC)
91+
92+
7093
def _extract(html: str) -> tuple[str | None, str | None]:
7194
"""Pull the main text and title out of ``html`` (trafilatura, imported lazily)."""
7295
import trafilatura
@@ -81,3 +104,23 @@ def _extract(html: str) -> tuple[str | None, str | None]:
81104
)
82105
title = getattr(trafilatura.extract_metadata(html), "title", None)
83106
return text, title
107+
108+
109+
def _extract_pdf(data: bytes) -> tuple[str | None, str | None]:
110+
"""Pull the text layer and title out of a PDF (pypdf, imported lazily)."""
111+
from io import BytesIO
112+
113+
from pypdf import PdfReader
114+
from pypdf.errors import PyPdfError
115+
116+
try:
117+
reader = PdfReader(BytesIO(data))
118+
pages = [page.extract_text() for page in reader.pages]
119+
except PyPdfError as exc:
120+
raise UsageError(
121+
f"Couldn't read the PDF at the URL: {exc}",
122+
suggestion="The file may be encrypted, corrupt, or not a valid PDF.",
123+
) from exc
124+
text = "\n\n".join(page for page in pages if page).strip() or None
125+
title = getattr(reader.metadata, "title", None)
126+
return text, title

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ dependencies = [
6565
# lazily). Strips boilerplate down to the readable body; ships prebuilt wheels
6666
# (lxml included), so it adds no source-compile step to Homebrew bottling.
6767
"trafilatura>=2.1.0",
68+
# PDF text extraction for `assembly speak --url` when the URL serves a PDF
69+
# (webpage.py, imported lazily). Pure-Python, permissively licensed, ships a
70+
# universal wheel, so it adds no source-compile step to Homebrew bottling.
71+
"pypdf>=5.1.0",
6872
]
6973

7074
[project.urls]

tests/__snapshots__/test_snapshots_help_run.ambr

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,7 @@
706706

707707
[sandbox] Synthesize speech from text with AssemblyAI streaming TTS
708708

709-
Reads text from the argument, piped stdin, or a web page with --url
709+
Reads text from the argument, piped stdin, or a web page or PDF with --url
710710
(its main content is extracted and the boilerplate stripped). Plays the
711711
audio through your speakers by default, or writes a WAV with --out.
712712
Speaker-labeled input (from 'assembly transcribe --speaker-labels') is
@@ -718,11 +718,11 @@
718718
│ text [TEXT] Text to speak. Omit to read from stdin. │
719719
╰──────────────────────────────────────────────────────────────────────────────╯
720720
╭─ Options ────────────────────────────────────────────────────────────────────╮
721-
│ --url TEXT Read a web page aloud: fetch
722-
│ the URL and narrate its main
723-
│ text (boilerplate stripped).
724-
│ Mutually exclusive with the
725-
text argument
721+
│ --url TEXT Read a web page or PDF aloud:
722+
fetch the URL and narrate its │
723+
main text (boilerplate
724+
stripped). Mutually exclusive │
725+
with the text argument
726726
│ --voice TEXT Voice id (e.g. jane, michael, │
727727
│ mary, paul, eve, george), or │
728728
│ SPEAKER=VOICE for diarized │

tests/test_webpage.py

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,20 @@ def test_article_is_immutable():
4545
setattr(article, field_name, "tampered")
4646

4747

48-
def test_fetch_html_returns_body_and_sends_browser_user_agent(monkeypatch):
48+
def test_fetch_returns_body_and_sends_browser_user_agent(monkeypatch):
4949
seen: dict[str, str] = {}
5050

5151
def handler(request: httpx.Request) -> httpx.Response:
5252
seen["ua"] = request.headers["user-agent"]
5353
return httpx.Response(200, text="<html>ok</html>")
5454

5555
_client_returning(monkeypatch, handler)
56-
assert webpage._fetch_html("https://example.com/post") == "<html>ok</html>"
56+
assert webpage._fetch("https://example.com/post").text == "<html>ok</html>"
5757
# The browser-like UA is sent so sites don't serve a stub/block page.
5858
assert "assembly-cli" in seen["ua"]
5959

6060

61-
def test_fetch_html_follows_redirects(monkeypatch):
61+
def test_fetch_follows_redirects(monkeypatch):
6262
# A 301 must be followed to the final 200; without follow_redirects the
6363
# client would return the empty 301 body instead of the article.
6464
def handler(request: httpx.Request) -> httpx.Response:
@@ -67,23 +67,23 @@ def handler(request: httpx.Request) -> httpx.Response:
6767
return httpx.Response(200, text="final body")
6868

6969
_client_returning(monkeypatch, handler)
70-
assert webpage._fetch_html("https://example.com/start") == "final body"
70+
assert webpage._fetch("https://example.com/start").text == "final body"
7171

7272

73-
def test_fetch_html_non_2xx_becomes_api_error(monkeypatch):
73+
def test_fetch_non_2xx_becomes_api_error(monkeypatch):
7474
_client_returning(monkeypatch, lambda request: httpx.Response(404, text="nope"))
7575
with pytest.raises(APIError) as exc:
76-
webpage._fetch_html("https://example.com/missing")
76+
webpage._fetch("https://example.com/missing")
7777
assert "https://example.com/missing" in exc.value.message
7878

7979

80-
def test_fetch_html_connect_error_becomes_api_error(monkeypatch):
80+
def test_fetch_connect_error_becomes_api_error(monkeypatch):
8181
def handler(request: httpx.Request) -> httpx.Response:
8282
raise httpx.ConnectError("boom")
8383

8484
_client_returning(monkeypatch, handler)
8585
with pytest.raises(APIError):
86-
webpage._fetch_html("https://example.com/post")
86+
webpage._fetch("https://example.com/post")
8787

8888

8989
def test_extract_strips_boilerplate_and_comments_and_reads_title():
@@ -106,7 +106,12 @@ def test_fetch_article_rejects_non_http_url():
106106

107107

108108
def test_fetch_article_returns_extracted_text_and_title(monkeypatch):
109-
monkeypatch.setattr(webpage, "_fetch_html", lambda url: ARTICLE_HTML)
109+
_client_returning(
110+
monkeypatch,
111+
lambda request: httpx.Response(
112+
200, text=ARTICLE_HTML, headers={"content-type": "text/html; charset=utf-8"}
113+
),
114+
)
110115
article = webpage.fetch_article("https://example.com/post")
111116
assert "first real paragraph of the article body" in article.text
112117
assert article.title == "The Real Headline"
@@ -115,7 +120,101 @@ def test_fetch_article_returns_extracted_text_and_title(monkeypatch):
115120

116121
def test_fetch_article_without_readable_text_is_a_usage_error(monkeypatch):
117122
# A page trafilatura can't extract an article from yields no text -> usage error.
118-
monkeypatch.setattr(webpage, "_fetch_html", lambda url: "<html><body></body></html>")
123+
_client_returning(
124+
monkeypatch,
125+
lambda request: httpx.Response(
126+
200, text="<html><body></body></html>", headers={"content-type": "text/html"}
127+
),
128+
)
119129
with pytest.raises(UsageError) as exc:
120130
webpage.fetch_article("https://example.com/empty")
121131
assert "Couldn't find readable text" in exc.value.message
132+
# The HTML-specific hint, not the scanned-PDF one.
133+
assert "paywalled" in (exc.value.suggestion or "")
134+
135+
136+
def _make_pdf(body_text: str, *, title: str | None = None) -> bytes:
137+
"""Build a minimal one-page PDF whose content stream shows ``body_text``.
138+
139+
Offsets in the xref table are computed as the file is assembled so pypdf reads
140+
it without falling back to recovery — enough of a real PDF to exercise the
141+
text-layer extraction path end to end.
142+
"""
143+
content = b"BT /F1 24 Tf 72 120 Td (" + body_text.encode("latin-1") + b") Tj ET"
144+
info = b"<< /Title (" + title.encode("latin-1") + b") >>" if title else None
145+
page = (
146+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R "
147+
b"/Resources << /Font << /F1 5 0 R >> >> >>"
148+
)
149+
objects = [
150+
b"<< /Type /Catalog /Pages 2 0 R >>",
151+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
152+
page,
153+
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(content), content),
154+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
155+
]
156+
if info is not None:
157+
objects.append(info)
158+
out = bytearray(b"%PDF-1.4\n")
159+
offsets: list[int] = []
160+
for i, obj in enumerate(objects, start=1):
161+
offsets.append(len(out))
162+
out += b"%d 0 obj\n%s\nendobj\n" % (i, obj)
163+
xref_pos = len(out)
164+
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
165+
for off in offsets:
166+
out += b"%010d 00000 n \n" % off
167+
trailer = b"<< /Size %d /Root 1 0 R" % (len(objects) + 1)
168+
if info is not None:
169+
trailer += b" /Info %d 0 R" % len(objects)
170+
trailer += b" >>"
171+
out += b"trailer\n%s\nstartxref\n%d\n%%%%EOF" % (trailer, xref_pos)
172+
return bytes(out)
173+
174+
175+
def _pdf_response(data: bytes, content_type: str = "application/pdf") -> httpx.Response:
176+
return httpx.Response(200, content=data, headers={"content-type": content_type})
177+
178+
179+
def test_is_pdf_detects_by_content_type_and_magic_bytes():
180+
# Either signal alone is sufficient...
181+
assert webpage._is_pdf(b"not a pdf", "application/pdf; charset=binary")
182+
assert webpage._is_pdf(b"%PDF-1.7\n...", "application/octet-stream")
183+
# ...and an HTML response is not a PDF.
184+
assert not webpage._is_pdf(b"<html></html>", "text/html; charset=utf-8")
185+
186+
187+
def test_fetch_article_extracts_pdf_text_and_title(monkeypatch):
188+
pdf = _make_pdf("Hello from the PDF body text", title="A PDF Report")
189+
_client_returning(monkeypatch, lambda request: _pdf_response(pdf))
190+
article = webpage.fetch_article("https://example.com/report")
191+
assert "Hello from the PDF body text" in article.text
192+
assert article.title == "A PDF Report"
193+
assert article.url == "https://example.com/report"
194+
195+
196+
def test_fetch_article_dispatches_pdf_by_magic_bytes_despite_generic_type(monkeypatch):
197+
# A server mislabeling the PDF as octet-stream still takes the PDF path.
198+
pdf = _make_pdf("Magic-byte routed body")
199+
_client_returning(
200+
monkeypatch, lambda request: _pdf_response(pdf, content_type="application/octet-stream")
201+
)
202+
assert "Magic-byte routed body" in webpage.fetch_article("https://example.com/x").text
203+
204+
205+
def test_fetch_article_scanned_pdf_without_text_is_a_usage_error(monkeypatch):
206+
# An image-only PDF has no text layer -> usage error with the OCR-shaped hint.
207+
pdf = _make_pdf("")
208+
_client_returning(monkeypatch, lambda request: _pdf_response(pdf))
209+
with pytest.raises(UsageError) as exc:
210+
webpage.fetch_article("https://example.com/scanned.pdf")
211+
assert "Couldn't find readable text" in exc.value.message
212+
assert "scanned" in (exc.value.suggestion or "")
213+
214+
215+
def test_fetch_article_corrupt_pdf_is_a_usage_error(monkeypatch):
216+
# Passes the %PDF- magic check but isn't a parseable PDF -> usage error.
217+
_client_returning(monkeypatch, lambda request: _pdf_response(b"%PDF-1.4\nnot really a pdf"))
218+
with pytest.raises(UsageError) as exc:
219+
webpage.fetch_article("https://example.com/broken.pdf")
220+
assert "PDF" in exc.value.message

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)