scrapeunblocker-haystack 0.2.0__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,22 @@
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
+
11
+ ## 0.2.1
12
+
13
+ - A URL whose page does not exist (the site answers HTTP 404 or 410) no longer
14
+ shows up as a generic request failure, and an older API response that carried
15
+ the not-found page as a 200 no longer becomes a Document. The fetcher skips it
16
+ with a clear warning, or raises the new `TargetNotFoundError` (`url`,
17
+ `origin_status`, `body`) when `raise_on_failure=True`. The call is billed by
18
+ ScrapeUnblocker and retrying returns the same answer.
19
+
3
20
  ## 0.2.0
4
21
 
5
22
  - `ScrapeUnblockerFetcher` now supports **browser steps** via the `steps`
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scrapeunblocker-haystack
3
- Version: 0.2.0
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
- Project-URL: Documentation, https://developers.scrapeunblocker.com
6
+ Project-URL: Documentation, https://docs.scrapeunblocker.com
7
7
  Project-URL: Source, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack
8
8
  Project-URL: Issues, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues
9
9
  Author-email: ScrapeUnblocker <info@scrapeunblocker.com>
@@ -120,6 +120,21 @@ 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
+
130
+ #### Pages that do not exist
131
+
132
+ If the site answers HTTP 404 or 410, there is no page to index: the URL is
133
+ skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
134
+ and the site's own `body`) is raised when `raise_on_failure=True`. That is the
135
+ site's answer, not a block - the call is billed and a retry returns the same
136
+ result.
137
+
123
138
  #### List elements
124
139
 
125
140
  Set `list_elements=True` to return the parsed elements JSON
@@ -206,7 +221,7 @@ reference, not as its value.
206
221
  ## Links
207
222
 
208
223
  - ScrapeUnblocker: [www.scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
209
- - API documentation: [developers.scrapeunblocker.com](https://developers.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
224
+ - API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
210
225
  - Haystack: https://haystack.deepset.ai
211
226
 
212
227
  ## License
@@ -95,6 +95,21 @@ 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
+
105
+ #### Pages that do not exist
106
+
107
+ If the site answers HTTP 404 or 410, there is no page to index: the URL is
108
+ skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
109
+ and the site's own `body`) is raised when `raise_on_failure=True`. That is the
110
+ site's answer, not a block - the call is billed and a retry returns the same
111
+ result.
112
+
98
113
  #### List elements
99
114
 
100
115
  Set `list_elements=True` to return the parsed elements JSON
@@ -181,7 +196,7 @@ reference, not as its value.
181
196
  ## Links
182
197
 
183
198
  - ScrapeUnblocker: [www.scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
184
- - API documentation: [developers.scrapeunblocker.com](https://developers.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
199
+ - API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
185
200
  - Haystack: https://haystack.deepset.ai
186
201
 
187
202
  ## License
@@ -25,7 +25,7 @@ dependencies = ["haystack-ai>=2.0.0", "requests>=2.31.0"]
25
25
 
26
26
  [project.urls]
27
27
  Homepage = "https://www.scrapeunblocker.com"
28
- Documentation = "https://developers.scrapeunblocker.com"
28
+ Documentation = "https://docs.scrapeunblocker.com"
29
29
  Source = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack"
30
30
  Issues = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues"
31
31
 
@@ -0,0 +1,15 @@
1
+ from scrapeunblocker_haystack.fetcher import (
2
+ NoDataExtractedError,
3
+ ScrapeUnblockerFetcher,
4
+ StepExecutionError,
5
+ TargetNotFoundError,
6
+ )
7
+ from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
8
+
9
+ __all__ = [
10
+ "NoDataExtractedError",
11
+ "ScrapeUnblockerFetcher",
12
+ "ScrapeUnblockerWebSearch",
13
+ "StepExecutionError",
14
+ "TargetNotFoundError",
15
+ ]
@@ -13,6 +13,59 @@ logger = logging.getLogger(__name__)
13
13
 
14
14
  DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
15
15
  DEFAULT_TIMEOUT = 180
16
+ _TARGET_GONE = frozenset({404, 410})
17
+
18
+
19
+ class TargetNotFoundError(Exception):
20
+ """Raised when the target page does not exist (the site answered 404/410).
21
+
22
+ This is the website's own answer, not a block or an API failure: the call
23
+ is billed, and retrying returns the same result. ``body`` is the target's
24
+ own not-found page (can be empty).
25
+ """
26
+
27
+ def __init__(self, url: str, origin_status: int, body: str = "") -> None:
28
+ self.url = url
29
+ self.origin_status = origin_status
30
+ self.body = body
31
+ super().__init__(
32
+ f"Target page does not exist: {url} answered HTTP {origin_status}. "
33
+ "This is the site's own answer, not a block or an API failure; the "
34
+ "call was billed and retrying returns the same result."
35
+ )
36
+
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
+
55
+ def _target_gone_status(response: requests.Response) -> Optional[int]:
56
+ """The target's own 404/410 answer, or None.
57
+
58
+ ``/getPageSource`` always exists, so a 404/410 from it is the target site's
59
+ answer, sent with ``X-Origin-Status``. Older API versions returned that
60
+ same answer as a 200 carrying the header, so the header is checked first.
61
+ """
62
+ try:
63
+ origin = int(response.headers.get("X-Origin-Status", ""))
64
+ except (TypeError, ValueError):
65
+ origin = None
66
+ if origin in _TARGET_GONE:
67
+ return origin
68
+ return response.status_code if response.status_code in _TARGET_GONE else None
16
69
 
17
70
 
18
71
  class StepExecutionError(Exception):
@@ -102,7 +155,8 @@ class ScrapeUnblockerFetcher:
102
155
  :param base_url: API base URL. Override to target a different environment.
103
156
  :param timeout: HTTP timeout in seconds.
104
157
  :param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
105
- (including when a browser step fails).
158
+ (including when a browser step fails, the page does not exist, or
159
+ `parsed_data` finds nothing to extract).
106
160
  """
107
161
  self.api_key = api_key
108
162
  self.parsed_data = parsed_data
@@ -155,8 +209,15 @@ class ScrapeUnblockerFetcher:
155
209
  timeout=self.timeout,
156
210
  )
157
211
 
158
- # A failed browser step returns 422 with a structured JSON error.
212
+ gone = _target_gone_status(response)
213
+ if gone is not None:
214
+ raise TargetNotFoundError(url, gone, response.text)
215
+
216
+ # A failed browser step returns 422 with a structured JSON error; so does
217
+ # a parsed_data call that found nothing to extract.
159
218
  if response.status_code == 422:
219
+ if self._is_no_data_extracted(response):
220
+ raise NoDataExtractedError(url)
160
221
  error = self._parse_step_error(response)
161
222
  if error is not None:
162
223
  raise error
@@ -164,6 +225,14 @@ class ScrapeUnblockerFetcher:
164
225
  response.raise_for_status()
165
226
  return response
166
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
+
167
236
  @staticmethod
168
237
  def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
169
238
  try:
@@ -184,6 +253,11 @@ class ScrapeUnblockerFetcher:
184
253
  """
185
254
  Fetch each URL and return one Document per successfully fetched page.
186
255
 
256
+ A URL whose page does not exist (the site answers 404/410) yields no
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`.
260
+
187
261
  :param urls: URLs to fetch.
188
262
  :returns: A dictionary with a `documents` key holding the fetched pages.
189
263
  """
@@ -194,6 +268,12 @@ class ScrapeUnblockerFetcher:
194
268
  for url in urls:
195
269
  try:
196
270
  response = self._fetch(url)
271
+ except (TargetNotFoundError, NoDataExtractedError) as exc:
272
+ if self.raise_on_failure:
273
+ raise
274
+ # A page that does not exist, or has nothing to parse, has no content to index.
275
+ logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
276
+ continue
197
277
  except Exception as exc:
198
278
  if self.raise_on_failure:
199
279
  raise
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -10,7 +10,9 @@ from haystack.utils import Secret
10
10
  from scrapeunblocker_haystack import (
11
11
  ScrapeUnblockerFetcher,
12
12
  ScrapeUnblockerWebSearch,
13
+ NoDataExtractedError,
13
14
  StepExecutionError,
15
+ TargetNotFoundError,
14
16
  )
15
17
 
16
18
  API_KEY = Secret.from_token("test_key")
@@ -249,3 +251,91 @@ class TestWebSearch:
249
251
  restored = ScrapeUnblockerWebSearch.from_dict(data)
250
252
  assert restored.top_k == 5
251
253
  assert restored.proxy_country == "us"
254
+
255
+
256
+ class TestTargetNotFound:
257
+ NOT_FOUND = "<html><title>404 Not Found</title></html>"
258
+
259
+ def _gone(self, status_code: int, origin: str) -> MagicMock:
260
+ response = _response(text=self.NOT_FOUND, status_code=status_code)
261
+ response.headers = {"Content-Type": "text/html", "X-Origin-Status": origin}
262
+ return response
263
+
264
+ @pytest.mark.parametrize("status", [404, 410])
265
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
266
+ def test_missing_page_is_skipped(self, mock_post, status):
267
+ mock_post.side_effect = [self._gone(status, str(status)), _response()]
268
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
269
+
270
+ result = fetcher.run(urls=["https://example.com/gone", "https://example.com"])
271
+
272
+ assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
273
+
274
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
275
+ def test_legacy_200_with_origin_404_is_skipped(self, mock_post):
276
+ mock_post.return_value = self._gone(200, "404")
277
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
278
+
279
+ assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
280
+
281
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
282
+ def test_missing_page_raises_when_asked(self, mock_post):
283
+ mock_post.return_value = self._gone(410, "410")
284
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, raise_on_failure=True)
285
+
286
+ with pytest.raises(TargetNotFoundError) as exc:
287
+ fetcher.run(urls=["https://example.com/gone"])
288
+ assert exc.value.origin_status == 410
289
+ assert exc.value.url == "https://example.com/gone"
290
+ assert "404 Not Found" in exc.value.body
291
+ assert "billed" in str(exc.value)
292
+
293
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
294
+ def test_dead_origin_5xx_page_is_still_a_document(self, mock_post):
295
+ response = _response(text="<html>502 Bad Gateway</html>")
296
+ response.headers = {"Content-Type": "text/html", "X-Origin-Status": "502"}
297
+ mock_post.return_value = response
298
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
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,4 +0,0 @@
1
- from scrapeunblocker_haystack.fetcher import ScrapeUnblockerFetcher, StepExecutionError
2
- from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
3
-
4
- __all__ = ["ScrapeUnblockerFetcher", "ScrapeUnblockerWebSearch", "StepExecutionError"]
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"