Skip to content

Instantly share code, notes, and snippets.

@listly-io
Created June 4, 2026 22:33
Show Gist options
  • Select an option

  • Save listly-io/b55dca9a2cf8015b4666377e45a2eb38 to your computer and use it in GitHub Desktop.

Select an option

Save listly-io/b55dca9a2cf8015b4666377e45a2eb38 to your computer and use it in GitHub Desktop.
costco_review_en_2026-06-04
// test page: https://www.costco.com/p/-/trunature-premium-milk-thistle-160-mg-120-vegetarian-capsules/100123488?langId=-1
// ============================================================
// ▶ User configuration (edit as needed)
// ============================================================
const MAX_PAGE_COUNT = 10; // Number of pages to collect (1 = current page only)
const WAIT_AFTER_CLICK = 5000; // Wait time after clicking to the next page (ms)
const WAIT_BEFORE_CLICK = 1000; // Wait time after scrolling, before clicking (ms)
const CONTAINER_ID = '__review_collector'; // Paste this into the Listly selector as-is
// ============================================================
// ▶ Shadow DOM selectors (adjusted for Costco's BazaarVoice structure)
// ============================================================
// Shadow root host: #CostcoBVContainer
// Reviews wrapper: section[class*="bv-rnr__ioct8z"]
// Each review: wrapper > section (direct child)
// Next page anchor: a.next (an <a> tag, not a button!)
// Pagination area: ul.bv-rnr__zv3ryj-1
// Accordion button: #member_reviews (click it to trigger the BV load)
// ============================================================
// ▶ Internal logic (no edits needed)
// ============================================================
(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── Helper: grab the shadow root ─────────────────────────
const getShadow = () => {
const host = document.getElementById('CostcoBVContainer');
return host ? host.shadowRoot : null;
};
// ── 1. Open the accordion (if it's closed) ───────────────
const accordionBtn = document.getElementById('member_reviews');
if (accordionBtn && accordionBtn.getAttribute('aria-expanded') === 'false') {
console.log('[Scraper] Opening the Member Reviews accordion...');
accordionBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
await sleep(800);
accordionBtn.click();
await sleep(WAIT_AFTER_CLICK);
}
// ── 2. Check the Shadow DOM ──────────────────────────────
let shadow = getShadow();
if (!shadow) {
console.error('[Scraper] Couldn\'t find the shadow root. Check the page.');
return;
}
// ── 3. Create the UI container (Listly scrapes this container's children) ──
let container = document.getElementById(CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.id = CONTAINER_ID;
Object.assign(container.style, {
position: 'fixed',
right: '200px',
bottom: '200px',
zIndex: '999999',
background: 'rgba(0, 0, 0, 0.85)',
color: '#0f0',
fontSize: '18px',
fontWeight: 'bold',
fontFamily: 'monospace',
padding: '16px 24px',
borderRadius: '12px',
border: '2px solid #0f0',
boxShadow: '0 4px 20px rgba(0,255,0,.3)',
cursor: 'default',
userSelect: 'none',
overflow: 'hidden',
});
document.body.appendChild(container);
}
// Collection store (hidden) — the actual data Listly reads via the selector
let store = container.querySelector('#__review_store');
if (!store) {
store = document.createElement('div');
store.id = '__review_store';
store.style.display = 'none';
container.appendChild(store);
}
// Running count label
let label = container.querySelector('#__review_label');
if (!label) {
label = document.createElement('div');
label.id = '__review_label';
container.appendChild(label);
}
const updateLabel = () => {
label.textContent = `Reviews collected: ${store.children.length}`;
};
// ── 4. Collect the reviews on the current page ───────────
const collectCurrentPage = () => {
shadow = getShadow();
if (!shadow) { console.warn('[Scraper] No shadow root, skipping'); return; }
const reviewsWrapper = shadow.querySelector('section[class*="bv-rnr__ioct8z"]');
if (!reviewsWrapper) { console.warn('[Scraper] Couldn\'t find the reviews wrapper'); return; }
const reviewSections = reviewsWrapper.querySelectorAll(':scope > section');
let added = 0;
reviewSections.forEach((sec) => {
const card = sec.children[0];
if (!card) return;
const lines = card.innerText.split('\n').map((l) => l.trim()).filter(Boolean);
// Layout: [0]rating [1]title [2]author [?]Verified Purchaser [?]date [...]body
const rating = lines[0] || '';
const title = lines[1] || '';
const author = lines[2] || '';
const verified = lines[3] === 'Verified Purchaser';
const date = verified ? (lines[4] || '') : (lines[3] || '');
const bodyStart = verified ? 5 : 4;
const body = lines.slice(bodyStart).join('\n');
// Build text nodes so Listly can pull each field with a CSS selector
const item = document.createElement('div');
item.className = 'scraped-review';
const addField = (cls, text) => {
const el = document.createElement('div');
el.className = cls;
el.textContent = text;
item.appendChild(el);
};
addField('review-rating', rating);
addField('review-title', title);
addField('review-author', author);
addField('review-verified', verified ? 'Verified Purchaser' : '');
addField('review-date', date);
addField('review-body', body);
store.appendChild(item);
added++;
});
console.log(`[Scraper] Collected ${added} reviews (total: ${store.children.length})`);
updateLabel();
};
// ── 5. Go to the next page ───────────────────────────────
const goNextPage = async () => {
shadow = getShadow();
if (!shadow) return false;
const nextA = shadow.querySelector('a.next');
if (!nextA || nextA.getAttribute('aria-disabled') === 'true' || !nextA.href) return false;
const paginationUL = shadow.querySelector('ul.bv-rnr__zv3ryj-1');
if (paginationUL) {
paginationUL.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
await sleep(WAIT_BEFORE_CLICK);
const nextAAgain = shadow.querySelector('a.next');
if (!nextAAgain || nextAAgain.getAttribute('aria-disabled') === 'true') return false;
nextAAgain.click();
return true;
};
// ── 6. Main loop ─────────────────────────────────────────
console.log(`[Scraper] Starting collection — up to ${MAX_PAGE_COUNT} pages`);
for (let page = 1; page <= MAX_PAGE_COUNT; page++) {
if (page > 1) await sleep(WAIT_AFTER_CLICK);
collectCurrentPage();
console.log(`[Scraper] Page ${page}/${MAX_PAGE_COUNT} collected`);
if (page === MAX_PAGE_COUNT) break;
const moved = await goNextPage();
if (!moved) {
console.log('[Scraper] No next page — collection finished');
break;
}
}
console.log(`[Scraper] Collection complete! Total reviews: ${store.children.length}`);
console.log(`[Scraper] Now open the Listly extension and enter #${CONTAINER_ID} in the CSS selector.`);
updateLabel();
// ▶ To pull the results as JSON later, run in the console:
// Array.from(document.querySelectorAll('#__review_store .scraped-review')).map(el => ({
// rating: el.querySelector('.review-rating')?.textContent,
// title: el.querySelector('.review-title')?.textContent,
// author: el.querySelector('.review-author')?.textContent,
// verified: el.querySelector('.review-verified')?.textContent,
// date: el.querySelector('.review-date')?.textContent,
// body: el.querySelector('.review-body')?.textContent,
// }))
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment