scrapeunblocker-haystack 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - With `parsed_data=True`, a page that loads but holds no structured data
6
+ (the API answers HTTP 422 `no_data_extracted`, not billed, no HTML) no longer
7
+ shows up as a generic request failure. The fetcher skips it with a clear
8
+ warning, or raises the new `NoDataExtractedError` when
9
+ `raise_on_failure=True`. Fetch without `parsed_data` to get the HTML.
10
+
3
11
  ## 0.2.1
4
12
 
5
13
  - A URL whose page does not exist (the site answers HTTP 404 or 410) no longer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scrapeunblocker-haystack
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Haystack integration for ScrapeUnblocker - scrape pages behind anti-bot protections
5
5
  Project-URL: Homepage, https://www.scrapeunblocker.com
6
6
  Project-URL: Documentation, https://docs.scrapeunblocker.com
@@ -120,6 +120,13 @@ Steps are **non-idempotent**. If a step fails, the fetch raises
120
120
  which is logged and skipped by default, or re-raised when
121
121
  `raise_on_failure=True`.
122
122
 
123
+ #### Pages with nothing to parse
124
+
125
+ With `parsed_data=True`, a page that loads but holds no structured data yields
126
+ no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
127
+ when `raise_on_failure=True`. The call is not billed; fetch the URL without
128
+ `parsed_data` to get the HTML.
129
+
123
130
  #### Pages that do not exist
124
131
 
125
132
  If the site answers HTTP 404 or 410, there is no page to index: the URL is
@@ -95,6 +95,13 @@ Steps are **non-idempotent**. If a step fails, the fetch raises
95
95
  which is logged and skipped by default, or re-raised when
96
96
  `raise_on_failure=True`.
97
97
 
98
+ #### Pages with nothing to parse
99
+
100
+ With `parsed_data=True`, a page that loads but holds no structured data yields
101
+ no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
102
+ when `raise_on_failure=True`. The call is not billed; fetch the URL without
103
+ `parsed_data` to get the HTML.
104
+
98
105
  #### Pages that do not exist
99
106
 
100
107
  If the site answers HTTP 404 or 410, there is no page to index: the URL is
@@ -1,4 +1,5 @@
1
1
  from scrapeunblocker_haystack.fetcher import (
2
+ NoDataExtractedError,
2
3
  ScrapeUnblockerFetcher,
3
4
  StepExecutionError,
4
5
  TargetNotFoundError,
@@ -6,6 +7,7 @@ from scrapeunblocker_haystack.fetcher import (
6
7
  from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
7
8
 
8
9
  __all__ = [
10
+ "NoDataExtractedError",
9
11
  "ScrapeUnblockerFetcher",
10
12
  "ScrapeUnblockerWebSearch",
11
13
  "StepExecutionError",
@@ -35,6 +35,23 @@ class TargetNotFoundError(Exception):
35
35
  )
36
36
 
37
37
 
38
+ class NoDataExtractedError(Exception):
39
+ """Raised when ``parsed_data`` found no structured data on the page.
40
+
41
+ The API answers this as HTTP 422 ``no_data_extracted``: the page loaded but
42
+ held nothing to extract. It is not billed and carries no HTML; fetch the
43
+ URL without ``parsed_data`` to get the page itself.
44
+ """
45
+
46
+ def __init__(self, url: str) -> None:
47
+ self.url = url
48
+ super().__init__(
49
+ f"No structured data could be extracted from {url}: the page loaded, but "
50
+ "nothing on it matched a structured shape. The call was not billed; fetch "
51
+ "it without parsed_data to get the HTML."
52
+ )
53
+
54
+
38
55
  def _target_gone_status(response: requests.Response) -> Optional[int]:
39
56
  """The target's own 404/410 answer, or None.
40
57
 
@@ -138,7 +155,8 @@ class ScrapeUnblockerFetcher:
138
155
  :param base_url: API base URL. Override to target a different environment.
139
156
  :param timeout: HTTP timeout in seconds.
140
157
  :param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
141
- (including when a browser step fails or the page does not exist).
158
+ (including when a browser step fails, the page does not exist, or
159
+ `parsed_data` finds nothing to extract).
142
160
  """
143
161
  self.api_key = api_key
144
162
  self.parsed_data = parsed_data
@@ -195,8 +213,11 @@ class ScrapeUnblockerFetcher:
195
213
  if gone is not None:
196
214
  raise TargetNotFoundError(url, gone, response.text)
197
215
 
198
- # A failed browser step returns 422 with a structured JSON error.
216
+ # A failed browser step returns 422 with a structured JSON error; so does
217
+ # a parsed_data call that found nothing to extract.
199
218
  if response.status_code == 422:
219
+ if self._is_no_data_extracted(response):
220
+ raise NoDataExtractedError(url)
200
221
  error = self._parse_step_error(response)
201
222
  if error is not None:
202
223
  raise error
@@ -204,6 +225,14 @@ class ScrapeUnblockerFetcher:
204
225
  response.raise_for_status()
205
226
  return response
206
227
 
228
+ @staticmethod
229
+ def _is_no_data_extracted(response: requests.Response) -> bool:
230
+ try:
231
+ payload = response.json()
232
+ except ValueError:
233
+ return False
234
+ return isinstance(payload, dict) and payload.get("error") == "no_data_extracted"
235
+
207
236
  @staticmethod
208
237
  def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
209
238
  try:
@@ -225,7 +254,9 @@ class ScrapeUnblockerFetcher:
225
254
  Fetch each URL and return one Document per successfully fetched page.
226
255
 
227
256
  A URL whose page does not exist (the site answers 404/410) yields no
228
- Document; with `raise_on_failure` it raises `TargetNotFoundError`.
257
+ Document; with `raise_on_failure` it raises `TargetNotFoundError`. With
258
+ `parsed_data`, a page holding no structured data yields no Document
259
+ either, or raises `NoDataExtractedError`.
229
260
 
230
261
  :param urls: URLs to fetch.
231
262
  :returns: A dictionary with a `documents` key holding the fetched pages.
@@ -237,10 +268,10 @@ class ScrapeUnblockerFetcher:
237
268
  for url in urls:
238
269
  try:
239
270
  response = self._fetch(url)
240
- except TargetNotFoundError as exc:
271
+ except (TargetNotFoundError, NoDataExtractedError) as exc:
241
272
  if self.raise_on_failure:
242
273
  raise
243
- # A page that does not exist has no content to index.
274
+ # A page that does not exist, or has nothing to parse, has no content to index.
244
275
  logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
245
276
  continue
246
277
  except Exception as exc:
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -10,6 +10,7 @@ from haystack.utils import Secret
10
10
  from scrapeunblocker_haystack import (
11
11
  ScrapeUnblockerFetcher,
12
12
  ScrapeUnblockerWebSearch,
13
+ NoDataExtractedError,
13
14
  StepExecutionError,
14
15
  TargetNotFoundError,
15
16
  )
@@ -297,3 +298,44 @@ class TestTargetNotFound:
297
298
  fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
298
299
 
299
300
  assert len(fetcher.run(urls=["https://example.com"])["documents"]) == 1
301
+
302
+
303
+ class TestNoDataExtracted:
304
+ BODY = {
305
+ "error": "no_data_extracted",
306
+ "detail": "The page was rendered, but no structured data could be extracted from it.",
307
+ }
308
+
309
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
310
+ def test_empty_parse_is_skipped(self, mock_post):
311
+ mock_post.side_effect = [
312
+ _response(status_code=422, json_data=self.BODY),
313
+ _response(json_data={"data": {"page_type": "product"}}),
314
+ ]
315
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
316
+
317
+ result = fetcher.run(urls=["https://example.com/empty", "https://example.com"])
318
+
319
+ assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
320
+
321
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
322
+ def test_empty_parse_raises_when_asked(self, mock_post):
323
+ mock_post.return_value = _response(status_code=422, json_data=self.BODY)
324
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True, raise_on_failure=True)
325
+
326
+ with pytest.raises(NoDataExtractedError) as exc:
327
+ fetcher.run(urls=["https://example.com/empty"])
328
+ assert exc.value.url == "https://example.com/empty"
329
+ assert "not billed" in str(exc.value)
330
+
331
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
332
+ def test_dead_url_with_parsed_body_is_skipped(self, mock_post):
333
+ response = _response(
334
+ status_code=404, json_data={"data": {"page_type": "not_found", "data": {}}}
335
+ )
336
+ response.text = '{"data":{"page_type":"not_found","data":{}}}'
337
+ response.headers = {"Content-Type": "application/json", "X-Origin-Status": "404"}
338
+ mock_post.return_value = response
339
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
340
+
341
+ assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"