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.
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: scrapeunblocker-haystack
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Haystack integration for ScrapeUnblocker - scrape pages behind anti-bot protections
5
5
  Project-URL: Homepage, https://www.scrapeunblocker.com
6
- Project-URL: Documentation, https://developers.scrapeunblocker.com
6
+ Project-URL: Documentation, https://docs.scrapeunblocker.com
7
7
  Project-URL: Source, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack
8
8
  Project-URL: Issues, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues
9
9
  Author-email: ScrapeUnblocker <info@scrapeunblocker.com>
@@ -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://developers.scrapeunblocker.com
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://developers.scrapeunblocker.com
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://developers.scrapeunblocker.com"
28
+ Documentation = "https://docs.scrapeunblocker.com"
29
29
  Source = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack"
30
30
  Issues = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues"
31
31
 
@@ -0,0 +1,13 @@
1
+ from scrapeunblocker_haystack.fetcher import (
2
+ ScrapeUnblockerFetcher,
3
+ StepExecutionError,
4
+ TargetNotFoundError,
5
+ )
6
+ from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
7
+
8
+ __all__ = [
9
+ "ScrapeUnblockerFetcher",
10
+ "ScrapeUnblockerWebSearch",
11
+ "StepExecutionError",
12
+ "TargetNotFoundError",
13
+ ]
@@ -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,8 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0
4
-
5
- Initial release.
6
-
7
- - `ScrapeUnblockerFetcher` - fetch pages behind anti-bot protections as Documents
8
- - `ScrapeUnblockerWebSearch` - Google organic results as Documents
@@ -1,4 +0,0 @@
1
- from scrapeunblocker_haystack.fetcher import ScrapeUnblockerFetcher
2
- from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
3
-
4
- __all__ = ["ScrapeUnblockerFetcher", "ScrapeUnblockerWebSearch"]
@@ -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"