@striderlabs/mcp-opentable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +226 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +97 -0
- package/dist/browser.d.ts +140 -0
- package/dist/browser.js +608 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +412 -0
- package/package.json +39 -0
- package/server.json +20 -0
- package/src/auth.ts +122 -0
- package/src/browser.ts +877 -0
- package/src/index.ts +503 -0
- package/tsconfig.json +14 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTable Browser Automation
|
|
3
|
+
*
|
|
4
|
+
* Playwright-based automation for OpenTable reservation operations.
|
|
5
|
+
*/
|
|
6
|
+
import { chromium } from "playwright";
|
|
7
|
+
import { saveCookies, loadCookies, getAuthState } from "./auth.js";
|
|
8
|
+
const OPENTABLE_BASE_URL = "https://www.opentable.com";
|
|
9
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
10
|
+
// Singleton browser instance
|
|
11
|
+
let browser = null;
|
|
12
|
+
let context = null;
|
|
13
|
+
let page = null;
|
|
14
|
+
/**
|
|
15
|
+
* Initialize browser with stealth settings
|
|
16
|
+
*/
|
|
17
|
+
async function initBrowser() {
|
|
18
|
+
if (browser)
|
|
19
|
+
return;
|
|
20
|
+
browser = await chromium.launch({
|
|
21
|
+
headless: true,
|
|
22
|
+
args: [
|
|
23
|
+
"--disable-blink-features=AutomationControlled",
|
|
24
|
+
"--no-sandbox",
|
|
25
|
+
"--disable-setuid-sandbox",
|
|
26
|
+
"--disable-dev-shm-usage",
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
context = await browser.newContext({
|
|
30
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
31
|
+
viewport: { width: 1280, height: 800 },
|
|
32
|
+
locale: "en-US",
|
|
33
|
+
extraHTTPHeaders: {
|
|
34
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
// Apply stealth patches
|
|
38
|
+
await context.addInitScript(() => {
|
|
39
|
+
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
|
40
|
+
Object.defineProperty(navigator, "plugins", {
|
|
41
|
+
get: () => [1, 2, 3, 4, 5],
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
// Load saved cookies
|
|
45
|
+
await loadCookies(context);
|
|
46
|
+
page = await context.newPage();
|
|
47
|
+
// Block unnecessary resources for speed
|
|
48
|
+
await page.route("**/*.{png,jpg,jpeg,gif,svg,woff,woff2,mp4,webm}", (route) => route.abort());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get the current page, initializing if needed
|
|
52
|
+
*/
|
|
53
|
+
async function getPage() {
|
|
54
|
+
await initBrowser();
|
|
55
|
+
if (!page)
|
|
56
|
+
throw new Error("Page not initialized");
|
|
57
|
+
return page;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get current context
|
|
61
|
+
*/
|
|
62
|
+
async function getContext() {
|
|
63
|
+
await initBrowser();
|
|
64
|
+
if (!context)
|
|
65
|
+
throw new Error("Context not initialized");
|
|
66
|
+
return context;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if user is logged in to OpenTable
|
|
70
|
+
*/
|
|
71
|
+
export async function checkAuth() {
|
|
72
|
+
const ctx = await getContext();
|
|
73
|
+
const p = await getPage();
|
|
74
|
+
await p.goto(OPENTABLE_BASE_URL, {
|
|
75
|
+
waitUntil: "domcontentloaded",
|
|
76
|
+
timeout: DEFAULT_TIMEOUT,
|
|
77
|
+
});
|
|
78
|
+
await p.waitForTimeout(2000);
|
|
79
|
+
const authState = await getAuthState(ctx);
|
|
80
|
+
await saveCookies(ctx);
|
|
81
|
+
return authState;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Return login URL and instructions for the user to authenticate
|
|
85
|
+
*/
|
|
86
|
+
export async function getLoginUrl() {
|
|
87
|
+
const loginUrl = `${OPENTABLE_BASE_URL}/login`;
|
|
88
|
+
return {
|
|
89
|
+
url: loginUrl,
|
|
90
|
+
instructions: "Please log in to OpenTable in your browser. After logging in, run the 'opentable_status' tool to verify authentication and save your session. Your session will be stored in ~/.strider/opentable/cookies.json.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Search restaurants on OpenTable
|
|
95
|
+
*/
|
|
96
|
+
export async function searchRestaurants(params) {
|
|
97
|
+
const p = await getPage();
|
|
98
|
+
const ctx = await getContext();
|
|
99
|
+
try {
|
|
100
|
+
const { location, cuisine, partySize = 2, date, time } = params;
|
|
101
|
+
// Build search URL with query parameters
|
|
102
|
+
const searchParams = new URLSearchParams();
|
|
103
|
+
searchParams.set("term", location);
|
|
104
|
+
if (cuisine)
|
|
105
|
+
searchParams.set("cuisine", cuisine);
|
|
106
|
+
searchParams.set("covers", String(partySize));
|
|
107
|
+
if (date)
|
|
108
|
+
searchParams.set("dateTime", `${date}T${time || "19:00"}:00`);
|
|
109
|
+
const searchUrl = `${OPENTABLE_BASE_URL}/s?${searchParams.toString()}`;
|
|
110
|
+
await p.goto(searchUrl, {
|
|
111
|
+
waitUntil: "domcontentloaded",
|
|
112
|
+
timeout: DEFAULT_TIMEOUT,
|
|
113
|
+
});
|
|
114
|
+
await p.waitForTimeout(3000);
|
|
115
|
+
// Wait for restaurant results
|
|
116
|
+
await p
|
|
117
|
+
.locator('[data-test="search-result"], [data-testid="restaurant-card"], article[data-restaurant-id]')
|
|
118
|
+
.first()
|
|
119
|
+
.waitFor({ timeout: 10000 })
|
|
120
|
+
.catch(() => { });
|
|
121
|
+
const restaurants = [];
|
|
122
|
+
// Extract restaurant cards
|
|
123
|
+
const cards = p.locator('[data-test="search-result"], [data-testid="restaurant-card"], [data-restaurant-id]');
|
|
124
|
+
const cardCount = await cards.count();
|
|
125
|
+
for (let i = 0; i < Math.min(cardCount, 20); i++) {
|
|
126
|
+
const card = cards.nth(i);
|
|
127
|
+
try {
|
|
128
|
+
const name = (await card
|
|
129
|
+
.locator('h2, h3, [data-test="restaurant-name"], a[data-ot-track-component="Restaurant Name"]')
|
|
130
|
+
.first()
|
|
131
|
+
.textContent()
|
|
132
|
+
.catch(() => "")) || "";
|
|
133
|
+
const cuisineText = (await card
|
|
134
|
+
.locator('[data-test="cuisine"], span:has-text("Cuisine")')
|
|
135
|
+
.first()
|
|
136
|
+
.textContent()
|
|
137
|
+
.catch(() => "")) || "";
|
|
138
|
+
const neighborhoodText = (await card
|
|
139
|
+
.locator('[data-test="neighborhood"], [data-testid="neighborhood"]')
|
|
140
|
+
.first()
|
|
141
|
+
.textContent()
|
|
142
|
+
.catch(() => "")) || "";
|
|
143
|
+
const ratingText = (await card
|
|
144
|
+
.locator('[data-test="rating"], [aria-label*="rating"]')
|
|
145
|
+
.first()
|
|
146
|
+
.textContent()
|
|
147
|
+
.catch(() => "")) || "";
|
|
148
|
+
const reviewCountText = (await card
|
|
149
|
+
.locator('[data-test="review-count"], span:has-text("reviews")')
|
|
150
|
+
.first()
|
|
151
|
+
.textContent()
|
|
152
|
+
.catch(() => "")) || "";
|
|
153
|
+
const priceRange = (await card
|
|
154
|
+
.locator('[data-test="price"], span:has-text("$")')
|
|
155
|
+
.first()
|
|
156
|
+
.textContent()
|
|
157
|
+
.catch(() => "")) || "";
|
|
158
|
+
// Get profile link and extract restaurant ID
|
|
159
|
+
const profileLink = (await card
|
|
160
|
+
.locator("a[href*='/restaurant/']")
|
|
161
|
+
.first()
|
|
162
|
+
.getAttribute("href")
|
|
163
|
+
.catch(() => "")) || "";
|
|
164
|
+
const ridMatch = profileLink.match(/\/restaurant\/([^/?]+)/);
|
|
165
|
+
const restaurantId = ridMatch?.[1] || `restaurant-${i}`;
|
|
166
|
+
const restaurantIdAttr = (await card.getAttribute("data-restaurant-id").catch(() => "")) || restaurantId;
|
|
167
|
+
if (name.trim()) {
|
|
168
|
+
restaurants.push({
|
|
169
|
+
id: restaurantIdAttr || restaurantId,
|
|
170
|
+
name: name.trim(),
|
|
171
|
+
cuisine: cuisineText.trim(),
|
|
172
|
+
location: neighborhoodText.trim() || location,
|
|
173
|
+
neighborhood: neighborhoodText.trim(),
|
|
174
|
+
rating: parseFloat(ratingText.replace(/[^0-9.]/g, "")) || undefined,
|
|
175
|
+
reviewCount: parseInt(reviewCountText.replace(/[^0-9]/g, "")) || undefined,
|
|
176
|
+
priceRange: priceRange.trim() || undefined,
|
|
177
|
+
profileUrl: profileLink
|
|
178
|
+
? `${OPENTABLE_BASE_URL}${profileLink}`
|
|
179
|
+
: undefined,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Skip problematic cards
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await saveCookies(ctx);
|
|
188
|
+
return { success: true, restaurants };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error: error instanceof Error ? error.message : "Failed to search restaurants",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Get detailed information about a specific restaurant
|
|
199
|
+
*/
|
|
200
|
+
export async function getRestaurantDetails(restaurantId) {
|
|
201
|
+
const p = await getPage();
|
|
202
|
+
const ctx = await getContext();
|
|
203
|
+
try {
|
|
204
|
+
const url = restaurantId.startsWith("http")
|
|
205
|
+
? restaurantId
|
|
206
|
+
: `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
|
|
207
|
+
await p.goto(url, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
|
|
208
|
+
await p.waitForTimeout(3000);
|
|
209
|
+
const name = (await p
|
|
210
|
+
.locator("h1, [data-test='restaurant-name']")
|
|
211
|
+
.first()
|
|
212
|
+
.textContent()
|
|
213
|
+
.catch(() => "")) || "";
|
|
214
|
+
const description = (await p
|
|
215
|
+
.locator('[data-test="restaurant-description"], p.description, [aria-label*="description"]')
|
|
216
|
+
.first()
|
|
217
|
+
.textContent()
|
|
218
|
+
.catch(() => "")) || "";
|
|
219
|
+
const cuisineText = (await p
|
|
220
|
+
.locator('[data-test="cuisine-link"], a[href*="/cuisine"]')
|
|
221
|
+
.first()
|
|
222
|
+
.textContent()
|
|
223
|
+
.catch(() => "")) || "";
|
|
224
|
+
const address = (await p
|
|
225
|
+
.locator('[data-test="address"], address, [itemprop="address"]')
|
|
226
|
+
.first()
|
|
227
|
+
.textContent()
|
|
228
|
+
.catch(() => "")) || "";
|
|
229
|
+
const phone = (await p
|
|
230
|
+
.locator('[data-test="phone"], a[href^="tel:"], [itemprop="telephone"]')
|
|
231
|
+
.first()
|
|
232
|
+
.textContent()
|
|
233
|
+
.catch(() => "")) || "";
|
|
234
|
+
const ratingText = (await p
|
|
235
|
+
.locator('[data-test="rating-value"], [aria-label*="stars"]')
|
|
236
|
+
.first()
|
|
237
|
+
.textContent()
|
|
238
|
+
.catch(() => "")) || "";
|
|
239
|
+
const reviewCountText = (await p
|
|
240
|
+
.locator('[data-test="review-count"]')
|
|
241
|
+
.first()
|
|
242
|
+
.textContent()
|
|
243
|
+
.catch(() => "")) || "";
|
|
244
|
+
const priceRange = (await p
|
|
245
|
+
.locator('[data-test="price"], [aria-label*="price"]')
|
|
246
|
+
.first()
|
|
247
|
+
.textContent()
|
|
248
|
+
.catch(() => "")) || "";
|
|
249
|
+
const neighborhood = (await p
|
|
250
|
+
.locator('[data-test="neighborhood"], [data-testid="neighborhood"]')
|
|
251
|
+
.first()
|
|
252
|
+
.textContent()
|
|
253
|
+
.catch(() => "")) || "";
|
|
254
|
+
// Extract feature tags
|
|
255
|
+
const featureElements = p.locator('[data-test="feature-tag"], [data-testid="feature"], li.feature');
|
|
256
|
+
const featureCount = await featureElements.count();
|
|
257
|
+
const features = [];
|
|
258
|
+
for (let i = 0; i < Math.min(featureCount, 10); i++) {
|
|
259
|
+
const feat = await featureElements
|
|
260
|
+
.nth(i)
|
|
261
|
+
.textContent()
|
|
262
|
+
.catch(() => "");
|
|
263
|
+
if (feat?.trim())
|
|
264
|
+
features.push(feat.trim());
|
|
265
|
+
}
|
|
266
|
+
await saveCookies(ctx);
|
|
267
|
+
return {
|
|
268
|
+
success: true,
|
|
269
|
+
restaurant: {
|
|
270
|
+
id: restaurantId,
|
|
271
|
+
name: name.trim(),
|
|
272
|
+
cuisine: cuisineText.trim(),
|
|
273
|
+
location: address.trim() || neighborhood.trim(),
|
|
274
|
+
neighborhood: neighborhood.trim() || undefined,
|
|
275
|
+
description: description.trim() || undefined,
|
|
276
|
+
address: address.trim() || undefined,
|
|
277
|
+
phone: phone.trim() || undefined,
|
|
278
|
+
rating: parseFloat(ratingText.replace(/[^0-9.]/g, "")) || undefined,
|
|
279
|
+
reviewCount: parseInt(reviewCountText.replace(/[^0-9]/g, "")) || undefined,
|
|
280
|
+
priceRange: priceRange.trim() || undefined,
|
|
281
|
+
features: features.length > 0 ? features : undefined,
|
|
282
|
+
profileUrl: url,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
success: false,
|
|
289
|
+
error: error instanceof Error
|
|
290
|
+
? error.message
|
|
291
|
+
: "Failed to get restaurant details",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Check available reservation times for a restaurant
|
|
297
|
+
*/
|
|
298
|
+
export async function checkAvailability(params) {
|
|
299
|
+
const p = await getPage();
|
|
300
|
+
const ctx = await getContext();
|
|
301
|
+
try {
|
|
302
|
+
const { restaurantId, date, time, partySize } = params;
|
|
303
|
+
// Build restaurant URL with availability params
|
|
304
|
+
const url = restaurantId.startsWith("http")
|
|
305
|
+
? restaurantId
|
|
306
|
+
: `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
|
|
307
|
+
const fullUrl = `${url}?dateTime=${date}T${time}:00&covers=${partySize}`;
|
|
308
|
+
await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
|
|
309
|
+
await p.waitForTimeout(3000);
|
|
310
|
+
const restaurantName = (await p
|
|
311
|
+
.locator("h1, [data-test='restaurant-name']")
|
|
312
|
+
.first()
|
|
313
|
+
.textContent()
|
|
314
|
+
.catch(() => "")) || "Unknown Restaurant";
|
|
315
|
+
// Wait for availability slots to load
|
|
316
|
+
await p
|
|
317
|
+
.locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime]')
|
|
318
|
+
.first()
|
|
319
|
+
.waitFor({ timeout: 10000 })
|
|
320
|
+
.catch(() => { });
|
|
321
|
+
const slots = [];
|
|
322
|
+
// Extract available time slots
|
|
323
|
+
const timeSlots = p.locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime], [aria-label*="Reserve"]');
|
|
324
|
+
const slotCount = await timeSlots.count();
|
|
325
|
+
for (let i = 0; i < Math.min(slotCount, 30); i++) {
|
|
326
|
+
const slot = timeSlots.nth(i);
|
|
327
|
+
try {
|
|
328
|
+
const timeText = (await slot.textContent().catch(() => "")) || "";
|
|
329
|
+
const datetime = (await slot.getAttribute("data-datetime").catch(() => "")) || "";
|
|
330
|
+
const token = (await slot.getAttribute("data-reservation-token").catch(() => "")) ||
|
|
331
|
+
undefined;
|
|
332
|
+
if (timeText.trim()) {
|
|
333
|
+
slots.push({
|
|
334
|
+
time: timeText.trim(),
|
|
335
|
+
partySize,
|
|
336
|
+
date,
|
|
337
|
+
reservationToken: token,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Skip problematic slots
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await saveCookies(ctx);
|
|
346
|
+
return {
|
|
347
|
+
success: true,
|
|
348
|
+
restaurantName: restaurantName.trim(),
|
|
349
|
+
slots,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
return {
|
|
354
|
+
success: false,
|
|
355
|
+
error: error instanceof Error
|
|
356
|
+
? error.message
|
|
357
|
+
: "Failed to check availability",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Make a reservation at a restaurant
|
|
363
|
+
*/
|
|
364
|
+
export async function makeReservation(params) {
|
|
365
|
+
const p = await getPage();
|
|
366
|
+
const ctx = await getContext();
|
|
367
|
+
try {
|
|
368
|
+
const { restaurantId, date, time, partySize, specialRequests, confirm, } = params;
|
|
369
|
+
const url = restaurantId.startsWith("http")
|
|
370
|
+
? restaurantId
|
|
371
|
+
: `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
|
|
372
|
+
const fullUrl = `${url}?dateTime=${date}T${time}:00&covers=${partySize}`;
|
|
373
|
+
await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
|
|
374
|
+
await p.waitForTimeout(3000);
|
|
375
|
+
const restaurantName = (await p
|
|
376
|
+
.locator("h1, [data-test='restaurant-name']")
|
|
377
|
+
.first()
|
|
378
|
+
.textContent()
|
|
379
|
+
.catch(() => "")) || "Unknown Restaurant";
|
|
380
|
+
// If not confirmed, return a preview
|
|
381
|
+
if (!confirm) {
|
|
382
|
+
return {
|
|
383
|
+
success: true,
|
|
384
|
+
requiresConfirmation: true,
|
|
385
|
+
preview: {
|
|
386
|
+
restaurantName: restaurantName.trim(),
|
|
387
|
+
date,
|
|
388
|
+
time,
|
|
389
|
+
partySize,
|
|
390
|
+
specialRequests,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Find and click the time slot
|
|
395
|
+
const timeSlot = p
|
|
396
|
+
.locator(`[data-test="availability-time"]:has-text("${time}"), button[data-datetime*="${time}"], [aria-label*="${time}"]`)
|
|
397
|
+
.first();
|
|
398
|
+
if (await timeSlot.isVisible({ timeout: 5000 })) {
|
|
399
|
+
await timeSlot.click();
|
|
400
|
+
await p.waitForTimeout(2000);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// Try clicking the first available slot
|
|
404
|
+
const firstSlot = p
|
|
405
|
+
.locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime]')
|
|
406
|
+
.first();
|
|
407
|
+
if (await firstSlot.isVisible({ timeout: 5000 })) {
|
|
408
|
+
await firstSlot.click();
|
|
409
|
+
await p.waitForTimeout(2000);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Fill in special requests if provided
|
|
413
|
+
if (specialRequests) {
|
|
414
|
+
const requestsField = p.locator('textarea[name*="request"], textarea[placeholder*="request"], [data-test="special-requests"]');
|
|
415
|
+
if (await requestsField.isVisible({ timeout: 3000 })) {
|
|
416
|
+
await requestsField.fill(specialRequests);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Click the reserve/complete button
|
|
420
|
+
const reserveButton = p
|
|
421
|
+
.locator('button:has-text("Complete reservation"), button:has-text("Reserve"), button[data-test="complete-reservation"], button[type="submit"]')
|
|
422
|
+
.first();
|
|
423
|
+
if (await reserveButton.isVisible({ timeout: 5000 })) {
|
|
424
|
+
await reserveButton.click();
|
|
425
|
+
await p.waitForTimeout(5000);
|
|
426
|
+
}
|
|
427
|
+
// Extract confirmation number from success page
|
|
428
|
+
const confirmationText = (await p
|
|
429
|
+
.locator('[data-test="confirmation-number"], h2:has-text("Confirmed"), [aria-label*="confirmation"]')
|
|
430
|
+
.first()
|
|
431
|
+
.textContent()
|
|
432
|
+
.catch(() => "")) || "";
|
|
433
|
+
const confirmationMatch = confirmationText.match(/[A-Z0-9]{6,}/);
|
|
434
|
+
const confirmationNumber = confirmationMatch?.[0];
|
|
435
|
+
// Get reservation ID from URL
|
|
436
|
+
const urlMatch = p.url().match(/reservation\/([^/?]+)/);
|
|
437
|
+
const reservationId = urlMatch?.[1] || `res-${Date.now()}`;
|
|
438
|
+
await saveCookies(ctx);
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
reservation: {
|
|
442
|
+
id: reservationId,
|
|
443
|
+
restaurantName: restaurantName.trim(),
|
|
444
|
+
date,
|
|
445
|
+
time,
|
|
446
|
+
partySize,
|
|
447
|
+
status: "confirmed",
|
|
448
|
+
confirmationNumber,
|
|
449
|
+
specialRequests,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
error: error instanceof Error ? error.message : "Failed to make reservation",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Get list of upcoming reservations
|
|
462
|
+
*/
|
|
463
|
+
export async function getReservations() {
|
|
464
|
+
const p = await getPage();
|
|
465
|
+
const ctx = await getContext();
|
|
466
|
+
try {
|
|
467
|
+
await p.goto(`${OPENTABLE_BASE_URL}/account/reservations`, {
|
|
468
|
+
waitUntil: "domcontentloaded",
|
|
469
|
+
timeout: DEFAULT_TIMEOUT,
|
|
470
|
+
});
|
|
471
|
+
await p.waitForTimeout(3000);
|
|
472
|
+
// Wait for reservations to load
|
|
473
|
+
await p
|
|
474
|
+
.locator('[data-test="reservation-card"], [data-testid="reservation"], article[data-reservation-id]')
|
|
475
|
+
.first()
|
|
476
|
+
.waitFor({ timeout: 10000 })
|
|
477
|
+
.catch(() => { });
|
|
478
|
+
const reservations = [];
|
|
479
|
+
const cards = p.locator('[data-test="reservation-card"], [data-testid="reservation"], article[data-reservation-id]');
|
|
480
|
+
const cardCount = await cards.count();
|
|
481
|
+
for (let i = 0; i < Math.min(cardCount, 20); i++) {
|
|
482
|
+
const card = cards.nth(i);
|
|
483
|
+
try {
|
|
484
|
+
const restaurantName = (await card
|
|
485
|
+
.locator('h2, h3, [data-test="restaurant-name"], a[data-ot-track-component="Restaurant Name"]')
|
|
486
|
+
.first()
|
|
487
|
+
.textContent()
|
|
488
|
+
.catch(() => "")) || "";
|
|
489
|
+
const dateText = (await card
|
|
490
|
+
.locator('[data-test="reservation-date"], time, [datetime]')
|
|
491
|
+
.first()
|
|
492
|
+
.textContent()
|
|
493
|
+
.catch(() => "")) || "";
|
|
494
|
+
const timeText = (await card
|
|
495
|
+
.locator('[data-test="reservation-time"], [aria-label*="time"]')
|
|
496
|
+
.first()
|
|
497
|
+
.textContent()
|
|
498
|
+
.catch(() => "")) || "";
|
|
499
|
+
const partySizeText = (await card
|
|
500
|
+
.locator('[data-test="party-size"], [aria-label*="guest"]')
|
|
501
|
+
.first()
|
|
502
|
+
.textContent()
|
|
503
|
+
.catch(() => "")) || "";
|
|
504
|
+
const statusText = (await card
|
|
505
|
+
.locator('[data-test="reservation-status"], [aria-label*="status"]')
|
|
506
|
+
.first()
|
|
507
|
+
.textContent()
|
|
508
|
+
.catch(() => "upcoming")) || "upcoming";
|
|
509
|
+
const confirmationText = (await card
|
|
510
|
+
.locator('[data-test="confirmation-number"], span:has-text("Confirmation")')
|
|
511
|
+
.first()
|
|
512
|
+
.textContent()
|
|
513
|
+
.catch(() => "")) || "";
|
|
514
|
+
const reservationId = (await card
|
|
515
|
+
.getAttribute("data-reservation-id")
|
|
516
|
+
.catch(() => "")) || `res-${i}`;
|
|
517
|
+
if (restaurantName.trim()) {
|
|
518
|
+
reservations.push({
|
|
519
|
+
id: reservationId,
|
|
520
|
+
restaurantName: restaurantName.trim(),
|
|
521
|
+
date: dateText.trim(),
|
|
522
|
+
time: timeText.trim(),
|
|
523
|
+
partySize: parseInt(partySizeText.replace(/[^0-9]/g, "")) || 2,
|
|
524
|
+
status: statusText.trim(),
|
|
525
|
+
confirmationNumber: confirmationText.trim() || undefined,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// Skip problematic cards
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
await saveCookies(ctx);
|
|
534
|
+
return { success: true, reservations };
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
return {
|
|
538
|
+
success: false,
|
|
539
|
+
error: error instanceof Error ? error.message : "Failed to get reservations",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Cancel a reservation
|
|
545
|
+
*/
|
|
546
|
+
export async function cancelReservation(params) {
|
|
547
|
+
const p = await getPage();
|
|
548
|
+
const ctx = await getContext();
|
|
549
|
+
try {
|
|
550
|
+
const { reservationId, confirm } = params;
|
|
551
|
+
if (!confirm) {
|
|
552
|
+
return {
|
|
553
|
+
success: true,
|
|
554
|
+
requiresConfirmation: true,
|
|
555
|
+
message: `Please confirm cancellation of reservation ${reservationId}. Set confirm=true to proceed.`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// Navigate to the reservation page
|
|
559
|
+
const url = `${OPENTABLE_BASE_URL}/account/reservations/${reservationId}`;
|
|
560
|
+
await p.goto(url, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
|
|
561
|
+
await p.waitForTimeout(2000);
|
|
562
|
+
// Click cancel button
|
|
563
|
+
const cancelButton = p
|
|
564
|
+
.locator('button:has-text("Cancel reservation"), button:has-text("Cancel"), [data-test="cancel-reservation"]')
|
|
565
|
+
.first();
|
|
566
|
+
if (!(await cancelButton.isVisible({ timeout: 5000 }))) {
|
|
567
|
+
return {
|
|
568
|
+
success: false,
|
|
569
|
+
error: "Cancel button not found. Reservation may not be cancellable.",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
await cancelButton.click();
|
|
573
|
+
await p.waitForTimeout(2000);
|
|
574
|
+
// Confirm cancellation in dialog if present
|
|
575
|
+
const confirmButton = p
|
|
576
|
+
.locator('button:has-text("Yes, cancel"), button:has-text("Confirm cancel"), [data-test="confirm-cancel"]')
|
|
577
|
+
.first();
|
|
578
|
+
if (await confirmButton.isVisible({ timeout: 3000 })) {
|
|
579
|
+
await confirmButton.click();
|
|
580
|
+
await p.waitForTimeout(3000);
|
|
581
|
+
}
|
|
582
|
+
await saveCookies(ctx);
|
|
583
|
+
return {
|
|
584
|
+
success: true,
|
|
585
|
+
message: `Reservation ${reservationId} has been cancelled successfully.`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
return {
|
|
590
|
+
success: false,
|
|
591
|
+
error: error instanceof Error ? error.message : "Failed to cancel reservation",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Cleanup browser resources
|
|
597
|
+
*/
|
|
598
|
+
export async function cleanup() {
|
|
599
|
+
if (context) {
|
|
600
|
+
await saveCookies(context);
|
|
601
|
+
}
|
|
602
|
+
if (browser) {
|
|
603
|
+
await browser.close();
|
|
604
|
+
browser = null;
|
|
605
|
+
context = null;
|
|
606
|
+
page = null;
|
|
607
|
+
}
|
|
608
|
+
}
|
package/dist/index.d.ts
ADDED