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.
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/CHANGELOG.md +10 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/PKG-INFO +5 -5
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/README.md +4 -4
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/src/scrapeunblocker_haystack/fetcher.py +14 -27
- scrapeunblocker_haystack-0.3.0/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/tests/test_components.py +18 -12
- scrapeunblocker_haystack-0.2.2/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/pyproject.toml +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/src/scrapeunblocker_haystack/__init__.py +0 -0
- {scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -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.
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
"""
|
|
39
|
+
"""No longer raised; kept so existing ``except`` clauses still import.
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
|
256
|
+
except TargetNotFoundError as exc:
|
|
272
257
|
if self.raise_on_failure:
|
|
273
258
|
raise
|
|
274
|
-
# A page that does not exist
|
|
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
|
-
"
|
|
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
|
|
312
|
+
def test_empty_parse_is_a_document_with_the_html(self, mock_post):
|
|
311
313
|
mock_post.side_effect = [
|
|
312
|
-
_response(
|
|
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
|
-
|
|
319
|
+
empty, parsed = fetcher.run(urls=["https://example.com/empty", "https://example.com"])[
|
|
320
|
+
"documents"
|
|
321
|
+
]
|
|
318
322
|
|
|
319
|
-
assert
|
|
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
|
|
323
|
-
mock_post.return_value = _response(
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
assert
|
|
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"
|
{scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.2.2 → scrapeunblocker_haystack-0.3.0}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|