firecrawl-haystack 1.0.1__tar.gz → 1.2.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.
- firecrawl_haystack-1.2.0/CHANGELOG.md +22 -0
- firecrawl_haystack-1.0.1/LICENSE → firecrawl_haystack-1.2.0/LICENSE.txt +1 -1
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/PKG-INFO +7 -6
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/README.md +2 -2
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/pydoc/config_docusaurus.yml +1 -0
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/pyproject.toml +18 -3
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/src/haystack_integrations/components/fetchers/firecrawl/firecrawl_crawler.py +25 -57
- firecrawl_haystack-1.2.0/src/haystack_integrations/components/websearch/firecrawl/__init__.py +7 -0
- firecrawl_haystack-1.2.0/src/haystack_integrations/components/websearch/firecrawl/firecrawl_websearch.py +194 -0
- firecrawl_haystack-1.2.0/src/haystack_integrations/components/websearch/py.typed +0 -0
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/tests/test_firecrawl_crawler.py +85 -61
- firecrawl_haystack-1.2.0/tests/test_firecrawl_websearch.py +301 -0
- firecrawl_haystack-1.0.1/CHANGELOG.md +0 -9
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/.gitignore +0 -0
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/src/haystack_integrations/components/fetchers/firecrawl/__init__.py +0 -0
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/src/haystack_integrations/components/fetchers/py.typed +0 -0
- {firecrawl_haystack-1.0.1 → firecrawl_haystack-1.2.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [integrations/firecrawl-v1.1.0] - 2026-03-09
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- Add FirecrawlWebSearch component (#2888)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [integrations/firecrawl-v1.0.1] - 2026-02-24
|
|
11
|
+
|
|
12
|
+
### 🌀 Miscellaneous
|
|
13
|
+
|
|
14
|
+
- Docs: Update Firecrawl module path in Docusaurus configuration (#2869)
|
|
15
|
+
|
|
16
|
+
## [integrations/firecrawl-v1.0.0] - 2026-02-24
|
|
17
|
+
|
|
18
|
+
### 🚀 Features
|
|
19
|
+
|
|
20
|
+
- Add FirecrawlCrawler (#2859)
|
|
21
|
+
|
|
22
|
+
<!-- generated by git-cliff -->
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright 2023 deepset GmbH
|
|
189
|
+
Copyright 2023-present deepset GmbH
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: firecrawl-haystack
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Haystack integration for Firecrawl
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/firecrawl#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
7
7
|
Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/firecrawl
|
|
8
8
|
Author-email: deepset GmbH <info@deepset.ai>
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: Firecrawl,Haystack,Web Crawler,Web Scraping
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Keywords: Firecrawl,Haystack,Web Crawler,Web Scraping,Web Search
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
14
|
Classifier: Programming Language :: Python
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
21
|
Requires-Python: >=3.10
|
|
21
22
|
Requires-Dist: firecrawl-py>=4.0.0
|
|
@@ -27,6 +28,7 @@ Description-Content-Type: text/markdown
|
|
|
27
28
|
[](https://pypi.org/project/firecrawl-haystack)
|
|
28
29
|
[](https://pypi.org/project/firecrawl-haystack)
|
|
29
30
|
|
|
31
|
+
- [Integration page](https://haystack.deepset.ai/integrations/firecrawl)
|
|
30
32
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/firecrawl/CHANGELOG.md)
|
|
31
33
|
|
|
32
34
|
---
|
|
@@ -35,5 +37,4 @@ Description-Content-Type: text/markdown
|
|
|
35
37
|
|
|
36
38
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
To run integration tests locally, you need to export the `FIRECRAWL_API_KEY` environment variable.
|
|
40
|
+
To run integration tests locally, you need to export the `FIRECRAWL_API_KEY` environment variable.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://pypi.org/project/firecrawl-haystack)
|
|
4
4
|
[](https://pypi.org/project/firecrawl-haystack)
|
|
5
5
|
|
|
6
|
+
- [Integration page](https://haystack.deepset.ai/integrations/firecrawl)
|
|
6
7
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/firecrawl/CHANGELOG.md)
|
|
7
8
|
|
|
8
9
|
---
|
|
@@ -11,5 +12,4 @@
|
|
|
11
12
|
|
|
12
13
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
To run integration tests locally, you need to export the `FIRECRAWL_API_KEY` environment variable.
|
|
15
|
+
To run integration tests locally, you need to export the `FIRECRAWL_API_KEY` environment variable.
|
|
@@ -14,6 +14,7 @@ keywords = [
|
|
|
14
14
|
"Haystack",
|
|
15
15
|
"Web Crawler",
|
|
16
16
|
"Web Scraping",
|
|
17
|
+
"Web Search",
|
|
17
18
|
]
|
|
18
19
|
authors = [
|
|
19
20
|
{ name = "deepset GmbH", email = "info@deepset.ai" },
|
|
@@ -26,6 +27,7 @@ classifiers = [
|
|
|
26
27
|
"Programming Language :: Python :: 3.11",
|
|
27
28
|
"Programming Language :: Python :: 3.12",
|
|
28
29
|
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
29
31
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
30
32
|
]
|
|
31
33
|
dependencies = [
|
|
@@ -72,8 +74,9 @@ dependencies = [
|
|
|
72
74
|
unit = 'pytest -m "not integration" {args:tests}'
|
|
73
75
|
integration = 'pytest -m "integration" {args:tests}'
|
|
74
76
|
all = 'pytest {args:tests}'
|
|
75
|
-
cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x {args:tests}'
|
|
76
|
-
|
|
77
|
+
unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}'
|
|
78
|
+
integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}'
|
|
79
|
+
types = "mypy -p haystack_integrations.components.fetchers.firecrawl -p haystack_integrations.components.websearch.firecrawl {args}"
|
|
77
80
|
|
|
78
81
|
[tool.mypy]
|
|
79
82
|
install_types = true
|
|
@@ -87,9 +90,17 @@ line-length = 120
|
|
|
87
90
|
[tool.ruff.lint]
|
|
88
91
|
select = [
|
|
89
92
|
"A",
|
|
93
|
+
"ANN",
|
|
90
94
|
"ARG",
|
|
91
95
|
"B",
|
|
92
96
|
"C",
|
|
97
|
+
"D102", # Missing docstring in public method
|
|
98
|
+
"D103", # Missing docstring in public function
|
|
99
|
+
"D205", # 1 blank line required between summary line and description
|
|
100
|
+
"D209", # Closing triple quotes go to new line
|
|
101
|
+
"D213", # summary lines must be positioned on the second physical line of the docstring
|
|
102
|
+
"D417", # Missing argument descriptions in the docstring
|
|
103
|
+
"D419", # Docstring is empty
|
|
93
104
|
"DTZ",
|
|
94
105
|
"E",
|
|
95
106
|
"EM",
|
|
@@ -122,12 +133,15 @@ ignore = [
|
|
|
122
133
|
"S105",
|
|
123
134
|
"S106",
|
|
124
135
|
"S107",
|
|
136
|
+
# Allow `Any` - used legitimately for dynamic types and SDK boundaries
|
|
137
|
+
"ANN401",
|
|
125
138
|
# Ignore complexity
|
|
126
139
|
"C901",
|
|
127
140
|
"PLR0911",
|
|
128
141
|
"PLR0912",
|
|
129
142
|
"PLR0913",
|
|
130
143
|
"PLR0915",
|
|
144
|
+
"PLR0917",
|
|
131
145
|
]
|
|
132
146
|
unfixable = [
|
|
133
147
|
# Don't touch unused imports
|
|
@@ -142,11 +156,12 @@ ban-relative-imports = "parents"
|
|
|
142
156
|
|
|
143
157
|
[tool.ruff.lint.per-file-ignores]
|
|
144
158
|
# Tests can use magic values, assertions, and relative imports
|
|
145
|
-
"tests/**/*" = ["PLR2004", "S101", "TID252"]
|
|
159
|
+
"tests/**/*" = ["D", "PLR2004", "S101", "TID252", "ANN"]
|
|
146
160
|
|
|
147
161
|
[tool.coverage.run]
|
|
148
162
|
source = ["haystack_integrations"]
|
|
149
163
|
branch = true
|
|
164
|
+
relative_files = true
|
|
150
165
|
parallel = false
|
|
151
166
|
|
|
152
167
|
[tool.coverage.report]
|
|
@@ -27,15 +27,15 @@ class FirecrawlCrawler:
|
|
|
27
27
|
### Usage example
|
|
28
28
|
|
|
29
29
|
```python
|
|
30
|
-
from haystack_integrations.components.fetchers.firecrawl import
|
|
30
|
+
from haystack_integrations.components.fetchers.firecrawl import FirecrawlCrawler
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
crawler = FirecrawlCrawler(
|
|
33
33
|
api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
34
34
|
params={"limit": 5},
|
|
35
35
|
)
|
|
36
|
-
|
|
36
|
+
crawler.warm_up()
|
|
37
37
|
|
|
38
|
-
result =
|
|
38
|
+
result = crawler.run(urls=["https://docs.haystack.deepset.ai/docs/intro"])
|
|
39
39
|
documents = result["documents"]
|
|
40
40
|
```
|
|
41
41
|
"""
|
|
@@ -46,7 +46,7 @@ class FirecrawlCrawler:
|
|
|
46
46
|
params: dict[str, Any] | None = None,
|
|
47
47
|
) -> None:
|
|
48
48
|
"""
|
|
49
|
-
Initialize the
|
|
49
|
+
Initialize the FirecrawlCrawler.
|
|
50
50
|
|
|
51
51
|
:param api_key:
|
|
52
52
|
API key for Firecrawl.
|
|
@@ -83,14 +83,18 @@ class FirecrawlCrawler:
|
|
|
83
83
|
:returns: A dictionary with the following keys:
|
|
84
84
|
- `documents`: List of documents, one for each URL crawled.
|
|
85
85
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
self.warm_up()
|
|
87
|
+
assert self._firecrawl_client is not None # noqa: S101
|
|
88
88
|
|
|
89
89
|
current_params = params if params is not None else self._params
|
|
90
90
|
documents: list[Document] = []
|
|
91
91
|
for url in urls:
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
try:
|
|
93
|
+
crawl_response = self._firecrawl_client.crawl(url=url, **current_params)
|
|
94
|
+
except Exception as error:
|
|
95
|
+
logger.exception(f"Failed to crawl website {url}: {error}")
|
|
96
|
+
continue
|
|
97
|
+
documents.extend(self._documents_from_crawl_response(url=url, crawl_response=crawl_response))
|
|
94
98
|
|
|
95
99
|
return {"documents": documents}
|
|
96
100
|
|
|
@@ -111,67 +115,31 @@ class FirecrawlCrawler:
|
|
|
111
115
|
:returns: A dictionary with the following keys:
|
|
112
116
|
- `documents`: List of documents, one for each URL crawled.
|
|
113
117
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
await self.warm_up_async()
|
|
119
|
+
assert self._async_firecrawl_client is not None # noqa: S101
|
|
116
120
|
|
|
117
121
|
current_params = params if params is not None else self._params
|
|
118
122
|
documents: list[Document] = []
|
|
119
123
|
for url in urls:
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
try:
|
|
125
|
+
crawl_response = await self._async_firecrawl_client.crawl(url=url, **current_params)
|
|
126
|
+
except Exception as error:
|
|
127
|
+
logger.exception(f"Failed to crawl website {url}: {error}")
|
|
128
|
+
continue
|
|
129
|
+
documents.extend(self._documents_from_crawl_response(url=url, crawl_response=crawl_response))
|
|
122
130
|
|
|
123
131
|
return {"documents": documents}
|
|
124
132
|
|
|
125
133
|
def warm_up(self) -> None:
|
|
126
|
-
"""
|
|
127
|
-
Warm up the Firecrawl client by initializing the clients.
|
|
128
|
-
This is useful to avoid cold start delays when crawling many URLs.
|
|
129
|
-
"""
|
|
134
|
+
"""Warm up the synchronous Firecrawl client."""
|
|
130
135
|
if self._firecrawl_client is None:
|
|
131
136
|
self._firecrawl_client = Firecrawl(api_key=self.api_key.resolve_value())
|
|
137
|
+
|
|
138
|
+
async def warm_up_async(self) -> None:
|
|
139
|
+
"""Warm up the asynchronous Firecrawl client."""
|
|
132
140
|
if self._async_firecrawl_client is None:
|
|
133
141
|
self._async_firecrawl_client = AsyncFirecrawl(api_key=self.api_key.resolve_value())
|
|
134
142
|
|
|
135
|
-
def _crawl_url(self, url: str, params: dict[str, Any]) -> list[Document]:
|
|
136
|
-
"""
|
|
137
|
-
Crawl a single URL and return Documents.
|
|
138
|
-
|
|
139
|
-
:param url: URL to crawl.
|
|
140
|
-
:param params: Crawl request parameters.
|
|
141
|
-
:return: List of Documents from the crawl result.
|
|
142
|
-
"""
|
|
143
|
-
try:
|
|
144
|
-
# Ignoring type because the firecrawl client is initialized as None and only set during warm_up
|
|
145
|
-
crawl_response = self._firecrawl_client.crawl( # type: ignore[union-attr]
|
|
146
|
-
url=url,
|
|
147
|
-
**params,
|
|
148
|
-
)
|
|
149
|
-
except Exception as error:
|
|
150
|
-
logger.exception(f"Failed to crawl website {url}: {error}")
|
|
151
|
-
return []
|
|
152
|
-
|
|
153
|
-
return self._documents_from_crawl_response(url=url, crawl_response=crawl_response)
|
|
154
|
-
|
|
155
|
-
async def _crawl_url_async(self, url: str, params: dict[str, Any]) -> list[Document]:
|
|
156
|
-
"""
|
|
157
|
-
Asynchronously crawl a single URL and return Documents.
|
|
158
|
-
|
|
159
|
-
:param url: URL to crawl.
|
|
160
|
-
:param params: Crawl request parameters.
|
|
161
|
-
:return: List of Documents from the crawl result.
|
|
162
|
-
"""
|
|
163
|
-
try:
|
|
164
|
-
# Ignoring type because the firecrawl client is initialized as None and only set during warm_up
|
|
165
|
-
crawl_response = await self._async_firecrawl_client.crawl( # type: ignore[union-attr]
|
|
166
|
-
url=url,
|
|
167
|
-
**params,
|
|
168
|
-
)
|
|
169
|
-
except Exception as error:
|
|
170
|
-
logger.exception(f"Failed to crawl website {url}: {error}")
|
|
171
|
-
return []
|
|
172
|
-
|
|
173
|
-
return self._documents_from_crawl_response(url=url, crawl_response=crawl_response)
|
|
174
|
-
|
|
175
143
|
def _documents_from_crawl_response(self, url: str, crawl_response: Any) -> list[Document]:
|
|
176
144
|
"""
|
|
177
145
|
Convert a Firecrawl crawl response to Haystack Documents.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from haystack import Document, component, logging
|
|
8
|
+
from haystack.utils import Secret
|
|
9
|
+
|
|
10
|
+
from firecrawl import AsyncFirecrawl, Firecrawl # type: ignore[import-untyped]
|
|
11
|
+
from firecrawl.types import SearchData # type: ignore[import-untyped]
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@component
|
|
17
|
+
class FirecrawlWebSearch:
|
|
18
|
+
"""
|
|
19
|
+
A component that uses Firecrawl to search the web and return results as Haystack Documents.
|
|
20
|
+
|
|
21
|
+
This component wraps the Firecrawl Search API, enabling web search queries that return
|
|
22
|
+
structured documents with content and links. It follows the standard Haystack WebSearch
|
|
23
|
+
component interface.
|
|
24
|
+
|
|
25
|
+
Firecrawl is a service that crawls and scrapes websites, returning content in formats suitable
|
|
26
|
+
for LLMs. You need a Firecrawl API key from [firecrawl.dev](https://firecrawl.dev).
|
|
27
|
+
|
|
28
|
+
### Usage example
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from haystack_integrations.components.websearch.firecrawl import FirecrawlWebSearch
|
|
32
|
+
from haystack.utils import Secret
|
|
33
|
+
|
|
34
|
+
websearch = FirecrawlWebSearch(
|
|
35
|
+
api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
36
|
+
top_k=5,
|
|
37
|
+
)
|
|
38
|
+
result = websearch.run(query="What is Haystack by deepset?")
|
|
39
|
+
documents = result["documents"]
|
|
40
|
+
links = result["links"]
|
|
41
|
+
```
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_key: Secret = Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
47
|
+
top_k: int | None = 10,
|
|
48
|
+
search_params: dict[str, Any] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initialize the FirecrawlWebSearch component.
|
|
52
|
+
|
|
53
|
+
:param api_key:
|
|
54
|
+
API key for Firecrawl.
|
|
55
|
+
Defaults to the `FIRECRAWL_API_KEY` environment variable.
|
|
56
|
+
:param top_k:
|
|
57
|
+
Maximum number of documents to return.
|
|
58
|
+
Defaults to 10. This can be overridden by the `"limit"` parameter in `search_params`.
|
|
59
|
+
:param search_params:
|
|
60
|
+
Additional parameters passed to the Firecrawl search API.
|
|
61
|
+
See the [Firecrawl API reference](https://docs.firecrawl.dev/api-reference/endpoint/search)
|
|
62
|
+
for available parameters. Supported keys include: `tbs`, `location`,
|
|
63
|
+
`scrape_options`, `sources`, `categories`, `timeout`.
|
|
64
|
+
"""
|
|
65
|
+
self.api_key = api_key
|
|
66
|
+
self.top_k = top_k
|
|
67
|
+
self.search_params = search_params
|
|
68
|
+
self._search_params = {} if search_params is None else search_params.copy()
|
|
69
|
+
self._firecrawl_client: Firecrawl | None = None
|
|
70
|
+
self._async_firecrawl_client: AsyncFirecrawl | None = None
|
|
71
|
+
|
|
72
|
+
def warm_up(self) -> None:
|
|
73
|
+
"""Warm up the synchronous Firecrawl client."""
|
|
74
|
+
if self._firecrawl_client is None:
|
|
75
|
+
self._firecrawl_client = Firecrawl(api_key=self.api_key.resolve_value())
|
|
76
|
+
|
|
77
|
+
async def warm_up_async(self) -> None:
|
|
78
|
+
"""Warm up the asynchronous Firecrawl client."""
|
|
79
|
+
if self._async_firecrawl_client is None:
|
|
80
|
+
self._async_firecrawl_client = AsyncFirecrawl(api_key=self.api_key.resolve_value())
|
|
81
|
+
|
|
82
|
+
@component.output_types(documents=list[Document], links=list[str])
|
|
83
|
+
def run(
|
|
84
|
+
self,
|
|
85
|
+
query: str,
|
|
86
|
+
search_params: dict[str, Any] | None = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""
|
|
89
|
+
Search the web using Firecrawl and return results as Documents.
|
|
90
|
+
|
|
91
|
+
:param query: Search query string.
|
|
92
|
+
:param search_params:
|
|
93
|
+
Optional override of search parameters for this run.
|
|
94
|
+
If provided, fully replaces the init-time search_params.
|
|
95
|
+
:returns: A dictionary with the following keys:
|
|
96
|
+
- `documents`: List of documents with search result content.
|
|
97
|
+
- `links`: List of URLs from the search results.
|
|
98
|
+
"""
|
|
99
|
+
self.warm_up()
|
|
100
|
+
assert self._firecrawl_client is not None # noqa: S101
|
|
101
|
+
|
|
102
|
+
current_params = search_params if search_params is not None else self._search_params
|
|
103
|
+
params = current_params.copy()
|
|
104
|
+
if "limit" not in params and self.top_k is not None:
|
|
105
|
+
params["limit"] = self.top_k
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
search_response = self._firecrawl_client.search(
|
|
109
|
+
query=query,
|
|
110
|
+
**params,
|
|
111
|
+
)
|
|
112
|
+
except Exception as error:
|
|
113
|
+
logger.exception(f"Failed to search for query '{query}': {error}")
|
|
114
|
+
return {"documents": [], "links": []}
|
|
115
|
+
|
|
116
|
+
documents, links = self._parse_search_response(search_response)
|
|
117
|
+
return {"documents": documents, "links": links}
|
|
118
|
+
|
|
119
|
+
@component.output_types(documents=list[Document], links=list[str])
|
|
120
|
+
async def run_async(
|
|
121
|
+
self,
|
|
122
|
+
query: str,
|
|
123
|
+
search_params: dict[str, Any] | None = None,
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""
|
|
126
|
+
Asynchronously search the web using Firecrawl and return results as Documents.
|
|
127
|
+
|
|
128
|
+
:param query: Search query string.
|
|
129
|
+
:param search_params:
|
|
130
|
+
Optional override of search parameters for this run.
|
|
131
|
+
If provided, fully replaces the init-time search_params.
|
|
132
|
+
:returns: A dictionary with the following keys:
|
|
133
|
+
- `documents`: List of documents with search result content.
|
|
134
|
+
- `links`: List of URLs from the search results.
|
|
135
|
+
"""
|
|
136
|
+
await self.warm_up_async()
|
|
137
|
+
assert self._async_firecrawl_client is not None # noqa: S101
|
|
138
|
+
|
|
139
|
+
current_params = search_params if search_params is not None else self._search_params
|
|
140
|
+
params = current_params.copy()
|
|
141
|
+
if "limit" not in params and self.top_k is not None:
|
|
142
|
+
params["limit"] = self.top_k
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
search_response = await self._async_firecrawl_client.search(
|
|
146
|
+
query=query,
|
|
147
|
+
**params,
|
|
148
|
+
)
|
|
149
|
+
except Exception as error:
|
|
150
|
+
logger.exception(f"Failed to search for query '{query}': {error}")
|
|
151
|
+
return {"documents": [], "links": []}
|
|
152
|
+
|
|
153
|
+
documents, links = self._parse_search_response(search_response)
|
|
154
|
+
return {"documents": documents, "links": links}
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _parse_search_response(search_response: SearchData) -> tuple[list[Document], list[str]]:
|
|
158
|
+
"""
|
|
159
|
+
Convert a Firecrawl search response to Haystack Documents and links.
|
|
160
|
+
|
|
161
|
+
:param search_response: Firecrawl search response object.
|
|
162
|
+
:returns: Tuple of (documents, links).
|
|
163
|
+
"""
|
|
164
|
+
documents: list[Document] = []
|
|
165
|
+
links: list[str] = []
|
|
166
|
+
|
|
167
|
+
web_results = search_response.web or []
|
|
168
|
+
for result in web_results:
|
|
169
|
+
url = ""
|
|
170
|
+
title = ""
|
|
171
|
+
content = ""
|
|
172
|
+
|
|
173
|
+
if hasattr(result, "markdown") and result.markdown:
|
|
174
|
+
content = result.markdown
|
|
175
|
+
metadata = result.metadata_dict if hasattr(result, "metadata_dict") else {}
|
|
176
|
+
url = metadata.get("url", getattr(result, "url", ""))
|
|
177
|
+
title = metadata.get("title", "")
|
|
178
|
+
else:
|
|
179
|
+
url = getattr(result, "url", "")
|
|
180
|
+
title = getattr(result, "title", "")
|
|
181
|
+
content = getattr(result, "description", "")
|
|
182
|
+
|
|
183
|
+
doc = Document(
|
|
184
|
+
content=content,
|
|
185
|
+
meta={
|
|
186
|
+
"title": title,
|
|
187
|
+
"url": url,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
documents.append(doc)
|
|
191
|
+
if url:
|
|
192
|
+
links.append(url)
|
|
193
|
+
|
|
194
|
+
return documents, links
|
|
File without changes
|
|
@@ -14,38 +14,44 @@ from haystack.utils import Secret
|
|
|
14
14
|
from haystack_integrations.components.fetchers.firecrawl import FirecrawlCrawler
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return page
|
|
24
|
-
|
|
25
|
-
@pytest.fixture
|
|
26
|
-
def crawl_response(self, page) -> MagicMock:
|
|
27
|
-
crawl_response = MagicMock()
|
|
28
|
-
crawl_response.status = "completed"
|
|
29
|
-
crawl_response.data = [page]
|
|
30
|
-
return crawl_response
|
|
31
|
-
|
|
32
|
-
@pytest.fixture
|
|
33
|
-
def mock_client(self, crawl_response) -> MagicMock:
|
|
34
|
-
mock_client = MagicMock()
|
|
35
|
-
mock_client.crawl.return_value = crawl_response
|
|
36
|
-
return mock_client
|
|
37
|
-
|
|
38
|
-
@pytest.fixture
|
|
39
|
-
def mock_async_client(self, crawl_response) -> MagicMock:
|
|
40
|
-
mock_async_client = MagicMock()
|
|
41
|
-
mock_async_client.crawl = AsyncMock(return_value=crawl_response)
|
|
42
|
-
return mock_async_client
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def page() -> MagicMock:
|
|
19
|
+
page = MagicMock()
|
|
20
|
+
page.markdown = "Page content"
|
|
21
|
+
page.metadata_dict = {"url": "https://example.com"}
|
|
22
|
+
return page
|
|
43
23
|
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def crawl_response(page) -> MagicMock:
|
|
27
|
+
crawl_response = MagicMock()
|
|
28
|
+
crawl_response.status = "completed"
|
|
29
|
+
crawl_response.data = [page]
|
|
30
|
+
return crawl_response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def mock_client(crawl_response) -> MagicMock:
|
|
35
|
+
mock_client = MagicMock()
|
|
36
|
+
mock_client.crawl.return_value = crawl_response
|
|
37
|
+
return mock_client
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def mock_async_client(crawl_response) -> MagicMock:
|
|
42
|
+
mock_async_client = MagicMock()
|
|
43
|
+
mock_async_client.crawl = AsyncMock(return_value=crawl_response)
|
|
44
|
+
return mock_async_client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestInit:
|
|
44
48
|
def test_init_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
45
49
|
monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
|
|
46
50
|
fetcher = FirecrawlCrawler()
|
|
47
51
|
assert fetcher._params == {"limit": 1, "scrape_options": {"formats": ["markdown"]}}
|
|
48
52
|
assert fetcher.api_key.resolve_value() == "test-key"
|
|
53
|
+
assert fetcher._firecrawl_client is None
|
|
54
|
+
assert fetcher._async_firecrawl_client is None
|
|
49
55
|
|
|
50
56
|
def test_init_with_params(self) -> None:
|
|
51
57
|
fetcher = FirecrawlCrawler(
|
|
@@ -55,13 +61,14 @@ class TestFirecrawlCrawler:
|
|
|
55
61
|
assert fetcher._params == {"limit": 10, "scrape_options": {"formats": ["markdown"]}}
|
|
56
62
|
assert fetcher.api_key.resolve_value() == "custom-key"
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
|
|
65
|
+
class TestSerialization:
|
|
66
|
+
def test_to_dict(self) -> None:
|
|
60
67
|
fetcher = FirecrawlCrawler(
|
|
61
68
|
api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
62
69
|
params={"limit": 5},
|
|
63
70
|
)
|
|
64
|
-
data = component_to_dict(fetcher, "
|
|
71
|
+
data = component_to_dict(fetcher, "FirecrawlCrawler")
|
|
65
72
|
assert data["type"] == "haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.FirecrawlCrawler"
|
|
66
73
|
assert data["init_parameters"]["params"] == {"limit": 5}
|
|
67
74
|
assert "api_key" in data["init_parameters"]
|
|
@@ -75,10 +82,46 @@ class TestFirecrawlCrawler:
|
|
|
75
82
|
"api_key": {"env_vars": ["FIRECRAWL_API_KEY"], "strict": True, "type": "env_var"},
|
|
76
83
|
},
|
|
77
84
|
}
|
|
78
|
-
fetcher = component_from_dict(FirecrawlCrawler, data, "
|
|
85
|
+
fetcher = component_from_dict(FirecrawlCrawler, data, "FirecrawlCrawler")
|
|
79
86
|
assert fetcher.params == {"limit": 3}
|
|
80
87
|
assert fetcher.api_key.resolve_value() == "test-key"
|
|
81
88
|
|
|
89
|
+
|
|
90
|
+
class TestComponentLifecycle:
|
|
91
|
+
def test_key_resolved_at_warm_up_not_init(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
92
|
+
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
|
93
|
+
fetcher = FirecrawlCrawler()
|
|
94
|
+
|
|
95
|
+
with pytest.raises(ValueError, match="None of the following authentication environment variables are set"):
|
|
96
|
+
fetcher.warm_up()
|
|
97
|
+
|
|
98
|
+
def test_warm_up(self) -> None:
|
|
99
|
+
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
100
|
+
|
|
101
|
+
with patch("haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.Firecrawl") as mock_client:
|
|
102
|
+
fetcher.warm_up()
|
|
103
|
+
fetcher.warm_up()
|
|
104
|
+
|
|
105
|
+
assert fetcher._firecrawl_client is mock_client.return_value
|
|
106
|
+
assert fetcher._async_firecrawl_client is None
|
|
107
|
+
mock_client.assert_called_once_with(api_key="test-key")
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_warm_up_async(self) -> None:
|
|
111
|
+
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
112
|
+
|
|
113
|
+
with patch(
|
|
114
|
+
"haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.AsyncFirecrawl"
|
|
115
|
+
) as mock_async_client:
|
|
116
|
+
await fetcher.warm_up_async()
|
|
117
|
+
await fetcher.warm_up_async()
|
|
118
|
+
|
|
119
|
+
assert fetcher._firecrawl_client is None
|
|
120
|
+
assert fetcher._async_firecrawl_client is mock_async_client.return_value
|
|
121
|
+
mock_async_client.assert_called_once_with(api_key="test-key")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestRun:
|
|
82
125
|
def test_run_with_mocked_firecrawl_client(self, mock_client) -> None:
|
|
83
126
|
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
84
127
|
|
|
@@ -135,12 +178,9 @@ class TestFirecrawlCrawler:
|
|
|
135
178
|
)
|
|
136
179
|
|
|
137
180
|
def test_run_calls_warm_up(self, crawl_response) -> None:
|
|
138
|
-
with (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
) as mock_firecrawl_client,
|
|
142
|
-
patch("haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.AsyncFirecrawl"),
|
|
143
|
-
):
|
|
181
|
+
with patch(
|
|
182
|
+
"haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.Firecrawl"
|
|
183
|
+
) as mock_firecrawl_client:
|
|
144
184
|
mock_firecrawl_client.return_value.crawl.return_value = crawl_response
|
|
145
185
|
|
|
146
186
|
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
@@ -151,12 +191,9 @@ class TestFirecrawlCrawler:
|
|
|
151
191
|
|
|
152
192
|
@pytest.mark.asyncio
|
|
153
193
|
async def test_run_async_calls_warm_up(self, crawl_response) -> None:
|
|
154
|
-
with (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.AsyncFirecrawl"
|
|
158
|
-
) as mock_async_firecrawl_client,
|
|
159
|
-
):
|
|
194
|
+
with patch(
|
|
195
|
+
"haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.AsyncFirecrawl"
|
|
196
|
+
) as mock_async_firecrawl_client:
|
|
160
197
|
mock_async_firecrawl_client.return_value.crawl = AsyncMock(return_value=crawl_response)
|
|
161
198
|
|
|
162
199
|
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
@@ -222,21 +259,13 @@ class TestFirecrawlCrawler:
|
|
|
222
259
|
|
|
223
260
|
assert "Failed to crawl website https://example.com: failed" in caplog.text
|
|
224
261
|
|
|
225
|
-
def test_warm_up_initializes_clients(self) -> None:
|
|
226
|
-
fetcher = FirecrawlCrawler(api_key=Secret.from_token("test-key"))
|
|
227
|
-
assert fetcher._firecrawl_client is None
|
|
228
|
-
assert fetcher._async_firecrawl_client is None
|
|
229
|
-
|
|
230
|
-
fetcher.warm_up()
|
|
231
|
-
|
|
232
|
-
assert fetcher._firecrawl_client is not None
|
|
233
|
-
assert fetcher._async_firecrawl_client is not None
|
|
234
262
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
@pytest.mark.skipif(
|
|
264
|
+
not os.environ.get("FIRECRAWL_API_KEY"),
|
|
265
|
+
reason="Export FIRECRAWL_API_KEY to run integration tests.",
|
|
266
|
+
)
|
|
267
|
+
@pytest.mark.integration
|
|
268
|
+
class TestIntegration:
|
|
240
269
|
def test_run_integration(self) -> None:
|
|
241
270
|
fetcher = FirecrawlCrawler(api_key=Secret.from_env_var("FIRECRAWL_API_KEY"), params={"limit": 1})
|
|
242
271
|
result = fetcher.run(urls=["https://docs.haystack.deepset.ai/docs/intro"], params={"limit": 1})
|
|
@@ -247,11 +276,6 @@ class TestFirecrawlCrawler:
|
|
|
247
276
|
assert "Haystack" in result["documents"][0].content
|
|
248
277
|
assert "https://docs.haystack.deepset.ai/docs/intro" == result["documents"][0].meta["url"]
|
|
249
278
|
|
|
250
|
-
@pytest.mark.skipif(
|
|
251
|
-
not os.environ.get("FIRECRAWL_API_KEY"),
|
|
252
|
-
reason="Export FIRECRAWL_API_KEY to run integration tests.",
|
|
253
|
-
)
|
|
254
|
-
@pytest.mark.integration
|
|
255
279
|
@pytest.mark.asyncio
|
|
256
280
|
async def test_run_async_integration(self) -> None:
|
|
257
281
|
fetcher = FirecrawlCrawler(api_key=Secret.from_env_var("FIRECRAWL_API_KEY"), params={"limit": 1})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from haystack import Document
|
|
10
|
+
from haystack.core.serialization import component_from_dict, component_to_dict
|
|
11
|
+
from haystack.utils import Secret
|
|
12
|
+
|
|
13
|
+
from haystack_integrations.components.websearch.firecrawl import FirecrawlWebSearch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def search_result_web() -> MagicMock:
|
|
18
|
+
"""A SearchResultWeb-like object (no markdown, just search metadata)."""
|
|
19
|
+
result = MagicMock(spec=["url", "title", "description"])
|
|
20
|
+
result.url = "https://example.com"
|
|
21
|
+
result.title = "Example Title"
|
|
22
|
+
result.description = "Example description snippet"
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def search_result_document() -> MagicMock:
|
|
28
|
+
"""A Document-like object (with markdown, from scrapeOptions)."""
|
|
29
|
+
result = MagicMock(spec=["markdown", "metadata_dict", "url"])
|
|
30
|
+
result.markdown = "# Full page content\nSome markdown text."
|
|
31
|
+
result.metadata_dict = {"url": "https://example.com/page", "title": "Page Title"}
|
|
32
|
+
result.url = "https://example.com/page"
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def search_response(search_result_web) -> MagicMock:
|
|
38
|
+
"""Standard search response with web results."""
|
|
39
|
+
response = MagicMock()
|
|
40
|
+
response.web = [search_result_web]
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def mock_client(search_response) -> MagicMock:
|
|
46
|
+
client = MagicMock()
|
|
47
|
+
client.search.return_value = search_response
|
|
48
|
+
return client
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def mock_async_client(search_response) -> MagicMock:
|
|
53
|
+
client = MagicMock()
|
|
54
|
+
client.search = AsyncMock(return_value=search_response)
|
|
55
|
+
return client
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestInit:
|
|
59
|
+
def test_init_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
60
|
+
monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
|
|
61
|
+
ws = FirecrawlWebSearch()
|
|
62
|
+
assert ws.top_k == 10
|
|
63
|
+
assert ws._search_params == {}
|
|
64
|
+
assert ws.api_key.resolve_value() == "test-key"
|
|
65
|
+
assert ws._firecrawl_client is None
|
|
66
|
+
assert ws._async_firecrawl_client is None
|
|
67
|
+
|
|
68
|
+
def test_init_with_params(self) -> None:
|
|
69
|
+
ws = FirecrawlWebSearch(
|
|
70
|
+
api_key=Secret.from_token("custom-key"),
|
|
71
|
+
top_k=5,
|
|
72
|
+
search_params={"tbs": "qdr:d", "location": "US"},
|
|
73
|
+
)
|
|
74
|
+
assert ws.top_k == 5
|
|
75
|
+
assert ws._search_params == {"tbs": "qdr:d", "location": "US"}
|
|
76
|
+
assert ws.api_key.resolve_value() == "custom-key"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestSerialization:
|
|
80
|
+
def test_to_dict(self) -> None:
|
|
81
|
+
ws = FirecrawlWebSearch(top_k=5, search_params={"tbs": "qdr:d"})
|
|
82
|
+
data = component_to_dict(ws, "FirecrawlWebSearch")
|
|
83
|
+
assert (
|
|
84
|
+
data["type"]
|
|
85
|
+
== "haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.FirecrawlWebSearch"
|
|
86
|
+
)
|
|
87
|
+
assert data["init_parameters"]["top_k"] == 5
|
|
88
|
+
assert data["init_parameters"]["search_params"] == {"tbs": "qdr:d"}
|
|
89
|
+
|
|
90
|
+
def test_from_dict(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
91
|
+
monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
|
|
92
|
+
data = {
|
|
93
|
+
"type": ("haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.FirecrawlWebSearch"),
|
|
94
|
+
"init_parameters": {
|
|
95
|
+
"top_k": 3,
|
|
96
|
+
"search_params": {"location": "UK"},
|
|
97
|
+
"api_key": {"env_vars": ["FIRECRAWL_API_KEY"], "strict": True, "type": "env_var"},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
ws = component_from_dict(FirecrawlWebSearch, data, "FirecrawlWebSearch")
|
|
101
|
+
assert ws.top_k == 3
|
|
102
|
+
assert ws.search_params == {"location": "UK"}
|
|
103
|
+
assert ws.api_key.resolve_value() == "test-key"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestComponentLifecycle:
|
|
107
|
+
def test_key_resolved_at_warm_up_not_init(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
108
|
+
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
|
109
|
+
ws = FirecrawlWebSearch()
|
|
110
|
+
|
|
111
|
+
with pytest.raises(ValueError, match="None of the following authentication environment variables are set"):
|
|
112
|
+
ws.warm_up()
|
|
113
|
+
|
|
114
|
+
def test_warm_up(self) -> None:
|
|
115
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
116
|
+
|
|
117
|
+
with patch("haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.Firecrawl") as mock_client:
|
|
118
|
+
ws.warm_up()
|
|
119
|
+
ws.warm_up()
|
|
120
|
+
|
|
121
|
+
assert ws._firecrawl_client is mock_client.return_value
|
|
122
|
+
assert ws._async_firecrawl_client is None
|
|
123
|
+
mock_client.assert_called_once_with(api_key="test-key")
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_warm_up_async(self) -> None:
|
|
127
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
128
|
+
|
|
129
|
+
with patch(
|
|
130
|
+
"haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.AsyncFirecrawl"
|
|
131
|
+
) as mock_async_client:
|
|
132
|
+
await ws.warm_up_async()
|
|
133
|
+
await ws.warm_up_async()
|
|
134
|
+
|
|
135
|
+
assert ws._firecrawl_client is None
|
|
136
|
+
assert ws._async_firecrawl_client is mock_async_client.return_value
|
|
137
|
+
mock_async_client.assert_called_once_with(api_key="test-key")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestRun:
|
|
141
|
+
def test_run_returns_documents_and_links(self, mock_client) -> None:
|
|
142
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"), top_k=10)
|
|
143
|
+
ws._firecrawl_client = mock_client
|
|
144
|
+
|
|
145
|
+
result = ws.run(query="test query")
|
|
146
|
+
|
|
147
|
+
assert "documents" in result
|
|
148
|
+
assert "links" in result
|
|
149
|
+
assert len(result["documents"]) == 1
|
|
150
|
+
assert isinstance(result["documents"][0], Document)
|
|
151
|
+
assert result["documents"][0].content == "Example description snippet"
|
|
152
|
+
assert result["documents"][0].meta["url"] == "https://example.com"
|
|
153
|
+
assert result["documents"][0].meta["title"] == "Example Title"
|
|
154
|
+
assert result["links"] == ["https://example.com"]
|
|
155
|
+
mock_client.search.assert_called_once_with(query="test query", limit=10)
|
|
156
|
+
|
|
157
|
+
def test_run_with_scraped_documents(self, search_result_document, mock_client) -> None:
|
|
158
|
+
"""When scrapeOptions are used, results contain markdown content."""
|
|
159
|
+
search_response = MagicMock()
|
|
160
|
+
search_response.web = [search_result_document]
|
|
161
|
+
mock_client.search.return_value = search_response
|
|
162
|
+
|
|
163
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"), top_k=10)
|
|
164
|
+
ws._firecrawl_client = mock_client
|
|
165
|
+
|
|
166
|
+
result = ws.run(query="test query")
|
|
167
|
+
|
|
168
|
+
assert result["documents"][0].content == "# Full page content\nSome markdown text."
|
|
169
|
+
assert result["documents"][0].meta["url"] == "https://example.com/page"
|
|
170
|
+
assert result["documents"][0].meta["title"] == "Page Title"
|
|
171
|
+
|
|
172
|
+
def test_run_overrides_init_params_with_runtime_params(self, mock_client) -> None:
|
|
173
|
+
ws = FirecrawlWebSearch(
|
|
174
|
+
api_key=Secret.from_token("test-key"),
|
|
175
|
+
top_k=10,
|
|
176
|
+
search_params={"location": "US"},
|
|
177
|
+
)
|
|
178
|
+
ws._firecrawl_client = mock_client
|
|
179
|
+
|
|
180
|
+
ws.run(query="test", search_params={"location": "UK", "limit": 5})
|
|
181
|
+
|
|
182
|
+
mock_client.search.assert_called_once_with(
|
|
183
|
+
query="test",
|
|
184
|
+
location="UK",
|
|
185
|
+
limit=5,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@pytest.mark.asyncio
|
|
189
|
+
async def test_run_async(self, mock_async_client) -> None:
|
|
190
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"), top_k=10)
|
|
191
|
+
ws._async_firecrawl_client = mock_async_client
|
|
192
|
+
|
|
193
|
+
result = await ws.run_async(query="test query")
|
|
194
|
+
|
|
195
|
+
assert len(result["documents"]) == 1
|
|
196
|
+
assert isinstance(result["documents"][0], Document)
|
|
197
|
+
assert result["links"] == ["https://example.com"]
|
|
198
|
+
mock_async_client.search.assert_awaited_once()
|
|
199
|
+
|
|
200
|
+
def test_run_returns_empty_on_error(self, mock_client) -> None:
|
|
201
|
+
mock_client.search.side_effect = Exception("API error")
|
|
202
|
+
|
|
203
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
204
|
+
ws._firecrawl_client = mock_client
|
|
205
|
+
|
|
206
|
+
result = ws.run(query="test")
|
|
207
|
+
assert result["documents"] == []
|
|
208
|
+
assert result["links"] == []
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_run_async_returns_empty_on_error(self, mock_async_client) -> None:
|
|
212
|
+
mock_async_client.search = AsyncMock(side_effect=Exception("API error"))
|
|
213
|
+
|
|
214
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
215
|
+
ws._async_firecrawl_client = mock_async_client
|
|
216
|
+
|
|
217
|
+
result = await ws.run_async(query="test")
|
|
218
|
+
assert result["documents"] == []
|
|
219
|
+
assert result["links"] == []
|
|
220
|
+
|
|
221
|
+
def test_run_calls_warm_up(self, search_response) -> None:
|
|
222
|
+
with patch(
|
|
223
|
+
"haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.Firecrawl"
|
|
224
|
+
) as mock_firecrawl_client:
|
|
225
|
+
mock_firecrawl_client.return_value.search.return_value = search_response
|
|
226
|
+
|
|
227
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
228
|
+
ws.run(query="test")
|
|
229
|
+
|
|
230
|
+
assert ws._firecrawl_client is mock_firecrawl_client.return_value
|
|
231
|
+
mock_firecrawl_client.assert_called_once_with(api_key="test-key")
|
|
232
|
+
|
|
233
|
+
@pytest.mark.asyncio
|
|
234
|
+
async def test_run_async_calls_warm_up(self, search_response) -> None:
|
|
235
|
+
with patch(
|
|
236
|
+
"haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.AsyncFirecrawl"
|
|
237
|
+
) as mock_async_firecrawl_client:
|
|
238
|
+
mock_async_firecrawl_client.return_value.search = AsyncMock(return_value=search_response)
|
|
239
|
+
|
|
240
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
241
|
+
await ws.run_async(query="test")
|
|
242
|
+
|
|
243
|
+
assert ws._async_firecrawl_client is mock_async_firecrawl_client.return_value
|
|
244
|
+
mock_async_firecrawl_client.assert_called_once_with(api_key="test-key")
|
|
245
|
+
|
|
246
|
+
def test_run_empty_web_results(self, mock_client) -> None:
|
|
247
|
+
empty_response = MagicMock()
|
|
248
|
+
empty_response.web = []
|
|
249
|
+
mock_client.search.return_value = empty_response
|
|
250
|
+
|
|
251
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
252
|
+
ws._firecrawl_client = mock_client
|
|
253
|
+
result = ws.run(query="obscure query")
|
|
254
|
+
assert result["documents"] == []
|
|
255
|
+
assert result["links"] == []
|
|
256
|
+
|
|
257
|
+
def test_run_none_web_results(self, mock_client) -> None:
|
|
258
|
+
none_response = MagicMock()
|
|
259
|
+
none_response.web = None
|
|
260
|
+
mock_client.search.return_value = none_response
|
|
261
|
+
|
|
262
|
+
ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
|
|
263
|
+
ws._firecrawl_client = mock_client
|
|
264
|
+
result = ws.run(query="obscure query")
|
|
265
|
+
assert result["documents"] == []
|
|
266
|
+
assert result["links"] == []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.skipif(
|
|
270
|
+
not os.environ.get("FIRECRAWL_API_KEY"),
|
|
271
|
+
reason="Export FIRECRAWL_API_KEY to run integration tests.",
|
|
272
|
+
)
|
|
273
|
+
@pytest.mark.integration
|
|
274
|
+
class TestIntegration:
|
|
275
|
+
def test_run_integration(self) -> None:
|
|
276
|
+
ws = FirecrawlWebSearch(
|
|
277
|
+
api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
278
|
+
top_k=3,
|
|
279
|
+
)
|
|
280
|
+
result = ws.run(query="What is Haystack by deepset?")
|
|
281
|
+
|
|
282
|
+
assert "documents" in result
|
|
283
|
+
assert "links" in result
|
|
284
|
+
assert isinstance(result["documents"], list)
|
|
285
|
+
assert len(result["documents"]) > 0
|
|
286
|
+
assert len(result["links"]) > 0
|
|
287
|
+
assert isinstance(result["documents"][0], Document)
|
|
288
|
+
assert result["documents"][0].content
|
|
289
|
+
|
|
290
|
+
@pytest.mark.asyncio
|
|
291
|
+
async def test_run_async_integration(self) -> None:
|
|
292
|
+
ws = FirecrawlWebSearch(
|
|
293
|
+
api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
|
|
294
|
+
top_k=3,
|
|
295
|
+
)
|
|
296
|
+
result = await ws.run_async(query="What is Haystack by deepset?")
|
|
297
|
+
|
|
298
|
+
assert "documents" in result
|
|
299
|
+
assert "links" in result
|
|
300
|
+
assert len(result["documents"]) > 0
|
|
301
|
+
assert len(result["links"]) > 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|