scrapeunblocker-haystack 0.2.2__tar.gz → 0.3.0__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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - With `parsed_data=True`, a page that loads but holds no structured data is
6
+ now a Document instead of being skipped. The API answers it as HTTP 200 with
7
+ `data_extracted: false` and the rendered HTML (billed like a plain fetch),
8
+ replacing the 422 `no_data_extracted` handled in 0.2.2. The Document's JSON
9
+ carries that HTML, and `meta["data_extracted"]` is `False` (`True` for a
10
+ successful parse). `NoDataExtractedError` is no longer raised; it stays
11
+ importable so existing `except` clauses keep working.
12
+
3
13
  ## 0.2.2
4
14
 
5
15
  - With `parsed_data=True`, a page that loads but holds no structured data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scrapeunblocker-haystack
3
- Version: 0.2.2
3
+ Version: 0.3.0
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
@@ -122,10 +122,10 @@ which is logged and skipped by default, or re-raised when
122
122
 
123
123
  #### Pages with nothing to parse
124
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.
125
+ With `parsed_data=True`, a page that loads but holds no structured data still
126
+ yields a Document. Its JSON carries `"data_extracted": false` and the rendered
127
+ page in `html`, and `meta["data_extracted"]` is `False`, so you can route it to
128
+ an HTML converter. The call is billed like a plain HTML fetch.
129
129
 
130
130
  #### Pages that do not exist
131
131
 
@@ -97,10 +97,10 @@ which is logged and skipped by default, or re-raised when
97
97
 
98
98
  #### Pages with nothing to parse
99
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.
100
+ With `parsed_data=True`, a page that loads but holds no structured data still
101
+ yields a Document. Its JSON carries `"data_extracted": false` and the rendered
102
+ page in `html`, and `meta["data_extracted"]` is `False`, so you can route it to
103
+ an HTML converter. The call is billed like a plain HTML fetch.
104
104
 
105
105
  #### Pages that do not exist
106
106
 
@@ -36,20 +36,16 @@ class TargetNotFoundError(Exception):
36
36
 
37
37
 
38
38
  class NoDataExtractedError(Exception):
39
- """Raised when ``parsed_data`` found no structured data on the page.
39
+ """No longer raised; kept so existing ``except`` clauses still import.
40
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.
41
+ A ``parsed_data`` page with no structured data comes back as a billed
42
+ HTTP 200 carrying ``data_extracted: false`` and the rendered HTML, so the
43
+ fetcher returns it as a Document (``meta["data_extracted"]`` is False).
44
44
  """
45
45
 
46
46
  def __init__(self, url: str) -> None:
47
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
- )
48
+ super().__init__(f"No structured data could be extracted from {url}.")
53
49
 
54
50
 
55
51
  def _target_gone_status(response: requests.Response) -> Optional[int]:
@@ -155,8 +151,7 @@ class ScrapeUnblockerFetcher:
155
151
  :param base_url: API base URL. Override to target a different environment.
156
152
  :param timeout: HTTP timeout in seconds.
157
153
  :param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
158
- (including when a browser step fails, the page does not exist, or
159
- `parsed_data` finds nothing to extract).
154
+ (including when a browser step fails or the page does not exist).
160
155
  """
161
156
  self.api_key = api_key
162
157
  self.parsed_data = parsed_data
@@ -213,11 +208,8 @@ class ScrapeUnblockerFetcher:
213
208
  if gone is not None:
214
209
  raise TargetNotFoundError(url, gone, response.text)
215
210
 
216
- # A failed browser step returns 422 with a structured JSON error; so does
217
- # a parsed_data call that found nothing to extract.
211
+ # A failed browser step returns 422 with a structured JSON error.
218
212
  if response.status_code == 422:
219
- if self._is_no_data_extracted(response):
220
- raise NoDataExtractedError(url)
221
213
  error = self._parse_step_error(response)
222
214
  if error is not None:
223
215
  raise error
@@ -225,14 +217,6 @@ class ScrapeUnblockerFetcher:
225
217
  response.raise_for_status()
226
218
  return response
227
219
 
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
-
236
220
  @staticmethod
237
221
  def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
238
222
  try:
@@ -255,8 +239,9 @@ class ScrapeUnblockerFetcher:
255
239
 
256
240
  A URL whose page does not exist (the site answers 404/410) yields no
257
241
  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`.
242
+ `parsed_data`, a page holding no structured data still yields a
243
+ Document: its JSON carries `data_extracted: false` and the rendered
244
+ `html`, and `meta["data_extracted"]` is False.
260
245
 
261
246
  :param urls: URLs to fetch.
262
247
  :returns: A dictionary with a `documents` key holding the fetched pages.
@@ -268,10 +253,10 @@ class ScrapeUnblockerFetcher:
268
253
  for url in urls:
269
254
  try:
270
255
  response = self._fetch(url)
271
- except (TargetNotFoundError, NoDataExtractedError) as exc:
256
+ except TargetNotFoundError as exc:
272
257
  if self.raise_on_failure:
273
258
  raise
274
- # A page that does not exist, or has nothing to parse, has no content to index.
259
+ # A page that does not exist has no content to index.
275
260
  logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
276
261
  continue
277
262
  except Exception as exc:
@@ -296,6 +281,8 @@ class ScrapeUnblockerFetcher:
296
281
  content = json.dumps(payload, ensure_ascii=False)
297
282
  if self.list_elements and isinstance(payload, dict):
298
283
  meta["count"] = payload.get("count")
284
+ elif self.parsed_data and isinstance(payload, dict):
285
+ meta["data_extracted"] = payload.get("data_extracted") is not False
299
286
  except ValueError:
300
287
  content = response.text
301
288
  else:
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -302,31 +302,37 @@ class TestTargetNotFound:
302
302
 
303
303
  class TestNoDataExtracted:
304
304
  BODY = {
305
- "error": "no_data_extracted",
305
+ "data": {"page_type": "unknown", "data": {}},
306
+ "data_extracted": False,
306
307
  "detail": "The page was rendered, but no structured data could be extracted from it.",
308
+ "html": "<html><title>Example Domain</title></html>",
307
309
  }
308
310
 
309
311
  @patch("scrapeunblocker_haystack.fetcher.requests.post")
310
- def test_empty_parse_is_skipped(self, mock_post):
312
+ def test_empty_parse_is_a_document_with_the_html(self, mock_post):
311
313
  mock_post.side_effect = [
312
- _response(status_code=422, json_data=self.BODY),
313
- _response(json_data={"data": {"page_type": "product"}}),
314
+ _response(json_data=self.BODY),
315
+ _response(json_data={"data": {"page_type": "product", "data": {"title": "x"}}}),
314
316
  ]
315
317
  fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
316
318
 
317
- result = fetcher.run(urls=["https://example.com/empty", "https://example.com"])
319
+ empty, parsed = fetcher.run(urls=["https://example.com/empty", "https://example.com"])[
320
+ "documents"
321
+ ]
318
322
 
319
- assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
323
+ assert empty.meta["data_extracted"] is False
324
+ assert json.loads(empty.content)["html"] == self.BODY["html"]
325
+ assert parsed.meta["data_extracted"] is True
320
326
 
321
327
  @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)
328
+ def test_empty_parse_does_not_raise(self, mock_post):
329
+ mock_post.return_value = _response(json_data=self.BODY)
324
330
  fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True, raise_on_failure=True)
325
331
 
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)
332
+ assert len(fetcher.run(urls=["https://example.com/empty"])["documents"]) == 1
333
+
334
+ def test_error_class_still_importable(self):
335
+ assert issubclass(NoDataExtractedError, Exception)
330
336
 
331
337
  @patch("scrapeunblocker_haystack.fetcher.requests.post")
332
338
  def test_dead_url_with_parsed_body_is_skipped(self, mock_post):
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"