@striderlabs/mcp-hm 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,820 @@
1
+ import { chromium, type Browser, type BrowserContext, type Page } from "patchright";
2
+ import { saveCookies, loadCookies, saveSessionInfo, loadSessionInfo, clearAuthData } from "./auth.js";
3
+
4
+ const HM_BASE = "https://www2.hm.com/en_us";
5
+ const HM_SEARCH = `${HM_BASE}/search-results.html`;
6
+ const HM_SALE = `${HM_BASE}/sale`;
7
+ const HM_CART = `${HM_BASE}/cart`;
8
+ const HM_WISHLIST = `${HM_BASE}/wishlist.html`;
9
+ const HM_ORDERS = `${HM_BASE}/profile/order-history.html`;
10
+ const HM_STORES = `${HM_BASE}/store-finder.html`;
11
+ const HM_MEMBER = `${HM_BASE}/member`;
12
+ const HM_SIZE_GUIDE = `${HM_BASE}/customer-service/size-guide.html`;
13
+
14
+ export interface ProductResult {
15
+ name: string;
16
+ price: string;
17
+ originalPrice?: string;
18
+ discount?: string;
19
+ color?: string;
20
+ imageUrl?: string;
21
+ productUrl?: string;
22
+ productId?: string;
23
+ category?: string;
24
+ isOnSale: boolean;
25
+ }
26
+
27
+ export interface ProductDetails {
28
+ name: string;
29
+ price: string;
30
+ originalPrice?: string;
31
+ description?: string;
32
+ materials?: string;
33
+ colors: string[];
34
+ sizes: string[];
35
+ availableSizes: string[];
36
+ images: string[];
37
+ productId?: string;
38
+ category?: string;
39
+ careInstructions?: string;
40
+ fit?: string;
41
+ }
42
+
43
+ export interface CartItem {
44
+ name: string;
45
+ price: string;
46
+ color?: string;
47
+ size?: string;
48
+ quantity: number;
49
+ imageUrl?: string;
50
+ }
51
+
52
+ export interface CartSummary {
53
+ items: CartItem[];
54
+ subtotal: string;
55
+ total: string;
56
+ itemCount: number;
57
+ estimatedShipping?: string;
58
+ }
59
+
60
+ export interface StoreResult {
61
+ name: string;
62
+ address: string;
63
+ city: string;
64
+ distance?: string;
65
+ phone?: string;
66
+ hours?: string;
67
+ hasStock?: boolean;
68
+ }
69
+
70
+ export interface MemberInfo {
71
+ name?: string;
72
+ email?: string;
73
+ points?: string;
74
+ tier?: string;
75
+ rewards?: string[];
76
+ nextReward?: string;
77
+ }
78
+
79
+ export interface OrderInfo {
80
+ orderId: string;
81
+ date: string;
82
+ status: string;
83
+ total: string;
84
+ items?: string[];
85
+ estimatedDelivery?: string;
86
+ trackingNumber?: string;
87
+ }
88
+
89
+ export interface WishlistItem {
90
+ name: string;
91
+ price: string;
92
+ color?: string;
93
+ imageUrl?: string;
94
+ productUrl?: string;
95
+ inStock: boolean;
96
+ }
97
+
98
+ export interface SizeGuideEntry {
99
+ garmentType: string;
100
+ measurements: Record<string, string>;
101
+ tips?: string;
102
+ }
103
+
104
+ let browser: Browser | null = null;
105
+ let context: BrowserContext | null = null;
106
+ let page: Page | null = null;
107
+
108
+ async function getPage(): Promise<Page> {
109
+ if (!browser) {
110
+ browser = await chromium.launch({
111
+ headless: true,
112
+ channel: "chrome",
113
+ args: [
114
+ "--no-sandbox",
115
+ "--disable-blink-features=AutomationControlled",
116
+ "--disable-infobars",
117
+ "--disable-dev-shm-usage",
118
+ ],
119
+ });
120
+ }
121
+
122
+ if (!context) {
123
+ context = await browser.newContext({
124
+ userAgent:
125
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
126
+ viewport: { width: 1280, height: 900 },
127
+ locale: "en-US",
128
+ });
129
+ await loadCookies(context);
130
+ }
131
+
132
+ if (!page || page.isClosed()) {
133
+ page = await context.newPage();
134
+ await page.setExtraHTTPHeaders({
135
+ "Accept-Language": "en-US,en;q=0.9",
136
+ });
137
+ }
138
+
139
+ return page;
140
+ }
141
+
142
+ async function dismissPopups(p: Page): Promise<void> {
143
+ try {
144
+ // Cookie consent
145
+ const cookieBtn = p.locator('[id*="onetrust-accept"], [class*="cookie-accept"], button:has-text("Accept All"), button:has-text("Accept all cookies")');
146
+ if (await cookieBtn.first().isVisible({ timeout: 3000 })) {
147
+ await cookieBtn.first().click();
148
+ await p.waitForTimeout(500);
149
+ }
150
+ } catch {
151
+ // no popup
152
+ }
153
+ try {
154
+ // Newsletter/modal
155
+ const closeBtn = p.locator('[aria-label="Close"], [class*="modal-close"], [class*="dialog-close"]');
156
+ if (await closeBtn.first().isVisible({ timeout: 2000 })) {
157
+ await closeBtn.first().click();
158
+ await p.waitForTimeout(300);
159
+ }
160
+ } catch {
161
+ // no modal
162
+ }
163
+ }
164
+
165
+ export async function checkLoginStatus(): Promise<{ isLoggedIn: boolean; userEmail?: string; userName?: string }> {
166
+ try {
167
+ const p = await getPage();
168
+ await p.goto(HM_BASE, { waitUntil: "domcontentloaded", timeout: 30000 });
169
+ await dismissPopups(p);
170
+
171
+ // Check for logged-in indicators
172
+ const accountName = await p.locator('[data-testid="header-member-name"], [class*="member-name"], [class*="account-name"]').first().textContent({ timeout: 3000 }).catch(() => null);
173
+ const isLoggedIn = accountName !== null && accountName.trim().length > 0;
174
+
175
+ const session = loadSessionInfo();
176
+ if (isLoggedIn) {
177
+ const info = { isLoggedIn: true, userName: accountName?.trim(), userEmail: session.userEmail, lastUpdated: new Date().toISOString() };
178
+ saveSessionInfo(info);
179
+ return { isLoggedIn: true, userName: accountName?.trim(), userEmail: session.userEmail };
180
+ }
181
+
182
+ // Check profile icon state
183
+ const loginLink = await p.locator('a[href*="/login"], a[href*="sign-in"], [data-testid*="login"]').first().isVisible({ timeout: 2000 }).catch(() => false);
184
+ return { isLoggedIn: !loginLink };
185
+ } catch {
186
+ return { isLoggedIn: false };
187
+ }
188
+ }
189
+
190
+ export async function initiateLogin(): Promise<{ url: string; instructions: string }> {
191
+ const p = await getPage();
192
+ await p.goto(`${HM_BASE}/login`, { waitUntil: "domcontentloaded", timeout: 30000 });
193
+ await dismissPopups(p);
194
+ return {
195
+ url: p.url(),
196
+ instructions:
197
+ "Please complete login in the browser window. Once logged in, call hm_status to confirm and save your session.",
198
+ };
199
+ }
200
+
201
+ export async function logout(): Promise<void> {
202
+ try {
203
+ const p = await getPage();
204
+ await p.goto(`${HM_BASE}/logout`, { waitUntil: "domcontentloaded", timeout: 15000 });
205
+ } catch {
206
+ // ignore
207
+ }
208
+ clearAuthData();
209
+ if (context) {
210
+ await context.clearCookies();
211
+ }
212
+ saveSessionInfo({ isLoggedIn: false, lastUpdated: new Date().toISOString() });
213
+ }
214
+
215
+ export async function searchProducts(
216
+ query: string,
217
+ options: { maxResults?: number; category?: string; sortBy?: string; onSaleOnly?: boolean } = {}
218
+ ): Promise<ProductResult[]> {
219
+ const { maxResults = 10, category, sortBy, onSaleOnly = false } = options;
220
+ const p = await getPage();
221
+
222
+ let url = `${HM_SEARCH}?q=${encodeURIComponent(query)}`;
223
+ if (category) url += `&department=${encodeURIComponent(category)}`;
224
+ if (sortBy) url += `&sort=${encodeURIComponent(sortBy)}`;
225
+ if (onSaleOnly) url += `&sale=true`;
226
+
227
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
228
+ await dismissPopups(p);
229
+ await p.waitForTimeout(2000);
230
+
231
+ const results = await p.evaluate((max: number) => {
232
+ const items: {
233
+ name: string;
234
+ price: string;
235
+ originalPrice?: string;
236
+ discount?: string;
237
+ color?: string;
238
+ imageUrl?: string;
239
+ productUrl?: string;
240
+ productId?: string;
241
+ isOnSale: boolean;
242
+ }[] = [];
243
+
244
+ // H&M product cards
245
+ const cards = document.querySelectorAll(
246
+ "article.product-item, [class*='product-item'], li[class*='product'], [data-productid]"
247
+ );
248
+
249
+ cards.forEach((card, idx) => {
250
+ if (idx >= max) return;
251
+
252
+ const nameEl = card.querySelector("h2, h3, [class*='item-heading'], [class*='product-name']");
253
+ const name = nameEl?.textContent?.trim() || "";
254
+ if (!name) return;
255
+
256
+ const priceEl = card.querySelector("[class*='price']:not([class*='original']):not([class*='regular-price'])");
257
+ const originalPriceEl = card.querySelector("[class*='original-price'], [class*='regular-price']");
258
+ const price = priceEl?.textContent?.trim() || "";
259
+ const originalPrice = originalPriceEl?.textContent?.trim();
260
+ const isOnSale = !!originalPrice && originalPrice !== price;
261
+
262
+ const colorEl = card.querySelector("[class*='color'], [class*='swatch']");
263
+ const color = colorEl?.getAttribute("title") || colorEl?.textContent?.trim();
264
+
265
+ const imgEl = card.querySelector("img");
266
+ const imageUrl = imgEl?.src || imgEl?.getAttribute("data-src") || "";
267
+
268
+ const linkEl = card.querySelector("a[href]");
269
+ const productUrl = linkEl ? "https://www2.hm.com" + (linkEl.getAttribute("href") || "") : "";
270
+ const productId = card.getAttribute("data-productid") || card.getAttribute("data-article-code") || "";
271
+
272
+ items.push({ name, price, originalPrice, isOnSale, color, imageUrl, productUrl, productId });
273
+ });
274
+
275
+ // Fallback: try alternative selectors
276
+ if (items.length === 0) {
277
+ const altCards = document.querySelectorAll("[class*='ProductCard'], [class*='product-card']");
278
+ altCards.forEach((card, idx) => {
279
+ if (idx >= max) return;
280
+ const nameEl = card.querySelector("[class*='heading'], [class*='name'], h3, h4");
281
+ const name = nameEl?.textContent?.trim() || "";
282
+ if (!name) return;
283
+ const priceEl = card.querySelector("[class*='price']");
284
+ const price = priceEl?.textContent?.trim() || "";
285
+ const linkEl = card.querySelector("a");
286
+ const productUrl = linkEl ? "https://www2.hm.com" + (linkEl.getAttribute("href") || "") : "";
287
+ items.push({ name, price, isOnSale: false, productUrl });
288
+ });
289
+ }
290
+
291
+ return items;
292
+ }, maxResults);
293
+
294
+ return results;
295
+ }
296
+
297
+ export async function getProductDetails(productUrl: string): Promise<ProductDetails> {
298
+ const p = await getPage();
299
+
300
+ // Ensure full URL
301
+ const fullUrl = productUrl.startsWith("http") ? productUrl : `https://www2.hm.com${productUrl}`;
302
+ await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
303
+ await dismissPopups(p);
304
+ await p.waitForTimeout(1500);
305
+
306
+ return await p.evaluate(() => {
307
+ const name = document.querySelector("h1, [class*='product-name'], [class*='heading-xlarge']")?.textContent?.trim() || "";
308
+ const priceEl = document.querySelector("[class*='price-value']:not([class*='original']), [class*='current-price']");
309
+ const price = priceEl?.textContent?.trim() || "";
310
+ const originalPriceEl = document.querySelector("[class*='original-price'], [class*='was-price']");
311
+ const originalPrice = originalPriceEl?.textContent?.trim();
312
+
313
+ const descEl = document.querySelector("[class*='description'], [class*='product-description'], [id*='section-descriptionAccordion']");
314
+ const description = descEl?.textContent?.trim();
315
+
316
+ const materialsEl = document.querySelector("[class*='composition'], [class*='material'], [id*='section-materialAndSuppliersAccordion']");
317
+ const materials = materialsEl?.textContent?.trim();
318
+
319
+ const careEl = document.querySelector("[class*='care'], [id*='section-careGuideAccordion']");
320
+ const careInstructions = careEl?.textContent?.trim();
321
+
322
+ // Colors
323
+ const colors: string[] = [];
324
+ document.querySelectorAll("[class*='color-option'], [class*='swatch'] [title], [aria-label*='color']").forEach((el) => {
325
+ const c = el.getAttribute("title") || el.getAttribute("aria-label") || el.textContent?.trim();
326
+ if (c && !colors.includes(c)) colors.push(c);
327
+ });
328
+
329
+ // Sizes
330
+ const sizes: string[] = [];
331
+ const availableSizes: string[] = [];
332
+ document.querySelectorAll("[class*='size-option'], [class*='size-button'], [data-size]").forEach((el) => {
333
+ const s = el.getAttribute("data-size") || el.textContent?.trim() || "";
334
+ if (!s) return;
335
+ if (!sizes.includes(s)) sizes.push(s);
336
+ const disabled = el.hasAttribute("disabled") || el.classList.contains("disabled") || el.getAttribute("aria-disabled") === "true";
337
+ if (!disabled && !availableSizes.includes(s)) availableSizes.push(s);
338
+ });
339
+
340
+ // Images
341
+ const images: string[] = [];
342
+ document.querySelectorAll("[class*='product-image'] img, [class*='gallery'] img").forEach((img) => {
343
+ const src = (img as HTMLImageElement).src;
344
+ if (src && !images.includes(src)) images.push(src);
345
+ });
346
+
347
+ const fitEl = document.querySelector("[class*='fit'], [class*='cut']");
348
+ const fit = fitEl?.textContent?.trim();
349
+
350
+ const productId = (document.querySelector("[data-articlecode], [data-productid]") as HTMLElement)?.dataset?.articlecode ||
351
+ (document.querySelector("[data-productid]") as HTMLElement)?.dataset?.productid ||
352
+ window.location.pathname.split(".").slice(-2)[0];
353
+
354
+ const breadcrumb = document.querySelector("[class*='breadcrumb'], nav[aria-label*='breadcrumb']");
355
+ const category = breadcrumb ? breadcrumb.textContent?.trim()?.split(/\s*\/\s*/).pop() : undefined;
356
+
357
+ return { name, price, originalPrice, description, materials, colors, sizes, availableSizes, images, productId, category, careInstructions, fit };
358
+ });
359
+ }
360
+
361
+ export async function getSizeGuide(category: string): Promise<SizeGuideEntry[]> {
362
+ const p = await getPage();
363
+ await p.goto(`${HM_SIZE_GUIDE}#${encodeURIComponent(category.toLowerCase())}`, {
364
+ waitUntil: "domcontentloaded",
365
+ timeout: 30000,
366
+ });
367
+ await dismissPopups(p);
368
+ await p.waitForTimeout(1000);
369
+
370
+ return await p.evaluate((cat: string) => {
371
+ const entries: { garmentType: string; measurements: Record<string, string>; tips?: string }[] = [];
372
+
373
+ const tables = document.querySelectorAll("table, [class*='size-table']");
374
+ tables.forEach((table) => {
375
+ const heading = table.previousElementSibling?.textContent?.trim() || cat;
376
+ const measurements: Record<string, string> = {};
377
+
378
+ const headers: string[] = [];
379
+ table.querySelectorAll("th").forEach((th) => headers.push(th.textContent?.trim() || ""));
380
+
381
+ table.querySelectorAll("tr").forEach((row) => {
382
+ const cells = row.querySelectorAll("td");
383
+ if (cells.length >= 2) {
384
+ const key = cells[0].textContent?.trim() || "";
385
+ const value = cells[1].textContent?.trim() || "";
386
+ if (key) measurements[key] = value;
387
+ }
388
+ });
389
+
390
+ if (Object.keys(measurements).length > 0) {
391
+ entries.push({ garmentType: heading, measurements });
392
+ }
393
+ });
394
+
395
+ if (entries.length === 0) {
396
+ // Fallback: extract any size content
397
+ const content = document.querySelector("[class*='size-guide'], [class*='sizing']");
398
+ if (content) {
399
+ entries.push({
400
+ garmentType: cat,
401
+ measurements: { info: content.textContent?.trim() || "See hm.com/en_us/customer-service/size-guide.html for full size guide" },
402
+ });
403
+ }
404
+ }
405
+
406
+ return entries;
407
+ }, category);
408
+ }
409
+
410
+ export async function addToCart(
411
+ productUrl: string,
412
+ options: { size?: string; color?: string; quantity?: number } = {}
413
+ ): Promise<{ success: boolean; message: string }> {
414
+ const { size, color, quantity = 1 } = options;
415
+ const p = await getPage();
416
+
417
+ const fullUrl = productUrl.startsWith("http") ? productUrl : `https://www2.hm.com${productUrl}`;
418
+ await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
419
+ await dismissPopups(p);
420
+ await p.waitForTimeout(1500);
421
+
422
+ // Select color if provided
423
+ if (color) {
424
+ try {
425
+ const colorBtn = p.locator(`[title="${color}"], [aria-label*="${color}"]`).first();
426
+ if (await colorBtn.isVisible({ timeout: 2000 })) {
427
+ await colorBtn.click();
428
+ await p.waitForTimeout(500);
429
+ }
430
+ } catch {
431
+ // color selection optional
432
+ }
433
+ }
434
+
435
+ // Select size if provided
436
+ if (size) {
437
+ try {
438
+ const sizeBtn = p.locator(`[data-size="${size}"], button:has-text("${size}")`).first();
439
+ if (await sizeBtn.isVisible({ timeout: 2000 })) {
440
+ await sizeBtn.click();
441
+ await p.waitForTimeout(500);
442
+ }
443
+ } catch {
444
+ // try alternative size selector
445
+ try {
446
+ await p.selectOption("select[name*='size'], select[id*='size']", size);
447
+ await p.waitForTimeout(500);
448
+ } catch {
449
+ // no size selector found
450
+ }
451
+ }
452
+ }
453
+
454
+ // Click Add to Cart
455
+ try {
456
+ const addBtn = p.locator(
457
+ "button[data-testid='add-to-bag'], button:has-text('Add to Bag'), button:has-text('Add to Cart'), [class*='add-to-cart']"
458
+ ).first();
459
+ await addBtn.waitFor({ state: "visible", timeout: 5000 });
460
+ await addBtn.click();
461
+ await p.waitForTimeout(1500);
462
+
463
+ // Handle quantity
464
+ if (quantity > 1) {
465
+ for (let i = 1; i < quantity; i++) {
466
+ await addBtn.click().catch(() => {});
467
+ await p.waitForTimeout(500);
468
+ }
469
+ }
470
+
471
+ // Check for success indicator
472
+ const successMsg = await p.locator("[class*='added-to-bag'], [class*='success'], [data-testid='mini-cart-count']").first().isVisible({ timeout: 3000 }).catch(() => false);
473
+ return { success: true, message: successMsg ? "Item added to bag successfully" : "Add to bag clicked - check your cart" };
474
+ } catch (e) {
475
+ return { success: false, message: `Could not add to cart: ${e instanceof Error ? e.message : String(e)}` };
476
+ }
477
+ }
478
+
479
+ export async function viewCart(): Promise<CartSummary> {
480
+ const p = await getPage();
481
+ await p.goto(HM_CART, { waitUntil: "domcontentloaded", timeout: 30000 });
482
+ await dismissPopups(p);
483
+ await p.waitForTimeout(2000);
484
+
485
+ return await p.evaluate(() => {
486
+ const items: {
487
+ name: string;
488
+ price: string;
489
+ color?: string;
490
+ size?: string;
491
+ quantity: number;
492
+ imageUrl?: string;
493
+ }[] = [];
494
+
495
+ const cartItems = document.querySelectorAll(
496
+ "[class*='cart-item'], [class*='bag-item'], [data-testid*='cart-item']"
497
+ );
498
+
499
+ cartItems.forEach((item) => {
500
+ const name = item.querySelector("[class*='product-name'], [class*='item-name'], h3")?.textContent?.trim() || "";
501
+ const price = item.querySelector("[class*='price']")?.textContent?.trim() || "";
502
+ const color = item.querySelector("[class*='color'], [class*='variant']")?.textContent?.trim();
503
+ const size = item.querySelector("[class*='size']")?.textContent?.trim();
504
+ const qtyEl = item.querySelector("[class*='quantity'], input[type='number']") as HTMLInputElement | null;
505
+ const quantity = qtyEl ? parseInt(qtyEl.value || qtyEl.textContent || "1") || 1 : 1;
506
+ const imgEl = item.querySelector("img");
507
+ const imageUrl = imgEl?.src || undefined;
508
+
509
+ if (name) items.push({ name, price, color, size, quantity, imageUrl });
510
+ });
511
+
512
+ const subtotalEl = document.querySelector("[class*='subtotal'], [class*='order-total']:not([class*='grand'])");
513
+ const totalEl = document.querySelector("[class*='grand-total'], [class*='total-price'], [data-testid='total']");
514
+ const shippingEl = document.querySelector("[class*='shipping'], [class*='delivery-cost']");
515
+
516
+ return {
517
+ items,
518
+ subtotal: subtotalEl?.textContent?.trim() || "",
519
+ total: totalEl?.textContent?.trim() || "",
520
+ itemCount: items.length,
521
+ estimatedShipping: shippingEl?.textContent?.trim(),
522
+ };
523
+ });
524
+ }
525
+
526
+ export async function initiateCheckout(): Promise<{ url: string; message: string }> {
527
+ const p = await getPage();
528
+ await p.goto(HM_CART, { waitUntil: "domcontentloaded", timeout: 30000 });
529
+ await dismissPopups(p);
530
+ await p.waitForTimeout(1500);
531
+
532
+ try {
533
+ const checkoutBtn = p.locator(
534
+ "button:has-text('Checkout'), a:has-text('Checkout'), [data-testid*='checkout'], [class*='checkout-button']"
535
+ ).first();
536
+ await checkoutBtn.waitFor({ state: "visible", timeout: 5000 });
537
+ await checkoutBtn.click();
538
+ await p.waitForTimeout(2000);
539
+
540
+ return {
541
+ url: p.url(),
542
+ message: "Checkout initiated. Complete your order in the browser or provide payment/address details.",
543
+ };
544
+ } catch {
545
+ return {
546
+ url: HM_CART,
547
+ message: "Please visit your cart to proceed to checkout: " + HM_CART,
548
+ };
549
+ }
550
+ }
551
+
552
+ export async function findStores(
553
+ location: string,
554
+ options: { productId?: string; radius?: number } = {}
555
+ ): Promise<StoreResult[]> {
556
+ const p = await getPage();
557
+ await p.goto(HM_STORES, { waitUntil: "domcontentloaded", timeout: 30000 });
558
+ await dismissPopups(p);
559
+ await p.waitForTimeout(1500);
560
+
561
+ // Enter location
562
+ try {
563
+ const searchInput = p.locator("input[type='search'], input[name*='location'], input[placeholder*='location'], input[placeholder*='city'], input[placeholder*='zip']").first();
564
+ await searchInput.waitFor({ state: "visible", timeout: 5000 });
565
+ await searchInput.fill(location);
566
+ await p.keyboard.press("Enter");
567
+ await p.waitForTimeout(3000);
568
+ } catch {
569
+ // might auto-detect location
570
+ }
571
+
572
+ return await p.evaluate(() => {
573
+ const stores: {
574
+ name: string;
575
+ address: string;
576
+ city: string;
577
+ distance?: string;
578
+ phone?: string;
579
+ hours?: string;
580
+ }[] = [];
581
+
582
+ const storeCards = document.querySelectorAll("[class*='store-item'], [class*='store-result'], [data-testid*='store']");
583
+
584
+ storeCards.forEach((card) => {
585
+ const name = card.querySelector("[class*='store-name'], h3, h4")?.textContent?.trim() || "";
586
+ const address = card.querySelector("[class*='address'], address")?.textContent?.trim() || "";
587
+ const city = card.querySelector("[class*='city']")?.textContent?.trim() || "";
588
+ const distance = card.querySelector("[class*='distance']")?.textContent?.trim();
589
+ const phone = card.querySelector("a[href^='tel:']")?.textContent?.trim() || card.querySelector("[class*='phone']")?.textContent?.trim();
590
+ const hours = card.querySelector("[class*='hours'], [class*='opening']")?.textContent?.trim();
591
+
592
+ if (name || address) {
593
+ stores.push({ name, address, city, distance, phone, hours });
594
+ }
595
+ });
596
+
597
+ return stores;
598
+ });
599
+ }
600
+
601
+ export async function getMemberRewards(): Promise<MemberInfo> {
602
+ const p = await getPage();
603
+ await p.goto(HM_MEMBER, { waitUntil: "domcontentloaded", timeout: 30000 });
604
+ await dismissPopups(p);
605
+ await p.waitForTimeout(1500);
606
+
607
+ return await p.evaluate(() => {
608
+ const name = document.querySelector("[class*='member-name'], [class*='greeting'] strong")?.textContent?.trim();
609
+ const email = document.querySelector("[class*='member-email']")?.textContent?.trim();
610
+ const points = document.querySelector("[class*='points'], [data-testid*='points']")?.textContent?.trim();
611
+ const tier = document.querySelector("[class*='tier'], [class*='level'], [class*='status']")?.textContent?.trim();
612
+
613
+ const rewards: string[] = [];
614
+ document.querySelectorAll("[class*='reward-item'], [class*='voucher']").forEach((r) => {
615
+ const text = r.textContent?.trim();
616
+ if (text) rewards.push(text);
617
+ });
618
+
619
+ const nextReward = document.querySelector("[class*='next-reward'], [class*='progress-label']")?.textContent?.trim();
620
+
621
+ return { name, email, points, tier, rewards, nextReward };
622
+ });
623
+ }
624
+
625
+ export async function trackOrder(orderId?: string): Promise<OrderInfo[]> {
626
+ const p = await getPage();
627
+ await p.goto(HM_ORDERS, { waitUntil: "domcontentloaded", timeout: 30000 });
628
+ await dismissPopups(p);
629
+ await p.waitForTimeout(1500);
630
+
631
+ const orders = await p.evaluate(() => {
632
+ const results: {
633
+ orderId: string;
634
+ date: string;
635
+ status: string;
636
+ total: string;
637
+ items?: string[];
638
+ estimatedDelivery?: string;
639
+ trackingNumber?: string;
640
+ }[] = [];
641
+
642
+ const orderCards = document.querySelectorAll("[class*='order-item'], [class*='order-row'], [data-testid*='order']");
643
+
644
+ orderCards.forEach((card) => {
645
+ const orderId = card.querySelector("[class*='order-number'], [class*='order-id']")?.textContent?.trim() ||
646
+ card.getAttribute("data-orderid") || "";
647
+ const date = card.querySelector("[class*='order-date'], time")?.textContent?.trim() || "";
648
+ const status = card.querySelector("[class*='status'], [class*='delivery-status']")?.textContent?.trim() || "";
649
+ const total = card.querySelector("[class*='total'], [class*='price']")?.textContent?.trim() || "";
650
+ const tracking = card.querySelector("[class*='tracking']")?.textContent?.trim();
651
+ const eta = card.querySelector("[class*='estimated'], [class*='delivery-date']")?.textContent?.trim();
652
+
653
+ const items: string[] = [];
654
+ card.querySelectorAll("[class*='item-name'], [class*='product-name']").forEach((item) => {
655
+ const text = item.textContent?.trim();
656
+ if (text) items.push(text);
657
+ });
658
+
659
+ if (orderId || status) {
660
+ results.push({ orderId, date, status, total, items, estimatedDelivery: eta, trackingNumber: tracking });
661
+ }
662
+ });
663
+
664
+ return results;
665
+ });
666
+
667
+ if (orderId) {
668
+ return orders.filter((o) => o.orderId.includes(orderId));
669
+ }
670
+ return orders;
671
+ }
672
+
673
+ export async function getWishlist(): Promise<WishlistItem[]> {
674
+ const p = await getPage();
675
+ await p.goto(HM_WISHLIST, { waitUntil: "domcontentloaded", timeout: 30000 });
676
+ await dismissPopups(p);
677
+ await p.waitForTimeout(1500);
678
+
679
+ return await p.evaluate(() => {
680
+ const items: {
681
+ name: string;
682
+ price: string;
683
+ color?: string;
684
+ imageUrl?: string;
685
+ productUrl?: string;
686
+ inStock: boolean;
687
+ }[] = [];
688
+
689
+ document.querySelectorAll(
690
+ "[class*='wishlist-item'], [class*='favorite-item'], article[class*='product']"
691
+ ).forEach((item) => {
692
+ const name = item.querySelector("[class*='product-name'], [class*='item-heading'], h3")?.textContent?.trim() || "";
693
+ const price = item.querySelector("[class*='price']")?.textContent?.trim() || "";
694
+ const color = item.querySelector("[class*='color']")?.textContent?.trim();
695
+ const imgEl = item.querySelector("img");
696
+ const imageUrl = imgEl?.src || undefined;
697
+ const linkEl = item.querySelector("a[href]");
698
+ const productUrl = linkEl ? "https://www2.hm.com" + linkEl.getAttribute("href") : undefined;
699
+ const outOfStock = item.querySelector("[class*='out-of-stock'], [class*='sold-out']") !== null;
700
+
701
+ if (name) {
702
+ items.push({ name, price, color, imageUrl, productUrl, inStock: !outOfStock });
703
+ }
704
+ });
705
+
706
+ return items;
707
+ });
708
+ }
709
+
710
+ export async function addToWishlist(productUrl: string): Promise<{ success: boolean; message: string }> {
711
+ const p = await getPage();
712
+ const fullUrl = productUrl.startsWith("http") ? productUrl : `https://www2.hm.com${productUrl}`;
713
+ await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
714
+ await dismissPopups(p);
715
+ await p.waitForTimeout(1000);
716
+
717
+ try {
718
+ const wishlistBtn = p.locator(
719
+ "button[aria-label*='wishlist'], button[aria-label*='favorite'], [data-testid*='wishlist'], [class*='favorite-btn']"
720
+ ).first();
721
+ await wishlistBtn.waitFor({ state: "visible", timeout: 5000 });
722
+ await wishlistBtn.click();
723
+ await p.waitForTimeout(1000);
724
+ return { success: true, message: "Added to wishlist" };
725
+ } catch (e) {
726
+ return { success: false, message: `Could not add to wishlist: ${e instanceof Error ? e.message : String(e)}` };
727
+ }
728
+ }
729
+
730
+ export async function browseSale(
731
+ options: { category?: string; maxResults?: number; sortBy?: string } = {}
732
+ ): Promise<ProductResult[]> {
733
+ const { category, maxResults = 20, sortBy } = options;
734
+ const p = await getPage();
735
+
736
+ let url = category ? `${HM_SALE}/${encodeURIComponent(category.toLowerCase())}` : HM_SALE;
737
+ if (sortBy) url += `?sort=${encodeURIComponent(sortBy)}`;
738
+
739
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
740
+ await dismissPopups(p);
741
+ await p.waitForTimeout(2000);
742
+
743
+ return await p.evaluate((max: number) => {
744
+ const items: {
745
+ name: string;
746
+ price: string;
747
+ originalPrice?: string;
748
+ discount?: string;
749
+ color?: string;
750
+ imageUrl?: string;
751
+ productUrl?: string;
752
+ productId?: string;
753
+ isOnSale: boolean;
754
+ }[] = [];
755
+
756
+ const cards = document.querySelectorAll(
757
+ "article.product-item, [class*='product-item'], li[class*='product'], [data-productid]"
758
+ );
759
+
760
+ cards.forEach((card, idx) => {
761
+ if (idx >= max) return;
762
+ const nameEl = card.querySelector("h2, h3, [class*='item-heading'], [class*='product-name']");
763
+ const name = nameEl?.textContent?.trim() || "";
764
+ if (!name) return;
765
+
766
+ const priceEl = card.querySelector("[class*='price-value'], [class*='sale-price'], [class*='current-price']");
767
+ const originalPriceEl = card.querySelector("[class*='original-price'], [class*='was-price'], [class*='regular-price']");
768
+ const price = priceEl?.textContent?.trim() || "";
769
+ const originalPrice = originalPriceEl?.textContent?.trim();
770
+
771
+ // Extract discount percentage
772
+ const discountEl = card.querySelector("[class*='discount'], [class*='percent-off'], [class*='saving']");
773
+ const discount = discountEl?.textContent?.trim();
774
+
775
+ const imgEl = card.querySelector("img");
776
+ const imageUrl = imgEl?.src || imgEl?.getAttribute("data-src") || "";
777
+
778
+ const linkEl = card.querySelector("a[href]");
779
+ const productUrl = linkEl ? "https://www2.hm.com" + (linkEl.getAttribute("href") || "") : "";
780
+ const productId = card.getAttribute("data-productid") || card.getAttribute("data-article-code") || "";
781
+
782
+ items.push({ name, price, originalPrice, discount, isOnSale: true, imageUrl, productUrl, productId });
783
+ });
784
+
785
+ return items;
786
+ }, maxResults);
787
+ }
788
+
789
+ export async function closeBrowser(): Promise<void> {
790
+ if (context) {
791
+ await saveCookies(context);
792
+ }
793
+ if (page && !page.isClosed()) {
794
+ await page.close().catch(() => {});
795
+ }
796
+ if (context) {
797
+ await context.close().catch(() => {});
798
+ context = null;
799
+ }
800
+ if (browser) {
801
+ await browser.close().catch(() => {});
802
+ browser = null;
803
+ }
804
+ page = null;
805
+ }
806
+
807
+ // Graceful shutdown
808
+ process.on("exit", () => {
809
+ if (context) {
810
+ saveCookies(context).catch(() => {});
811
+ }
812
+ });
813
+ process.on("SIGINT", async () => {
814
+ await closeBrowser();
815
+ process.exit(0);
816
+ });
817
+ process.on("SIGTERM", async () => {
818
+ await closeBrowser();
819
+ process.exit(0);
820
+ });