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.
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/CHANGELOG.md +8 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/PKG-INFO +8 -1
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/README.md +7 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/src/scrapeunblocker_haystack/__init__.py +2 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/src/scrapeunblocker_haystack/fetcher.py +36 -5
- scrapeunblocker_haystack-0.2.2/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/tests/test_components.py +42 -0
- scrapeunblocker_haystack-0.2.1/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/pyproject.toml +0 -0
- {scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -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.
|
|
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
|
|
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"
|
{scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.2.1 → scrapeunblocker_haystack-0.2.2}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|