Skip to content

Commit 6840ee5

Browse files
KrisOeiclaude
andauthored
feat: add menu scrape format (engine client + 9 SDKs, menuBeta-gated) (firecrawl#3831)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef12eb3 commit 6840ee5

64 files changed

Lines changed: 2679 additions & 20 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/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ FIRE_ENGINE_BETA_URL=
7777
# `product` format returns a warning and no product (same pattern as the
7878
# audio/video formats and AVGRAB_SERVICE_URL).
7979
PRODUCT_EXTRACTION_SERVICE_URL=
80+
MENU_EXTRACTION_SERVICE_URL=
8081

8182
# Proxy Settings for Playwright (Alternative you can use a proxy service like oxylabs, which rotates IPs for you on every request)
8283
PROXY_SERVER=

apps/api/src/__tests__/snips/lib.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const HAS_FIRE_ENGINE = !!config.FIRE_ENGINE_BETA_URL;
2727
export const HAS_PLAYWRIGHT = !!config.PLAYWRIGHT_MICROSERVICE_URL;
2828
export const HAS_PROXY = !!config.PROXY_SERVER;
2929
export const HAS_PRODUCT_SERVICE = !!config.PRODUCT_EXTRACTION_SERVICE_URL;
30+
export const HAS_MENU_SERVICE = !!config.MENU_EXTRACTION_SERVICE_URL;
3031

3132
export const HAS_SEARCH = TEST_PRODUCTION || !!config.SEARXNG_ENDPOINT;
3233

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
ALLOW_TEST_SUITE_WEBSITE,
3+
describeIf,
4+
TEST_SUITE_WEBSITE,
5+
} from "../lib";
6+
import { scrape, scrapeTimeout, idmux, Identity } from "./lib";
7+
8+
let identity: Identity;
9+
10+
beforeAll(async () => {
11+
identity = await idmux({
12+
name: "menu-v1",
13+
concurrency: 100,
14+
credits: 1000000,
15+
});
16+
}, 10000 + scrapeTimeout);
17+
18+
// The `menu` scrape format is registered for both v1 and v2. The v2 suite covers
19+
// the positive extraction paths; here we only need to prove the additive-only
20+
// contract on v1: a scrape that does NOT request the `menu` format is
21+
// byte-identical in shape to a pre-feature v1 scrape (no `menu`, no spurious
22+
// `warning`, markdown + metadata intact). The menu transformer is shared across
23+
// v1/v2, so this guards the v1 format gate specifically.
24+
describeIf(ALLOW_TEST_SUITE_WEBSITE)(
25+
"Menu scrape format (v1 back-compat)",
26+
() => {
27+
const base = TEST_SUITE_WEBSITE;
28+
const menuUrl = `${base}/product`;
29+
30+
it.concurrent(
31+
"does not populate menu when the menu format is not requested (v1)",
32+
async () => {
33+
const response = await scrape(
34+
{
35+
url: menuUrl,
36+
formats: ["markdown"],
37+
},
38+
identity,
39+
);
40+
41+
expect(response.menu).toBeUndefined();
42+
expect(response.warning).toBeUndefined();
43+
expect(response.markdown).toBeDefined();
44+
expect(typeof response.markdown).toBe("string");
45+
expect(response.markdown?.length).toBeGreaterThan(0);
46+
expect(response.metadata).toBeDefined();
47+
expect(response.metadata.statusCode).toBe(200);
48+
},
49+
scrapeTimeout,
50+
);
51+
},
52+
);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
ALLOW_TEST_SUITE_WEBSITE,
3+
describeIf,
4+
HAS_MENU_SERVICE,
5+
TEST_SUITE_WEBSITE,
6+
} from "../lib";
7+
import { scrape, scrapeTimeout, idmux, Identity } from "./lib";
8+
9+
let identity: Identity;
10+
11+
beforeAll(async () => {
12+
identity = await idmux({
13+
name: "menu",
14+
concurrency: 100,
15+
credits: 1000000,
16+
});
17+
}, 10000 + scrapeTimeout);
18+
19+
// The `menu` scrape format extracts a structured restaurant menu from a page's
20+
// HTML. The extraction itself runs in the menu-search Rust service, called over
21+
// HTTP when MENU_EXTRACTION_SERVICE_URL is set (the product → PRODUCT_EXTRACTION_
22+
// SERVICE_URL pattern). These tests target a representative real restaurant menu
23+
// page for the positive path, and the static test-site non-menu pages
24+
// (`/about`, `/product`) for the negative / absence paths. Because the format
25+
// requires the external extractor, we gate on HAS_MENU_SERVICE so the suite only
26+
// runs where the service is configured (mirroring how the product snips are
27+
// gated on HAS_PRODUCT_SERVICE); and we also gate on ALLOW_TEST_SUITE_WEBSITE
28+
// because the negative / absence cases fetch the local test-site.
29+
describeIf(ALLOW_TEST_SUITE_WEBSITE && HAS_MENU_SERVICE)(
30+
"Menu scrape format",
31+
() => {
32+
const base = TEST_SUITE_WEBSITE;
33+
// A representative restaurant menu page (Toast online ordering) for the live
34+
// extraction path. The page embeds a structured menu the service can parse.
35+
const menuUrl =
36+
"https://order.toasttab.com/online/the-coffee-shop-123-main-st";
37+
const nonMenuUrl = `${base}/about`;
38+
const productUrl = `${base}/product`;
39+
40+
it.concurrent(
41+
"extracts a structured menu from a restaurant menu page",
42+
async () => {
43+
const response = await scrape(
44+
{
45+
url: menuUrl,
46+
formats: [{ type: "menu" }],
47+
},
48+
identity,
49+
);
50+
51+
expect(response.menu).toBeDefined();
52+
// Canonical shape: a merchant profile plus an ordered list of sections,
53+
// each holding items.
54+
expect(response.menu?.isMenu).toBe(true);
55+
expect(response.menu?.merchant).toBeDefined();
56+
expect(typeof response.menu?.merchant?.name).toBe("string");
57+
expect(Array.isArray(response.menu?.sections)).toBe(true);
58+
expect(response.menu?.sections?.length).toBeGreaterThan(0);
59+
const section = response.menu?.sections?.[0];
60+
expect(section).toBeDefined();
61+
expect(typeof section?.name).toBe("string");
62+
expect(Array.isArray(section?.items)).toBe(true);
63+
},
64+
scrapeTimeout,
65+
);
66+
67+
it.concurrent(
68+
"sets a no-menu warning for a non-menu page",
69+
async () => {
70+
const response = await scrape(
71+
{
72+
url: nonMenuUrl,
73+
formats: [{ type: "menu" }],
74+
},
75+
identity,
76+
);
77+
78+
expect(response.menu).toBeUndefined();
79+
expect(response.warning).toBeDefined();
80+
expect(response.warning).toContain("No menu found");
81+
},
82+
scrapeTimeout,
83+
);
84+
85+
it.concurrent(
86+
"does not populate menu when the menu format is not requested",
87+
async () => {
88+
const response = await scrape(
89+
{
90+
url: productUrl,
91+
formats: ["markdown"],
92+
},
93+
identity,
94+
);
95+
96+
// Additive-only contract: a scrape that does not request the `menu`
97+
// format must be byte-identical in shape to a pre-feature scrape.
98+
// No `menu` field...
99+
expect(response.menu).toBeUndefined();
100+
// ...and no spurious `warning` (the no-menu warning is only emitted when
101+
// the format is actually requested).
102+
expect(response.warning).toBeUndefined();
103+
// ...while the rest of the document is present and unchanged.
104+
expect(response.markdown).toBeDefined();
105+
expect(typeof response.markdown).toBe("string");
106+
expect(response.markdown?.length).toBeGreaterThan(0);
107+
expect(response.metadata).toBeDefined();
108+
expect(response.metadata.sourceURL ?? response.metadata.url).toBe(
109+
productUrl,
110+
);
111+
expect(response.metadata.statusCode).toBe(200);
112+
},
113+
scrapeTimeout,
114+
);
115+
},
116+
);

apps/api/src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ const configSchema = z.object({
330330
// Product extraction (product-search Rust service)
331331
PRODUCT_EXTRACTION_SERVICE_URL: z.string().optional(),
332332

333+
// Menu extraction (menu-search Rust service)
334+
MENU_EXTRACTION_SERVICE_URL: z.string().optional(),
335+
333336
// PII Redaction (fire-privacy)
334337
FIRE_PRIVACY_URL: z.string().optional(),
335338
FIRE_PRIVACY_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { includesFormat } from "../../lib/format-utils";
1818
import { webhookSchema } from "../../services/webhook/schema";
1919
import { BrandingProfile } from "../../types/branding";
2020
import { ProductProfile } from "../../types/product";
21+
import { MenuProfile } from "../../types/menu";
2122

2223
type Format =
2324
| "markdown"
@@ -31,7 +32,8 @@ type Format =
3132
| "summary"
3233
| "changeTracking"
3334
| "branding"
34-
| "product";
35+
| "product"
36+
| "menu";
3537

3638
export const url = z.preprocess(
3739
x => {
@@ -432,6 +434,7 @@ const baseScrapeOptions = z.strictObject({
432434
"changeTracking",
433435
"branding",
434436
"product",
437+
"menu",
435438
])
436439
.array()
437440
.optional()
@@ -996,6 +999,7 @@ export type Document = {
996999
summary?: string;
9971000
branding?: BrandingProfile;
9981001
product?: ProductProfile;
1002+
menu?: MenuProfile;
9991003
warning?: string;
10001004
actions?: {
10011005
screenshots?: string[];
@@ -1563,6 +1567,7 @@ export const searchRequestSchema = z
15631567
"extract",
15641568
"json",
15651569
"product",
1570+
"menu",
15661571
]),
15671572
)
15681573
.prefault([]),

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "../../services/webhook/schema";
2828
import { BrandingProfile } from "../../types/branding";
2929
import { ProductProfile } from "../../types/product";
30+
import { MenuProfile } from "../../types/menu";
3031

3132
// Base URL schema with common validation logic
3233
export const URL = z.preprocess(
@@ -460,6 +461,7 @@ export type FormatObject =
460461
| QueryFormatWithOptions
461462
| { type: "branding" }
462463
| { type: "product" }
464+
| { type: "menu" }
463465
| { type: "audio" }
464466
| { type: "video" };
465467

@@ -632,6 +634,7 @@ const baseScrapeOptions = z.strictObject({
632634
attributesFormatWithOptions,
633635
z.strictObject({ type: z.literal("branding") }),
634636
z.strictObject({ type: z.literal("product") }),
637+
z.strictObject({ type: z.literal("menu") }),
635638
questionFormatWithOptions,
636639
highlightsFormatWithOptions,
637640
queryFormatWithOptions,
@@ -1208,6 +1211,7 @@ export type Document = {
12081211
highlights?: string;
12091212
branding?: BrandingProfile;
12101213
product?: ProductProfile;
1214+
menu?: MenuProfile;
12111215
warning?: string;
12121216
attributes?: {
12131217
selector: string;
@@ -1531,6 +1535,7 @@ export type TeamFlags = {
15311535
maxBrowserSessions?: number;
15321536
researchBeta?: boolean;
15331537
highlightsBeta?: boolean;
1538+
menuBeta?: boolean;
15341539
} | null;
15351540

15361541
interface RequestWithMaybeACUC<
@@ -1779,6 +1784,8 @@ export function fromV1ScrapeOptions(
17791784
return { type: "branding" as const };
17801785
} else if (x === "product") {
17811786
return { type: "product" as const };
1787+
} else if (x === "menu") {
1788+
return { type: "menu" as const };
17821789
} else {
17831790
return x;
17841791
}
@@ -1940,6 +1947,7 @@ export const searchRequestSchema = z
19401947
z.strictObject({ type: z.literal("images") }),
19411948
z.strictObject({ type: z.literal("summary") }),
19421949
z.strictObject({ type: z.literal("product") }),
1950+
z.strictObject({ type: z.literal("menu") }),
19431951
jsonFormatWithOptions,
19441952
questionFormatWithOptions,
19451953
highlightsFormatWithOptions,

0 commit comments

Comments
 (0)