diff --git a/.gitignore b/.gitignore index 4e2511851..47dcc2df7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ target/ # local files _local/ + + +apps/api/src/lib/scrape-interact/*.md \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index b4b7dc6e0..6d19641ee 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -75,7 +75,7 @@ "@ai-sdk/anthropic": "^2.0.41", "@ai-sdk/deepinfra": "^1.0.27", "@ai-sdk/fireworks": "^1.0.27", - "@ai-sdk/google": "^2.0.28", + "@ai-sdk/google": "^3.0.29", "@ai-sdk/google-vertex": "^3.0.86", "@ai-sdk/groq": "^2.0.28", "@ai-sdk/openai": "2.0.64", @@ -93,7 +93,7 @@ "@x402/core": "^2.4.0", "@x402/evm": "^2.4.0", "@x402/express": "^2.4.0", - "ai": "5.0.89", + "ai": "6.0.86", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "amqplib": "^0.10.9", diff --git a/apps/api/pnpm-lock.yaml b/apps/api/pnpm-lock.yaml index 4816ff560..7f59a0d26 100644 --- a/apps/api/pnpm-lock.yaml +++ b/apps/api/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^1.0.27 version: 1.0.29(zod@4.1.12) '@ai-sdk/google': - specifier: ^2.0.28 - version: 2.0.44(zod@4.1.12) + specifier: ^3.0.29 + version: 3.0.52(zod@4.1.12) '@ai-sdk/google-vertex': specifier: ^3.0.86 version: 3.0.86(zod@4.1.12) @@ -87,8 +87,8 @@ importers: specifier: ^2.4.0 version: 2.4.0(bufferutil@4.0.9)(encoding@0.1.13)(ethers@6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(express@4.22.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) ai: - specifier: 5.0.89 - version: 5.0.89(zod@4.1.12) + specifier: 6.0.86 + version: 6.0.86(zod@4.1.12) ajv: specifier: ^8.18.0 version: 8.18.0 @@ -387,8 +387,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@2.0.7': - resolution: {integrity: sha512-/AI5AKi4vOK9SEb8Z1dfXkhsJ5NAfWsoJQc96B/mzn2KIrjw5occOjIwD06scuhV9xWlghCoXJT1sQD9QH/tyg==} + '@ai-sdk/gateway@3.0.46': + resolution: {integrity: sha512-zH1UbNRjG5woOXXFOrVCZraqZuFTtmPvLardMGcgLkzpxKV0U3tAGoyWKSZ862H+eBJfI/Hf2yj/zzGJcCkycg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -405,6 +405,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.52': + resolution: {integrity: sha512-HiFB4VlHnv55k9xIbgQW9tHw5OsLXzbAghnDUqrnk/S94QpQuyrDwLSDsk/tUkxJeT00B+wvhL1y6/SARdLeXw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/groq@2.0.32': resolution: {integrity: sha512-5kadf9Mjd4Ep6jVhrIy56UL7DV5HDisW8UakwB11IN7lSLi8Qwb1fB9uO34GT7JxYqE4w7qZXVuelOmTH9m2Mg==} engines: {node: '>=18'} @@ -453,6 +459,18 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.15': + resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.21': + resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@1.0.8': resolution: {integrity: sha512-f9jSYwKMdXvm44Dmab1vUBnfCDSFfI5rOtvV1W9oKB7WYHR5dGvCC6x68Mk3NUfrdmNoMVHGoh6JT9HCVMlMow==} engines: {node: '>=18'} @@ -465,6 +483,10 @@ packages: resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@apidevtools/json-schema-ref-parser@15.1.2': resolution: {integrity: sha512-54vnqDyGsDAVM0LOIdGMnfVyCN1NlqoGnHEGpMfaDBLMvClfT4j2XbJgvfuF0Ca0kxT6Gb7xUgS5W1I14QqjiQ==} engines: {node: '>=20'} @@ -2536,6 +2558,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/auth-js@2.71.1': resolution: {integrity: sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==} @@ -2846,8 +2871,8 @@ packages: cpu: [x64] os: [win32] - '@vercel/oidc@3.0.3': - resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} '@x402/core@2.4.0': @@ -2936,8 +2961,8 @@ packages: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} engines: {node: '>= 8.0.0'} - ai@5.0.89: - resolution: {integrity: sha512-8Nq+ZojGacQrupoJEQLrTDzT5VtR3gyp5AaqFSV3tzsAXlYQ9Igb7QE3yeoEdzOk5IRfDwWL7mDCUD+oBg1hDA==} + ai@6.0.86: + resolution: {integrity: sha512-U2W2LBCHA/pr0Ui7vmmsjBiLEzBbZF3yVHNy7Rbzn7IX+SvoQPFM5rN74hhfVzZoE8zBuGD4nLLk+j0elGacvQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -5814,11 +5839,11 @@ snapshots: '@ai-sdk/provider-utils': 3.0.18(zod@4.1.12) zod: 4.1.12 - '@ai-sdk/gateway@2.0.7(zod@4.1.12)': + '@ai-sdk/gateway@3.0.46(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) - '@vercel/oidc': 3.0.3 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.1.12) + '@vercel/oidc': 3.1.0 zod: 4.1.12 '@ai-sdk/google-vertex@3.0.86(zod@4.1.12)': @@ -5838,6 +5863,12 @@ snapshots: '@ai-sdk/provider-utils': 3.0.18(zod@4.1.12) zod: 4.1.12 + '@ai-sdk/google@3.0.52(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.1.12) + zod: 4.1.12 + '@ai-sdk/groq@2.0.32(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -5888,6 +5919,20 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.12 + '@ai-sdk/provider-utils@4.0.15(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.12 + + '@ai-sdk/provider-utils@4.0.21(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.1.12 + '@ai-sdk/provider@1.0.8': dependencies: json-schema: 0.4.0 @@ -5900,6 +5945,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@apidevtools/json-schema-ref-parser@15.1.2(@types/json-schema@7.0.15)': dependencies: '@types/json-schema': 7.0.15 @@ -8080,6 +8129,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.71.1': dependencies: '@supabase/node-fetch': 2.6.15 @@ -8407,7 +8458,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/oidc@3.0.3': {} + '@vercel/oidc@3.1.0': {} '@x402/core@2.4.0': dependencies: @@ -8508,11 +8559,11 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ai@5.0.89(zod@4.1.12): + ai@6.0.86(zod@4.1.12): dependencies: - '@ai-sdk/gateway': 2.0.7(zod@4.1.12) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + '@ai-sdk/gateway': 3.0.46(zod@4.1.12) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@4.1.12) '@opentelemetry/api': 1.9.0 zod: 4.1.12 diff --git a/apps/api/requests.http b/apps/api/requests.http index 5ffc11708..9fe6a6012 100644 --- a/apps/api/requests.http +++ b/apps/api/requests.http @@ -12,6 +12,44 @@ content-type: application/json "url": "https://firecrawl.dev" } +### Scrape Website (Replay-ready for scrape interact APIs) +# @name scrapeForBrowser +POST {{baseUrl}}/v2/scrape HTTP/1.1 +Authorization: Bearer {{$dotenv TEST_API_KEY}} +content-type: application/json + +{ + "url": "https://firecrawl.dev", + "actions": [ + { + "type": "executeJavascript", + "script": "window.location.href = 'https://roastmywebsite.ai'" + }, + { + "type": "wait", + "milliseconds": 2000 + } + ] +} + +### Interact With Scrape Browser Session (POST /interact) +@scrapeJobId = {{scrapeForBrowser.response.body.$.data.metadata.scrapeId}} +# @name scrapeInteract +POST {{baseUrl}}/v2/scrape/{{scrapeJobId}}/interact HTTP/1.1 +Authorization: Bearer {{$dotenv TEST_API_KEY}} +content-type: application/json + +{ + "code": "const title = await page.title(); const url = page.url(); console.log('url=', url, 'title=', JSON.stringify(title)); JSON.stringify({ url, title });", + "language": "node", + "timeout": 30 +} + +### Stop Interactive Browser Session (DELETE /interact) +# @name scrapeStopInteractiveBrowser +DELETE {{baseUrl}}/v2/scrape/{{scrapeJobId}}/interact HTTP/1.1 +Authorization: Bearer {{$dotenv TEST_API_KEY}} + ### Crawl Website # @name crawl POST {{baseUrl}}/v2/crawl HTTP/1.1 diff --git a/apps/api/src/__tests__/snips/v2/lib.ts b/apps/api/src/__tests__/snips/v2/lib.ts index 2a119a4c8..2f3384f0a 100644 --- a/apps/api/src/__tests__/snips/v2/lib.ts +++ b/apps/api/src/__tests__/snips/v2/lib.ts @@ -104,6 +104,33 @@ export async function scrapeStatus( return raw.body.data; } +export async function scrapeInteractRaw( + jobId: string, + body: { + code: string; + language?: "python" | "node" | "bash"; + timeout?: number; + origin?: string; + }, + identity: Identity, +) { + return await request(TEST_API_URL) + .post("/v2/scrape/" + encodeURIComponent(jobId) + "/interact") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send(body); +} + +export async function scrapeStopInteractiveBrowserRaw( + jobId: string, + identity: Identity, +) { + return await request(TEST_API_URL) + .delete("/v2/scrape/" + encodeURIComponent(jobId) + "/interact") + .set("Authorization", `Bearer ${identity.apiKey}`) + .send(); +} + // ========================================= // Crawl API // ========================================= diff --git a/apps/api/src/__tests__/snips/v2/scrape-browser.test.ts b/apps/api/src/__tests__/snips/v2/scrape-browser.test.ts new file mode 100644 index 000000000..960df30c0 --- /dev/null +++ b/apps/api/src/__tests__/snips/v2/scrape-browser.test.ts @@ -0,0 +1,289 @@ +import crypto from "crypto"; +import { config } from "../../../config"; +import { + ALLOW_TEST_SUITE_WEBSITE, + HAS_FIRE_ENGINE, + TEST_PRODUCTION, + TEST_SUITE_WEBSITE, + itIf, +} from "../lib"; +import { + Identity, + idmux, + scrapeStopInteractiveBrowserRaw, + scrapeInteractRaw, + scrapeRaw, + scrapeTimeout, +} from "./lib"; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function interactWithReplicaRetry( + jobId: string, + body: { + code: string; + language?: "python" | "node" | "bash"; + timeout?: number; + }, + identity: Identity, + attempts: number = 5, +) { + let lastResponse: Awaited> | null = null; + + for (let i = 0; i < attempts; i += 1) { + const response = await scrapeInteractRaw(jobId, body, identity); + lastResponse = response; + if (response.statusCode !== 404) return response; + await sleep(500); + } + + return lastResponse!; +} + +describe("Scrape browser interact replay", () => { + let identity: Identity; + let otherIdentity: Identity; + + beforeAll(async () => { + identity = await idmux({ + name: "scrape-browser-replay", + concurrency: 20, + credits: 1_000_000, + }); + otherIdentity = await idmux({ + name: "scrape-browser-replay-other", + concurrency: 10, + credits: 1_000_000, + }); + }, 10000 + scrapeTimeout); + + const canRunReplayHappyPath = + ALLOW_TEST_SUITE_WEBSITE && + !!config.BROWSER_SERVICE_URL && + (TEST_PRODUCTION || HAS_FIRE_ENGINE); + + itIf(canRunReplayHappyPath)( + "replays scrape URL/waitFor/actions before interactive code runs", + async () => { + const marker = crypto.randomUUID(); + const url = `${TEST_SUITE_WEBSITE}?testId=${crypto.randomUUID()}`; + let scrapeId: string | null = null; + + try { + const scrapeResponse = await scrapeRaw( + { + url, + origin: "website-replay-test", + waitFor: 500, + actions: [ + { + type: "executeJavascript", + script: `window.__firecrawlReplayMarker = "${marker}";`, + }, + ], + }, + identity, + ); + + expect(scrapeResponse.statusCode).toBe(200); + expect(scrapeResponse.body.success).toBe(true); + expect(typeof scrapeResponse.body.scrape_id).toBe("string"); + scrapeId = scrapeResponse.body.scrape_id as string; + + const executeResponse = await interactWithReplicaRetry( + scrapeId, + { + language: "node", + timeout: 60, + code: ` + const replayMarker = await page.evaluate(() => window.__firecrawlReplayMarker ?? null); + console.log(replayMarker ?? "missing-marker"); + `, + }, + identity, + ); + + expect(executeResponse.statusCode).toBe(200); + expect(executeResponse.body.success).toBe(true); + expect(executeResponse.body.stdout).toContain(marker); + } finally { + if (scrapeId) { + await scrapeStopInteractiveBrowserRaw(scrapeId, identity); + } + } + }, + scrapeTimeout, + ); + + itIf(canRunReplayHappyPath)( + "keeps a non-blank replay tab in the foreground for follow-up execs", + async () => { + const url = `${TEST_SUITE_WEBSITE}?testId=${crypto.randomUUID()}`; + let scrapeId: string | null = null; + + try { + const scrapeResponse = await scrapeRaw( + { + url, + origin: "website-replay-test", + actions: [ + { + type: "executeJavascript", + script: "window.open('about:blank', '_blank');", + }, + ], + }, + identity, + ); + + expect(scrapeResponse.statusCode).toBe(200); + expect(scrapeResponse.body.success).toBe(true); + expect(typeof scrapeResponse.body.scrape_id).toBe("string"); + scrapeId = scrapeResponse.body.scrape_id as string; + + const executeResponse = await interactWithReplicaRetry( + scrapeId, + { + language: "node", + timeout: 60, + code: ` + const visibleUrls = []; + for (const candidate of page.context().pages()) { + try { + const isVisible = await candidate.evaluate( + () => document.visibilityState === "visible", + ); + if (isVisible) { + visibleUrls.push(candidate.url()); + } + } catch {} + } + + const visibleNonBlankUrl = + visibleUrls.find(value => value !== "about:blank") ?? "about:blank"; + console.log(visibleNonBlankUrl); + `, + }, + identity, + ); + + expect(executeResponse.statusCode).toBe(200); + expect(executeResponse.body.success).toBe(true); + + const visibleUrl = + executeResponse.body.stdout + ?.trim() + .split("\n") + .filter(Boolean) + .pop() ?? ""; + + expect(visibleUrl).not.toBe("about:blank"); + expect(visibleUrl).toContain(TEST_SUITE_WEBSITE); + } finally { + if (scrapeId) { + await scrapeStopInteractiveBrowserRaw(scrapeId, identity); + } + } + }, + scrapeTimeout, + ); + + it("returns 400 for invalid scrape job id format", async () => { + const response = await scrapeInteractRaw( + "not-a-valid-uuid", + { + code: "console.log('hi')", + language: "node", + }, + identity, + ); + + expect(response.statusCode).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe( + "Invalid job ID format. Job ID must be a valid UUID.", + ); + }); + + it("returns 404 when scrape job does not exist", async () => { + const response = await scrapeInteractRaw( + crypto.randomUUID(), + { + code: "console.log('hi')", + language: "node", + }, + identity, + ); + + expect(response.statusCode).toBe(404); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Job not found."); + }); + + itIf(ALLOW_TEST_SUITE_WEBSITE && !!config.IDMUX_URL)( + "returns 403 when scrape job belongs to another team", + async () => { + const scrapeResponse = await scrapeRaw( + { + url: `${TEST_SUITE_WEBSITE}?testId=${crypto.randomUUID()}`, + origin: "website-replay-test", + }, + identity, + ); + + expect(scrapeResponse.statusCode).toBe(200); + expect(scrapeResponse.body.success).toBe(true); + expect(typeof scrapeResponse.body.scrape_id).toBe("string"); + + const scrapeId = scrapeResponse.body.scrape_id as string; + const executeResponse = await interactWithReplicaRetry( + scrapeId, + { + code: "console.log('should fail')", + language: "node", + }, + otherIdentity, + ); + + expect(executeResponse.statusCode).toBe(403); + expect(executeResponse.body.success).toBe(false); + expect(executeResponse.body.error).toBe("Forbidden."); + }, + scrapeTimeout, + ); + + itIf(ALLOW_TEST_SUITE_WEBSITE)( + "returns replay-context error when scrape data is not retained", + async () => { + const scrapeResponse = await scrapeRaw( + { + url: `${TEST_SUITE_WEBSITE}?testId=${crypto.randomUUID()}`, + origin: "website-replay-test", + zeroDataRetention: true, + }, + identity, + ); + + expect(scrapeResponse.statusCode).toBe(200); + expect(scrapeResponse.body.success).toBe(true); + expect(typeof scrapeResponse.body.scrape_id).toBe("string"); + + const scrapeId = scrapeResponse.body.scrape_id as string; + const executeResponse = await interactWithReplicaRetry( + scrapeId, + { + code: "console.log('should not run')", + language: "node", + }, + identity, + ); + + expect(executeResponse.statusCode).toBe(409); + expect(executeResponse.body.success).toBe(false); + expect(executeResponse.body.error).toContain( + "Replay context is unavailable", + ); + }, + scrapeTimeout, + ); +}); diff --git a/apps/api/src/controllers/v2/browser.ts b/apps/api/src/controllers/v2/browser.ts index 14385cb0d..78e2024ce 100644 --- a/apps/api/src/controllers/v2/browser.ts +++ b/apps/api/src/controllers/v2/browser.ts @@ -464,6 +464,7 @@ export async function browserExecuteController( enqueueBrowserSessionActivity({ team_id: req.auth.team_id, session_id: id, + source: "browser", language, timeout, exit_code: execResult.exitCode ?? null, @@ -552,8 +553,11 @@ export async function browserDeleteController( }); } + const wallClockMs = Date.now() - new Date(session.created_at).getTime(); const durationMs = - sessionDurationMs ?? Date.now() - new Date(session.created_at).getTime(); + sessionDurationMs && sessionDurationMs > 0 + ? sessionDurationMs + : wallClockMs; const creditsBilled = calculateBrowserSessionCredits(durationMs); updateBrowserSessionCreditsUsed(session.id, creditsBilled).catch(error => { @@ -623,8 +627,8 @@ export async function browserListController( id: r.id, status: r.status, cdpUrl: r.cdp_url, - liveViewUrl: r.cdp_path, // cdp_path stores the view URL - interactiveLiveViewUrl: r.cdp_interactive_path, // cdp_interactive_path stores the interactive view URL + liveViewUrl: r.cdp_path, + interactiveLiveViewUrl: r.cdp_interactive_path, streamWebView: r.stream_web_view, createdAt: r.created_at, lastActivity: r.updated_at, diff --git a/apps/api/src/controllers/v2/scrape-browser.ts b/apps/api/src/controllers/v2/scrape-browser.ts new file mode 100644 index 000000000..aba06ada7 --- /dev/null +++ b/apps/api/src/controllers/v2/scrape-browser.ts @@ -0,0 +1,647 @@ +import { createHash } from "crypto"; +import { v7 as uuidv7 } from "uuid"; +import { Response } from "express"; +import { z } from "zod"; +import { logger as _logger } from "../../lib/logger"; +import { config } from "../../config"; +import { + insertBrowserSession, + updateBrowserSessionActivity, + updateBrowserSessionCreditsUsed, + claimBrowserSessionDestroyed, + getActiveBrowserSessionCount, + invalidateActiveBrowserSessionCount, + MAX_ACTIVE_BROWSER_SESSIONS_PER_TEAM, + getBrowserSessionFromScrape, +} from "../../lib/browser-sessions"; +import { + browserServiceRequest, + BrowserServiceError, + BrowserServiceExecResponse, + BrowserServiceCreateResponse, + BrowserServiceDeleteResponse, +} from "../../lib/scrape-interact/browser-service-client"; +import { + ScrapeContextRow, + buildReplayContextFromScrape, + estimateReplayTimeoutSeconds, + buildReplayScript, +} from "../../lib/scrape-interact/scrape-replay"; +import { + executePromptViaBrowserAgent, + AgentResult, +} from "../../lib/scrape-interact/browser-agent"; +import { RequestWithAuth, ScrapeOptions } from "./types"; +import { billTeam } from "../../services/billing/credit_billing"; +import { enqueueBrowserSessionActivity } from "../../lib/browser-session-activity"; +import { logRequest } from "../../services/logging/log_job"; +import { integrationSchema } from "../../utils/integration"; +import { supabaseGetScrapeById } from "../../lib/supabase-jobs"; + +// --------------------------------------------------------------------------- +// Constants & schemas +// --------------------------------------------------------------------------- + +const BROWSER_CREDITS_PER_HOUR = 120; + +function calculateBrowserSessionCredits(durationMs: number): number { + const hours = durationMs / 3_600_000; + return Math.max(1, Math.ceil(hours * BROWSER_CREDITS_PER_HOUR)); +} + +const browserCreateRequestSchema = z.object({ + ttl: z.number().min(30).max(3600).default(600), + activityTtl: z.number().min(10).max(3600).default(300), + streamWebView: z.boolean().default(true), + integration: integrationSchema.optional().transform(val => val || null), + profile: z + .object({ + name: z.string().min(1).max(128), + saveChanges: z.boolean().default(true), + }) + .optional(), +}); + +const browserExecuteRequestSchema = z + .object({ + code: z.string().min(1).max(100_000).optional(), + prompt: z.string().min(1).max(10_000).optional(), + language: z.enum(["python", "node", "bash"]).default("node"), + timeout: z.number().min(1).max(300).default(30), + origin: z.string().optional(), + integration: integrationSchema.optional().transform(val => val || null), + }) + .refine(data => data.code || data.prompt, { + message: "Either 'code' or 'prompt' must be provided.", + }); + +type BrowserExecuteRequest = z.infer; + +interface BrowserExecuteResponse { + success: boolean; + liveViewUrl?: string; + interactiveLiveViewUrl?: string; + output?: string; + stdout?: string; + result?: string; + stderr?: string; + exitCode?: number; + killed?: boolean; + error?: string; +} + +interface BrowserDeleteResponse { + success: boolean; + sessionDurationMs?: number; + creditsBilled?: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// POST /v2/scrape/:jobId/interact +// --------------------------------------------------------------------------- + +export async function scrapeInteractController( + req: RequestWithAuth< + { jobId: string }, + BrowserExecuteResponse, + BrowserExecuteRequest + >, + res: Response, +) { + req.body = browserExecuteRequestSchema.parse(req.body); + + const scrapeId = req.params.jobId; + const { code: rawCode, prompt, language, timeout, origin } = req.body; + + let logger = _logger.child({ + scrapeId, + teamId: req.auth.team_id, + module: "api/v2", + method: "scrapeInteractController", + }); + + // --- Validate scrape ownership --- + + const scrape = (await supabaseGetScrapeById( + scrapeId, + )) as ScrapeContextRow | null; + if (!scrape) { + return res.status(404).json({ success: false, error: "Job not found." }); + } + if (scrape.team_id !== req.auth.team_id) { + return res.status(403).json({ success: false, error: "Forbidden." }); + } + + // --- Build replay context from original scrape --- + + const replay = buildReplayContextFromScrape(scrape); + if (!replay.context) { + return res.status(409).json({ + success: false, + error: + replay.error ?? + "Replay context is unavailable for this scrape job. Please rerun the scrape.", + }); + } + const replayContext = replay.context; + + logger = logger.child({ + replayTargetUrl: replayContext.targetUrl, + replayWaitForMs: replayContext.waitForMs, + replayActions: replayContext.actions.length, + }); + + // --- Ensure a browser session exists (create + replay if needed) --- + + let session = await getBrowserSessionFromScrape(scrapeId); + + if (!session) { + const created = await createSessionForScrape( + req, + scrapeId, + replayContext, + logger, + (scrape.options as ScrapeOptions).profile, + ); + if ("error" in created) { + return res.status(created.status).json(created.body); + } + session = created.session; + + logger = logger.child({ + sessionId: session.id, + browserId: session.browser_id, + }); + logger.info("Browser session created for scrape", { + scrapeId, + sessionId: session.id, + browserId: session.browser_id, + }); + } + + if (session.team_id !== req.auth.team_id) { + return res.status(403).json({ success: false, error: "Forbidden." }); + } + if (session.status === "destroyed") { + return res + .status(410) + .json({ success: false, error: "Browser session has been destroyed." }); + } + + updateBrowserSessionActivity(session.id).catch(() => {}); + + // --- Execute: prompt-based agent loop OR direct code --- + + let execResult: BrowserServiceExecResponse | AgentResult; + + if (prompt && !rawCode) { + logger.info("Starting agent loop from prompt", { prompt, timeout }); + + try { + execResult = await executePromptViaBrowserAgent( + prompt, + session.browser_id, + timeout, + logger, + ); + } catch (err) { + logger.error("Agent loop failed", { error: err }); + return res.status(502).json({ + success: false, + error: "Browser agent failed to execute the task.", + }); + } + + enqueueBrowserSessionActivity({ + team_id: req.auth.team_id, + session_id: session.id, + source: "interact", + language: "bash", + timeout, + exit_code: execResult.exitCode ?? null, + killed: execResult.killed ?? false, + }); + } else { + logger.info("Executing code in browser session", { language, timeout }); + + try { + execResult = await browserServiceRequest( + "POST", + `/browsers/${session.browser_id}/exec`, + { code: rawCode!, language, timeout, origin }, + ); + } catch (err) { + logger.error("Failed to execute code via browser service", { + error: err, + }); + return res.status(502).json({ + success: false, + error: "Failed to execute code in browser session.", + }); + } + + enqueueBrowserSessionActivity({ + team_id: req.auth.team_id, + session_id: session.id, + source: "interact", + language, + timeout, + exit_code: execResult.exitCode ?? null, + killed: execResult.killed ?? false, + }); + } + + // --- Respond --- + + logger.debug("Execution result", { + exitCode: execResult.exitCode, + killed: execResult.killed, + stdoutLength: execResult.stdout?.length, + stderrLength: execResult.stderr?.length, + }); + + const hasError = execResult.exitCode !== 0 || execResult.killed; + const agentOutput = "output" in execResult ? execResult.output : undefined; + + return res.status(200).json({ + success: !hasError, + liveViewUrl: session.cdp_path, + interactiveLiveViewUrl: session.cdp_interactive_path, + ...(agentOutput ? { output: agentOutput } : {}), + stdout: execResult.stdout, + result: execResult.result, + stderr: execResult.stderr, + exitCode: execResult.exitCode, + killed: execResult.killed, + ...(hasError ? { error: execResult.stderr || "Execution failed" } : {}), + }); +} + +// --------------------------------------------------------------------------- +// DELETE /v2/scrape/:jobId/interact +// --------------------------------------------------------------------------- + +export async function scrapeStopInteractiveBrowserController( + req: RequestWithAuth<{ jobId: string }, BrowserDeleteResponse>, + res: Response, +) { + let logger = _logger.child({ + scrapeId: req.params.jobId, + teamId: req.auth.team_id, + module: "api/v2", + method: "scrapeStopInteractiveBrowserController", + }); + + const session = await getBrowserSessionFromScrape(req.params.jobId); + + if (!session) { + return res + .status(404) + .json({ success: false, error: "Browser session not found." }); + } + if (session.team_id !== req.auth.team_id) { + return res.status(403).json({ success: false, error: "Forbidden." }); + } + + logger = logger.child({ + sessionId: session.id, + browserId: session.browser_id, + }); + logger.info("Deleting browser session"); + + let sessionDurationMs: number | undefined; + try { + const deleteResult = + await browserServiceRequest( + "DELETE", + `/browsers/${session.browser_id}`, + ); + sessionDurationMs = deleteResult?.sessionDurationMs; + } catch (err) { + logger.warn("Failed to delete browser session via browser service", { + error: err, + }); + } + + const claimed = await claimBrowserSessionDestroyed(session.id); + + invalidateActiveBrowserSessionCount(session.team_id).catch(() => {}); + + if (!claimed) { + logger.info("Session already destroyed by another path, skipping billing", { + sessionId: session.id, + }); + return res.status(200).json({ success: true }); + } + + const wallClockMs = Date.now() - new Date(session.created_at).getTime(); + const durationMs = + sessionDurationMs && sessionDurationMs > 0 + ? sessionDurationMs + : wallClockMs; + const creditsBilled = calculateBrowserSessionCredits(durationMs); + + updateBrowserSessionCreditsUsed(session.id, creditsBilled).catch(error => { + logger.error("Failed to update credits_used on browser session", { + error, + sessionId: session.id, + creditsBilled, + }); + }); + + billTeam( + req.auth.team_id, + req.acuc?.sub_id ?? undefined, + creditsBilled, + req.acuc?.api_key_id ?? null, + { endpoint: "interact", jobId: session.id }, + ).catch(error => { + logger.error("Failed to bill team for interact session", { + error, + creditsBilled, + durationMs, + }); + }); + + logger.info("Browser session destroyed", { + sessionDurationMs: durationMs, + creditsBilled, + }); + + return res.status(200).json({ success: true }); +} + +// --------------------------------------------------------------------------- +// Internal: create a browser session for a scrape, replay original context +// --------------------------------------------------------------------------- + +async function createSessionForScrape( + req: RequestWithAuth, + scrapeId: string, + replayContext: ReturnType extends { + context?: infer C; + } + ? NonNullable + : never, + logger: typeof _logger, + profile: { name: string; saveChanges: boolean } | undefined, +): Promise< + | { session: Awaited> } + | { status: number; body: { success: false; error: string }; error: true } +> { + const sessionId = uuidv7(); + const { ttl, activityTtl, streamWebView } = browserCreateRequestSchema.parse( + {}, + ); + const integration = req.body?.integration ?? null; + + if (!config.BROWSER_SERVICE_URL) { + return { + status: 503, + body: { + success: false, + error: + "Browser feature is not configured (BROWSER_SERVICE_URL is missing).", + }, + error: true, + }; + } + + logger.info("No browser session found for scrape. Creating one.", { + scrapeId, + ttl, + activityTtl, + }); + + // Credit check + const estimatedCredits = calculateBrowserSessionCredits(ttl * 1000); + if (req.acuc && req.acuc.remaining_credits < estimatedCredits) { + return { + status: 402, + body: { + success: false, + error: `Insufficient credits for a ${ttl}s browser session (requires ~${estimatedCredits} credits). For more credits, you can upgrade your plan at https://firecrawl.dev/pricing.`, + }, + error: true, + }; + } + + // Active session limit + const activeCount = await getActiveBrowserSessionCount(req.auth.team_id); + if (activeCount >= MAX_ACTIVE_BROWSER_SESSIONS_PER_TEAM) { + return { + status: 429, + body: { + success: false, + error: `You have reached the maximum number of active browser sessions (${MAX_ACTIVE_BROWSER_SESSIONS_PER_TEAM}). Please destroy existing sessions before creating new ones.`, + }, + error: true, + }; + } + + // Create the browser session (retry up to 3 times) + const MAX_CREATE_RETRIES = 3; + let svcResponse: BrowserServiceCreateResponse | undefined; + let lastCreateError: unknown; + + let persistentStorage: { uniqueId: string; write: boolean } | undefined; + if (profile) { + const teamHash = createHash("sha256") + .update(req.auth.team_id) + .digest("hex") + .slice(0, 16); + persistentStorage = { + uniqueId: `${teamHash}_${profile.name}`, + write: profile.saveChanges !== false, + }; + } + + for (let attempt = 1; attempt <= MAX_CREATE_RETRIES; attempt++) { + try { + svcResponse = await browserServiceRequest( + "POST", + "/browsers", + { + ttl, + ...(activityTtl !== undefined ? { activityTtl } : {}), + ...(persistentStorage !== undefined ? { persistentStorage } : {}), + }, + ); + break; + } catch (err) { + if (err instanceof BrowserServiceError && err.status === 409) { + return { + status: 409, + body: { + success: false, + error: + "Another session is currently writing to this profile. Only one writer is allowed at a time. You can still access it with saveChanges: false, or try again later.", + }, + error: true, + }; + } + lastCreateError = err; + logger.warn("Browser session creation attempt failed", { + attempt, + maxRetries: MAX_CREATE_RETRIES, + error: err, + }); + if (attempt < MAX_CREATE_RETRIES) { + await new Promise(resolve => setTimeout(resolve, 200 * attempt)); + } + } + } + + if (!svcResponse) { + logger.error("Failed to create browser session after all retries", { + error: lastCreateError, + }); + return { + status: 502, + body: { success: false, error: "Failed to create browser session." }, + error: true, + }; + } + + // Replay original scrape context + try { + const replayResult = + await browserServiceRequest( + "POST", + `/browsers/${svcResponse.sessionId}/exec`, + { + code: buildReplayScript(replayContext), + language: "node", + timeout: estimateReplayTimeoutSeconds(replayContext), + origin: "scrape_replay", + }, + ); + + if (replayResult.exitCode !== 0 || replayResult.killed) { + throw new Error( + replayResult.stderr?.trim() || + replayResult.stdout?.trim() || + "Replay script exited with an error.", + ); + } + + // Ensure only one tab exists with the content page in the foreground. + // The replay may have created extra tabs. Find the one with content, + // close everything else, update the REPL's page var, and bring to front. + await browserServiceRequest( + "POST", + `/browsers/${svcResponse.sessionId}/exec`, + { + code: [ + `const ctx = page.context();`, + `const pages = ctx.pages();`, + `if (pages.length > 1) {`, + ` const target = pages.find(p => { const u = p.url(); return u && u !== 'about:blank'; }) || pages[pages.length - 1];`, + ` for (const p of pages) { if (p !== target) await p.close().catch(() => {}); }`, + ` page = target;`, + `}`, + `await page.bringToFront();`, + ].join("\n"), + language: "node", + timeout: 10, + origin: "tab_sync", + }, + ).catch(() => {}); + + // Sync agent-browser to the correct page + const syncResult = await browserServiceRequest( + "POST", + `/browsers/${svcResponse.sessionId}/exec`, + { + code: `agent-browser get url`, + language: "bash", + timeout: 10, + origin: "scrape_replay_sync", + }, + ); + + const agentUrl = (syncResult.stdout || "").trim(); + if (!agentUrl || agentUrl === "about:blank") { + logger.info("agent-browser on wrong page after replay, navigating", { + agentUrl, + targetUrl: replayContext.targetUrl, + }); + await browserServiceRequest( + "POST", + `/browsers/${svcResponse.sessionId}/exec`, + { + code: `await page.goto(${JSON.stringify(replayContext.targetUrl)}, { waitUntil: "networkidle0" });`, + language: "node", + timeout: 30, + origin: "scrape_replay_sync", + }, + ); + } + } catch (err) { + logger.error("Failed to initialize scrape browser session context", { + error: err, + }); + await browserServiceRequest( + "DELETE", + `/browsers/${svcResponse.sessionId}`, + ).catch(() => {}); + return { + status: 409, + body: { + success: false, + error: + "Failed to initialize browser session from the original scrape context. Please rerun the scrape and try again.", + }, + error: true, + }; + } + + // Persist in Supabase + try { + await logRequest({ + id: sessionId, + kind: "interact", + api_version: "v2", + team_id: req.auth.team_id, + target_hint: "Interact session", + origin: req.body?.origin ?? "api", + integration: integration ?? null, + zeroDataRetention: false, + api_key_id: req.acuc?.api_key_id ?? null, + }); + const session = await insertBrowserSession({ + id: sessionId, + team_id: req.auth.team_id, + scrape_id: scrapeId, + browser_id: svcResponse.sessionId, + workspace_id: "", + context_id: "", + cdp_url: svcResponse.cdpUrl, + cdp_path: svcResponse.iframeUrl, + cdp_interactive_path: svcResponse.interactiveIframeUrl, + stream_web_view: streamWebView, + status: "active", + ttl_total: ttl, + ttl_without_activity: activityTtl ?? null, + credits_used: null, + }); + + invalidateActiveBrowserSessionCount(req.auth.team_id).catch(() => {}); + + return { session }; + } catch (err) { + logger.error("Failed to persist browser session, cleaning up", { + error: err, + }); + await browserServiceRequest( + "DELETE", + `/browsers/${svcResponse.sessionId}`, + ).catch(() => {}); + return { + status: 500, + body: { success: false, error: "Failed to persist browser session." }, + error: true, + }; + } +} diff --git a/apps/api/src/controllers/v2/types.ts b/apps/api/src/controllers/v2/types.ts index b14c31ac0..7bbcbb888 100644 --- a/apps/api/src/controllers/v2/types.ts +++ b/apps/api/src/controllers/v2/types.ts @@ -571,6 +571,14 @@ const baseScrapeOptions = z.strictObject({ maxAge: z.int().gte(0).optional(), minAge: z.int().gte(0).optional(), storeInCache: z.boolean().prefault(true), + + profile: z + .object({ + name: z.string().min(1).max(128), + saveChanges: z.boolean().default(true), + }) + .optional(), + // @deprecated __searchPreviewToken: z.string().optional(), __experimental_omce: z.boolean().prefault(false).optional(), diff --git a/apps/api/src/lib/browser-session-activity.ts b/apps/api/src/lib/browser-session-activity.ts index 06b418da7..74d7659b8 100644 --- a/apps/api/src/lib/browser-session-activity.ts +++ b/apps/api/src/lib/browser-session-activity.ts @@ -10,6 +10,7 @@ const BATCH_SIZE = 500; interface BrowserSessionActivityEvent { team_id: string; session_id: string; + source: "interact" | "browser"; language: string; timeout: number; exit_code: number | null; @@ -25,17 +26,14 @@ export function enqueueBrowserSessionActivity( created_at: new Date().toISOString(), }; - redisEvictConnection - .rpush(QUEUE_KEY, JSON.stringify(row)) - .catch(() => {}); + redisEvictConnection.rpush(QUEUE_KEY, JSON.stringify(row)).catch(() => {}); } export async function processBrowserSessionActivityJobs() { - const raw = - (await redisEvictConnection.lpop(QUEUE_KEY, BATCH_SIZE)) ?? []; + const raw = (await redisEvictConnection.lpop(QUEUE_KEY, BATCH_SIZE)) ?? []; if (raw.length === 0) return; - const rows: BrowserSessionActivityEvent[] = raw.map((x) => JSON.parse(x)); + const rows: BrowserSessionActivityEvent[] = raw.map(x => JSON.parse(x)); try { const { error } = await supabase_service diff --git a/apps/api/src/lib/browser-sessions.ts b/apps/api/src/lib/browser-sessions.ts index 1c6d8ea3f..18308e3c0 100644 --- a/apps/api/src/lib/browser-sessions.ts +++ b/apps/api/src/lib/browser-sessions.ts @@ -16,6 +16,7 @@ type BrowserSessionStatus = "active" | "destroyed" | "error"; interface BrowserSessionRow { id: string; team_id: string; + scrape_id?: string | null; // linked scrape job id for /scrape/:jobId/interact sessions browser_id: string; // browser service sessionId workspace_id: string; // unused (legacy), stored as "" context_id: string; // unused (legacy), stored as "" @@ -79,6 +80,26 @@ export async function getBrowserSession( return data as BrowserSessionRow; } +export async function getBrowserSessionFromScrape( + id: string, +): Promise { + const { data, error } = await supabase_service + .from(TABLE) + .select("*") + .eq("scrape_id", id) + .single(); + + if (error) { + if (isPostgrestNoRowsError(error)) return null; + logger.error("Failed to get browser session from scrape", { error, id }); + throw new Error( + `Failed to get browser session from scrape: ${error.message}`, + ); + } + + return data as BrowserSessionRow; +} + export async function listBrowserSessions( teamId: string, opts?: { status?: BrowserSessionStatus }, diff --git a/apps/api/src/lib/scrape-interact/browser-agent.ts b/apps/api/src/lib/scrape-interact/browser-agent.ts new file mode 100644 index 000000000..941fcc048 --- /dev/null +++ b/apps/api/src/lib/scrape-interact/browser-agent.ts @@ -0,0 +1,365 @@ +import { z } from "zod"; +import { promises as fs } from "fs"; +import path from "path"; +import { generateText, tool, stepCountIs } from "ai"; +import { logger as _logger } from "../logger"; +import { getModel } from "../generic-ai"; +import { + browserServiceRequest, + BrowserServiceExecResponse, +} from "./browser-service-client"; +import { config } from "../../config"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_STEPS = 25; +const SNAPSHOT_TIMEOUT = 15; +const SNAPSHOT_MAX_CHARS = 40_000; + +// --------------------------------------------------------------------------- +// Debug log +// --------------------------------------------------------------------------- + +const AGENT_LOG_DIR = path.join( + __dirname, + "../../logs/scrape-interact-agent-logs", +); + +const IS_PRODUCTION = config.IS_PRODUCTION === true; + +class AgentDebugLog { + private filePath: string; + private lines: string[] = []; + private enabled: boolean; + + constructor(browserId: string) { + this.enabled = !IS_PRODUCTION; + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + this.filePath = path.join(AGENT_LOG_DIR, `${ts}_${browserId}.log`); + } + + add(text: string) { + if (!this.enabled) return; + this.lines.push(text); + } + + async flush() { + if (!this.enabled || this.lines.length === 0) return; + try { + await fs.mkdir(AGENT_LOG_DIR, { recursive: true }); + await fs.appendFile(this.filePath, this.lines.join("\n") + "\n"); + this.lines = []; + } catch (err) { + _logger.error("AgentDebugLog flush failed", { + filePath: this.filePath, + agentLogDir: AGENT_LOG_DIR, + error: err, + }); + } + } + + getPath() { + return this.filePath; + } +} + +// --------------------------------------------------------------------------- +// System prompt +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPT = `You are an autonomous browser automation agent. You complete tasks by interacting with a browser using the browser tool. Be autonomous — break tasks into steps, execute ALL steps without stopping early, and be concise. + +## Browser Tool — agent-browser commands +The browser tool runs agent-browser CLI commands. Each tool call should contain a single command or a short chain (joined with &&). + +Commands: + agent-browser snapshot Get full accessibility tree with clickable refs (@e1, @e2...) + agent-browser snapshot -i Only interactive elements + agent-browser snapshot -s "#css" Scope snapshot to a CSS selector + agent-browser click @e1 Click element by ref + agent-browser fill @e2 "text" Clear field and type text + agent-browser type @e2 "text" Type without clearing + agent-browser select @e1 "option" Select dropdown option + agent-browser check @e1 Toggle checkbox + agent-browser press Enter Press a key + agent-browser keyboard type "text" Type at current focus + agent-browser hover @e1 Hover element + agent-browser scroll down 500 Scroll down (px) + agent-browser scroll up 500 Scroll up (px) + agent-browser get text @e1 Get text content of element + agent-browser get title Get page title + agent-browser get url Get current URL + agent-browser wait @e1 Wait for element to appear + agent-browser wait --load networkidle Wait for network idle + agent-browser wait --text "Welcome" Wait for text to appear + agent-browser wait 2000 Wait milliseconds + agent-browser find text "X" click Find element by text and click + agent-browser find role button click --name "Submit" + agent-browser find placeholder "Q" type "query" + agent-browser frame @e2 Scope to iframe + agent-browser frame main Return to main frame + agent-browser eval "js code" Run JavaScript in page + agent-browser back Go back + +## Workflow +1. An initial page snapshot is provided — use it immediately, don't re-snapshot. +2. Interact with elements using @refs from the snapshot. +3. After interactions that change the page, call snapshot to see the updated state. +4. Use the new @refs from the latest snapshot for further interactions. +5. Repeat until the task is complete. + +## Rules +1. You are already on the target page. Do NOT navigate to external sites or search engines. +2. @refs are invalidated after page changes — always snapshot again after interactions. +3. Chain independent commands with && to save round-trips. +4. When extracting data, use agent-browser get text or agent-browser eval to pull content. +5. If a command fails, try a different approach (different selector, wait first, use find, etc.). +6. NEVER open new tabs. Always work in the current tab. Do not use agent-browser tab new or agent-browser open. If you need to navigate within the site, click links directly. + +## Output Format +Your final text response is what the user sees. It MUST be a clean, human-readable answer: +- If asked for a price → respond with the product name and price (e.g. "iPhone 15 Pro Max: $1,199") +- If asked for a list → respond with a clean list of items +- If asked to perform an action → confirm what was done +- NEVER dump raw HTML, accessibility trees, or @ref identifiers in your final response +- Be concise and direct — just the answer the user asked for`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export interface AgentResult extends BrowserServiceExecResponse { + output: string; +} + +async function execInBrowser( + browserId: string, + code: string, + timeout: number, + origin: string, +): Promise { + return browserServiceRequest( + "POST", + `/browsers/${browserId}/exec`, + { code, language: "bash", timeout, origin }, + ); +} + +async function getCurrentUrl(browserId: string): Promise { + try { + const result = await execInBrowser( + browserId, + "agent-browser get url", + SNAPSHOT_TIMEOUT, + "agent_get_url", + ); + return (result.stdout || result.result || "").trim(); + } catch { + return ""; + } +} + +async function takeSnapshot(browserId: string): Promise { + try { + const result = await execInBrowser( + browserId, + "agent-browser snapshot -i", + SNAPSHOT_TIMEOUT, + "agent_snapshot", + ); + return (result.stdout || result.result || "").slice(0, SNAPSHOT_MAX_CHARS); + } catch { + return ""; + } +} + +// --------------------------------------------------------------------------- +// Main agent — tool-calling loop via AI SDK +// --------------------------------------------------------------------------- + +export async function executePromptViaBrowserAgent( + prompt: string, + browserId: string, + stepTimeout: number, + logger: typeof _logger, +): Promise { + const debugLog = new AgentDebugLog(browserId); + debugLog.add(`=== AGENT RUN ===`); + debugLog.add(`Time: ${new Date().toISOString()}`); + debugLog.add(`Browser: ${browserId}`); + debugLog.add(`Prompt: ${prompt}\n`); + logger.info("Agent debug log", { path: debugLog.getPath() }); + + const [initialSnapshot, initialUrl] = await Promise.all([ + takeSnapshot(browserId), + getCurrentUrl(browserId), + ]); + + debugLog.add(`URL: ${initialUrl}`); + debugLog.add( + `Snapshot (${initialSnapshot.length} chars):\n${initialSnapshot || "(empty)"}\n`, + ); + debugLog.flush(); + + let toolCallCount = 0; + const allOutputs: string[] = []; + let lastSnapshotResult = initialSnapshot; + const actionLog: string[] = []; + + const browserTool = tool({ + description: + "Run an agent-browser CLI command in the browser. Each call should be one command or a short && chain.", + inputSchema: z.object({ + code: z.string().describe("The agent-browser command(s) to execute"), + }), + execute: async ({ code }) => { + toolCallCount++; + const start = Date.now(); + debugLog.add(`--- [${toolCallCount}] ${code} ---`); + + if (/agent-browser\s+(tab\s+new|open\s)/.test(code)) { + const msg = + "Blocked: opening new tabs/URLs is not allowed. Use click to navigate within the site."; + debugLog.add(`BLOCKED: ${msg}\n`); + actionLog.push(`${toolCallCount}. ${code} → BLOCKED`); + return { error: msg }; + } + + try { + const result = await execInBrowser( + browserId, + code, + stepTimeout, + "agent_action", + ); + const output = (result.stdout || result.result || "").trim(); + + // Ensure only one tab exists and it's in the foreground for live view + try { + await browserServiceRequest("POST", `/browsers/${browserId}/exec`, { + code: [ + `const ctx = page.context();`, + `const pages = ctx.pages();`, + `if (pages.length > 1) {`, + ` const target = pages.find(p => { const u = p.url(); return u && u !== 'about:blank'; }) || pages[pages.length - 1];`, + ` for (const p of pages) { if (p !== target) await p.close().catch(() => {}); }`, + ` page = target;`, + `}`, + `await page.bringToFront();`, + ].join("\n"), + language: "node", + timeout: 5, + origin: "tab_sync", + }); + } catch {} + + const elapsed = Date.now() - start; + + debugLog.add(`Exit: ${result.exitCode} (${elapsed}ms)`); + if (output) debugLog.add(`Output:\n${output}`); + if (result.stderr) debugLog.add(`Stderr:\n${result.stderr}`); + debugLog.add(""); + debugLog.flush(); + + if (code.includes("snapshot")) lastSnapshotResult = output; + if (output) allOutputs.push(output); + + const brief = code.includes("snapshot") + ? `(${output.length} chars)` + : (output || result.stderr || "").slice(0, 120); + const status = + result.exitCode === 0 ? "OK" : `FAIL(${result.exitCode})`; + actionLog.push(`${toolCallCount}. ${code} → ${status}: ${brief}`); + + if (result.exitCode !== 0) { + return { + error: result.stderr || "Command failed", + exit_code: result.exitCode, + output, + }; + } + return { result: output || "(no output)" }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + debugLog.add(`Error: ${msg}\n`); + debugLog.flush(); + actionLog.push( + `${toolCallCount}. ${code} → ERROR: ${msg.slice(0, 120)}`, + ); + return { error: msg }; + } + }, + }); + + try { + const result = await generateText({ + model: getModel("gemini-2.5-flash", "google"), + system: SYSTEM_PROMPT, + messages: [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: `Current URL: ${initialUrl || "(unknown)"}\n\nPage snapshot:\n${initialSnapshot || "(empty — page may still be loading)"}\n\nTask: ${prompt}`, + }, + ], + }, + ], + tools: { browser: browserTool }, + stopWhen: stepCountIs(MAX_STEPS), + temperature: 0, + prepareStep: async ({ stepNumber, messages }) => { + if (actionLog.length === 0) return {}; + return { + messages: [ + ...messages, + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: `ACTION LOG (your commands so far):\n${actionLog.join("\n")}\n\nReview this log before your next action. Common mistakes to check for:\n- Typed text but forgot to press Enter\n- Clicked a link but didn't wait or re-snapshot\n- Used stale @refs from a previous snapshot\n- Scrolled but didn't snapshot to see new content`, + }, + ], + }, + ], + }; + }, + onStepFinish: ({ text, toolCalls }) => { + if (toolCalls?.length) { + debugLog.add(`[Step: ${toolCalls.length} tool call(s)]`); + } + if (text) debugLog.add(`Assistant: ${text}`); + }, + }); + + debugLog.add(`\n=== END: completed (${toolCallCount} tool calls) ===\n`); + await debugLog.flush(); + + return { + output: result.text || "", + stdout: allOutputs.join("\n"), + result: lastSnapshotResult, + stderr: "", + exitCode: 0, + killed: false, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Agent failed", { error: err }); + debugLog.add(`\n=== END: error — ${msg} ===\n`); + await debugLog.flush(); + + return { + output: "", + stdout: allOutputs.join("\n"), + result: lastSnapshotResult, + stderr: msg, + exitCode: 1, + killed: false, + }; + } +} diff --git a/apps/api/src/lib/scrape-interact/browser-service-client.ts b/apps/api/src/lib/scrape-interact/browser-service-client.ts new file mode 100644 index 000000000..371c0dca3 --- /dev/null +++ b/apps/api/src/lib/scrape-interact/browser-service-client.ts @@ -0,0 +1,84 @@ +import { config } from "../../config"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BrowserServiceCreateResponse { + sessionId: string; + cdpUrl: string; + viewUrl: string; + iframeUrl: string; + interactiveIframeUrl: string; + expiresAt: string; +} + +export interface BrowserServiceExecResponse { + stdout: string; + result: string; + stderr: string; + exitCode: number; + killed: boolean; +} + +export interface BrowserServiceDeleteResponse { + ok: boolean; + sessionDurationMs?: number; +} + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +export class BrowserServiceError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +// --------------------------------------------------------------------------- +// HTTP client +// --------------------------------------------------------------------------- + +function browserServiceHeaders( + extra?: Record, +): Record { + const headers: Record = { + "Content-Type": "application/json", + ...(extra ?? {}), + }; + if (config.BROWSER_SERVICE_API_KEY) { + headers["Authorization"] = `Bearer ${config.BROWSER_SERVICE_API_KEY}`; + } + return headers; +} + +/** + * Call the browser service and return parsed JSON. + * Throws `BrowserServiceError` on non-2xx responses. + */ +export async function browserServiceRequest( + method: string, + path: string, + body?: unknown, +): Promise { + const url = `${config.BROWSER_SERVICE_URL}${path}`; + const res = await fetch(url, { + method, + headers: browserServiceHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const text = await res.text(); + throw new BrowserServiceError( + res.status, + `Browser service ${method} ${path} failed (${res.status}): ${text}`, + ); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} diff --git a/apps/api/src/lib/scrape-interact/scrape-replay.ts b/apps/api/src/lib/scrape-interact/scrape-replay.ts new file mode 100644 index 000000000..e7f401afd --- /dev/null +++ b/apps/api/src/lib/scrape-interact/scrape-replay.ts @@ -0,0 +1,370 @@ +import { rewriteUrl } from "../../scraper/scrapeURL/lib/rewriteUrl"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ScrapeContextRow { + id: string; + team_id: string; + url: string | null; + options: unknown; +} + +type ReplayAction = + | { type: "wait"; milliseconds?: number; selector?: string } + | { type: "click"; selector: string; all?: boolean } + | { type: "write"; text: string } + | { type: "press"; key: string } + | { type: "scroll"; direction?: "up" | "down"; selector?: string } + | { type: "executeJavascript"; script: string } + | { type: "screenshot" | "pdf" | "scrape" }; + +interface ScrapeReplayContext { + targetUrl: string; + waitForMs: number; + actions: ReplayAction[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function clampPositiveInteger(value: unknown, max: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + if (value <= 0) return undefined; + return Math.min(Math.floor(value), max); +} + +// --------------------------------------------------------------------------- +// Action sanitization +// --------------------------------------------------------------------------- + +function sanitizeReplayActions(rawActions: unknown): ReplayAction[] { + if (!Array.isArray(rawActions)) return []; + + const actions: ReplayAction[] = []; + + for (const rawAction of rawActions) { + if (!isRecord(rawAction)) continue; + const type = rawAction.type; + + if (type === "wait") { + const milliseconds = clampPositiveInteger(rawAction.milliseconds, 60_000); + const selector = + typeof rawAction.selector === "string" && + rawAction.selector.trim().length > 0 + ? rawAction.selector + : undefined; + if ( + (milliseconds === undefined && !selector) || + (milliseconds && selector) + ) { + continue; + } + actions.push({ + type, + ...(milliseconds !== undefined ? { milliseconds } : {}), + ...(selector ? { selector } : {}), + }); + continue; + } + + if (type === "click") { + if ( + typeof rawAction.selector !== "string" || + rawAction.selector.length === 0 + ) { + continue; + } + actions.push({ + type, + selector: rawAction.selector, + all: rawAction.all === true, + }); + continue; + } + + if (type === "write") { + if (typeof rawAction.text !== "string") continue; + actions.push({ type, text: rawAction.text }); + continue; + } + + if (type === "press") { + if (typeof rawAction.key !== "string") continue; + actions.push({ type, key: rawAction.key }); + continue; + } + + if (type === "scroll") { + const direction = rawAction.direction === "up" ? "up" : "down"; + const selector = + typeof rawAction.selector === "string" && + rawAction.selector.trim().length > 0 + ? rawAction.selector + : undefined; + actions.push({ + type, + direction, + ...(selector ? { selector } : {}), + }); + continue; + } + + if (type === "executeJavascript") { + if (typeof rawAction.script !== "string") continue; + actions.push({ type, script: rawAction.script }); + continue; + } + + if (type === "screenshot" || type === "pdf" || type === "scrape") { + actions.push({ type }); + } + } + + return actions; +} + +// --------------------------------------------------------------------------- +// Build replay context from a saved scrape row +// --------------------------------------------------------------------------- + +export function buildReplayContextFromScrape(scrape: ScrapeContextRow): { + context?: ScrapeReplayContext; + error?: string; +} { + if ( + typeof scrape.url !== "string" || + scrape.url.trim().length === 0 || + scrape.url.startsWith(" { + if (action.type !== "wait") return total; + if (typeof action.milliseconds === "number") + return total + action.milliseconds; + if (action.selector) return total + 1_000; + return total; + }, 0); + + const waitBudgetMs = context.waitForMs + actionWaitMs; + return Math.min(300, Math.max(30, Math.ceil((waitBudgetMs + 45_000) / 1000))); +} + +// --------------------------------------------------------------------------- +// Generate the Playwright replay script +// --------------------------------------------------------------------------- + +export function buildReplayScript(context: ScrapeReplayContext): string { + const payload = JSON.stringify(context); + return ` +const replay = ${payload}; + +const failReplay = (step, error) => { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + throw new Error(\`\${step}: \${reason}\`); +}; + +const listReplayPages = () => page.context().pages().filter(candidate => !candidate.isClosed()); + +const candidateReplayPages = () => { + const pages = listReplayPages(); + const nonExtensionPages = pages.filter( + candidate => !candidate.url().startsWith("chrome-extension://"), + ); + return nonExtensionPages.length > 0 ? nonExtensionPages : pages; +}; + +const syncReplayPage = async () => { + const pages = candidateReplayPages(); + if (pages.length === 0) return; + + const isBlankLikeUrl = (url) => url === "" || url === "about:blank"; + + let selected = null; + + for (let idx = pages.length - 1; idx >= 0; idx -= 1) { + const candidate = pages[idx]; + const url = candidate.url(); + if (isBlankLikeUrl(url)) continue; + try { + const isVisible = await candidate.evaluate( + () => document.visibilityState === "visible", + ); + if (isVisible) { + selected = candidate; + break; + } + } catch {} + } + + if (!selected) { + for (let idx = pages.length - 1; idx >= 0; idx -= 1) { + const candidate = pages[idx]; + if (!isBlankLikeUrl(candidate.url())) { + selected = candidate; + break; + } + } + } + + if (!selected) { + for (let idx = pages.length - 1; idx >= 0; idx -= 1) { + const candidate = pages[idx]; + try { + const isVisible = await candidate.evaluate( + () => document.visibilityState === "visible", + ); + if (isVisible) { + selected = candidate; + break; + } + } catch {} + } + } + + if (!selected) { + selected = pages[pages.length - 1]; + } + page = selected; + + try { + await page.bringToFront(); + } catch {} +}; + +try { + await page.goto(replay.targetUrl, { waitUntil: "domcontentloaded" }); +} catch (error) { + failReplay("Failed to load scrape URL", error); +} + +await syncReplayPage(); + +if (typeof replay.waitForMs === "number" && replay.waitForMs > 0) { + await page.waitForTimeout(Math.min(replay.waitForMs, 30000)); +} + +for (let i = 0; i < replay.actions.length; i += 1) { + const action = replay.actions[i]; + const step = \`Replay action #\${i + 1} (\${action.type})\`; + + try { + await syncReplayPage(); + + switch (action.type) { + case "wait": + if (typeof action.milliseconds === "number") { + await page.waitForTimeout(Math.min(action.milliseconds, 60000)); + } else if (typeof action.selector === "string") { + await page.waitForSelector(action.selector, { timeout: 60000 }); + } + break; + case "click": + if (action.all) { + const locator = page.locator(action.selector); + const count = await locator.count(); + for (let idx = 0; idx < count; idx += 1) { + await locator.nth(idx).click(); + } + } else { + await page.click(action.selector); + } + break; + case "write": + await page.keyboard.type(action.text); + break; + case "press": + await page.keyboard.press(action.key); + break; + case "scroll": + if (typeof action.selector === "string") { + await page.evaluate( + ({ selector, direction }) => { + const el = document.querySelector(selector); + if (!el) { + throw new Error(\`Selector not found: \${selector}\`); + } + const delta = direction === "up" ? -window.innerHeight : window.innerHeight; + if (typeof el.scrollBy === "function") { + el.scrollBy(0, delta); + } else { + window.scrollBy(0, delta); + } + }, + { selector: action.selector, direction: action.direction ?? "down" }, + ); + } else { + await page.mouse.wheel(0, action.direction === "up" ? -800 : 800); + } + break; + case "executeJavascript": { + const wrapped = \`(async () => { \${action.script} })()\`; + await page.evaluate(script => (0, eval)(script), wrapped); + break; + } + case "screenshot": + case "pdf": + case "scrape": + console.log(\`[firecrawl-replay] skipping output-only action: \${action.type}\`); + break; + default: + console.log(\`[firecrawl-replay] skipping unsupported action type: \${String(action.type)}\`); + break; + } + + await syncReplayPage(); + } catch (error) { + failReplay(step, error); + } +} + +await syncReplayPage(); +`; +} diff --git a/apps/api/src/routes/v2.ts b/apps/api/src/routes/v2.ts index 6e6ca9bd9..6e10084e0 100644 --- a/apps/api/src/routes/v2.ts +++ b/apps/api/src/routes/v2.ts @@ -55,6 +55,10 @@ import { agentSignupConfirmController, agentSignupBlockController, } from "../controllers/v2/agent-signup-confirm"; +import { + scrapeInteractController, + scrapeStopInteractiveBrowserController, +} from "../controllers/v2/scrape-browser"; expressWs(express()); @@ -203,6 +207,20 @@ v2Router.get( wrap(scrapeStatusController), ); +v2Router.post( + "/scrape/:jobId/interact", + authMiddleware(RateLimiterMode.BrowserExecute), + validateJobIdParam, + wrap(scrapeInteractController), +); + +v2Router.delete( + "/scrape/:jobId/interact", + authMiddleware(RateLimiterMode.BrowserExecute), + validateJobIdParam, + wrap(scrapeStopInteractiveBrowserController), +); + v2Router.post( "/batch/scrape", authMiddleware(RateLimiterMode.Scrape), diff --git a/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts b/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts index e1652dcbf..862ff643b 100644 --- a/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts +++ b/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts @@ -37,6 +37,7 @@ import { withSpan, setSpanAttributes } from "../../../../lib/otel-tracer"; import { getBrandingScript } from "./brandingScript"; import { abTestFireEngine } from "../../../../services/ab-test"; import { scheduleABComparison } from "../../../../services/ab-test-comparison"; +import { createHash } from "node:crypto"; /** Default wait (ms) before running the branding script when user did not set waitFor. Lets the page settle so DOM/images are ready and reduces JS errors. */ const BRANDING_DEFAULT_WAIT_MS = 2000; @@ -353,6 +354,11 @@ export async function scrapeURLWithFireEngineChromeCDP( meta.internalOptions.saveScrapeResultToGCS, zeroDataRetention: meta.internalOptions.zeroDataRetention, ...(shouldAllowMedia ? { blockMedia: false } : {}), + persistentStorage: meta.options.profile + ? { + uniqueId: `${createHash("sha256").update(meta.internalOptions.teamId).digest("hex").slice(0, 16)}_${meta.options.profile.name}`, + } + : undefined, }; let response = await performFireEngineScrape( diff --git a/apps/api/src/scraper/scrapeURL/engines/fire-engine/scrape.ts b/apps/api/src/scraper/scrapeURL/engines/fire-engine/scrape.ts index c7d80ce01..8cb691601 100644 --- a/apps/api/src/scraper/scrapeURL/engines/fire-engine/scrape.ts +++ b/apps/api/src/scraper/scrapeURL/engines/fire-engine/scrape.ts @@ -53,6 +53,7 @@ export type FireEngineScrapeRequestChromeCDP = { blockMedia?: boolean; mobile?: boolean; disableSmartWaitCache?: boolean; + persistentStorage?: { uniqueId: string }; }; export type FireEngineScrapeRequestTLSClient = { diff --git a/apps/api/src/scraper/scrapeURL/engines/index.ts b/apps/api/src/scraper/scrapeURL/engines/index.ts index 876ea0e21..9f17ff620 100644 --- a/apps/api/src/scraper/scrapeURL/engines/index.ts +++ b/apps/api/src/scraper/scrapeURL/engines/index.ts @@ -467,7 +467,8 @@ export function shouldUseIndex(meta: Meta) { meta.options.maxAge !== 0 && (meta.options.headers === undefined || Object.keys(meta.options.headers).length === 0) && - (meta.options.actions === undefined || meta.options.actions.length === 0) + (meta.options.actions === undefined || meta.options.actions.length === 0) && + meta.options.profile === undefined ); } diff --git a/apps/api/src/scraper/scrapeURL/engines/index/index.ts b/apps/api/src/scraper/scrapeURL/engines/index/index.ts index 955c14f7e..b7b84652f 100644 --- a/apps/api/src/scraper/scrapeURL/engines/index/index.ts +++ b/apps/api/src/scraper/scrapeURL/engines/index/index.ts @@ -14,7 +14,12 @@ import { generateDomainSplits, addOMCEJob, } from "../../../../services"; -import { AgentIndexOnlyError, EngineError, IndexMissError, NoCachedDataError } from "../../error"; +import { + AgentIndexOnlyError, + EngineError, + IndexMissError, + NoCachedDataError, +} from "../../error"; import { shouldParsePDF } from "../../../../controllers/v2/types"; import { hasFormatOfType } from "../../../../lib/format-utils"; @@ -48,7 +53,8 @@ export async function sendDocumentToIndex(meta: Meta, document: Document) { !meta.featureFlags.has("actions") && !hasCustomScreenshotSettings && (meta.options.headers === undefined || - Object.keys(meta.options.headers).length === 0); + Object.keys(meta.options.headers).length === 0) && + meta.options.profile === undefined; if (!shouldCache) { return document; diff --git a/apps/api/src/services/billing/types.ts b/apps/api/src/services/billing/types.ts index f7bda2750..33326465c 100644 --- a/apps/api/src/services/billing/types.ts +++ b/apps/api/src/services/billing/types.ts @@ -6,6 +6,7 @@ export type BillingEndpoint = | "deep_research" | "extract" | "fireclaw" + | "interact" | "llms_txt" | "map" | "scrape" diff --git a/apps/api/src/services/logging/log_job.ts b/apps/api/src/services/logging/log_job.ts index 88d164d15..8a20cdbc5 100644 --- a/apps/api/src/services/logging/log_job.ts +++ b/apps/api/src/services/logging/log_job.ts @@ -155,7 +155,8 @@ type LoggedRequest = { | "deep_research" | "map" | "agent" - | "browser"; + | "browser" + | "interact"; api_version: string; team_id: string; origin?: string; diff --git a/apps/java-sdk/README.md b/apps/java-sdk/README.md index 8c776d60f..bc8191ca6 100644 --- a/apps/java-sdk/README.md +++ b/apps/java-sdk/README.md @@ -82,13 +82,13 @@ Before using the Java SDK, ensure you have the following installed: ### Gradle (Kotlin DSL) ```kotlin -implementation("com.firecrawl:firecrawl-java:1.1.0") +implementation("com.firecrawl:firecrawl-java:1.1.1") ``` ### Gradle (Groovy) ```groovy -implementation 'com.firecrawl:firecrawl-java:1.1.0' +implementation 'com.firecrawl:firecrawl-java:1.1.1' ``` ### Maven @@ -97,7 +97,7 @@ implementation 'com.firecrawl:firecrawl-java:1.1.0' com.firecrawl firecrawl-java - 1.1.0 + 1.1.1 ``` @@ -171,6 +171,27 @@ Document doc = client.scrape("https://example.com/product", System.out.println(doc.getJson()); ``` +#### Scrape-Bound Interactive Session + +Run browser automation against the page context captured by a scrape job: + +```java +Document doc = client.scrape("https://example.com"); +String scrapeId = String.valueOf(doc.getMetadata().get("scrapeId")); + +BrowserExecuteResponse exec = client.interact( + scrapeId, + "console.log(await page.title());", + "node", + 30 +); + +System.out.println(exec.getStdout()); + +BrowserDeleteResponse deleted = client.stopInteractiveBrowser(scrapeId); +System.out.println("Deleted: " + deleted.isSuccess()); +``` + ### Crawl Crawl an entire website. The `crawl()` method polls until completion. @@ -350,14 +371,14 @@ gradle build ```bash gradle jar -# Output: build/libs/firecrawl-java-1.1.0.jar +# Output: build/libs/firecrawl-java-1.1.1.jar ``` ### Install Locally ```bash gradle publishToMavenLocal -# Now available as: com.firecrawl:firecrawl-java:1.1.0 in local Maven repository +# Now available as: com.firecrawl:firecrawl-java:1.1.1 in local Maven repository ``` ## Running Tests diff --git a/apps/java-sdk/build.gradle.kts b/apps/java-sdk/build.gradle.kts index 0b9524755..e87f9f923 100644 --- a/apps/java-sdk/build.gradle.kts +++ b/apps/java-sdk/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.firecrawl" -version = "1.1.0" +version = "1.1.1" java { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/apps/java-sdk/src/main/java/com/firecrawl/client/FirecrawlClient.java b/apps/java-sdk/src/main/java/com/firecrawl/client/FirecrawlClient.java index dcf0408ae..15ed50a30 100644 --- a/apps/java-sdk/src/main/java/com/firecrawl/client/FirecrawlClient.java +++ b/apps/java-sdk/src/main/java/com/firecrawl/client/FirecrawlClient.java @@ -103,6 +103,98 @@ public class FirecrawlClient { return extractData(http.post("/v2/scrape", body, Map.class), Document.class); } + /** + * Interacts with the scrape-bound browser session for a scrape job. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @return the execution result including stdout, stderr, and exit code + */ + public BrowserExecuteResponse interact(String jobId, String code) { + return interact(jobId, code, "node", null, null); + } + + /** + * Interacts with the scrape-bound browser session for a scrape job. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @param language the language: "python", "node", or "bash" (default: "node") + * @param timeout execution timeout in seconds (1-300), or null for default (30) + * @return the execution result including stdout, stderr, and exit code + */ + public BrowserExecuteResponse interact(String jobId, String code, + String language, Integer timeout) { + return interact(jobId, code, language, timeout, null); + } + + /** + * Interacts with the scrape-bound browser session for a scrape job. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @param language the language: "python", "node", or "bash" (default: "node") + * @param timeout execution timeout in seconds (1-300), or null for default (30) + * @param origin optional origin tag for request attribution + * @return the execution result including stdout, stderr, and exit code + */ + public BrowserExecuteResponse interact(String jobId, String code, + String language, Integer timeout, String origin) { + Objects.requireNonNull(jobId, "Job ID is required"); + Objects.requireNonNull(code, "Code is required"); + Map body = new LinkedHashMap<>(); + body.put("code", code); + body.put("language", language != null ? language : "node"); + if (timeout != null) body.put("timeout", timeout); + if (origin != null) body.put("origin", origin); + return http.post("/v2/scrape/" + jobId + "/interact", body, BrowserExecuteResponse.class); + } + + /** + * Stops the interactive browser session for a scrape job. + * + * @param jobId the scrape job ID + * @return the stop response with session duration and billing info + */ + public BrowserDeleteResponse stopInteractiveBrowser(String jobId) { + Objects.requireNonNull(jobId, "Job ID is required"); + return http.delete("/v2/scrape/" + jobId + "/interact", BrowserDeleteResponse.class); + } + + /** + * @deprecated Use {@link #interact(String, String)}. + */ + @Deprecated + public BrowserExecuteResponse scrapeExecute(String jobId, String code) { + return interact(jobId, code); + } + + /** + * @deprecated Use {@link #interact(String, String, String, Integer)}. + */ + @Deprecated + public BrowserExecuteResponse scrapeExecute(String jobId, String code, + String language, Integer timeout) { + return interact(jobId, code, language, timeout); + } + + /** + * @deprecated Use {@link #interact(String, String, String, Integer, String)}. + */ + @Deprecated + public BrowserExecuteResponse scrapeExecute(String jobId, String code, + String language, Integer timeout, String origin) { + return interact(jobId, code, language, timeout, origin); + } + + /** + * @deprecated Use {@link #stopInteractiveBrowser(String)}. + */ + @Deprecated + public BrowserDeleteResponse deleteScrapeBrowser(String jobId) { + return stopInteractiveBrowser(jobId); + } + // ================================================================ // CRAWL // ================================================================ @@ -533,6 +625,96 @@ public class FirecrawlClient { return CompletableFuture.supplyAsync(() -> scrape(url, options), asyncExecutor); } + /** + * Asynchronously executes code in a scrape-bound browser session. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @return a CompletableFuture that resolves to the BrowserExecuteResponse + */ + public CompletableFuture interactAsync(String jobId, String code) { + return CompletableFuture.supplyAsync(() -> interact(jobId, code), asyncExecutor); + } + + /** + * Asynchronously executes code in a scrape-bound browser session. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @param language the language: "python", "node", or "bash" + * @param timeout execution timeout in seconds, or null for default + * @return a CompletableFuture that resolves to the BrowserExecuteResponse + */ + public CompletableFuture interactAsync(String jobId, String code, + String language, Integer timeout) { + return CompletableFuture.supplyAsync( + () -> interact(jobId, code, language, timeout), + asyncExecutor + ); + } + + /** + * Asynchronously executes code in a scrape-bound browser session. + * + * @param jobId the scrape job ID + * @param code the code to execute + * @param language the language: "python", "node", or "bash" + * @param timeout execution timeout in seconds, or null for default + * @param origin optional origin tag for request attribution + * @return a CompletableFuture that resolves to the BrowserExecuteResponse + */ + public CompletableFuture interactAsync(String jobId, String code, + String language, Integer timeout, String origin) { + return CompletableFuture.supplyAsync( + () -> interact(jobId, code, language, timeout, origin), + asyncExecutor + ); + } + + /** + * Asynchronously deletes a scrape-bound browser session. + * + * @param jobId the scrape job ID + * @return a CompletableFuture that resolves to the BrowserDeleteResponse + */ + public CompletableFuture stopInteractiveBrowserAsync(String jobId) { + return CompletableFuture.supplyAsync(() -> stopInteractiveBrowser(jobId), asyncExecutor); + } + + /** + * @deprecated Use {@link #interactAsync(String, String)}. + */ + @Deprecated + public CompletableFuture scrapeExecuteAsync(String jobId, String code) { + return interactAsync(jobId, code); + } + + /** + * @deprecated Use {@link #interactAsync(String, String, String, Integer)}. + */ + @Deprecated + public CompletableFuture scrapeExecuteAsync(String jobId, String code, + String language, Integer timeout) { + return interactAsync(jobId, code, language, timeout); + } + + /** + * @deprecated Use {@link #interactAsync(String, String, String, Integer, String)}. + */ + @Deprecated + public CompletableFuture scrapeExecuteAsync(String jobId, String code, + String language, Integer timeout, String origin) { + return interactAsync(jobId, code, language, timeout, origin); + } + + /** + * @deprecated Use {@link #stopInteractiveBrowserAsync(String)}. + */ + @Deprecated + public CompletableFuture deleteScrapeBrowserAsync(String jobId) { + return stopInteractiveBrowserAsync(jobId); + } + /** * Asynchronously crawls a website and waits for completion. * diff --git a/apps/java-sdk/src/test/java/com/firecrawl/FirecrawlClientTest.java b/apps/java-sdk/src/test/java/com/firecrawl/FirecrawlClientTest.java index 7aa4d5c74..a8a513a78 100644 --- a/apps/java-sdk/src/test/java/com/firecrawl/FirecrawlClientTest.java +++ b/apps/java-sdk/src/test/java/com/firecrawl/FirecrawlClientTest.java @@ -120,6 +120,26 @@ class FirecrawlClientTest { ); } + @Test + void testInteractRequiresJobId() { + FirecrawlClient client = FirecrawlClient.builder() + .apiKey("fc-test-key") + .build(); + assertThrows(NullPointerException.class, () -> + client.interact(null, "console.log('hi')") + ); + } + + @Test + void testInteractRequiresCode() { + FirecrawlClient client = FirecrawlClient.builder() + .apiKey("fc-test-key") + .build(); + assertThrows(NullPointerException.class, () -> + client.interact("job-id", null) + ); + } + @Test void testBrowserDeleteRequiresSessionId() { FirecrawlClient client = FirecrawlClient.builder() @@ -130,6 +150,16 @@ class FirecrawlClientTest { ); } + @Test + void testStopInteractiveBrowserRequiresJobId() { + FirecrawlClient client = FirecrawlClient.builder() + .apiKey("fc-test-key") + .build(); + assertThrows(NullPointerException.class, () -> + client.stopInteractiveBrowser(null) + ); + } + // ================================================================ // E2E TESTS (require FIRECRAWL_API_KEY) // ================================================================ diff --git a/apps/js-sdk/firecrawl/README.md b/apps/js-sdk/firecrawl/README.md index 9142099b4..07f242ac0 100644 --- a/apps/js-sdk/firecrawl/README.md +++ b/apps/js-sdk/firecrawl/README.md @@ -110,6 +110,28 @@ const mapResult = await app.map('https://example.com'); console.log(mapResult); ``` +### Scrape-bound interactive browsing (v2) + +Use a scrape job ID to keep interacting with the replayed browser context: + +```js +const doc = await app.scrape('https://example.com', { + actions: [{ type: 'click', selector: 'a[href="/pricing"]' }], +}); + +const scrapeJobId = doc?.metadata?.scrapeId; +if (!scrapeJobId) throw new Error('Missing scrapeId'); + +const run = await app.interact(scrapeJobId, { + code: 'console.log(await page.url())', + language: 'node', + timeout: 60, +}); +console.log(run.stdout); + +await app.stopInteraction(scrapeJobId); +``` + ### Crawl a website with real‑time updates To receive real‑time updates, start a crawl and attach a watcher. diff --git a/apps/js-sdk/firecrawl/package.json b/apps/js-sdk/firecrawl/package.json index 9a8ed5a17..ffa227aaf 100644 --- a/apps/js-sdk/firecrawl/package.json +++ b/apps/js-sdk/firecrawl/package.json @@ -1,6 +1,6 @@ { "name": "@mendable/firecrawl-js", - "version": "4.16.0", + "version": "4.17.0", "description": "JavaScript SDK for Firecrawl API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/apps/js-sdk/firecrawl/src/__tests__/unit/v2/scrape-browser.unit.test.ts b/apps/js-sdk/firecrawl/src/__tests__/unit/v2/scrape-browser.unit.test.ts new file mode 100644 index 000000000..1ddc6a747 --- /dev/null +++ b/apps/js-sdk/firecrawl/src/__tests__/unit/v2/scrape-browser.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, test, expect, jest } from "@jest/globals"; +import { interact, stopInteraction } from "../../../v2/methods/scrape"; +import { SdkError } from "../../../v2/types"; + +describe("JS SDK v2 scrape-browser methods", () => { + test("interact posts to scrape interact endpoint", async () => { + const post = jest.fn(async () => ({ + status: 200, + data: { + success: true, + stdout: "ok", + exitCode: 0, + }, + })); + + const http = { post } as any; + const response = await interact(http, "job-123", { code: "console.log('ok')" }); + + expect(post).toHaveBeenCalledWith("/v2/scrape/job-123/interact", { + code: "console.log('ok')", + language: "node", + }); + expect(response.success).toBe(true); + expect(response.exitCode).toBe(0); + }); + + test("interact with prompt posts prompt to endpoint", async () => { + const post = jest.fn(async () => ({ + status: 200, + data: { + success: true, + output: "Clicked the button", + liveViewUrl: "https://live.example.com/view", + interactiveLiveViewUrl: "https://live.example.com/interactive", + stdout: "", + exitCode: 0, + }, + })); + + const http = { post } as any; + const response = await interact(http, "job-456", { prompt: "Click the login button" }); + + expect(post).toHaveBeenCalledWith("/v2/scrape/job-456/interact", { + prompt: "Click the login button", + language: "node", + }); + expect(response.success).toBe(true); + expect(response.output).toBe("Clicked the button"); + expect(response.liveViewUrl).toBe("https://live.example.com/view"); + expect(response.interactiveLiveViewUrl).toBe("https://live.example.com/interactive"); + }); + + test("interact throws when neither code nor prompt provided", async () => { + const http = { post: jest.fn() } as any; + await expect(interact(http, "job-123", {})).rejects.toThrow( + "Either 'code' or 'prompt' must be provided" + ); + }); + + test("interact throws on non-200 response", async () => { + const post = jest.fn(async () => ({ + status: 400, + data: { + success: false, + error: "Invalid job ID format", + }, + })); + + const http = { post } as any; + await expect( + interact(http, "bad-id", { code: "console.log('ok')" }) + ).rejects.toBeInstanceOf(SdkError); + }); + + test("stopInteraction calls delete endpoint", async () => { + const del = jest.fn(async () => ({ + status: 200, + data: { + success: true, + }, + })); + + const http = { delete: del } as any; + const response = await stopInteraction(http, "job-123"); + + expect(del).toHaveBeenCalledWith("/v2/scrape/job-123/interact"); + expect(response.success).toBe(true); + }); + + test("stopInteraction throws on non-200 response", async () => { + const del = jest.fn(async () => ({ + status: 404, + data: { + success: false, + error: "Browser session not found.", + }, + })); + + const http = { delete: del } as any; + await expect(stopInteraction(http, "job-123")).rejects.toBeInstanceOf( + SdkError + ); + }); +}); diff --git a/apps/js-sdk/firecrawl/src/v2/client.ts b/apps/js-sdk/firecrawl/src/v2/client.ts index 5dea615a8..f76a6d909 100644 --- a/apps/js-sdk/firecrawl/src/v2/client.ts +++ b/apps/js-sdk/firecrawl/src/v2/client.ts @@ -1,5 +1,9 @@ import { HttpClient } from "./utils/httpClient"; -import { scrape } from "./methods/scrape"; +import { + scrape, + interact as interactMethod, + stopInteraction as stopInteractionMethod, +} from "./methods/scrape"; import { search } from "./methods/search"; import { map as mapMethod } from "./methods/map"; import { @@ -50,6 +54,9 @@ import type { BrowserExecuteResponse, BrowserDeleteResponse, BrowserListResponse, + ScrapeExecuteRequest, + ScrapeExecuteResponse, + ScrapeBrowserDeleteResponse, } from "./types"; import { Watcher } from "./watcher"; import type { WatcherOptions } from "./watcher"; @@ -129,6 +136,46 @@ export class FirecrawlClient { async scrape(url: string, options?: ScrapeOptions): Promise { return scrape(this.http, url, options); } + /** + * Interact with the browser session associated with a scrape job. + * @param jobId Scrape job id. + * @param args Code or prompt to execute, with language/timeout options. + * @returns Execution result including output, stdout, stderr, exitCode, and killed status. + */ + async interact( + jobId: string, + args: ScrapeExecuteRequest + ): Promise { + return interactMethod(this.http, jobId, args); + } + /** + * Stop the interaction session associated with a scrape job. + * @param jobId Scrape job id. + */ + async stopInteraction(jobId: string): Promise { + return stopInteractionMethod(this.http, jobId); + } + /** + * @deprecated Use interact(). + */ + async scrapeExecute( + jobId: string, + args: ScrapeExecuteRequest + ): Promise { + return this.interact(jobId, args); + } + /** + * @deprecated Use stopInteraction(). + */ + async stopInteractiveBrowser(jobId: string): Promise { + return this.stopInteraction(jobId); + } + /** + * @deprecated Use stopInteraction(). + */ + async deleteScrapeBrowser(jobId: string): Promise { + return this.stopInteraction(jobId); + } // Search /** diff --git a/apps/js-sdk/firecrawl/src/v2/methods/scrape.ts b/apps/js-sdk/firecrawl/src/v2/methods/scrape.ts index c2ddfc045..b6d8acda2 100644 --- a/apps/js-sdk/firecrawl/src/v2/methods/scrape.ts +++ b/apps/js-sdk/firecrawl/src/v2/methods/scrape.ts @@ -1,4 +1,10 @@ -import { type Document, type ScrapeOptions } from "../types"; +import { + type Document, + type ScrapeBrowserDeleteResponse, + type ScrapeExecuteRequest, + type ScrapeExecuteResponse, + type ScrapeOptions, +} from "../types"; import { HttpClient } from "../utils/httpClient"; import { ensureValidScrapeOptions } from "../utils/validation"; import { throwForBadResponse, normalizeAxiosError } from "../utils/errorHandler"; @@ -24,3 +30,82 @@ export async function scrape(http: HttpClient, url: string, options?: ScrapeOpti } } +export async function interact( + http: HttpClient, + jobId: string, + args: ScrapeExecuteRequest +): Promise { + if (!jobId || !jobId.trim()) { + throw new Error("Job ID cannot be empty"); + } + const hasCode = args?.code && args.code.trim(); + const hasPrompt = args?.prompt && args.prompt.trim(); + if (!hasCode && !hasPrompt) { + throw new Error("Either 'code' or 'prompt' must be provided"); + } + + const body: Record = {}; + if (hasCode) body.code = args.code; + if (hasPrompt) body.prompt = args.prompt; + body.language = args.language ?? "node"; + if (args.timeout != null) body.timeout = args.timeout; + if (args.origin) body.origin = args.origin; + + try { + const res = await http.post( + `/v2/scrape/${jobId}/interact`, + body + ); + if (res.status !== 200) throwForBadResponse(res, "interact with scrape browser"); + return res.data; + } catch (err: any) { + if (err?.isAxiosError) return normalizeAxiosError(err, "interact with scrape browser"); + throw err; + } +} + +export async function stopInteraction( + http: HttpClient, + jobId: string +): Promise { + if (!jobId || !jobId.trim()) { + throw new Error("Job ID cannot be empty"); + } + + try { + const res = await http.delete( + `/v2/scrape/${jobId}/interact` + ); + if (res.status !== 200) throwForBadResponse(res, "stop interaction"); + return res.data; + } catch (err: any) { + if (err?.isAxiosError) return normalizeAxiosError(err, "stop interaction"); + throw err; + } +} + +/** @deprecated Use interact(). */ +export async function scrapeExecute( + http: HttpClient, + jobId: string, + args: ScrapeExecuteRequest +): Promise { + return interact(http, jobId, args); +} + +/** @deprecated Use stopInteraction(). */ +export async function stopInteractiveBrowser( + http: HttpClient, + jobId: string +): Promise { + return stopInteraction(http, jobId); +} + +/** @deprecated Use stopInteraction(). */ +export async function deleteScrapeBrowser( + http: HttpClient, + jobId: string +): Promise { + return stopInteraction(http, jobId); +} + diff --git a/apps/js-sdk/firecrawl/src/v2/types.ts b/apps/js-sdk/firecrawl/src/v2/types.ts index 21e294a7c..bd974a7b1 100644 --- a/apps/js-sdk/firecrawl/src/v2/types.ts +++ b/apps/js-sdk/firecrawl/src/v2/types.ts @@ -163,6 +163,10 @@ export interface ScrapeOptions { maxAge?: number; minAge?: number; storeInCache?: boolean; + profile?: { + name: string; + saveChanges?: boolean; + }; integration?: string; origin?: string; } @@ -711,6 +715,9 @@ export interface BrowserCreateResponse { export interface BrowserExecuteResponse { success: boolean; + liveViewUrl?: string; + interactiveLiveViewUrl?: string; + output?: string; stdout?: string; result?: string; stderr?: string; @@ -726,6 +733,17 @@ export interface BrowserDeleteResponse { error?: string; } +export interface ScrapeExecuteRequest { + code?: string; + prompt?: string; + language?: "python" | "node" | "bash"; + timeout?: number; + origin?: string; +} + +export type ScrapeExecuteResponse = BrowserExecuteResponse; +export type ScrapeBrowserDeleteResponse = BrowserDeleteResponse; + export interface BrowserSession { id: string; status: string; diff --git a/apps/python-sdk/README.md b/apps/python-sdk/README.md index 50aa82ca2..4b3797051 100644 --- a/apps/python-sdk/README.md +++ b/apps/python-sdk/README.md @@ -132,6 +132,31 @@ map_result = firecrawl.map('https://firecrawl.dev') print(map_result) ``` +### Scrape-bound interactive browsing (v2) + +Use a scrape job ID to keep interacting with the replayed browser context: + +```python +doc = firecrawl.scrape( + "https://example.com", + actions=[{"type": "click", "selector": "a[href='/pricing']"}], +) + +scrape_job_id = doc.metadata_typed.scrape_id +if not scrape_job_id: + raise RuntimeError("Missing scrape job id") + +run = firecrawl.interact( + scrape_job_id, + code="print(await page.url())", + language="python", + timeout=60, +) +print(run.stdout) + +firecrawl.stop_interaction(scrape_job_id) +``` + {/* ### Extracting Structured Data from Websites To extract structured data from websites, use the `extract` method. It takes the URLs to extract data from, a prompt, and a schema as arguments. The schema is a Pydantic model that defines the structure of the extracted data. diff --git a/apps/python-sdk/firecrawl/__init__.py b/apps/python-sdk/firecrawl/__init__.py index c2f626428..04db4894c 100644 --- a/apps/python-sdk/firecrawl/__init__.py +++ b/apps/python-sdk/firecrawl/__init__.py @@ -17,7 +17,7 @@ from .v1 import ( V1ChangeTrackingOptions, ) -__version__ = "4.19.0" +__version__ = "4.20.0" # Define the logger for the Firecrawl project logger: logging.Logger = logging.getLogger("firecrawl") diff --git a/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/aio/test_aio_scrape_request_preparation.py b/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/aio/test_aio_scrape_request_preparation.py index ed76d132a..1ecb54ab5 100644 --- a/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/aio/test_aio_scrape_request_preparation.py +++ b/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/aio/test_aio_scrape_request_preparation.py @@ -1,6 +1,44 @@ import pytest from firecrawl.v2.types import ScrapeOptions, Location -from firecrawl.v2.methods.aio.scrape import _prepare_scrape_request +from firecrawl.v2.methods.aio.scrape import ( + _prepare_scrape_request, + interact, + stop_interaction, +) + + +class _FakeAsyncResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + @property + def text(self): + return str(self._payload) + + +class _FakeAsyncClient: + def __init__( + self, + *, + post_response: _FakeAsyncResponse, + delete_response: _FakeAsyncResponse, + ): + self.post_response = post_response + self.delete_response = delete_response + self.last_post = None + self.last_delete = None + + async def post(self, endpoint, payload): + self.last_post = (endpoint, payload) + return self.post_response + + async def delete(self, endpoint): + self.last_delete = endpoint + return self.delete_response class TestAsyncScrapeRequestPreparation: @@ -48,3 +86,112 @@ class TestAsyncScrapeRequestPreparation: assert payload["maxAge"] == 1000 assert payload["storeInCache"] is False + @pytest.mark.asyncio + async def test_interact_request_and_response_normalization(self): + client = _FakeAsyncClient( + post_response=_FakeAsyncResponse( + 200, + { + "success": True, + "stdout": "ok", + "exitCode": 0, + }, + ), + delete_response=_FakeAsyncResponse(200, {"success": True}), + ) + response = await interact( + client, + "job-123", + "console.log('ok')", + timeout=30, + origin="_unit-test", + ) + + assert client.last_post[0] == "/v2/scrape/job-123/interact" + assert client.last_post[1] == { + "code": "console.log('ok')", + "language": "node", + "timeout": 30, + "origin": "_unit-test", + } + assert response.success is True + assert response.exit_code == 0 + + @pytest.mark.asyncio + async def test_interact_with_prompt(self): + client = _FakeAsyncClient( + post_response=_FakeAsyncResponse( + 200, + { + "success": True, + "output": "Clicked the button", + "liveViewUrl": "https://live.example.com/view", + "interactiveLiveViewUrl": "https://live.example.com/interactive", + "stdout": "", + "exitCode": 0, + }, + ), + delete_response=_FakeAsyncResponse(200, {"success": True}), + ) + response = await interact( + client, + "job-456", + prompt="Click the login button", + ) + + assert client.last_post[0] == "/v2/scrape/job-456/interact" + assert client.last_post[1] == { + "language": "node", + "prompt": "Click the login button", + } + assert response.success is True + assert response.output == "Clicked the button" + assert response.live_view_url == "https://live.example.com/view" + assert response.interactive_live_view_url == "https://live.example.com/interactive" + + @pytest.mark.asyncio + async def test_interact_validates_required_inputs(self): + client = _FakeAsyncClient( + post_response=_FakeAsyncResponse(200, {"success": True}), + delete_response=_FakeAsyncResponse(200, {"success": True}), + ) + with pytest.raises(ValueError, match="Job ID cannot be empty"): + await interact(client, "", "console.log('ok')") + with pytest.raises(ValueError, match="Either 'code' or 'prompt' must be provided"): + await interact(client, "job-123") + + @pytest.mark.asyncio + async def test_interact_raises_when_success_false(self): + client = _FakeAsyncClient( + post_response=_FakeAsyncResponse( + 200, + { + "success": False, + "error": "Replay context is unavailable", + }, + ), + delete_response=_FakeAsyncResponse(200, {"success": True}), + ) + with pytest.raises(Exception, match="Replay context is unavailable"): + await interact(client, "job-123", "console.log('ok')") + + @pytest.mark.asyncio + async def test_stop_interaction_request_and_response_normalization(self): + client = _FakeAsyncClient( + post_response=_FakeAsyncResponse(200, {"success": True}), + delete_response=_FakeAsyncResponse( + 200, + { + "success": True, + "sessionDurationMs": 900, + "creditsBilled": 2, + }, + ), + ) + response = await stop_interaction(client, "job-123") + + assert client.last_delete == "/v2/scrape/job-123/interact" + assert response.success is True + assert response.session_duration_ms == 900 + assert response.credits_billed == 2 + diff --git a/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/test_scrape_request_preparation.py b/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/test_scrape_request_preparation.py index 6002a6b70..a4b4d4867 100644 --- a/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/test_scrape_request_preparation.py +++ b/apps/python-sdk/firecrawl/__tests__/unit/v2/methods/test_scrape_request_preparation.py @@ -1,6 +1,40 @@ import pytest from firecrawl.v2.types import ScrapeOptions, Viewport, ScreenshotAction -from firecrawl.v2.methods.scrape import _prepare_scrape_request +from firecrawl.v2.methods.scrape import ( + _prepare_scrape_request, + interact, + stop_interaction, +) + + +class _FakeResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + self.ok = status_code < 400 + + def json(self): + return self._payload + + @property + def text(self): + return str(self._payload) + + +class _FakeClient: + def __init__(self, *, post_response: _FakeResponse, delete_response: _FakeResponse): + self.post_response = post_response + self.delete_response = delete_response + self.last_post = None + self.last_delete = None + + def post(self, endpoint, payload): + self.last_post = (endpoint, payload) + return self.post_response + + def delete(self, endpoint): + self.last_delete = endpoint + return self.delete_response class TestScrapeRequestPreparation: @@ -106,4 +140,109 @@ class TestScrapeRequestPreparation: integration=" _unit-test ", ) data = _prepare_scrape_request("https://example.com", opts) - assert data["integration"] == "_unit-test" \ No newline at end of file + assert data["integration"] == "_unit-test" + + def test_interact_request_and_response_normalization(self): + client = _FakeClient( + post_response=_FakeResponse( + 200, + { + "success": True, + "stdout": "ok", + "exitCode": 0, + }, + ), + delete_response=_FakeResponse(200, {"success": True}), + ) + response = interact( + client, + "job-123", + "console.log('ok')", + timeout=45, + origin="_unit-test", + ) + + assert client.last_post[0] == "/v2/scrape/job-123/interact" + assert client.last_post[1] == { + "code": "console.log('ok')", + "language": "node", + "timeout": 45, + "origin": "_unit-test", + } + assert response.success is True + assert response.exit_code == 0 + + def test_interact_with_prompt(self): + client = _FakeClient( + post_response=_FakeResponse( + 200, + { + "success": True, + "output": "Clicked the button", + "liveViewUrl": "https://live.example.com/view", + "interactiveLiveViewUrl": "https://live.example.com/interactive", + "stdout": "", + "exitCode": 0, + }, + ), + delete_response=_FakeResponse(200, {"success": True}), + ) + response = interact( + client, + "job-456", + prompt="Click the login button", + ) + + assert client.last_post[0] == "/v2/scrape/job-456/interact" + assert client.last_post[1] == { + "language": "node", + "prompt": "Click the login button", + } + assert response.success is True + assert response.output == "Clicked the button" + assert response.live_view_url == "https://live.example.com/view" + assert response.interactive_live_view_url == "https://live.example.com/interactive" + + def test_interact_validates_required_inputs(self): + client = _FakeClient( + post_response=_FakeResponse(200, {"success": True}), + delete_response=_FakeResponse(200, {"success": True}), + ) + with pytest.raises(ValueError, match="Job ID cannot be empty"): + interact(client, "", "console.log('ok')") + with pytest.raises(ValueError, match="Either 'code' or 'prompt' must be provided"): + interact(client, "job-123") + + def test_interact_raises_when_success_false(self): + client = _FakeClient( + post_response=_FakeResponse( + 200, + { + "success": False, + "error": "Replay context is unavailable", + }, + ), + delete_response=_FakeResponse(200, {"success": True}), + ) + + with pytest.raises(Exception, match="Replay context is unavailable"): + interact(client, "job-123", "console.log('ok')") + + def test_stop_interaction_request_and_response_normalization(self): + client = _FakeClient( + post_response=_FakeResponse(200, {"success": True}), + delete_response=_FakeResponse( + 200, + { + "success": True, + "sessionDurationMs": 1200, + "creditsBilled": 3, + }, + ), + ) + response = stop_interaction(client, "job-123") + + assert client.last_delete == "/v2/scrape/job-123/interact" + assert response.success is True + assert response.session_duration_ms == 1200 + assert response.credits_billed == 3 \ No newline at end of file diff --git a/apps/python-sdk/firecrawl/client.py b/apps/python-sdk/firecrawl/client.py index a1ca1ccb2..bf77b443a 100644 --- a/apps/python-sdk/firecrawl/client.py +++ b/apps/python-sdk/firecrawl/client.py @@ -57,6 +57,11 @@ class V2Proxy: if client_instance: self.scrape = client_instance.scrape + self.interact = client_instance.interact + self.stop_interaction = client_instance.stop_interaction + self.stop_interactive_browser = client_instance.stop_interactive_browser + self.scrape_execute = self.interact + self.delete_scrape_browser = self.stop_interaction self.search = client_instance.search self.crawl = client_instance.crawl self.start_crawl = client_instance.start_crawl @@ -129,6 +134,11 @@ class AsyncV2Proxy: if client_instance: self.scrape = client_instance.scrape + self.interact = client_instance.interact + self.stop_interaction = client_instance.stop_interaction + self.stop_interactive_browser = client_instance.stop_interactive_browser + self.scrape_execute = self.interact + self.delete_scrape_browser = self.stop_interaction self.search = client_instance.search self.crawl = client_instance.crawl self.start_crawl = client_instance.start_crawl @@ -221,6 +231,11 @@ class Firecrawl: self.v2 = V2Proxy(self._v2_client) self.scrape = self._v2_client.scrape + self.interact = self._v2_client.interact + self.stop_interaction = self._v2_client.stop_interaction + self.stop_interactive_browser = self._v2_client.stop_interactive_browser + self.scrape_execute = self.interact + self.delete_scrape_browser = self.stop_interaction self.search = self._v2_client.search self.map = self._v2_client.map @@ -293,6 +308,11 @@ class AsyncFirecrawl: # Expose v2 async surface directly on the top-level client for ergonomic access # Keep method names aligned with the sync client self.scrape = self._v2_client.scrape + self.interact = self._v2_client.interact + self.stop_interaction = self._v2_client.stop_interaction + self.stop_interactive_browser = self._v2_client.stop_interactive_browser + self.scrape_execute = self.interact + self.delete_scrape_browser = self.stop_interaction self.search = self._v2_client.search self.map = self._v2_client.map diff --git a/apps/python-sdk/firecrawl/v2/client.py b/apps/python-sdk/firecrawl/v2/client.py index 54b65a4f8..43990c683 100644 --- a/apps/python-sdk/firecrawl/v2/client.py +++ b/apps/python-sdk/firecrawl/v2/client.py @@ -185,6 +185,82 @@ class FirecrawlClient: ) if any(v is not None for v in [formats, headers, include_tags, exclude_tags, only_main_content, timeout, wait_for, mobile, parsers, actions, location, skip_tls_verification, remove_base64_images, fast_mode, use_mock, block_ads, proxy, max_age, store_in_cache, integration]) else None return scrape_module.scrape(self.http_client, url, options) + def interact( + self, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, + ): + """ + Interact with the browser session associated with a scrape job. + + Either ``code`` or ``prompt`` must be provided. + + Args: + job_id: Scrape job ID + code: Code to execute (optional if prompt is provided) + prompt: Natural-language instruction for the browser agent (optional if code is provided) + language: Programming language ("python", "node", or "bash") + timeout: Execution timeout in seconds (1-300) + origin: Optional request origin tag + + Returns: + BrowserExecuteResponse with execution result + """ + return scrape_module.interact( + self.http_client, + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + def stop_interaction(self, job_id: str): + """ + Stop the interaction session associated with a scrape job. + + Args: + job_id: Scrape job ID + + Returns: + BrowserDeleteResponse + """ + return scrape_module.stop_interaction(self.http_client, job_id) + + def stop_interactive_browser(self, job_id: str): + """Deprecated alias for stop_interaction().""" + return self.stop_interaction(job_id) + + def scrape_execute( + self, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, + ): + """Deprecated alias for interact().""" + return self.interact( + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + def delete_scrape_browser(self, job_id: str): + """Deprecated alias for stop_interaction().""" + return self.stop_interaction(job_id) + def search( self, query: str, diff --git a/apps/python-sdk/firecrawl/v2/client_async.py b/apps/python-sdk/firecrawl/v2/client_async.py index aaae8899e..5f368ac7f 100644 --- a/apps/python-sdk/firecrawl/v2/client_async.py +++ b/apps/python-sdk/firecrawl/v2/client_async.py @@ -91,6 +91,60 @@ class AsyncFirecrawlClient: options = ScrapeOptions(**{k: v for k, v in kwargs.items() if v is not None}) if kwargs else None return await async_scrape.scrape(self.async_http_client, url, options) + async def interact( + self, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, + ): + return await async_scrape.interact( + self.async_http_client, + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + async def stop_interaction(self, job_id: str): + return await async_scrape.stop_interaction( + self.async_http_client, + job_id, + ) + + async def stop_interactive_browser(self, job_id: str): + """Deprecated alias for stop_interaction().""" + return await self.stop_interaction(job_id) + + async def scrape_execute( + self, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, + ): + """Deprecated alias for interact().""" + return await self.interact( + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + async def delete_scrape_browser(self, job_id: str): + """Deprecated alias for stop_interaction().""" + return await self.stop_interaction(job_id) + # Search async def search( self, diff --git a/apps/python-sdk/firecrawl/v2/methods/aio/scrape.py b/apps/python-sdk/firecrawl/v2/methods/aio/scrape.py index 848f6e278..df1065a93 100644 --- a/apps/python-sdk/firecrawl/v2/methods/aio/scrape.py +++ b/apps/python-sdk/firecrawl/v2/methods/aio/scrape.py @@ -1,5 +1,10 @@ -from typing import Optional, Dict, Any -from ...types import ScrapeOptions, Document +from typing import Optional, Dict, Any, Literal +from ...types import ( + ScrapeOptions, + Document, + BrowserExecuteResponse, + BrowserDeleteResponse, +) from ...utils.normalize import normalize_document_input from ...utils.error_handler import handle_response_error from ...utils.validation import prepare_scrape_options, validate_scrape_options @@ -31,3 +36,109 @@ async def scrape(client: AsyncHttpClient, url: str, options: Optional[ScrapeOpti normalized = normalize_document_input(document_data) return Document(**normalized) + +async def interact( + client: AsyncHttpClient, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, +) -> BrowserExecuteResponse: + if not job_id or not job_id.strip(): + raise ValueError("Job ID cannot be empty") + has_code = code and code.strip() + has_prompt = prompt and prompt.strip() + if not has_code and not has_prompt: + raise ValueError("Either 'code' or 'prompt' must be provided") + + payload: Dict[str, Any] = { + "language": language, + } + if has_code: + payload["code"] = code + if has_prompt: + payload["prompt"] = prompt + if timeout is not None: + payload["timeout"] = timeout + if origin is not None: + payload["origin"] = origin + + response = await client.post(f"/v2/scrape/{job_id}/interact", payload) + if response.status_code >= 400: + handle_response_error(response, "interact with scrape browser") + + body = response.json() + if not body.get("success"): + raise Exception(body.get("error", "Unknown error occurred")) + + normalized = dict(body) + if "exitCode" in normalized and "exit_code" not in normalized: + normalized["exit_code"] = normalized["exitCode"] + if "liveViewUrl" in normalized and "live_view_url" not in normalized: + normalized["live_view_url"] = normalized["liveViewUrl"] + if "interactiveLiveViewUrl" in normalized and "interactive_live_view_url" not in normalized: + normalized["interactive_live_view_url"] = normalized["interactiveLiveViewUrl"] + return BrowserExecuteResponse(**normalized) + + +async def stop_interaction( + client: AsyncHttpClient, + job_id: str, +) -> BrowserDeleteResponse: + if not job_id or not job_id.strip(): + raise ValueError("Job ID cannot be empty") + + response = await client.delete(f"/v2/scrape/{job_id}/interact") + if response.status_code >= 400: + handle_response_error(response, "stop interaction") + + body = response.json() + normalized = dict(body) + if "sessionDurationMs" in normalized and "session_duration_ms" not in normalized: + normalized["session_duration_ms"] = normalized["sessionDurationMs"] + if "creditsBilled" in normalized and "credits_billed" not in normalized: + normalized["credits_billed"] = normalized["creditsBilled"] + + return BrowserDeleteResponse(**normalized) + + +async def stop_interactive_browser( + client: AsyncHttpClient, + job_id: str, +) -> BrowserDeleteResponse: + """Deprecated alias for stop_interaction().""" + return await stop_interaction(client, job_id) + + +async def scrape_execute( + client: AsyncHttpClient, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, +) -> BrowserExecuteResponse: + """Deprecated alias for interact().""" + return await interact( + client, + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + +async def delete_scrape_browser( + client: AsyncHttpClient, + job_id: str, +) -> BrowserDeleteResponse: + """Deprecated alias for stop_interaction().""" + return await stop_interaction(client, job_id) + diff --git a/apps/python-sdk/firecrawl/v2/methods/scrape.py b/apps/python-sdk/firecrawl/v2/methods/scrape.py index 733f5e318..073a41890 100644 --- a/apps/python-sdk/firecrawl/v2/methods/scrape.py +++ b/apps/python-sdk/firecrawl/v2/methods/scrape.py @@ -2,8 +2,13 @@ Scraping functionality for Firecrawl v2 API. """ -from typing import Optional, Dict, Any -from ..types import ScrapeOptions, Document +from typing import Optional, Dict, Any, Literal +from ..types import ( + ScrapeOptions, + Document, + BrowserExecuteResponse, + BrowserDeleteResponse, +) from ..utils.normalize import normalize_document_input from ..utils import HttpClient, handle_response_error, prepare_scrape_options, validate_scrape_options @@ -61,4 +66,139 @@ def scrape(client: HttpClient, url: str, options: Optional[ScrapeOptions] = None document_data = body.get("data", {}) normalized = normalize_document_input(document_data) - return Document(**normalized) \ No newline at end of file + return Document(**normalized) + + +def interact( + client: HttpClient, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, +) -> BrowserExecuteResponse: + """ + Interact with the scrape-bound browser session for a scrape job. + + Either ``code`` or ``prompt`` must be provided. When ``prompt`` is given + the server runs an AI agent that translates the natural-language instruction + into browser actions. + + Args: + client: HTTP client instance + job_id: Scrape job ID + code: Code to execute (optional if prompt is provided) + prompt: Natural-language instruction for the browser agent (optional if code is provided) + language: Programming language ("python", "node", or "bash") + timeout: Execution timeout in seconds (1-300) + origin: Optional request origin tag + + Returns: + BrowserExecuteResponse with execution output + """ + if not job_id or not job_id.strip(): + raise ValueError("Job ID cannot be empty") + has_code = code and code.strip() + has_prompt = prompt and prompt.strip() + if not has_code and not has_prompt: + raise ValueError("Either 'code' or 'prompt' must be provided") + + body: Dict[str, Any] = { + "language": language, + } + if has_code: + body["code"] = code + if has_prompt: + body["prompt"] = prompt + if timeout is not None: + body["timeout"] = timeout + if origin is not None: + body["origin"] = origin + + response = client.post(f"/v2/scrape/{job_id}/interact", body) + if not response.ok: + handle_response_error(response, "interact with scrape browser") + + payload = response.json() + if not payload.get("success"): + raise Exception(payload.get("error", "Unknown error occurred")) + + normalized = dict(payload) + if "exitCode" in normalized and "exit_code" not in normalized: + normalized["exit_code"] = normalized["exitCode"] + if "liveViewUrl" in normalized and "live_view_url" not in normalized: + normalized["live_view_url"] = normalized["liveViewUrl"] + if "interactiveLiveViewUrl" in normalized and "interactive_live_view_url" not in normalized: + normalized["interactive_live_view_url"] = normalized["interactiveLiveViewUrl"] + return BrowserExecuteResponse(**normalized) + + +def stop_interaction( + client: HttpClient, + job_id: str, +) -> BrowserDeleteResponse: + """ + Stop the interaction session for a scrape job. + + Args: + client: HTTP client instance + job_id: Scrape job ID + + Returns: + BrowserDeleteResponse + """ + if not job_id or not job_id.strip(): + raise ValueError("Job ID cannot be empty") + + response = client.delete(f"/v2/scrape/{job_id}/interact") + if not response.ok: + handle_response_error(response, "stop interaction") + + payload = response.json() + normalized = dict(payload) + if "sessionDurationMs" in normalized and "session_duration_ms" not in normalized: + normalized["session_duration_ms"] = normalized["sessionDurationMs"] + if "creditsBilled" in normalized and "credits_billed" not in normalized: + normalized["credits_billed"] = normalized["creditsBilled"] + + return BrowserDeleteResponse(**normalized) + + +def stop_interactive_browser( + client: HttpClient, + job_id: str, +) -> BrowserDeleteResponse: + """Deprecated alias for stop_interaction().""" + return stop_interaction(client, job_id) + + +def scrape_execute( + client: HttpClient, + job_id: str, + code: Optional[str] = None, + *, + prompt: Optional[str] = None, + language: Literal["python", "node", "bash"] = "node", + timeout: Optional[int] = None, + origin: Optional[str] = None, +) -> BrowserExecuteResponse: + """Deprecated alias for interact().""" + return interact( + client, + job_id, + code, + prompt=prompt, + language=language, + timeout=timeout, + origin=origin, + ) + + +def delete_scrape_browser( + client: HttpClient, + job_id: str, +) -> BrowserDeleteResponse: + """Deprecated alias for stop_interaction().""" + return stop_interaction(client, job_id) \ No newline at end of file diff --git a/apps/python-sdk/firecrawl/v2/types.py b/apps/python-sdk/firecrawl/v2/types.py index 6f5bf37f6..e19653262 100644 --- a/apps/python-sdk/firecrawl/v2/types.py +++ b/apps/python-sdk/firecrawl/v2/types.py @@ -543,6 +543,7 @@ class ScrapeOptions(BaseModel): max_age: Optional[int] = None min_age: Optional[int] = None store_in_cache: Optional[bool] = None + profile: Optional[Dict[str, Any]] = None integration: Optional[str] = None @field_validator("formats") @@ -845,6 +846,9 @@ class BrowserExecuteResponse(BaseModel): """Response from executing code in a browser session.""" success: bool + live_view_url: Optional[str] = None + interactive_live_view_url: Optional[str] = None + output: Optional[str] = None stdout: Optional[str] = None result: Optional[str] = None stderr: Optional[str] = None diff --git a/apps/python-sdk/firecrawl/v2/utils/validation.py b/apps/python-sdk/firecrawl/v2/utils/validation.py index 2de93f69c..0a13f2dcb 100644 --- a/apps/python-sdk/firecrawl/v2/utils/validation.py +++ b/apps/python-sdk/firecrawl/v2/utils/validation.py @@ -714,11 +714,22 @@ def prepare_scrape_options(options: Optional[ScrapeOptions]) -> Optional[Dict[st converted_parsers.append(parser_data) scrape_data["parsers"] = converted_parsers elif key == "location": - # Handle location conversion if isinstance(value, dict): scrape_data["location"] = value else: scrape_data["location"] = value.model_dump(exclude_none=True) + elif key == "profile": + if isinstance(value, dict): + profile_data = { + "name": value["name"], + "saveChanges": value.get("save_changes", value.get("saveChanges", True)), + } + else: + profile_data = { + "name": value.name, + "saveChanges": getattr(value, "save_changes", getattr(value, "saveChanges", True)), + } + scrape_data["profile"] = profile_data else: # For fields that don't need conversion, use as-is scrape_data[key] = value diff --git a/apps/rust-sdk/Cargo.lock b/apps/rust-sdk/Cargo.lock index 90c4fc326..120fe7e5e 100644 --- a/apps/rust-sdk/Cargo.lock +++ b/apps/rust-sdk/Cargo.lock @@ -685,7 +685,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "firecrawl" -version = "1.2.2" +version = "1.2.3" dependencies = [ "assert_matches", "axum", diff --git a/apps/rust-sdk/Cargo.toml b/apps/rust-sdk/Cargo.toml index 502d31f6e..5d86ffe2f 100644 --- a/apps/rust-sdk/Cargo.toml +++ b/apps/rust-sdk/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "firecrawl" author= "Mendable.ai" -version = "1.2.2" +version = "1.3.0" edition = "2021" license = "MIT" homepage = "https://www.firecrawl.dev/" diff --git a/apps/rust-sdk/README.md b/apps/rust-sdk/README.md index 248dde1b0..771272b56 100644 --- a/apps/rust-sdk/README.md +++ b/apps/rust-sdk/README.md @@ -141,6 +141,32 @@ match map_result { } ``` +### Scrape-bound interactive browsing (v2) + +Use a scrape job ID to keep interacting with the replayed browser context: + +```rust +use firecrawl::v2::{Client, ScrapeExecuteLanguage, ScrapeExecuteOptions}; + +let client = Client::new("fc-YOUR-API-KEY")?; +let job_id = "550e8400-e29b-41d4-a716-446655440000"; + +let run = client + .interact( + job_id, + ScrapeExecuteOptions { + code: Some("console.log(await page.url())".to_string()), + language: Some(ScrapeExecuteLanguage::Node), + timeout: Some(60), + ..Default::default() + }, + ) + .await?; + +println!("{:?}", run.stdout); +client.stop_interaction(job_id).await?; +``` + ## Error Handling The SDK handles errors returned by the Firecrawl API and by our dependencies, and combines them into the `FirecrawlError` enum, implementing `Error`, `Debug` and `Display`. All of our methods return a `Result`. diff --git a/apps/rust-sdk/src/v2/scrape.rs b/apps/rust-sdk/src/v2/scrape.rs index 83c934b04..94db9c8fa 100644 --- a/apps/rust-sdk/src/v2/scrape.rs +++ b/apps/rust-sdk/src/v2/scrape.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use super::client::Client; use super::types::{ Action, AttributeSelector, ChangeTrackingOptions, Document, Format, JsonOptions, - LocationConfig, ProxyType, ScreenshotOptions, + LocationConfig, ProfileConfig, ProxyType, ScreenshotOptions, }; use crate::FirecrawlError; @@ -73,6 +73,9 @@ pub struct ScrapeOptions { /// Store the result in cache for future requests. pub store_in_cache: Option, + /// Persistent browser profile for maintaining state across scrapes. + pub profile: Option, + /// Integration identifier for tracking. pub integration: Option, @@ -123,6 +126,76 @@ struct ScrapeResponse { warning: Option, } +/// Supported languages for scrape-bound browser execution. +#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ScrapeExecuteLanguage { + Python, + Node, + Bash, +} + +/// Options for executing code or a prompt in a scrape-bound browser session. +/// +/// At least one of `code` or `prompt` must be provided. +#[serde_with::skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScrapeExecuteOptions { + /// Code to execute (optional if `prompt` is provided). + pub code: Option, + /// Natural-language instruction for the browser agent (optional if `code` is provided). + pub prompt: Option, + /// Runtime language for the code. + pub language: Option, + /// Execution timeout in seconds. + pub timeout: Option, + /// Optional origin tag for request attribution. + pub origin: Option, +} + +/// Response from scrape-bound browser execution. +#[serde_with::skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScrapeExecuteResponse { + /// Whether the request succeeded. + pub success: bool, + /// Live-view URL for the browser session. + pub live_view_url: Option, + /// Interactive live-view URL for the browser session. + pub interactive_live_view_url: Option, + /// Agent output when a prompt was used. + pub output: Option, + /// Captured stdout from execution. + pub stdout: Option, + /// Optional execution result payload. + pub result: Option, + /// Captured stderr from execution. + pub stderr: Option, + /// Process exit code. + pub exit_code: Option, + /// Whether execution was killed by timeout or system. + pub killed: Option, + /// Error message when execution fails. + pub error: Option, +} + +/// Response from deleting a scrape-bound browser session. +#[serde_with::skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScrapeBrowserDeleteResponse { + /// Whether the delete request succeeded. + pub success: bool, + /// Session duration in milliseconds when available. + pub session_duration_ms: Option, + /// Credits billed when available. + pub credits_billed: Option, + /// Error message when deletion fails. + pub error: Option, +} + impl Client { /// Scrapes a URL and returns the content in the requested formats. /// @@ -248,6 +321,109 @@ impl Client { let document = self.scrape(url, options).await?; Ok(document.json.unwrap_or(Value::Null)) } + + /// Interacts with the browser session associated with a scrape job. + /// + /// # Arguments + /// + /// * `job_id` - The scrape job ID. + /// * `options` - Execution options including code and runtime config. + /// + /// # Returns + /// + /// A `ScrapeExecuteResponse` containing execution output. + pub async fn interact( + &self, + job_id: impl AsRef, + options: ScrapeExecuteOptions, + ) -> Result { + let has_code = options.code.as_ref().is_some_and(|c| !c.trim().is_empty()); + let has_prompt = options.prompt.as_ref().is_some_and(|p| !p.trim().is_empty()); + if !has_code && !has_prompt { + return Err(FirecrawlError::Missuse( + "Either 'code' or 'prompt' must be provided".into(), + )); + } + + let mut body = options; + if body.language.is_none() { + body.language = Some(ScrapeExecuteLanguage::Node); + } + + let response = self + .client + .post(self.url(&format!("/scrape/{}/interact", job_id.as_ref()))) + .headers(self.prepare_headers(None)) + .json(&body) + .send() + .await + .map_err(|e| { + FirecrawlError::HttpError( + format!("Interacting with scrape browser for {}", job_id.as_ref()), + e, + ) + })?; + + self.handle_response(response, "scrape interact").await + } + + /// Stops the interaction session associated with a scrape job. + /// + /// # Arguments + /// + /// * `job_id` - The scrape job ID. + /// + /// # Returns + /// + /// A `ScrapeBrowserDeleteResponse` indicating stop status. + pub async fn stop_interaction( + &self, + job_id: impl AsRef, + ) -> Result { + let response = self + .client + .delete(self.url(&format!("/scrape/{}/interact", job_id.as_ref()))) + .headers(self.prepare_headers(None)) + .send() + .await + .map_err(|e| { + FirecrawlError::HttpError( + format!("Stopping interaction for {}", job_id.as_ref()), + e, + ) + })?; + + self.handle_response(response, "stop interaction") + .await + } + + /// Deprecated alias for [`Client::interact`]. + #[deprecated(note = "Use interact() instead")] + pub async fn scrape_execute( + &self, + job_id: impl AsRef, + options: ScrapeExecuteOptions, + ) -> Result { + self.interact(job_id, options).await + } + + /// Deprecated alias for [`Client::stop_interaction`]. + #[deprecated(note = "Use stop_interaction() instead")] + pub async fn stop_interactive_browser( + &self, + job_id: impl AsRef, + ) -> Result { + self.stop_interaction(job_id).await + } + + /// Deprecated alias for [`Client::stop_interaction`]. + #[deprecated(note = "Use stop_interaction() instead")] + pub async fn delete_scrape_browser( + &self, + job_id: impl AsRef, + ) -> Result { + self.stop_interaction(job_id).await + } } #[cfg(test)] @@ -401,4 +577,144 @@ mod tests { assert!(result.is_err()); mock.assert(); } + + #[tokio::test] + async fn test_interact_with_mock() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/v2/scrape/job-123/interact") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "success": true, + "stdout": "ok", + "result": "done", + "stderr": "", + "exitCode": 0, + "killed": false + }) + .to_string(), + ) + .create(); + + let client = Client::new_selfhosted(server.url(), Some("test_key")).unwrap(); + let response = client + .interact( + "job-123", + ScrapeExecuteOptions { + code: Some("console.log('ok')".to_string()), + timeout: Some(30), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert!(response.success); + assert_eq!(response.exit_code, Some(0)); + assert_eq!(response.result, Some("done".to_string())); + mock.assert(); + } + + #[tokio::test] + async fn test_interact_with_prompt() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/v2/scrape/job-789/interact") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "success": true, + "output": "Clicked the login button", + "liveViewUrl": "https://live.example.com/view", + "interactiveLiveViewUrl": "https://live.example.com/interactive", + "stdout": "", + "exitCode": 0, + "killed": false + }) + .to_string(), + ) + .create(); + + let client = Client::new_selfhosted(server.url(), Some("test_key")).unwrap(); + let response = client + .interact( + "job-789", + ScrapeExecuteOptions { + prompt: Some("Click the login button".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert!(response.success); + assert_eq!(response.output, Some("Clicked the login button".to_string())); + assert_eq!(response.live_view_url, Some("https://live.example.com/view".to_string())); + assert_eq!(response.interactive_live_view_url, Some("https://live.example.com/interactive".to_string())); + mock.assert(); + } + + #[tokio::test] + async fn test_stop_interaction_with_mock() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("DELETE", "/v2/scrape/job-123/interact") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "success": true, + "sessionDurationMs": 1200, + "creditsBilled": 3 + }) + .to_string(), + ) + .create(); + + let client = Client::new_selfhosted(server.url(), Some("test_key")).unwrap(); + let response = client.stop_interaction("job-123").await.unwrap(); + + assert!(response.success); + assert_eq!(response.session_duration_ms, Some(1200)); + assert_eq!(response.credits_billed, Some(3)); + mock.assert(); + } + + #[tokio::test] + async fn test_interact_error_response() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/v2/scrape/job-404/interact") + .with_status(404) + .with_header("content-type", "application/json") + .with_body( + json!({ + "success": false, + "error": "Job not found." + }) + .to_string(), + ) + .create(); + + let client = Client::new_selfhosted(server.url(), Some("test_key")).unwrap(); + let result = client + .interact( + "job-404", + ScrapeExecuteOptions { + code: Some("console.log('ok')".to_string()), + ..Default::default() + }, + ) + .await; + + assert!(result.is_err()); + mock.assert(); + } } diff --git a/apps/rust-sdk/src/v2/types.rs b/apps/rust-sdk/src/v2/types.rs index 5d119fd42..06736f0b0 100644 --- a/apps/rust-sdk/src/v2/types.rs +++ b/apps/rust-sdk/src/v2/types.rs @@ -112,6 +112,17 @@ pub struct LocationConfig { pub languages: Option>, } +/// Persistent browser profile for maintaining state across scrapes. +#[serde_with::skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ProfileConfig { + /// Profile name (1–128 characters). + pub name: String, + /// Whether to persist changes made during the session (defaults to true). + pub save_changes: Option, +} + /// Proxy type for scraping. #[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "lowercase")]