From 60925e5e9fbf79b3febc1c223361bc1f279d1bb2 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 14 May 2026 14:33:29 -0400 Subject: [PATCH 1/2] Nick: search feedback --- skills/firecrawl-cli/SKILL.md | 19 ++ skills/firecrawl-search/SKILL.md | 65 ++++ src/__tests__/commands/search.test.ts | 420 +++++++++++++------------ src/commands/search-feedback.ts | 422 ++++++++++++++++++++++++++ src/commands/search.ts | 48 +-- src/commands/status.ts | 7 + src/index.ts | 90 ++++++ 7 files changed, 822 insertions(+), 249 deletions(-) create mode 100644 src/commands/search-feedback.ts diff --git a/skills/firecrawl-cli/SKILL.md b/skills/firecrawl-cli/SKILL.md index 8d7e6a7c7..cbd099cca 100644 --- a/skills/firecrawl-cli/SKILL.md +++ b/skills/firecrawl-cli/SKILL.md @@ -129,6 +129,25 @@ jq -r '.data.web[].url' .firecrawl/search.json jq -r '.data.web[] | "\(.title): \(.url)"' .firecrawl/search.json ``` +## After search: send feedback (refunds 1 credit) + +Search costs 2 credits per call. **After you finish using a search result**, send structured feedback in the background. The first feedback per search id refunds 1 credit and feeds search-quality improvements. + +```bash +SEARCH_ID=$(jq -r '.id' .firecrawl/search-react-hooks.json) + +firecrawl search-feedback "$SEARCH_ID" \ + --rating good \ + --valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Authoritative"}]' \ + --missing-content '[{"topic":"useDeferredValue example"},{"topic":"Server Components hooks"}]' \ + --query-suggestions "Boost react.dev for react-hooks queries" \ + --silent & +``` + +The most useful field is `--missing-content`: an _array_ of specific pieces of content you expected to find but didn't. Use one entry per missing topic. Bad/partial feedback with detailed `--missing-content` is just as valuable as good feedback. + +**Opt out:** `export FIRECRAWL_NO_SEARCH_FEEDBACK=1` makes the CLI skip every feedback call silently. Respect that flag — do not try to work around it. See [firecrawl-search](../firecrawl-search/SKILL.md) for the full pattern. + ## Parallelization Run independent operations in parallel. Check `firecrawl --status` for concurrency limit: diff --git a/skills/firecrawl-search/SKILL.md b/skills/firecrawl-search/SKILL.md index aeea8ed0f..87b426cf9 100644 --- a/skills/firecrawl-search/SKILL.md +++ b/skills/firecrawl-search/SKILL.md @@ -52,6 +52,71 @@ firecrawl search "your query" --sources news --tbs qdr:d -o .firecrawl/news.json - Use `jq` to extract URLs or titles: `jq -r '.data.web[].url' .firecrawl/search.json` - Naming convention: `.firecrawl/search-{query}.json` or `.firecrawl/search-{query}-scraped.json` +## Send feedback after every search (refunds 1 credit) + +Search costs 2 credits. After you've actually used the results (or decided they were useless), send structured feedback with `firecrawl search-feedback `. The first feedback per search refunds 1 credit and helps us improve search quality. **Do this once per search**, in the background, after you finish processing the results — it does not block your main task. + +**Opt out:** if `FIRECRAWL_NO_SEARCH_FEEDBACK=1` (or `FIRECRAWL_DISABLE_SEARCH_FEEDBACK=1`) is set, the CLI silently skips the call and never sends anything. Respect that — do not try to work around it. (Team admins can also disable this server-side; the API will return `feedbackErrorCode: "TEAM_OPTED_OUT"` and the CLI will exit 0 silently.) + +**Rules to know before you call this:** + +- **Time window:** must be sent within ~2 minutes of the search. Late feedback is rejected. +- **`--missing-content` is the most important field.** It's a list of _specific pieces_ of content you expected but did not find. One topic per entry — do not pack multiple topics into one string. These aggregate across teams and tell us what to index next. +- **Substantive content required** (zero-effort feedback is rejected with HTTP 400): + - `good` → must include at least one `--valuable-sources` entry. + - `partial` → must include `--valuable-sources` or `--missing-content`. + - `bad` → must include `--missing-content` or `--query-suggestions`. +- **Daily refund cap (per team, per UTC day, default 100 credits).** Once your team has been refunded 100 credits today, further submissions still record feedback but no longer refund credits. The response includes `creditsRefundedToday` / `dailyRefundCap` / `dailyCapReached`. **When `dailyCapReached: true`, stop calling `search-feedback` for the rest of the UTC day** — it won't refund anything and you're wasting bandwidth. +- **Idempotent:** re-submitting for the same search id returns success but no extra refund. +- **`--silent &`** is the right pattern — exit code 0 even on failure, so a rejected/expired call never crashes your pipeline. + +Read the search response's `id`: + +```bash +SEARCH_ID=$(jq -r '.id' .firecrawl/search-react-hooks.json) +``` + +Then send feedback. Pick the rating that matches what actually happened: + +```bash +# Results were useful, with notes on what was still missing +firecrawl search-feedback "$SEARCH_ID" \ + --rating good \ + --valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Most authoritative"}]' \ + --missing-content '[ + {"topic":"useDeferredValue","description":"No example of useDeferredValue with Suspense"}, + {"topic":"useTransition","description":"No coverage of useTransition for routing"} + ]' \ + --query-suggestions "Boost react.dev for queries about react hooks" \ + --silent & + +# Results were partially useful — multiple missing topics, one entry per topic +firecrawl search-feedback "$SEARCH_ID" \ + --rating partial \ + --missing-content '[ + {"topic":"useDeferredValue"}, + {"topic":"useTransition","description":"Need React 18+ examples"}, + {"topic":"Server Components hooks"} + ]' \ + --silent & + +# Quick form — repeat --missing-content or use comma-separated topics +firecrawl search-feedback "$SEARCH_ID" \ + --rating bad \ + --missing-content "official api reference: missing v2 endpoints" \ + --missing-content "code examples in python" \ + --silent & +``` + +**`--missing-content` accepts:** + +- JSON array of `{topic, description?}` objects (richest, preferred) +- `"topic: description"` strings (shorthand) +- Plain `"topic1, topic2, topic3"` (when you only have topic names) +- Repeated `--missing-content` flags + +`--silent` suppresses output and `&` runs it in the background so feedback never blocks you. + ## See also - [firecrawl-scrape](../firecrawl-scrape/SKILL.md) — scrape a specific URL diff --git a/src/__tests__/commands/search.test.ts b/src/__tests__/commands/search.test.ts index a0950e042..f55968381 100644 --- a/src/__tests__/commands/search.test.ts +++ b/src/__tests__/commands/search.test.ts @@ -19,21 +19,37 @@ vi.mock('../../utils/client', async () => { describe('executeSearch', () => { let mockClient: any; + let mockHttpPost: ReturnType; + + // Wrap a payload in the axios envelope returned by `client.http.post`. + // Mirrors the `/v2/search` response shape: + // { success, data: { web?, news?, images? }, id?, creditsUsed?, warning? } + const mockSearchResponse = ( + payload: Record | any[], + extras: Record = {} + ) => { + const inner: Record = { + success: true, + data: Array.isArray(payload) ? { web: payload } : payload, + ...extras, + }; + return { data: inner }; + }; beforeEach(() => { setupTest(); - // Initialize config with test API key initializeConfig({ apiKey: 'test-api-key', apiUrl: 'https://api.firecrawl.dev', }); - // Create mock client + mockHttpPost = vi.fn(); mockClient = { - search: vi.fn(), + http: { + post: mockHttpPost, + }, }; - // Mock getClient to return our mock vi.mocked(getClient).mockReturnValue(mockClient as any); }); @@ -43,28 +59,33 @@ describe('executeSearch', () => { }); describe('API call generation', () => { - it('should call search with correct query and default options', async () => { - const mockResponse = { - web: [ - { url: 'https://example.com', title: 'Example', description: 'Test' }, - ], - }; - mockClient.search.mockResolvedValue(mockResponse); + it('should call /v2/search with correct query and default options', async () => { + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [ + { + url: 'https://example.com', + title: 'Example', + description: 'Test', + }, + ], + }) + ); await executeSearch({ query: 'test query', }); - expect(mockClient.search).toHaveBeenCalledTimes(1); - expect(mockClient.search).toHaveBeenCalledWith('test query', { + expect(mockHttpPost).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledWith('/v2/search', { + query: 'test query', limit: undefined, integration: 'cli', }); }); it('should pass apiUrl to getClient when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'test query', @@ -78,8 +99,7 @@ describe('executeSearch', () => { }); it('should pass both apiKey and apiUrl to getClient when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'test query', @@ -94,189 +114,193 @@ describe('executeSearch', () => { }); it('should include limit option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'AI news', limit: 10, }); - expect(mockClient.search).toHaveBeenCalledWith( - 'AI news', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'AI news', limit: 10, }) ); }); it('should include sources option when provided', async () => { - const mockResponse = { web: [], images: [], news: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ web: [], images: [], news: [] }) + ); await executeSearch({ query: 'test query', sources: ['web', 'images', 'news'], }); - expect(mockClient.search).toHaveBeenCalledWith( - 'test query', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'test query', sources: [{ type: 'web' }, { type: 'images' }, { type: 'news' }], }) ); }); it('should include single source correctly', async () => { - const mockResponse = { news: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ news: [] })); await executeSearch({ query: 'tech news', sources: ['news'], }); - expect(mockClient.search).toHaveBeenCalledWith( - 'tech news', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'tech news', sources: [{ type: 'news' }], }) ); }); it('should include categories option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'web scraping python', categories: ['github'], }); - expect(mockClient.search).toHaveBeenCalledWith( - 'web scraping python', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'web scraping python', categories: [{ type: 'github' }], }) ); }); it('should include multiple categories correctly', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'transformer architecture', categories: ['research', 'pdf'], }); - expect(mockClient.search).toHaveBeenCalledWith( - 'transformer architecture', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'transformer architecture', categories: [{ type: 'research' }, { type: 'pdf' }], }) ); }); it('should include tbs (time-based search) option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'AI announcements', tbs: 'qdr:d', // Past day }); - expect(mockClient.search).toHaveBeenCalledWith( - 'AI announcements', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'AI announcements', tbs: 'qdr:d', }) ); }); it('should include location option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'restaurants', location: 'San Francisco,California,United States', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'restaurants', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'restaurants', location: 'San Francisco,California,United States', }) ); }); it('should include country option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'local news', country: 'DE', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'local news', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'local news', country: 'DE', }) ); }); it('should include timeout option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'test query', timeout: 30000, }); - expect(mockClient.search).toHaveBeenCalledWith( - 'test query', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'test query', timeout: 30000, }) ); }); it('should include ignoreInvalidUrls option when provided', async () => { - const mockResponse = { web: [] }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'test query', ignoreInvalidUrls: true, }); - expect(mockClient.search).toHaveBeenCalledWith( - 'test query', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'test query', ignoreInvalidURLs: true, }) ); }); it('should include scrape options when scrape is enabled', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', markdown: '# Test' }], - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [{ url: 'https://example.com', markdown: '# Test' }], + }) + ); await executeSearch({ query: 'firecrawl tutorials', scrape: true, }); - expect(mockClient.search).toHaveBeenCalledWith( - 'firecrawl tutorials', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'firecrawl tutorials', scrapeOptions: { formats: [{ type: 'markdown' }], }, @@ -285,10 +309,11 @@ describe('executeSearch', () => { }); it('should include custom scrape formats when provided', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', markdown: '# Test', links: [] }], - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [{ url: 'https://example.com', markdown: '# Test', links: [] }], + }) + ); await executeSearch({ query: 'API docs', @@ -296,9 +321,10 @@ describe('executeSearch', () => { scrapeFormats: ['markdown', 'links'], }); - expect(mockClient.search).toHaveBeenCalledWith( - 'API docs', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'API docs', scrapeOptions: { formats: [{ type: 'markdown' }, { type: 'links' }], }, @@ -307,10 +333,11 @@ describe('executeSearch', () => { }); it('should include onlyMainContent in scrape options when provided', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', markdown: '# Test' }], - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [{ url: 'https://example.com', markdown: '# Test' }], + }) + ); await executeSearch({ query: 'test query', @@ -318,9 +345,10 @@ describe('executeSearch', () => { onlyMainContent: true, }); - expect(mockClient.search).toHaveBeenCalledWith( - 'test query', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ + query: 'test query', scrapeOptions: { formats: [{ type: 'markdown' }], onlyMainContent: true, @@ -330,11 +358,12 @@ describe('executeSearch', () => { }); it('should combine all options correctly', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', markdown: '# Test' }], - news: [{ url: 'https://news.example.com', title: 'News' }], - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [{ url: 'https://example.com', markdown: '# Test' }], + news: [{ url: 'https://news.example.com', title: 'News' }], + }) + ); await executeSearch({ query: 'comprehensive test', @@ -350,7 +379,8 @@ describe('executeSearch', () => { onlyMainContent: true, }); - expect(mockClient.search).toHaveBeenCalledWith('comprehensive test', { + expect(mockHttpPost).toHaveBeenCalledWith('/v2/search', { + query: 'comprehensive test', limit: 20, integration: 'cli', sources: [{ type: 'web' }, { type: 'news' }], @@ -369,45 +399,39 @@ describe('executeSearch', () => { describe('Response handling', () => { it('should return success result with web results', async () => { - const mockResponse = { - web: [ - { - url: 'https://example.com', - title: 'Example', - description: 'Test description', - }, - { - url: 'https://example2.com', - title: 'Example 2', - description: 'Another test', - }, - ], - }; - mockClient.search.mockResolvedValue(mockResponse); + const web = [ + { + url: 'https://example.com', + title: 'Example', + description: 'Test description', + }, + { + url: 'https://example2.com', + title: 'Example 2', + description: 'Another test', + }, + ]; + mockHttpPost.mockResolvedValue(mockSearchResponse({ web })); const result = await executeSearch({ query: 'test query', }); expect(result.success).toBe(true); - expect(result.data).toEqual({ - web: mockResponse.web, - }); + expect(result.data).toEqual({ web }); }); it('should return success result with image results', async () => { - const mockResponse = { - images: [ - { - imageUrl: 'https://example.com/image.jpg', - url: 'https://example.com', - title: 'Image 1', - imageWidth: 800, - imageHeight: 600, - }, - ], - }; - mockClient.search.mockResolvedValue(mockResponse); + const images = [ + { + imageUrl: 'https://example.com/image.jpg', + url: 'https://example.com', + title: 'Image 1', + imageWidth: 800, + imageHeight: 600, + }, + ]; + mockHttpPost.mockResolvedValue(mockSearchResponse({ images })); const result = await executeSearch({ query: 'landscapes', @@ -415,23 +439,19 @@ describe('executeSearch', () => { }); expect(result.success).toBe(true); - expect(result.data).toEqual({ - images: mockResponse.images, - }); + expect(result.data).toEqual({ images }); }); it('should return success result with news results', async () => { - const mockResponse = { - news: [ - { - url: 'https://news.example.com', - title: 'Breaking News', - snippet: 'Something happened', - date: '2024-01-15', - }, - ], - }; - mockClient.search.mockResolvedValue(mockResponse); + const news = [ + { + url: 'https://news.example.com', + title: 'Breaking News', + snippet: 'Something happened', + date: '2024-01-15', + }, + ]; + mockHttpPost.mockResolvedValue(mockSearchResponse({ news })); const result = await executeSearch({ query: 'tech news', @@ -439,13 +459,11 @@ describe('executeSearch', () => { }); expect(result.success).toBe(true); - expect(result.data).toEqual({ - news: mockResponse.news, - }); + expect(result.data).toEqual({ news }); }); it('should handle combined results from multiple sources', async () => { - const mockResponse = { + const payload = { web: [{ url: 'https://example.com', title: 'Web Result' }], images: [ { @@ -455,7 +473,7 @@ describe('executeSearch', () => { ], news: [{ url: 'https://news.example.com', title: 'News' }], }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse(payload)); const result = await executeSearch({ query: 'machine learning', @@ -463,24 +481,21 @@ describe('executeSearch', () => { }); expect(result.success).toBe(true); - expect(result.data).toEqual({ - web: mockResponse.web, - images: mockResponse.images, - news: mockResponse.news, - }); + expect(result.data).toEqual(payload); }); it('should handle response with scraped content', async () => { - const mockResponse = { - web: [ - { - url: 'https://example.com', - title: 'Example', - markdown: '# Page Content\n\nThis is the content.', - }, - ], - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ + web: [ + { + url: 'https://example.com', + title: 'Example', + markdown: '# Page Content\n\nThis is the content.', + }, + ], + }) + ); const result = await executeSearch({ query: 'test', @@ -493,45 +508,13 @@ describe('executeSearch', () => { ); }); - it('should handle nested data response format', async () => { - const mockResponse = { - data: { - web: [{ url: 'https://example.com', title: 'Test' }], - }, - }; - mockClient.search.mockResolvedValue(mockResponse); - - const result = await executeSearch({ - query: 'test', - }); - - expect(result.success).toBe(true); - expect(result.data?.web).toEqual([ - { url: 'https://example.com', title: 'Test' }, - ]); - }); - - it('should handle array response format (legacy)', async () => { - const mockResponse = [ - { url: 'https://example.com', title: 'Test 1' }, - { url: 'https://example2.com', title: 'Test 2' }, - ]; - mockClient.search.mockResolvedValue(mockResponse); - - const result = await executeSearch({ - query: 'test', - }); - - expect(result.success).toBe(true); - expect(result.data?.web).toEqual(mockResponse); - }); - it('should include warning in result when present', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', title: 'Test' }], - warning: 'Some warning message', - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse( + { web: [{ url: 'https://example.com', title: 'Test' }] }, + { warning: 'Some warning message' } + ) + ); const result = await executeSearch({ query: 'test', @@ -542,11 +525,12 @@ describe('executeSearch', () => { }); it('should include id in result when present', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', title: 'Test' }], - id: 'search-123', - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse( + { web: [{ url: 'https://example.com', title: 'Test' }] }, + { id: 'search-123' } + ) + ); const result = await executeSearch({ query: 'test', @@ -557,11 +541,12 @@ describe('executeSearch', () => { }); it('should include creditsUsed in result when present', async () => { - const mockResponse = { - web: [{ url: 'https://example.com', title: 'Test' }], - creditsUsed: 5, - }; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue( + mockSearchResponse( + { web: [{ url: 'https://example.com', title: 'Test' }] }, + { creditsUsed: 5 } + ) + ); const result = await executeSearch({ query: 'test', @@ -572,8 +557,7 @@ describe('executeSearch', () => { }); it('should handle empty results', async () => { - const mockResponse = {}; - mockClient.search.mockResolvedValue(mockResponse); + mockHttpPost.mockResolvedValue(mockSearchResponse({})); const result = await executeSearch({ query: 'nonexistent content xyz123', @@ -585,7 +569,7 @@ describe('executeSearch', () => { it('should return error result when search fails', async () => { const errorMessage = 'API Error: Rate limit exceeded'; - mockClient.search.mockRejectedValue(new Error(errorMessage)); + mockHttpPost.mockRejectedValue(new Error(errorMessage)); const result = await executeSearch({ query: 'test query', @@ -598,7 +582,7 @@ describe('executeSearch', () => { }); it('should handle non-Error exceptions', async () => { - mockClient.search.mockRejectedValue('String error'); + mockHttpPost.mockRejectedValue('String error'); const result = await executeSearch({ query: 'test query', @@ -611,71 +595,71 @@ describe('executeSearch', () => { describe('Time-based search parameters', () => { it('should support qdr:h for past hour', async () => { - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'breaking news', tbs: 'qdr:h', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'breaking news', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ tbs: 'qdr:h' }) ); }); it('should support qdr:d for past day', async () => { - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'AI announcements', tbs: 'qdr:d', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'AI announcements', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ tbs: 'qdr:d' }) ); }); it('should support qdr:w for past week', async () => { - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'tech news', tbs: 'qdr:w', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'tech news', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ tbs: 'qdr:w' }) ); }); it('should support qdr:m for past month', async () => { - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'startup funding', tbs: 'qdr:m', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'startup funding', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ tbs: 'qdr:m' }) ); }); it('should support qdr:y for past year', async () => { - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); await executeSearch({ query: 'yearly review', tbs: 'qdr:y', }); - expect(mockClient.search).toHaveBeenCalledWith( - 'yearly review', + expect(mockHttpPost).toHaveBeenCalledWith( + '/v2/search', expect.objectContaining({ tbs: 'qdr:y' }) ); }); @@ -688,7 +672,9 @@ describe('executeSearch', () => { 'images', 'news', ]; - mockClient.search.mockResolvedValue({ web: [], images: [], news: [] }); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ web: [], images: [], news: [] }) + ); for (const source of sourceList) { const result = await executeSearch({ @@ -705,7 +691,7 @@ describe('executeSearch', () => { 'research', 'pdf', ]; - mockClient.search.mockResolvedValue({ web: [] }); + mockHttpPost.mockResolvedValue(mockSearchResponse({ web: [] })); for (const category of categoryList) { const result = await executeSearch({ @@ -725,9 +711,9 @@ describe('executeSearch', () => { ]; for (const format of formatList) { - mockClient.search.mockResolvedValue({ - web: [{ url: 'https://example.com' }], - }); + mockHttpPost.mockResolvedValue( + mockSearchResponse({ web: [{ url: 'https://example.com' }] }) + ); const result = await executeSearch({ query: 'test', scrape: true, diff --git a/src/commands/search-feedback.ts b/src/commands/search-feedback.ts new file mode 100644 index 000000000..35898e78b --- /dev/null +++ b/src/commands/search-feedback.ts @@ -0,0 +1,422 @@ +import { getConfig, validateConfig } from '../utils/config'; +import { getClient } from '../utils/client'; + +export type SearchFeedbackRating = 'good' | 'bad' | 'partial'; + +export interface ValuableSourceInput { + url: string; + reason?: string; +} + +export interface MissingContentInput { + topic: string; + description?: string; +} + +export interface SearchFeedbackOptions { + searchId: string; + rating: SearchFeedbackRating; + valuableSources?: ValuableSourceInput[]; + missingContent?: MissingContentInput[]; + querySuggestions?: string; + apiKey?: string; + apiUrl?: string; + output?: string; + json?: boolean; + pretty?: boolean; + silent?: boolean; +} + +export type SearchFeedbackErrorCode = + | 'SEARCH_NOT_FOUND' + | 'FEEDBACK_WINDOW_EXPIRED' + | 'SEARCH_FAILED' + | 'PREVIEW_TEAM_NOT_ALLOWED' + | 'TEAM_OPTED_OUT' + | 'INVALID_BODY' + | 'DB_DISABLED' + | 'INTERNAL'; + +export interface SearchFeedbackResult { + success: boolean; + feedbackId?: string; + creditsRefunded?: number; + creditsRefundedToday?: number; + dailyRefundCap?: number; + dailyCapReached?: boolean; + alreadySubmitted?: boolean; + warning?: string; + error?: string; + errorCode?: SearchFeedbackErrorCode; + // True when the call was skipped due to local or team opt-out; `success` + // is also `true` so background callers (`--silent &`) do not error. + disabled?: boolean; + disabledSource?: 'env' | 'team'; +} + +export const SEARCH_FEEDBACK_OPT_OUT_ENV_VARS = [ + 'FIRECRAWL_NO_SEARCH_FEEDBACK', + 'FIRECRAWL_DISABLE_SEARCH_FEEDBACK', +] as const; + +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); + +export function isSearchFeedbackDisabledLocally( + env: NodeJS.ProcessEnv = process.env +): boolean { + for (const key of SEARCH_FEEDBACK_OPT_OUT_ENV_VARS) { + const value = env[key]; + if (typeof value === 'string' && TRUTHY.has(value.trim().toLowerCase())) { + return true; + } + } + return false; +} + +const DEFAULT_API_URL = 'https://api.firecrawl.dev'; + +export async function executeSearchFeedback( + options: SearchFeedbackOptions +): Promise { + if (isSearchFeedbackDisabledLocally()) { + return { + success: true, + disabled: true, + disabledSource: 'env', + creditsRefunded: 0, + warning: + 'Search feedback disabled by FIRECRAWL_NO_SEARCH_FEEDBACK; no data was sent.', + }; + } + + try { + if (options.apiKey || options.apiUrl) { + getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); + } + + const config = getConfig(); + const apiKey = options.apiKey || config.apiKey; + validateConfig(apiKey); + + const apiUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace( + /\/$/, + '' + ); + + const url = `${apiUrl}/v2/search/${encodeURIComponent(options.searchId)}/feedback`; + const body: Record = { + rating: options.rating, + origin: 'cli', + integration: 'cli', + }; + + if (options.valuableSources && options.valuableSources.length > 0) { + body.valuableSources = options.valuableSources + .filter((s) => !!s.url) + .map((s) => ({ + url: s.url, + ...(s.reason ? { reason: s.reason } : {}), + })); + } + if (options.missingContent && options.missingContent.length > 0) { + body.missingContent = options.missingContent + .filter((m) => !!m.topic) + .map((m) => ({ + topic: m.topic, + ...(m.description ? { description: m.description } : {}), + })); + } + if (options.querySuggestions) { + body.querySuggestions = options.querySuggestions; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data: Record = await response + .json() + .catch(() => ({}) as Record); + + if (!response.ok || data?.success !== true) { + const errorMessage = + (typeof data?.error === 'string' && data.error) || + `HTTP ${response.status}: ${response.statusText}`; + const errorCode = + typeof data?.feedbackErrorCode === 'string' + ? (data.feedbackErrorCode as SearchFeedbackErrorCode) + : undefined; + + if (errorCode === 'TEAM_OPTED_OUT') { + return { + success: true, + disabled: true, + disabledSource: 'team', + creditsRefunded: 0, + warning: + 'Search feedback is disabled for this team. Contact support to re-enable.', + }; + } + + return { success: false, error: errorMessage, errorCode }; + } + + return { + success: true, + feedbackId: data.feedbackId, + creditsRefunded: data.creditsRefunded ?? 0, + creditsRefundedToday: + typeof data.creditsRefundedToday === 'number' + ? data.creditsRefundedToday + : undefined, + dailyRefundCap: + typeof data.dailyRefundCap === 'number' + ? data.dailyRefundCap + : undefined, + dailyCapReached: data.dailyCapReached === true, + alreadySubmitted: data.alreadySubmitted, + warning: data.warning, + }; + } catch (error: any) { + return { + success: false, + error: error?.message || 'Unknown error occurred', + }; + } +} + +function formatReadable(result: SearchFeedbackResult): string { + const lines: string[] = []; + if (result.alreadySubmitted) { + lines.push(`Feedback already submitted for this search.`); + } else { + lines.push(`Feedback recorded.`); + } + if (result.feedbackId) { + lines.push(`Feedback ID: ${result.feedbackId}`); + } + lines.push(`Credits refunded: ${result.creditsRefunded ?? 0}`); + if ( + typeof result.creditsRefundedToday === 'number' && + typeof result.dailyRefundCap === 'number' + ) { + lines.push( + `Refunds today: ${result.creditsRefundedToday} / ${result.dailyRefundCap}` + ); + } + if (result.dailyCapReached) { + lines.push( + 'Daily refund cap reached — further /feedback calls today will not refund credits.' + ); + } + if (result.warning) { + lines.push(`Warning: ${result.warning}`); + } + return lines.join('\n') + '\n'; +} + +export async function handleSearchFeedbackCommand( + options: SearchFeedbackOptions +): Promise { + const result = await executeSearchFeedback(options); + + if (result.disabled) { + if (options.silent) { + process.exit(0); + } + if (result.disabledSource === 'env') { + console.error( + 'Search feedback is disabled (FIRECRAWL_NO_SEARCH_FEEDBACK is set). ' + + 'Nothing was sent. Unset the env var to re-enable.' + ); + } else { + console.error( + result.warning ?? 'Search feedback is disabled for this team.' + ); + } + process.exit(0); + } + + if (!result.success) { + // --silent always exits 0 so background pipelines don't crash; only + // surface 5xx-class errors to stderr when explicit debugging is on. + if (options.silent) { + const noisy: SearchFeedbackErrorCode[] = ['INTERNAL', 'DB_DISABLED']; + if ( + result.errorCode && + noisy.includes(result.errorCode) && + process.env.FIRECRAWL_SEARCH_FEEDBACK_DEBUG === '1' + ) { + console.error( + `firecrawl search-feedback: ${result.errorCode}: ${result.error}` + ); + } + process.exit(0); + } + console.error('Error:', result.error); + if (result.errorCode) { + console.error(`Code: ${result.errorCode}`); + } + process.exit(1); + } + + if (options.silent) { + return; + } + + let outputContent: string; + if (options.json || options.pretty) { + const json: Record = { + success: true, + feedbackId: result.feedbackId, + creditsRefunded: result.creditsRefunded ?? 0, + ...(typeof result.creditsRefundedToday === 'number' + ? { creditsRefundedToday: result.creditsRefundedToday } + : {}), + ...(typeof result.dailyRefundCap === 'number' + ? { dailyRefundCap: result.dailyRefundCap } + : {}), + ...(result.dailyCapReached ? { dailyCapReached: true } : {}), + ...(result.alreadySubmitted ? { alreadySubmitted: true } : {}), + ...(result.warning ? { warning: result.warning } : {}), + }; + outputContent = options.pretty + ? JSON.stringify(json, null, 2) + : JSON.stringify(json); + } else { + outputContent = formatReadable(result); + } + + if (options.output) { + const fs = await import('fs'); + const path = await import('path'); + const dir = path.dirname(options.output); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(options.output, outputContent, 'utf-8'); + console.error(`Output written to: ${options.output}`); + } else { + if (!outputContent.endsWith('\n')) outputContent += '\n'; + process.stdout.write(outputContent); + } +} + +export function parseValuableSourcesArg( + raw: string | undefined +): ValuableSourceInput[] | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + const cleaned: ValuableSourceInput[] = []; + for (const entry of arr) { + if ( + entry && + typeof entry === 'object' && + typeof entry.url === 'string' + ) { + cleaned.push({ + url: entry.url, + ...(typeof entry.reason === 'string' + ? { reason: entry.reason } + : {}), + }); + } else if (typeof entry === 'string') { + cleaned.push({ url: entry }); + } + } + return cleaned.length > 0 ? cleaned : undefined; + } catch { + throw new Error( + '--valuable-sources must be valid JSON or a comma-separated list of URLs.' + ); + } + } + + return trimmed + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0) + .map((url) => ({ url })); +} + +// Accepts JSON arrays/objects, "topic: description" strings, comma- +// separated topic lists, or repeated values. Caps at 20 entries. +export function parseMissingContentArg( + raw: string | string[] | undefined +): MissingContentInput[] | undefined { + if (!raw) return undefined; + const inputs = Array.isArray(raw) ? raw : [raw]; + const out: MissingContentInput[] = []; + + for (const value of inputs) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + for (const entry of arr) { + if (typeof entry === 'string') { + const topic = entry.trim(); + if (topic) out.push({ topic }); + } else if ( + entry && + typeof entry === 'object' && + typeof entry.topic === 'string' + ) { + const topic = entry.topic.trim(); + if (!topic) continue; + const description = + typeof entry.description === 'string' + ? entry.description.trim() + : undefined; + out.push({ + topic, + ...(description ? { description } : {}), + }); + } + } + continue; + } catch { + throw new Error( + '--missing-content must be valid JSON, "topic: description" form, or a comma-separated topic list.' + ); + } + } + + for (const part of trimmed + .split(',') + .map((p) => p.trim()) + .filter(Boolean)) { + const colonIdx = part.indexOf(':'); + if (colonIdx > 0) { + const topic = part.slice(0, colonIdx).trim(); + const description = part.slice(colonIdx + 1).trim(); + if (topic) { + out.push({ + topic, + ...(description ? { description } : {}), + }); + } + } else { + out.push({ topic: part }); + } + } + } + + if (out.length === 0) return undefined; + return out.slice(0, 20); +} diff --git a/src/commands/search.ts b/src/commands/search.ts index 68c16102a..8756829e1 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -90,44 +90,28 @@ export async function executeSearch( searchParams.scrapeOptions = scrapeOptions; } - // Execute search - const result = await app.search(options.query, searchParams); + // Call /v2/search through the SDK's HTTP layer (auth + retries) instead + // of `app.search()` so we keep the full response envelope. The high-level + // `search()` helper drops `id` and `creditsUsed`, which breaks the + // `firecrawl search-feedback ` workflow that consumers rely on. + const httpResponse = await (app as any).http.post('/v2/search', { + query: options.query, + ...searchParams, + }); + const envelope = (httpResponse?.data ?? {}) as Record; + const payload = (envelope.data ?? {}) as Record; - // Handle the response - the SDK returns the data directly or wrapped const data: SearchResultData = {}; - - // Check if result has the expected structure - if (result) { - // Handle web results - if (result.web || (result as any).data?.web) { - data.web = (result.web || - (result as any).data?.web) as WebSearchResult[]; - } - - // Handle image results - if (result.images || (result as any).data?.images) { - data.images = (result.images || - (result as any).data?.images) as ImageSearchResult[]; - } - - // Handle news results - if (result.news || (result as any).data?.news) { - data.news = (result.news || - (result as any).data?.news) as NewsSearchResult[]; - } - - // If result is an array (legacy format), treat as web results - if (Array.isArray(result)) { - data.web = result as WebSearchResult[]; - } - } + if (payload.web) data.web = payload.web as WebSearchResult[]; + if (payload.images) data.images = payload.images as ImageSearchResult[]; + if (payload.news) data.news = payload.news as NewsSearchResult[]; return { success: true, data, - warning: (result as any)?.warning, - id: (result as any)?.id, - creditsUsed: (result as any)?.creditsUsed, + warning: envelope.warning, + id: envelope.id, + creditsUsed: envelope.creditsUsed, }; } catch (error) { return { diff --git a/src/commands/status.ts b/src/commands/status.ts index a08dae76d..5be52f0a6 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -9,6 +9,7 @@ import packageJson from '../../package.json'; import { isAuthenticated } from '../utils/auth'; import { getConfig, validateConfig } from '../utils/config'; import { loadCredentials } from '../utils/credentials'; +import { isSearchFeedbackDisabledLocally } from './search-feedback'; type AuthSource = 'env' | 'stored' | 'none'; @@ -367,5 +368,11 @@ export async function handleStatusCommand(): Promise { ); } + if (isSearchFeedbackDisabledLocally()) { + console.log( + ` ${dim}Search feedback:${reset} ${red}disabled${reset} ${dim}(FIRECRAWL_NO_SEARCH_FEEDBACK is set)${reset}` + ); + } + console.log(''); } diff --git a/src/index.ts b/src/index.ts index 7bb787dd6..bda1cba71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,12 @@ import { handleCrawlCommand } from './commands/crawl'; import { handleMapCommand } from './commands/map'; import { handleParseCommand } from './commands/parse'; import { handleSearchCommand } from './commands/search'; +import { + handleSearchFeedbackCommand, + parseValuableSourcesArg, + parseMissingContentArg, + type SearchFeedbackRating, +} from './commands/search-feedback'; import { handleAgentCommand } from './commands/agent'; import { handleBrowserLaunch, @@ -64,6 +70,7 @@ const AUTH_REQUIRED_COMMANDS = [ 'map', 'parse', 'search', + 'search-feedback', 'agent', 'browser', 'interact', @@ -730,6 +737,88 @@ function createSearchCommand(): Command { return searchCmd; } +/** + * Create the search-feedback command. Used by agents (CLI, MCP, skills) to + * report search-result quality after a `firecrawl search` call. The first + * feedback per search id refunds 1 credit (search costs 2). Re-submitting + * for the same search id is a no-op refund-wise. + */ +function createSearchFeedbackCommand(): Command { + const cmd = new Command('search-feedback') + .description( + 'Send feedback on a previous search result. Refunds 1 credit on first submission.' + ) + .argument('', 'The id returned by `firecrawl search ... --json`') + .requiredOption('--rating ', 'Overall rating: good | bad | partial') + .option( + '--valuable-sources ', + 'Comma-separated URLs OR JSON array of {url, reason} entries' + ) + .option( + '--missing-content ', + 'Specific pieces of content missing from results. ' + + 'Accepts: JSON array of {topic, description} objects, ' + + 'comma-separated topics ("pricing tiers, api rate limits"), ' + + 'or "topic: description" form. Repeat the flag for multiple entries.' + ) + .option( + '--query-suggestions ', + 'How the query or result set could be improved' + ) + .option( + '-k, --api-key ', + 'Firecrawl API key (overrides global --api-key)' + ) + .option('--api-url ', 'API URL (overrides global --api-url)') + .option('-o, --output ', 'Output file path (default: stdout)') + .option('--json', 'Output as compact JSON', false) + .option('--pretty', 'Pretty print JSON output', false) + .option( + '--silent', + 'Suppress output; useful when called in the background by another agent', + false + ) + .action(async (searchId: string, options: any) => { + const rating = String(options.rating || '').toLowerCase(); + if (!['good', 'bad', 'partial'].includes(rating)) { + console.error('Error: --rating must be one of: good, bad, partial'); + process.exit(1); + } + + let valuableSources; + try { + valuableSources = parseValuableSourcesArg(options.valuableSources); + } catch (error: any) { + console.error('Error:', error?.message || 'Invalid --valuable-sources'); + process.exit(1); + } + + let missingContent; + try { + missingContent = parseMissingContentArg(options.missingContent); + } catch (error: any) { + console.error('Error:', error?.message || 'Invalid --missing-content'); + process.exit(1); + } + + await handleSearchFeedbackCommand({ + searchId, + rating: rating as SearchFeedbackRating, + valuableSources, + missingContent, + querySuggestions: options.querySuggestions, + apiKey: options.apiKey, + apiUrl: options.apiUrl, + output: options.output, + json: options.json, + pretty: options.pretty, + silent: options.silent, + }); + }); + + return cmd; +} + /** * Create and configure the agent command */ @@ -1282,6 +1371,7 @@ program.addCommand(createCrawlCommand()); program.addCommand(createMapCommand()); program.addCommand(createParseCommand()); program.addCommand(createSearchCommand()); +program.addCommand(createSearchFeedbackCommand()); program.addCommand(createAgentCommand()); program.addCommand(createInteractCommand()); From 9fad8ca542f664a6468a901343e323b85f158f45 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 14 May 2026 14:34:08 -0400 Subject: [PATCH 2/2] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fcc50c6a7..402520dd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.16.0", + "version": "1.17.0", "description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.", "main": "dist/index.js", "bin": {