@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/README.md +141 -0
- package/dist/index.js +21921 -0
- package/package.json +44 -0
- package/server.json +20 -0
- package/src/auth.ts +114 -0
- package/src/browser.ts +820 -0
- package/src/index.ts +565 -0
- package/tsconfig.json +14 -0
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
|
+
});
|