-
-
Save listly-io/d1a5fcffba7bb6a8bbca623b83f86706 to your computer and use it in GitHub Desktop.
sephora_review_en_2026-06-01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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