공간정보아카데미

[LX-lastpjt] Playwright 으로 네이버지도 리뷰 크롤링/스크래

minjava 2025. 10. 27. 16:20

⚠️ 본 코드는 학습 및 개인 연구 목적으로 작성되었으며, 네이버 지도 서비스의 구조가 변경될 경우 작동하지 않을 수 있습니다. 상업적 목적이나 대량 데이터 수집에는 사용하지 마세요. ⚠️ 

 

크롤링? 스크래핑?

웹사이트를 자동으로 탐색하며 정보를 수집하는 것

데이터를 추출해 검색 엔진의 색인 생성, 데이터 분석, 시장 조사 등 다양한 용도로 활용됨.

웹 크롤링 웹 스크래핑
여러 웹 페이지를 자동으로 탐색하며 데이터 수집
검색 엔진이 웹사이트를 인덱싱할 때 사용
크롤링 봇(크롤러)이 사이트의 링크를 따라가며 데이터 수집
특정 웹페이지에서 원하는 데이터만 추출
가격 비교 사이트, 뉴스 기사 수집에 활용
일반적으로 특정 HTML 요소에서 텍스트, 이미지 등 필요한 데이터만 수집

도구

Python 라이브러리 GUI 기반 크롤링, 스프래핑 도구
BeautifulSoup : HTML 파싱 및 데이터 추출
Scrapy : 대규모 크롤링 프레임워크
Selenium : 동적 웹사이트 크롤링 (->Playwright)
Requests : 웹페이지 요청 보낼 때
1. Screaming Frog: SEO 분석 및 사이트 크롤러
2. Octoparce: 코드 없이 데이터 추출
3. ParseHub: 비개발자를 위한 크롤러

프로젝트 목표

- 사용자 경험 기반 데이터 분석 : 혼자 즐길  수 있는 장소 ( 혼밥, 혼카페, 혼술)를 네이버지도의 실제 후기를 통해 추출해낸다.

- 동적 웹 스크래핑 + AI 리뷰 분석 + 키워드 분류

 

전체 설계

데이터 수집 네이버지도 리뷰 크롤링 Playwright 가게명, 주소, 후기 텍스트
전처리  불필요한 텍스트 제거 Python, pandas 정제된 리뷰 문장
AI 분석 혼밥, 혼카페, 혼술 등 문맥분류 OpenAI GPT, KoBERT 각 리뷰별 라벨
결과 저장 분석 결과 엑셀파일로 저장 pandas (to_exel) 엑셀 데이터셋

 

1. 시작할 URL 설정 : 크롤링할 웹사이트 주소 정하기

2. 웹페이지 요청 : HTTP 요청을 보내  해당 페이지의 HTML 가져옴

3. 데이터 파싱 : HTML을 분석해 원하는 정보 추출

4. 버튼 클릭과 프레임 전환으로 페이지 전환 제어 (동적 탐색)

5. 반복 실행

키워드

WHITE = ["혼밥", "혼술", "혼카페", "혼자", "1인", "1인석", "카운터석", "편하게", "눈치 안보", "칸막이", "프라이버시", "자리간격",                   "바테이블", "바석", "혼자 있어도", "혼자 하기 좋", "적당히 조용", "혼자와도", "부담없음", "혼자오기", "혼자먹기",
                 "혼자가기"]
BLACK = ["시끄럽", "자리없", "불친절", "별로", "단체"]

 

네이버 지도 구조 및 통신 파악

 GraphQL로 통신

식당 리스트 - 무한 스크롤 + 페이지네이션

 

1. 구조 탐색 (Elements 탭)

리뷰 내용은 초기 HTML에는 없고,
JS 실행 후 비동기로 로드되어 DOM에 추가됨 (즉, 브라우저 렌더링 후 확인 가능)

페이지 소스 (Ctrl+U) 서버에서 받은 초기 HTML 없음
Elements 탭 (F12) JS가 실행된 후의 DOM 있음

 

2. 데이터 요청 흐름 보기 (Network 탭)

filter - xhr, fetch (JS가 백엔드로부터 데이터를 받아오는 요청만 받음)

grahql 형태로 되어있음.

즉, 브라우저가 HTMl로 렌더링하기 전에 AJAX로 받아온다.

 

3. 동적 프레임 구조 확인 (Sources 탭)

네이버 지도는 iframe 구조 (main page -> searchIframe -> entryIframe)

각각의 iframe은 별도의 HTML 문서라 개발자도구 상단의 드롭다운으로 전환해 해당 프레임 내부 구조를 다시 탐색

리뷰는 거의 항상 entryIframe 내부에서 비동기로 로드

* iframe 이란? : 웹페이지 안에 또 다른 웹페이지를 삽입하는 HTML 요소. 브라우저 상에서는 한 페이지처럼 보이지만, 실제로는 여러 HTML 문서가 겹쳐있음.

 

4. API 형태 확인하기  (참고용)

Network 탭에서 리뷰 로드 요청을 찾으면 

Headers : https://pcmap-api.place.naver.com/graphql

Response : JSON 형태

필드명 파악하면 나중에 Python으로 데이터 추출할 때 정확히 매칭

data.visitorReviews.items[i].author.nickname : 사용자 이름

data.visitorReviews.items[i].body : 후기 본문

data.visitorReviews.items[i].businessName : 가게명

data.visitorReviews.items[i].votedKeywords[].name : 후기 키워드

=> 네이버지도는 GraphQL 기반 비동기 요청으로 데이터를 로드하지만, 이번 프로젝트에선 직접 API 요청을 보내지 않고, Playwright로 실제 사용자 동작 (클릭, 스크롤)을 시뮬레이션하여 리뷰데이터를 추출


검색했을 때 리스트
iframe
id = searchIframe
title = Naver Place search

해당 가게
li class = UEzoS rTjJo

가게 눌렀을 때
iframe
id = entryIframe
title = Naver Place Entry

리뷰 버튼
class = veBoZ

리뷰 더보기
data-pui-click-code="rvshowmore"

class="place_section_content" -> 전체 리뷰 영역
class="place_apply_pui EjjAW" -> 리뷰 하나 영역
class="pui__vn15t2" -> 리뷰내용
class="pui__wFzIYl" -> 버튼

검색했을 때 리스트
스크롤 끝까지 + 페이지네이션

가게 눌렀을 때 리뷰 목록
스크롤 끝까지 + 리뷰 더보기 클릭


data.visitorReviews.items[i].author.nickname : 사용자 이름
data.visitorReviews.items[i].body : 후기 본문
data.visitorReviews.items[i].businessName : 가게명
data.visitorReviews.items[i].votedKeywords[].name : 후기 키워드

화이트리스트 : 혼밥, 혼술, 혼카페, 혼자, 혼커피, 혼자, 1인, 1인석,
카운터석, 칸막이, 프라이버시, 자리간격, 바테이블,
바석, 혼자와도, 부담없음, 혼자오기, 혼자먹기, 혼자가기
블랙리스트 : 시끄럽, 자리없, 불친절, 별로, 단체
리뷰 없는 경우는 X

id="entryIframe"

class="place_section no_margin OP4V8"
class="zD5Nm undefined"
id="_title" class="LylZZ v8v5j"
class="GHAhO"

data-nclicks-area-code="rrv"
class="NSTUp"
class="lfH3O"
class="fvwqf"
class="TeItc"


 

+ 추가로 :: 리스트에서 가게를 클릭하면 그 가게에 대한 후기만 나온다고. 근데 요청시 받는 데이터에 그 클릭한 가게이름도 있으니까 차라리 리스트가 아니라 가게 클릭시 받는 데이터만 추적하는게 나을 것 같음.

+ 근데 이건 어케 해야하지 : 근데 그럼 리스트는 페이지네이션으로 되어있는데, 첫번째 페이지에 있는 가게들을 클릭해서 각각 해당하는 리뷰만 가져올 순 없고, 다른페이지에 있는 리스트들도 클릭했을 때 해당하는 후기를 보게 해야되잖아.

==> ==> 밑에 방법으로 해결하래요 <==<==

  • 리스트 페이지는 페이지네이션 구조이므로, Playwright로 '다음 페이지' 버튼을 클릭하며 탐색
  • 각 페이지의 가게를 하나씩 클릭하여 해당 entryIframe 진입
  • 이때 브라우저 내부에서 자동으로 GraphQL 요청이 발생하고,
  • 응답 데이터가 DOM에 렌더링되므로, HTML 요소에서 리뷰 텍스트를 추출

 

< 작업 과정 >

환경 설정 + 준비 과정

1. 파이썬 설치 3.12.8

2. 작업용 폴더 생성 C:\naver_review

3. 가상환경 만들기 python -m venv venv

4. 가상환경 활성화 venv\Scripts\activate

5. 패키지 설치 pip install playwright pandas openpyxl

- playwright: 브라우저 자동 제어 (네이버 지도 조작용)

- pandas: 표 데이터 다루기

- openpyxl: 엑셀 파일 저장용

6. 브라우저 바이너리 설치 playwright install - 실제 사용할 브라우저(Chromium, Firefox, WebKit)의 드라이버를 자동으로 설치하여 Playwright가 제어할 수 있게 함.

7. 설치 확인 python --version / pip list (playwright, pandas, openyxl 이 보이면 OK)

 

실제 크롤링 코드 작성 및 데이터 수집 단계

8.VSCode에서 프로젝트 열기 - Python 3.12.7(venv)가 떠있는지 확인

8-1. 안뜨면, Ctrl+Shift+P -> Python: Select Interpreter -> C:\naver_review\venv\Scripts\python.exe 선택

9. 테스트 코드 작성 후 실행 -> python review_crawler.py

 

브라우저 열기 테스트용 코드

from playwright.sync_api import sync_playwright

# 브라우저 실행
with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)  # headless=False → 실제 브라우저 화면 띄움
    page = browser.new_page()

    # 네이버 지도 접속
    page.goto("https://map.naver.com/p")

    # 10초간 대기 (페이지 확인용)
    page.wait_for_timeout(10000)

    browser.close()

 

from playwright.sync_api import sync_playwright
import time
import pandas as pd
import json, re


def contains_any(text, keywords):
    return any(k in text for k in keywords)


WHITE = ["혼밥", "혼술", "혼카페", "혼자", "1인", "1인석", "카운터석", "편하게", "눈치 안보",
         "칸막이", "프라이버시", "자리간격", "바테이블", "바석", "혼자 있어도", "혼자 하기 좋", "적당히 조용",
         "혼자와도", "부담없음", "혼자오기", "혼자먹기", "혼자가기"]
BLACK = ["시끄럽", "자리없", "불친절", "별로", "단체"]


with sync_playwright() as p:
    browser = p.chromium.launch(headless=False, slow_mo=60)
    page = browser.new_page()
    page.goto("https://map.naver.com/p")
    page.set_viewport_size({"width": 1280, "height": 900})
    print("⏳ 네이버 지도 로딩 중...")
    page.wait_for_timeout(6000)

    query = "구로5동 술집"
    print(f"🔍 '{query}' 검색 중...")

    # [1] 검색창 입력
    search_box = page.locator("div.input_box input, div[role='textbox']")
    search_box.first.click()
    search_box.first.fill(query)
    page.keyboard.press("Enter")
    page.wait_for_timeout(4000)

    # [2] searchIframe 접근
    search_frame = None
    for _ in range(40):
        for f in page.frames:
            if f.name == "searchIframe":
                search_frame = f
                break
        if search_frame:
            break
        time.sleep(0.5)

    if not search_frame:
        raise Exception("❌ searchIframe 감지 실패")

    print("✅ searchIframe 접근 성공!")

    # [3] 리스트 로딩 및 스크롤
    list_sel = "li.UEzoS.rTjJo"
    search_frame.wait_for_selector(list_sel, timeout=20000)

    print("📜 리스트 전체 스크롤 탐색 중...")

    seen_texts = set()
    scroll_stable_count = 0
    last_scroll_height = 0

    while True:
        try:
            elements = search_frame.locator(list_sel)
            count_now = elements.count()

            for idx in range(count_now):
                try:
                    txt = elements.nth(idx).inner_text().strip()
                    if txt not in seen_texts:
                        seen_texts.add(txt)
                except:
                    pass

            search_frame.evaluate("""
                () => {
                    const scrollable =
                        document.querySelector('#_pcmap_list_scroll_container') ||
                        document.querySelector('#pcmap_list_scroll_container') ||
                        document.querySelector('[class*="scroll"]') ||
                        document.scrollingElement;
                    if (scrollable) scrollable.scrollBy(0, scrollable.scrollHeight);
                }
            """)
            time.sleep(1.5)

            current_scroll_height = search_frame.evaluate(
                "() => document.body.scrollHeight"
            )

            if current_scroll_height == last_scroll_height:
                scroll_stable_count += 1
            else:
                scroll_stable_count = 0

            if scroll_stable_count >= 3:
                print("✅ 스크롤 끝 감지됨 (더 이상 새로운 가게 없음)")
                # ✅ 브라우저에서 스크롤 완료 상태 확인용 대기
                print("⏸️ 3초간 대기 중... (스크롤 완료 화면 확인)")
                time.sleep(3)
                break

            last_scroll_height = current_scroll_height

        except Exception as e:
            print(f"⚠️ 스크롤 중 오류 발생: {e}")
            break

    print(f"✅ 총 {len(seen_texts)}개 가게 탐지 완료 (가상 스크롤 포함)")

    items = search_frame.locator(list_sel)
    total_places = items.count()
    print(f"📋 실제 감지된 항목 수: {total_places}")

    if total_places == 0:
        print("❌ 검색 결과 없음. 종료.")
        browser.close()
        exit()

    results = []
    seen_reviews = set()
    last_saved_count = 0

    # [4] 각 가게 반복
    for i in range(total_places):
        try:
            print(f"\n🏠 [{i+1}/{total_places}] 가게 클릭 중...")

            # ✅ [수정1] searchIframe 재탐색 안정화
            time.sleep(1.0)  # iframe 로드 안정화용 대기
            for _ in range(15):  # 최대 7.5초 동안 시도
                search_frame = None
                for f in page.frames:
                    if f.name == "searchIframe":
                        search_frame = f
                        break
                if search_frame:
                    break
                print(f"⏳ searchIframe 재로딩 대기 중... ({i+1}번째 가게)")
                time.sleep(0.5)

            if not search_frame:
                print(f"❌ searchIframe 감지 실패 — {i+1}번째 가게 스킵")
                continue

            # ✅ 프레임 로드 후 기본 요소 확인
            search_frame = page.frame(name="searchIframe")
            search_frame.wait_for_selector("#_pcmap_list_scroll_container, #pcmap_list_scroll_container", timeout=30000)
            search_frame.wait_for_selector(list_sel, state="attached", timeout=30000)

            if not page.frame(name="searchIframe"):
                page.wait_for_function("document.querySelector('iframe[name=searchIframe]') !== null", timeout=10000)

            search_frame = page.frame(name="searchIframe")
            items = search_frame.locator(list_sel)
            search_frame.wait_for_selector(list_sel, timeout=10000)

            search_frame.wait_for_function(
                f"() => document.querySelectorAll('{list_sel}').length > {i}",
                timeout=15000
            )

            # i번째 항목이 렌더링될 때까지 스크롤 반복
            max_scroll_attempts = 8
            scroll_attempt = 0
            while True:
                try:
                    count_visible = search_frame.evaluate(f"() => document.querySelectorAll('{list_sel}').length")
                    if count_visible > i:
                        break  # i번째 항목이 DOM에 렌더링됨
                    search_frame.evaluate("""
                        () => {
                            const sc =
                                document.querySelector('#_pcmap_list_scroll_container') ||
                                document.querySelector('#pcmap_list_scroll_container') ||
                                document.querySelector('[class*="scroll"]') ||
                                document.scrollingElement;
                            if (sc) sc.scrollBy(0, 500);
                        }
                    """)
                    time.sleep(0.8)
                    scroll_attempt += 1
                    if scroll_attempt > max_scroll_attempts:
                        print(f"⚠️ {i+1}번째 항목이 끝내 렌더링되지 않음 — 건너뜀.")
                        raise Exception("가상 스크롤 초과")
                except Exception as e:
                    print(f"⚠️ 스크롤 시도 중 오류: {e}")
                    break

            # 스크롤 컨테이너 직접 스크롤
            search_frame.evaluate(
                """({ idx, sel }) => {
                    const sc =
                    document.querySelector('#_pcmap_list_scroll_container') ||
                    document.querySelector('#pcmap_list_scroll_container');
                    const els = Array.from(document.querySelectorAll(sel));
                    const el = els[idx];
                    if (sc && el) sc.scrollTo({ top: el.offsetTop - 100 });
                }""",
                {"idx": i, "sel": list_sel}
            )
            time.sleep(0.5)

            # ✅ 클릭 전 lazyload-wrapper가 아닌 UEzoS.rTjJo로 완전히 로드될 때까지 대기
            for _ in range(10):
                class_name = search_frame.evaluate(
                    f"""(idx) => {{
                        const el = document.querySelectorAll('{list_sel}')[idx];
                        return el ? el.className : '';
                    }}""",
                    i
                )
                if class_name and "lazyload-wrapper" not in class_name:
                    break
                print(f"⏳ {i+1}번째 항목 로딩 중... (현재 상태: {class_name})")
                time.sleep(0.5)
            else:
                print(f"⚠️ {i+1}번째 항목이 끝내 로드되지 않음 (lazyload-wrapper 상태 유지)")
                continue  # 다음 가게로 넘어감

            # 실제 클릭 시도
            # ✅ li 안의 a.place_bluelink 클릭 (가게명 텍스트 기준)
            place_link_sel = f"{list_sel} a.place_bluelink"

            try:
                place_links = search_frame.locator(place_link_sel)
                # i번째 가게의 링크 클릭
                place_links.nth(i).scroll_into_view_if_needed(timeout=5000)
                name = place_links.nth(i).inner_text().strip()
                print(f"🏠 클릭 대상: {name}")
                place_links.nth(i).click(timeout=7000)
                print(f"🖱️ {i+1}번째 가게명 클릭 완료! entryIframe 생성 대기 중...")
                time.sleep(3)
            except Exception as e:
                print(f"⚠️ {i+1}번째 가게명 클릭 실패 → 재시도 중...", e)
                time.sleep(1)
                try:
                    place_links.nth(i).scroll_into_view_if_needed(timeout=5000)
                    place_links.nth(i).click(timeout=10000)
                    print(f"🖱️ {i+1}번째 가게명 재클릭 성공!")
                    time.sleep(3)
                except Exception as e:
                    print(f"❌ {i+1}번째 가게 클릭 완전 실패:", e)
                    continue

            # ✅ entryIframe 대기 (최대 40초로 확장)
            try:
                page.wait_for_function(
                    "() => document.querySelector('iframe#entryIframe') !== null",
                    timeout=40000
                )
                print("✅ entryIframe 생성 감지 완료!")
            except Exception as e:
                print(f"⚠️ entryIframe 생성 지연: {e} → 1회 새로고침 후 재시도")
                page.reload()
                time.sleep(5)
                try:
                    page.wait_for_function(
                        "() => document.querySelector('iframe#entryIframe') !== null",
                        timeout=30000
                    )
                    print("✅ entryIframe 생성 성공 (재시도 후)!")
                except:
                    print(f"❌ entryIframe 로드 실패 — {i+1}번째 가게 스킵")
                    page.go_back()
                    print("🔄 목록 페이지로 복귀 중... searchIframe 로딩 대기")
                    for _ in range(20):  # 최대 10초 동안 시도
                        search_frame = None
                        for f in page.frames:
                            if f.name == "searchIframe":
                                search_frame = f
                                break
                        if search_frame:
                            print("✅ searchIframe 복구 감지됨!")
                            break
                        time.sleep(0.5)
                    else:
                        print("⚠️ searchIframe 복구 실패 — 스킵 또는 reload 시도")
                        try:
                            page.reload()
                            time.sleep(5)
                        except:
                            pass
                    time.sleep(1)
                    continue

            page.wait_for_function(
                "() => document.querySelector('iframe#entryIframe') !== null",
                timeout=15000
            )
            time.sleep(1.5)

            entry_frame = None
            for attempt in range(3):
                try:
                    page.wait_for_function(
                        "() => document.querySelector('iframe#entryIframe') !== null", timeout=20000
                    )
                    entry_frame = page.frame(name="entryIframe")
                    if entry_frame:
                        break
                except Exception as e:
                    print(f"⚠️ entryIframe 탐색 중 예외 (시도 {attempt+1}/3):", e)
                    time.sleep(3)

            if not entry_frame:
                print("❌ entryIframe 로드 실패 — 다음 가게로 넘어감.")
                page.go_back()
                print("🔄 목록 페이지로 복귀 중... searchIframe 로딩 대기")
                for _ in range(20):  # 최대 10초 동안 시도
                    search_frame = None
                    for f in page.frames:
                        if f.name == "searchIframe":
                            search_frame = f
                            break
                    if search_frame:
                        print("✅ searchIframe 복구 감지됨!")
                        break
                    time.sleep(0.5)
                else:
                    print("⚠️ searchIframe 복구 실패 — 스킵 또는 reload 시도")
                    try:
                        page.reload()
                        time.sleep(5)
                    except:
                        pass
                time.sleep(1)
                continue

            try:
                entry_frame.wait_for_selector('span.PXMot', timeout=10000)
                review_btns = entry_frame.locator('span.PXMot')
                target = None
                for btn in review_btns.all():
                    try:
                        if "방문자 리뷰" in btn.inner_text().strip():
                            target = btn
                            break
                    except:
                        continue
                if not target:
                    raise Exception("방문자 리뷰 버튼 없음")

                target.click()
                print("✅ 방문자 리뷰 버튼 클릭 완료!")
                time.sleep(4)
            except Exception as e:
                print("⚠️ 방문자 리뷰 클릭 실패:", e)
                page.go_back()
                print("🔄 목록 페이지로 복귀 중... searchIframe 로딩 대기")
                for _ in range(20):  # 최대 10초 동안 시도
                    search_frame = None
                    for f in page.frames:
                        if f.name == "searchIframe":
                            search_frame = f
                            break
                    if search_frame:
                        print("✅ searchIframe 복구 감지됨!")
                        break
                    time.sleep(0.5)
                else:
                    print("⚠️ searchIframe 복구 실패 — 스킵 또는 reload 시도")
                    try:
                        page.reload()
                        time.sleep(5)
                    except:
                        pass
                time.sleep(1)
                continue

            store_name = ""
            try:
                entry_frame.wait_for_selector("div#_title span.GHAhO", timeout=5000)
                store_name = entry_frame.locator("div#_title span.GHAhO").inner_text().strip()
            except:
                store_name = "가게명_확인불가"

            if store_name == "가게명_확인불가":
                page.go_back()
                print("🔄 목록 페이지로 복귀 중... searchIframe 로딩 대기")
                for _ in range(20):  # 최대 10초 동안 시도
                    search_frame = None
                    for f in page.frames:
                        if f.name == "searchIframe":
                            search_frame = f
                            break
                    if search_frame:
                        print("✅ searchIframe 복구 감지됨!")
                        break
                    time.sleep(0.5)
                else:
                    print("⚠️ searchIframe 복구 실패 — 스킵 또는 reload 시도")
                    try:
                        page.reload()
                        time.sleep(5)
                    except:
                        pass
                time.sleep(1)
                continue

            entry_frame.wait_for_selector("div.place_section.k1QQ5 > div.place_section_content", timeout=8000)
            review_area = entry_frame.locator("div.place_section.k1QQ5 > div.place_section_content").first

            print("📜 리뷰탭 스크롤 및 리뷰 수집 시작...")

            for page_round in range(7):
                print(f"--- 리뷰 페이지 {page_round+1} ---")

                last_scroll = 0
                same_scroll_count = 0
                while True:
                    try:
                        entry_frame.evaluate("""
                            () => {
                                const scrollable = document.querySelector('div.place_section.k1QQ5 > div.place_section_content');
                                if (scrollable) scrollable.scrollBy(0, scrollable.scrollHeight);
                            }
                        """)
                        time.sleep(1.0)

                        curr_scroll = entry_frame.evaluate("""
                            () => {
                                const el = document.querySelector('div.place_section.k1QQ5 > div.place_section_content');
                                return el ? el.scrollTop : 0;
                            }
                        """)
                        if curr_scroll == last_scroll:
                            same_scroll_count += 1
                        else:
                            same_scroll_count = 0
                        last_scroll = curr_scroll

                        more_btns = entry_frame.locator("a.pui__wFzIYl[data-pui-click-code='rvshowmore']")
                        for btn in more_btns.all():
                            try:
                                btn.scroll_into_view_if_needed()
                                btn.click()
                                time.sleep(0.2)
                            except:
                                pass

                        if same_scroll_count >= 2:
                            break
                    except:
                        break

                review_blocks = entry_frame.locator("li.place_apply_pui.EjjAW").all()
                if not review_blocks:
                    print("⚠️ 리뷰 없음, 종료")
                    break

                count_before = len(results)

                for block in review_blocks:
                    try:
                        text = block.locator("div.pui__vn15t2").inner_text().strip()
                        if text not in seen_reviews:
                            if contains_any(text, WHITE) and not contains_any(text, BLACK):
                                seen_reviews.add(text)
                                results.append({
                                    "가게명": store_name,
                                    "리뷰내용": text
                                })
                    except:
                        continue

                if len(results) - last_saved_count >= 20:
                    try:
                        pd.DataFrame(results).to_excel("naver_reviews_autosave.xlsx", index=False)
                        last_saved_count = len(results)
                        print(f"💾 자동 저장 완료 ({len(results)}개 누적)")
                    except Exception as e:
                        print("⚠️ 자동저장 실패:", e)

                count_added = len(results) - count_before
                print(f"💬 이번 페이지에서 리뷰 {count_added}개 수집 완료 (누적 {len(results)})")

                load_more_btn = entry_frame.locator("a.fvwqf:has(span.TeItc:has-text('더보기'))")
                if load_more_btn.count() > 0:
                    try:
                        load_more_btn.first.scroll_into_view_if_needed()
                        load_more_btn.first.click()
                        print(f"➡️ 리뷰 더보기 클릭 ({page_round+1}/7)")
                        # ✅ 리뷰 더보기 클릭 후 명시적으로 3초 대기 (리뷰 로드 확인용)
                        print("⏸️ 리뷰 더보기 클릭 후 3초 대기 중...")
                        time.sleep(3)
                    except:
                        print("⚠️ 리뷰 더보기 클릭 실패")
                        break

            page.go_back()
            print("🔄 목록 페이지로 복귀 중... searchIframe 로딩 대기")
            for _ in range(20):  # 최대 10초 동안 시도
                search_frame = None
                for f in page.frames:
                    if f.name == "searchIframe":
                        search_frame = f
                        break
                if search_frame:
                    print("✅ searchIframe 복구 감지됨!")
                    break
                time.sleep(0.5)
            else:
                print("⚠️ searchIframe 복구 실패 — 스킵 또는 reload 시도")
                try:
                    page.reload()
                    time.sleep(5)
                except:
                    pass
            time.sleep(1)

        except Exception as e:
            print(f"⚠️ 오류 발생 ({i+1}번째):", e)
            continue

    try:
        if results:
            df = pd.DataFrame(results)
            df.to_excel("naver_reviews_filtered.xlsx", index=False)
            print(f"\n✅ 완료! 총 {len(df)}개의 리뷰를 naver_reviews_filtered.xlsx에 저장했습니다.")
        else:
            print("❌ 조건에 맞는 리뷰 없음")
    except Exception as e:
        print("⚠️ 최종 저장 중 오류:", e)

    browser.close()