From fec4b94a640a1592ebb2afde46c6673072498c41 Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Thu, 22 Oct 2026 16:17:54 +0300 Subject: [PATCH] chore: initial crewAI integration setup --- .github/workflows/ci.yml | 37 +++++++ .github/workflows/pr-title.yml | 16 +++ .github/workflows/release.yml | 63 +++++++++++ .gitignore | 37 +++++++ CONTRIBUTING.md | 25 +++++ README.md | 106 ++++++++++++++++++ pyproject.toml | 65 +++++++++++ src/decodo_crewai/__init__.py | 5 + src/decodo_crewai/_base.py | 94 ++++++++++++++++ src/decodo_crewai/py.typed | 0 src/decodo_crewai/search_tool.py | 60 ++++++++++ src/decodo_crewai/web_scraper_tool.py | 48 ++++++++ tests/test_tools.py | 152 ++++++++++++++++++++++++++ 13 files changed, 708 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/decodo_crewai/__init__.py create mode 100644 src/decodo_crewai/_base.py create mode 100644 src/decodo_crewai/py.typed create mode 100644 src/decodo_crewai/search_tool.py create mode 100644 src/decodo_crewai/web_scraper_tool.py create mode 100644 tests/test_tools.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..412f539 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: pip install build -e ".[dev]" + + - name: Ruff + run: | + ruff check . + ruff format --check . + + - name: Mypy + run: mypy + + - name: Pytest + run: pytest + + - name: Build + run: python -m build diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..90944a1 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,16 @@ +name: PR title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + conventional: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aa7a8fd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - run: git reset --hard ${{ github.sha }} + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install + run: pip install build -e ".[dev]" + + - name: Ruff + run: | + ruff check . + ruff format --check . + + - name: Mypy + run: mypy + + - name: Pytest + run: pytest + + - id: release + uses: python-semantic-release/python-semantic-release@v10.7.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + commit: "false" + changelog: "false" + + - name: Build + if: steps.release.outputs.released == 'true' + run: python -m build + + - name: Publish + if: steps.release.outputs.released == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6745acb --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# Type checking / linting caches +.mypy_cache/ +.ruff_cache/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment +.env + +# OS +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..76bce77 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +## Development + +```bash +python3.12 -m venv .venv +.venv/bin/pip install -e ".[dev]" +.venv/bin/ruff check . && .venv/bin/ruff format --check . +.venv/bin/mypy +.venv/bin/pytest +``` + +The live test calls the real API and only runs when `DECODO_LIVE_API_KEY` is set: + +```bash +DECODO_LIVE_API_KEY="" .venv/bin/pytest -m live +``` + +## Releases + +Releases are automated. Merging to `main` publishes a new version based on the +PR title, which must follow [Conventional Commits](https://www.conventionalcommits.org): +`fix:` → patch, `feat:` → minor, `feat!:` or a `BREAKING CHANGE:` footer → major. +`chore:`, `docs:`, `ci:`, `refactor:`, `test:` don't release. Don't edit the +version in `pyproject.toml`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7559738 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Decodo tools for CrewAI + +![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13-green) +![License](https://img.shields.io/badge/license-MIT-blue) + +[CrewAI](https://www.crewai.com) tools for the [Decodo](https://decodo.com) Web Scraping API. Give your agents live web pages and search results. + +| Tool | What it does | +| --- | --- | +| `DecodoWebScraperTool` | Scrapes any public URL and returns the page as Markdown. | +| `DecodoSearchTool` | Searches Google or Amazon and returns the parsed results as JSON. | + +## Requirements + +- Python 3.12 or 3.13 +- A Decodo Web Scraping API key + +## Installation + +```bash +pip install decodo-crewai +``` + +## Credentials + +Copy your API key from your Web Scraping API subscription on the [Decodo dashboard](https://dashboard.decodo.com/web-data/playground) and set it as an environment variable: + +```bash +export DECODO_API_KEY="" +``` + +Or pass it to each tool: + +```python +DecodoWebScraperTool(api_key="") +``` + +## Usage + +```python +from crewai import Agent, Crew, Task + +from decodo_crewai import DecodoSearchTool, DecodoWebScraperTool + +researcher = Agent( + role="Researcher", + goal="Find and summarize up-to-date information from the web", + backstory="You search the web, read the most relevant pages and report what they say.", + tools=[DecodoSearchTool(), DecodoWebScraperTool()], +) + +task = Task( + description="Find the three most recent announcements about {topic} and summarize each one.", + expected_output="Three bullet points, each with a one-sentence summary and the source URL.", + agent=researcher, +) + +crew = Crew(agents=[researcher], tasks=[task]) +print(crew.kickoff(inputs={"topic": "web scraping APIs"})) +``` + +The tools can also be called directly: + +```python +from decodo_crewai import DecodoSearchTool, DecodoWebScraperTool + +print(DecodoWebScraperTool().run(url="https://ip.decodo.com")) +print(DecodoSearchTool().run(query="mechanical keyboard", source="amazon")) +``` + +## Tools + +### DecodoWebScraperTool + +Arguments the agent fills in: + +| Argument | Type | Description | +| --- | --- | --- | +| `url` | `str` | Full URL of the page to scrape. | +| `geo` | `str`, optional | Country to send the request from, e.g. `United States`. | +| `js_render` | `bool`, optional | Open the page in a headless browser. Only needed for pages that load content with JavaScript. | + +Constructor options: + +| Option | Default | Description | +| --- | --- | --- | +| `api_key` | `DECODO_API_KEY` | Decodo Web Scraping API key. | +| `max_chars` | `45000` | Markdown longer than this is truncated to keep the page within the agent's context. | + +### DecodoSearchTool + +Arguments the agent fills in: + +| Argument | Type | Description | +| --- | --- | --- | +| `query` | `str` | Search query. | +| `source` | `"google"` or `"amazon"`, optional | `google` (default) for web results, `amazon` for product listings. | +| `geo` | `str`, optional | Google: a country name, e.g. `United States`. Amazon: a US ZIP code, e.g. `10001`, or a two-letter country code for delivery outside the US, e.g. `DE`. | + +## Errors + +When a request fails, for example because of an invalid API key, a rate limit or a page that returns `404`, the tools return a CrewAI `ToolFailure` instead of raising. The agent sees what went wrong and can retry or change course, and CrewAI records the failure according to the agent's `tool_failure_policy`. + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fad287f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "decodo-crewai" +version = "0.0.0" +description = "Decodo Web Scraping API tools for CrewAI agents." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.12,<3.14" +authors = [{ name = "Decodo", email = "support@decodo.com" }] +keywords = ["decodo", "crewai", "web-scraping", "agents", "tools"] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + # ToolFailure first shipped in 1.15.9. + "crewai>=1.15.9", + "decodo-sdk>=2.4.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "ruff>=0.4.0", + "mypy>=1.10.0", +] + +[project.urls] +Homepage = "https://decodo.com" +Repository = "https://github.com/Decodo/crewAI" +Documentation = "https://help.decodo.com/docs/introduction" + +[tool.hatch.build.targets.wheel] +packages = ["src/decodo_crewai"] + +[tool.ruff] +line-length = 120 +src = ["src"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.mypy] +strict = true +packages = ["decodo_crewai"] +mypy_path = "src" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["live: calls the real Decodo API; needs DECODO_LIVE_API_KEY"] + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +commit_parser = "conventional" + +[tool.semantic_release.branches.main] +match = "main" diff --git a/src/decodo_crewai/__init__.py b/src/decodo_crewai/__init__.py new file mode 100644 index 0000000..efba8fe --- /dev/null +++ b/src/decodo_crewai/__init__.py @@ -0,0 +1,5 @@ +from ._base import DecodoBaseTool +from .search_tool import DecodoSearchTool +from .web_scraper_tool import DecodoWebScraperTool + +__all__ = ["DecodoBaseTool", "DecodoSearchTool", "DecodoWebScraperTool"] diff --git a/src/decodo_crewai/_base.py b/src/decodo_crewai/_base.py new file mode 100644 index 0000000..4f18c29 --- /dev/null +++ b/src/decodo_crewai/_base.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +from typing import Any + +from crewai.tools import BaseTool, EnvVar +from crewai.tools.tool_failure import ToolFailure +from decodo import ( + AuthenticationError, + DecodoClient, + DecodoConfig, + DecodoError, + RateLimitError, + ScrapeRequest, + ValidationError, + WebScrapingApiConfig, +) +from decodo import TimeoutError as DecodoTimeoutError +from pydantic import Field, PrivateAttr + +API_KEY_ENV_VAR = "DECODO_API_KEY" +INTEGRATION_HEADER = "crewai-python" + + +class DecodoBaseTool(BaseTool): + """Shared Decodo client and error handling for the Decodo tools. + + Get an API key from your Web Scraping API subscription on the Decodo dashboard: + https://dashboard.decodo.com/web-data/playground + """ + + package_dependencies: list[str] = Field(default_factory=lambda: ["decodo-sdk"]) + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar( + name=API_KEY_ENV_VAR, + description="API key for the Decodo Web Scraping API", + required=True, + ) + ] + ) + + _client: DecodoClient = PrivateAttr() + + def __init__(self, api_key: str | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + api_key = api_key or os.environ.get(API_KEY_ENV_VAR) + if not api_key: + raise ValueError(f"Pass api_key or set the {API_KEY_ENV_VAR} environment variable.") + self._client = DecodoClient( + DecodoConfig( + web_scraping_api=WebScrapingApiConfig(api_key=api_key, integration_header=INTEGRATION_HEADER), + ) + ) + + def _scrape(self, params: ScrapeRequest) -> Any: + """Return the first result's content, or a ToolFailure the agent can act on.""" + try: + response = self._client.web_scraping_api.scrape(params) + except AuthenticationError as e: + return ToolFailure( + message=f"Decodo rejected the API key ({e}). Check {API_KEY_ENV_VAR}.", + code="authentication_failed", + ) + except RateLimitError as e: + return ToolFailure(message=f"Decodo rate limit reached: {e}", code="rate_limited", retryable=True) + except ValidationError as e: + return ToolFailure(message=f"Decodo rejected the request parameters: {e}", code="invalid_params") + except DecodoTimeoutError as e: + return ToolFailure(message=f"Decodo request timed out: {e}", code="timeout", retryable=True) + except DecodoError as e: + return ToolFailure( + message=f"Decodo request failed: {e}", + code=str(e.status_code), + retryable=e.status_code >= 500, + ) + + results = response.get("results") or [] + if not results: + return ToolFailure(message="Decodo returned no results.", code="empty_response") + + result = results[0] + status_code = result.get("status_code") + if isinstance(status_code, int) and not 200 <= status_code < 300: + return ToolFailure( + message=f"Decodo could not retrieve the page: the target responded with status {status_code}.", + code=str(status_code), + retryable=status_code == 429 or status_code >= 500, + ) + + content = result.get("content") + if content is None or content == "": + return ToolFailure(message="Decodo returned a result with no content.", code="empty_content") + return content diff --git a/src/decodo_crewai/py.typed b/src/decodo_crewai/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/decodo_crewai/search_tool.py b/src/decodo_crewai/search_tool.py new file mode 100644 index 0000000..bf125fc --- /dev/null +++ b/src/decodo_crewai/search_tool.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from typing import Literal + +from crewai.tools.tool_failure import ToolFailure +from decodo import AmazonSearchParams, GoogleSearchParams, ScrapeRequest +from pydantic import BaseModel, Field + +from ._base import DecodoBaseTool + + +class DecodoSearchArgs(BaseModel): + query: str = Field(description="Search query") + source: Literal["google", "amazon"] = Field( + default="google", + description="Where to search: 'google' for web results, 'amazon' for product listings", + ) + geo: str | None = Field( + default=None, + description=( + "Location to search from. Google: a country name, e.g. 'United States'. " + "Amazon: a US ZIP code, e.g. '10001', or a two-letter country code for delivery outside the US, e.g. 'DE'" + ), + ) + + +class DecodoSearchTool(DecodoBaseTool): + """Search Google or Amazon with the Decodo Web Scraping API and return the parsed results as JSON. + + Args: + api_key: Decodo Web Scraping API key. Defaults to the ``DECODO_API_KEY`` environment variable. + """ + + name: str = "Decodo Search" + description: str = ( + "Search Google or Amazon and return the parsed results page as JSON. " + "Use source='google' for up-to-date web results and rankings, " + "source='amazon' to find and compare product listings." + ) + args_schema: type[BaseModel] = DecodoSearchArgs + + def _run( + self, + query: str, + source: Literal["google", "amazon"] = "google", + geo: str | None = None, + ) -> str | ToolFailure: + params: ScrapeRequest + if source == "amazon": + params = AmazonSearchParams(query=query, geo=geo or None, parse=True) + else: + params = GoogleSearchParams(query=query, geo=geo or None, parse=True) + + content = self._scrape(params) + if isinstance(content, ToolFailure): + return content + if isinstance(content, str): + return content + return json.dumps(content) diff --git a/src/decodo_crewai/web_scraper_tool.py b/src/decodo_crewai/web_scraper_tool.py new file mode 100644 index 0000000..823ea58 --- /dev/null +++ b/src/decodo_crewai/web_scraper_tool.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from crewai.tools.tool_failure import ToolFailure +from decodo import UniversalParams +from pydantic import BaseModel, Field + +from ._base import DecodoBaseTool + + +class DecodoWebScraperArgs(BaseModel): + url: str = Field(description="Full URL of the page to scrape, including https://") + geo: str | None = Field( + default=None, + description="Country to send the request from, as a country name, e.g. 'United States'", + ) + js_render: bool = Field( + default=False, + description="Open the page in a headless browser. Only needed for pages that load content with JavaScript.", + ) + + +class DecodoWebScraperTool(DecodoBaseTool): + """Scrape any public web page with the Decodo Web Scraping API and return it as Markdown. + + Args: + api_key: Decodo Web Scraping API key. Defaults to the ``DECODO_API_KEY`` environment variable. + max_chars: Markdown longer than this is truncated, to keep the page within the agent's context. + """ + + name: str = "Decodo Web Scraper" + description: str = ( + "Fetch any public web page and return its content as Markdown. " + "Use it to read a specific URL: an article, documentation, a product page." + ) + args_schema: type[BaseModel] = DecodoWebScraperArgs + max_chars: int = 45_000 + + def _run(self, url: str, geo: str | None = None, js_render: bool = False) -> str | ToolFailure: + content = self._scrape( + UniversalParams(url=url, geo=geo or None, headless="html" if js_render else None, markdown=True), + ) + if isinstance(content, ToolFailure): + return content + + markdown = str(content) + if len(markdown) > self.max_chars: + return f"{markdown[: self.max_chars]}\n\n[Truncated to {self.max_chars} of {len(markdown)} characters.]" + return markdown diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..894fddf --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +import os +from typing import Any + +import httpx +import pytest +from crewai.tools.tool_failure import ToolFailure + +from decodo_crewai import DecodoSearchTool, DecodoWebScraperTool + + +class FakeApi: + """Stands in for httpx.request inside the SDK and records what was sent.""" + + def __init__(self, status_code: int = 200, body: Any = None) -> None: + self.status_code = status_code + self.body = body if body is not None else {"results": [{"content": "# Hello", "status_code": 200}]} + self.calls: list[dict[str, Any]] = [] + + def __call__(self, method: str, url: str, *, headers: dict[str, str], json: Any, timeout: float) -> httpx.Response: + self.calls.append({"method": method, "url": url, "headers": headers, "json": json}) + return httpx.Response(self.status_code, json=self.body) + + @property + def payload(self) -> dict[str, Any]: + return self.calls[-1]["json"] # type: ignore[no-any-return] + + +@pytest.fixture +def fake_api(monkeypatch: pytest.MonkeyPatch) -> FakeApi: + api = FakeApi() + monkeypatch.setattr("decodo.http.httpx.request", api) + return api + + +@pytest.fixture(autouse=True) +def _api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DECODO_API_KEY", "test-key") + + +def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DECODO_API_KEY") + with pytest.raises(ValueError, match="DECODO_API_KEY"): + DecodoWebScraperTool() + + +def test_api_key_argument_overrides_env(fake_api: FakeApi) -> None: + DecodoWebScraperTool(api_key="explicit-key").run(url="https://example.com") + assert fake_api.calls[0]["headers"]["Authorization"] == "Bearer explicit-key" + + +def test_requests_go_to_data_api_with_crewai_integration_header(fake_api: FakeApi) -> None: + DecodoWebScraperTool().run(url="https://example.com") + + call = fake_api.calls[0] + assert call["url"].startswith("https://data.decodo.com/") + assert call["headers"]["Authorization"] == "Bearer test-key" + assert call["headers"]["x-integration"] == "crewai-python" + + +def test_web_scraper_returns_markdown(fake_api: FakeApi) -> None: + result = DecodoWebScraperTool().run(url="https://example.com") + + assert result == "# Hello" + assert fake_api.payload == {"target": "universal", "url": "https://example.com", "markdown": True} + + +def test_web_scraper_js_render_and_geo(fake_api: FakeApi) -> None: + DecodoWebScraperTool().run(url="https://example.com", js_render=True, geo="Germany") + + assert fake_api.payload["headless"] == "html" + assert fake_api.payload["geo"] == "Germany" + + +def test_web_scraper_truncates_long_pages(fake_api: FakeApi) -> None: + fake_api.body = {"results": [{"content": "a" * 50, "status_code": 200}]} + + result = DecodoWebScraperTool(max_chars=10).run(url="https://example.com") + + assert result.startswith("a" * 10 + "\n\n[Truncated to 10 of 50 characters.]") + + +def test_search_google_returns_full_parsed_results(fake_api: FakeApi) -> None: + content = {"results": {"organic": [{"title": "A", "images": ["x"]}], "images": ["y"]}} + fake_api.body = {"results": [{"status_code": 200, "content": content}]} + + result = DecodoSearchTool().run(query="coffee") + + assert fake_api.payload == {"target": "google_search", "query": "coffee", "parse": True} + assert json.loads(result) == content + + +def test_search_amazon(fake_api: FakeApi) -> None: + content = {"results": {"organic": [], "suggested": [1]}} + fake_api.body = {"results": [{"status_code": 200, "content": content}]} + + result = DecodoSearchTool().run(query="keyboard", source="amazon", geo="United States") + + assert fake_api.payload == {"target": "amazon_search", "query": "keyboard", "geo": "United States", "parse": True} + assert json.loads(result) == content + + +@pytest.mark.parametrize( + ("status_code", "code", "retryable"), + [ + (401, "authentication_failed", False), + (422, "invalid_params", False), + (429, "rate_limited", True), + (500, "500", True), + ], +) +def test_api_errors_become_tool_failures(fake_api: FakeApi, status_code: int, code: str, retryable: bool) -> None: + fake_api.status_code = status_code + fake_api.body = {"message": "nope"} + + result = DecodoWebScraperTool()._run(url="https://example.com") + + assert isinstance(result, ToolFailure) + assert result.code == code + assert result.retryable is retryable + + +def test_target_error_status_becomes_tool_failure(fake_api: FakeApi) -> None: + fake_api.body = {"results": [{"content": "", "status_code": 404}]} + + result = DecodoWebScraperTool()._run(url="https://example.com/missing") + + assert isinstance(result, ToolFailure) + assert result.code == "404" + + +def test_empty_results_become_tool_failure(fake_api: FakeApi) -> None: + fake_api.body = {"results": []} + + result = DecodoSearchTool()._run(query="coffee") + + assert isinstance(result, ToolFailure) + assert result.code == "empty_response" + + +@pytest.mark.live +@pytest.mark.skipif(not os.environ.get("DECODO_LIVE_API_KEY"), reason="DECODO_LIVE_API_KEY not set") +def test_live_scrape_and_search() -> None: + api_key = os.environ["DECODO_LIVE_API_KEY"] + + page = DecodoWebScraperTool(api_key=api_key).run(url="https://ip.decodo.com") + assert isinstance(page, str) and page + + results = DecodoSearchTool(api_key=api_key).run(query="decodo") + assert isinstance(results, str) and json.loads(results)