Skip to content

Instantly share code, notes, and snippets.

@listly-io
Last active April 21, 2026 03:03
Show Gist options
  • Select an option

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

Select an option

Save listly-io/c4ae6706b3bb6d436c882d46d2b8776b to your computer and use it in GitHub Desktop.
naver_smartstore_review_2026-04-21
// ==================== 리뷰 수집기 (모달 + 무한스크롤 모드) ====================
//
// [사용법]
// 1. 상품 상세 페이지에서 이 스크립트를 콘솔에 붙여넣기
// → "리뷰 전체보기" 버튼을 자동 클릭 → 모달 오픈 → 무한 스크롤로 수집
// 2. 수집 완료 후 화면 우측 하단 패널에서 원하는 영역의 "데이터 불러오기" 클릭
// 3. 패널 위에 나타난 파란 박스를 Listly Parts로 선택하여 추출
// 4. 추출 완료 후 "내리기" 클릭 → 다음 영역 반복
//
// ==================== 설정 변수 ====================
// 최대 스크롤 시도 횟수
const MAX_SCROLL_COUNT = 500;
// 모달 오픈 후 대기 시간 (밀리초)
const MODAL_OPEN_WAIT_TIME = 1500;
// 스크롤 후 대기 시간 (밀리초)
const SCROLL_WAIT_TIME = 1000;
// 새 리뷰가 안 나오는 스크롤이 연속 N회면 수집 종료
const MAX_EMPTY_SCROLLS = 4;
// 수집 영역 1개당 최대 리뷰 수 (이 수를 초과하면 새 영역 생성)
const MAX_REVIEWS_PER_COLLECTOR = 2000;
// CSS Selectors
const REVIEW_ITEM_SELECTOR = '[id^="REVIEW_ITEM_"]';
const MODAL_ROOT_SELECTOR = '#MODAL_ROOT_ID';
const OPEN_MODAL_BUTTON_SELECTOR = '#REVIEW button[data-shp-area="sprvrpre.more"][data-shp-area-id="more"]';
// ==================== 전역 변수 ====================
let scrollCycleCount = 0;
let collectorIndex = 1;
let currentCollectorCount = 0;
const collectors = [];
let activeCollector = null;
// 무한스크롤에서는 이전 리뷰가 DOM에 계속 남아있으므로 ID 기반 dedup 필수
const collectedReviewIds = new Set();
let isCollecting = false;
let statusDiv = null;
// ==================== 유틸리티 함수 ====================
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ==================== 메모리 기반 수집 영역 관리 ====================
function createNewCollector(index) {
const collector = {
index: index,
htmlChunks: [],
count: 0
};
collectors.push(collector);
console.log(`✅ 수집 영역 #${index} 생성 (메모리 저장, 최대 ${MAX_REVIEWS_PER_COLLECTOR}개)`);
return collector;
}
// 상태 표시 UI 초기화
function initStatusUI() {
statusDiv = document.getElementById('review-collector-status');
if (!statusDiv) {
statusDiv = document.createElement('div');
statusDiv.id = 'review-collector-status';
statusDiv.style.cssText = `
position: fixed;
bottom: 80px;
right: 30px;
background: #1e1e1e;
color: #fff;
padding: 0;
border-radius: 12px;
font-size: 13px;
z-index: 2147483647;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
width: 320px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
`;
document.body.appendChild(statusDiv);
}
updateStatusUI();
}
function injectStyles() {
if (document.getElementById('review-collector-styles')) return;
const style = document.createElement('style');
style.id = 'review-collector-styles';
style.textContent = `
#review-collector-status * { box-sizing: border-box; }
.rc-header {
padding: 14px 16px;
background: #2a2a2a;
border-radius: 12px 12px 0 0;
border-bottom: 1px solid #3a3a3a;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
}
.rc-header-title { font-size: 14px; font-weight: 700; }
.rc-header-count { font-size: 13px; color: #4fc3f7; }
.rc-body {
padding: 8px;
overflow-y: auto;
max-height: 50vh;
flex: 1;
}
.rc-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
margin-bottom: 4px;
background: #2a2a2a;
transition: background 0.15s;
}
.rc-row:hover { background: #333; }
.rc-row-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.rc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.rc-badge-mem { background: #37474f; color: #90a4ae; }
.rc-badge-dom { background: #1b5e20; color: #66bb6a; }
.rc-count { font-size: 12px; color: #aaa; }
.rc-actions { display: flex; gap: 4px; flex-shrink: 0; }
.rc-btn {
padding: 4px 10px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.rc-btn:active { transform: scale(0.95); }
.rc-btn-mount { background: #1565c0; color: #fff; }
.rc-btn-mount:hover { background: #1976d2; }
.rc-btn-unmount { background: #e65100; color: #fff; }
.rc-btn-unmount:hover { background: #ef6c00; }
.rc-status-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
.rc-tag-mem { background: #37474f; color: #90a4ae; }
.rc-tag-dom { background: #1b5e20; color: #66bb6a; }
.rc-collecting-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #1a237e;
font-size: 12px;
color: #8c9eff;
}
.rc-spinner {
width: 14px;
height: 14px;
border: 2px solid #555;
border-top-color: #4fc3f7;
border-radius: 50%;
animation: rc-spin 0.8s linear infinite;
}
@keyframes rc-spin { to { transform: rotate(360deg); } }
.rc-minimize-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
.rc-minimize-btn:hover { color: #fff; }
/* 페이지 내에 표시되는 마운트된 수집 영역 */
.review-collector-area {
position: fixed;
right: 30px;
width: 320px;
height: 100px;
border: 3px dashed #1565c0;
border-radius: 12px;
background: #e3f2fd;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.review-collector-area:hover {
border-color: #0d47a1;
background: #bbdefb;
}
.review-collector-area .rc-mount-label {
font-size: 15px;
font-weight: 700;
color: #1565c0;
}
.review-collector-area .rc-mount-guide {
font-size: 12px;
color: #1976d2;
background: rgba(21,101,192,0.1);
padding: 4px 12px;
border-radius: 6px;
}
`;
document.head.appendChild(style);
}
function updateStatusUI() {
if (!statusDiv) return;
injectStyles();
const totalCount = collectors.reduce((sum, c) => sum + c.count, 0);
let html = '';
// 헤더
html += `<div class="rc-header">
<div>
<div class="rc-header-title">📦 리뷰 수집기</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="rc-header-count">${totalCount.toLocaleString()}개</span>
</div>
</div>`;
// 수집 중 표시
if (isCollecting) {
html += `<div class="rc-collecting-indicator">
<div class="rc-spinner"></div>
수집 중... (스크롤 ${scrollCycleCount}회)
</div>`;
}
// 영역 목록
html += `<div class="rc-body">`;
if (collectors.length === 0) {
html += `<div style="text-align:center;padding:20px;color:#666;">수집된 리뷰가 없습니다</div>`;
} else {
collectors.forEach(c => {
const isMounted = !!document.getElementById(`review-collector-${c.index}`);
let statusTag, badgeClass;
if (isMounted) {
statusTag = `<span class="rc-status-tag rc-tag-dom">불러옴</span>`;
badgeClass = 'rc-badge-dom';
} else {
statusTag = `<span class="rc-status-tag rc-tag-mem">대기</span>`;
badgeClass = 'rc-badge-mem';
}
html += `<div class="rc-row">
<div class="rc-row-info">
<span class="rc-badge ${badgeClass}">${c.index}</span>
<span class="rc-count">${c.count.toLocaleString()}개</span>
${statusTag}
</div>
<div class="rc-actions">`;
if (isMounted) {
html += `<button class="rc-btn rc-btn-unmount" onclick="unmountCollector(${c.index}); updateStatusUI();">내리기</button>`;
} else {
html += `<button class="rc-btn rc-btn-mount" onclick="mountCollector(${c.index}); updateStatusUI();">데이터 불러오기</button>`;
}
html += `</div></div>`;
});
}
html += `</div>`;
statusDiv.innerHTML = html;
}
// 수집 영역 초기화 (기존 DOM/레거시 마이그레이션 포함)
function initCollectors() {
// 기존 DOM 기반 수집 영역이 있으면 메모리로 마이그레이션
const existingAreas = document.querySelectorAll('.review-collector-area');
if (existingAreas.length > 0) {
console.log(`🔄 기존 DOM 수집 영역 ${existingAreas.length}개를 메모리로 마이그레이션합니다...`);
existingAreas.forEach((area, idx) => {
const areaIndex = idx + 1;
const reviews = area.querySelectorAll(REVIEW_ITEM_SELECTOR + ', li[data-shp-area-id="review"]');
const collector = {
index: areaIndex,
htmlChunks: [],
count: reviews.length
};
reviews.forEach(review => {
collector.htmlChunks.push(review.outerHTML);
// 마이그레이션 시에도 ID 추출하여 dedup 세트에 추가
const idMatch = review.id && review.id.match(/REVIEW_ITEM_\d+/);
if (idMatch) collectedReviewIds.add(idMatch[0]);
});
collectors.push(collector);
console.log(` 영역 #${areaIndex}: ${reviews.length}개 마이그레이션 완료`);
area.remove();
});
collectorIndex = collectors.length;
activeCollector = collectors[collectors.length - 1];
currentCollectorCount = activeCollector.count;
if (currentCollectorCount >= MAX_REVIEWS_PER_COLLECTOR) {
collectorIndex++;
currentCollectorCount = 0;
activeCollector = createNewCollector(collectorIndex);
}
return;
}
// 레거시 호환: 이전 버전의 단일 #review-collector
const legacyDiv = document.getElementById('review-collector');
if (legacyDiv) {
const reviews = legacyDiv.querySelectorAll(REVIEW_ITEM_SELECTOR + ', li[data-shp-area-id="review"]');
const collector = {
index: 1,
htmlChunks: [],
count: reviews.length
};
reviews.forEach(review => {
collector.htmlChunks.push(review.outerHTML);
const idMatch = review.id && review.id.match(/REVIEW_ITEM_\d+/);
if (idMatch) collectedReviewIds.add(idMatch[0]);
});
collectors.push(collector);
legacyDiv.remove();
console.log(`✅ 레거시 수집 영역 마이그레이션 → 영역 #1 (${reviews.length}개)`);
collectorIndex = 1;
activeCollector = collector;
currentCollectorCount = collector.count;
if (currentCollectorCount >= MAX_REVIEWS_PER_COLLECTOR) {
collectorIndex++;
currentCollectorCount = 0;
activeCollector = createNewCollector(collectorIndex);
}
return;
}
// 아무것도 없으면 새로 생성
currentCollectorCount = 0;
activeCollector = createNewCollector(collectorIndex);
}
function ensureCollectorCapacity() {
if (currentCollectorCount >= MAX_REVIEWS_PER_COLLECTOR) {
console.log(`📦 수집 영역 #${collectorIndex}가 ${currentCollectorCount}개로 가득 찼습니다.`);
collectorIndex++;
currentCollectorCount = 0;
activeCollector = createNewCollector(collectorIndex);
}
}
// ==================== 모달 제어 ====================
function getModalRoot() {
return document.querySelector(MODAL_ROOT_SELECTOR);
}
function isModalOpen() {
const root = getModalRoot();
if (!root) return false;
// 모달이 실제로 열려있는지 확인 (내용이 있는지)
return root.querySelector(REVIEW_ITEM_SELECTOR) !== null ||
root.children.length > 0;
}
async function openReviewModal() {
if (isModalOpen()) {
console.log('ℹ️ 리뷰 모달이 이미 열려있습니다.');
return true;
}
// "리뷰 전체보기" 버튼 찾기
let openBtn = document.querySelector(OPEN_MODAL_BUTTON_SELECTOR);
// 첫 번째 셀렉터가 실패하면 텍스트로 폴백
if (!openBtn) {
const candidates = document.querySelectorAll('#REVIEW button');
for (const btn of candidates) {
if ((btn.textContent || '').includes('리뷰 전체보기')) {
openBtn = btn;
break;
}
}
}
if (!openBtn) {
console.log('❌ "리뷰 전체보기" 버튼을 찾을 수 없습니다.');
return false;
}
// 버튼이 보이는 위치로 스크롤한 뒤 클릭
openBtn.scrollIntoView({ behavior: 'auto', block: 'center' });
await wait(300);
openBtn.click();
console.log('🖱️ "리뷰 전체보기" 버튼 클릭 → 모달 로딩 대기');
// 모달이 실제로 열릴 때까지 대기 (최대 10초)
const maxWait = 10000;
const pollInterval = 200;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
if (isModalOpen()) {
console.log('✅ 리뷰 모달 오픈 확인');
await wait(MODAL_OPEN_WAIT_TIME);
return true;
}
await wait(pollInterval);
}
console.log('⚠️ 모달 오픈 대기 시간 초과');
return false;
}
// 모달 내부에서 스크롤 가능한 컨테이너 찾기
function findScrollableContainer() {
const modalRoot = getModalRoot();
if (!modalRoot) return null;
let bestCandidate = null;
let maxScrollable = 0;
const walk = (el) => {
const style = window.getComputedStyle(el);
const overflowY = style.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll')) {
const scrollable = el.scrollHeight - el.clientHeight;
if (scrollable > 10 && scrollable > maxScrollable) {
maxScrollable = scrollable;
bestCandidate = el;
}
}
for (const child of el.children) walk(child);
};
walk(modalRoot);
// 폴백: 모달 자체가 스크롤 가능할 수도 있음
if (!bestCandidate) {
if (modalRoot.scrollHeight > modalRoot.clientHeight + 10) {
return modalRoot;
}
// 최후 폴백: window
return null;
}
return bestCandidate;
}
// 스크롤을 바닥으로 내리고 새 콘텐츠 로드 대기
async function scrollToBottomAndWait() {
const container = findScrollableContainer();
// 모달 내 전용 스크롤 컨테이너가 없으면 window 스크롤
if (!container) {
const prevScrollY = window.scrollY;
const prevDocHeight = document.documentElement.scrollHeight;
window.scrollTo(0, document.documentElement.scrollHeight);
await wait(SCROLL_WAIT_TIME);
return {
scrolled: window.scrollY > prevScrollY,
heightGrew: document.documentElement.scrollHeight > prevDocHeight
};
}
const prevScrollTop = container.scrollTop;
const prevScrollHeight = container.scrollHeight;
container.scrollTop = container.scrollHeight;
await wait(SCROLL_WAIT_TIME);
return {
scrolled: container.scrollTop > prevScrollTop,
heightGrew: container.scrollHeight > prevScrollHeight
};
}
// 모달 닫기 버튼 셀렉터
const CLOSE_MODAL_BUTTON_SELECTOR = 'button[data-shp-area="sprvarvs_l.close"][data-shp-area-id="close"]';
// 모달 닫기
function closeReviewModal() {
const btn = document.querySelector(CLOSE_MODAL_BUTTON_SELECTOR);
if (!btn) {
console.log('ℹ️ 모달 닫기 버튼을 찾을 수 없습니다.');
return false;
}
btn.click();
console.log('🚪 모달 닫기 버튼 클릭');
return true;
}
// ==================== 리뷰 수집 ====================
function collectReviews() {
const modalRoot = getModalRoot();
const searchRoot = modalRoot || document;
const reviews = searchRoot.querySelectorAll(REVIEW_ITEM_SELECTOR);
if (reviews.length === 0) {
console.log('⚠️ 현재 DOM에서 리뷰를 찾을 수 없습니다.');
return 0;
}
let addedCount = 0;
reviews.forEach(review => {
const id = review.id;
if (!id || collectedReviewIds.has(id)) return; // 이미 수집한 리뷰는 스킵
ensureCollectorCapacity();
activeCollector.htmlChunks.push(review.outerHTML);
activeCollector.count++;
currentCollectorCount++;
collectedReviewIds.add(id);
addedCount++;
});
updateStatusUI();
if (addedCount > 0) {
console.log(`✅ ${addedCount}개의 새 리뷰를 수집 (누적 ${collectedReviewIds.size}개)`);
}
return addedCount;
}
function getTotalCollectedCount() {
return collectors.reduce((sum, c) => sum + c.count, 0);
}
// ==================== 데이터 추출 함수 (콘솔용) ====================
function getCollectorHTML(index) {
const collector = collectors.find(c => c.index === index);
if (!collector) {
console.log(`⚠️ 수집 영역 #${index}를 찾을 수 없습니다.`);
return '';
}
return collector.htmlChunks.join('\n');
}
function getAllHTML() {
return collectors.map(c => c.htmlChunks.join('\n')).join('\n');
}
// ==================== 마운트/언마운트 ====================
function repositionMountedBoxes() {
const panelHeight = statusDiv ? statusDiv.offsetHeight : 0;
const panelBottom = 80;
const boxHeight = 100;
const gap = 10;
let currentBottom = panelBottom + panelHeight + gap;
const mountedBoxes = document.querySelectorAll('.review-collector-area');
mountedBoxes.forEach(box => {
box.style.bottom = currentBottom + 'px';
currentBottom += boxHeight + gap;
});
}
function mountCollector(index) {
const collector = collectors.find(c => c.index === index);
if (!collector) {
console.log(`⚠️ 수집 영역 #${index}를 찾을 수 없습니다.`);
return null;
}
// 마운트 박스가 모달에 가려지지 않도록 모달 먼저 닫기
closeReviewModal();
const existingId = `review-collector-${index}`;
const existing = document.getElementById(existingId);
if (existing) {
console.log(`ℹ️ 영역 #${index}는 이미 DOM에 있습니다.`);
return existing;
}
const div = document.createElement('div');
div.id = existingId;
div.className = 'review-collector-area';
const label = document.createElement('div');
label.className = 'rc-mount-label';
label.textContent = `📦 수집 영역 #${index} (${collector.count.toLocaleString()}개)`;
div.appendChild(label);
const guide = document.createElement('div');
guide.className = 'rc-mount-guide';
guide.textContent = 'Listly Parts 로 이 영역을 추출하세요';
div.appendChild(guide);
const hidden = document.createElement('div');
hidden.style.display = 'none';
hidden.innerHTML = collector.htmlChunks.join('\n');
div.appendChild(hidden);
document.body.appendChild(div);
repositionMountedBoxes();
console.log(`✅ 영역 #${index} 데이터 불러오기 완료 (${collector.count}개) → #${existingId}`);
return div;
}
function unmountCollector(index) {
const div = document.getElementById(`review-collector-${index}`);
if (div) {
div.remove();
repositionMountedBoxes();
console.log(`🗑️ 영역 #${index} DOM에서 내리기 완료 (메모리 데이터 유지)`);
return true;
}
console.log(`⚠️ 영역 #${index}가 DOM에 없습니다.`);
return false;
}
// ==================== 메인 실행 함수 ====================
async function startReviewCollection() {
console.log('🚀 리뷰 수집을 시작합니다... (모달 + 무한스크롤 모드)');
console.log(`📊 설정: 최대 스크롤 ${MAX_SCROLL_COUNT}회, 영역당 최대 ${MAX_REVIEWS_PER_COLLECTOR}개, 빈 스크롤 ${MAX_EMPTY_SCROLLS}회 시 종료`);
initCollectors();
isCollecting = true;
initStatusUI();
let totalReviews = getTotalCollectedCount();
if (totalReviews > 0) {
console.log(`📦 기존 수집 리뷰: ${totalReviews}개`);
}
try {
// 1단계: 모달 열기
const opened = await openReviewModal();
if (!opened) {
console.log('❌ 모달을 열 수 없어 수집을 중단합니다.');
isCollecting = false;
updateStatusUI();
return;
}
// 2단계: 최초 리뷰 수집 (모달 오픈 직후 보이는 리뷰)
collectReviews();
// 3단계: 무한 스크롤로 계속 수집
let emptyScrollStreak = 0;
let stagnantHeightStreak = 0;
while (scrollCycleCount < MAX_SCROLL_COUNT) {
scrollCycleCount++;
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`📜 스크롤 ${scrollCycleCount}/${MAX_SCROLL_COUNT} 회`);
const beforeCount = collectedReviewIds.size;
const { heightGrew } = await scrollToBottomAndWait();
const addedCount = collectReviews();
const afterCount = collectedReviewIds.size;
updateStatusUI();
// 종료 조건 체크
if (addedCount === 0) {
emptyScrollStreak++;
console.log(`ℹ️ 새 리뷰 없음 (${emptyScrollStreak}/${MAX_EMPTY_SCROLLS} 연속)`);
} else {
emptyScrollStreak = 0;
}
if (!heightGrew) {
stagnantHeightStreak++;
} else {
stagnantHeightStreak = 0;
}
// 빈 스크롤이 연속되거나, 스크롤 높이가 안 늘어나는 상태가 충분히 이어지면 종료
if (emptyScrollStreak >= MAX_EMPTY_SCROLLS) {
console.log(`✅ 연속 ${MAX_EMPTY_SCROLLS}회 새 리뷰가 없어 수집을 종료합니다.`);
break;
}
if (stagnantHeightStreak >= MAX_EMPTY_SCROLLS && afterCount === beforeCount) {
console.log(`✅ 더 이상 스크롤이 확장되지 않아 수집을 종료합니다.`);
break;
}
}
if (scrollCycleCount >= MAX_SCROLL_COUNT) {
console.log(`⚠️ 최대 스크롤 횟수(${MAX_SCROLL_COUNT})에 도달했습니다.`);
}
isCollecting = false;
updateStatusUI();
const finalTotal = getTotalCollectedCount();
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`✨ 수집 완료! 총 ${finalTotal}개 (고유 ID ${collectedReviewIds.size}개)`);
console.log(`🖱️ 화면 오른쪽 하단 패널에서 영역별 "데이터 불러오기"/"내리기"를 조작하세요.`);
} catch (error) {
console.error('❌ 에러 발생:', error);
isCollecting = false;
updateStatusUI();
}
}
// ==================== 실행 ====================
startReviewCollection();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment