Skip to content

Commit af8adaf

Browse files
authored
feat(api): video format (firecrawl#3516)
1 parent 3de6414 commit af8adaf

62 files changed

Lines changed: 703 additions & 60 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/__tests__/snips/v2/audio-routing.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,30 @@ describe("Audio format engine routing (buildFallbackList)", () => {
8484
expect(engines).not.toContain("fetch");
8585
});
8686

87+
it("routes video format to chrome-cdp before tlsclient", async () => {
88+
const fallback = await buildFallbackList(buildStubMeta(["video"]));
89+
const engines = fallback.map(f => f.engine);
90+
91+
expect(engines).toEqual([
92+
"fire-engine;chrome-cdp",
93+
"fire-engine(retry);chrome-cdp",
94+
"fire-engine;tlsclient",
95+
]);
96+
});
97+
98+
it("excludes index and non-browser engines when video format is requested", async () => {
99+
const fallback = await buildFallbackList(buildStubMeta(["video"]));
100+
const engines = fallback.map(f => f.engine);
101+
102+
expect(engines).toContain("fire-engine;chrome-cdp");
103+
expect(engines).toContain("fire-engine(retry);chrome-cdp");
104+
expect(engines).not.toContain("index");
105+
expect(engines).not.toContain("index;documents");
106+
expect(engines).not.toContain("fire-engine;chrome-cdp;stealth");
107+
expect(engines).not.toContain("fire-engine(retry);chrome-cdp;stealth");
108+
expect(engines).not.toContain("fetch");
109+
});
110+
87111
it("still allows chrome-cdp for non-audio requests", async () => {
88112
const fallback = await buildFallbackList(buildStubMeta([]));
89113
const engines = fallback.map(f => f.engine);

apps/api/src/__tests__/snips/v2/scrape-lockdown.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,33 +99,34 @@ describeIf(TEST_PRODUCTION)("V2 Scrape Lockdown Mode", () => {
9999
);
100100

101101
test(
102-
"should serve cache and skip audio fetch even when audio format is requested",
102+
"should serve cache and skip media fetch even when audio and video formats are requested",
103103
async () => {
104104
const id = crypto.randomUUID();
105-
const url = "https://firecrawl.dev/?testAudioGate=" + id;
105+
const url = "https://firecrawl.dev/?testMediaGate=" + id;
106106

107107
const seed = await scrape({ url }, identity);
108108
expect(seed).toBeDefined();
109109
expect(seed.metadata.cacheState).toBe("miss");
110110

111111
await new Promise(resolve => setTimeout(resolve, 20000));
112112

113-
// Without the lockdown audio gate this would either throw
114-
// AudioUnsupportedUrlError (firecrawl.dev is not an audio source) or
113+
// Without the lockdown media gate this would either throw an unsupported
114+
// media URL error (firecrawl.dev is not an avgrab source) or
115115
// POST to AVGRAB_SERVICE_URL with the target URL. Success here implies
116116
// the gate short-circuited before any outbound call.
117117
const data = await scrape(
118118
{
119119
url,
120120
lockdown: true,
121-
formats: ["markdown", "audio"],
121+
formats: ["markdown", "audio", "video"],
122122
},
123123
identity,
124124
);
125125

126126
expect(data).toBeDefined();
127127
expect(data.metadata.cacheState).toBe("hit");
128128
expect(data.audio).toBeUndefined();
129+
expect(data.video).toBeUndefined();
129130
},
130131
scrapeTimeout * 2 + 20000,
131132
);

apps/api/src/__tests__/snips/v2/scrape.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,6 +2065,42 @@ describe("Attribute formats", () => {
20652065
);
20662066
});
20672067

2068+
describeIf(!TEST_SELF_HOST)("Video format", () => {
2069+
it.concurrent(
2070+
"should return video field with signed GCS URL for a supported video URL",
2071+
async () => {
2072+
const data = await scrape(
2073+
{
2074+
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
2075+
formats: ["video"],
2076+
},
2077+
identity,
2078+
);
2079+
2080+
expect(data.video).toBeDefined();
2081+
expect(typeof data.video).toBe("string");
2082+
expect(data.video).toMatch(/^https:\/\//);
2083+
},
2084+
scrapeTimeout * 2,
2085+
);
2086+
2087+
it.concurrent(
2088+
"should reject unsupported URL with video format",
2089+
async () => {
2090+
const result = await scrapeWithFailure(
2091+
{
2092+
url: "https://example.com",
2093+
formats: ["video"],
2094+
},
2095+
identity,
2096+
);
2097+
2098+
expect(result.error).toMatch(/video/i);
2099+
},
2100+
scrapeTimeout,
2101+
);
2102+
});
2103+
20682104
describe("UUID validation", () => {
20692105
it.concurrent(
20702106
"should reject invalid UUID 'None' for scrape status",

apps/api/src/__tests__/snips/v2/types-validation.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,24 @@ describe("V2 Types Validation", () => {
6060
expect(result.formats).toEqual([{ type: "markdown" }, { type: "html" }]);
6161
});
6262

63+
it("should accept video format as string and object", () => {
64+
const stringInput: ScrapeRequestInput = {
65+
url: "https://example.com",
66+
formats: ["video"],
67+
};
68+
const objectInput: ScrapeRequestInput = {
69+
url: "https://example.com",
70+
formats: [{ type: "video" }],
71+
};
72+
73+
expect(scrapeRequestSchema.parse(stringInput).formats).toEqual([
74+
{ type: "video" },
75+
]);
76+
expect(scrapeRequestSchema.parse(objectInput).formats).toEqual([
77+
{ type: "video" },
78+
]);
79+
});
80+
6381
it("should accept valid scrape request with json format options", () => {
6482
const input: ScrapeRequestInput = {
6583
url: "https://example.com",

apps/api/src/controllers/v2/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,8 @@ export type FormatObject =
438438
| HighlightsFormatWithOptions
439439
| QueryFormatWithOptions
440440
| { type: "branding" }
441-
| { type: "audio" };
441+
| { type: "audio" }
442+
| { type: "video" };
442443

443444
const pdfModeSchema = z.enum(["fast", "auto", "ocr"]);
444445

@@ -550,6 +551,7 @@ const baseScrapeOptions = z.strictObject({
550551
highlightsFormatWithOptions,
551552
queryFormatWithOptions,
552553
z.strictObject({ type: z.literal("audio") }),
554+
z.strictObject({ type: z.literal("video") }),
553555
])
554556
.array()
555557
.optional()
@@ -1097,6 +1099,7 @@ export type Document = {
10971099
images?: string[];
10981100
screenshot?: string;
10991101
audio?: string;
1102+
video?: string;
11001103
extract?: any;
11011104
json?: any;
11021105
summary?: string;

apps/api/src/lib/error-serde.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ScrapeRetryLimitError,
3333
BrandingNotSupportedError,
3434
AudioUnsupportedUrlError,
35+
VideoUnsupportedUrlError,
3536
XTwitterConfigurationError,
3637
} from "../scraper/scrapeURL/error";
3738

@@ -65,6 +66,7 @@ const errorMap: Record<ErrorCodes, any> = {
6566
SCRAPE_SITEMAP_ERROR: SitemapError,
6667
CRAWL_DENIAL: CrawlDenialError,
6768
SCRAPE_AUDIO_UNSUPPORTED_URL: AudioUnsupportedUrlError,
69+
SCRAPE_VIDEO_UNSUPPORTED_URL: VideoUnsupportedUrlError,
6870
SCRAPE_X_TWITTER_CONFIGURATION_ERROR: XTwitterConfigurationError,
6971
MAP_FAILED: MapFailedError,
7072

apps/api/src/lib/error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type ErrorCodes =
2626
| "SCRAPE_BRANDING_NOT_SUPPORTED"
2727
| "AGENT_INDEX_ONLY"
2828
| "SCRAPE_AUDIO_UNSUPPORTED_URL"
29+
| "SCRAPE_VIDEO_UNSUPPORTED_URL"
2930
| "SCRAPE_X_TWITTER_CONFIGURATION_ERROR"
3031
| "PARSE_UNSUPPORTED_OPTIONS"
3132
| "CRAWL_DENIAL"

apps/api/src/lib/scrape-billing.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export async function calculateCreditsToBeBilled(
9090
creditsToBeBilled += 4;
9191
}
9292

93+
if (hasFormatOfType(options.formats, "video")) {
94+
creditsToBeBilled += 4;
95+
}
96+
9397
if (document.metadata?.postprocessorsUsed?.includes("x-twitter")) {
9498
creditsToBeBilled += xTwitterCostBonus;
9599
}

apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export async function scrapeURLWithFireEngineChromeCDP(
265265
});
266266
const hasBranding = hasFormatOfType(meta.options.formats, "branding");
267267
const hasAudio = hasFormatOfType(meta.options.formats, "audio");
268+
const hasVideo = hasFormatOfType(meta.options.formats, "video");
268269
const shouldRunYoutubePostprocessor = youtubePostprocessor.shouldRun(
269270
meta,
270271
new URL(meta.rewrittenUrl ?? meta.url),
@@ -321,7 +322,7 @@ export async function scrapeURLWithFireEngineChromeCDP(
321322
},
322323
]
323324
: []),
324-
...(hasAudio || shouldRunYoutubePostprocessor
325+
...(hasAudio || hasVideo || shouldRunYoutubePostprocessor
325326
? ([
326327
{
327328
type: "getCookies",
@@ -464,7 +465,9 @@ export async function scrapeURLWithFireEngineChromeCDP(
464465
proxyUsed: response.usedMobileProxy ? "stealth" : "basic",
465466
youtubeTranscriptContent: response.youtubeTranscriptContent,
466467
timezone: response.timezone,
467-
...(hasAudio || shouldRunYoutubePostprocessor ? { audioCookies } : {}),
468+
...(hasAudio || hasVideo || shouldRunYoutubePostprocessor
469+
? { audioCookies }
470+
: {}),
468471
};
469472
});
470473
}

apps/api/src/scraper/scrapeURL/engines/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const featureFlags = [
9090
"pdf",
9191
"document",
9292
"audio",
93+
"video",
9394
"atsv",
9495
"location",
9596
"mobile",
@@ -114,6 +115,7 @@ const featureFlagOptions: {
114115
pdf: { priority: 100 },
115116
document: { priority: 100 },
116117
audio: { priority: 100 },
118+
video: { priority: 100 },
117119
atsv: { priority: 90 }, // NOTE: should atsv force to tlsclient? adjust priority if not
118120
useFastMode: { priority: 90 },
119121
location: { priority: 10 },
@@ -224,6 +226,7 @@ const engineOptions: {
224226
pdf: false,
225227
document: false,
226228
audio: false,
229+
video: false,
227230
atsv: false,
228231
mobile: true,
229232
location: true,
@@ -244,6 +247,7 @@ const engineOptions: {
244247
pdf: false,
245248
document: false,
246249
audio: true,
250+
video: true,
247251
atsv: false,
248252
location: true,
249253
mobile: true,
@@ -264,6 +268,7 @@ const engineOptions: {
264268
pdf: false,
265269
document: false,
266270
audio: true,
271+
video: true,
267272
atsv: false,
268273
location: true,
269274
mobile: true,
@@ -284,6 +289,7 @@ const engineOptions: {
284289
pdf: true,
285290
document: true,
286291
audio: false,
292+
video: false,
287293
atsv: false,
288294
location: true,
289295
mobile: true,
@@ -304,6 +310,7 @@ const engineOptions: {
304310
pdf: false,
305311
document: false,
306312
audio: true,
313+
video: true,
307314
atsv: false,
308315
location: true,
309316
mobile: true,
@@ -324,6 +331,7 @@ const engineOptions: {
324331
pdf: false,
325332
document: false,
326333
audio: true,
334+
video: true,
327335
atsv: false,
328336
location: true,
329337
mobile: true,
@@ -344,6 +352,7 @@ const engineOptions: {
344352
pdf: false,
345353
document: false,
346354
audio: false,
355+
video: false,
347356
atsv: false,
348357
location: false,
349358
mobile: false,
@@ -364,6 +373,7 @@ const engineOptions: {
364373
pdf: false,
365374
document: false,
366375
audio: true,
376+
video: true,
367377
atsv: true,
368378
location: true,
369379
mobile: false,
@@ -384,6 +394,7 @@ const engineOptions: {
384394
pdf: false,
385395
document: false,
386396
audio: true,
397+
video: true,
387398
atsv: true,
388399
location: true,
389400
mobile: false,
@@ -404,6 +415,7 @@ const engineOptions: {
404415
pdf: false,
405416
document: false,
406417
audio: false,
418+
video: false,
407419
atsv: false,
408420
location: false,
409421
mobile: false,
@@ -424,6 +436,7 @@ const engineOptions: {
424436
pdf: true,
425437
document: false,
426438
audio: false,
439+
video: false,
427440
atsv: false,
428441
location: false,
429442
mobile: false,
@@ -444,6 +457,7 @@ const engineOptions: {
444457
pdf: false,
445458
document: true,
446459
audio: false,
460+
video: false,
447461
atsv: false,
448462
location: false,
449463
mobile: false,
@@ -464,6 +478,7 @@ const engineOptions: {
464478
pdf: false,
465479
document: false,
466480
audio: false,
481+
video: false,
467482
atsv: false,
468483
location: false,
469484
mobile: false,
@@ -484,6 +499,7 @@ const engineOptions: {
484499
pdf: false,
485500
document: false,
486501
audio: false,
502+
video: false,
487503
atsv: false,
488504
location: false,
489505
mobile: false,

0 commit comments

Comments
 (0)