@open-mercato/web-research-firecrawl 0.7.1-develop.7194.1.ab4fc81f82

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.
@@ -0,0 +1,2 @@
1
+ [build:web-research-firecrawl] found 2 entry points
2
+ [build:web-research-firecrawl] built successfully
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { buildPackage } from '../../scripts/build-package.mjs'
4
+
5
+ const packageDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ await buildPackage(packageDir, {
8
+ name: 'web-research-firecrawl',
9
+ entryPoints: 'src/**/*.ts',
10
+ })
@@ -0,0 +1,160 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk,
7
+ stripTrailingSlashes,
8
+ toOutcomeFailure
9
+ } from "@open-mercato/web-research";
10
+ import { z } from "zod";
11
+ const firecrawlOptionsSchema = z.object({
12
+ apiKey: z.string().min(1).optional(),
13
+ baseUrl: z.url().optional(),
14
+ /** Ask Firecrawl to return page content inline, sparing a second round trip. */
15
+ includeContent: z.boolean().optional(),
16
+ timeoutMs: z.number().int().min(1e3).max(12e4).optional()
17
+ });
18
+ const DEFAULT_BASE_URL = "https://api.firecrawl.dev";
19
+ const DEFAULT_TIMEOUT_MS = 2e4;
20
+ const searchResponseSchema = z.object({
21
+ success: z.boolean().optional(),
22
+ data: z.array(
23
+ z.object({
24
+ url: z.string(),
25
+ title: z.string().nullish(),
26
+ description: z.string().nullish(),
27
+ markdown: z.string().nullish(),
28
+ publishedDate: z.string().nullish()
29
+ })
30
+ ).nullish()
31
+ });
32
+ const scrapeResponseSchema = z.object({
33
+ success: z.boolean().optional(),
34
+ data: z.object({
35
+ markdown: z.string().nullish(),
36
+ html: z.string().nullish(),
37
+ metadata: z.object({
38
+ title: z.string().nullish(),
39
+ sourceURL: z.string().nullish(),
40
+ statusCode: z.number().nullish()
41
+ }).nullish()
42
+ }).nullish()
43
+ });
44
+ function parseJson(body) {
45
+ try {
46
+ return JSON.parse(body);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ function createFirecrawlAdapter(options) {
52
+ const apiKey = options.apiKey?.trim() ?? "";
53
+ const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
54
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
55
+ const includeContent = options.includeContent ?? true;
56
+ const post = (path, payload, context) => context.http.request(`${baseUrl}${path}`, {
57
+ method: "POST",
58
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
59
+ body: JSON.stringify(payload),
60
+ accept: ["application/json"],
61
+ timeoutMs,
62
+ signal: context.signal
63
+ });
64
+ return {
65
+ id: "firecrawl",
66
+ kind: "api",
67
+ capabilities: {
68
+ search: true,
69
+ fetch: true,
70
+ snippets: true,
71
+ content: includeContent,
72
+ freshness: false,
73
+ siteFilter: true,
74
+ cost: "metered"
75
+ },
76
+ /** `billable` — issues a real /v1/search query. */
77
+ probeCost: "billable",
78
+ readiness: () => apiKey.length > 0 ? READY : notReady("Firecrawl API key is not set"),
79
+ async search(request, context) {
80
+ if (apiKey.length === 0) return { status: "unavailable", reason: "Firecrawl API key is not set" };
81
+ try {
82
+ const query = request.site ? `${request.query} site:${request.site}` : request.query;
83
+ const response = await post(
84
+ "/v1/search",
85
+ {
86
+ query,
87
+ limit: request.limit,
88
+ ...includeContent ? { scrapeOptions: { formats: ["markdown"] } } : {}
89
+ },
90
+ context
91
+ );
92
+ const parsed = searchResponseSchema.safeParse(parseJson(response.body));
93
+ if (!parsed.success) {
94
+ return { status: "error", reason: "Firecrawl returned an unrecognized response shape", retriable: false };
95
+ }
96
+ const results = (parsed.data.data ?? []).map((item) => ({
97
+ url: item.url,
98
+ title: item.title ?? null,
99
+ snippet: item.description ?? null,
100
+ content: item.markdown ?? null,
101
+ publishedAt: item.publishedDate ?? null
102
+ }));
103
+ context.report("ok", `Firecrawl returned ${results.length} result(s)`, { resultCount: results.length });
104
+ return searchOk(results);
105
+ } catch (error) {
106
+ return toOutcomeFailure(error);
107
+ }
108
+ },
109
+ async fetch(request, context) {
110
+ if (apiKey.length === 0) return { status: "unavailable", reason: "Firecrawl API key is not set" };
111
+ try {
112
+ const response = await post("/v1/scrape", { url: request.url, formats: ["markdown"] }, context);
113
+ const parsed = scrapeResponseSchema.safeParse(parseJson(response.body));
114
+ const data = parsed.success ? parsed.data.data : null;
115
+ const text = data?.markdown?.trim() ?? "";
116
+ if (text.length === 0) {
117
+ return { status: "error", reason: "Firecrawl returned no readable content", retriable: false };
118
+ }
119
+ return {
120
+ status: "ok",
121
+ page: {
122
+ url: data?.metadata?.sourceURL ?? request.url,
123
+ title: data?.metadata?.title ?? null,
124
+ text,
125
+ contentType: "text/markdown",
126
+ status: data?.metadata?.statusCode ?? 200,
127
+ truncated: false,
128
+ // Firecrawl renders server-side, so content that needed JavaScript
129
+ // arrives without us running a browser at all.
130
+ renderedWith: "browser"
131
+ }
132
+ };
133
+ } catch (error) {
134
+ return toOutcomeFailure(error);
135
+ }
136
+ },
137
+ async healthCheck(context) {
138
+ if (apiKey.length === 0) return { ok: false, detail: "Firecrawl API key is not set" };
139
+ try {
140
+ await post("/v1/search", { query: "open mercato health check", limit: 1 }, context);
141
+ return { ok: true };
142
+ } catch (error) {
143
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
144
+ }
145
+ }
146
+ };
147
+ }
148
+ const firecrawlAdapterModule = defineAdapterModule({
149
+ id: "firecrawl",
150
+ kind: "api",
151
+ contractVersion: CONTRACT_VERSION,
152
+ optionsSchema: firecrawlOptionsSchema,
153
+ createAdapter: createFirecrawlAdapter
154
+ });
155
+ export {
156
+ createFirecrawlAdapter,
157
+ firecrawlAdapterModule,
158
+ firecrawlOptionsSchema
159
+ };
160
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/adapter.ts"],
4
+ "sourcesContent": ["import {\n CONTRACT_VERSION,\n READY,\n defineAdapterModule,\n notReady,\n searchOk,\n stripTrailingSlashes,\n toOutcomeFailure,\n type AdapterContext,\n type FetchOutcome,\n type FetchRequest,\n type RawResult,\n type SearchAdapter,\n type SearchOutcome,\n} from '@open-mercato/web-research'\nimport { z } from 'zod'\n\nexport const firecrawlOptionsSchema = z.object({\n apiKey: z.string().min(1).optional(),\n baseUrl: z.url().optional(),\n /** Ask Firecrawl to return page content inline, sparing a second round trip. */\n includeContent: z.boolean().optional(),\n timeoutMs: z.number().int().min(1000).max(120_000).optional(),\n})\n\nexport type FirecrawlOptions = z.infer<typeof firecrawlOptionsSchema>\n\nconst DEFAULT_BASE_URL = 'https://api.firecrawl.dev'\nconst DEFAULT_TIMEOUT_MS = 20_000\n\nconst searchResponseSchema = z.object({\n success: z.boolean().optional(),\n data: z\n .array(\n z.object({\n url: z.string(),\n title: z.string().nullish(),\n description: z.string().nullish(),\n markdown: z.string().nullish(),\n publishedDate: z.string().nullish(),\n }),\n )\n .nullish(),\n})\n\nconst scrapeResponseSchema = z.object({\n success: z.boolean().optional(),\n data: z\n .object({\n markdown: z.string().nullish(),\n html: z.string().nullish(),\n metadata: z\n .object({\n title: z.string().nullish(),\n sourceURL: z.string().nullish(),\n statusCode: z.number().nullish(),\n })\n .nullish(),\n })\n .nullish(),\n})\n\nfunction parseJson(body: string): unknown {\n try {\n return JSON.parse(body)\n } catch {\n return null\n }\n}\n\nexport function createFirecrawlAdapter(options: FirecrawlOptions): SearchAdapter {\n const apiKey = options.apiKey?.trim() ?? ''\n const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL)\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const includeContent = options.includeContent ?? true\n\n const post = (path: string, payload: unknown, context: AdapterContext) =>\n context.http.request(`${baseUrl}${path}`, {\n method: 'POST',\n headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n accept: ['application/json'],\n timeoutMs,\n signal: context.signal,\n })\n\n return {\n id: 'firecrawl',\n kind: 'api',\n capabilities: {\n search: true,\n fetch: true,\n snippets: true,\n content: includeContent,\n freshness: false,\n siteFilter: true,\n cost: 'metered',\n },\n /** `billable` \u2014 issues a real /v1/search query. */\n probeCost: 'billable' as const,\n readiness: () => (apiKey.length > 0 ? READY : notReady('Firecrawl API key is not set')),\n\n async search(request, context): Promise<SearchOutcome> {\n if (apiKey.length === 0) return { status: 'unavailable', reason: 'Firecrawl API key is not set' }\n try {\n const query = request.site ? `${request.query} site:${request.site}` : request.query\n const response = await post(\n '/v1/search',\n {\n query,\n limit: request.limit,\n ...(includeContent ? { scrapeOptions: { formats: ['markdown'] } } : {}),\n },\n context,\n )\n const parsed = searchResponseSchema.safeParse(parseJson(response.body))\n if (!parsed.success) {\n return { status: 'error', reason: 'Firecrawl returned an unrecognized response shape', retriable: false }\n }\n const results: RawResult[] = (parsed.data.data ?? []).map((item) => ({\n url: item.url,\n title: item.title ?? null,\n snippet: item.description ?? null,\n content: item.markdown ?? null,\n publishedAt: item.publishedDate ?? null,\n }))\n context.report('ok', `Firecrawl returned ${results.length} result(s)`, { resultCount: results.length })\n return searchOk(results)\n } catch (error) {\n return toOutcomeFailure(error)\n }\n },\n\n async fetch(request: FetchRequest, context): Promise<FetchOutcome> {\n if (apiKey.length === 0) return { status: 'unavailable', reason: 'Firecrawl API key is not set' }\n try {\n const response = await post('/v1/scrape', { url: request.url, formats: ['markdown'] }, context)\n const parsed = scrapeResponseSchema.safeParse(parseJson(response.body))\n const data = parsed.success ? parsed.data.data : null\n const text = data?.markdown?.trim() ?? ''\n if (text.length === 0) {\n return { status: 'error', reason: 'Firecrawl returned no readable content', retriable: false }\n }\n return {\n status: 'ok',\n page: {\n url: data?.metadata?.sourceURL ?? request.url,\n title: data?.metadata?.title ?? null,\n text,\n contentType: 'text/markdown',\n status: data?.metadata?.statusCode ?? 200,\n truncated: false,\n // Firecrawl renders server-side, so content that needed JavaScript\n // arrives without us running a browser at all.\n renderedWith: 'browser',\n },\n }\n } catch (error) {\n return toOutcomeFailure(error)\n }\n },\n\n async healthCheck(context) {\n if (apiKey.length === 0) return { ok: false, detail: 'Firecrawl API key is not set' }\n try {\n await post('/v1/search', { query: 'open mercato health check', limit: 1 }, context)\n return { ok: true }\n } catch (error) {\n return { ok: false, detail: error instanceof Error ? error.message : String(error) }\n }\n },\n }\n}\n\nexport const firecrawlAdapterModule = defineAdapterModule({\n id: 'firecrawl',\n kind: 'api',\n contractVersion: CONTRACT_VERSION,\n optionsSchema: firecrawlOptionsSchema,\n createAdapter: createFirecrawlAdapter,\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ import {
2
+ createFirecrawlAdapter,
3
+ firecrawlAdapterModule,
4
+ firecrawlOptionsSchema
5
+ } from "./adapter.js";
6
+ import { firecrawlAdapterModule as firecrawlAdapterModule2 } from "./adapter.js";
7
+ export {
8
+ createFirecrawlAdapter,
9
+ firecrawlAdapterModule2 as default,
10
+ firecrawlAdapterModule,
11
+ firecrawlOptionsSchema
12
+ };
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export {\n createFirecrawlAdapter,\n firecrawlAdapterModule,\n firecrawlOptionsSchema,\n type FirecrawlOptions,\n} from './adapter'\n\nexport { firecrawlAdapterModule as default } from './adapter'\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAEP,SAAmC,0BAA1BA,+BAAyC;",
6
+ "names": ["firecrawlAdapterModule"]
7
+ }
@@ -0,0 +1,28 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ preset: 'ts-jest',
7
+ testEnvironment: 'node',
8
+ watchman: false,
9
+ rootDir: '.',
10
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
11
+ moduleNameMapper: {
12
+ '^@open-mercato/web-research/(.*)$': '<rootDir>/../web-research/src/$1',
13
+ '^@open-mercato/web-research$': '<rootDir>/../web-research/src/index.ts',
14
+ },
15
+ transform: {
16
+ '^.+\\.(t|j)sx?$': [
17
+ 'ts-jest',
18
+ {
19
+ tsconfig: {
20
+ rootDir: '.',
21
+ ignoreDeprecations: '6.0',
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
27
+ passWithNoTests: true,
28
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@open-mercato/web-research-firecrawl",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "openMercato": {
8
+ "webResearchAdapter": {
9
+ "id": "firecrawl"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "watch": "node watch.mjs",
15
+ "test": "jest --config jest.config.cjs",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./*.ts": {
24
+ "types": "./src/*.ts",
25
+ "default": "./dist/*.js"
26
+ },
27
+ "./*": {
28
+ "types": [
29
+ "./src/*.ts"
30
+ ],
31
+ "default": "./dist/*.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@open-mercato/web-research": "^0.6.0"
36
+ },
37
+ "dependencies": {
38
+ "zod": "^4.4.3"
39
+ },
40
+ "devDependencies": {
41
+ "@open-mercato/web-research": "0.7.1-develop.7194.1.ab4fc81f82",
42
+ "@types/jest": "^30.0.0",
43
+ "@types/node": "^26.0.1",
44
+ "esbuild": "^0.28.1",
45
+ "glob": "^13.0.6",
46
+ "jest": "^30.4.2",
47
+ "ts-jest": "^29.4.11",
48
+ "typescript": "^6.0.3"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/open-mercato/open-mercato",
56
+ "directory": "packages/web-research-firecrawl"
57
+ },
58
+ "stableVersion": "0.7.0"
59
+ }
@@ -0,0 +1,120 @@
1
+ import { createStubHttpClient, createTestContext, describeAdapterContract } from '@open-mercato/web-research/testing'
2
+ import { createFirecrawlAdapter, firecrawlAdapterModule } from '../adapter'
3
+
4
+ const SEARCH_BODY = JSON.stringify({
5
+ success: true,
6
+ data: [
7
+ {
8
+ url: 'https://example.com/a',
9
+ title: 'Alpha',
10
+ description: 'Alpha snippet',
11
+ markdown: '# Alpha\n\nBody text.',
12
+ publishedDate: '2026-01-01',
13
+ },
14
+ { url: 'https://example.com/b', title: 'Beta', description: 'Beta snippet' },
15
+ ],
16
+ })
17
+
18
+ describe('firecrawl adapter', () => {
19
+ it('is unavailable without an API key and never calls out', async () => {
20
+ const http = createStubHttpClient(() => ({ body: SEARCH_BODY }))
21
+ const adapter = createFirecrawlAdapter({})
22
+
23
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext({ http }))
24
+
25
+ expect(outcome).toMatchObject({ status: 'unavailable' })
26
+ expect(http.calls).toHaveLength(0)
27
+ })
28
+
29
+ it('maps search results including inline content', async () => {
30
+ const http = createStubHttpClient(() => ({ body: SEARCH_BODY, contentType: 'application/json' }))
31
+ const adapter = createFirecrawlAdapter({ apiKey: 'k' })
32
+
33
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext({ http }))
34
+
35
+ expect(outcome.status).toBe('ok')
36
+ if (outcome.status !== 'ok') return
37
+ expect(outcome.results[0]).toEqual({
38
+ url: 'https://example.com/a',
39
+ title: 'Alpha',
40
+ snippet: 'Alpha snippet',
41
+ content: '# Alpha\n\nBody text.',
42
+ publishedAt: '2026-01-01',
43
+ })
44
+ expect(outcome.results[1].content).toBeNull()
45
+ })
46
+
47
+ it('appends a site filter to the query', async () => {
48
+ const bodies: string[] = []
49
+ const http = createStubHttpClient((_url, init) => {
50
+ bodies.push(String(init?.body ?? ''))
51
+ return { body: SEARCH_BODY }
52
+ })
53
+ const adapter = createFirecrawlAdapter({ apiKey: 'k' })
54
+
55
+ await adapter.search({ query: 'pricing', limit: 5, site: 'example.com' }, createTestContext({ http }))
56
+
57
+ expect(JSON.parse(bodies[0]).query).toBe('pricing site:example.com')
58
+ })
59
+
60
+ it('reports an unrecognized payload as an error rather than crashing', async () => {
61
+ const http = createStubHttpClient(() => ({ body: '{"unexpected":true,"data":"nope"}' }))
62
+ const adapter = createFirecrawlAdapter({ apiKey: 'k' })
63
+
64
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext({ http }))
65
+
66
+ expect(outcome).toMatchObject({ status: 'error' })
67
+ })
68
+
69
+ it('returns empty rather than ok when the vendor found nothing', async () => {
70
+ const http = createStubHttpClient(() => ({ body: JSON.stringify({ success: true, data: [] }) }))
71
+ const adapter = createFirecrawlAdapter({ apiKey: 'k' })
72
+
73
+ const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext({ http }))
74
+
75
+ expect(outcome.status).toBe('empty')
76
+ })
77
+
78
+ it('scrapes a page into readable text', async () => {
79
+ const http = createStubHttpClient(() => ({
80
+ body: JSON.stringify({
81
+ success: true,
82
+ data: {
83
+ markdown: 'Rendered content',
84
+ metadata: { title: 'Title', sourceURL: 'https://example.com/a', statusCode: 200 },
85
+ },
86
+ }),
87
+ }))
88
+ const adapter = createFirecrawlAdapter({ apiKey: 'k' })
89
+
90
+ const outcome = await adapter.fetch?.({ url: 'https://example.com/a' }, createTestContext({ http }))
91
+
92
+ expect(outcome).toMatchObject({
93
+ status: 'ok',
94
+ page: { title: 'Title', text: 'Rendered content', renderedWith: 'browser' },
95
+ })
96
+ })
97
+
98
+ it('sends the API key as a bearer token', async () => {
99
+ const seen: Array<Record<string, string> | undefined> = []
100
+ const http = createStubHttpClient((_url, init) => {
101
+ seen.push(init?.headers as Record<string, string> | undefined)
102
+ return { body: SEARCH_BODY }
103
+ })
104
+ const adapter = createFirecrawlAdapter({ apiKey: 'secret-key' })
105
+
106
+ await adapter.search({ query: 'x', limit: 5 }, createTestContext({ http }))
107
+
108
+ expect(seen[0]?.authorization).toBe('Bearer secret-key')
109
+ })
110
+ })
111
+
112
+ describeAdapterContract(
113
+ firecrawlAdapterModule,
114
+ {
115
+ unconfiguredOptions: {},
116
+ configuredOptions: { apiKey: 'test-key' },
117
+ context: () => createTestContext({ http: createStubHttpClient(() => ({ body: SEARCH_BODY })) }),
118
+ },
119
+ { describe, it, expect: expect as never },
120
+ )
package/src/adapter.ts ADDED
@@ -0,0 +1,181 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk,
7
+ stripTrailingSlashes,
8
+ toOutcomeFailure,
9
+ type AdapterContext,
10
+ type FetchOutcome,
11
+ type FetchRequest,
12
+ type RawResult,
13
+ type SearchAdapter,
14
+ type SearchOutcome,
15
+ } from '@open-mercato/web-research'
16
+ import { z } from 'zod'
17
+
18
+ export const firecrawlOptionsSchema = z.object({
19
+ apiKey: z.string().min(1).optional(),
20
+ baseUrl: z.url().optional(),
21
+ /** Ask Firecrawl to return page content inline, sparing a second round trip. */
22
+ includeContent: z.boolean().optional(),
23
+ timeoutMs: z.number().int().min(1000).max(120_000).optional(),
24
+ })
25
+
26
+ export type FirecrawlOptions = z.infer<typeof firecrawlOptionsSchema>
27
+
28
+ const DEFAULT_BASE_URL = 'https://api.firecrawl.dev'
29
+ const DEFAULT_TIMEOUT_MS = 20_000
30
+
31
+ const searchResponseSchema = z.object({
32
+ success: z.boolean().optional(),
33
+ data: z
34
+ .array(
35
+ z.object({
36
+ url: z.string(),
37
+ title: z.string().nullish(),
38
+ description: z.string().nullish(),
39
+ markdown: z.string().nullish(),
40
+ publishedDate: z.string().nullish(),
41
+ }),
42
+ )
43
+ .nullish(),
44
+ })
45
+
46
+ const scrapeResponseSchema = z.object({
47
+ success: z.boolean().optional(),
48
+ data: z
49
+ .object({
50
+ markdown: z.string().nullish(),
51
+ html: z.string().nullish(),
52
+ metadata: z
53
+ .object({
54
+ title: z.string().nullish(),
55
+ sourceURL: z.string().nullish(),
56
+ statusCode: z.number().nullish(),
57
+ })
58
+ .nullish(),
59
+ })
60
+ .nullish(),
61
+ })
62
+
63
+ function parseJson(body: string): unknown {
64
+ try {
65
+ return JSON.parse(body)
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ export function createFirecrawlAdapter(options: FirecrawlOptions): SearchAdapter {
72
+ const apiKey = options.apiKey?.trim() ?? ''
73
+ const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL)
74
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
75
+ const includeContent = options.includeContent ?? true
76
+
77
+ const post = (path: string, payload: unknown, context: AdapterContext) =>
78
+ context.http.request(`${baseUrl}${path}`, {
79
+ method: 'POST',
80
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
81
+ body: JSON.stringify(payload),
82
+ accept: ['application/json'],
83
+ timeoutMs,
84
+ signal: context.signal,
85
+ })
86
+
87
+ return {
88
+ id: 'firecrawl',
89
+ kind: 'api',
90
+ capabilities: {
91
+ search: true,
92
+ fetch: true,
93
+ snippets: true,
94
+ content: includeContent,
95
+ freshness: false,
96
+ siteFilter: true,
97
+ cost: 'metered',
98
+ },
99
+ /** `billable` — issues a real /v1/search query. */
100
+ probeCost: 'billable' as const,
101
+ readiness: () => (apiKey.length > 0 ? READY : notReady('Firecrawl API key is not set')),
102
+
103
+ async search(request, context): Promise<SearchOutcome> {
104
+ if (apiKey.length === 0) return { status: 'unavailable', reason: 'Firecrawl API key is not set' }
105
+ try {
106
+ const query = request.site ? `${request.query} site:${request.site}` : request.query
107
+ const response = await post(
108
+ '/v1/search',
109
+ {
110
+ query,
111
+ limit: request.limit,
112
+ ...(includeContent ? { scrapeOptions: { formats: ['markdown'] } } : {}),
113
+ },
114
+ context,
115
+ )
116
+ const parsed = searchResponseSchema.safeParse(parseJson(response.body))
117
+ if (!parsed.success) {
118
+ return { status: 'error', reason: 'Firecrawl returned an unrecognized response shape', retriable: false }
119
+ }
120
+ const results: RawResult[] = (parsed.data.data ?? []).map((item) => ({
121
+ url: item.url,
122
+ title: item.title ?? null,
123
+ snippet: item.description ?? null,
124
+ content: item.markdown ?? null,
125
+ publishedAt: item.publishedDate ?? null,
126
+ }))
127
+ context.report('ok', `Firecrawl returned ${results.length} result(s)`, { resultCount: results.length })
128
+ return searchOk(results)
129
+ } catch (error) {
130
+ return toOutcomeFailure(error)
131
+ }
132
+ },
133
+
134
+ async fetch(request: FetchRequest, context): Promise<FetchOutcome> {
135
+ if (apiKey.length === 0) return { status: 'unavailable', reason: 'Firecrawl API key is not set' }
136
+ try {
137
+ const response = await post('/v1/scrape', { url: request.url, formats: ['markdown'] }, context)
138
+ const parsed = scrapeResponseSchema.safeParse(parseJson(response.body))
139
+ const data = parsed.success ? parsed.data.data : null
140
+ const text = data?.markdown?.trim() ?? ''
141
+ if (text.length === 0) {
142
+ return { status: 'error', reason: 'Firecrawl returned no readable content', retriable: false }
143
+ }
144
+ return {
145
+ status: 'ok',
146
+ page: {
147
+ url: data?.metadata?.sourceURL ?? request.url,
148
+ title: data?.metadata?.title ?? null,
149
+ text,
150
+ contentType: 'text/markdown',
151
+ status: data?.metadata?.statusCode ?? 200,
152
+ truncated: false,
153
+ // Firecrawl renders server-side, so content that needed JavaScript
154
+ // arrives without us running a browser at all.
155
+ renderedWith: 'browser',
156
+ },
157
+ }
158
+ } catch (error) {
159
+ return toOutcomeFailure(error)
160
+ }
161
+ },
162
+
163
+ async healthCheck(context) {
164
+ if (apiKey.length === 0) return { ok: false, detail: 'Firecrawl API key is not set' }
165
+ try {
166
+ await post('/v1/search', { query: 'open mercato health check', limit: 1 }, context)
167
+ return { ok: true }
168
+ } catch (error) {
169
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) }
170
+ }
171
+ },
172
+ }
173
+ }
174
+
175
+ export const firecrawlAdapterModule = defineAdapterModule({
176
+ id: 'firecrawl',
177
+ kind: 'api',
178
+ contractVersion: CONTRACT_VERSION,
179
+ optionsSchema: firecrawlOptionsSchema,
180
+ createAdapter: createFirecrawlAdapter,
181
+ })
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export {
2
+ createFirecrawlAdapter,
3
+ firecrawlAdapterModule,
4
+ firecrawlOptionsSchema,
5
+ type FirecrawlOptions,
6
+ } from './adapter'
7
+
8
+ export { firecrawlAdapterModule as default } from './adapter'
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { watch } from '../../scripts/watch.mjs'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)