@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +10 -0
- package/dist/adapter.js +160 -0
- package/dist/adapter.js.map +7 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +7 -0
- package/jest.config.cjs +28 -0
- package/package.json +59 -0
- package/src/__tests__/firecrawl.test.ts +120 -0
- package/src/adapter.ts +181 -0
- package/src/index.ts +8 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +7 -0
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
|
+
})
|
package/dist/adapter.js
ADDED
|
@@ -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
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -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
package/tsconfig.json
ADDED