@@ -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
8989def test_extract_strips_boilerplate_and_comments_and_reads_title ():
@@ -106,7 +106,12 @@ def test_fetch_article_rejects_non_http_url():
106106
107107
108108def 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
116121def 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 >>\n stream\n %s\n endstream" % (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\n endobj\n " % (i , obj )
163+ xref_pos = len (out )
164+ out += b"xref\n 0 %d\n 0000000000 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\n startxref\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\n not 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
0 commit comments