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.
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/CHANGELOG.md +9 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/PKG-INFO +11 -3
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/README.md +9 -1
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/pyproject.toml +1 -1
- scrapeunblocker_haystack-0.2.1/src/scrapeunblocker_haystack/__init__.py +13 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/src/scrapeunblocker_haystack/fetcher.py +50 -1
- scrapeunblocker_haystack-0.2.1/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/tests/test_components.py +48 -0
- scrapeunblocker_haystack-0.2.0/src/scrapeunblocker_haystack/__init__.py +0 -4
- scrapeunblocker_haystack-0.2.0/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -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.
|
|
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://
|
|
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: [
|
|
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: [
|
|
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://
|
|
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 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
{scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|