arcade-mcp/toolkits/web/arcade_web/tools/firecrawl.py
Eric Gustin cc2a08ec34
Add Firecrawl Tools For The New arcade_web` Toolkit (#110)
# PR Description
This PR adds 6 new tools inside the new `arcade_web` toolkit. None of
these tools require auth. They do, however, require the
`FIRECRAWL_API_KEY` API Key to be set.

The new tools implement the [Firecrawl](https://www.firecrawl.dev/) APIs
`/scrape (POST)`, `/crawl (POST)`, `/crawl/{id} (GET)`, `/crawl/{id}
(DELETE)`, and `/map (POST)`.

The six tools are:
* `Web.ScrapeUrl`: 
- In the future I would like this tool to support actions (clicking,
scrolling, screenshotting, etc) and extract (specify what you want to
scrape) parameters. Firecrawl supports both of these parameters.
* `Web.CrawlWebsite`:
- If `async_crawl` is true, then the tool just returns the id of the
crawl job, which you can retrieve later with the `Web.GetCrawlData`
tool. If `async_crawl` is false, then the entire contents of the crawl
are returned.
* `Web.GetCrawlStatus`
- Works for in progress or recently finished crawl jobs (Firecrawl's
limitation)
* `Web.GetCrawlData`
- Works for in progress or recently finished crawl jobs (Firecrawl's
limitation)
* `Web.CancelCrawl`
    - You can cancel an in progress async crawl job
* `Web.MapWebsite`
- This endpoint is in alpha, but it can give you all of the links of an
entire website, or optionally, you can specify in natural language what
type of links you want to map by using the `search` parameter. For
example "only map webpages that are about AI"
2024-10-17 16:10:53 -07:00

180 lines
6.1 KiB
Python

from typing import Annotated, Any, Optional
from firecrawl import FirecrawlApp
from arcade.sdk import tool
from arcade_web.tools.models import Formats
from arcade_web.tools.utils import get_secret
# TODO: Support actions. This would enable clicking, scrolling, screenshotting, etc.
# TODO: Support extract.
# TODO: Support headers param?
@tool
async def scrape_url(
url: Annotated[str, "URL to scrape"],
formats: Annotated[
Optional[list[Formats]], "Formats to retrieve. Defaults to ['markdown']."
] = None,
only_main_content: Annotated[
Optional[bool],
"Only return the main content of the page excluding headers, navs, footers, etc.",
] = True,
include_tags: Annotated[list[str] | None, "List of tags to include in the output"] = None,
exclude_tags: Annotated[list[str] | None, "List of tags to exclude from the output"] = None,
wait_for: Annotated[
Optional[int],
"Specify a delay in milliseconds before fetching the content, allowing the page sufficient time to load.",
] = 10,
timeout: Annotated[Optional[int], "Timeout in milliseconds for the request"] = 30000,
) -> Annotated[dict[str, Any], "Scraped data in specified formats"]:
"""Scrape a URL using Firecrawl and return the data in specified formats."""
api_key = get_secret("FIRECRAWL_API_KEY")
formats = formats or [Formats.MARKDOWN]
app = FirecrawlApp(api_key=api_key)
params = {
"formats": formats,
"onlyMainContent": only_main_content,
"includeTags": include_tags or [],
"excludeTags": exclude_tags or [],
"waitFor": wait_for,
"timeout": timeout,
}
response = app.scrape_url(url, params=params)
return response
# TODO: Support scrapeOptions.
@tool
async def crawl_website(
url: Annotated[str, "URL to crawl"],
exclude_paths: Annotated[list[str] | None, "URL patterns to exclude from the crawl"] = None,
include_paths: Annotated[list[str] | None, "URL patterns to include in the crawl"] = None,
max_depth: Annotated[int, "Maximum depth to crawl relative to the entered URL"] = 2,
ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True,
limit: Annotated[int, "Limit the number of pages to crawl"] = 10,
allow_backward_links: Annotated[
bool,
"Enable navigation to previously linked pages and enable crawling sublinks that are not children of the 'url' input parameter.",
] = False,
allow_external_links: Annotated[bool, "Allow following links to external websites"] = False,
webhook: Annotated[
Optional[str],
"The URL to send a POST request to when the crawl is started, updated and completed.",
] = None,
async_crawl: Annotated[bool, "Run the crawl asynchronously"] = True,
) -> Annotated[dict[str, Any], "Crawl status and data"]:
"""
Crawl a website using Firecrawl. If the crawl is asynchronous, then returns the crawl ID.
If the crawl is synchronous, then returns the crawl data.
"""
api_key = get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
params = {
"limit": limit,
"excludePaths": exclude_paths or [],
"includePaths": include_paths or [],
"maxDepth": max_depth,
"ignoreSitemap": ignore_sitemap,
"allowBackwardLinks": allow_backward_links,
"allowExternalLinks": allow_external_links,
}
if webhook:
params["webhook"] = webhook
if async_crawl:
response = app.async_crawl_url(url, params=params)
if (
"url" in response
): # Url isn't clickable, so removing it since only the ID is needed to check status
del response["url"]
else:
response = app.crawl_url(url, params=params)
return response
@tool
async def get_crawl_status(
crawl_id: Annotated[str, "The ID of the crawl job"],
) -> Annotated[dict[str, Any], "Crawl status information"]:
"""
Get the status of a Firecrawl 'crawl' that is either in progress or recently completed.
"""
api_key = get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
crawl_status = app.check_crawl_status(crawl_id)
if "data" in crawl_status:
del crawl_status["data"]
return crawl_status
# TODO: Support responses greater than 10 MB. If the response is greater than 10 MB, then the Firecrawl API response will have a next_url field.
@tool
async def get_crawl_data(
crawl_id: Annotated[str, "The ID of the crawl job"],
) -> Annotated[dict[str, Any], "Crawl data information"]:
"""
Get the data of a Firecrawl 'crawl' that is either in progress or recently completed.
"""
api_key = get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
crawl_data = app.check_crawl_status(crawl_id)
return crawl_data
@tool
async def cancel_crawl(
crawl_id: Annotated[str, "The ID of the asynchronous crawl job to cancel"],
) -> Annotated[dict[str, Any], "Cancellation status information"]:
"""
Cancel an asynchronous crawl job that is in progress using the Firecrawl API.
"""
api_key = get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
cancellation_status = app.cancel_crawl(crawl_id)
return cancellation_status
@tool
async def map_website(
url: Annotated[str, "The base URL to start crawling from"],
search: Annotated[Optional[str], "Search query to use for mapping"] = None,
ignore_sitemap: Annotated[bool, "Ignore the website sitemap when crawling"] = True,
include_subdomains: Annotated[bool, "Include subdomains of the website"] = False,
limit: Annotated[int, "Maximum number of links to return"] = 5000,
) -> Annotated[dict[str, Any], "Website map data"]:
"""
Map a website from a single URL to a map of the entire website.
"""
api_key = get_secret("FIRECRAWL_API_KEY")
app = FirecrawlApp(api_key=api_key)
params = {
"ignoreSitemap": ignore_sitemap,
"includeSubdomains": include_subdomains,
"limit": limit,
}
if search:
params["search"] = search
map_result = app.map_url(url, params=params)
return map_result