scrapeunblocker-haystack 0.1.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.1/CHANGELOG.md +30 -0
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/PKG-INFO +72 -7
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/README.md +69 -4
- {scrapeunblocker_haystack-0.1.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.1/src/scrapeunblocker_haystack/fetcher.py +275 -0
- scrapeunblocker_haystack-0.2.1/src/scrapeunblocker_haystack/version.py +1 -0
- scrapeunblocker_haystack-0.2.1/tests/test_components.py +299 -0
- scrapeunblocker_haystack-0.1.0/CHANGELOG.md +0 -8
- scrapeunblocker_haystack-0.1.0/src/scrapeunblocker_haystack/__init__.py +0 -4
- scrapeunblocker_haystack-0.1.0/src/scrapeunblocker_haystack/fetcher.py +0 -143
- scrapeunblocker_haystack-0.1.0/src/scrapeunblocker_haystack/version.py +0 -1
- scrapeunblocker_haystack-0.1.0/tests/test_components.py +0 -150
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
12
|
+
## 0.2.0
|
|
13
|
+
|
|
14
|
+
- `ScrapeUnblockerFetcher` now supports **browser steps** via the `steps`
|
|
15
|
+
parameter - a list of browser actions (`wait_for`, `wait_for_text`, `wait`,
|
|
16
|
+
`click`, `type`, `select`, `press_key`, `scroll`) run in a real browser after
|
|
17
|
+
the page loads, before the HTML is captured. A failed step raises
|
|
18
|
+
`StepExecutionError` (skipped or raised per `raise_on_failure`).
|
|
19
|
+
- `ScrapeUnblockerFetcher` now supports **listing elements** via the
|
|
20
|
+
`list_elements` parameter - returns the parsed elements JSON
|
|
21
|
+
(`{url, count, elements: [...]}`) instead of HTML.
|
|
22
|
+
- New `StepExecutionError` exception exposing `step_index`, `action`, `selector`
|
|
23
|
+
and `reason`.
|
|
24
|
+
|
|
25
|
+
## 0.1.0
|
|
26
|
+
|
|
27
|
+
Initial release.
|
|
28
|
+
|
|
29
|
+
- `ScrapeUnblockerFetcher` - fetch pages behind anti-bot protections as Documents
|
|
30
|
+
- `ScrapeUnblockerWebSearch` - Google organic results as Documents
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: scrapeunblocker-haystack
|
|
3
|
-
Version: 0.1
|
|
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>
|
|
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
# scrapeunblocker-haystack
|
|
27
27
|
|
|
28
28
|
[Haystack](https://haystack.deepset.ai/) integration for
|
|
29
|
-
[ScrapeUnblocker](https://www.scrapeunblocker.com) - fetch pages that block
|
|
29
|
+
[ScrapeUnblocker](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration) - fetch pages that block
|
|
30
30
|
ordinary HTTP requests.
|
|
31
31
|
|
|
32
32
|
ScrapeUnblocker renders web pages in a real browser behind anti-bot protections
|
|
@@ -41,7 +41,7 @@ pip install scrapeunblocker-haystack
|
|
|
41
41
|
|
|
42
42
|
## Setup
|
|
43
43
|
|
|
44
|
-
Get an API key at [scrapeunblocker.com](https://www.scrapeunblocker.com) and
|
|
44
|
+
Get an API key at [scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration) and
|
|
45
45
|
export it:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
@@ -71,6 +71,8 @@ print(result["documents"][0].content[:200])
|
|
|
71
71
|
| `parsed_data` | `False` | Return AI-parsed structured JSON instead of raw HTML |
|
|
72
72
|
| `proxy_country` | `None` | Two-letter country code for the exit IP |
|
|
73
73
|
| `time_sleep` | `None` | Seconds to wait after load before capturing |
|
|
74
|
+
| `steps` | `None` | Browser actions to run before the HTML is captured (see below) |
|
|
75
|
+
| `list_elements` | `False` | Return the parsed elements JSON instead of HTML (see below) |
|
|
74
76
|
| `base_url` | `https://api.scrapeunblocker.com` | API base URL |
|
|
75
77
|
| `timeout` | `180` | HTTP timeout in seconds |
|
|
76
78
|
| `raise_on_failure` | `False` | Raise instead of skipping a URL that fails |
|
|
@@ -78,6 +80,69 @@ print(result["documents"][0].content[:200])
|
|
|
78
80
|
By default a URL that cannot be fetched is logged and skipped, so one bad URL
|
|
79
81
|
does not discard the rest of the batch.
|
|
80
82
|
|
|
83
|
+
#### Browser steps
|
|
84
|
+
|
|
85
|
+
Pass `steps` to drive the page in a real browser before its HTML is captured -
|
|
86
|
+
wait for content to appear, click, type, scroll, and so on. Each step is a dict
|
|
87
|
+
with an `action` key plus its parameters:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
91
|
+
|
|
92
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
93
|
+
steps=[
|
|
94
|
+
{"action": "wait_for", "selector": "#results"},
|
|
95
|
+
{"action": "type", "selector": "input#q", "value": "laptops"},
|
|
96
|
+
{"action": "press_key", "value": "Enter"},
|
|
97
|
+
{"action": "wait_for_text", "value": "in stock"},
|
|
98
|
+
{"action": "click", "selector": "button.load-more"},
|
|
99
|
+
{"action": "scroll", "value": "bottom"},
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
result = fetcher.run(urls=["https://example.com/search"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Available actions:
|
|
106
|
+
|
|
107
|
+
| Action | Parameters |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `wait_for` | `selector`, `selector_type?` (`css`\|`xPath`\|`className`\|`tagName`), `timeout_ms?` |
|
|
110
|
+
| `wait_for_text` | `value`, `timeout_ms?` |
|
|
111
|
+
| `wait` | `value` (milliseconds) |
|
|
112
|
+
| `click` | `selector`, `selector_type?`, `timeout_ms?` |
|
|
113
|
+
| `type` | `selector`, `selector_type?`, `value`, `clear?`, `timeout_ms?` (human-like typing) |
|
|
114
|
+
| `select` | `selector`, `selector_type?`, `value`, `timeout_ms?` |
|
|
115
|
+
| `press_key` | `value` (`Enter`\|`Tab`\|`Escape`\|`Backspace`\|`Delete`\|`Space`\|`Arrow*`\|`Home`\|`End`\|`PageUp`\|`PageDown`) |
|
|
116
|
+
| `scroll` | `value` (`"bottom"` or an integer pixel offset) |
|
|
117
|
+
|
|
118
|
+
Steps are **non-idempotent**. If a step fails, the fetch raises
|
|
119
|
+
`StepExecutionError` (with `step_index`, `action`, `selector` and `reason`),
|
|
120
|
+
which is logged and skipped by default, or re-raised when
|
|
121
|
+
`raise_on_failure=True`.
|
|
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
|
+
|
|
131
|
+
#### List elements
|
|
132
|
+
|
|
133
|
+
Set `list_elements=True` to return the parsed elements JSON
|
|
134
|
+
(`{url, count, elements: [...]}`) instead of the page HTML. The JSON is stored
|
|
135
|
+
as the Document content, and the element count is available in `meta["count"]`:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
fetcher = ScrapeUnblockerFetcher(list_elements=True)
|
|
139
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
140
|
+
|
|
141
|
+
doc = result["documents"][0]
|
|
142
|
+
print(doc.meta["count"]) # number of elements
|
|
143
|
+
print(doc.content) # {"url": ..., "count": ..., "elements": [...]}
|
|
144
|
+
```
|
|
145
|
+
|
|
81
146
|
### ScrapeUnblockerWebSearch
|
|
82
147
|
|
|
83
148
|
Searches Google and returns the organic results as `Document` objects, with the
|
|
@@ -148,8 +213,8 @@ reference, not as its value.
|
|
|
148
213
|
|
|
149
214
|
## Links
|
|
150
215
|
|
|
151
|
-
- ScrapeUnblocker: https://www.scrapeunblocker.com
|
|
152
|
-
- API documentation: https://
|
|
216
|
+
- ScrapeUnblocker: [www.scrapeunblocker.com](https://www.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)
|
|
153
218
|
- Haystack: https://haystack.deepset.ai
|
|
154
219
|
|
|
155
220
|
## License
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# scrapeunblocker-haystack
|
|
2
2
|
|
|
3
3
|
[Haystack](https://haystack.deepset.ai/) integration for
|
|
4
|
-
[ScrapeUnblocker](https://www.scrapeunblocker.com) - fetch pages that block
|
|
4
|
+
[ScrapeUnblocker](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration) - fetch pages that block
|
|
5
5
|
ordinary HTTP requests.
|
|
6
6
|
|
|
7
7
|
ScrapeUnblocker renders web pages in a real browser behind anti-bot protections
|
|
@@ -16,7 +16,7 @@ pip install scrapeunblocker-haystack
|
|
|
16
16
|
|
|
17
17
|
## Setup
|
|
18
18
|
|
|
19
|
-
Get an API key at [scrapeunblocker.com](https://www.scrapeunblocker.com) and
|
|
19
|
+
Get an API key at [scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration) and
|
|
20
20
|
export it:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
@@ -46,6 +46,8 @@ print(result["documents"][0].content[:200])
|
|
|
46
46
|
| `parsed_data` | `False` | Return AI-parsed structured JSON instead of raw HTML |
|
|
47
47
|
| `proxy_country` | `None` | Two-letter country code for the exit IP |
|
|
48
48
|
| `time_sleep` | `None` | Seconds to wait after load before capturing |
|
|
49
|
+
| `steps` | `None` | Browser actions to run before the HTML is captured (see below) |
|
|
50
|
+
| `list_elements` | `False` | Return the parsed elements JSON instead of HTML (see below) |
|
|
49
51
|
| `base_url` | `https://api.scrapeunblocker.com` | API base URL |
|
|
50
52
|
| `timeout` | `180` | HTTP timeout in seconds |
|
|
51
53
|
| `raise_on_failure` | `False` | Raise instead of skipping a URL that fails |
|
|
@@ -53,6 +55,69 @@ print(result["documents"][0].content[:200])
|
|
|
53
55
|
By default a URL that cannot be fetched is logged and skipped, so one bad URL
|
|
54
56
|
does not discard the rest of the batch.
|
|
55
57
|
|
|
58
|
+
#### Browser steps
|
|
59
|
+
|
|
60
|
+
Pass `steps` to drive the page in a real browser before its HTML is captured -
|
|
61
|
+
wait for content to appear, click, type, scroll, and so on. Each step is a dict
|
|
62
|
+
with an `action` key plus its parameters:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
66
|
+
|
|
67
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
68
|
+
steps=[
|
|
69
|
+
{"action": "wait_for", "selector": "#results"},
|
|
70
|
+
{"action": "type", "selector": "input#q", "value": "laptops"},
|
|
71
|
+
{"action": "press_key", "value": "Enter"},
|
|
72
|
+
{"action": "wait_for_text", "value": "in stock"},
|
|
73
|
+
{"action": "click", "selector": "button.load-more"},
|
|
74
|
+
{"action": "scroll", "value": "bottom"},
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
result = fetcher.run(urls=["https://example.com/search"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Available actions:
|
|
81
|
+
|
|
82
|
+
| Action | Parameters |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `wait_for` | `selector`, `selector_type?` (`css`\|`xPath`\|`className`\|`tagName`), `timeout_ms?` |
|
|
85
|
+
| `wait_for_text` | `value`, `timeout_ms?` |
|
|
86
|
+
| `wait` | `value` (milliseconds) |
|
|
87
|
+
| `click` | `selector`, `selector_type?`, `timeout_ms?` |
|
|
88
|
+
| `type` | `selector`, `selector_type?`, `value`, `clear?`, `timeout_ms?` (human-like typing) |
|
|
89
|
+
| `select` | `selector`, `selector_type?`, `value`, `timeout_ms?` |
|
|
90
|
+
| `press_key` | `value` (`Enter`\|`Tab`\|`Escape`\|`Backspace`\|`Delete`\|`Space`\|`Arrow*`\|`Home`\|`End`\|`PageUp`\|`PageDown`) |
|
|
91
|
+
| `scroll` | `value` (`"bottom"` or an integer pixel offset) |
|
|
92
|
+
|
|
93
|
+
Steps are **non-idempotent**. If a step fails, the fetch raises
|
|
94
|
+
`StepExecutionError` (with `step_index`, `action`, `selector` and `reason`),
|
|
95
|
+
which is logged and skipped by default, or re-raised when
|
|
96
|
+
`raise_on_failure=True`.
|
|
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
|
+
|
|
106
|
+
#### List elements
|
|
107
|
+
|
|
108
|
+
Set `list_elements=True` to return the parsed elements JSON
|
|
109
|
+
(`{url, count, elements: [...]}`) instead of the page HTML. The JSON is stored
|
|
110
|
+
as the Document content, and the element count is available in `meta["count"]`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
fetcher = ScrapeUnblockerFetcher(list_elements=True)
|
|
114
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
115
|
+
|
|
116
|
+
doc = result["documents"][0]
|
|
117
|
+
print(doc.meta["count"]) # number of elements
|
|
118
|
+
print(doc.content) # {"url": ..., "count": ..., "elements": [...]}
|
|
119
|
+
```
|
|
120
|
+
|
|
56
121
|
### ScrapeUnblockerWebSearch
|
|
57
122
|
|
|
58
123
|
Searches Google and returns the organic results as `Document` objects, with the
|
|
@@ -123,8 +188,8 @@ reference, not as its value.
|
|
|
123
188
|
|
|
124
189
|
## Links
|
|
125
190
|
|
|
126
|
-
- ScrapeUnblocker: https://www.scrapeunblocker.com
|
|
127
|
-
- API documentation: https://
|
|
191
|
+
- ScrapeUnblocker: [www.scrapeunblocker.com](https://www.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)
|
|
128
193
|
- Haystack: https://haystack.deepset.ai
|
|
129
194
|
|
|
130
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
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Haystack component that fetches pages through the ScrapeUnblocker API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
|
10
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
|
|
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
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StepExecutionError(Exception):
|
|
55
|
+
"""Raised when a browser step fails (getPageSource returns HTTP 422)."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
reason: str,
|
|
60
|
+
step_index: Optional[int] = None,
|
|
61
|
+
action: Optional[str] = None,
|
|
62
|
+
selector: Optional[str] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.reason = reason
|
|
65
|
+
self.step_index = step_index
|
|
66
|
+
self.action = action
|
|
67
|
+
self.selector = selector
|
|
68
|
+
detail = f"step {step_index} ({action})" if action is not None else "browser step"
|
|
69
|
+
if selector:
|
|
70
|
+
detail += f" on {selector!r}"
|
|
71
|
+
super().__init__(f"ScrapeUnblocker {detail} failed: {reason}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@component
|
|
75
|
+
class ScrapeUnblockerFetcher:
|
|
76
|
+
"""
|
|
77
|
+
Fetches web pages through the ScrapeUnblocker API and returns them as Documents.
|
|
78
|
+
|
|
79
|
+
ScrapeUnblocker renders pages in a real browser behind anti-bot protections
|
|
80
|
+
(Cloudflare, DataDome, PerimeterX, Akamai), so it reaches pages that a plain
|
|
81
|
+
HTTP fetch cannot.
|
|
82
|
+
|
|
83
|
+
### Usage example
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
87
|
+
|
|
88
|
+
fetcher = ScrapeUnblockerFetcher()
|
|
89
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
90
|
+
print(result["documents"][0].content[:200])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Browser steps
|
|
94
|
+
|
|
95
|
+
Drive the page in a real browser before the HTML is captured - click, type,
|
|
96
|
+
wait for a selector, scroll, and so on:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
100
|
+
steps=[
|
|
101
|
+
{"action": "wait_for", "selector": "#results"},
|
|
102
|
+
{"action": "click", "selector": "button.load-more"},
|
|
103
|
+
{"action": "scroll", "value": "bottom"},
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### List elements
|
|
109
|
+
|
|
110
|
+
Return the parsed elements JSON (`{url, count, elements: [...]}`) instead of
|
|
111
|
+
HTML by setting `list_elements=True`.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
api_key: Secret = Secret.from_env_var("SCRAPEUNBLOCKER_API_KEY"),
|
|
117
|
+
parsed_data: bool = False,
|
|
118
|
+
proxy_country: Optional[str] = None,
|
|
119
|
+
time_sleep: Optional[int] = None,
|
|
120
|
+
steps: Optional[List[Dict[str, Any]]] = None,
|
|
121
|
+
list_elements: bool = False,
|
|
122
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
123
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
124
|
+
raise_on_failure: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
:param api_key: ScrapeUnblocker API key. Read from `SCRAPEUNBLOCKER_API_KEY` by default.
|
|
128
|
+
:param parsed_data: Return AI-parsed structured JSON instead of raw HTML.
|
|
129
|
+
:param proxy_country: Two-letter country code for the exit IP, for geo-restricted content.
|
|
130
|
+
:param time_sleep: Seconds to wait after page load before capturing.
|
|
131
|
+
:param steps: Browser actions to run after the page loads, before the HTML is
|
|
132
|
+
captured. A list of dicts, each with an `action` key (for example
|
|
133
|
+
`wait_for`, `wait_for_text`, `wait`, `click`, `type`, `select`,
|
|
134
|
+
`press_key`, `scroll`) plus its parameters. These actions are
|
|
135
|
+
non-idempotent. Sent JSON-encoded in the `steps` query parameter.
|
|
136
|
+
:param list_elements: Return the parsed elements JSON
|
|
137
|
+
(`{url, count, elements: [...]}`) instead of the page HTML.
|
|
138
|
+
:param base_url: API base URL. Override to target a different environment.
|
|
139
|
+
:param timeout: HTTP timeout in seconds.
|
|
140
|
+
:param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
|
|
141
|
+
(including when a browser step fails or the page does not exist).
|
|
142
|
+
"""
|
|
143
|
+
self.api_key = api_key
|
|
144
|
+
self.parsed_data = parsed_data
|
|
145
|
+
self.proxy_country = proxy_country
|
|
146
|
+
self.time_sleep = time_sleep
|
|
147
|
+
self.steps = steps
|
|
148
|
+
self.list_elements = list_elements
|
|
149
|
+
self.base_url = base_url.rstrip("/")
|
|
150
|
+
self.timeout = timeout
|
|
151
|
+
self.raise_on_failure = raise_on_failure
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
154
|
+
"""Serialize this component to a dictionary."""
|
|
155
|
+
return default_to_dict(
|
|
156
|
+
self,
|
|
157
|
+
api_key=self.api_key.to_dict(),
|
|
158
|
+
parsed_data=self.parsed_data,
|
|
159
|
+
proxy_country=self.proxy_country,
|
|
160
|
+
time_sleep=self.time_sleep,
|
|
161
|
+
steps=self.steps,
|
|
162
|
+
list_elements=self.list_elements,
|
|
163
|
+
base_url=self.base_url,
|
|
164
|
+
timeout=self.timeout,
|
|
165
|
+
raise_on_failure=self.raise_on_failure,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ScrapeUnblockerFetcher":
|
|
170
|
+
"""Deserialize this component from a dictionary."""
|
|
171
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
172
|
+
return default_from_dict(cls, data)
|
|
173
|
+
|
|
174
|
+
def _fetch(self, url: str) -> requests.Response:
|
|
175
|
+
params: Dict[str, Any] = {"url": url}
|
|
176
|
+
if self.parsed_data:
|
|
177
|
+
params["parsed_data"] = True
|
|
178
|
+
if self.proxy_country:
|
|
179
|
+
params["proxy_country"] = self.proxy_country
|
|
180
|
+
if self.time_sleep is not None:
|
|
181
|
+
params["time_sleep"] = self.time_sleep
|
|
182
|
+
if self.steps:
|
|
183
|
+
params["steps"] = json.dumps(self.steps)
|
|
184
|
+
if self.list_elements:
|
|
185
|
+
params["list_elements"] = True
|
|
186
|
+
|
|
187
|
+
response = requests.post(
|
|
188
|
+
f"{self.base_url}/getPageSource",
|
|
189
|
+
params=params,
|
|
190
|
+
headers={"X-ScrapeUnblocker-Key": self.api_key.resolve_value()},
|
|
191
|
+
timeout=self.timeout,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
gone = _target_gone_status(response)
|
|
195
|
+
if gone is not None:
|
|
196
|
+
raise TargetNotFoundError(url, gone, response.text)
|
|
197
|
+
|
|
198
|
+
# A failed browser step returns 422 with a structured JSON error.
|
|
199
|
+
if response.status_code == 422:
|
|
200
|
+
error = self._parse_step_error(response)
|
|
201
|
+
if error is not None:
|
|
202
|
+
raise error
|
|
203
|
+
|
|
204
|
+
response.raise_for_status()
|
|
205
|
+
return response
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
|
|
209
|
+
try:
|
|
210
|
+
payload = response.json()
|
|
211
|
+
except ValueError:
|
|
212
|
+
return None
|
|
213
|
+
if not isinstance(payload, dict) or payload.get("error") != "step_failed":
|
|
214
|
+
return None
|
|
215
|
+
return StepExecutionError(
|
|
216
|
+
reason=payload.get("reason", "unknown"),
|
|
217
|
+
step_index=payload.get("step_index"),
|
|
218
|
+
action=payload.get("action"),
|
|
219
|
+
selector=payload.get("selector"),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@component.output_types(documents=List[Document])
|
|
223
|
+
def run(self, urls: List[str]) -> Dict[str, List[Document]]:
|
|
224
|
+
"""
|
|
225
|
+
Fetch each URL and return one Document per successfully fetched page.
|
|
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
|
+
|
|
230
|
+
:param urls: URLs to fetch.
|
|
231
|
+
:returns: A dictionary with a `documents` key holding the fetched pages.
|
|
232
|
+
"""
|
|
233
|
+
documents: List[Document] = []
|
|
234
|
+
# `parsed_data` and `list_elements` both return a JSON body rather than HTML.
|
|
235
|
+
json_mode = self.parsed_data or self.list_elements
|
|
236
|
+
|
|
237
|
+
for url in urls:
|
|
238
|
+
try:
|
|
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
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
if self.raise_on_failure:
|
|
248
|
+
raise
|
|
249
|
+
# One unreachable URL should not discard the rest of the batch.
|
|
250
|
+
logger.warning(
|
|
251
|
+
"ScrapeUnblocker could not fetch {url}: {error}", url=url, error=str(exc)
|
|
252
|
+
)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
meta: Dict[str, Any] = {
|
|
256
|
+
"url": url,
|
|
257
|
+
"content_type": response.headers.get("Content-Type", ""),
|
|
258
|
+
"parsed_data": self.parsed_data,
|
|
259
|
+
"list_elements": self.list_elements,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if json_mode:
|
|
263
|
+
try:
|
|
264
|
+
payload = response.json()
|
|
265
|
+
content = json.dumps(payload, ensure_ascii=False)
|
|
266
|
+
if self.list_elements and isinstance(payload, dict):
|
|
267
|
+
meta["count"] = payload.get("count")
|
|
268
|
+
except ValueError:
|
|
269
|
+
content = response.text
|
|
270
|
+
else:
|
|
271
|
+
content = response.text
|
|
272
|
+
|
|
273
|
+
documents.append(Document(content=content, meta=meta))
|
|
274
|
+
|
|
275
|
+
return {"documents": documents}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Unit tests for the ScrapeUnblocker Haystack components."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from haystack import Document
|
|
8
|
+
from haystack.utils import Secret
|
|
9
|
+
|
|
10
|
+
from scrapeunblocker_haystack import (
|
|
11
|
+
ScrapeUnblockerFetcher,
|
|
12
|
+
ScrapeUnblockerWebSearch,
|
|
13
|
+
StepExecutionError,
|
|
14
|
+
TargetNotFoundError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
API_KEY = Secret.from_token("test_key")
|
|
18
|
+
# Haystack refuses to serialize token secrets, so to_dict tests use an env-var secret.
|
|
19
|
+
ENV_KEY = Secret.from_env_var("SCRAPEUNBLOCKER_API_KEY")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _response(
|
|
23
|
+
text: str = "<html><title>Test</title></html>", json_data=None, status_code: int = 200
|
|
24
|
+
) -> MagicMock:
|
|
25
|
+
response = MagicMock()
|
|
26
|
+
response.text = text
|
|
27
|
+
response.status_code = status_code
|
|
28
|
+
response.headers = {"Content-Type": "text/html"}
|
|
29
|
+
response.raise_for_status.return_value = None
|
|
30
|
+
if json_data is not None:
|
|
31
|
+
response.json.return_value = json_data
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestFetcher:
|
|
36
|
+
def test_defaults(self):
|
|
37
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
38
|
+
assert fetcher.parsed_data is False
|
|
39
|
+
assert fetcher.base_url == "https://api.scrapeunblocker.com"
|
|
40
|
+
|
|
41
|
+
def test_base_url_trailing_slash_stripped(self):
|
|
42
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, base_url="https://example.com/")
|
|
43
|
+
assert fetcher.base_url == "https://example.com"
|
|
44
|
+
|
|
45
|
+
def test_to_dict_and_back(self):
|
|
46
|
+
fetcher = ScrapeUnblockerFetcher(api_key=ENV_KEY, parsed_data=True, proxy_country="de")
|
|
47
|
+
data = fetcher.to_dict()
|
|
48
|
+
assert data["init_parameters"]["parsed_data"] is True
|
|
49
|
+
assert data["init_parameters"]["proxy_country"] == "de"
|
|
50
|
+
|
|
51
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
52
|
+
assert restored.parsed_data is True
|
|
53
|
+
assert restored.proxy_country == "de"
|
|
54
|
+
|
|
55
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
56
|
+
def test_run_single_url(self, mock_post):
|
|
57
|
+
mock_post.return_value = _response()
|
|
58
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
59
|
+
|
|
60
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
61
|
+
|
|
62
|
+
assert len(result["documents"]) == 1
|
|
63
|
+
doc = result["documents"][0]
|
|
64
|
+
assert isinstance(doc, Document)
|
|
65
|
+
assert "Test" in doc.content
|
|
66
|
+
assert doc.meta["url"] == "https://example.com"
|
|
67
|
+
|
|
68
|
+
_, kwargs = mock_post.call_args
|
|
69
|
+
assert kwargs["headers"]["X-ScrapeUnblocker-Key"] == "test_key"
|
|
70
|
+
assert kwargs["params"] == {"url": "https://example.com"}
|
|
71
|
+
|
|
72
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
73
|
+
def test_run_forwards_options(self, mock_post):
|
|
74
|
+
mock_post.return_value = _response(json_data={"ok": True})
|
|
75
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
76
|
+
api_key=API_KEY, parsed_data=True, proxy_country="de", time_sleep=5
|
|
77
|
+
)
|
|
78
|
+
fetcher.run(urls=["https://example.com"])
|
|
79
|
+
|
|
80
|
+
_, kwargs = mock_post.call_args
|
|
81
|
+
assert kwargs["params"]["parsed_data"] is True
|
|
82
|
+
assert kwargs["params"]["proxy_country"] == "de"
|
|
83
|
+
assert kwargs["params"]["time_sleep"] == 5
|
|
84
|
+
|
|
85
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
86
|
+
def test_parsed_data_serialised_as_json(self, mock_post):
|
|
87
|
+
mock_post.return_value = _response(json_data={"title": "Test", "price": "9.99"})
|
|
88
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
89
|
+
|
|
90
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
91
|
+
|
|
92
|
+
assert json.loads(result["documents"][0].content) == {"title": "Test", "price": "9.99"}
|
|
93
|
+
|
|
94
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
95
|
+
def test_failure_is_skipped_by_default(self, mock_post):
|
|
96
|
+
mock_post.side_effect = [Exception("boom"), _response()]
|
|
97
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
98
|
+
|
|
99
|
+
result = fetcher.run(urls=["https://broken.com", "https://ok.com"])
|
|
100
|
+
|
|
101
|
+
assert len(result["documents"]) == 1
|
|
102
|
+
assert result["documents"][0].meta["url"] == "https://ok.com"
|
|
103
|
+
|
|
104
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
105
|
+
def test_failure_raises_when_requested(self, mock_post):
|
|
106
|
+
mock_post.side_effect = Exception("boom")
|
|
107
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, raise_on_failure=True)
|
|
108
|
+
|
|
109
|
+
with pytest.raises(Exception, match="boom"):
|
|
110
|
+
fetcher.run(urls=["https://broken.com"])
|
|
111
|
+
|
|
112
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
113
|
+
def test_steps_are_json_encoded(self, mock_post):
|
|
114
|
+
mock_post.return_value = _response()
|
|
115
|
+
steps = [
|
|
116
|
+
{"action": "wait_for", "selector": "#results"},
|
|
117
|
+
{"action": "click", "selector": "button.load-more"},
|
|
118
|
+
]
|
|
119
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, steps=steps)
|
|
120
|
+
|
|
121
|
+
fetcher.run(urls=["https://example.com"])
|
|
122
|
+
|
|
123
|
+
_, kwargs = mock_post.call_args
|
|
124
|
+
assert kwargs["params"]["steps"] == json.dumps(steps)
|
|
125
|
+
assert json.loads(kwargs["params"]["steps"]) == steps
|
|
126
|
+
|
|
127
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
128
|
+
def test_list_elements_returns_json(self, mock_post):
|
|
129
|
+
payload = {
|
|
130
|
+
"url": "https://example.com",
|
|
131
|
+
"count": 2,
|
|
132
|
+
"elements": [{"tag": "a"}, {"tag": "div"}],
|
|
133
|
+
}
|
|
134
|
+
mock_post.return_value = _response(json_data=payload)
|
|
135
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, list_elements=True)
|
|
136
|
+
|
|
137
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
138
|
+
|
|
139
|
+
_, kwargs = mock_post.call_args
|
|
140
|
+
assert kwargs["params"]["list_elements"] is True
|
|
141
|
+
doc = result["documents"][0]
|
|
142
|
+
assert json.loads(doc.content) == payload
|
|
143
|
+
assert doc.meta["count"] == 2
|
|
144
|
+
assert doc.meta["list_elements"] is True
|
|
145
|
+
|
|
146
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
147
|
+
def test_step_failure_is_skipped_by_default(self, mock_post):
|
|
148
|
+
mock_post.return_value = _response(
|
|
149
|
+
json_data={
|
|
150
|
+
"error": "step_failed",
|
|
151
|
+
"step_index": 1,
|
|
152
|
+
"action": "click",
|
|
153
|
+
"reason": "selector not found",
|
|
154
|
+
"selector": "button.load-more",
|
|
155
|
+
},
|
|
156
|
+
status_code=422,
|
|
157
|
+
)
|
|
158
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
159
|
+
api_key=API_KEY, steps=[{"action": "click", "selector": "button.load-more"}]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result = fetcher.run(urls=["https://example.com"])
|
|
163
|
+
|
|
164
|
+
assert result["documents"] == []
|
|
165
|
+
|
|
166
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
167
|
+
def test_step_failure_raises_when_requested(self, mock_post):
|
|
168
|
+
mock_post.return_value = _response(
|
|
169
|
+
json_data={
|
|
170
|
+
"error": "step_failed",
|
|
171
|
+
"step_index": 1,
|
|
172
|
+
"action": "click",
|
|
173
|
+
"reason": "selector not found",
|
|
174
|
+
"selector": "button.load-more",
|
|
175
|
+
},
|
|
176
|
+
status_code=422,
|
|
177
|
+
)
|
|
178
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
179
|
+
api_key=API_KEY,
|
|
180
|
+
steps=[{"action": "click", "selector": "button.load-more"}],
|
|
181
|
+
raise_on_failure=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
with pytest.raises(StepExecutionError) as exc_info:
|
|
185
|
+
fetcher.run(urls=["https://example.com"])
|
|
186
|
+
|
|
187
|
+
error = exc_info.value
|
|
188
|
+
assert error.step_index == 1
|
|
189
|
+
assert error.action == "click"
|
|
190
|
+
assert error.selector == "button.load-more"
|
|
191
|
+
assert error.reason == "selector not found"
|
|
192
|
+
|
|
193
|
+
def test_to_dict_includes_steps_and_list_elements(self):
|
|
194
|
+
steps = [{"action": "wait_for", "selector": "#results"}]
|
|
195
|
+
fetcher = ScrapeUnblockerFetcher(
|
|
196
|
+
api_key=ENV_KEY, steps=steps, list_elements=True
|
|
197
|
+
)
|
|
198
|
+
data = fetcher.to_dict()
|
|
199
|
+
assert data["init_parameters"]["steps"] == steps
|
|
200
|
+
assert data["init_parameters"]["list_elements"] is True
|
|
201
|
+
|
|
202
|
+
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
203
|
+
assert restored.steps == steps
|
|
204
|
+
assert restored.list_elements is True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestWebSearch:
|
|
208
|
+
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
209
|
+
def test_run(self, mock_post):
|
|
210
|
+
mock_post.return_value = _response(
|
|
211
|
+
json_data={
|
|
212
|
+
"organic": [
|
|
213
|
+
{"title": "First", "url": "https://a.com", "description": "snippet a", "position": "1"},
|
|
214
|
+
{"title": "Second", "url": "https://b.com", "description": "snippet b", "position": "2"},
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
search = ScrapeUnblockerWebSearch(api_key=API_KEY)
|
|
219
|
+
|
|
220
|
+
result = search.run(query="test query")
|
|
221
|
+
|
|
222
|
+
assert len(result["documents"]) == 2
|
|
223
|
+
assert result["documents"][0].content == "snippet a"
|
|
224
|
+
assert result["documents"][0].meta["title"] == "First"
|
|
225
|
+
assert result["documents"][0].meta["link"] == "https://a.com"
|
|
226
|
+
assert result["documents"][0].meta["query"] == "test query"
|
|
227
|
+
|
|
228
|
+
_, kwargs = mock_post.call_args
|
|
229
|
+
assert kwargs["params"]["keyword"] == "test query"
|
|
230
|
+
|
|
231
|
+
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
232
|
+
def test_top_k(self, mock_post):
|
|
233
|
+
mock_post.return_value = _response(
|
|
234
|
+
json_data={"organic": [{"title": str(i), "url": "", "description": str(i)} for i in range(10)]}
|
|
235
|
+
)
|
|
236
|
+
search = ScrapeUnblockerWebSearch(api_key=API_KEY, top_k=3)
|
|
237
|
+
|
|
238
|
+
assert len(search.run(query="q")["documents"]) == 3
|
|
239
|
+
|
|
240
|
+
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
241
|
+
def test_missing_organic_returns_empty(self, mock_post):
|
|
242
|
+
mock_post.return_value = _response(json_data={"totalResults": None})
|
|
243
|
+
search = ScrapeUnblockerWebSearch(api_key=API_KEY)
|
|
244
|
+
|
|
245
|
+
assert search.run(query="q")["documents"] == []
|
|
246
|
+
|
|
247
|
+
def test_to_dict_and_back(self):
|
|
248
|
+
search = ScrapeUnblockerWebSearch(api_key=ENV_KEY, top_k=5, proxy_country="us")
|
|
249
|
+
data = search.to_dict()
|
|
250
|
+
restored = ScrapeUnblockerWebSearch.from_dict(data)
|
|
251
|
+
assert restored.top_k == 5
|
|
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,143 +0,0 @@
|
|
|
1
|
-
"""Haystack component that fetches pages through the ScrapeUnblocker API."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from typing import Any, Dict, List, Optional
|
|
7
|
-
|
|
8
|
-
import requests
|
|
9
|
-
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
|
10
|
-
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
|
|
15
|
-
DEFAULT_TIMEOUT = 180
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@component
|
|
19
|
-
class ScrapeUnblockerFetcher:
|
|
20
|
-
"""
|
|
21
|
-
Fetches web pages through the ScrapeUnblocker API and returns them as Documents.
|
|
22
|
-
|
|
23
|
-
ScrapeUnblocker renders pages in a real browser behind anti-bot protections
|
|
24
|
-
(Cloudflare, DataDome, PerimeterX, Akamai), so it reaches pages that a plain
|
|
25
|
-
HTTP fetch cannot.
|
|
26
|
-
|
|
27
|
-
### Usage example
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
from scrapeunblocker_haystack import ScrapeUnblockerFetcher
|
|
31
|
-
|
|
32
|
-
fetcher = ScrapeUnblockerFetcher()
|
|
33
|
-
result = fetcher.run(urls=["https://example.com"])
|
|
34
|
-
print(result["documents"][0].content[:200])
|
|
35
|
-
```
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
def __init__(
|
|
39
|
-
self,
|
|
40
|
-
api_key: Secret = Secret.from_env_var("SCRAPEUNBLOCKER_API_KEY"),
|
|
41
|
-
parsed_data: bool = False,
|
|
42
|
-
proxy_country: Optional[str] = None,
|
|
43
|
-
time_sleep: Optional[int] = None,
|
|
44
|
-
base_url: str = DEFAULT_BASE_URL,
|
|
45
|
-
timeout: int = DEFAULT_TIMEOUT,
|
|
46
|
-
raise_on_failure: bool = False,
|
|
47
|
-
) -> None:
|
|
48
|
-
"""
|
|
49
|
-
:param api_key: ScrapeUnblocker API key. Read from `SCRAPEUNBLOCKER_API_KEY` by default.
|
|
50
|
-
:param parsed_data: Return AI-parsed structured JSON instead of raw HTML.
|
|
51
|
-
:param proxy_country: Two-letter country code for the exit IP, for geo-restricted content.
|
|
52
|
-
:param time_sleep: Seconds to wait after page load before capturing.
|
|
53
|
-
:param base_url: API base URL. Override to target a different environment.
|
|
54
|
-
:param timeout: HTTP timeout in seconds.
|
|
55
|
-
:param raise_on_failure: Raise instead of skipping when a URL cannot be fetched.
|
|
56
|
-
"""
|
|
57
|
-
self.api_key = api_key
|
|
58
|
-
self.parsed_data = parsed_data
|
|
59
|
-
self.proxy_country = proxy_country
|
|
60
|
-
self.time_sleep = time_sleep
|
|
61
|
-
self.base_url = base_url.rstrip("/")
|
|
62
|
-
self.timeout = timeout
|
|
63
|
-
self.raise_on_failure = raise_on_failure
|
|
64
|
-
|
|
65
|
-
def to_dict(self) -> Dict[str, Any]:
|
|
66
|
-
"""Serialize this component to a dictionary."""
|
|
67
|
-
return default_to_dict(
|
|
68
|
-
self,
|
|
69
|
-
api_key=self.api_key.to_dict(),
|
|
70
|
-
parsed_data=self.parsed_data,
|
|
71
|
-
proxy_country=self.proxy_country,
|
|
72
|
-
time_sleep=self.time_sleep,
|
|
73
|
-
base_url=self.base_url,
|
|
74
|
-
timeout=self.timeout,
|
|
75
|
-
raise_on_failure=self.raise_on_failure,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
@classmethod
|
|
79
|
-
def from_dict(cls, data: Dict[str, Any]) -> "ScrapeUnblockerFetcher":
|
|
80
|
-
"""Deserialize this component from a dictionary."""
|
|
81
|
-
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
82
|
-
return default_from_dict(cls, data)
|
|
83
|
-
|
|
84
|
-
def _fetch(self, url: str) -> requests.Response:
|
|
85
|
-
params: Dict[str, Any] = {"url": url}
|
|
86
|
-
if self.parsed_data:
|
|
87
|
-
params["parsed_data"] = True
|
|
88
|
-
if self.proxy_country:
|
|
89
|
-
params["proxy_country"] = self.proxy_country
|
|
90
|
-
if self.time_sleep is not None:
|
|
91
|
-
params["time_sleep"] = self.time_sleep
|
|
92
|
-
|
|
93
|
-
response = requests.post(
|
|
94
|
-
f"{self.base_url}/getPageSource",
|
|
95
|
-
params=params,
|
|
96
|
-
headers={"X-ScrapeUnblocker-Key": self.api_key.resolve_value()},
|
|
97
|
-
timeout=self.timeout,
|
|
98
|
-
)
|
|
99
|
-
response.raise_for_status()
|
|
100
|
-
return response
|
|
101
|
-
|
|
102
|
-
@component.output_types(documents=List[Document])
|
|
103
|
-
def run(self, urls: List[str]) -> Dict[str, List[Document]]:
|
|
104
|
-
"""
|
|
105
|
-
Fetch each URL and return one Document per successfully fetched page.
|
|
106
|
-
|
|
107
|
-
:param urls: URLs to fetch.
|
|
108
|
-
:returns: A dictionary with a `documents` key holding the fetched pages.
|
|
109
|
-
"""
|
|
110
|
-
documents: List[Document] = []
|
|
111
|
-
|
|
112
|
-
for url in urls:
|
|
113
|
-
try:
|
|
114
|
-
response = self._fetch(url)
|
|
115
|
-
except Exception as exc:
|
|
116
|
-
if self.raise_on_failure:
|
|
117
|
-
raise
|
|
118
|
-
# One unreachable URL should not discard the rest of the batch.
|
|
119
|
-
logger.warning(
|
|
120
|
-
"ScrapeUnblocker could not fetch {url}: {error}", url=url, error=str(exc)
|
|
121
|
-
)
|
|
122
|
-
continue
|
|
123
|
-
|
|
124
|
-
if self.parsed_data:
|
|
125
|
-
try:
|
|
126
|
-
content = json.dumps(response.json(), ensure_ascii=False)
|
|
127
|
-
except ValueError:
|
|
128
|
-
content = response.text
|
|
129
|
-
else:
|
|
130
|
-
content = response.text
|
|
131
|
-
|
|
132
|
-
documents.append(
|
|
133
|
-
Document(
|
|
134
|
-
content=content,
|
|
135
|
-
meta={
|
|
136
|
-
"url": url,
|
|
137
|
-
"content_type": response.headers.get("Content-Type", ""),
|
|
138
|
-
"parsed_data": self.parsed_data,
|
|
139
|
-
},
|
|
140
|
-
)
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
return {"documents": documents}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
"""Unit tests for the ScrapeUnblocker Haystack components."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from unittest.mock import MagicMock, patch
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
from haystack import Document
|
|
8
|
-
from haystack.utils import Secret
|
|
9
|
-
|
|
10
|
-
from scrapeunblocker_haystack import ScrapeUnblockerFetcher, ScrapeUnblockerWebSearch
|
|
11
|
-
|
|
12
|
-
API_KEY = Secret.from_token("test_key")
|
|
13
|
-
# Haystack refuses to serialize token secrets, so to_dict tests use an env-var secret.
|
|
14
|
-
ENV_KEY = Secret.from_env_var("SCRAPEUNBLOCKER_API_KEY")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _response(text: str = "<html><title>Test</title></html>", json_data=None) -> MagicMock:
|
|
18
|
-
response = MagicMock()
|
|
19
|
-
response.text = text
|
|
20
|
-
response.headers = {"Content-Type": "text/html"}
|
|
21
|
-
response.raise_for_status.return_value = None
|
|
22
|
-
if json_data is not None:
|
|
23
|
-
response.json.return_value = json_data
|
|
24
|
-
return response
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class TestFetcher:
|
|
28
|
-
def test_defaults(self):
|
|
29
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
30
|
-
assert fetcher.parsed_data is False
|
|
31
|
-
assert fetcher.base_url == "https://api.scrapeunblocker.com"
|
|
32
|
-
|
|
33
|
-
def test_base_url_trailing_slash_stripped(self):
|
|
34
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, base_url="https://example.com/")
|
|
35
|
-
assert fetcher.base_url == "https://example.com"
|
|
36
|
-
|
|
37
|
-
def test_to_dict_and_back(self):
|
|
38
|
-
fetcher = ScrapeUnblockerFetcher(api_key=ENV_KEY, parsed_data=True, proxy_country="de")
|
|
39
|
-
data = fetcher.to_dict()
|
|
40
|
-
assert data["init_parameters"]["parsed_data"] is True
|
|
41
|
-
assert data["init_parameters"]["proxy_country"] == "de"
|
|
42
|
-
|
|
43
|
-
restored = ScrapeUnblockerFetcher.from_dict(data)
|
|
44
|
-
assert restored.parsed_data is True
|
|
45
|
-
assert restored.proxy_country == "de"
|
|
46
|
-
|
|
47
|
-
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
48
|
-
def test_run_single_url(self, mock_post):
|
|
49
|
-
mock_post.return_value = _response()
|
|
50
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
51
|
-
|
|
52
|
-
result = fetcher.run(urls=["https://example.com"])
|
|
53
|
-
|
|
54
|
-
assert len(result["documents"]) == 1
|
|
55
|
-
doc = result["documents"][0]
|
|
56
|
-
assert isinstance(doc, Document)
|
|
57
|
-
assert "Test" in doc.content
|
|
58
|
-
assert doc.meta["url"] == "https://example.com"
|
|
59
|
-
|
|
60
|
-
_, kwargs = mock_post.call_args
|
|
61
|
-
assert kwargs["headers"]["X-ScrapeUnblocker-Key"] == "test_key"
|
|
62
|
-
assert kwargs["params"] == {"url": "https://example.com"}
|
|
63
|
-
|
|
64
|
-
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
65
|
-
def test_run_forwards_options(self, mock_post):
|
|
66
|
-
mock_post.return_value = _response(json_data={"ok": True})
|
|
67
|
-
fetcher = ScrapeUnblockerFetcher(
|
|
68
|
-
api_key=API_KEY, parsed_data=True, proxy_country="de", time_sleep=5
|
|
69
|
-
)
|
|
70
|
-
fetcher.run(urls=["https://example.com"])
|
|
71
|
-
|
|
72
|
-
_, kwargs = mock_post.call_args
|
|
73
|
-
assert kwargs["params"]["parsed_data"] is True
|
|
74
|
-
assert kwargs["params"]["proxy_country"] == "de"
|
|
75
|
-
assert kwargs["params"]["time_sleep"] == 5
|
|
76
|
-
|
|
77
|
-
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
78
|
-
def test_parsed_data_serialised_as_json(self, mock_post):
|
|
79
|
-
mock_post.return_value = _response(json_data={"title": "Test", "price": "9.99"})
|
|
80
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
81
|
-
|
|
82
|
-
result = fetcher.run(urls=["https://example.com"])
|
|
83
|
-
|
|
84
|
-
assert json.loads(result["documents"][0].content) == {"title": "Test", "price": "9.99"}
|
|
85
|
-
|
|
86
|
-
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
87
|
-
def test_failure_is_skipped_by_default(self, mock_post):
|
|
88
|
-
mock_post.side_effect = [Exception("boom"), _response()]
|
|
89
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
90
|
-
|
|
91
|
-
result = fetcher.run(urls=["https://broken.com", "https://ok.com"])
|
|
92
|
-
|
|
93
|
-
assert len(result["documents"]) == 1
|
|
94
|
-
assert result["documents"][0].meta["url"] == "https://ok.com"
|
|
95
|
-
|
|
96
|
-
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
97
|
-
def test_failure_raises_when_requested(self, mock_post):
|
|
98
|
-
mock_post.side_effect = Exception("boom")
|
|
99
|
-
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, raise_on_failure=True)
|
|
100
|
-
|
|
101
|
-
with pytest.raises(Exception, match="boom"):
|
|
102
|
-
fetcher.run(urls=["https://broken.com"])
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class TestWebSearch:
|
|
106
|
-
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
107
|
-
def test_run(self, mock_post):
|
|
108
|
-
mock_post.return_value = _response(
|
|
109
|
-
json_data={
|
|
110
|
-
"organic": [
|
|
111
|
-
{"title": "First", "url": "https://a.com", "description": "snippet a", "position": "1"},
|
|
112
|
-
{"title": "Second", "url": "https://b.com", "description": "snippet b", "position": "2"},
|
|
113
|
-
]
|
|
114
|
-
}
|
|
115
|
-
)
|
|
116
|
-
search = ScrapeUnblockerWebSearch(api_key=API_KEY)
|
|
117
|
-
|
|
118
|
-
result = search.run(query="test query")
|
|
119
|
-
|
|
120
|
-
assert len(result["documents"]) == 2
|
|
121
|
-
assert result["documents"][0].content == "snippet a"
|
|
122
|
-
assert result["documents"][0].meta["title"] == "First"
|
|
123
|
-
assert result["documents"][0].meta["link"] == "https://a.com"
|
|
124
|
-
assert result["documents"][0].meta["query"] == "test query"
|
|
125
|
-
|
|
126
|
-
_, kwargs = mock_post.call_args
|
|
127
|
-
assert kwargs["params"]["keyword"] == "test query"
|
|
128
|
-
|
|
129
|
-
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
130
|
-
def test_top_k(self, mock_post):
|
|
131
|
-
mock_post.return_value = _response(
|
|
132
|
-
json_data={"organic": [{"title": str(i), "url": "", "description": str(i)} for i in range(10)]}
|
|
133
|
-
)
|
|
134
|
-
search = ScrapeUnblockerWebSearch(api_key=API_KEY, top_k=3)
|
|
135
|
-
|
|
136
|
-
assert len(search.run(query="q")["documents"]) == 3
|
|
137
|
-
|
|
138
|
-
@patch("scrapeunblocker_haystack.search.requests.post")
|
|
139
|
-
def test_missing_organic_returns_empty(self, mock_post):
|
|
140
|
-
mock_post.return_value = _response(json_data={"totalResults": None})
|
|
141
|
-
search = ScrapeUnblockerWebSearch(api_key=API_KEY)
|
|
142
|
-
|
|
143
|
-
assert search.run(query="q")["documents"] == []
|
|
144
|
-
|
|
145
|
-
def test_to_dict_and_back(self):
|
|
146
|
-
search = ScrapeUnblockerWebSearch(api_key=ENV_KEY, top_k=5, proxy_country="us")
|
|
147
|
-
data = search.to_dict()
|
|
148
|
-
restored = ScrapeUnblockerWebSearch.from_dict(data)
|
|
149
|
-
assert restored.top_k == 5
|
|
150
|
-
assert restored.proxy_country == "us"
|
{scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.1.0 → scrapeunblocker_haystack-0.2.1}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|