Skip to content
Merged
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,47 @@ firecrawl search "AI startups funding" --sources news --tbs qdr:w --limit 15

---

### `feedback` - Send endpoint job feedback

Send concise feedback for a completed v2 `search`, `scrape`, `parse`, or `map`
job. For search-result quality, `search-feedback` is still the most guided
command; `feedback` is the generic endpoint/job surface.

```bash
firecrawl feedback scrape 0193f6c5-1234-7890-abcd-1234567890ab \
--rating partial \
--issues missing_markdown \
--tags docs \
--note "The pricing table was missing from the markdown output." \
--url https://example.com/pricing \
--page-numbers 1
```

Keep notes and metadata small. Do not send raw scrape or parse outputs as
feedback.

Set `FIRECRAWL_NO_ENDPOINT_FEEDBACK=1` to make `firecrawl feedback` skip
endpoint feedback calls silently.

#### Feedback Options

| Option | Description |
| -------------------------------- | -------------------------------------------- |
| `--rating <rating>` | Required: `good`, `partial`, or `bad` |
| `--issues <codesOrJson>` | Comma-separated issue codes or JSON array |
| `--tags <codesOrJson>` | Comma-separated tags or JSON array |
| `--note <text>` | Short human-readable feedback |
| `--valuable-sources <json>` | JSON array of `{url, reason}` entries |
| `--missing-content <json>` | JSON array of `{topic, description}` entries |
| `--query-suggestions <text>` | Search/query improvement notes |
| `--url <url>` | Relevant URL for scrape or parse feedback |
| `--page-numbers <numbersOrJson>` | Comma-separated page numbers or JSON array |
| `--metadata <json>` | Small JSON object with extra context |
| `--metadata-file <path>` | Path to small metadata JSON object |
| `--silent` | Suppress output for background agent calls |

---

### `map` - Discover all URLs on a website

Quickly discover all URLs on a website without scraping content.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.19.9",
"version": "1.19.10",
"description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.",
"main": "dist/index.js",
"bin": {
Expand Down
19 changes: 19 additions & 0 deletions skills/firecrawl-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,25 @@ The most useful field is `--missing-content`: an _array_ of specific pieces of c

**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.

## Endpoint job feedback

For non-search endpoint jobs, use `firecrawl feedback <endpoint> <jobId>` to send concise job-level feedback through `/v2/feedback`. Supported endpoints are `search`, `scrape`, `parse`, and `map`.

```bash
firecrawl feedback scrape "$SCRAPE_ID" \
--rating partial \
--issues missing_markdown \
--tags docs \
--note "The pricing table was missing from the markdown output." \
--url "https://example.com/pricing" \
--page-numbers 1 \
--silent &
```

Keep generic feedback small: issue codes, tags, short notes, URLs, page numbers, and small metadata objects. Do not send raw scrape/parse outputs or full page contents as feedback.

**Opt out:** `export FIRECRAWL_NO_ENDPOINT_FEEDBACK=1` makes the CLI skip every endpoint feedback call silently. Respect that flag — do not try to work around it.

## Parallelization

Run independent operations in parallel. Check `firecrawl --status` for concurrency limit:
Expand Down
210 changes: 210 additions & 0 deletions src/__tests__/commands/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
executeEndpointFeedback,
handleEndpointFeedbackCommand,
parseEndpointFeedbackEndpoint,
parseFeedbackListArg,
parsePageNumbersArg,
} from '../../commands/feedback';
import { getClient } from '../../utils/client';
import { initializeConfig } from '../../utils/config';
import { setupTest, teardownTest } from '../utils/mock-client';

vi.mock('../../utils/client', async () => {
const actual = await vi.importActual('../../utils/client');
return {
...actual,
getClient: vi.fn(),
};
});

describe('executeEndpointFeedback', () => {
let mockFetch: ReturnType<typeof vi.fn>;

beforeEach(() => {
setupTest();
initializeConfig({
apiKey: 'test-api-key',
apiUrl: 'https://api.firecrawl.dev',
});

mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});

afterEach(() => {
teardownTest();
vi.clearAllMocks();
delete process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK;
delete process.env.FIRECRAWL_DISABLE_ENDPOINT_FEEDBACK;
});

it('posts generic endpoint feedback to /v2/feedback', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
success: true,
feedbackId: '0193f6c5-1234-7890-abcd-1234567890ab',
creditsRefunded: 1,
}),
});

const result = await executeEndpointFeedback({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'partial',
issues: ['missing_markdown'],
tags: ['docs'],
note: 'The markdown missed the pricing table.',
url: 'https://example.com/pricing',
pageNumbers: [1, 2],
metadata: { source: 'test' },
apiUrl: 'http://localhost:3002',
});

expect(getClient).toHaveBeenCalledWith({
apiKey: undefined,
apiUrl: 'http://localhost:3002',
});
expect(result).toEqual({
success: true,
feedbackId: '0193f6c5-1234-7890-abcd-1234567890ab',
creditsRefunded: 1,
creditsRefundedToday: undefined,
dailyRefundCap: undefined,
dailyCapReached: false,
alreadySubmitted: undefined,
warning: undefined,
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3002/v2/feedback',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-api-key',
'Content-Type': 'application/json',
}),
})
);

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).toEqual({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'partial',
origin: 'cli',
integration: 'cli',
issues: ['missing_markdown'],
tags: ['docs'],
note: 'The markdown missed the pricing table.',
url: 'https://example.com/pricing',
pageNumbers: [1, 2],
metadata: { source: 'test' },
});
});

it('treats team opt-out as a disabled success', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: async () => ({
success: false,
error: 'Feedback is disabled for this team.',
feedbackErrorCode: 'TEAM_OPTED_OUT',
}),
});

await expect(
executeEndpointFeedback({
endpoint: 'map',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'bad',
issues: ['missing_links'],
})
).resolves.toMatchObject({
success: true,
disabled: true,
disabledSource: 'team',
creditsRefunded: 0,
});
});

it('skips endpoint feedback when local opt-out is set', async () => {
process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK = '1';

await expect(
executeEndpointFeedback({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'bad',
issues: ['missing_markdown'],
})
).resolves.toMatchObject({
success: true,
disabled: true,
disabledSource: 'env',
creditsRefunded: 0,
});

expect(getClient).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});

it('handles local opt-out silently in the CLI command path', async () => {
process.env.FIRECRAWL_NO_ENDPOINT_FEEDBACK = '1';

const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
code?: number | string | null
) => {
throw new Error(`process.exit:${code}`);
}) as typeof process.exit);
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);

try {
await expect(
handleEndpointFeedbackCommand({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'bad',
issues: ['missing_markdown'],
})
).rejects.toThrow('process.exit:0');

expect(stderrSpy).not.toHaveBeenCalled();
expect(stdoutSpy).not.toHaveBeenCalled();
expect(getClient).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
} finally {
exitSpy.mockRestore();
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
}
});
});

describe('feedback parsing', () => {
it('parses endpoint names', () => {
expect(parseEndpointFeedbackEndpoint('Scrape')).toBe('scrape');
expect(() => parseEndpointFeedbackEndpoint('crawl')).toThrow(
'endpoint must be one of'
);
});

it('parses comma-separated and JSON list options', () => {
expect(
parseFeedbackListArg('missing_markdown, bad_pdf', '--issues')
).toEqual(['missing_markdown', 'bad_pdf']);
expect(parseFeedbackListArg('["a","b"]', '--tags')).toEqual(['a', 'b']);
});

it('parses positive page numbers', () => {
expect(parsePageNumbersArg('1, 2, bad, -1, 3')).toEqual([1, 2, 3]);
expect(parsePageNumbersArg('[4,5]')).toEqual([4, 5]);
});
});
Loading
Loading