scrapedo-haystack 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install -e ".[test]"
17
+ - run: pytest -q
18
+
19
+ publish:
20
+ needs: test
21
+ runs-on: ubuntu-latest
22
+ environment: pypi
23
+ permissions:
24
+ id-token: write
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - run: pip install build && python -m build
31
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ dist/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Scrape.do
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: scrapedo-haystack
3
+ Version: 0.1.0
4
+ Summary: Fetch web pages through the Scrape.do API in Haystack pipelines and agents.
5
+ Project-URL: Homepage, https://scrape.do
6
+ Project-URL: Documentation, https://scrape.do/documentation/
7
+ Project-URL: Source, https://github.com/bugrahansss/scrapedo-haystack
8
+ Project-URL: Issues, https://github.com/bugrahansss/scrapedo-haystack/issues
9
+ Author: Scrape.do
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: haystack,llm,rag,scrape.do,web scraping
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: haystack-ai>=2.0
18
+ Requires-Dist: requests>=2.28
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # scrapedo-haystack
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/scrapedo-haystack.svg)](https://pypi.org/project/scrapedo-haystack/)
26
+
27
+ A [Haystack](https://haystack.deepset.ai/) component that fetches web pages through the [Scrape.do](https://scrape.do) API and returns them as Haystack `Document`s.
28
+
29
+ Scrape.do handles proxies, retries, CAPTCHAs and anti-bot protection, so the component works on pages that block normal HTTP requests. Pages can be returned as Markdown (ready for an LLM) or raw HTML.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install scrapedo-haystack
35
+ ```
36
+
37
+ Get an API token from [Scrape.do](https://scrape.do) and set it as an environment variable:
38
+
39
+ ```bash
40
+ export SCRAPEDO_API_KEY="your-token"
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### Fetch pages
46
+
47
+ ```python
48
+ from scrapedo_haystack import ScrapeDoFetcher
49
+
50
+ fetcher = ScrapeDoFetcher()
51
+ result = fetcher.run(urls=["https://example.com"])
52
+
53
+ doc = result["documents"][0]
54
+ print(doc.meta["url"])
55
+ print(doc.content) # page content as Markdown
56
+ ```
57
+
58
+ ### Options
59
+
60
+ ```python
61
+ fetcher = ScrapeDoFetcher(
62
+ output="markdown", # "markdown" or "raw" (original HTML)
63
+ render=True, # run JavaScript in a headless browser
64
+ super_proxy=True, # use residential and mobile IPs
65
+ geo_code="us", # route the request through a country
66
+ extra_params={"waitUntil": "networkidle0"}, # any other Scrape.do parameter
67
+ timeout=60,
68
+ max_workers=4, # URLs fetched in parallel
69
+ raise_on_failure=False, # skip failed URLs instead of raising
70
+ )
71
+ ```
72
+
73
+ All Scrape.do API parameters are listed in the [Scrape.do documentation](https://scrape.do/documentation/).
74
+
75
+ ### Use in a RAG pipeline
76
+
77
+ Fetch pages, split them, and answer a question from their content:
78
+
79
+ ```python
80
+ from haystack import Pipeline
81
+ from haystack.components.builders import ChatPromptBuilder
82
+ from haystack.components.generators.chat import OpenAIChatGenerator
83
+ from haystack.components.preprocessors import DocumentSplitter
84
+ from haystack.dataclasses import ChatMessage
85
+
86
+ from scrapedo_haystack import ScrapeDoFetcher
87
+
88
+ template = [
89
+ ChatMessage.from_user(
90
+ """Answer the question using only these pages.
91
+
92
+ {% for doc in documents %}
93
+ Source: {{ doc.meta.url }}
94
+ {{ doc.content }}
95
+ {% endfor %}
96
+
97
+ Question: {{ question }}"""
98
+ )
99
+ ]
100
+
101
+ pipe = Pipeline()
102
+ pipe.add_component("fetcher", ScrapeDoFetcher(render=True))
103
+ pipe.add_component("splitter", DocumentSplitter(split_by="word", split_length=300))
104
+ pipe.add_component("prompt", ChatPromptBuilder(template=template, required_variables=["question"]))
105
+ pipe.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini"))
106
+
107
+ pipe.connect("fetcher.documents", "splitter.documents")
108
+ pipe.connect("splitter.documents", "prompt.documents")
109
+ pipe.connect("prompt.prompt", "llm.messages")
110
+
111
+ result = pipe.run({
112
+ "fetcher": {"urls": ["https://haystack.deepset.ai/overview/intro"]},
113
+ "prompt": {"question": "What is Haystack?"},
114
+ })
115
+ print(result["llm"]["replies"][0].text)
116
+ ```
117
+
118
+ This example also needs `OPENAI_API_KEY` set.
119
+
120
+ ### Load a saved pipeline
121
+
122
+ Haystack 3 only loads components from allowed modules when reading a pipeline from YAML. Add this package to the allowlist:
123
+
124
+ ```python
125
+ pipe = Pipeline.loads(yaml_text, allowed_modules=["scrapedo_haystack.fetcher"])
126
+ ```
127
+
128
+ The API token is never written to the YAML; it is read from `SCRAPEDO_API_KEY` when the pipeline loads.
129
+
130
+ ## Output
131
+
132
+ Each fetched URL becomes one `Document`:
133
+
134
+ | Field | Value |
135
+ |---|---|
136
+ | `content` | Page as Markdown, or raw HTML when `output="raw"` |
137
+ | `meta["url"]` | The requested URL |
138
+ | `meta["status_code"]` | HTTP status returned by Scrape.do |
139
+ | `meta["content_type"]` | `text/markdown`, or the page's content type for raw output |
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ pip install -e ".[test]"
145
+ pytest
146
+ ```
147
+
148
+ ## License
149
+
150
+ Apache-2.0
@@ -0,0 +1,128 @@
1
+ # scrapedo-haystack
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/scrapedo-haystack.svg)](https://pypi.org/project/scrapedo-haystack/)
4
+
5
+ A [Haystack](https://haystack.deepset.ai/) component that fetches web pages through the [Scrape.do](https://scrape.do) API and returns them as Haystack `Document`s.
6
+
7
+ Scrape.do handles proxies, retries, CAPTCHAs and anti-bot protection, so the component works on pages that block normal HTTP requests. Pages can be returned as Markdown (ready for an LLM) or raw HTML.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install scrapedo-haystack
13
+ ```
14
+
15
+ Get an API token from [Scrape.do](https://scrape.do) and set it as an environment variable:
16
+
17
+ ```bash
18
+ export SCRAPEDO_API_KEY="your-token"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Fetch pages
24
+
25
+ ```python
26
+ from scrapedo_haystack import ScrapeDoFetcher
27
+
28
+ fetcher = ScrapeDoFetcher()
29
+ result = fetcher.run(urls=["https://example.com"])
30
+
31
+ doc = result["documents"][0]
32
+ print(doc.meta["url"])
33
+ print(doc.content) # page content as Markdown
34
+ ```
35
+
36
+ ### Options
37
+
38
+ ```python
39
+ fetcher = ScrapeDoFetcher(
40
+ output="markdown", # "markdown" or "raw" (original HTML)
41
+ render=True, # run JavaScript in a headless browser
42
+ super_proxy=True, # use residential and mobile IPs
43
+ geo_code="us", # route the request through a country
44
+ extra_params={"waitUntil": "networkidle0"}, # any other Scrape.do parameter
45
+ timeout=60,
46
+ max_workers=4, # URLs fetched in parallel
47
+ raise_on_failure=False, # skip failed URLs instead of raising
48
+ )
49
+ ```
50
+
51
+ All Scrape.do API parameters are listed in the [Scrape.do documentation](https://scrape.do/documentation/).
52
+
53
+ ### Use in a RAG pipeline
54
+
55
+ Fetch pages, split them, and answer a question from their content:
56
+
57
+ ```python
58
+ from haystack import Pipeline
59
+ from haystack.components.builders import ChatPromptBuilder
60
+ from haystack.components.generators.chat import OpenAIChatGenerator
61
+ from haystack.components.preprocessors import DocumentSplitter
62
+ from haystack.dataclasses import ChatMessage
63
+
64
+ from scrapedo_haystack import ScrapeDoFetcher
65
+
66
+ template = [
67
+ ChatMessage.from_user(
68
+ """Answer the question using only these pages.
69
+
70
+ {% for doc in documents %}
71
+ Source: {{ doc.meta.url }}
72
+ {{ doc.content }}
73
+ {% endfor %}
74
+
75
+ Question: {{ question }}"""
76
+ )
77
+ ]
78
+
79
+ pipe = Pipeline()
80
+ pipe.add_component("fetcher", ScrapeDoFetcher(render=True))
81
+ pipe.add_component("splitter", DocumentSplitter(split_by="word", split_length=300))
82
+ pipe.add_component("prompt", ChatPromptBuilder(template=template, required_variables=["question"]))
83
+ pipe.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini"))
84
+
85
+ pipe.connect("fetcher.documents", "splitter.documents")
86
+ pipe.connect("splitter.documents", "prompt.documents")
87
+ pipe.connect("prompt.prompt", "llm.messages")
88
+
89
+ result = pipe.run({
90
+ "fetcher": {"urls": ["https://haystack.deepset.ai/overview/intro"]},
91
+ "prompt": {"question": "What is Haystack?"},
92
+ })
93
+ print(result["llm"]["replies"][0].text)
94
+ ```
95
+
96
+ This example also needs `OPENAI_API_KEY` set.
97
+
98
+ ### Load a saved pipeline
99
+
100
+ Haystack 3 only loads components from allowed modules when reading a pipeline from YAML. Add this package to the allowlist:
101
+
102
+ ```python
103
+ pipe = Pipeline.loads(yaml_text, allowed_modules=["scrapedo_haystack.fetcher"])
104
+ ```
105
+
106
+ The API token is never written to the YAML; it is read from `SCRAPEDO_API_KEY` when the pipeline loads.
107
+
108
+ ## Output
109
+
110
+ Each fetched URL becomes one `Document`:
111
+
112
+ | Field | Value |
113
+ |---|---|
114
+ | `content` | Page as Markdown, or raw HTML when `output="raw"` |
115
+ | `meta["url"]` | The requested URL |
116
+ | `meta["status_code"]` | HTTP status returned by Scrape.do |
117
+ | `meta["content_type"]` | `text/markdown`, or the page's content type for raw output |
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ pip install -e ".[test]"
123
+ pytest
124
+ ```
125
+
126
+ ## License
127
+
128
+ Apache-2.0
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "scrapedo-haystack"
7
+ version = "0.1.0"
8
+ description = "Fetch web pages through the Scrape.do API in Haystack pipelines and agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Scrape.do" }]
13
+ keywords = ["haystack", "scrape.do", "web scraping", "rag", "llm"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = ["haystack-ai>=2.0", "requests>=2.28"]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://scrape.do"
26
+ Documentation = "https://scrape.do/documentation/"
27
+ Source = "https://github.com/bugrahansss/scrapedo-haystack"
28
+ Issues = "https://github.com/bugrahansss/scrapedo-haystack/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/scrapedo_haystack"]
@@ -0,0 +1,4 @@
1
+ from scrapedo_haystack.fetcher import ScrapeDoFetcher
2
+
3
+ __all__ = ["ScrapeDoFetcher"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,144 @@
1
+ """Haystack component that fetches web pages through the Scrape.do API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any, Literal
8
+
9
+ import requests
10
+ from haystack import Document, component, default_from_dict, default_to_dict
11
+ from haystack.utils import Secret, deserialize_secrets_inplace
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ SCRAPEDO_API_URL = "https://api.scrape.do/"
16
+
17
+ OutputFormat = Literal["markdown", "raw"]
18
+
19
+
20
+ @component
21
+ class ScrapeDoFetcher:
22
+ """
23
+ Fetches web pages through the Scrape.do API and returns them as Haystack `Document`s.
24
+
25
+ Scrape.do handles proxies, retries and anti-bot protection. Set `render=True` for pages
26
+ that need JavaScript, `super_proxy=True` for residential and mobile IPs, and `geo_code`
27
+ to route the request through a specific country.
28
+
29
+ Usage example:
30
+ ```python
31
+ from scrapedo_haystack import ScrapeDoFetcher
32
+
33
+ fetcher = ScrapeDoFetcher() # reads the SCRAPEDO_API_KEY environment variable
34
+ result = fetcher.run(urls=["https://example.com"])
35
+ print(result["documents"][0].content)
36
+ ```
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ api_key: Secret = Secret.from_env_var("SCRAPEDO_API_KEY"),
42
+ output: OutputFormat = "markdown",
43
+ render: bool = False,
44
+ super_proxy: bool = False,
45
+ geo_code: str | None = None,
46
+ extra_params: dict[str, Any] | None = None,
47
+ timeout: int = 60,
48
+ max_workers: int = 4,
49
+ raise_on_failure: bool = False,
50
+ ) -> None:
51
+ """
52
+ Create a ScrapeDoFetcher.
53
+
54
+ :param api_key: Scrape.do API token. Defaults to the `SCRAPEDO_API_KEY` environment variable.
55
+ :param output: `"markdown"` returns the page as Markdown, `"raw"` returns the original HTML.
56
+ :param render: Render the page in a headless browser so JavaScript runs.
57
+ :param super_proxy: Use residential and mobile proxies.
58
+ :param geo_code: Two-letter country code to route the request through, for example `"us"`.
59
+ :param extra_params: Any other Scrape.do API parameters, passed as-is (for example `{"waitUntil": "networkidle0"}`).
60
+ :param timeout: Request timeout in seconds.
61
+ :param max_workers: Number of URLs fetched in parallel.
62
+ :param raise_on_failure: Raise an error when a URL fails. If `False`, failed URLs are logged and skipped.
63
+ """
64
+ if output not in ("markdown", "raw"):
65
+ raise ValueError(f"output must be 'markdown' or 'raw', got {output!r}")
66
+ self.api_key = api_key
67
+ self.output = output
68
+ self.render = render
69
+ self.super_proxy = super_proxy
70
+ self.geo_code = geo_code
71
+ self.extra_params = extra_params or {}
72
+ self.timeout = timeout
73
+ self.max_workers = max_workers
74
+ self.raise_on_failure = raise_on_failure
75
+
76
+ def to_dict(self) -> dict[str, Any]:
77
+ """Serialize the component to a dictionary."""
78
+ return default_to_dict(
79
+ self,
80
+ api_key=self.api_key.to_dict(),
81
+ output=self.output,
82
+ render=self.render,
83
+ super_proxy=self.super_proxy,
84
+ geo_code=self.geo_code,
85
+ extra_params=self.extra_params,
86
+ timeout=self.timeout,
87
+ max_workers=self.max_workers,
88
+ raise_on_failure=self.raise_on_failure,
89
+ )
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any]) -> "ScrapeDoFetcher":
93
+ """Deserialize the component from a dictionary."""
94
+ deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
95
+ return default_from_dict(cls, data)
96
+
97
+ def _build_params(self, url: str) -> dict[str, Any]:
98
+ token = self.api_key.resolve_value()
99
+ if not token:
100
+ raise ValueError("Scrape.do API key is missing. Set the SCRAPEDO_API_KEY environment variable.")
101
+ params: dict[str, Any] = {"token": token, "url": url}
102
+ if self.output == "markdown":
103
+ params["output"] = "markdown"
104
+ if self.render:
105
+ params["render"] = "true"
106
+ if self.super_proxy:
107
+ params["super"] = "true"
108
+ if self.geo_code:
109
+ params["geoCode"] = self.geo_code
110
+ params.update(self.extra_params)
111
+ return params
112
+
113
+ def _fetch(self, url: str) -> Document | None:
114
+ try:
115
+ response = requests.get(SCRAPEDO_API_URL, params=self._build_params(url), timeout=self.timeout)
116
+ response.raise_for_status()
117
+ except Exception as error:
118
+ if self.raise_on_failure:
119
+ raise
120
+ # never log the request URL: it contains the API token
121
+ logger.warning("Scrape.do could not fetch %s: %s", url, type(error).__name__)
122
+ return None
123
+
124
+ meta = {
125
+ "url": url,
126
+ "status_code": response.status_code,
127
+ "content_type": "text/markdown" if self.output == "markdown" else response.headers.get("Content-Type"),
128
+ }
129
+ return Document(content=response.text, meta=meta)
130
+
131
+ @component.output_types(documents=list[Document])
132
+ def run(self, urls: list[str]) -> dict[str, list[Document]]:
133
+ """
134
+ Fetch the given URLs through Scrape.do.
135
+
136
+ :param urls: The web pages to fetch.
137
+ :returns: A dictionary with `documents`: one Document per successfully fetched URL,
138
+ with the page content and `url`, `status_code` and `content_type` in `meta`.
139
+ """
140
+ if not urls:
141
+ return {"documents": []}
142
+ with ThreadPoolExecutor(max_workers=min(self.max_workers, len(urls))) as pool:
143
+ results = list(pool.map(self._fetch, urls))
144
+ return {"documents": [doc for doc in results if doc is not None]}
@@ -0,0 +1,132 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ import requests
5
+ from haystack import Document, Pipeline
6
+ from haystack.utils import Secret
7
+
8
+ from scrapedo_haystack import ScrapeDoFetcher
9
+
10
+
11
+ def _response(text="# Hello", status=200, content_type="text/plain"):
12
+ resp = MagicMock()
13
+ resp.text = text
14
+ resp.status_code = status
15
+ resp.headers = {"Content-Type": content_type}
16
+ if status >= 400:
17
+ resp.raise_for_status.side_effect = requests.HTTPError(f"{status}")
18
+ return resp
19
+
20
+
21
+ @pytest.fixture
22
+ def fetcher():
23
+ return ScrapeDoFetcher(api_key=Secret.from_token("test-token"))
24
+
25
+
26
+ @patch("scrapedo_haystack.fetcher.requests.get")
27
+ def test_returns_documents(mock_get, fetcher):
28
+ mock_get.return_value = _response("# Example page")
29
+ docs = fetcher.run(urls=["https://example.com"])["documents"]
30
+
31
+ assert len(docs) == 1
32
+ assert isinstance(docs[0], Document)
33
+ assert docs[0].content == "# Example page"
34
+ assert docs[0].meta["url"] == "https://example.com"
35
+ assert docs[0].meta["content_type"] == "text/markdown"
36
+
37
+
38
+ @patch("scrapedo_haystack.fetcher.requests.get")
39
+ def test_sends_scrapedo_params(mock_get):
40
+ mock_get.return_value = _response()
41
+ fetcher = ScrapeDoFetcher(
42
+ api_key=Secret.from_token("test-token"),
43
+ render=True,
44
+ super_proxy=True,
45
+ geo_code="us",
46
+ extra_params={"waitUntil": "networkidle0"},
47
+ )
48
+ fetcher.run(urls=["https://example.com"])
49
+
50
+ args, kwargs = mock_get.call_args
51
+ assert args[0] == "https://api.scrape.do/"
52
+ assert kwargs["params"] == {
53
+ "token": "test-token",
54
+ "url": "https://example.com",
55
+ "output": "markdown",
56
+ "render": "true",
57
+ "super": "true",
58
+ "geoCode": "us",
59
+ "waitUntil": "networkidle0",
60
+ }
61
+
62
+
63
+ @patch("scrapedo_haystack.fetcher.requests.get")
64
+ def test_raw_output_has_no_output_param(mock_get):
65
+ mock_get.return_value = _response("<html></html>", content_type="text/html")
66
+ fetcher = ScrapeDoFetcher(api_key=Secret.from_token("t"), output="raw")
67
+ docs = fetcher.run(urls=["https://example.com"])["documents"]
68
+
69
+ assert "output" not in mock_get.call_args.kwargs["params"]
70
+ assert docs[0].meta["content_type"] == "text/html"
71
+
72
+
73
+ @patch("scrapedo_haystack.fetcher.requests.get")
74
+ def test_failed_url_is_skipped(mock_get, fetcher):
75
+ mock_get.side_effect = [_response("ok"), _response(status=500)]
76
+ docs = fetcher.run(urls=["https://a.com", "https://b.com"])["documents"]
77
+ assert [d.meta["url"] for d in docs] == ["https://a.com"]
78
+
79
+
80
+ @patch("scrapedo_haystack.fetcher.requests.get")
81
+ def test_raise_on_failure(mock_get):
82
+ mock_get.return_value = _response(status=401)
83
+ fetcher = ScrapeDoFetcher(api_key=Secret.from_token("t"), raise_on_failure=True)
84
+ with pytest.raises(requests.HTTPError):
85
+ fetcher.run(urls=["https://example.com"])
86
+
87
+
88
+ def test_empty_urls(fetcher):
89
+ assert fetcher.run(urls=[]) == {"documents": []}
90
+
91
+
92
+ def test_missing_api_key(monkeypatch):
93
+ monkeypatch.delenv("SCRAPEDO_API_KEY", raising=False)
94
+ fetcher = ScrapeDoFetcher(api_key=Secret.from_env_var("SCRAPEDO_API_KEY", strict=False), raise_on_failure=True)
95
+ with pytest.raises(ValueError, match="API key is missing"):
96
+ fetcher.run(urls=["https://example.com"])
97
+
98
+
99
+ def test_invalid_output():
100
+ with pytest.raises(ValueError):
101
+ ScrapeDoFetcher(api_key=Secret.from_token("t"), output="pdf")
102
+
103
+
104
+ def test_serialization_roundtrip(monkeypatch):
105
+ monkeypatch.setenv("SCRAPEDO_API_KEY", "env-token")
106
+ fetcher = ScrapeDoFetcher(render=True, geo_code="de")
107
+ data = fetcher.to_dict()
108
+
109
+ assert "env-token" not in str(data)
110
+ restored = ScrapeDoFetcher.from_dict(data)
111
+ assert restored.render is True
112
+ assert restored.geo_code == "de"
113
+ assert restored.api_key.resolve_value() == "env-token"
114
+
115
+
116
+ @patch("scrapedo_haystack.fetcher.requests.get")
117
+ def test_works_in_pipeline_and_yaml(mock_get, monkeypatch):
118
+ monkeypatch.setenv("SCRAPEDO_API_KEY", "env-token")
119
+ mock_get.return_value = _response("# Page")
120
+
121
+ from haystack.components.preprocessors import DocumentSplitter
122
+
123
+ pipe = Pipeline()
124
+ pipe.add_component("fetcher", ScrapeDoFetcher())
125
+ pipe.add_component("splitter", DocumentSplitter(split_by="word", split_length=50))
126
+ pipe.connect("fetcher.documents", "splitter.documents")
127
+
128
+ result = pipe.run({"fetcher": {"urls": ["https://example.com"]}})
129
+ assert result["splitter"]["documents"][0].content == "# Page"
130
+
131
+ restored = Pipeline.loads(pipe.dumps(), allowed_modules=["scrapedo_haystack.fetcher"])
132
+ assert "fetcher" in restored.graph.nodes