scrapeunblocker-haystack 0.2.0__tar.gz → 0.2.1__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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - A URL whose page does not exist (the site answers HTTP 404 or 410) no longer
6
+ shows up as a generic request failure, and an older API response that carried
7
+ the not-found page as a 200 no longer becomes a Document. The fetcher skips it
8
+ with a clear warning, or raises the new `TargetNotFoundError` (`url`,
9
+ `origin_status`, `body`) when `raise_on_failure=True`. The call is billed by
10
+ ScrapeUnblocker and retrying returns the same answer.
11
+
3
12
  ## 0.2.0
4
13
 
5
14
  - `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.1
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,14 @@ 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 that do not exist
124
+
125
+ If the site answers HTTP 404 or 410, there is no page to index: the URL is
126
+ skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
127
+ and the site's own `body`) is raised when `raise_on_failure=True`. That is the
128
+ site's answer, not a block - the call is billed and a retry returns the same
129
+ result.
130
+
123
131
  #### List elements
124
132
 
125
133
  Set `list_elements=True` to return the parsed elements JSON
@@ -206,7 +214,7 @@ reference, not as its value.
206
214
  ## Links
207
215
 
208
216
  - 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)
217
+ - API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
210
218
  - Haystack: https://haystack.deepset.ai
211
219
 
212
220
  ## License
@@ -95,6 +95,14 @@ 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 that do not exist
99
+
100
+ If the site answers HTTP 404 or 410, there is no page to index: the URL is
101
+ skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
102
+ and the site's own `body`) is raised when `raise_on_failure=True`. That is the
103
+ site's answer, not a block - the call is billed and a retry returns the same
104
+ result.
105
+
98
106
  #### List elements
99
107
 
100
108
  Set `list_elements=True` to return the parsed elements JSON
@@ -181,7 +189,7 @@ reference, not as its value.
181
189
  ## Links
182
190
 
183
191
  - 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)
192
+ - API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
185
193
  - Haystack: https://haystack.deepset.ai
186
194
 
187
195
  ## 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,13 @@
1
+ from scrapeunblocker_haystack.fetcher import (
2
+ ScrapeUnblockerFetcher,
3
+ StepExecutionError,
4
+ TargetNotFoundError,
5
+ )
6
+ from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
7
+
8
+ __all__ = [
9
+ "ScrapeUnblockerFetcher",
10
+ "ScrapeUnblockerWebSearch",
11
+ "StepExecutionError",
12
+ "TargetNotFoundError",
13
+ ]
@@ -13,6 +13,42 @@ 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
+ def _target_gone_status(response: requests.Response) -> Optional[int]:
39
+ """The target's own 404/410 answer, or None.
40
+
41
+ ``/getPageSource`` always exists, so a 404/410 from it is the target site's
42
+ answer, sent with ``X-Origin-Status``. Older API versions returned that
43
+ same answer as a 200 carrying the header, so the header is checked first.
44
+ """
45
+ try:
46
+ origin = int(response.headers.get("X-Origin-Status", ""))
47
+ except (TypeError, ValueError):
48
+ origin = None
49
+ if origin in _TARGET_GONE:
50
+ return origin
51
+ return response.status_code if response.status_code in _TARGET_GONE else None
16
52
 
17
53
 
18
54
  class StepExecutionError(Exception):
@@ -102,7 +138,7 @@ class ScrapeUnblockerFetcher:
102
138
  :param base_url: API base URL. Override to target a different environment.
103
139
  :param timeout: HTTP timeout in seconds.
104
140
  :param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
105
- (including when a browser step fails).
141
+ (including when a browser step fails or the page does not exist).
106
142
  """
107
143
  self.api_key = api_key
108
144
  self.parsed_data = parsed_data
@@ -155,6 +191,10 @@ class ScrapeUnblockerFetcher:
155
191
  timeout=self.timeout,
156
192
  )
157
193
 
194
+ gone = _target_gone_status(response)
195
+ if gone is not None:
196
+ raise TargetNotFoundError(url, gone, response.text)
197
+
158
198
  # A failed browser step returns 422 with a structured JSON error.
159
199
  if response.status_code == 422:
160
200
  error = self._parse_step_error(response)
@@ -184,6 +224,9 @@ class ScrapeUnblockerFetcher:
184
224
  """
185
225
  Fetch each URL and return one Document per successfully fetched page.
186
226
 
227
+ A URL whose page does not exist (the site answers 404/410) yields no
228
+ Document; with `raise_on_failure` it raises `TargetNotFoundError`.
229
+
187
230
  :param urls: URLs to fetch.
188
231
  :returns: A dictionary with a `documents` key holding the fetched pages.
189
232
  """
@@ -194,6 +237,12 @@ class ScrapeUnblockerFetcher:
194
237
  for url in urls:
195
238
  try:
196
239
  response = self._fetch(url)
240
+ except TargetNotFoundError as exc:
241
+ if self.raise_on_failure:
242
+ raise
243
+ # A page that does not exist has no content to index.
244
+ logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
245
+ continue
197
246
  except Exception as exc:
198
247
  if self.raise_on_failure:
199
248
  raise
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -11,6 +11,7 @@ from scrapeunblocker_haystack import (
11
11
  ScrapeUnblockerFetcher,
12
12
  ScrapeUnblockerWebSearch,
13
13
  StepExecutionError,
14
+ TargetNotFoundError,
14
15
  )
15
16
 
16
17
  API_KEY = Secret.from_token("test_key")
@@ -249,3 +250,50 @@ class TestWebSearch:
249
250
  restored = ScrapeUnblockerWebSearch.from_dict(data)
250
251
  assert restored.top_k == 5
251
252
  assert restored.proxy_country == "us"
253
+
254
+
255
+ class TestTargetNotFound:
256
+ NOT_FOUND = "<html><title>404 Not Found</title></html>"
257
+
258
+ def _gone(self, status_code: int, origin: str) -> MagicMock:
259
+ response = _response(text=self.NOT_FOUND, status_code=status_code)
260
+ response.headers = {"Content-Type": "text/html", "X-Origin-Status": origin}
261
+ return response
262
+
263
+ @pytest.mark.parametrize("status", [404, 410])
264
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
265
+ def test_missing_page_is_skipped(self, mock_post, status):
266
+ mock_post.side_effect = [self._gone(status, str(status)), _response()]
267
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
268
+
269
+ result = fetcher.run(urls=["https://example.com/gone", "https://example.com"])
270
+
271
+ assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
272
+
273
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
274
+ def test_legacy_200_with_origin_404_is_skipped(self, mock_post):
275
+ mock_post.return_value = self._gone(200, "404")
276
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
277
+
278
+ assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
279
+
280
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
281
+ def test_missing_page_raises_when_asked(self, mock_post):
282
+ mock_post.return_value = self._gone(410, "410")
283
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, raise_on_failure=True)
284
+
285
+ with pytest.raises(TargetNotFoundError) as exc:
286
+ fetcher.run(urls=["https://example.com/gone"])
287
+ assert exc.value.origin_status == 410
288
+ assert exc.value.url == "https://example.com/gone"
289
+ assert "404 Not Found" in exc.value.body
290
+ assert "billed" in str(exc.value)
291
+
292
+ @patch("scrapeunblocker_haystack.fetcher.requests.post")
293
+ def test_dead_origin_5xx_page_is_still_a_document(self, mock_post):
294
+ response = _response(text="<html>502 Bad Gateway</html>")
295
+ response.headers = {"Content-Type": "text/html", "X-Origin-Status": "502"}
296
+ mock_post.return_value = response
297
+ fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
298
+
299
+ assert len(fetcher.run(urls=["https://example.com"])["documents"]) == 1
@@ -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"