Skip to content

Commit 4c52ece

Browse files
authored
fix(firecrawl): complete v2 response and depth compatibility (#9847)
* fix(firecrawl): complete v2 response and depth compatibility * fix(firecrawl): preserve index pages with crawl path filters
1 parent 7299c3e commit 4c52ece

9 files changed

Lines changed: 183 additions & 17 deletions

File tree

‎apps/docs/content/docs/integrations/firecrawl.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Crawl entire websites and extract structured content from all accessible pages
184184
| --------- | ---- | -------- | ----------- |
185185
| `url` | string | Yes | The website URL to crawl \(e.g., "https://example.com" or "https://docs.example.com/guide") |
186186
| `limit` | number | No | Maximum number of pages to crawl \(e.g., 50, 100, 500\). Default: 100 |
187-
| `maxDepth` | number | No | Maximum depth to crawl from the starting URL \(e.g., 1, 2, 3\). Controls how many levels deep to follow links |
187+
| `maxDepth` | number | No | Maximum URL path depth \(e.g., 1 allows /docs and /docs/index.html\). Independent of link discovery depth |
188188
| `formats` | json | No | Output formats for scraped content \(e.g., \["markdown"\], \["markdown", "html"\], \["markdown", "links"\]\) |
189189
| `prompt` | string | No | Natural-language crawl guidance supplied by existing configurations |
190190
| `excludePaths` | json | No | URL paths to exclude from crawling \(e.g., \["/blog/*", "/admin/*", "/*.pdf"\]\) |
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { isPlainRecord } from '@sim/utils/object'
2+
import { describe, expect, it } from 'vitest'
3+
import { crawlTool } from '@/tools/firecrawl/crawl'
4+
5+
function crawlBody(options: {
6+
maxDepth: number
7+
maxDiscoveryDepth?: number
8+
includePaths?: string[]
9+
excludePaths?: string[]
10+
}) {
11+
if (typeof crawlTool.request.body !== 'function') throw new Error('Missing request formatter')
12+
const body = crawlTool.request.body({ apiKey: 'test', url: 'https://example.com', ...options })
13+
if (!isPlainRecord(body)) throw new Error('Expected a JSON request body')
14+
return body
15+
}
16+
17+
describe('Firecrawl legacy crawl depth', () => {
18+
it('bounds URL path segments without limiting discovery hops', () => {
19+
const body = crawlBody({ maxDepth: 1, maxDiscoveryDepth: 4 })
20+
const patterns = (body.includePaths as string[]).map((pattern) => new RegExp(pattern))
21+
const accepts = (path: string) => patterns.some((pattern) => pattern.test(path))
22+
23+
expect(body.maxDiscoveryDepth).toBe(4)
24+
expect(body.regexOnFullURL).toBe(false)
25+
expect(accepts('/')).toBe(true)
26+
expect(accepts('/docs')).toBe(true)
27+
expect(accepts('/docs/index.html')).toBe(true)
28+
expect(accepts('/docs/guide')).toBe(false)
29+
expect(accepts('/docs/guide/page')).toBe(false)
30+
})
31+
32+
it('keeps existing path filters while excluding paths beyond the legacy depth', () => {
33+
const body = crawlBody({ maxDepth: 1, includePaths: ['^/docs'], excludePaths: ['private'] })
34+
const patterns = (body.excludePaths as string[]).map((pattern) => new RegExp(pattern))
35+
const excludes = (path: string) => patterns.some((pattern) => pattern.test(path))
36+
37+
expect(body.includePaths).toEqual(['^/docs'])
38+
expect(excludes('/docs')).toBe(false)
39+
expect(excludes('/docs/guide')).toBe(true)
40+
expect(excludes('/private')).toBe(true)
41+
expect(body.maxDiscoveryDepth).toBeUndefined()
42+
})
43+
44+
it('honors a zero depth instead of dropping the limit', () => {
45+
const body = crawlBody({ maxDepth: 0 })
46+
const patterns = (body.includePaths as string[]).map((pattern) => new RegExp(pattern))
47+
expect(patterns.some((pattern) => pattern.test('/'))).toBe(true)
48+
expect(patterns.some((pattern) => pattern.test('/docs'))).toBe(false)
49+
})
50+
51+
it.each<[number, string, boolean]>([
52+
[0, '/index.html', false],
53+
[0, '/index.php/', false],
54+
[1, '/docs/index.html', false],
55+
[1, '/docs/index.php', false],
56+
[1, '/docs//index.html/', false],
57+
[1, '/docs/guide/index.html', true],
58+
[1, '/docs/index.html/child', true],
59+
[1, '/docs/index.html.bak', true],
60+
[1, '/docs/index.phpx', true],
61+
[1, '/docs/inde', true],
62+
[1, '/docs/index.htm', true],
63+
])('applies depth %s with existing includes to %s', (maxDepth, path, excluded) => {
64+
const body = crawlBody({ maxDepth, includePaths: ['^/'] })
65+
const patterns = (body.excludePaths as string[]).map((pattern) => new RegExp(pattern))
66+
expect(patterns.some((pattern) => pattern.test(path))).toBe(excluded)
67+
})
68+
69+
it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
70+
'rejects an invalid legacy depth: %s',
71+
(maxDepth) => {
72+
expect(() => crawlBody({ maxDepth })).toThrow(/maxDepth/)
73+
}
74+
)
75+
})

‎apps/sim/tools/firecrawl/crawl.ts‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { sleep } from '@sim/utils/helpers'
3+
import { escapeRegExp } from '@sim/utils/string'
34
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
45
import { firecrawlHosting } from '@/tools/firecrawl/hosting'
56
import {
@@ -41,7 +42,7 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
4142
required: false,
4243
visibility: 'user-or-llm',
4344
description:
44-
'Maximum depth to crawl from the starting URL (e.g., 1, 2, 3). Controls how many levels deep to follow links',
45+
'Maximum URL path depth (e.g., 1 allows /docs and /docs/index.html). Independent of link discovery depth',
4546
},
4647
formats: {
4748
type: 'json',
@@ -130,7 +131,7 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
130131
Authorization: `Bearer ${params.apiKey}`,
131132
}),
132133
body: (params) => {
133-
const body: Record<string, any> = {
134+
const body: Record<string, unknown> = {
134135
url: params.url,
135136
limit: Number(params.limit) || 100,
136137
scrapeOptions: params.scrapeOptions || {
@@ -140,8 +141,8 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
140141
}
141142

142143
if (params.prompt) body.prompt = params.prompt
143-
if (params.maxDepth) body.maxDiscoveryDepth = Number(params.maxDepth)
144-
if (params.maxDiscoveryDepth) body.maxDiscoveryDepth = Number(params.maxDiscoveryDepth)
144+
if (params.maxDiscoveryDepth != null)
145+
body.maxDiscoveryDepth = Number(params.maxDiscoveryDepth)
145146
if (params.sitemap) body.sitemap = params.sitemap
146147
if (typeof params.crawlEntireDomain === 'boolean')
147148
body.crawlEntireDomain = params.crawlEntireDomain
@@ -154,6 +155,44 @@ export const crawlTool: ToolConfig<FirecrawlCrawlParams, FirecrawlCrawlResponse>
154155
if (params.maxConcurrency) body.maxConcurrency = Number(params.maxConcurrency)
155156
if (params.excludePaths) body.excludePaths = params.excludePaths
156157
if (params.includePaths) body.includePaths = params.includePaths
158+
if (params.maxDepth != null) {
159+
const maxDepth = Number(params.maxDepth)
160+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0) {
161+
throw new Error('maxDepth must be a non-negative integer')
162+
}
163+
164+
// v1 depth counted URL path segments; v2 discovery depth counts link hops.
165+
body.regexOnFullURL = false
166+
if (params.includePaths?.length) {
167+
const indexFiles = ['index.html', 'index.php']
168+
const nonIndexSegmentPatterns = new Set<string>()
169+
// Rust regex has no lookarounds, so enumerate incomplete prefixes and first mismatches.
170+
for (const file of indexFiles) {
171+
for (let length = 0; length <= file.length; length++) {
172+
const prefix = file.slice(0, length)
173+
const nextCharacters = indexFiles
174+
.filter((indexFile) => indexFile.startsWith(prefix))
175+
.map((indexFile) => indexFile.charAt(length))
176+
.join('')
177+
const escapedPrefix = escapeRegExp(prefix)
178+
if (prefix && !indexFiles.includes(prefix)) {
179+
nonIndexSegmentPatterns.add(escapedPrefix)
180+
}
181+
nonIndexSegmentPatterns.add(`${escapedPrefix}[^/${nextCharacters}][^/]*`)
182+
}
183+
}
184+
const nonIndexBoundary = `^(/+[^/]+){${maxDepth}}/+(${[...nonIndexSegmentPatterns].join('|')})/*$`
185+
body.excludePaths = [
186+
...(params.excludePaths ?? []),
187+
`^(/+[^/]+){${maxDepth + 2}}|${nonIndexBoundary}`,
188+
]
189+
} else {
190+
body.includePaths = [
191+
`^(/+[^/]+){0,${maxDepth}}/*$`,
192+
`^(/+[^/]+){0,${maxDepth}}/+index\\.(html|php)/*$`,
193+
]
194+
}
195+
}
157196
if (params.webhook) body.webhook = params.webhook
158197
if (typeof params.zeroDataRetention === 'boolean')
159198
body.zeroDataRetention = params.zeroDataRetention
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { jsonResponse } from '@sim/testing'
2+
import { describe, expect, it } from 'vitest'
3+
import { mapTool } from '@/tools/firecrawl/map'
4+
5+
describe('Firecrawl v2 map results', () => {
6+
it('preserves URL strings for workflows when the provider returns link objects', async () => {
7+
if (!mapTool.transformResponse) throw new Error('Missing response transform')
8+
const result = await mapTool.transformResponse(
9+
jsonResponse({
10+
success: true,
11+
links: [
12+
{ url: 'https://example.com', title: 'Home', description: 'Home page' },
13+
{ url: 'https://example.com/docs', title: 'Docs' },
14+
],
15+
})
16+
)
17+
18+
expect(result.output.links).toEqual(['https://example.com', 'https://example.com/docs'])
19+
})
20+
})

‎apps/sim/tools/firecrawl/map.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const mapTool: ToolConfig<MapParams, MapResponse> = {
7979
Authorization: `Bearer ${params.apiKey}`,
8080
}),
8181
body: (params) => {
82-
const body: Record<string, any> = {
82+
const body: Record<string, unknown> = {
8383
url: params.url,
8484
}
8585

@@ -98,13 +98,13 @@ export const mapTool: ToolConfig<MapParams, MapResponse> = {
9898
},
9999

100100
transformResponse: async (response: Response) => {
101-
const data = await response.json()
101+
const data: { success: boolean; links?: { url: string }[] } = await response.json()
102102

103103
return {
104104
success: data.success,
105105
output: {
106106
success: data.success,
107-
links: data.links || [],
107+
links: data.links?.map((link) => link.url) ?? [],
108108
creditsUsed: 1,
109109
},
110110
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { jsonResponse } from '@sim/testing'
2+
import { describe, expect, it } from 'vitest'
3+
import { searchTool } from '@/tools/firecrawl/search'
4+
5+
describe('Firecrawl v2 search results', () => {
6+
it('preserves the workflow result array when the provider groups results by source', async () => {
7+
if (!searchTool.transformResponse) throw new Error('Missing response transform')
8+
const result = await searchTool.transformResponse(
9+
jsonResponse({
10+
success: true,
11+
data: {
12+
web: [{ url: 'https://example.com', title: 'Example', description: 'A web result' }],
13+
},
14+
creditsUsed: 2,
15+
})
16+
)
17+
18+
expect(result.output.data).toEqual([
19+
{ url: 'https://example.com', title: 'Example', description: 'A web result' },
20+
])
21+
expect(result.output.creditsUsed).toBe(2)
22+
})
23+
24+
it('returns an empty result array when the provider finds no web results', async () => {
25+
if (!searchTool.transformResponse) throw new Error('Missing response transform')
26+
const result = await searchTool.transformResponse(
27+
jsonResponse({ success: true, data: {}, creditsUsed: 0 })
28+
)
29+
30+
expect(result.output.data).toEqual([])
31+
})
32+
})

‎apps/sim/tools/firecrawl/search.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,11 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
5656
Authorization: `Bearer ${params.apiKey}`,
5757
}),
5858
body: (params) => {
59-
const body: Record<string, any> = {
59+
const body: Record<string, unknown> = {
6060
query: params.query,
61+
limit: params.limit ? Number(params.limit) : 5,
6162
}
6263

63-
// Add optional parameters if provided (truthy check filters empty strings, null, undefined)
64-
if (params.limit) body.limit = Number(params.limit)
6564
if (params.sources) body.sources = params.sources
6665
if (params.categories) body.categories = params.categories
6766
if (params.tbs) body.tbs = params.tbs
@@ -77,12 +76,15 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
7776
},
7877

7978
transformResponse: async (response: Response) => {
80-
const data = await response.json()
79+
const data: {
80+
data?: { web?: SearchResponse['output']['data'] }
81+
creditsUsed?: number
82+
} = await response.json()
8183

8284
return {
8385
success: true,
8486
output: {
85-
data: data.data,
87+
data: data.data?.web ?? [],
8688
creditsUsed: data.creditsUsed,
8789
},
8890
}

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎scripts/check-explicit-any.baseline.json‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,9 @@
444444
"apps/sim/tools/exa/utils.ts": 5,
445445
"apps/sim/tools/firecrawl/agent.ts": 2,
446446
"apps/sim/tools/firecrawl/batch-scrape.ts": 3,
447-
"apps/sim/tools/firecrawl/crawl.ts": 2,
447+
"apps/sim/tools/firecrawl/crawl.ts": 1,
448448
"apps/sim/tools/firecrawl/extract.ts": 3,
449-
"apps/sim/tools/firecrawl/map.ts": 1,
450449
"apps/sim/tools/firecrawl/scrape.ts": 1,
451-
"apps/sim/tools/firecrawl/search.ts": 1,
452450
"apps/sim/tools/firecrawl/types.ts": 8,
453451
"apps/sim/tools/function/types.ts": 1,
454452
"apps/sim/tools/github/add_assignees.ts": 3,

0 commit comments

Comments
 (0)