Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save listly-io/d1a5fcffba7bb6a8bbca623b83f86706 to your computer and use it in GitHub Desktop.
sephora_review_en_2026-06-01
// test page: https://www.sephora.com/product/ultra-shine-lip-color-P429018?skuId=2857886&icid2=products%20grid:p429018:product
// ============================================================
// ▶ User configuration (edit as needed)
// ============================================================
const MAX_PAGE_COUNT = 10; // Number of pages to collect (1 = current page only)
const WAIT_AFTER_LOAD = 5000; // Wait time after a new page loads (ms)
const WAIT_BEFORE_NAV = 1000; // Wait time after scrolling, before navigating (ms)
const REVIEW_LIST_SELECTOR = '[data-comp~="Review"]'; // Individual review item selector
const PAGINATION_SELECTOR = '[data-comp~="Pagination"]'; // Pagination area selector
const NEXT_BTN_SELECTOR = 'button[aria-label="Next page"]'; // "Next page" button selector
const CONTAINER_ID = '__review_collector'; // Collector container ID
// ============================================================
// ▶ Internal logic (no edits needed)
// ============================================================
(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 1. Create the collector container (reuse if it already exists)
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) — a display:none wrapper inside the container
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 seen = new Set();
Array.from(store.children).forEach((el) => seen.add(el.textContent.trim()));
const updateLabel = () => {
const count = store.children.length;
label.textContent = `Reviews collected: ${count}`;
};
updateLabel();
// 2. Collect the reviews on the current page (skipping duplicates)
const collectCurrentPage = () => {
const reviews = document.querySelectorAll(
`${REVIEW_LIST_SELECTOR}:not(#${CONTAINER_ID} *)`
);
let added = 0;
reviews.forEach((el) => {
const key = el.textContent.trim();
if (!key || seen.has(key)) return; // skip empties and duplicates
seen.add(key);
store.appendChild(el.cloneNode(true));
added++;
});
console.log(`[Scraper] Collected ${added} new reviews (total: ${store.children.length})`);
updateLabel();
};
// 3. Go to the next page
// Returns: true = navigation triggered, false = no next page
// NOTE: Sephora paginates reviews via an in-page AJAX button (the
// content swaps without a full page reload), so there is no reload
// to survive and no need to persist anything to sessionStorage.
const goNextPage = async () => {
// Scroll the pagination area into view
const paginationArea = document.querySelector(PAGINATION_SELECTOR);
if (paginationArea) {
paginationArea.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
await sleep(WAIT_BEFORE_NAV);
// Snapshot the first review so we can detect when the AJAX swap is done
const firstBefore = document.querySelector(REVIEW_LIST_SELECTOR);
const beforeKey = firstBefore ? firstBefore.textContent.trim() : '';
// Click the page's own "Next" button if present and enabled
const btn = document.querySelector(NEXT_BTN_SELECTOR);
const isDisabled = !btn || btn.disabled ||
btn.getAttribute('aria-disabled') === 'true' ||
btn.closest('[aria-disabled="true"]');
if (btn && !isDisabled) {
btn.click();
// Wait for the in-page AJAX swap: poll until the first review changes
const deadline = Date.now() + WAIT_AFTER_LOAD + 5000;
while (Date.now() < deadline) {
await sleep(300);
const firstNow = document.querySelector(REVIEW_LIST_SELECTOR);
if (firstNow && firstNow.textContent.trim() !== beforeKey) break;
}
return true;
}
// No enabled "Next" button → we're on the last page
return false;
};
// 3.5. Ensure the reviews widget is loaded (handle lazy-loading)
const ensureReviewsLoaded = async () => {
const anchor = document.querySelector('#ratings-reviews-container');
if (anchor) {
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
const deadline = Date.now() + 15000; // up to 15s
while (Date.now() < deadline) {
if (document.querySelector(REVIEW_LIST_SELECTOR)) return true;
window.scrollBy(0, 400); // nudge the lazy-loader
await sleep(500);
}
console.warn('[Scraper] Reviews did not load in time');
return false;
};
// 4. Main loop
await ensureReviewsLoaded();
console.log(`[Scraper] Starting collection — up to ${MAX_PAGE_COUNT} pages`);
for (let page = 1; page <= MAX_PAGE_COUNT; page++) {
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;
}
await sleep(WAIT_AFTER_LOAD);
}
console.log(`[Scraper] Collection complete! Total reviews: ${store.children.length}`);
updateLabel();
// ▶ To retrieve the collected HTML later, run in the console:
// document.getElementById('__review_store').innerHTML
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment