@ever-works/firecrawl-plugin 1.0.0

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/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @ever-works/firecrawl-plugin
2
+
3
+ Firecrawl Plugin - Web search and content extraction using the Firecrawl API
4
+
5
+ ## Plugin metadata
6
+
7
+ | Field | Value |
8
+ | ------------ | ----------------------------- |
9
+ | ID | `firecrawl` |
10
+ | Category | `search` |
11
+ | Capabilities | `search`, `content-extractor` |
12
+ | Author | Ever Works Team |
13
+ | License | AGPL-3.0 |
14
+ | Built-in | yes |
15
+ | Auto-enable | no |
16
+
17
+ ## What does Firecrawl do?
18
+
19
+ Firecrawl is a web scraping and search API that can search the web for relevant results and extract clean, well-formatted markdown content from any web page. It handles JavaScript rendering, anti-bot bypasses, and content cleaning automatically.
20
+
21
+ ## Why use it?
22
+
23
+ - **Web search** — search the web and get structured results with content snippets
24
+ - **Clean markdown output** — returns well-structured markdown from any web page
25
+ - **JavaScript rendering** — handles dynamic/SPA pages that simple HTTP fetches miss
26
+ - **Anti-bot handling** — bypasses common protections automatically
27
+ - **Metadata extraction** — captures title, description, and other page metadata
28
+
29
+ ## How it works in Ever Works
30
+
31
+ When enabled, Firecrawl can be used as both a search provider and content extractor during work generation. It searches the web for relevant information and extracts high-quality content from web pages, including JavaScript-heavy sites.
32
+
33
+ ## Getting started
34
+
35
+ 1. Create an account at [firecrawl.dev](https://firecrawl.dev)
36
+ 2. Copy your API key from the Firecrawl dashboard
37
+ 3. Enter the key in the **API Key** field below
38
+ 4. Enable this plugin to use it for search and content extraction
39
+
40
+ ## Settings
41
+
42
+ - **API Key** (`apiKey`) — required, secret. Your Firecrawl API key (also configurable via the `PLUGIN_FIRECRAWL_API_KEY` environment variable).
43
+ - Stored at user scope so each user can supply their own credentials.
44
+ - Configuration mode is `hybrid` — admins can preset values and users may override them.
45
+
46
+ ## Troubleshooting
47
+
48
+ | Symptom | Likely cause | Fix |
49
+ | -------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `401 Unauthorized` / `Authentication failed` | API key missing, revoked, or wrong key entered | Re-enter the **API Key** from the Firecrawl dashboard, or set `PLUGIN_FIRECRAWL_API_KEY` in the host environment for default fallback |
51
+ | `429 Too Many Requests` | Free-tier or paid quota exhausted on Firecrawl | Throttle calls, wait for the quota reset, or upgrade the plan in the Firecrawl dashboard |
52
+ | Empty / sparse results | Query is too restrictive, time-range or domain filters are too narrow | Broaden the query, relax `time_range` / `safesearch` / `include_domains` / `exclude_domains` filters, or raise `max_results` |
53
+ | Plugin not used during work generation | Another search plugin is set as the default for the `search` capability | In **Settings → Plugins**, set `firecrawl` as the default for `search`, or disable competing search plugins |
54
+ | `Failed to extract content` for a URL | Page is gated by login, Cloudflare, or robots.txt; URL is malformed | Verify the URL is publicly reachable; for protected pages enable a different `content-extractor` plugin (`scrapfly`, `notion-extractor`, `pdf-extractor`) |
55
+ | `healthCheck` reports unhealthy | API key invalid OR Firecrawl endpoint unreachable from the host | Verify the key with a manual `curl` against the documented endpoint and confirm outbound HTTPS is allowed by the firewall |
56
+
57
+ ## Local development
58
+
59
+ This plugin ships built-in with the Ever Works platform. To work on it locally from the monorepo root:
60
+
61
+ ```bash
62
+ pnpm install
63
+ pnpm --filter @ever-works/firecrawl-plugin build
64
+ pnpm --filter @ever-works/firecrawl-plugin test
65
+ ```
66
+
67
+ ## Documentation
68
+
69
+ - [Ever Works documentation](https://docs.ever.works)
70
+ - [Ever Works repository](https://github.com/ever-works/ever-works)
71
+ - [Plugin system](../../plugin/README.md)
72
+ - [Firecrawl homepage](https://firecrawl.dev)
73
+
74
+ ## License
75
+
76
+ AGPL-3.0
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@ever-works/firecrawl-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Firecrawl Plugin - Web search and content extraction using the Firecrawl API",
5
+ "author": {
6
+ "name": "Ever Co. LTD",
7
+ "email": "ever@ever.co",
8
+ "url": "https://ever.co"
9
+ },
10
+ "license": "AGPL-3.0",
11
+ "private": false,
12
+ "type": "module",
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "require": "./dist/index.cjs"
21
+ }
22
+ },
23
+ "dependencies": {
24
+ "@mendable/firecrawl-js": "^4.12.1"
25
+ },
26
+ "peerDependencies": {
27
+ "@ever-works/plugin": "1.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsup": "^8.4.0",
32
+ "typescript": "^5.7.3",
33
+ "vitest": "^4.1.0",
34
+ "@ever-works/plugin": "1.1.0"
35
+ },
36
+ "everworks": {
37
+ "plugin": {
38
+ "id": "firecrawl",
39
+ "name": "Firecrawl",
40
+ "version": "1.0.0",
41
+ "category": "search",
42
+ "capabilities": [
43
+ "search",
44
+ "content-extractor"
45
+ ],
46
+ "description": "Web search and content extraction using the Firecrawl API. Search the web and scrape pages for clean markdown content.",
47
+ "author": {
48
+ "name": "Ever Works Team"
49
+ },
50
+ "license": "AGPL-3.0",
51
+ "autoEnable": false,
52
+ "systemPlugin": false,
53
+ "builtIn": true,
54
+ "envVars": [
55
+ {
56
+ "name": "PLUGIN_FIRECRAWL_API_KEY",
57
+ "required": false,
58
+ "secret": true,
59
+ "description": "Firecrawl API key (optional - can be set via admin/user settings)"
60
+ }
61
+ ],
62
+ "distribution": "registry"
63
+ }
64
+ },
65
+ "publishConfig": {
66
+ "access": "restricted",
67
+ "registry": "https://registry.npmjs.org"
68
+ },
69
+ "scripts": {
70
+ "build": "tsc --noEmit && tsup",
71
+ "dev": "tsup --watch",
72
+ "type-check": "tsc --noEmit",
73
+ "clean": "rm -rf dist",
74
+ "test": "vitest run --passWithNoTests",
75
+ "test:watch": "vitest",
76
+ "test:coverage": "vitest run --coverage"
77
+ }
78
+ }
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { PluginContext, SearchOptions, ContentExtractionOptions } from '@ever-works/plugin';
3
+
4
+ const { searchMock, scrapeMock, batchScrapeMock, FirecrawlAppCtorMock } = vi.hoisted(() => {
5
+ const search = vi.fn();
6
+ const scrape = vi.fn();
7
+ const batchScrape = vi.fn();
8
+ const ctor = vi.fn().mockImplementation(function () {
9
+ return { search, scrape, batchScrape };
10
+ });
11
+ return {
12
+ searchMock: search,
13
+ scrapeMock: scrape,
14
+ batchScrapeMock: batchScrape,
15
+ FirecrawlAppCtorMock: ctor
16
+ };
17
+ });
18
+
19
+ vi.mock('@mendable/firecrawl-js', () => ({
20
+ default: FirecrawlAppCtorMock
21
+ }));
22
+
23
+ const { FirecrawlPlugin } = await import('../firecrawl.plugin.js');
24
+
25
+ const buildContext = (settings: Record<string, unknown> = {}): PluginContext =>
26
+ ({
27
+ pluginId: 'firecrawl',
28
+ logger: {
29
+ log: vi.fn(),
30
+ debug: vi.fn(),
31
+ warn: vi.fn(),
32
+ error: vi.fn()
33
+ },
34
+ getSettings: vi.fn().mockResolvedValue(settings)
35
+ }) as unknown as PluginContext;
36
+
37
+ describe('FirecrawlPlugin', () => {
38
+ let plugin: FirecrawlPlugin;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ plugin = new FirecrawlPlugin();
43
+ });
44
+
45
+ describe('metadata', () => {
46
+ it('exposes stable identity fields', () => {
47
+ expect(plugin.id).toBe('firecrawl');
48
+ expect(plugin.name).toBe('Firecrawl');
49
+ expect(plugin.version).toBe('1.0.0');
50
+ expect(plugin.category).toBe('search');
51
+ expect(plugin.providerName).toBe('Firecrawl');
52
+ });
53
+
54
+ it('declares both search and content-extractor capabilities', () => {
55
+ expect(plugin.capabilities).toEqual(['search', 'content-extractor']);
56
+ });
57
+
58
+ it('uses hybrid configuration mode', () => {
59
+ expect(plugin.configurationMode).toBe('hybrid');
60
+ });
61
+ });
62
+
63
+ describe('settingsSchema', () => {
64
+ it('requires apiKey and marks it as user-scoped secret', () => {
65
+ expect(plugin.settingsSchema.required).toContain('apiKey');
66
+ const props = plugin.settingsSchema.properties as Record<string, Record<string, unknown>>;
67
+ expect(props.apiKey['x-secret']).toBe(true);
68
+ expect(props.apiKey['x-envVar']).toBe('PLUGIN_FIRECRAWL_API_KEY');
69
+ });
70
+ });
71
+
72
+ describe('search', () => {
73
+ const opts = (overrides: Partial<SearchOptions> = {}): SearchOptions => ({
74
+ query: 'hello',
75
+ settings: { apiKey: 'k' },
76
+ ...overrides
77
+ });
78
+
79
+ it('throws when apiKey is missing', async () => {
80
+ await expect(plugin.search({ query: 'hello', settings: {} })).rejects.toThrow(/API key not configured/i);
81
+ expect(FirecrawlAppCtorMock).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('maps web results with description+title and derives source hostname', async () => {
85
+ searchMock.mockResolvedValueOnce({
86
+ web: [
87
+ {
88
+ title: 'Example',
89
+ url: 'https://www.example.com/page',
90
+ description: 'Example description'
91
+ }
92
+ ]
93
+ });
94
+ const r = await plugin.search(opts({ limit: 5 }));
95
+
96
+ expect(FirecrawlAppCtorMock).toHaveBeenCalledWith({ apiKey: 'k' });
97
+ expect(searchMock).toHaveBeenCalledWith('hello', { limit: 5 });
98
+ expect(r.results).toHaveLength(1);
99
+ expect(r.results[0]).toMatchObject({
100
+ title: 'Example',
101
+ url: 'https://www.example.com/page',
102
+ snippet: 'Example description',
103
+ position: 1,
104
+ source: 'www.example.com'
105
+ });
106
+ });
107
+
108
+ it('falls back to markdown when description is absent', async () => {
109
+ searchMock.mockResolvedValueOnce({
110
+ web: [{ title: 't', url: 'https://example.com', markdown: '# header' }]
111
+ });
112
+ const r = await plugin.search(opts());
113
+ expect(r.results[0].snippet).toBe('# header');
114
+ });
115
+
116
+ it('handles missing fields gracefully', async () => {
117
+ searchMock.mockResolvedValueOnce({ web: [{}] });
118
+ const r = await plugin.search(opts());
119
+ expect(r.results[0]).toMatchObject({ title: '', url: '', snippet: '' });
120
+ });
121
+
122
+ it('returns an empty result list when web is missing', async () => {
123
+ searchMock.mockResolvedValueOnce({});
124
+ const r = await plugin.search(opts());
125
+ expect(r.results).toEqual([]);
126
+ });
127
+
128
+ it('logs and rethrows on SDK failure', async () => {
129
+ const ctx = buildContext();
130
+ await plugin.onLoad(ctx);
131
+ searchMock.mockRejectedValueOnce(new Error('boom'));
132
+ await expect(plugin.search(opts())).rejects.toThrow(/boom/);
133
+ expect(ctx.logger.error).toHaveBeenCalled();
134
+ });
135
+ });
136
+
137
+ describe('extract', () => {
138
+ const opts = (overrides: Partial<ContentExtractionOptions> = {}): ContentExtractionOptions => ({
139
+ url: 'https://example.com/post',
140
+ settings: { apiKey: 'k' },
141
+ ...overrides
142
+ });
143
+
144
+ it('returns success with markdown, title, wordCount, readingTime', async () => {
145
+ scrapeMock.mockResolvedValueOnce({
146
+ markdown: 'one two three four five',
147
+ metadata: { title: 'Title', url: 'https://example.com/post' }
148
+ });
149
+ const r = await plugin.extract(opts());
150
+ expect(scrapeMock).toHaveBeenCalledWith('https://example.com/post', { formats: ['markdown'] });
151
+ expect(r.success).toBe(true);
152
+ expect(r.title).toBe('Title');
153
+ expect(r.markdown).toBe('one two three four five');
154
+ expect(r.wordCount).toBe(5);
155
+ expect(r.readingTime).toBe(1);
156
+ });
157
+
158
+ it('exposes finalUrl when metadata.url differs', async () => {
159
+ scrapeMock.mockResolvedValueOnce({
160
+ markdown: 'hi',
161
+ metadata: { title: 't', url: 'https://example.com/redirected' }
162
+ });
163
+ const r = await plugin.extract(opts());
164
+ expect(r.finalUrl).toBe('https://example.com/redirected');
165
+ });
166
+
167
+ it('returns failure when markdown is empty', async () => {
168
+ scrapeMock.mockResolvedValueOnce({ markdown: '', metadata: {} });
169
+ const r = await plugin.extract(opts());
170
+ expect(r.success).toBe(false);
171
+ expect(r.error).toMatch(/No content extracted/);
172
+ });
173
+
174
+ it('returns failure when SDK throws', async () => {
175
+ scrapeMock.mockRejectedValueOnce(new Error('extract failed'));
176
+ const r = await plugin.extract(opts());
177
+ expect(r.success).toBe(false);
178
+ expect(r.error).toMatch(/extract failed/);
179
+ });
180
+ });
181
+
182
+ describe('extractBatch', () => {
183
+ it('uses batch API when available', async () => {
184
+ batchScrapeMock.mockResolvedValueOnce({
185
+ data: [
186
+ { markdown: 'one two', metadata: { title: 'A', url: 'https://a' } },
187
+ { markdown: 'three four five', metadata: { title: 'B', url: 'https://b' } }
188
+ ]
189
+ });
190
+ const r = await plugin.extractBatch(['https://a', 'https://b'], { settings: { apiKey: 'k' } });
191
+ expect(r).toHaveLength(2);
192
+ expect(r[0]).toMatchObject({ success: true, url: 'https://a', wordCount: 2 });
193
+ expect(r[1]).toMatchObject({ success: true, url: 'https://b', wordCount: 3 });
194
+ });
195
+
196
+ it('falls back to sequential extract when batch API throws', async () => {
197
+ batchScrapeMock.mockRejectedValueOnce(new Error('batch unavailable'));
198
+ scrapeMock
199
+ .mockResolvedValueOnce({ markdown: 'one two', metadata: { title: 'A', url: 'https://a' } })
200
+ .mockResolvedValueOnce({ markdown: 'three', metadata: { title: 'B', url: 'https://b' } });
201
+
202
+ const r = await plugin.extractBatch(['https://a', 'https://b'], { settings: { apiKey: 'k' } });
203
+ expect(r).toHaveLength(2);
204
+ expect(r[0]).toMatchObject({ success: true, wordCount: 2 });
205
+ expect(r[1]).toMatchObject({ success: true, wordCount: 1 });
206
+ });
207
+
208
+ it('reports per-item failure when batch returns empty markdown', async () => {
209
+ batchScrapeMock.mockResolvedValueOnce({
210
+ data: [{ markdown: '', metadata: { url: 'https://a' } }]
211
+ });
212
+ const r = await plugin.extractBatch(['https://a'], { settings: { apiKey: 'k' } });
213
+ expect(r[0].success).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe('isAvailable', () => {
218
+ it('returns false without context', async () => {
219
+ expect(await plugin.isAvailable()).toBe(false);
220
+ });
221
+
222
+ it('reflects whether settings has an apiKey', async () => {
223
+ await plugin.onLoad(buildContext({ apiKey: 'k' }));
224
+ expect(await plugin.isAvailable()).toBe(true);
225
+
226
+ await plugin.onLoad(buildContext({}));
227
+ expect(await plugin.isAvailable()).toBe(false);
228
+ });
229
+ });
230
+
231
+ describe('validateConnection', () => {
232
+ it('fails fast without apiKey', async () => {
233
+ const r = await plugin.validateConnection({});
234
+ expect(r.success).toBe(false);
235
+ expect(r.message).toMatch(/not configured/i);
236
+ });
237
+
238
+ it('returns success when SDK search works', async () => {
239
+ searchMock.mockResolvedValueOnce({ web: [] });
240
+ const r = await plugin.validateConnection({ apiKey: 'k' });
241
+ expect(r.success).toBe(true);
242
+ });
243
+
244
+ it('returns failure when SDK throws', async () => {
245
+ searchMock.mockRejectedValueOnce(new Error('bad key'));
246
+ const r = await plugin.validateConnection({ apiKey: 'k' });
247
+ expect(r.success).toBe(false);
248
+ });
249
+ });
250
+
251
+ describe('canExtract / getSupportedFormats', () => {
252
+ it('accepts http and https URLs', async () => {
253
+ expect(await plugin.canExtract('https://example.com')).toBe(true);
254
+ expect(await plugin.canExtract('http://example.com')).toBe(true);
255
+ });
256
+
257
+ it('rejects non-http schemes and invalid URLs', async () => {
258
+ expect(await plugin.canExtract('ftp://example.com')).toBe(false);
259
+ expect(await plugin.canExtract('garbage')).toBe(false);
260
+ });
261
+
262
+ it('exposes only the markdown format', () => {
263
+ expect(plugin.getSupportedFormats()).toEqual(['markdown']);
264
+ });
265
+ });
266
+
267
+ describe('lifecycle', () => {
268
+ it('logs on load and clears context on unload', async () => {
269
+ const ctx = buildContext();
270
+ await plugin.onLoad(ctx);
271
+ expect(ctx.logger.log).toHaveBeenCalledWith('Firecrawl Plugin loaded');
272
+ await plugin.onUnload();
273
+ expect(await plugin.isAvailable()).toBe(false);
274
+ });
275
+ });
276
+
277
+ describe('healthCheck + manifest', () => {
278
+ it('reports healthy', async () => {
279
+ const h = await plugin.healthCheck();
280
+ expect(h.status).toBe('healthy');
281
+ });
282
+
283
+ it('returns a manifest aligned with plugin metadata', () => {
284
+ const m = plugin.getManifest();
285
+ expect(m.id).toBe('firecrawl');
286
+ expect(m.category).toBe('search');
287
+ expect(m.capabilities).toEqual(['search', 'content-extractor']);
288
+ expect(m.builtIn).toBe(true);
289
+ });
290
+ });
291
+ });