Files
2026-07-13 13:39:12 +08:00

178 lines
5.9 KiB
TypeScript

/**
* Firecrawl Web Fetch Executor
*
* Fetches content from a URL using the Firecrawl scrape API.
* POST https://api.firecrawl.dev/v1/scrape
*
* Free tier: 500 fetches/month, no credit card required.
* Docs: https://docs.firecrawl.dev/api-reference/endpoint/scrape
*
* Self-hosted: set FIRECRAWL_BASE_URL to point at a self-hosted Firecrawl
* instance (e.g. http://127.0.0.1:3002). The API key is only required against
* the default cloud base URL — self-hosted instances typically run with no
* auth in front of them, so credentials.apiKey becomes optional in that case.
*/
import { sanitizeErrorMessage, buildErrorBody } from "../utils/error.ts";
import type { WebFetchResult, WebFetchFormat, WebFetchCredentials } from "../handlers/webFetch.ts";
const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev";
const FIRECRAWL_DEFAULT_TIMEOUT_MS = 30_000;
/** Resolve the configured Firecrawl base URL, falling back to the public cloud API. */
function getFirecrawlBaseUrl(): string {
const envBase = process.env.FIRECRAWL_BASE_URL?.trim();
return envBase ? envBase.replace(/\/+$/, "") : FIRECRAWL_DEFAULT_BASE_URL;
}
/** Whether the given base URL is the default Firecrawl cloud endpoint. */
function isDefaultFirecrawlBaseUrl(baseUrl: string): boolean {
return baseUrl === FIRECRAWL_DEFAULT_BASE_URL;
}
/** Resolve the configured request timeout, falling back to a sane default. */
function getFirecrawlTimeoutMs(): number {
const raw = process.env.FIRECRAWL_TIMEOUT_MS;
if (!raw) return FIRECRAWL_DEFAULT_TIMEOUT_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : FIRECRAWL_DEFAULT_TIMEOUT_MS;
}
function mapFormat(format: WebFetchFormat): string {
switch (format) {
case "html":
return "html";
case "links":
return "links";
case "screenshot":
return "screenshot";
case "markdown":
default:
return "markdown";
}
}
interface FirecrawlScrapeOptions {
url: string;
format: WebFetchFormat;
depth: number;
waitForSelector?: string;
includeMetadata: boolean;
credentials: WebFetchCredentials;
}
/**
* Execute a Firecrawl scrape request.
*/
export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise<WebFetchResult> {
const { url, format, depth, waitForSelector, includeMetadata, credentials } = opts;
const baseUrl = getFirecrawlBaseUrl();
const isDefaultBaseUrl = isDefaultFirecrawlBaseUrl(baseUrl);
// The API key is mandatory for the public Firecrawl cloud API, but optional
// once a custom (self-hosted) base URL is configured.
if (isDefaultBaseUrl && !credentials.apiKey) {
const body = buildErrorBody(401, "Firecrawl API key required");
return { success: false, status: 401, error: body.error.message };
}
const formats = [mapFormat(format)];
const requestBody: Record<string, unknown> = {
url,
formats,
};
// NOTE: Firecrawl returns metadata (title, description, og:title, etc.)
// automatically in response.data.metadata — no special request params needed.
// Sending the `includeTags` parameter with non-CSS-selector values like
// "og:title" or "description" causes Firecrawl's parser to crash (HTTP 500).
// The `includeMetadata` flag only controls whether we surface metadata
// in our response (see response parsing below).
if (depth > 0) {
requestBody.maxDepth = depth;
}
if (waitForSelector) {
requestBody.waitFor = waitForSelector;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), getFirecrawlTimeoutMs());
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (credentials.apiKey) {
headers.Authorization = `Bearer ${credentials.apiKey}`;
}
const response = await fetch(`${baseUrl}/v1/scrape`, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
if (!response.ok) {
const rawError = await response.text().catch(() => `HTTP ${response.status}`);
const msg = sanitizeErrorMessage(`Firecrawl error ${response.status}: ${rawError}`);
const body = buildErrorBody(response.status, msg);
return { success: false, status: response.status, error: body.error.message };
}
const data = (await response.json()) as Record<string, unknown>;
const scraped = (data.data as Record<string, unknown> | null) ?? {};
const content =
format === "html"
? String(scraped.html ?? "")
: format === "links"
? JSON.stringify(scraped.links ?? [])
: String(scraped.markdown ?? scraped.content ?? "");
const rawLinks = scraped.links;
const links: string[] = Array.isArray(rawLinks) ? rawLinks.map((l) => String(l)) : [];
const rawMeta = scraped.metadata as Record<string, unknown> | null | undefined;
const metadata = includeMetadata
? {
title: rawMeta?.title != null ? String(rawMeta.title) : null,
description: rawMeta?.description != null ? String(rawMeta.description) : null,
}
: null;
const screenshotUrl =
format === "screenshot"
? scraped.screenshot != null
? String(scraped.screenshot)
: null
: null;
return {
success: true,
data: {
provider: "firecrawl",
url,
content,
links,
metadata,
screenshot_url: screenshotUrl,
},
};
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
const body = buildErrorBody(504, "Firecrawl request timed out");
return { success: false, status: 504, error: body.error.message };
}
const msg =
err instanceof Error ? sanitizeErrorMessage(err.message) : sanitizeErrorMessage(String(err));
const body = buildErrorBody(502, msg);
return { success: false, status: 502, error: body.error.message };
} finally {
clearTimeout(timeoutId);
}
}