@striderlabs/mcp-bjs 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/src/browser.ts ADDED
@@ -0,0 +1,1050 @@
1
+ import { chromium, Browser, BrowserContext, Page } from "patchright";
2
+ import {
3
+ saveCookies,
4
+ loadCookies,
5
+ saveSessionInfo,
6
+ loadSessionInfo,
7
+ clearAuthData,
8
+ SessionInfo,
9
+ } from "./auth.js";
10
+
11
+ const BJS_BASE = "https://www.bjs.com";
12
+
13
+ let browser: Browser | null = null;
14
+ let context: BrowserContext | null = null;
15
+ let page: Page | null = null;
16
+
17
+ // ── Data Types ────────────────────────────────────────────────────────────────
18
+
19
+ export interface ProductResult {
20
+ name: string;
21
+ itemNumber?: string;
22
+ price?: string;
23
+ unitPrice?: string;
24
+ brand?: string;
25
+ rating?: string;
26
+ reviewCount?: string;
27
+ inStock: boolean;
28
+ imageUrl?: string;
29
+ productUrl?: string;
30
+ isMemberPrice?: boolean;
31
+ }
32
+
33
+ export interface ProductDetails extends ProductResult {
34
+ description?: string;
35
+ features?: string[];
36
+ dimensions?: string;
37
+ weight?: string;
38
+ model?: string;
39
+ upc?: string;
40
+ }
41
+
42
+ export interface CartItem {
43
+ name: string;
44
+ itemNumber?: string;
45
+ quantity: number;
46
+ price: string;
47
+ lineTotal?: string;
48
+ }
49
+
50
+ export interface CartSummary {
51
+ items: CartItem[];
52
+ subtotal: string;
53
+ estimatedTax?: string;
54
+ total: string;
55
+ itemCount: number;
56
+ fulfillmentType?: string;
57
+ }
58
+
59
+ export interface Club {
60
+ clubId: string;
61
+ name: string;
62
+ address: string;
63
+ city: string;
64
+ state: string;
65
+ zip: string;
66
+ phone?: string;
67
+ distance?: string;
68
+ hours?: string;
69
+ hasGas?: boolean;
70
+ gasPriceRegular?: string;
71
+ gasPricePremium?: string;
72
+ gasPriceDiesel?: string;
73
+ }
74
+
75
+ export interface Coupon {
76
+ couponId: string;
77
+ title: string;
78
+ description?: string;
79
+ discount?: string;
80
+ expiryDate?: string;
81
+ isClipped: boolean;
82
+ category?: string;
83
+ }
84
+
85
+ export interface Order {
86
+ orderId: string;
87
+ orderDate: string;
88
+ status: string;
89
+ total: string;
90
+ itemCount?: number;
91
+ fulfillmentType?: string;
92
+ trackingNumber?: string;
93
+ }
94
+
95
+ // ── Browser Lifecycle ─────────────────────────────────────────────────────────
96
+
97
+ async function initBrowser(): Promise<void> {
98
+ if (browser) return;
99
+
100
+ browser = await chromium.launch({
101
+ headless: true,
102
+ channel: "chrome",
103
+ args: [
104
+ "--no-sandbox",
105
+ "--disable-setuid-sandbox",
106
+ "--disable-blink-features=AutomationControlled",
107
+ ],
108
+ });
109
+
110
+ context = await browser.newContext({
111
+ viewport: { width: 1280, height: 720 },
112
+ userAgent:
113
+ "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",
114
+ locale: "en-US",
115
+ timezoneId: "America/New_York",
116
+ });
117
+
118
+ await loadCookies(context);
119
+
120
+ page = await context.newPage();
121
+ page.setDefaultTimeout(30000);
122
+ }
123
+
124
+ export async function closeBrowser(): Promise<void> {
125
+ if (context) await saveCookies(context);
126
+ if (browser) {
127
+ await browser.close();
128
+ browser = null;
129
+ context = null;
130
+ page = null;
131
+ }
132
+ }
133
+
134
+ async function getPage(): Promise<Page> {
135
+ await initBrowser();
136
+ if (!page) throw new Error("Failed to initialize browser page");
137
+ return page;
138
+ }
139
+
140
+ // ── Auth ──────────────────────────────────────────────────────────────────────
141
+
142
+ export async function checkLoginStatus(): Promise<SessionInfo> {
143
+ const p = await getPage();
144
+ await p.goto(`${BJS_BASE}/`, { waitUntil: "domcontentloaded" });
145
+
146
+ const isLoggedIn = await p.evaluate(() => {
147
+ const signInBtn = document.querySelector(
148
+ '[data-testid="sign-in-link"], a[href*="sign-in"], .signin-link'
149
+ );
150
+ const accountEl = document.querySelector(
151
+ '[data-testid="account-menu"], .account-name, .member-name'
152
+ );
153
+ return !!accountEl && !signInBtn;
154
+ });
155
+
156
+ let memberName: string | undefined;
157
+ let memberEmail: string | undefined;
158
+ let membershipNumber: string | undefined;
159
+
160
+ if (isLoggedIn) {
161
+ try {
162
+ const info = await p.evaluate(() => {
163
+ const nameEl = document.querySelector(
164
+ ".account-name, .member-name, [data-testid='member-name']"
165
+ );
166
+ return { name: nameEl?.textContent?.trim() };
167
+ });
168
+ memberName = info.name;
169
+
170
+ // Try to get more details from the account page
171
+ await p.goto(`${BJS_BASE}/account/profile`, {
172
+ waitUntil: "domcontentloaded",
173
+ });
174
+ const profileInfo = await p.evaluate(() => {
175
+ const emailEl = document.querySelector(
176
+ '[data-testid="email"], .profile-email, input[name="email"]'
177
+ );
178
+ const membershipEl = document.querySelector(
179
+ '[data-testid="membership-number"], .membership-number'
180
+ );
181
+ return {
182
+ email:
183
+ emailEl?.getAttribute("value") ||
184
+ emailEl?.textContent?.trim(),
185
+ membershipNumber: membershipEl?.textContent?.trim(),
186
+ };
187
+ });
188
+ memberEmail = profileInfo.email ?? undefined;
189
+ membershipNumber = profileInfo.membershipNumber ?? undefined;
190
+ } catch {
191
+ // Profile page may redirect if not truly logged in
192
+ }
193
+ }
194
+
195
+ const session: SessionInfo = {
196
+ isLoggedIn,
197
+ memberName,
198
+ memberEmail,
199
+ membershipNumber,
200
+ lastUpdated: new Date().toISOString(),
201
+ };
202
+
203
+ await saveSessionInfo(session);
204
+ return session;
205
+ }
206
+
207
+ export async function initiateLogin(): Promise<{
208
+ loginUrl: string;
209
+ message: string;
210
+ }> {
211
+ const p = await getPage();
212
+ await p.goto(`${BJS_BASE}/account/sign-in`, {
213
+ waitUntil: "domcontentloaded",
214
+ });
215
+ const url = p.url();
216
+ return {
217
+ loginUrl: url,
218
+ message:
219
+ "Please complete login in the browser window. After signing in, call bjs_status to confirm your session is active.",
220
+ };
221
+ }
222
+
223
+ export async function logout(): Promise<{ success: boolean }> {
224
+ const p = await getPage();
225
+ try {
226
+ await p.goto(`${BJS_BASE}/account/sign-out`, {
227
+ waitUntil: "domcontentloaded",
228
+ });
229
+ } catch {
230
+ // Ignore navigation errors
231
+ }
232
+ await clearAuthData();
233
+ return { success: true };
234
+ }
235
+
236
+ // ── Product Search ────────────────────────────────────────────────────────────
237
+
238
+ export async function searchProducts(
239
+ query: string,
240
+ maxResults = 10,
241
+ clubId?: string
242
+ ): Promise<ProductResult[]> {
243
+ const p = await getPage();
244
+ const url = clubId
245
+ ? `${BJS_BASE}/search?q=${encodeURIComponent(query)}&storeId=${clubId}`
246
+ : `${BJS_BASE}/search?q=${encodeURIComponent(query)}`;
247
+
248
+ await p.goto(url, { waitUntil: "domcontentloaded" });
249
+
250
+ // Wait for results
251
+ await p
252
+ .waitForSelector(
253
+ '[data-testid="product-tile"], .product-item, .product-card',
254
+ { timeout: 10000 }
255
+ )
256
+ .catch(() => {});
257
+
258
+ const results = await p.evaluate((max: number) => {
259
+ const tiles = document.querySelectorAll(
260
+ '[data-testid="product-tile"], .product-item, .product-card, .product-tile'
261
+ );
262
+ const products: {
263
+ name: string;
264
+ itemNumber?: string;
265
+ price?: string;
266
+ unitPrice?: string;
267
+ brand?: string;
268
+ rating?: string;
269
+ reviewCount?: string;
270
+ inStock: boolean;
271
+ imageUrl?: string;
272
+ productUrl?: string;
273
+ isMemberPrice?: boolean;
274
+ }[] = [];
275
+
276
+ Array.from(tiles)
277
+ .slice(0, max)
278
+ .forEach((tile) => {
279
+ const nameEl = tile.querySelector(
280
+ '[data-testid="product-name"], .product-name, h2, h3'
281
+ );
282
+ const priceEl = tile.querySelector(
283
+ '[data-testid="product-price"], .product-price, .price, .sale-price'
284
+ );
285
+ const unitPriceEl = tile.querySelector(
286
+ ".unit-price, .price-per-unit, [data-testid='unit-price']"
287
+ );
288
+ const brandEl = tile.querySelector(
289
+ ".brand, .product-brand, [data-testid='brand']"
290
+ );
291
+ const ratingEl = tile.querySelector(".rating, .star-rating, [aria-label*='star']");
292
+ const reviewEl = tile.querySelector(".review-count, .ratings-count");
293
+ const imgEl = tile.querySelector("img") as HTMLImageElement | null;
294
+ const linkEl = tile.querySelector("a") as HTMLAnchorElement | null;
295
+ const itemNumEl = tile.querySelector(
296
+ "[data-item-number], [data-sku], [data-testid='item-number']"
297
+ );
298
+ const outOfStock = !!(
299
+ tile.querySelector(".out-of-stock, [data-testid='out-of-stock']") ||
300
+ tile.textContent?.toLowerCase().includes("out of stock")
301
+ );
302
+
303
+ const name = nameEl?.textContent?.trim();
304
+ if (!name) return;
305
+
306
+ products.push({
307
+ name,
308
+ itemNumber:
309
+ itemNumEl?.getAttribute("data-item-number") ||
310
+ itemNumEl?.getAttribute("data-sku") ||
311
+ itemNumEl?.textContent?.trim(),
312
+ price: priceEl?.textContent?.trim().replace(/\s+/g, " "),
313
+ unitPrice: unitPriceEl?.textContent?.trim(),
314
+ brand: brandEl?.textContent?.trim(),
315
+ rating: ratingEl?.textContent?.trim() || ratingEl?.getAttribute("aria-label"),
316
+ reviewCount: reviewEl?.textContent?.trim(),
317
+ inStock: !outOfStock,
318
+ imageUrl: imgEl?.src,
319
+ productUrl: linkEl?.href,
320
+ isMemberPrice: !!(
321
+ tile.querySelector(".member-price, .club-price") ||
322
+ tile.textContent?.toLowerCase().includes("member")
323
+ ),
324
+ });
325
+ });
326
+
327
+ return products;
328
+ }, maxResults);
329
+
330
+ return results;
331
+ }
332
+
333
+ export async function getProductDetails(
334
+ productUrl: string
335
+ ): Promise<ProductDetails> {
336
+ const p = await getPage();
337
+ const url = productUrl.startsWith("http")
338
+ ? productUrl
339
+ : `${BJS_BASE}${productUrl}`;
340
+ await p.goto(url, { waitUntil: "domcontentloaded" });
341
+
342
+ await p
343
+ .waitForSelector('[data-testid="product-name"], .product-name, h1', {
344
+ timeout: 10000,
345
+ })
346
+ .catch(() => {});
347
+
348
+ return await p.evaluate(() => {
349
+ const nameEl = document.querySelector(
350
+ "h1, [data-testid='product-name'], .product-name"
351
+ );
352
+ const priceEl = document.querySelector(
353
+ "[data-testid='product-price'], .product-price, .price"
354
+ );
355
+ const unitPriceEl = document.querySelector(".unit-price, .price-per-unit");
356
+ const brandEl = document.querySelector(".brand, [data-testid='brand']");
357
+ const descEl = document.querySelector(
358
+ "[data-testid='description'], .product-description, .description"
359
+ );
360
+ const ratingEl = document.querySelector(
361
+ ".rating, .star-rating, [data-testid='rating']"
362
+ );
363
+ const reviewEl = document.querySelector(".review-count, .ratings-count");
364
+ const imgEl = document.querySelector(".product-image img") as HTMLImageElement | null;
365
+ const itemNumEl = document.querySelector(
366
+ "[data-testid='item-number'], .item-number"
367
+ );
368
+ const modelEl = document.querySelector(
369
+ "[data-testid='model-number'], .model-number"
370
+ );
371
+ const outOfStock = !!(
372
+ document.querySelector(".out-of-stock, [data-testid='out-of-stock']") ||
373
+ document.body.textContent?.toLowerCase().includes("out of stock")
374
+ );
375
+
376
+ const featureEls = document.querySelectorAll(
377
+ ".product-features li, [data-testid='features'] li"
378
+ );
379
+ const features = Array.from(featureEls)
380
+ .map((el) => el.textContent?.trim())
381
+ .filter(Boolean) as string[];
382
+
383
+ return {
384
+ name: nameEl?.textContent?.trim() || "Unknown",
385
+ itemNumber: itemNumEl?.textContent?.trim(),
386
+ price: priceEl?.textContent?.trim().replace(/\s+/g, " "),
387
+ unitPrice: unitPriceEl?.textContent?.trim(),
388
+ brand: brandEl?.textContent?.trim(),
389
+ description: descEl?.textContent?.trim(),
390
+ features,
391
+ model: modelEl?.textContent?.trim(),
392
+ rating: ratingEl?.textContent?.trim(),
393
+ reviewCount: reviewEl?.textContent?.trim(),
394
+ inStock: !outOfStock,
395
+ imageUrl: imgEl?.src,
396
+ productUrl: window.location.href,
397
+ };
398
+ });
399
+ }
400
+
401
+ // ── Inventory ─────────────────────────────────────────────────────────────────
402
+
403
+ export async function checkClubInventory(
404
+ productUrl: string,
405
+ clubId?: string
406
+ ): Promise<{ inStock: boolean; quantity?: string; clubName?: string; message: string }> {
407
+ const p = await getPage();
408
+ const url = productUrl.startsWith("http")
409
+ ? productUrl
410
+ : `${BJS_BASE}${productUrl}`;
411
+ await p.goto(url, { waitUntil: "domcontentloaded" });
412
+
413
+ if (clubId) {
414
+ // Try to switch to the specified club
415
+ await p.evaluate((id: string) => {
416
+ const clubSelect = document.querySelector(
417
+ `[data-club-id='${id}'], select[name='club']`
418
+ ) as HTMLElement | null;
419
+ if (clubSelect) clubSelect.click();
420
+ }, clubId);
421
+ await p.waitForTimeout(1500);
422
+ }
423
+
424
+ return await p.evaluate(() => {
425
+ const outOfStockEl = document.querySelector(
426
+ ".out-of-stock, [data-testid='out-of-stock'], .not-available"
427
+ );
428
+ const inStockEl = document.querySelector(
429
+ ".in-stock, [data-testid='in-stock'], .add-to-cart-btn:not([disabled])"
430
+ );
431
+ const qtyEl = document.querySelector(
432
+ ".availability, .stock-quantity, [data-testid='availability']"
433
+ );
434
+ const clubNameEl = document.querySelector(
435
+ ".selected-club, [data-testid='club-name']"
436
+ );
437
+
438
+ const inStock = !outOfStockEl && !!inStockEl;
439
+ return {
440
+ inStock,
441
+ quantity: qtyEl?.textContent?.trim(),
442
+ clubName: clubNameEl?.textContent?.trim(),
443
+ message: inStock
444
+ ? "Item is available at your selected club"
445
+ : "Item is out of stock at your selected club",
446
+ };
447
+ });
448
+ }
449
+
450
+ // ── Cart ──────────────────────────────────────────────────────────────────────
451
+
452
+ export async function addToCart(
453
+ productUrl: string,
454
+ quantity = 1
455
+ ): Promise<{ success: boolean; cartCount?: number; message: string }> {
456
+ const p = await getPage();
457
+ const url = productUrl.startsWith("http")
458
+ ? productUrl
459
+ : `${BJS_BASE}${productUrl}`;
460
+ await p.goto(url, { waitUntil: "domcontentloaded" });
461
+
462
+ // Set quantity if > 1
463
+ if (quantity > 1) {
464
+ const qtyInput = await p
465
+ .$("input[name='quantity'], input[data-testid='quantity-input']")
466
+ .catch(() => null);
467
+ if (qtyInput) {
468
+ await qtyInput.fill(String(quantity));
469
+ }
470
+ }
471
+
472
+ // Click Add to Cart
473
+ const addBtn = await p
474
+ .$(
475
+ "[data-testid='add-to-cart-btn'], .add-to-cart-btn, button:has-text('Add to Cart'), button:has-text('Add to club')"
476
+ )
477
+ .catch(() => null);
478
+
479
+ if (!addBtn) {
480
+ return { success: false, message: "Could not find Add to Cart button — item may be out of stock" };
481
+ }
482
+
483
+ await addBtn.click();
484
+ await p.waitForTimeout(2000);
485
+
486
+ const cartCount = await p.evaluate(() => {
487
+ const el = document.querySelector(
488
+ ".cart-count, .cart-badge, [data-testid='cart-count']"
489
+ );
490
+ const text = el?.textContent?.trim();
491
+ return text ? parseInt(text, 10) : undefined;
492
+ });
493
+
494
+ return {
495
+ success: true,
496
+ cartCount,
497
+ message: `Added ${quantity}x item to cart${cartCount !== undefined ? `. Cart now has ${cartCount} item(s).` : "."}`,
498
+ };
499
+ }
500
+
501
+ export async function viewCart(): Promise<CartSummary> {
502
+ const p = await getPage();
503
+ await p.goto(`${BJS_BASE}/cart`, { waitUntil: "domcontentloaded" });
504
+
505
+ await p
506
+ .waitForSelector(".cart-item, [data-testid='cart-item']", {
507
+ timeout: 8000,
508
+ })
509
+ .catch(() => {});
510
+
511
+ return await p.evaluate(() => {
512
+ const itemEls = document.querySelectorAll(
513
+ ".cart-item, [data-testid='cart-item']"
514
+ );
515
+ const items: {
516
+ name: string;
517
+ quantity: number;
518
+ price: string;
519
+ lineTotal?: string;
520
+ }[] = [];
521
+
522
+ itemEls.forEach((el) => {
523
+ const nameEl = el.querySelector(
524
+ ".item-name, [data-testid='item-name'], a"
525
+ );
526
+ const qtyEl = el.querySelector(
527
+ "input[name='quantity'], .item-quantity, [data-testid='item-qty']"
528
+ ) as HTMLInputElement | null;
529
+ const priceEl = el.querySelector(
530
+ ".item-price, [data-testid='item-price']"
531
+ );
532
+ const totalEl = el.querySelector(
533
+ ".line-total, [data-testid='line-total']"
534
+ );
535
+
536
+ const name = nameEl?.textContent?.trim();
537
+ if (!name) return;
538
+
539
+ items.push({
540
+ name,
541
+ quantity: qtyEl
542
+ ? parseInt(qtyEl.value || qtyEl.textContent || "1", 10)
543
+ : 1,
544
+ price: priceEl?.textContent?.trim() || "N/A",
545
+ lineTotal: totalEl?.textContent?.trim(),
546
+ });
547
+ });
548
+
549
+ const subtotalEl = document.querySelector(
550
+ ".subtotal, [data-testid='subtotal']"
551
+ );
552
+ const taxEl = document.querySelector(
553
+ ".estimated-tax, [data-testid='tax']"
554
+ );
555
+ const totalEl = document.querySelector(
556
+ ".order-total, [data-testid='order-total']"
557
+ );
558
+ const fulfillmentEl = document.querySelector(
559
+ ".fulfillment-type, [data-testid='fulfillment']"
560
+ );
561
+
562
+ return {
563
+ items,
564
+ subtotal: subtotalEl?.textContent?.trim() || "N/A",
565
+ estimatedTax: taxEl?.textContent?.trim(),
566
+ total: totalEl?.textContent?.trim() || "N/A",
567
+ itemCount: items.length,
568
+ fulfillmentType: fulfillmentEl?.textContent?.trim(),
569
+ };
570
+ });
571
+ }
572
+
573
+ export async function removeFromCart(
574
+ itemName: string
575
+ ): Promise<{ success: boolean; message: string }> {
576
+ const p = await getPage();
577
+ await p.goto(`${BJS_BASE}/cart`, { waitUntil: "domcontentloaded" });
578
+
579
+ const removed = await p.evaluate((name: string) => {
580
+ const items = document.querySelectorAll(
581
+ ".cart-item, [data-testid='cart-item']"
582
+ );
583
+ for (const item of Array.from(items)) {
584
+ const nameEl = item.querySelector(".item-name, [data-testid='item-name'], a");
585
+ if (nameEl?.textContent?.toLowerCase().includes(name.toLowerCase())) {
586
+ const removeBtn = item.querySelector(
587
+ ".remove-btn, [data-testid='remove-item'], button:has-text('Remove')"
588
+ ) as HTMLButtonElement | null;
589
+ if (removeBtn) {
590
+ removeBtn.click();
591
+ return true;
592
+ }
593
+ }
594
+ }
595
+ return false;
596
+ }, itemName);
597
+
598
+ await p.waitForTimeout(1500);
599
+
600
+ return {
601
+ success: removed,
602
+ message: removed
603
+ ? `Removed "${itemName}" from cart`
604
+ : `Could not find "${itemName}" in cart`,
605
+ };
606
+ }
607
+
608
+ export async function startCheckout(
609
+ fulfillmentType: "pickup" | "delivery" | "ship" = "pickup"
610
+ ): Promise<{ checkoutUrl: string; message: string }> {
611
+ const p = await getPage();
612
+ await p.goto(`${BJS_BASE}/cart`, { waitUntil: "domcontentloaded" });
613
+
614
+ // Try to select fulfillment type
615
+ await p.evaluate((type: string) => {
616
+ const tabs = document.querySelectorAll(
617
+ ".fulfillment-tab, [data-testid='fulfillment-option']"
618
+ );
619
+ tabs.forEach((tab) => {
620
+ if (tab.textContent?.toLowerCase().includes(type)) {
621
+ (tab as HTMLElement).click();
622
+ }
623
+ });
624
+ }, fulfillmentType);
625
+
626
+ await p.waitForTimeout(1000);
627
+
628
+ // Click checkout
629
+ const checkoutBtn = await p
630
+ .$(
631
+ "[data-testid='checkout-btn'], .checkout-btn, button:has-text('Checkout'), a:has-text('Checkout')"
632
+ )
633
+ .catch(() => null);
634
+
635
+ if (checkoutBtn) {
636
+ await checkoutBtn.click();
637
+ await p.waitForNavigation({ waitUntil: "domcontentloaded" }).catch(() => {});
638
+ }
639
+
640
+ const url = p.url();
641
+ return {
642
+ checkoutUrl: url,
643
+ message: `Checkout initiated with ${fulfillmentType} fulfillment. URL: ${url}`,
644
+ };
645
+ }
646
+
647
+ // ── Membership ────────────────────────────────────────────────────────────────
648
+
649
+ export async function getMembershipInfo(): Promise<{
650
+ memberName?: string;
651
+ membershipNumber?: string;
652
+ membershipType?: string;
653
+ membershipExpiry?: string;
654
+ primaryCard?: string;
655
+ additionalCards?: number;
656
+ rewards?: string;
657
+ }> {
658
+ const p = await getPage();
659
+ await p.goto(`${BJS_BASE}/account/membership`, {
660
+ waitUntil: "domcontentloaded",
661
+ });
662
+
663
+ return await p.evaluate(() => {
664
+ const nameEl = document.querySelector(".member-name, [data-testid='member-name']");
665
+ const numberEl = document.querySelector(".membership-number, [data-testid='membership-number']");
666
+ const typeEl = document.querySelector(".membership-type, [data-testid='membership-type']");
667
+ const expiryEl = document.querySelector(
668
+ ".membership-expiry, .expiry-date, [data-testid='expiry']"
669
+ );
670
+ const rewardsEl = document.querySelector(
671
+ ".rewards-balance, [data-testid='rewards']"
672
+ );
673
+
674
+ return {
675
+ memberName: nameEl?.textContent?.trim(),
676
+ membershipNumber: numberEl?.textContent?.trim(),
677
+ membershipType: typeEl?.textContent?.trim(),
678
+ membershipExpiry: expiryEl?.textContent?.trim(),
679
+ rewards: rewardsEl?.textContent?.trim(),
680
+ };
681
+ });
682
+ }
683
+
684
+ export async function renewMembership(): Promise<{
685
+ renewUrl: string;
686
+ message: string;
687
+ }> {
688
+ const p = await getPage();
689
+ await p.goto(`${BJS_BASE}/account/membership/renew`, {
690
+ waitUntil: "domcontentloaded",
691
+ });
692
+ return {
693
+ renewUrl: p.url(),
694
+ message:
695
+ "Membership renewal page loaded. Complete the renewal process in the browser.",
696
+ };
697
+ }
698
+
699
+ // ── Coupons ───────────────────────────────────────────────────────────────────
700
+
701
+ export async function getCoupons(
702
+ category?: string
703
+ ): Promise<Coupon[]> {
704
+ const p = await getPage();
705
+ const url = category
706
+ ? `${BJS_BASE}/offers?category=${encodeURIComponent(category)}`
707
+ : `${BJS_BASE}/offers`;
708
+ await p.goto(url, { waitUntil: "domcontentloaded" });
709
+
710
+ await p
711
+ .waitForSelector(".coupon-card, [data-testid='coupon'], .offer-card", {
712
+ timeout: 8000,
713
+ })
714
+ .catch(() => {});
715
+
716
+ return await p.evaluate(() => {
717
+ const couponEls = document.querySelectorAll(
718
+ ".coupon-card, [data-testid='coupon'], .offer-card"
719
+ );
720
+ return Array.from(couponEls).map((el) => {
721
+ const titleEl = el.querySelector(".coupon-title, .offer-title, h3, h4");
722
+ const descEl = el.querySelector(".coupon-desc, .offer-description");
723
+ const discountEl = el.querySelector(".discount, .savings, .coupon-value");
724
+ const expiryEl = el.querySelector(".expiry, .expires, [data-testid='expiry']");
725
+ const clippedEl = el.querySelector(".clipped, .clipped-badge, [data-testid='clipped']");
726
+ const categoryEl = el.querySelector(".category, .coupon-category");
727
+ const idAttr =
728
+ el.getAttribute("data-coupon-id") ||
729
+ el.getAttribute("data-offer-id") ||
730
+ el.getAttribute("id") ||
731
+ Math.random().toString(36).slice(2);
732
+
733
+ return {
734
+ couponId: idAttr,
735
+ title: titleEl?.textContent?.trim() || "Untitled Offer",
736
+ description: descEl?.textContent?.trim(),
737
+ discount: discountEl?.textContent?.trim(),
738
+ expiryDate: expiryEl?.textContent?.trim(),
739
+ isClipped: !!clippedEl,
740
+ category: categoryEl?.textContent?.trim(),
741
+ };
742
+ });
743
+ });
744
+ }
745
+
746
+ export async function clipCoupon(
747
+ couponId: string
748
+ ): Promise<{ success: boolean; message: string }> {
749
+ const p = await getPage();
750
+ await p.goto(`${BJS_BASE}/offers`, { waitUntil: "domcontentloaded" });
751
+
752
+ const clipped = await p.evaluate((id: string) => {
753
+ const couponEl = document.querySelector(
754
+ `[data-coupon-id='${id}'], [data-offer-id='${id}'], #${id}`
755
+ );
756
+ if (!couponEl) return false;
757
+ const clipBtn = couponEl.querySelector(
758
+ ".clip-btn, [data-testid='clip-coupon'], button:has-text('Clip'), button:has-text('Add')"
759
+ ) as HTMLButtonElement | null;
760
+ if (clipBtn && !clipBtn.disabled) {
761
+ clipBtn.click();
762
+ return true;
763
+ }
764
+ return false;
765
+ }, couponId);
766
+
767
+ await p.waitForTimeout(1000);
768
+
769
+ return {
770
+ success: clipped,
771
+ message: clipped
772
+ ? `Coupon ${couponId} clipped to your membership card`
773
+ : `Could not clip coupon ${couponId} — it may already be clipped or not found`,
774
+ };
775
+ }
776
+
777
+ export async function getMyClippedCoupons(): Promise<Coupon[]> {
778
+ const p = await getPage();
779
+ await p.goto(`${BJS_BASE}/account/coupons`, {
780
+ waitUntil: "domcontentloaded",
781
+ });
782
+
783
+ await p
784
+ .waitForSelector(".coupon-card, [data-testid='coupon'], .offer-card", {
785
+ timeout: 8000,
786
+ })
787
+ .catch(() => {});
788
+
789
+ return await p.evaluate(() => {
790
+ const couponEls = document.querySelectorAll(
791
+ ".coupon-card, [data-testid='coupon'], .offer-card"
792
+ );
793
+ return Array.from(couponEls).map((el) => {
794
+ const titleEl = el.querySelector(".coupon-title, .offer-title, h3, h4");
795
+ const discountEl = el.querySelector(".discount, .savings, .coupon-value");
796
+ const expiryEl = el.querySelector(".expiry, .expires");
797
+ const idAttr =
798
+ el.getAttribute("data-coupon-id") ||
799
+ el.getAttribute("data-offer-id") ||
800
+ Math.random().toString(36).slice(2);
801
+
802
+ return {
803
+ couponId: idAttr,
804
+ title: titleEl?.textContent?.trim() || "Untitled Offer",
805
+ discount: discountEl?.textContent?.trim(),
806
+ expiryDate: expiryEl?.textContent?.trim(),
807
+ isClipped: true,
808
+ };
809
+ });
810
+ });
811
+ }
812
+
813
+ // ── Orders ────────────────────────────────────────────────────────────────────
814
+
815
+ export async function getOrderHistory(maxOrders = 10): Promise<Order[]> {
816
+ const p = await getPage();
817
+ await p.goto(`${BJS_BASE}/account/orders`, { waitUntil: "domcontentloaded" });
818
+
819
+ await p
820
+ .waitForSelector(".order-card, [data-testid='order'], .order-item", {
821
+ timeout: 8000,
822
+ })
823
+ .catch(() => {});
824
+
825
+ return await p.evaluate((max: number) => {
826
+ const orderEls = document.querySelectorAll(
827
+ ".order-card, [data-testid='order'], .order-item"
828
+ );
829
+ return Array.from(orderEls)
830
+ .slice(0, max)
831
+ .map((el) => {
832
+ const idEl = el.querySelector(
833
+ ".order-number, [data-testid='order-id']"
834
+ );
835
+ const dateEl = el.querySelector(
836
+ ".order-date, [data-testid='order-date']"
837
+ );
838
+ const statusEl = el.querySelector(
839
+ ".order-status, [data-testid='order-status']"
840
+ );
841
+ const totalEl = el.querySelector(
842
+ ".order-total, [data-testid='order-total']"
843
+ );
844
+ const fulfillmentEl = el.querySelector(
845
+ ".fulfillment-type, [data-testid='fulfillment']"
846
+ );
847
+ const trackingEl = el.querySelector(
848
+ ".tracking-number, [data-testid='tracking']"
849
+ );
850
+ const itemCountEl = el.querySelector(
851
+ ".item-count, [data-testid='item-count']"
852
+ );
853
+
854
+ return {
855
+ orderId:
856
+ idEl?.textContent?.trim() || Math.random().toString(36).slice(2),
857
+ orderDate: dateEl?.textContent?.trim() || "Unknown",
858
+ status: statusEl?.textContent?.trim() || "Unknown",
859
+ total: totalEl?.textContent?.trim() || "N/A",
860
+ itemCount: itemCountEl
861
+ ? parseInt(itemCountEl.textContent || "0", 10)
862
+ : undefined,
863
+ fulfillmentType: fulfillmentEl?.textContent?.trim(),
864
+ trackingNumber: trackingEl?.textContent?.trim(),
865
+ };
866
+ });
867
+ }, maxOrders);
868
+ }
869
+
870
+ export async function getOrderDetails(orderId: string): Promise<{
871
+ order?: Order;
872
+ items?: { name: string; quantity: number; price: string }[];
873
+ deliveryAddress?: string;
874
+ message: string;
875
+ }> {
876
+ const p = await getPage();
877
+ await p.goto(`${BJS_BASE}/account/orders/${orderId}`, {
878
+ waitUntil: "domcontentloaded",
879
+ });
880
+
881
+ const exists = await p.evaluate(() => {
882
+ return !document.querySelector(".not-found, .error-page");
883
+ });
884
+
885
+ if (!exists) {
886
+ return { message: `Order ${orderId} not found` };
887
+ }
888
+
889
+ const details = await p.evaluate(() => {
890
+ const orderEls = document.querySelectorAll(".order-line-item, [data-testid='order-item']");
891
+ const items = Array.from(orderEls).map((el) => {
892
+ const nameEl = el.querySelector(".item-name, a");
893
+ const qtyEl = el.querySelector(".item-qty, .quantity");
894
+ const priceEl = el.querySelector(".item-price");
895
+ return {
896
+ name: nameEl?.textContent?.trim() || "Unknown item",
897
+ quantity: parseInt(qtyEl?.textContent || "1", 10),
898
+ price: priceEl?.textContent?.trim() || "N/A",
899
+ };
900
+ });
901
+
902
+ const statusEl = document.querySelector(".order-status, [data-testid='order-status']");
903
+ const dateEl = document.querySelector(".order-date");
904
+ const totalEl = document.querySelector(".order-total");
905
+ const addressEl = document.querySelector(".delivery-address, .ship-to");
906
+ const trackingEl = document.querySelector(".tracking-number");
907
+
908
+ return {
909
+ status: statusEl?.textContent?.trim() || "Unknown",
910
+ orderDate: dateEl?.textContent?.trim() || "Unknown",
911
+ total: totalEl?.textContent?.trim() || "N/A",
912
+ deliveryAddress: addressEl?.textContent?.trim(),
913
+ trackingNumber: trackingEl?.textContent?.trim(),
914
+ items,
915
+ };
916
+ });
917
+
918
+ return {
919
+ order: {
920
+ orderId,
921
+ orderDate: details.orderDate,
922
+ status: details.status,
923
+ total: details.total,
924
+ trackingNumber: details.trackingNumber,
925
+ },
926
+ items: details.items,
927
+ deliveryAddress: details.deliveryAddress,
928
+ message: `Order ${orderId} details retrieved`,
929
+ };
930
+ }
931
+
932
+ // ── Clubs ─────────────────────────────────────────────────────────────────────
933
+
934
+ export async function findClubs(
935
+ zipCode: string,
936
+ radius = 25
937
+ ): Promise<Club[]> {
938
+ const p = await getPage();
939
+ await p.goto(
940
+ `${BJS_BASE}/club-finder?zip=${encodeURIComponent(zipCode)}&radius=${radius}`,
941
+ { waitUntil: "domcontentloaded" }
942
+ );
943
+
944
+ await p
945
+ .waitForSelector(".club-card, [data-testid='club'], .club-result", {
946
+ timeout: 10000,
947
+ })
948
+ .catch(() => {});
949
+
950
+ return await p.evaluate(() => {
951
+ const clubEls = document.querySelectorAll(
952
+ ".club-card, [data-testid='club'], .club-result"
953
+ );
954
+ return Array.from(clubEls).map((el) => {
955
+ const nameEl = el.querySelector(".club-name, h2, h3");
956
+ const addressEl = el.querySelector(".club-address, .address-street");
957
+ const cityEl = el.querySelector(".city, .club-city");
958
+ const stateEl = el.querySelector(".state, .club-state");
959
+ const zipEl = el.querySelector(".zip, .club-zip");
960
+ const phoneEl = el.querySelector(".phone, .club-phone");
961
+ const distanceEl = el.querySelector(".distance");
962
+ const hoursEl = el.querySelector(".hours, .club-hours");
963
+ const clubId =
964
+ el.getAttribute("data-club-id") ||
965
+ el.getAttribute("data-store-id") ||
966
+ Math.random().toString(36).slice(2);
967
+ const hasGas = !!(
968
+ el.querySelector(".gas-station, .fuel") ||
969
+ el.textContent?.toLowerCase().includes("gas")
970
+ );
971
+
972
+ return {
973
+ clubId,
974
+ name: nameEl?.textContent?.trim() || "BJ's Wholesale Club",
975
+ address: addressEl?.textContent?.trim() || "",
976
+ city: cityEl?.textContent?.trim() || "",
977
+ state: stateEl?.textContent?.trim() || "",
978
+ zip: zipEl?.textContent?.trim() || "",
979
+ phone: phoneEl?.textContent?.trim(),
980
+ distance: distanceEl?.textContent?.trim(),
981
+ hours: hoursEl?.textContent?.trim(),
982
+ hasGas,
983
+ };
984
+ });
985
+ });
986
+ }
987
+
988
+ export async function getGasPrices(
989
+ zipCode?: string
990
+ ): Promise<{ clubs: Club[]; lastUpdated: string }> {
991
+ const p = await getPage();
992
+ const url = zipCode
993
+ ? `${BJS_BASE}/gas-prices?zip=${encodeURIComponent(zipCode)}`
994
+ : `${BJS_BASE}/gas-prices`;
995
+ await p.goto(url, { waitUntil: "domcontentloaded" });
996
+
997
+ await p
998
+ .waitForSelector(
999
+ ".gas-station, [data-testid='gas-price'], .fuel-price",
1000
+ { timeout: 10000 }
1001
+ )
1002
+ .catch(() => {});
1003
+
1004
+ const clubs = await p.evaluate(() => {
1005
+ const stationEls = document.querySelectorAll(
1006
+ ".gas-station, [data-testid='gas-station'], .fuel-station"
1007
+ );
1008
+ return Array.from(stationEls).map((el) => {
1009
+ const nameEl = el.querySelector(".station-name, .club-name, h3");
1010
+ const addressEl = el.querySelector(".address");
1011
+ const regularEl = el.querySelector(
1012
+ ".regular-price, [data-grade='regular'], [data-testid='regular']"
1013
+ );
1014
+ const premiumEl = el.querySelector(
1015
+ ".premium-price, [data-grade='premium'], [data-testid='premium']"
1016
+ );
1017
+ const dieselEl = el.querySelector(
1018
+ ".diesel-price, [data-grade='diesel'], [data-testid='diesel']"
1019
+ );
1020
+ const clubId =
1021
+ el.getAttribute("data-club-id") ||
1022
+ el.getAttribute("data-store-id") ||
1023
+ Math.random().toString(36).slice(2);
1024
+
1025
+ return {
1026
+ clubId,
1027
+ name: nameEl?.textContent?.trim() || "BJ's Gas Station",
1028
+ address: addressEl?.textContent?.trim() || "",
1029
+ city: "",
1030
+ state: "",
1031
+ zip: "",
1032
+ hasGas: true,
1033
+ gasPriceRegular: regularEl?.textContent?.trim(),
1034
+ gasPricePremium: premiumEl?.textContent?.trim(),
1035
+ gasPriceDiesel: dieselEl?.textContent?.trim(),
1036
+ };
1037
+ });
1038
+ });
1039
+
1040
+ return {
1041
+ clubs,
1042
+ lastUpdated: new Date().toISOString(),
1043
+ };
1044
+ }
1045
+
1046
+ // ── Process cleanup ───────────────────────────────────────────────────────────
1047
+
1048
+ process.on("exit", () => {
1049
+ if (browser) browser.close();
1050
+ });