1import {
2 cleanText,
3 compactArray,
4 normalizeCostcoUrl,
5 parseInteger,
6 parsePrice,
7 productIdFromUrl,
8} from './helpers.js';
9
10const PRODUCT_LINK_RE = /\.product\.\d+\.html/i;
11
12const addCompetitorCompatibleFields = (record) => {
13 const availabilityText = cleanText(record.availability);
14 const isInStock = availabilityText == null ? null : !/out|unavailable|sold out/i.test(availabilityText);
15 const marketingKeywords = compactArray([
16 ...(record.name || '').split(/[^a-z0-9]+/i),
17 ...(record.brand || '').split(/[^a-z0-9]+/i),
18 ...(record.features || []).flatMap((feature) => feature.split(/[^a-z0-9]+/i)),
19 ])
20 .map((word) => word.toLowerCase())
21 .filter((word) => word.length >= 3 && word.length <= 40)
22 .slice(0, 80);
23
24 return {
25 ...record,
26 categoryUrl: record.sourceUrl || null,
27 id: record.sku ? `${record.sku}!item.en-US` : null,
28 groupId: record.productId || null,
29 itemNumber: record.sku || record.productId || null,
30 itemName: record.name,
31 image: record.mainImage || null,
32 currencyCode: record.currency || null,
33 pricePerUnit: record.unitPrice || record.price || null,
34 reviewsCount: record.reviewCount ?? null,
35 isBuyable: isInStock,
36 isPublished: true,
37 isInStock,
38 availabilityStatus: availabilityText,
39 stockStatus: availabilityText,
40 deliveryStatus: availabilityText,
41 minQuantityRestriction: null,
42 maxQuantityRestriction: null,
43 startDate: null,
44 marketingFeatures: record.features || [],
45 marketingKeywords,
46 categoryPaths: record.breadcrumbs || [],
47 itemClassification: null,
48 as400Category: null,
49 isMemberOnly: record.memberOnly,
50 isSingleSku: null,
51 };
52};
53
54export const isBlockedPage = async (page) => {
55 const title = cleanText(await page.title().catch(() => '')) || '';
56 const body = cleanText(await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')) || '';
57 const html = await page.content().catch(() => '');
58 const combined = `${title} ${body} ${html.slice(0, 5000)}`.toLowerCase();
59 return combined.includes('access denied')
60 || combined.includes('errors.edgesuite.net')
61 || combined.includes('powered and protected by')
62 || combined.includes('sec-if-cpt-container')
63 || combined.includes('pardon our interruption')
64 || combined.includes('verify you are human')
65 || combined.includes('captcha')
66 || combined.includes('bot detection');
67};
68
69export const waitForCostcoContent = async (page) => {
70 const hasAkamaiChallenge = async () => {
71 const html = await page.content().catch(() => '');
72 return /sec-if-cpt-container|powered and protected by/i.test(html);
73 };
74
75 if (await hasAkamaiChallenge()) {
76 await page.waitForFunction(() => {
77 const html = document.documentElement.outerHTML;
78 return !/sec-if-cpt-container|powered and protected by/i.test(html);
79 }, { timeout: 35000 }).catch(() => {});
80 }
81
82 await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
83 await page.waitForFunction(() => {
84 const text = document.body?.innerText || '';
85 return document.querySelector('a[href*=".product."]')
86 || document.querySelector('h1')
87 || /no results|did not match|out of stock|add to cart/i.test(text)
88 || text.length > 5000;
89 }, { timeout: 20000 }).catch(() => {});
90};
91
92export const maybeAcceptCookies = async (page) => {
93 const candidates = [
94 'button:has-text("Accept All")',
95 'button:has-text("Accept all")',
96 'button:has-text("I Accept")',
97 'button:has-text("Agree")',
98 '#onetrust-accept-btn-handler',
99 ];
100 for (const selector of candidates) {
101 const button = page.locator(selector).first();
102 if (await button.isVisible({ timeout: 750 }).catch(() => false)) {
103 await button.click({ timeout: 2000 }).catch(() => {});
104 return;
105 }
106 }
107};
108
109export const autoScroll = async (page) => {
110 await page.evaluate(async () => {
111 const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
112 const steps = Math.min(8, Math.ceil(document.body.scrollHeight / Math.max(1, window.innerHeight)));
113 for (let i = 0; i < steps; i += 1) {
114 window.scrollBy(0, Math.max(600, window.innerHeight * 0.85));
115 await delay(250);
116 }
117 window.scrollTo(0, 0);
118 }).catch(() => {});
119};
120
121export const extractListingProducts = async (page, sourceUrl) => {
122 const products = await page.evaluate(() => {
123 const clean = (value) => (value || '').replace(/\s+/g, ' ').trim();
124 const parsePriceText = (value) => {
125 const match = String(value || '').replace(/,/g, '').match(/\$\s*\d+(?:\.\d{1,2})?/);
126 return match ? match[0].replace(/\s+/g, '') : null;
127 };
128 const anchors = Array.from(document.querySelectorAll('a[href*=".product."]'));
129 const byUrl = new Map();
130
131 for (const anchor of anchors) {
132 const href = anchor.getAttribute('href');
133 if (!href || !/\.product\.\d+\.html/i.test(href)) continue;
134 let productUrl;
135 try {
136 const parsed = new URL(href, location.href);
137 parsed.hash = '';
138 parsed.search = '';
139 productUrl = parsed.href;
140 } catch {
141 continue;
142 }
143
144 const card = anchor.closest('[data-testid], [class*="product"], li, article, .col, .row') || anchor;
145 const cardText = clean(card.innerText);
146 const name = clean(
147 anchor.getAttribute('aria-label')
148 || anchor.getAttribute('title')
149 || anchor.innerText
150 || card.querySelector('[class*="description"], [class*="title"], [class*="name"], h2, h3')?.innerText,
151 );
152 const image = card.querySelector('img')?.currentSrc || card.querySelector('img')?.src || null;
153 const priceText = parsePriceText(cardText);
154 const ratingText = clean(card.querySelector('[aria-label*="star" i], [class*="rating"]')?.getAttribute('aria-label')
155 || card.querySelector('[class*="rating"]')?.innerText);
156 const reviewText = clean(card.querySelector('[class*="review"], [aria-label*="review" i]')?.innerText);
157
158 const existing = byUrl.get(productUrl) || {};
159 byUrl.set(productUrl, {
160 ...existing,
161 productUrl,
162 name: existing.name || name || null,
163 priceText: existing.priceText || priceText,
164 ratingText: existing.ratingText || ratingText || null,
165 reviewText: existing.reviewText || reviewText || null,
166 mainImage: existing.mainImage || image,
167 });
168 }
169
170 for (const script of Array.from(document.scripts)) {
171 const text = script.textContent || '';
172 if (!text.includes('.product.')) continue;
173 const matches = text.match(/https?:\\?\/\\?\/www\.costco\.com\\?\/[^"' <>\s]+?\.product\.\d+\.html/g) || [];
174 for (const raw of matches) {
175 const href = raw.replace(/\\\//g, '/');
176 try {
177 const parsed = new URL(href);
178 parsed.hash = '';
179 parsed.search = '';
180 if (!byUrl.has(parsed.href)) {
181 byUrl.set(parsed.href, { productUrl: parsed.href, name: null, priceText: null, mainImage: null });
182 }
183 } catch {}
184 }
185 }
186
187 return Array.from(byUrl.values());
188 });
189
190 return products
191 .map((item) => {
192 const productUrl = normalizeCostcoUrl(item.productUrl, sourceUrl);
193 if (!productUrl) return null;
194 const price = parsePrice(item.priceText);
195 return addCompetitorCompatibleFields({
196 productId: productIdFromUrl(productUrl),
197 sku: null,
198 productUrl,
199 sourceUrl,
200 name: cleanText(item.name) || `Costco product ${productIdFromUrl(productUrl) || ''}`.trim(),
201 brand: null,
202 price,
203 listPrice: null,
204 minPrice: null,
205 maxPrice: null,
206 currency: price == null ? null : 'USD',
207 priceText: cleanText(item.priceText),
208 unitPrice: null,
209 rating: parsePrice(item.ratingText),
210 reviewCount: parseInteger(item.reviewText),
211 availability: null,
212 memberOnly: false,
213 description: null,
214 features: [],
215 specifications: {},
216 breadcrumbs: [],
217 mainImage: item.mainImage || null,
218 images: compactArray([item.mainImage]),
219 category: null,
220 scrapedAt: new Date().toISOString(),
221 });
222 })
223 .filter(Boolean);
224};
225
226export const extractPaginationUrls = async (page, baseUrl) => {
227 const urls = await page.evaluate(() => {
228 return Array.from(document.querySelectorAll('a[href]'))
229 .map((anchor) => ({
230 href: anchor.getAttribute('href'),
231 rel: anchor.getAttribute('rel') || '',
232 text: (anchor.innerText || anchor.getAttribute('aria-label') || '').trim(),
233 }))
234 .filter(({ href, rel, text }) => {
235 if (!href) return false;
236 const marker = `${rel} ${text}`.toLowerCase();
237 return marker.includes('next') || /[?&](page|currentPage|p)=\d+/i.test(href);
238 })
239 .map(({ href }) => href);
240 });
241 return compactArray(urls.map((url) => normalizeCostcoUrl(url, baseUrl)));
242};
243
244export const extractProductDetails = async (page, sourceUrl) => {
245 const raw = await page.evaluate(() => {
246 const clean = (value) => (value || '').replace(/\s+/g, ' ').trim();
247 const textOf = (selectors) => {
248 for (const selector of selectors) {
249 const element = document.querySelector(selector);
250 const text = clean(element?.textContent || element?.getAttribute?.('content') || '');
251 if (text) return text;
252 }
253 return null;
254 };
255 const attrOf = (selectors, attr) => {
256 for (const selector of selectors) {
257 const element = document.querySelector(selector);
258 const value = element?.getAttribute?.(attr);
259 if (value) return value;
260 }
261 return null;
262 };
263 const parseJson = (text) => {
264 try { return JSON.parse(text); } catch { return null; }
265 };
266 const flatten = (value) => {
267 if (!value) return [];
268 if (Array.isArray(value)) return value.flatMap(flatten);
269 if (typeof value === 'object' && Array.isArray(value['@graph'])) return [value, ...value['@graph'].flatMap(flatten)];
270 return [value];
271 };
272 const jsonObjects = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
273 .flatMap((script) => flatten(parseJson(script.textContent || '')));
274 const product = jsonObjects.find((item) => {
275 const type = item?.['@type'];
276 return type === 'Product' || (Array.isArray(type) && type.includes('Product'));
277 }) || {};
278 const breadcrumbList = jsonObjects.find((item) => item?.['@type'] === 'BreadcrumbList');
279 const breadcrumbs = (breadcrumbList?.itemListElement || [])
280 .map((entry) => clean(entry?.item?.name || entry?.name))
281 .filter(Boolean);
282 const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers || {};
283 const aggregateRating = product.aggregateRating || {};
284 const imageValues = Array.isArray(product.image) ? product.image : [product.image];
285 const domImages = Array.from(document.querySelectorAll('img'))
286 .map((img) => img.currentSrc || img.src)
287 .filter((src) => src && /costco|scene7|images/i.test(src));
288 const tableSpecs = {};
289 for (const row of Array.from(document.querySelectorAll('table tr'))) {
290 const cells = Array.from(row.querySelectorAll('th, td')).map((cell) => clean(cell.textContent)).filter(Boolean);
291 if (cells.length >= 2 && cells[0].length < 80 && cells[1].length < 300) {
292 tableSpecs[cells[0].replace(/:$/, '')] = cells.slice(1).join(' ');
293 }
294 }
295 for (const item of Array.from(document.querySelectorAll('dl'))) {
296 const terms = Array.from(item.querySelectorAll('dt'));
297 for (const term of terms) {
298 const key = clean(term.textContent)?.replace(/:$/, '');
299 const value = clean(term.nextElementSibling?.textContent);
300 if (key && value && key.length < 80 && value.length < 300) tableSpecs[key] = value;
301 }
302 }
303 const bodyText = clean(document.body.innerText);
304 const featureSelectors = [
305 '[class*="feature"] li',
306 '[class*="Feature"] li',
307 '[class*="product"] li',
308 '#productDetails li',
309 '[data-testid*="feature"] li',
310 ];
311 const features = Array.from(document.querySelectorAll(featureSelectors.join(',')))
312 .map((item) => clean(item.textContent))
313 .filter((text) => text && text.length >= 5 && text.length <= 240)
314 .slice(0, 80);
315 const skuText = bodyText?.match(/(?:Item|Item #|Item Number|SKU)\s*#?:?\s*(\d{4,})/i)?.[1] || null;
316 const availability = clean(offers.availability || '')
317 .replace(/^https?:\/\/schema\.org\//i, '')
318 || (bodyText && /out of stock/i.test(bodyText) ? 'OutOfStock' : null)
319 || (bodyText && /in stock/i.test(bodyText) ? 'InStock' : null);
320
321 return {
322 url: location.href,
323 canonical: attrOf(['link[rel="canonical"]'], 'href'),
324 name: clean(product.name) || textOf(['h1', '[itemprop="name"]', 'meta[property="og:title"]']),
325 brand: clean(product.brand?.name || product.brand) || textOf(['[itemprop="brand"]', '[class*="brand"]']),
326 description: clean(product.description) || textOf(['meta[name="description"]', 'meta[property="og:description"]', '[itemprop="description"]', '[class*="description"]']),
327 price: offers.price || attrOf(['meta[property="product:price:amount"]', '[itemprop="price"]'], 'content') || textOf(['[class*="price"]']),
328 priceCurrency: offers.priceCurrency || attrOf(['meta[property="product:price:currency"]'], 'content'),
329 listPrice: textOf(['[class*="list-price"]', '[class*="was-price"]', '[class*="strike"]']),
330 rating: aggregateRating.ratingValue || textOf(['[itemprop="ratingValue"]', '[class*="rating"]']),
331 reviewCount: aggregateRating.reviewCount || aggregateRating.ratingCount || textOf(['[itemprop="reviewCount"]', '[class*="review"]']),
332 availability,
333 sku: product.sku || product.mpn || skuText,
334 breadcrumbs,
335 features,
336 specifications: tableSpecs,
337 images: [...imageValues, ...domImages].filter(Boolean),
338 mainImage: attrOf(['meta[property="og:image"]'], 'content') || imageValues.find(Boolean) || domImages[0] || null,
339 bodyText,
340 };
341 });
342
343 const productUrl = normalizeCostcoUrl(raw.canonical || raw.url, sourceUrl) || normalizeCostcoUrl(page.url(), sourceUrl);
344 const images = compactArray((raw.images || []).map((url) => normalizeCostcoUrl(url, productUrl) || url));
345 const price = parsePrice(raw.price);
346 const listPrice = parsePrice(raw.listPrice);
347 const productId = productIdFromUrl(productUrl);
348 const name = cleanText(raw.name) || `Costco product ${productId || raw.sku || ''}`.trim();
349 const breadcrumbs = compactArray(raw.breadcrumbs || []);
350
351 return addCompetitorCompatibleFields({
352 productId,
353 sku: cleanText(raw.sku) || productId,
354 productUrl,
355 sourceUrl,
356 name,
357 brand: cleanText(raw.brand),
358 price,
359 listPrice,
360 minPrice: null,
361 maxPrice: null,
362 currency: cleanText(raw.priceCurrency) || (price == null ? null : 'USD'),
363 priceText: cleanText(raw.price),
364 unitPrice: null,
365 rating: parsePrice(raw.rating),
366 reviewCount: parseInteger(raw.reviewCount),
367 availability: cleanText(raw.availability),
368 memberOnly: /member only|members only|sign in to see price|member-only/i.test(raw.bodyText || ''),
369 description: cleanText(raw.description),
370 features: compactArray(raw.features || []),
371 specifications: raw.specifications || {},
372 breadcrumbs,
373 mainImage: raw.mainImage || images[0] || null,
374 images,
375 category: breadcrumbs.length ? breadcrumbs[breadcrumbs.length - 1] : null,
376 scrapedAt: new Date().toISOString(),
377 });
378};
379
380export const isLikelyProductLink = (url) => PRODUCT_LINK_RE.test(url || '');