--- title: 네이버 플레이스 크롤러 tags: [backend, crawler, playwright, anti-bot, naver, python] created: 2026-04-20 --- # 네이버 플레이스 크롤러 패턴 네이버 플레이스에서 장소별 정보(층수/호수/이미지 등)를 수집하는 재사용 가능 패턴. 봇탐지 회피 전략 9종 기본 + 누적 볼륨 제어 3종 고급 (총 12종) 적용. ## 핵심 포인트 — 가장 중요한 것부터 1. **CDP + 로그인 세션** — Playwright의 `launch()` 대신 `connect_over_cdp()`로 평소 쓰던(로그인된) Chrome에 붙기. 이것만으로 차단 대부분 회피. 2. **헤드리스 금지** — 반드시 GUI Chrome에 사람이 직접 로그인한 프로필 3. **데이터센터 IP 금지** — AWS/OCI/GCP IP는 즉시 블랙. 로컬 또는 레지덴셜 프록시 4. **블록 감지 → 즉시 중단** — "서비스 이용이 제한", "자동화된 접근" 등 시그널 만나면 6~12h 대기 ## 적용된 봇 회피 전략 ### 기본 9종 | 전략 | 구현 | |---|---| | CDP 연결 | `p.chromium.connect_over_cdp(f"http://localhost:{CDP_PORT}")` | | 세션 웜업 | map.naver.com 먼저 방문 + 3~10초 대기 | | 지수분포 지연 | `random.expovariate(1/mean)` + floor/cap | | Long Break | 30~50건마다 20~60초 | | 인간 행동 시뮬 | `human_move`(워밍업) + **`human_browse`(레코드간 50%, 끊어 스크롤 2~4스텝)** | | 데코이 검색 | 5% 확률로 무관한 검색어 | | 블록 감지 | `detect_block(page)` — 시그널 리스트 스캔 | | 활동시간 제한 | 9~24시만 작동 (`--force`로 무시 가능) | | 순서 셔플 | `random.shuffle(records)` — ID 순차 접근 패턴 회피 | ### 누적 볼륨 제어 3종 (장기·대량 크롤 전용 — 추가) | 전략 | 구현 | 발동 기준 | |---|---|---| | **Long Break 상향** | `long_break(60, 180)` — 30~50건마다 1~3분 | 하루 2,000건+ 크롤 | | **하루 상한 소프트캡** | `_daily_count` 카운터 → 400건 도달 시 자동 종료 | 장기 크롤 프로젝트 | | **프로필 로테이션** | 프로필 A/B/C + 포트 9222/9223/9224 | 10,000건+ 정기 크롤 | > 기본 9종은 모두 적용해도 **같은 IP·프로필로 하루 485건 누적 시 차단됐음** (2026-04-20 경험칙). > 장기 크롤에는 위 3종을 반드시 추가 적용. ## 층/호 파서 지원 패턴 | 입력 | 결과 | |---|---| | `5층` | `floors:["5층"]` | | `5층 501호` | `floors:["5층"], unit:"501호"` | | `1~3층` / `1층~3층` | `floors:["1층","2층","3층"]` | | `1, 2, 3층` / `1·2층` | `floors:["1층","2층","3층"]` (나열) | | `지하1층 101호` / `B1` | `floors:["지1층"], unit:"101호"` | | `전층` | `floors:["전층"]` (건축물대장 조회 시 전체 지상층 자동 합산) | ## 실전 속도 (참고) | 모드 | 분당 | 시간당 | 하루 상한 | |---|---|---|---| | 기본 (9종) | 15~20건 | 400~600건 | ~1,000건 | | **방어 모드** (9종 + 볼륨 제어 3종) | **3~5건** | **200~300건** | **400건 (소프트캡)** | > **경험칙 (2026-04-20)**: 같은 IP·프로필로 하루 485건 누적 처리 시 차단. > 장기·대량 크롤에서는 방어 모드 권장. ## 재사용 시 변경 포인트 - `parse_floor_info()` : 층수 대신 다른 정보 추출 시 변경 - `extract_*_from_page()` : DOM 추출 로직 교체 - `INPUT_FILE` / `OUTPUT_FILE` : 입출력 파일 - 크롤링 URL : `m.place.naver.com` (모바일) vs `map.naver.com/p/entry/place` (데스크톱) ## 환경 요구사항 - Python 3.9+ - playwright (`pip install playwright`) - macOS Chrome (또는 경로 조정) ## ⚠️ 주의 - 헤드리스 서버(OCI/AWS 등)에서는 효과 없음 — 로컬 PC + 로그인 Chrome 조합 필수 - IP 차단되면 데이터센터 IP는 오래 유지되므로 복구 어려움 - **API 키 등 민감 정보는 .env 로 분리하고 코드에 하드코딩 금지** ## 파생 프로젝트 - SmartPicks / 260225_MugJJang — 대형카페 1,580건 층수·면적 수집 (2026-04) - SmartPicks / space_rental — 공간대여 2,417건 detail 수집 + 외부/내부 사진 수집 (2026-04-27) - false positive "잠시 후 다시" 트리거 발견 (record_id 1450061243 = 아임키즈룸 안내문) - 카테고리 탭 사진 추출에 JS-click + set-diff 패턴 적용 - `human_browse(page)` 레코드 간 스크롤 모션 추가 --- ## 상세 가이드 > Playwright CDP 방식 + 봇 탐지 회피 전략 (실전 적용 기준) ### 1. 전제 조건 및 환경 설정 #### 필수 패키지 ```bash pip install playwright playwright install chromium ``` #### Chrome 프로필 설정 (가장 중요) 네이버는 로그인된 Chrome 세션을 신뢰합니다. **전용 프로필을 만들고, 네이버에 로그인한 상태를 유지**해야 합니다. ``` # 전용 프로필 경로 (예시, PC마다 다르게 설정) ~/chrome-naver-auto ``` #### Chrome 디버그 모드로 실행 ```bash # Windows (CMD/PowerShell) "C:/Program Files/Google/Chrome/Application/chrome.exe" ^ --user-data-dir="C:/Users/{유저명}/chrome-naver-auto" ^ --remote-debugging-port=9222 # 주의: --user-data-dir 없으면 Chrome이 CDP 포트를 거부함 # 기본 프로필(AppData/Local/Google/Chrome/User Data)은 사용 불가 ``` #### 최초 1회 설정 1. 위 명령으로 Chrome 실행 2. 네이버(naver.com)에서 로그인 3. 네이버 지도(map.naver.com) 방문하여 정상 작동 확인 4. Chrome을 그대로 두고 크롤러 실행 ### 2. Chrome CDP 연결 방식 #### 절대 금칙 ```python # ❌ 절대 금지 — 새 브라우저 인스턴스 = 로그인 없음 = 탐지됨 browser = p.chromium.launch() ``` #### 올바른 방식 (CDP 연결) ```python from playwright.sync_api import sync_playwright with sync_playwright() as p: # CDP로 기존 Chrome에 붙기 browser = p.chromium.connect_over_cdp("http://localhost:9222") # 기존 컨텍스트(로그인 세션) 사용 ctx = browser.contexts[0] page = ctx.new_page() # 팝업/다이얼로그 자동 닫기 page.on("dialog", lambda d: d.dismiss()) # 작업... page.close() ``` ### 3. 데이터 추출 전략 네이버 플레이스에서 데이터를 추출하는 방법은 **2가지 URL 패턴**. #### 방법 A: place_id를 알고 있을 때 (빠름, 추천) ``` URL: https://map.naver.com/p/entry/place/{place_id} ``` ```python def get_data_by_place_id(page, place_id: str) -> dict: url = f"https://map.naver.com/p/entry/place/{place_id}" page.goto(url, wait_until="domcontentloaded", timeout=30000) time.sleep(random.uniform(1.5, 3.0)) result = {} # 1순위: og:image (서버사이드 렌더링, 가장 안정적) og_image = page.evaluate( "document.querySelector('meta[property=\"og:image\"]')?.getAttribute('content')" ) if og_image and "pstatic" in og_image: result["image_url"] = og_image # 2순위: Apollo State (SPA 데이터 저장소) try: apollo = page.evaluate("window.__APOLLO_STATE__ || {}") or {} for val in apollo.values(): if isinstance(val, dict): img = val.get("imageUrl") or val.get("mainPhotoUrl") name = val.get("name") if img and "naver" in img: result["image_url"] = img if name: result["name"] = name except Exception: pass # 3순위: DOM에서 직접 추출 if "image_url" not in result: img = page.evaluate(""" (() => { const imgs = [...document.querySelectorAll('img[src]')]; const cdn = imgs.find(i => i.src.includes('pstatic.net') || i.src.includes('ldb-phinf') ); return cdn ? cdn.src : null; })() """) if img: result["image_url"] = img return result ``` #### 방법 B: 이름+주소로 검색 (place_id 모를 때) ``` URL: https://pcmap.place.naver.com/place/list?query={검색어} ``` ```python def search_place(page, name: str, addr: str) -> dict: import re m = re.search(r"(\S+(?:시|도))\s+(\S+(?:시|군|구))", addr or "") region_hint = f"{m.group(1)} {m.group(2)}" if m else "" query = f"{name} {region_hint}".strip() url = f"https://pcmap.place.naver.com/place/list?query={urllib.parse.quote(query)}" page.goto(url, wait_until="load", timeout=25000) time.sleep(random.uniform(1.0, 2.5)) try: apollo = page.evaluate("window.__APOLLO_STATE__") or {} except Exception: return {} for val in apollo.values(): if isinstance(val, dict): cname = val.get("name", "") cid = str(val.get("id", "")) if cid.isdigit() and len(cid) >= 7: if normalize(cname) == normalize(name): return { "place_id": cid, "name": cname, "image_url": val.get("imageUrl"), "category": val.get("category"), "map_url": f"https://map.naver.com/p/entry/place/{cid}", } return {} def normalize(s: str) -> str: """공백/괄호 제거 후 비교""" import re return re.sub(r"[\s\(\)(\)]", "", s or "") ``` ### 4. 봇 탐지 회피 전략 상세 #### 4-1. 세션 웜업 (시작 전 필수) ```python page.goto("https://map.naver.com/", wait_until="load", timeout=20000) time.sleep(random.uniform(3.0, 6.0)) human_move(page) time.sleep(random.uniform(1.5, 3.0)) ``` #### 4-2. 지수분포 자연 지연 ```python def natural_wait(mean: float = 3.0, cap: float = 8.0, floor: float = 1.0): """ 지수분포로 대기시간 생성 — 실제 사람처럼 불규칙한 간격 - mean: 평균 대기시간(초) — 권장 3.0 - cap : 최대 대기시간 — 권장 8.0 (15초 outlier 제거) - floor: 최소 대기시간 — 권장 1.0 (0.8초는 봇 의심) """ t = random.expovariate(1.0 / mean) t = max(floor, min(t, cap)) time.sleep(t) ``` > **권장값 변경 이력 (2026-04-27)**: 기존 `(mean=2.5, floor=0.8, cap=15)` → > `(mean=3.0, floor=1.0, cap=8.0)` 로 상향. 이유: > - **floor 1초**: 0.8초는 인간 평균 페이지 전환보다 빠름. 봇 의심 신호. > - **cap 8초**: 15초 outlier 는 long-tail 인간 행동 시뮬엔 좋지만 실제 사용성에서 너무 큼. > - **mean 3초**: 평균 +0.5초로 rate-limit 마진 ↑. 분당 처리량은 ~16% 감소. > - **uniform vs exponential**: uniform(1~8s) 도 안전하지만 stddev 작음(~2초) → 패턴 균일. > exponential 분포는 stddev=mean(3초) 으로 더 자연스러움. **exp 권장**. #### 4-3. 인간 행동 시뮬레이션 ```python def human_move(page): """마우스 이동 + 스크롤로 사람처럼 행동 (단발성, 워밍업용)""" try: page.mouse.move(random.randint(200, 900), random.randint(200, 600)) time.sleep(random.uniform(0.15, 0.5)) page.evaluate(f"window.scrollBy(0, {random.randint(80, 400)})") time.sleep(random.uniform(0.2, 0.7)) if random.random() < 0.3: page.evaluate(f"window.scrollBy(0, {-random.randint(40, 200)})") time.sleep(random.uniform(0.1, 0.4)) except Exception: pass def human_browse(page): """레코드 처리 후 페이지를 훑어보는 모션 — 끊어서 스크롤 다운, 가끔 위로. **레코드 사이마다** 50% 확률로 호출. bot-detection 회피용. `human_move` 와 차이점: smooth scroll + 2~4 multi-step (한 번에 X). 평균 1~2초 소요. """ if random.random() >= 0.5: return try: # 마우스 살짝 이동 page.mouse.move(random.randint(150, 1000), random.randint(150, 700)) time.sleep(random.uniform(0.1, 0.3)) # 2~4번 끊어서 스크롤 다운 (한 번에 X) steps = random.randint(2, 4) for _ in range(steps): delta = random.randint(120, 400) page.evaluate( f"window.scrollBy({{top: {delta}, left: 0, behavior: 'smooth'}})" ) time.sleep(random.uniform(0.25, 0.7)) # 30% 확률로 살짝 위로 (다시 보는 척) if random.random() < 0.3: page.evaluate( f"window.scrollBy({{top: -{random.randint(60, 220)}, left: 0, behavior: 'smooth'}})" ) time.sleep(random.uniform(0.15, 0.4)) except Exception: pass # 메인 루프에서 사용 for i, record in enumerate(records, 1): # ... 처리 ... natural_wait() human_browse(page) # 50% 확률로 스크롤 모션 if i % long_break_every == 0: long_break(60, 180) ``` > **2026-04-27 추가**: `human_move` 는 워밍업 1회용. 레코드 사이 자연스러운 > 스크롤은 `human_browse` 로 분리. smooth + 끊어진 multi-step 으로 단발 scrollBy 보다 > 더 인간답게 보임. #### 4-4. 긴 휴식 (Long Break) — 가장 중요 ```python def long_break(min_s: float = 30, max_s: float = 120): """30~120초 긴 휴식 — 30~50건마다 한 번""" t = random.uniform(min_s, max_s) print(f" [☕ long break {t:.0f}s]") time.sleep(t) # 사용 예시 long_break_every = random.randint(30, 50) for i, record in enumerate(records, 1): natural_wait() if i % long_break_every == 0: long_break(20, 60) long_break_every = random.randint(30, 50) ``` #### 4-5. 데코이 검색 (5% 확률) ```python DECOY_QUERIES = [ "강남역 카페", "홍대 맛집", "여의도 공원 카페", "명동 쇼핑", "이태원 브런치", "성수동 카페", "잠실 맛집", "건대입구 술집", ] if random.random() < 0.05 and i > 1: dq = random.choice(DECOY_QUERIES) decoy_url = f"https://pcmap.place.naver.com/place/list?query={urllib.parse.quote(dq)}" page.goto(decoy_url, wait_until="load", timeout=20000) natural_wait(mean=2.0, cap=6.0) ``` #### 4-6. 블록 감지 → 즉시 중단 ```python BLOCK_SIGNALS = [ "비정상적인 접근", "비정상 접근", "자동화된 접근", "자동화 접근", "보안문자", "보안 문자", "captcha", "CAPTCHA", "reCAPTCHA", "일시적으로 차단", "접근이 차단", "잠시 후 다시", ] def detect_block(page) -> str: try: text = page.inner_text("body", timeout=3000)[:3000] except Exception: return "" for sig in BLOCK_SIGNALS: if sig.lower() in text.lower(): return sig return "" # 매 페이지 방문 후 반드시 체크 blk = detect_block(page) if blk: print(f"🚨 BLOCK 감지: {blk} — 즉시 중단, 6~12시간 후 재시도") break ``` ##### ⚠️ False Positive 사례 — "잠시 후 다시" (2026-04-27) `BLOCK_SIGNALS` 의 **"잠시 후 다시"** 는 가장 false positive 가 잦은 시그널. 한국어 안내문에 흔하게 등장하는 표현이라 **사장님이 작성한 정상 본문**이 차단 시그널로 오인되는 케이스가 발생. **실전 케이스**: detail crawler 1647/2417 시점에 record_id `1450061243` ("아임키즈룸" 경산 키즈룸/장소대여) 페이지에서 차단 판정 → 전체 run 중단. 조사 결과 사장님 예약 안내문에 다음 문장이 있었음: > "뒤로가기 시 발생하는 일시적 예약 오류는 5~10분 뒤 자동 해제됩니다. > **잠시 후 다시 시도해 주시면** 감사하겠습니다." `page.inner_text("body")[:3000]` 안에 매칭되어 차단 판정. 실제로는 정상 페이지(HTTP 200, 콘텐츠 풀로드). **대응 옵션 (셋 중 택 1)**: ```python # 옵션 A: BLOCK_SIGNALS 에서 "잠시 후 다시" 제거 (가장 단순) BLOCK_SIGNALS = [ "비정상적인 접근", "비정상 접근", "자동화된 접근", "자동화 접근", "보안문자", "보안 문자", "captcha", "CAPTCHA", "reCAPTCHA", "일시적으로 차단", "접근이 차단", # "잠시 후 다시", # ← false positive 다발 — 제거 ] # 옵션 B: 더 구체적인 phrase 로 교체 BLOCK_SIGNALS = [ ..., "잠시 후 다시 접속", # 차단 페이지의 정확한 문구만 "잠시 후 다시 시도해 주시기 바랍니다", # 네이버 차단 안내문 ] # 옵션 C: 본문 전체가 아닌 차단 페이지 전용 셀렉터 검사 def detect_block(page) -> str: # 차단 페이지는 보통 .error_page 또는 별도 title title = page.title() or "" if any(s in title for s in ["접근 제한", "비정상", "차단"]): return f"title: {title}" err = page.locator(".error_page, #error_message").first if err.count() > 0: try: return err.inner_text(timeout=1000)[:50] except Exception: pass return "" # 옵션 D: 그 ID 만 skip 리스트에 추가하고 계속 (당장 대응) SKIP_IDS = {"1450061243"} # known false-positive triggers records = [r for r in records if str(r["id"]) not in SKIP_IDS] ``` **권장**: 신규 프로젝트는 **B** (정확한 문구 매칭). 운영 중인 크롤러는 **D** (즉시 우회) → 다음 maintenance 때 **B** 로 마이그레이션. **검증 방법**: blocked 으로 멈추면 그 record_id 페이지를 직접 (다른 Chrome) 열어보고: 1. HTTP 200 인지 2. 콘텐츠가 풀로드 됐는지 3. 차단 시그널 텍스트가 *고객 안내문* 인지 *네이버 차단 안내* 인지 세 가지 확인. 1~3 모두 해당하면 false positive. #### 4-7. 활동 시간대 제한 ```python def in_active_hours(start: int = 9, end: int = 23) -> bool: """새벽 크롤링은 봇 패턴 — 낮 시간대만 운영""" return start <= datetime.now().hour < end if not in_active_hours(): print("⛔ 활동시간대 아님 — 중단") sys.exit(0) ``` #### 4-8. 레코드 순서 셔플 ```python # 순차적 접근은 패턴 탐지됨 → 반드시 셔플 random.shuffle(records) ``` #### 4-9. 누적 볼륨 제어 (고빈도 재차단 방지) **실전 사례** (2026-04-20): 같은 IP·같은 Chrome 프로필로 하루 2세션 (오전 320건 + 오후 165건 = 485건)을 돌렸더니 두 번째 세션 165건째에 블록 감지. 구현은 완벽(4-1 ~ 4-8 전부 적용)했지만 **누적 페이스가 네이버 기준 "동일 세션 집중 접근"으로 판정됨**. ##### (1) Long Break 상향 (방어 모드) ```python # 기본: long_break(20, 60) — 30~50건마다 20~60초 # 방어 모드: long_break(60, 180) — 30~50건마다 60~180초 long_break(min_s=60, max_s=180) ``` 건당 평균 +1분, 시속 200~300건으로 감속. 하루 2,000건+ 장기 크롤에서 권장. ##### (2) 하루 상한 소프트캡 (자동 종료) ```python from datetime import date DAILY_CAP = 400 # 하루 처리 한도 (성공+실패 합산) today = date.today().isoformat() daily = progress.get("_daily_count", {}).get(today, 0) for i, record in enumerate(records, 1): if daily >= DAILY_CAP: print(f"⛔ 하루 상한 {DAILY_CAP}건 도달 — 내일 재개") save_progress(progress) break # ... 처리 ... daily += 1 progress.setdefault("_daily_count", {})[today] = daily ``` progress 파일에 `_daily_count: {"2026-04-20": 400}` 식으로 유지. 재실행 시 오늘 한도 도달 여부 확인 → 자동 종료. ##### (3) 프로필 2~3개 로테이션 (고급) ``` ~/chrome-naver-crawl-A (네이버 계정 A, 포트 9222) ~/chrome-naver-crawl-B (네이버 계정 B, 포트 9223) ~/chrome-naver-crawl-C (네이버 계정 C, 포트 9224) ``` - 세션마다 프로필 선택 (round-robin 또는 차단 감지 시 자동 교체) - **주의**: 같은 IP에서 여러 계정 돌리면 오히려 의심 살 수 있음 → IP도 분리(VPN·라우터 재연결)가 이상적 - **비용 대비**: 단일 프로젝트엔 과투자 — 10,000건+ 정기 크롤일 때만 의미 > 블록 회피 철학: "무한 방어"보다 **"걸릴 때 우아하게 중단 + 다음날 재개"** 가 현실적. > 위 3가지는 누적 페이스를 여유있게 낮추는 안전 마진. ### 봇 회피 강도 요약 | 전략 | 효과 | 비용 | |------|------|------| | CDP 연결 (로그인 세션) | ★★★★★ | Chrome 한 번 실행 | | 세션 웜업 | ★★★★☆ | +5~10초 | | 지수분포 지연 | ★★★★☆ | 속도 50% 감소 | | Long Break | ★★★★★ | 속도 30% 감소 | | 인간 행동 시뮬 | ★★★☆☆ | 거의 없음 | | 데코이 검색 | ★★★☆☆ | 5% 오버헤드 | | 블록 감지 | ★★★★★ | 없음 (필수 안전장치) | | 시간대 제한 | ★★★☆☆ | 없음 | | 순서 셔플 | ★★★☆☆ | 없음 | | **Long Break 상향 (방어 모드)** | ★★★★☆ | 추가 속도 50% 감소 | | **하루 상한 소프트캡** | ★★★★★ | 하루 처리량 상한 (자동 종료) | | **프로필 로테이션** | ★★★☆☆ | 계정 2~3개 + IP 분리 | ### 5. 백그라운드 실행 및 모니터링 ```bash # 백그라운드 실행 + 로그 저장 nohup python -u crawler.py >> crawler.log 2>&1 & echo "PID: $!" # -u 플래그: stdout 버퍼링 비활성화 → 로그 실시간 출력 # 진행 상황 모니터링 grep -c "저장완료" crawler.log tail -10 crawler.log ps aux | grep python | grep -v grep ``` 크래시 자동 재시작 (bash): ```bash #!/bin/bash # auto_restart.sh while true; do python -u crawler.py --force-hour >> crawler.log 2>&1 echo "[$(date)] 크래시 감지 — 30초 후 재시작" >> crawler.log sleep 30 done ``` ### 6. 자주 겪는 문제와 해결법 | 문제 | 원인 / 해결 | |---|---| | `TimeoutError: The read operation timed out` | 네이버 서버 응답 지연. try/except로 감싸고 다음 레코드로 continue | | 이미지 URL 항상 None | og:image 없는 장소거나 로딩 불완전. `wait_until="domcontentloaded"` + 추가 대기 | | `__APOLLO_STATE__ = null` | JS 실행 전 추출 시도. 페이지 이동 후 1.5초 이상 대기 | | Chrome CDP 포트 연결 실패 | `--user-data-dir` 없이 실행했을 것. `http://localhost:9222/json` 직접 접속 테스트 | | 검색 결과 엉뚱한 장소 매칭 | 이름이 너무 일반적. 주소 지역 힌트 쿼리에 추가 (`f"{name} {시도} {시군구}"`) | | Supabase 1000행 제한 | offset 페이지네이션으로 배치 순회 | ### 카테고리 탭 사진 추출 (외부/내부) — JS-click + set-diff `pcmap.place.naver.com/{type}/{id}/photo` 의 카테고리 탭(외부/내부/음식·음료/메뉴판/...) 별 사진을 추출하는 패턴. **URL 파라미터(`?subFilter=EXTERIOR`)는 동작 불안정** (errorCode 408/AbortError) — JavaScript 클릭 + 차집합으로 분리. #### URL 패턴 ``` https://pcmap.place.naver.com/{place_type}/{place_id}/photo ``` `place_type` 우선순위 fallback: `restaurant` → `cafe` → `place`. 카테고리 탭은 `
  • {label}
  • ` (label = "외부", "내부", "음식·음료" 등). #### 함정 1: URL 파라미터로는 안 됨 ```python # ❌ 동작 안함 — errorCode 408 또는 AbortError url = f"https://pcmap.place.naver.com/restaurant/{id}/photo?filterType=AI%20View&subFilter=EXTERIOR" ``` #### 함정 2: 작은 viewport 에서 click 안 먹음 Headless Chrome 의 viewport 가 작으면(800x600 등) 탭이 화면 밖에 있어 click 이 트리거 안 됨. **`scrollIntoView` 후 `force=True` click 필수**: ```python loc = page.locator(".Zt2Kl").filter(has_text="외부").first loc.scroll_into_view_if_needed(timeout=3000) loc.click(force=True, timeout=5000) # URL 변화 확인 page.wait_for_url("**subFilter=EXTERIOR**", timeout=5000) ``` 또는 evaluate 안에서 보강: ```js const t = [...document.querySelectorAll('.Zt2Kl')].find(t => t.textContent.trim() === '외부'); t.scrollIntoView({behavior: 'instant', block: 'center'}); const a = t.querySelector('a') || t; a.click(); // React onClick 대비 dispatch 시퀀스 for (const type of ['pointerdown','mousedown','pointerup','mouseup','click']) { a.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, view: window})); } ``` #### 함정 3: 단순 추출 → 외부/내부에 같은 사진 17장씩 섞임 각 탭 활성화 후 `document.querySelectorAll('img')` 로 추출하면, **헤더/sticky preview 의 카페 대표 이미지(~17장)** 가 두 탭 모두에 보여서 외부/내부 사진이 교차 오염됨. **해결 — set-diff 차집합 방식**: ```python def extract_per_tab(page) -> dict[str, str]: """현재 활성 탭의 모든 pstatic 이미지 → {origkey: best_url}""" return page.evaluate(""" () => { function origKey(src) { if (!src) return ''; if (src.includes('search.pstatic.net')) { const m = src.match(/[&?]src=([^&]+)/); if (m) return decodeURIComponent(m[1]); } return src.split('#')[0]; } function sizeRank(src) { const m = src.match(/type=([wf]\\d+|f\\d+_\\d+)/); return m ? parseInt((m[1].match(/\\d+/) || [0])[0]) : 0; } const imgs = [...document.querySelectorAll('img')] .filter(i => /pstatic\\.net|naver\\.net/.test(i.src||'')) .filter(i => !i.naturalWidth || i.naturalWidth >= 100); const byOrig = {}; for (const i of imgs) { const key = origKey(i.src); if (!key) continue; if (!byOrig[key] || sizeRank(i.src) > sizeRank(byOrig[key])) byOrig[key] = i.src; } return byOrig; } """) or {} # 외부 탭 클릭 → lazy-load 스크롤 → 추출 click_tab(page, "외부"); ext_raw = extract_per_tab(page) # 내부 탭 클릭 → lazy-load 스크롤 → 추출 click_tab(page, "내부"); int_raw = extract_per_tab(page) # 차집합으로 진짜 카테고리만 추리기 ext_keys = set(ext_raw.keys()) int_keys = set(int_raw.keys()) common = ext_keys & int_keys # 헤더/sticky — 카페 대표 이미지 (~17장) ext_only_keys = ext_keys - common # 진짜 외부 int_only_keys = int_keys - common # 진짜 내부 exterior_urls = [ext_raw[k] for k in ext_only_keys][:MAX_PER_TAB] interior_urls = [int_raw[k] for k in int_only_keys][:MAX_PER_TAB] ``` **검증된 수치 (라운지티 테스트 카페)**: - EXT 탭 활성화 시 보이는 unique URL: 48 (= 헤더 17 + 진짜 외부 31) - INT 탭 활성화 시 보이는 unique URL: 55 (= 헤더 17 + 진짜 내부 38) - 공통(common) = 17장 → **제거**, 외부 only 31장 + 내부 only 38장만 사용 #### `origkey()` — search.pstatic.net wrapper 분해 네이버 사진 URL 은 보통 wrapper 형식: ``` https://search.pstatic.net/common/?autoRotate=true&type=w560_sharpen&src=https%3A%2F%2Fldb-phinf.pstatic.net%2F.../IMG_9528.jpg ``` 같은 원본 사진이 다양한 wrapper 사이즈(`w560`, `w278`, `f320_320` ...) 로 페이지에 여러 번 등장. dedup 시 wrapper URL 그대로 비교하면 같은 사진을 중복으로 셈. **`src=` 파라미터의 원본 URL 추출**해서 그것을 key 로 dedup: ```python import re from urllib.parse import unquote def origkey(src: str) -> str: if not src: return "" if "search.pstatic.net" in src: m = re.search(r"[&?]src=([^&]+)", src) if m: return unquote(m.group(1)) return src.split("#")[0] ``` ### 빠른 참고 — URL 패턴 | 용도 | URL | |------|-----| | place_id로 직접 방문 | `https://map.naver.com/p/entry/place/{id}` | | 이름으로 검색 | `https://pcmap.place.naver.com/place/list?query={검색어}` | | 지도 홈 (웜업용) | `https://map.naver.com/` | | 모바일 장소 페이지 | `https://m.place.naver.com/place/{id}/home` | | 사진 페이지 (PC) | `https://pcmap.place.naver.com/{type}/{id}/photo` (`type` = restaurant/cafe/place) | | 상세 페이지 (PC) | `https://pcmap.place.naver.com/place/{id}/home` | ### 권장 크롤링 속도 | 상황 | 권장 간격 | 긴 휴식 | 하루 상한 | |------|----------|---------|---------| | place_id 직접 방문 | 1.5~4초/건 | 30~50건마다 20~60초 | ~1,000건 | | 검색 방식 | 2~5초/건 | 10~20건마다 30~120초 | ~500건 | | **방어 모드** (장기 크롤) | 3~6초/건 | 30~50건마다 **60~180초** | **400건 (소프트캡)** | | 블록 감지 후 | 6~12시간 대기 | - | - | > **결론**: 시간당 약 400~600건 처리 가능 (place_id 직접 방문 기준) · 24시간 기준 최대 약 10,000건 > **방어 모드**: 시속 200~300건 / 하루 400건 소프트캡 (경험칙 2026-04-20 — 같은 IP·프로필 누적 485건에서 블록) --- ## 실전 파이썬 템플릿 (층수/호수 크롤러) `crawl_naver_floors_local.py` — 봇탐지 회피 전략 전부 적용, 층/호 파서 포함: ```python # -*- coding: utf-8 -*- """ 네이버 플레이스 층수/호수 크롤러 — 로컬 CDP 방식 - CDP 연결 (로그인된 Chrome 세션 재사용) - 세션 웜업 + 지수분포 자연 지연 + Long Break - 인간 행동 시뮬레이션 + 데코이 검색 - 블록 감지 → 즉시 중단 - 활동 시간대 제한 + 순서 셔플 추출 대상: - floors : ["1층", "2층"] — 여러 층 점유 시 전부 리스트로 - unit : "301호" — 호수가 있으면 추출 (optional) 사용법: 1. (최초 1회) 별도 터미널에서: ./start_chrome.sh 2. 열린 Chrome에서 naver.com 로그인 3. python3 crawl_naver_floors_local.py (심야 강행: python3 crawl_naver_floors_local.py --force) """ import sys, re, json, time, random, subprocess, urllib.parse from datetime import datetime from pathlib import Path from playwright.sync_api import sync_playwright sys.stdout.reconfigure(encoding="utf-8", errors="replace") # ── 경로 ───────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent INPUT_FILE = BASE_DIR / "large_cafes_final.json" OUTPUT_FILE = BASE_DIR / "large_cafes_final.json" PROGRESS_FILE = BASE_DIR / "progress_naver.json" # ── 설정 ───────────────────────────────────────────────────────── CDP_PORT = 9222 # ⚠️ 민감한 키는 환경변수/env 파일로 옮기세요. 코드에 하드코딩 금지. API_KEY = "" # 건축물대장 API — .env에서 로드 권장 API_BASE = "https://apis.data.go.kr/1613000/BldRgstHubService" BLOCK_SIGNALS = [ "비정상적인 접근", "비정상 접근", "자동화된 접근", "자동화 접근", "보안문자", "보안 문자", "captcha", "CAPTCHA", "reCAPTCHA", "일시적으로 차단", "접근이 차단", "잠시 후 다시", "서비스 이용이 제한", ] DECOY_QUERIES = [ "강남역 카페", "홍대 맛집", "여의도 공원 카페", "명동 쇼핑", "이태원 브런치", "성수동 카페", "잠실 맛집", "건대입구 술집", "한남동 카페", ] # ── 봇 방지 유틸 ───────────────────────────────────────────────── def natural_wait(mean=2.5, cap=15.0, floor_=0.8): t = random.expovariate(1.0 / mean) time.sleep(max(floor_, min(t, cap))) def long_break(min_s=20, max_s=60): t = random.uniform(min_s, max_s) print(f" [☕ long break {t:.0f}s]") time.sleep(t) def human_move(page): try: page.mouse.move(random.randint(200, 900), random.randint(200, 600)) time.sleep(random.uniform(0.2, 0.5)) page.evaluate(f"window.scrollBy(0, {random.randint(80, 400)})") time.sleep(random.uniform(0.2, 0.7)) if random.random() < 0.3: page.evaluate(f"window.scrollBy(0, -{random.randint(40, 200)})") time.sleep(0.2) except Exception: pass def detect_block(page): try: text = page.inner_text("body", timeout=3000)[:3000] for sig in BLOCK_SIGNALS: if sig.lower() in text.lower(): return sig except Exception: pass return "" def in_active_hours(start=9, end=24): return start <= datetime.now().hour < end # ── 층/호 파서 ─────────────────────────────────────────────────── def parse_floor_info(text): """ 텍스트에서 층 목록과 호수를 추출. 반환: {"floors": ["1층", "2층"], "unit": "301호"} # 호수 없으면 None None (층 정보를 하나도 못 찾았을 때) """ if not text: return None floors = [] def add(f): if f and f not in floors: floors.append(f) consumed = text # 1) 전층 / 전관 if re.search(r'전\s*(층|관)', consumed): add("전층") consumed = re.sub(r'전\s*(층|관)', lambda m: " " * len(m.group(0)), consumed) # 2) 지하 — "지하 N층" 또는 "BN" / "BN층" def _basement(m): n = m.group(1) or m.group(2) add(f"지{n}층") return " " * len(m.group(0)) consumed = re.sub(r'지하\s*(\d+)\s*층', _basement, consumed) consumed = re.sub(r'\b[Bb](\d+)(?:\s*층)?\b', _basement, consumed) # 3) 지상 N층 (명시적) def _ground(m): add(f"{m.group(1)}층") return " " * len(m.group(0)) consumed = re.sub(r'지상\s*(\d+)\s*층', _ground, consumed) # 4) 범위 "1~3층", "1층~3층", "1-3층" def _range(m): a, b = int(m.group(1)), int(m.group(2)) if 1 <= a <= b and b - a <= 15: for n in range(a, b + 1): add(f"{n}층") return " " * len(m.group(0)) consumed = re.sub(r'(\d+)\s*층?\s*[~\-–]\s*(\d+)\s*층', _range, consumed) # 5) 나열 "1,2층" / "1, 2, 3층" / "1·2·3층" def _multi(m): for num in re.split(r'[,·\s]+', m.group(1)): if num.strip().isdigit(): add(f"{num.strip()}층") return " " * len(m.group(0)) consumed = re.sub(r'((?:\d+\s*[,·]\s*)+\d+)\s*층', _multi, consumed) # 6) 단일 "N층" for m in re.finditer(r'(\d+)\s*층', consumed): add(f"{m.group(1)}층") break if not floors: return None # 호수 unit = None m = re.search(r'(\d+)\s*호', text) if m: unit = f"{m.group(1)}호" return {"floors": floors, "unit": unit} def extract_floor_info_from_page(page): """페이지 본문에서 층·호 정보 추출. 주소로 보이는 라인 우선.""" try: text = page.inner_text("body", timeout=5000) except Exception: return None for line in text.split("\n"): line = line.strip() if not line or len(line) > 150: continue if "층" not in line and "호" not in line: continue if not any(kw in line for kw in ["층", "호", "번길", "로 ", "동 ", "구 ", "시 ", "군 "]): continue info = parse_floor_info(line) if info and info.get("floors"): info["raw"] = line[:120] return info info = parse_floor_info(text[:3000]) if info and info.get("floors"): info["raw"] = "(fallback from body)" return info return None # ── 메인 ───────────────────────────────────────────────────────── def main(): force = "--force" in sys.argv if not force and not in_active_hours(): print("⛔ 활동시간대(9~24시) 아님. 심야에도 돌리려면 --force 플래그") return with open(INPUT_FILE) as f: cafes = json.load(f) progress = {} if PROGRESS_FILE.exists(): with open(PROGRESS_FILE) as f: progress = json.load(f) # blocked는 재시도 대상으로 간주 progress = {k: v for k, v in progress.items() if v.get("error") != "blocked"} # 타겟: naver_place_id 있고, cafeArea 아직 없고, progress에 없는 것 targets = [ c for c in cafes if str(c["id"]) not in progress and not (c.get("building") or {}).get("cafeArea") and c.get("naver_place_id") ] print(f"대상: {len(targets)}건 (이전 진행: {len(progress)}건)") if not targets: print("처리할 항목 없음.") return random.shuffle(targets) with sync_playwright() as p: try: browser = p.chromium.connect_over_cdp(f"http://localhost:{CDP_PORT}") except Exception as e: print(f"❌ Chrome CDP 연결 실패 (localhost:{CDP_PORT}): {e}") print(f" 먼저 ./start_chrome.sh 를 실행하고 네이버 로그인하세요.") return if not browser.contexts: print("❌ 기존 컨텍스트 없음. Chrome을 --user-data-dir로 실행했는지 확인") return ctx = browser.contexts[0] page = ctx.new_page() page.on("dialog", lambda d: d.dismiss()) try: # 세션 웜업 print("[warmup] map.naver.com 방문...") page.goto("https://map.naver.com/", wait_until="load", timeout=20000) natural_wait(mean=4.0, cap=10.0, floor_=2.0) human_move(page) natural_wait(mean=2.0, cap=5.0, floor_=1.0) success = fail = 0 long_break_every = random.randint(30, 50) start_time = time.time() for i, c in enumerate(targets, 1): if not force and not in_active_hours(): print(f"⛔ 활동시간대 벗어남 — 중단 ({i-1}/{len(targets)})") break cid = str(c["id"]) pid = c["naver_place_id"] name = (c.get("name") or "")[:20] # 데코이 (5%) if random.random() < 0.05 and i > 1: dq = random.choice(DECOY_QUERIES) print(f" [decoy: {dq}]") try: page.goto( f"https://pcmap.place.naver.com/place/list?query={urllib.parse.quote(dq)}", wait_until="load", timeout=20000 ) natural_wait(mean=2.0, cap=5.0) except Exception: pass # 추출 try: url = f"https://m.place.naver.com/place/{pid}/home" page.goto(url, wait_until="domcontentloaded", timeout=25000) natural_wait(mean=2.5, cap=8.0, floor_=1.5) human_move(page) blk = detect_block(page) if blk: progress[cid] = {"error": "blocked", "sig": blk} fail += 1 print(f"🚨 BLOCK 감지 ({blk}) — 즉시 중단") print(f" 6~12시간 후 재시도 권장") break info = extract_floor_info_from_page(page) if info and info.get("floors"): entry = {"floors": info["floors"]} if info.get("unit"): entry["unit"] = info["unit"] progress[cid] = entry success += 1 floors_str = "·".join(info["floors"]) unit_str = f" {info['unit']}" if info.get("unit") else "" print(f"[{i}/{len(targets)}] {name}: {floors_str}{unit_str} ✓") else: progress[cid] = {"floors": None, "error": "not_found"} fail += 1 except Exception as e: progress[cid] = {"floors": None, "error": str(e)[:60]} fail += 1 if i % 10 == 0: with open(PROGRESS_FILE, "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2) natural_wait(mean=2.5, cap=10.0, floor_=1.0) if i % long_break_every == 0: long_break(20, 60) long_break_every = random.randint(30, 50) with open(PROGRESS_FILE, "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2) print(f"\n크롤링 완료: 성공 {success} / 실패 {fail}") finally: try: page.close() except Exception: pass if __name__ == "__main__": main() ``` --- ## Chrome 실행 스크립트 (macOS) `start_chrome.sh`: ```bash #!/bin/bash # 네이버 크롤링용 전용 Chrome 프로필 실행 # 이 프로필은 평소 쓰는 Chrome과 완전히 분리됨 (기존 Chrome 세션에 영향 없음) PROFILE_DIR="$HOME/chrome-naver-crawl" echo "🌐 크롤링 전용 Chrome 실행 중..." echo " 프로필: $PROFILE_DIR" echo " CDP 포트: 9222" echo "" echo "📌 최초 1번만: 열린 Chrome에서 네이버(naver.com) 로그인하세요." echo "📌 이후엔 이 창만 켜두면 자동 로그인 유지됩니다." echo "" "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ --user-data-dir="$PROFILE_DIR" \ --remote-debugging-port=9222 \ --no-first-run \ --no-default-browser-check \ "https://nid.naver.com/nidlogin.login" ``` 실행 권한: `chmod +x start_chrome.sh` --- ## 구성 요약 (이 패턴의 가치) 이 패턴은 **4가지 전략이 맞물려야** 효과를 냅니다. 하나라도 빠지면 차단률이 급등: 1. **로그인 세션 재사용** (CDP + `--user-data-dir`) 2. **인간화된 행동 패턴** (지연 분포, 스크롤, 데코이) 3. **블록 조기 감지** (매 페이지 후 시그널 스캔) 4. **속도 자발적 제한** (Long Break, 시간대) 재사용 시 `parse_floor_info()` / `extract_*_from_page()` 만 바꾸면 다른 데이터(카테고리, 별점, 영업시간 등)도 같은 방식으로 수집 가능.