firecrawl-haystack 1.1.0__tar.gz → 2.0.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.
Files changed (18) hide show
  1. firecrawl_haystack-2.0.0/CHANGELOG.md +50 -0
  2. firecrawl_haystack-1.1.0/LICENSE → firecrawl_haystack-2.0.0/LICENSE.txt +1 -1
  3. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/PKG-INFO +8 -7
  4. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/README.md +2 -2
  5. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/pyproject.toml +18 -4
  6. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/fetchers/firecrawl/firecrawl_crawler.py +27 -59
  7. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/websearch/firecrawl/firecrawl_websearch.py +13 -12
  8. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/tests/test_firecrawl_crawler.py +85 -61
  9. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/tests/test_firecrawl_websearch.py +96 -71
  10. firecrawl_haystack-1.1.0/CHANGELOG.md +0 -15
  11. firecrawl_haystack-1.1.0/tests/__init__.py +0 -3
  12. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/.gitignore +0 -0
  13. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/pydoc/config_docusaurus.yml +0 -0
  14. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/fetchers/firecrawl/__init__.py +0 -0
  15. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/fetchers/py.typed +0 -0
  16. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/websearch/firecrawl/__init__.py +0 -0
  17. {firecrawl_haystack-1.1.0 → firecrawl_haystack-2.0.0}/src/haystack_integrations/components/websearch/py.typed +0 -0
  18. {firecrawl_haystack-1.1.0/src/haystack_integrations/components/websearch → firecrawl_haystack-2.0.0/tests}/__init__.py +0 -0
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## [integrations/firecrawl-v1.2.0] - 2026-09-04
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Fix new issues raised by ruff 0.16.0 (#3670)
8
+ - Standardize license files (#3771)
9
+
10
+ ### 🚜 Refactor
11
+
12
+ - Firecrawl - decouple sync/async clients creation (#3914)
13
+
14
+ ### 📚 Documentation
15
+
16
+ - Fix FirecrawlCrawler wrong name in docstrings (#3560)
17
+
18
+ ### 🧪 Testing
19
+
20
+ - Test compatible integrations with python 3.14; update pyproject (#3001)
21
+ - Track test coverage for all integrations (#3065)
22
+
23
+ ### 🧹 Chores
24
+
25
+ - Add ANN type annotations to faiss, fastembed, firecrawl, github, google_ai (#2988)
26
+ - Enforce ruff docstring rules (D102/D103/D205/D209/D213/D417/D419) in integrations 11-20 (#3009)
27
+ - Improve consistency of integrations folder structure (#3430)
28
+ - Re-standardize READMEs (#3768)
29
+
30
+
31
+ ## [integrations/firecrawl-v1.1.0] - 2026-03-09
32
+
33
+ ### 🚀 Features
34
+
35
+ - Add FirecrawlWebSearch component (#2888)
36
+
37
+
38
+ ## [integrations/firecrawl-v1.0.1] - 2026-02-24
39
+
40
+ ### 🌀 Miscellaneous
41
+
42
+ - Docs: Update Firecrawl module path in Docusaurus configuration (#2869)
43
+
44
+ ## [integrations/firecrawl-v1.0.0] - 2026-02-24
45
+
46
+ ### 🚀 Features
47
+
48
+ - Add FirecrawlCrawler (#2859)
49
+
50
+ <!-- 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,23 +1,24 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: firecrawl-haystack
3
- Version: 1.1.0
3
+ Version: 2.0.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
10
+ License-File: LICENSE.txt
11
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
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Classifier: Programming Language :: Python :: Implementation :: CPython
20
- Requires-Python: >=3.10
21
+ Requires-Python: >=3.11
21
22
  Requires-Dist: firecrawl-py>=4.0.0
22
23
  Requires-Dist: haystack-ai>=2.24.1
23
24
  Description-Content-Type: text/markdown
@@ -27,6 +28,7 @@ Description-Content-Type: text/markdown
27
28
  [![PyPI - Version](https://img.shields.io/pypi/v/firecrawl-haystack.svg)](https://pypi.org/project/firecrawl-haystack)
28
29
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/firecrawl-haystack.svg)](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
  [![PyPI - Version](https://img.shields.io/pypi/v/firecrawl-haystack.svg)](https://pypi.org/project/firecrawl-haystack)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/firecrawl-haystack.svg)](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.
@@ -7,7 +7,7 @@ name = "firecrawl-haystack"
7
7
  dynamic = ["version"]
8
8
  description = "Haystack integration for Firecrawl"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
12
12
  keywords = [
13
13
  "Firecrawl",
@@ -23,10 +23,11 @@ classifiers = [
23
23
  "License :: OSI Approved :: Apache Software License",
24
24
  "Development Status :: 4 - Beta",
25
25
  "Programming Language :: Python",
26
- "Programming Language :: Python :: 3.10",
27
26
  "Programming Language :: Python :: 3.11",
28
27
  "Programming Language :: Python :: 3.12",
29
28
  "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Programming Language :: Python :: 3.15",
30
31
  "Programming Language :: Python :: Implementation :: CPython",
31
32
  ]
32
33
  dependencies = [
@@ -73,7 +74,8 @@ dependencies = [
73
74
  unit = 'pytest -m "not integration" {args:tests}'
74
75
  integration = 'pytest -m "integration" {args:tests}'
75
76
  all = 'pytest {args:tests}'
76
- cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x {args:tests}'
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}'
77
79
  types = "mypy -p haystack_integrations.components.fetchers.firecrawl -p haystack_integrations.components.websearch.firecrawl {args}"
78
80
 
79
81
  [tool.mypy]
@@ -88,9 +90,17 @@ line-length = 120
88
90
  [tool.ruff.lint]
89
91
  select = [
90
92
  "A",
93
+ "ANN",
91
94
  "ARG",
92
95
  "B",
93
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
94
104
  "DTZ",
95
105
  "E",
96
106
  "EM",
@@ -123,12 +133,15 @@ ignore = [
123
133
  "S105",
124
134
  "S106",
125
135
  "S107",
136
+ # Allow `Any` - used legitimately for dynamic types and SDK boundaries
137
+ "ANN401",
126
138
  # Ignore complexity
127
139
  "C901",
128
140
  "PLR0911",
129
141
  "PLR0912",
130
142
  "PLR0913",
131
143
  "PLR0915",
144
+ "PLR0917",
132
145
  ]
133
146
  unfixable = [
134
147
  # Don't touch unused imports
@@ -143,11 +156,12 @@ ban-relative-imports = "parents"
143
156
 
144
157
  [tool.ruff.lint.per-file-ignores]
145
158
  # Tests can use magic values, assertions, and relative imports
146
- "tests/**/*" = ["PLR2004", "S101", "TID252"]
159
+ "tests/**/*" = ["D", "PLR2004", "S101", "TID252", "ANN"]
147
160
 
148
161
  [tool.coverage.run]
149
162
  source = ["haystack_integrations"]
150
163
  branch = true
164
+ relative_files = true
151
165
  parallel = false
152
166
 
153
167
  [tool.coverage.report]
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from typing import Any
7
7
 
8
8
  from haystack import Document, component, logging
@@ -27,15 +27,15 @@ class FirecrawlCrawler:
27
27
  ### Usage example
28
28
 
29
29
  ```python
30
- from haystack_integrations.components.fetchers.firecrawl import FirecrawlFetcher
30
+ from haystack_integrations.components.fetchers.firecrawl import FirecrawlCrawler
31
31
 
32
- fetcher = FirecrawlFetcher(
32
+ crawler = FirecrawlCrawler(
33
33
  api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
34
34
  params={"limit": 5},
35
35
  )
36
- fetcher.warm_up()
36
+ crawler.warm_up()
37
37
 
38
- result = fetcher.run(urls=["https://docs.haystack.deepset.ai/docs/intro"])
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 FirecrawlFetcher.
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
- if self._firecrawl_client is None:
87
- self.warm_up()
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
- docs = self._crawl_url(url=url, params=current_params)
93
- documents.extend(docs)
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
- if self._async_firecrawl_client is None:
115
- self.warm_up()
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
- docs = await self._crawl_url_async(url=url, params=current_params)
121
- documents.extend(docs)
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.
@@ -189,7 +157,7 @@ class FirecrawlCrawler:
189
157
  doc = Document(
190
158
  content=page.markdown,
191
159
  meta={
192
- "timestamp": datetime.now(tz=timezone.utc).isoformat(),
160
+ "timestamp": datetime.now(tz=UTC).isoformat(),
193
161
  **metadata,
194
162
  },
195
163
  )
@@ -4,11 +4,12 @@
4
4
 
5
5
  from typing import Any
6
6
 
7
- from firecrawl import AsyncFirecrawl, Firecrawl # type: ignore[import-untyped]
8
- from firecrawl.types import SearchData # type: ignore[import-untyped]
9
7
  from haystack import Document, component, logging
10
8
  from haystack.utils import Secret
11
9
 
10
+ from firecrawl import AsyncFirecrawl, Firecrawl # type: ignore[import-untyped]
11
+ from firecrawl.types import SearchData # type: ignore[import-untyped]
12
+
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
 
@@ -69,12 +70,12 @@ class FirecrawlWebSearch:
69
70
  self._async_firecrawl_client: AsyncFirecrawl | None = None
70
71
 
71
72
  def warm_up(self) -> None:
72
- """
73
- Warm up the Firecrawl clients by initializing the sync and async clients.
74
- This is useful to avoid cold start delays when performing searches.
75
- """
73
+ """Warm up the synchronous Firecrawl client."""
76
74
  if self._firecrawl_client is None:
77
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."""
78
79
  if self._async_firecrawl_client is None:
79
80
  self._async_firecrawl_client = AsyncFirecrawl(api_key=self.api_key.resolve_value())
80
81
 
@@ -95,8 +96,8 @@ class FirecrawlWebSearch:
95
96
  - `documents`: List of documents with search result content.
96
97
  - `links`: List of URLs from the search results.
97
98
  """
98
- if self._firecrawl_client is None:
99
- self.warm_up()
99
+ self.warm_up()
100
+ assert self._firecrawl_client is not None # noqa: S101
100
101
 
101
102
  current_params = search_params if search_params is not None else self._search_params
102
103
  params = current_params.copy()
@@ -104,7 +105,7 @@ class FirecrawlWebSearch:
104
105
  params["limit"] = self.top_k
105
106
 
106
107
  try:
107
- search_response = self._firecrawl_client.search( # type: ignore[union-attr]
108
+ search_response = self._firecrawl_client.search(
108
109
  query=query,
109
110
  **params,
110
111
  )
@@ -132,8 +133,8 @@ class FirecrawlWebSearch:
132
133
  - `documents`: List of documents with search result content.
133
134
  - `links`: List of URLs from the search results.
134
135
  """
135
- if self._async_firecrawl_client is None:
136
- self.warm_up()
136
+ await self.warm_up_async()
137
+ assert self._async_firecrawl_client is not None # noqa: S101
137
138
 
138
139
  current_params = search_params if search_params is not None else self._search_params
139
140
  params = current_params.copy()
@@ -141,7 +142,7 @@ class FirecrawlWebSearch:
141
142
  params["limit"] = self.top_k
142
143
 
143
144
  try:
144
- search_response = await self._async_firecrawl_client.search( # type: ignore[union-attr]
145
+ search_response = await self._async_firecrawl_client.search(
145
146
  query=query,
146
147
  **params,
147
148
  )
@@ -14,38 +14,44 @@ from haystack.utils import Secret
14
14
  from haystack_integrations.components.fetchers.firecrawl import FirecrawlCrawler
15
15
 
16
16
 
17
- class TestFirecrawlCrawler:
18
- @pytest.fixture
19
- def page(self) -> MagicMock:
20
- page = MagicMock()
21
- page.markdown = "Page content"
22
- page.metadata_dict = {"url": "https://example.com"}
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
- def test_to_dict(self, monkeypatch: pytest.MonkeyPatch) -> None:
59
- monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
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, "FirecrawlFetcher")
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, "FirecrawlFetcher")
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
- patch(
140
- "haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.Firecrawl"
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
- patch("haystack_integrations.components.fetchers.firecrawl.firecrawl_crawler.Firecrawl"),
156
- patch(
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
- @pytest.mark.skipif(
236
- not os.environ.get("FIRECRAWL_API_KEY"),
237
- reason="Export FIRECRAWL_API_KEY to run integration tests.",
238
- )
239
- @pytest.mark.integration
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})
@@ -13,50 +13,57 @@ from haystack.utils import Secret
13
13
  from haystack_integrations.components.websearch.firecrawl import FirecrawlWebSearch
14
14
 
15
15
 
16
- class TestFirecrawlWebSearch:
17
- @pytest.fixture
18
- def search_result_web(self) -> MagicMock:
19
- """A SearchResultWeb-like object (no markdown, just search metadata)."""
20
- result = MagicMock(spec=["url", "title", "description"])
21
- result.url = "https://example.com"
22
- result.title = "Example Title"
23
- result.description = "Example description snippet"
24
- return result
25
-
26
- @pytest.fixture
27
- def search_result_document(self) -> 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
- @pytest.fixture
36
- def search_response(self, search_result_web) -> MagicMock:
37
- """Standard search response with web results."""
38
- response = MagicMock()
39
- response.web = [search_result_web]
40
- return response
41
-
42
- @pytest.fixture
43
- def mock_client(self, search_response) -> MagicMock:
44
- client = MagicMock()
45
- client.search.return_value = search_response
46
- return client
47
-
48
- @pytest.fixture
49
- def mock_async_client(self, search_response) -> MagicMock:
50
- client = MagicMock()
51
- client.search = AsyncMock(return_value=search_response)
52
- return client
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
53
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:
54
59
  def test_init_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
55
60
  monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
56
61
  ws = FirecrawlWebSearch()
57
62
  assert ws.top_k == 10
58
63
  assert ws._search_params == {}
59
64
  assert ws.api_key.resolve_value() == "test-key"
65
+ assert ws._firecrawl_client is None
66
+ assert ws._async_firecrawl_client is None
60
67
 
61
68
  def test_init_with_params(self) -> None:
62
69
  ws = FirecrawlWebSearch(
@@ -68,8 +75,9 @@ class TestFirecrawlWebSearch:
68
75
  assert ws._search_params == {"tbs": "qdr:d", "location": "US"}
69
76
  assert ws.api_key.resolve_value() == "custom-key"
70
77
 
71
- def test_to_dict(self, monkeypatch: pytest.MonkeyPatch) -> None:
72
- monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key")
78
+
79
+ class TestSerialization:
80
+ def test_to_dict(self) -> None:
73
81
  ws = FirecrawlWebSearch(top_k=5, search_params={"tbs": "qdr:d"})
74
82
  data = component_to_dict(ws, "FirecrawlWebSearch")
75
83
  assert (
@@ -94,6 +102,42 @@ class TestFirecrawlWebSearch:
94
102
  assert ws.search_params == {"location": "UK"}
95
103
  assert ws.api_key.resolve_value() == "test-key"
96
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:
97
141
  def test_run_returns_documents_and_links(self, mock_client) -> None:
98
142
  ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"), top_k=10)
99
143
  ws._firecrawl_client = mock_client
@@ -175,12 +219,9 @@ class TestFirecrawlWebSearch:
175
219
  assert result["links"] == []
176
220
 
177
221
  def test_run_calls_warm_up(self, search_response) -> None:
178
- with (
179
- patch(
180
- "haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.Firecrawl"
181
- ) as mock_firecrawl_client,
182
- patch("haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.AsyncFirecrawl"),
183
- ):
222
+ with patch(
223
+ "haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.Firecrawl"
224
+ ) as mock_firecrawl_client:
184
225
  mock_firecrawl_client.return_value.search.return_value = search_response
185
226
 
186
227
  ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
@@ -191,12 +232,9 @@ class TestFirecrawlWebSearch:
191
232
 
192
233
  @pytest.mark.asyncio
193
234
  async def test_run_async_calls_warm_up(self, search_response) -> None:
194
- with (
195
- patch("haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.Firecrawl"),
196
- patch(
197
- "haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.AsyncFirecrawl"
198
- ) as mock_async_firecrawl_client,
199
- ):
235
+ with patch(
236
+ "haystack_integrations.components.websearch.firecrawl.firecrawl_websearch.AsyncFirecrawl"
237
+ ) as mock_async_firecrawl_client:
200
238
  mock_async_firecrawl_client.return_value.search = AsyncMock(return_value=search_response)
201
239
 
202
240
  ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
@@ -205,16 +243,6 @@ class TestFirecrawlWebSearch:
205
243
  assert ws._async_firecrawl_client is mock_async_firecrawl_client.return_value
206
244
  mock_async_firecrawl_client.assert_called_once_with(api_key="test-key")
207
245
 
208
- def test_warm_up_initializes_clients(self) -> None:
209
- ws = FirecrawlWebSearch(api_key=Secret.from_token("test-key"))
210
- assert ws._firecrawl_client is None
211
- assert ws._async_firecrawl_client is None
212
-
213
- ws.warm_up()
214
-
215
- assert ws._firecrawl_client is not None
216
- assert ws._async_firecrawl_client is not None
217
-
218
246
  def test_run_empty_web_results(self, mock_client) -> None:
219
247
  empty_response = MagicMock()
220
248
  empty_response.web = []
@@ -237,11 +265,13 @@ class TestFirecrawlWebSearch:
237
265
  assert result["documents"] == []
238
266
  assert result["links"] == []
239
267
 
240
- @pytest.mark.skipif(
241
- not os.environ.get("FIRECRAWL_API_KEY"),
242
- reason="Export FIRECRAWL_API_KEY to run integration tests.",
243
- )
244
- @pytest.mark.integration
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:
245
275
  def test_run_integration(self) -> None:
246
276
  ws = FirecrawlWebSearch(
247
277
  api_key=Secret.from_env_var("FIRECRAWL_API_KEY"),
@@ -257,11 +287,6 @@ class TestFirecrawlWebSearch:
257
287
  assert isinstance(result["documents"][0], Document)
258
288
  assert result["documents"][0].content
259
289
 
260
- @pytest.mark.skipif(
261
- not os.environ.get("FIRECRAWL_API_KEY"),
262
- reason="Export FIRECRAWL_API_KEY to run integration tests.",
263
- )
264
- @pytest.mark.integration
265
290
  @pytest.mark.asyncio
266
291
  async def test_run_async_integration(self) -> None:
267
292
  ws = FirecrawlWebSearch(
@@ -1,15 +0,0 @@
1
- # Changelog
2
-
3
- ## [integrations/firecrawl-v1.0.1] - 2026-02-24
4
-
5
- ### 🌀 Miscellaneous
6
-
7
- - Docs: Update Firecrawl module path in Docusaurus configuration (#2869)
8
-
9
- ## [integrations/firecrawl-v1.0.0] - 2026-02-24
10
-
11
- ### 🚀 Features
12
-
13
- - Add FirecrawlCrawler (#2859)
14
-
15
- <!-- generated by git-cliff -->
@@ -1,3 +0,0 @@
1
- # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
- #
3
- # SPDX-License-Identifier: Apache-2.0