add main to start selenium crawler

main
jaehwang 2025-09-25 11:38:20 +09:00
parent 08d5840c71
commit 56682fa54e
9 changed files with 2073 additions and 14 deletions

1
.gitignore vendored
View File

@ -160,3 +160,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
data/

111
main.py
View File

@ -1,22 +1,105 @@
import os, json
import asyncio
import playwright
import platform
from playwright.async_api import async_playwright
from selenium_crawler.logger_utils import setup_logger, config
from selenium_crawler.naver_integrated_crawler import NaverIntegratedCrawler
from selenium_crawler.naver_blog_crawler import ImageFilterConfig
import asyncio
HOME_PATH = "/home/azureuser"
async def playwright_test():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://playwright.dev")
print(await page.title())
await browser.close()
pass
async def main():
"""메인 함수"""
print("=== 네이버 지도 + 블로그 통합 크롤러 (리팩토링 버전) ===\n")
# 설정 파일에서 값 읽기
print("설정 파일에서 크롤러 설정을 로드합니다...")
# 이미지 필터 설정
image_filter_config = config.get('image_filter', {})
image_filter = ImageFilterConfig(
min_width=image_filter_config.get('min_width', 400),
min_height=image_filter_config.get('min_height', 400),
min_file_size_kb=image_filter_config.get('min_file_size_kb', 10),
max_file_size_mb=image_filter_config.get('max_file_size_mb', 10),
require_both_dimensions=image_filter_config.get('require_both_dimensions', False),
allowed_formats=set(image_filter_config.get('allowed_formats', ['.jpg', '.jpeg', '.png', '.webp']))
)
print(f"\n이미지 필터 설정:")
print(f" - 최소 크기: {image_filter.min_width}x{image_filter.min_height} px")
print(f" - 파일 크기: {image_filter.min_file_size_kb}KB ~ {image_filter.max_file_size_mb}MB")
print(f" - 허용 포맷: {', '.join(sorted(image_filter.allowed_formats))}")
print(f" - 크기 조건: {'AND' if image_filter.require_both_dimensions else 'OR'}")
# 프로젝트 디렉토리 설정
if platform.system() == 'Windows':
project_dir = config.get('paths.project_dir_windows', r'C:\CrawlingData')
else:
project_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data')
# 크롤러 초기화
crawler = NaverIntegratedCrawler(project_dir=project_dir, image_filter=image_filter)
print(f"\n프로젝트 디렉토리: {project_dir}")
try:
crawler.setup_driver()
# 검색어 입력 (설정 파일의 기본값 사용)
default_query = config.get('crawler.default_search_query', '선돌막국수')
search_query = input(f"\n검색할 장소를 입력하세요 (기본값: {default_query}): ").strip()
if not search_query:
search_query = default_query
# 블로그 수 입력 (설정 파일의 기본값 사용)
default_max_blogs = config.get('crawler.max_blogs', 1)
max_blogs_str = input(f"수집할 블로그 수를 입력하세요 (기본값: {default_max_blogs}): ").strip()
max_blogs = int(max_blogs_str) if max_blogs_str.isdigit() else default_max_blogs
# 설정 확인
print(f"\n=== 크롤링 설정 확인 ===")
print(f"검색어: {search_query}")
print(f"수집할 블로그 수: {max_blogs}")
proceed = input("\n위 설정으로 진행하시겠습니까? (Y/n): ").strip().lower()
if proceed == 'n':
print("크롤링을 취소합니다.")
return
# 통합 수집 실행
max_blogs =1
results = crawler.collect_all_info(search_query, max_blogs)
print(f"\n=== 수집 완료 ===")
print(f"장소 정보: {results['place_info'].get('name', 'N/A')}")
print(f"수집한 사진: {len(results['photo_urls'])}")
print(f"지도 블로그 리뷰: {len(results['map_blog_reviews'])}")
print(f"상세 블로그 정보: {results['total_blogs_visited']}")
# 이미지 다운로드 통계 출력
if 'image_download_info' in results:
dl_info = results['image_download_info']
print(f"\n=== 이미지 다운로드 통계 ===")
print(f"총 처리: {dl_info['total_images']}")
print(f"다운로드 성공: {dl_info['downloaded']}")
if 'statistics' in dl_info:
stats = dl_info['statistics']
print(f" - 신규 저장: {stats['success']}")
print(f" - 기존 파일: {stats['file_exists']}")
print(f" - 중복 제거: {stats['duplicate']}")
print(f" - 크기 필터링: {stats['size_filtered']}")
except Exception as e:
print(f"오류 발생: {e}")
import traceback
traceback.print_exc()
finally:
input("\n엔터를 누르면 브라우저를 종료합니다...")
crawler.close()
asyncio.run(playwright_test())
if __name__=="__main__":
with open(os.path.join(HOME_PATH, "success.txt"), "w") as fp:
json.dump("", fp)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,5 +1,8 @@
playwright
dotenv
selenium
requests
pillow
azure-identity
azure-mgmt-resource
azure-mgmt-compute

View File

@ -0,0 +1,51 @@
{
"crawler": {
"max_blogs": 2,
"default_search_query": "선돌막국수"
},
"image_filter": {
"min_width": 400,
"min_height": 400,
"min_file_size_kb": 10,
"max_file_size_mb": 10,
"require_both_dimensions": false,
"allowed_formats": [
".jpg",
".jpeg",
".png",
".webp"
]
},
"paths": {
"project_dir_linux": "/data/crawler"
},
"webdriver": {
"headless": false,
"window_size": "1920,1080",
"page_load_timeout": 20,
"implicit_wait": 5,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
"loading_delays": {
"initial_page_load": 3,
"after_search": 5,
"iframe_wait": 2,
"search_result_wait": 3,
"place_detail_wait": 3,
"dynamic_wait": {
"enabled": true,
"check_interval": 0.5,
"max_wait": 15,
"elements_to_check": [
".GHAhO",
".place_name",
"span.LDgIH"
]
}
},
"logging": {
"console_level": "INFO",
"file_level": "DEBUG",
"log_format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
}
}

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
설정 관리 도구
config.json 파일을 쉽게 편집할 있는 도구
"""
import json
from pathlib import Path
from logger_utils import config
def display_config():
"""현재 설정 표시"""
print("\n=== 현재 설정 ===")
current_config = config.get_all()
print(json.dumps(current_config, indent=2, ensure_ascii=False))
def edit_crawler_settings():
"""크롤러 설정 편집"""
print("\n=== 크롤러 설정 편집 ===")
# 현재 값 표시
max_blogs = config.get('crawler.max_blogs', 5)
default_query = config.get('crawler.default_search_query', '선돌막국수')
print(f"현재 설정:")
print(f" - 최대 블로그 수: {max_blogs}")
print(f" - 기본 검색어: {default_query}")
# 새 값 입력
new_max_blogs = input(f"\n새로운 최대 블로그 수 (현재: {max_blogs}, Enter로 유지): ").strip()
if new_max_blogs.isdigit():
config.update('crawler.max_blogs', int(new_max_blogs))
print(f"✓ 최대 블로그 수를 {new_max_blogs}로 변경했습니다.")
new_default_query = input(f"새로운 기본 검색어 (현재: {default_query}, Enter로 유지): ").strip()
if new_default_query:
config.update('crawler.default_search_query', new_default_query)
print(f"✓ 기본 검색어를 '{new_default_query}'로 변경했습니다.")
def edit_image_filter_settings():
"""이미지 필터 설정 편집"""
print("\n=== 이미지 필터 설정 편집 ===")
# 현재 값 표시
image_filter = config.get('image_filter', {})
print(f"현재 설정:")
print(f" - 최소 가로: {image_filter.get('min_width', 400)}px")
print(f" - 최소 세로: {image_filter.get('min_height', 400)}px")
print(f" - 최소 파일 크기: {image_filter.get('min_file_size_kb', 10)}KB")
print(f" - 최대 파일 크기: {image_filter.get('max_file_size_mb', 10)}MB")
print(f" - 크기 조건: {'AND' if image_filter.get('require_both_dimensions', False) else 'OR'}")
print(f" - 허용 포맷: {', '.join(image_filter.get('allowed_formats', []))}")
# 새 값 입력
new_min_width = input(f"\n새로운 최소 가로 (현재: {image_filter.get('min_width', 400)}, Enter로 유지): ").strip()
if new_min_width.isdigit():
config.update('image_filter.min_width', int(new_min_width))
print(f"✓ 최소 가로를 {new_min_width}px로 변경했습니다.")
new_min_height = input(f"새로운 최소 세로 (현재: {image_filter.get('min_height', 400)}, Enter로 유지): ").strip()
if new_min_height.isdigit():
config.update('image_filter.min_height', int(new_min_height))
print(f"✓ 최소 세로를 {new_min_height}px로 변경했습니다.")
new_min_size = input(f"새로운 최소 파일 크기 KB (현재: {image_filter.get('min_file_size_kb', 10)}, Enter로 유지): ").strip()
if new_min_size.isdigit():
config.update('image_filter.min_file_size_kb', int(new_min_size))
print(f"✓ 최소 파일 크기를 {new_min_size}KB로 변경했습니다.")
new_max_size = input(f"새로운 최대 파일 크기 MB (현재: {image_filter.get('max_file_size_mb', 10)}, Enter로 유지): ").strip()
if new_max_size.isdigit():
config.update('image_filter.max_file_size_mb', int(new_max_size))
print(f"✓ 최대 파일 크기를 {new_max_size}MB로 변경했습니다.")
both_dimensions = input("가로와 세로 모두 조건 만족 필요? (y/N): ").strip().lower()
if both_dimensions in ['y', 'n']:
config.update('image_filter.require_both_dimensions', both_dimensions == 'y')
print(f"✓ 크기 조건을 {'AND' if both_dimensions == 'y' else 'OR'}로 변경했습니다.")
def edit_webdriver_settings():
"""웹드라이버 설정 편집"""
print("\n=== 웹드라이버 설정 편집 ===")
# 현재 값 표시
webdriver = config.get('webdriver', {})
print(f"현재 설정:")
print(f" - Headless 모드: {webdriver.get('headless', False)}")
print(f" - 창 크기: {webdriver.get('window_size', '1920,1080')}")
print(f" - 페이지 로드 타임아웃: {webdriver.get('page_load_timeout', 30)}")
print(f" - Implicit Wait: {webdriver.get('implicit_wait', 10)}")
# Headless 모드
headless = input("\nHeadless 모드 사용? (y/N): ").strip().lower()
if headless in ['y', 'n']:
config.update('webdriver.headless', headless == 'y')
print(f"✓ Headless 모드를 {'활성화' if headless == 'y' else '비활성화'}했습니다.")
# 타임아웃 설정
new_timeout = input(f"새로운 페이지 로드 타임아웃 초 (현재: {webdriver.get('page_load_timeout', 30)}, Enter로 유지): ").strip()
if new_timeout.isdigit():
config.update('webdriver.page_load_timeout', int(new_timeout))
print(f"✓ 페이지 로드 타임아웃을 {new_timeout}초로 변경했습니다.")
def main():
"""메인 함수"""
print("=== 네이버 크롤러 설정 관리 도구 ===")
while True:
print("\n메뉴:")
print("1. 현재 설정 보기")
print("2. 크롤러 설정 편집")
print("3. 이미지 필터 설정 편집")
print("4. 웹드라이버 설정 편집")
print("5. 설정 저장 및 종료")
print("0. 저장하지 않고 종료")
choice = input("\n선택: ").strip()
if choice == '1':
display_config()
elif choice == '2':
edit_crawler_settings()
elif choice == '3':
edit_image_filter_settings()
elif choice == '4':
edit_webdriver_settings()
elif choice == '5':
config.save_config()
print("\n설정이 저장되었습니다.")
break
elif choice == '0':
print("\n설정을 저장하지 않고 종료합니다.")
break
else:
print("\n잘못된 선택입니다.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,315 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
로깅 유틸리티 모듈
네이버 크롤러에서 사용하는 공통 로깅 기능
"""
import os
import sys
import json
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from pathlib import Path
class Config:
"""설정 파일 관리 클래스"""
_instance = None
_config = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(Config, cls).__new__(cls)
return cls._instance
def __init__(self):
if self._config is None:
self.load_config()
def load_config(self, config_path: str = None):
"""설정 파일 로드"""
if config_path is None:
# 현재 스크립트 위치에서 config.json 찾기
current_dir = Path(__file__).parent
config_path = current_dir / 'config.json'
try:
with open(config_path, 'r', encoding='utf-8') as f:
self._config = json.load(f)
print(f"설정 파일 로드 완료: {config_path}")
except FileNotFoundError:
print(f"설정 파일을 찾을 수 없습니다: {config_path}")
print("기본 설정을 사용합니다.")
self._config = self._get_default_config()
except json.JSONDecodeError as e:
print(f"설정 파일 파싱 오류: {e}")
print("기본 설정을 사용합니다.")
self._config = self._get_default_config()
def _get_default_config(self) -> Dict[str, Any]:
"""기본 설정 반환"""
return {
"crawler": {
"max_blogs": 5,
"default_search_query": "선돌막국수"
},
"image_filter": {
"min_width": 400,
"min_height": 400,
"min_file_size_kb": 10,
"max_file_size_mb": 10,
"require_both_dimensions": False,
"allowed_formats": [".jpg", ".jpeg", ".png", ".webp"]
},
"paths": {
"project_dir_linux": "/data/crawling"
},
"webdriver": {
"headless": False,
"window_size": "1920,1080",
"page_load_timeout": 30,
"implicit_wait": 10
},
"logging": {
"console_level": "INFO",
"file_level": "DEBUG",
"log_format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
}
}
def get(self, key_path: str, default=None):
"""
표기법으로 중첩된 설정값 가져오기
: config.get('crawler.max_blogs')
"""
keys = key_path.split('.')
value = self._config
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return default
return value
def get_all(self) -> Dict[str, Any]:
"""전체 설정 반환"""
return self._config.copy()
def save_config(self, config_path: str = None):
"""설정 파일 저장"""
if config_path is None:
current_dir = Path(__file__).parent
config_path = current_dir / 'config.json'
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(self._config, f, ensure_ascii=False, indent=2)
print(f"설정 파일 저장 완료: {config_path}")
except Exception as e:
print(f"설정 파일 저장 실패: {e}")
def update(self, key_path: str, value: Any):
"""설정값 업데이트"""
keys = key_path.split('.')
config = self._config
for key in keys[:-1]:
if key not in config:
config[key] = {}
config = config[key]
config[keys[-1]] = value
# 전역 설정 인스턴스
config = Config()
def setup_logger(
name: str,
log_dir: str,
log_prefix: str = "log",
console_level: int = None,
file_level: int = None
) -> logging.Logger:
"""
로거 설정 함수
Args:
name: 로거 이름
log_dir: 로그 파일 저장 디렉토리
log_prefix: 로그 파일명 접두사
console_level: 콘솔 출력 로그 레벨 (None이면 설정에서 읽음)
file_level: 파일 출력 로그 레벨 (None이면 설정에서 읽음)
Returns:
설정된 로거 객체
"""
# 설정에서 로그 레벨 가져오기
if console_level is None:
console_level_str = config.get('logging.console_level', 'INFO')
console_level = getattr(logging, console_level_str)
if file_level is None:
file_level_str = config.get('logging.file_level', 'DEBUG')
file_level = getattr(logging, file_level_str)
# 로그 디렉토리 생성
os.makedirs(log_dir, exist_ok=True)
# 로그 포맷 설정
log_format = config.get('logging.log_format',
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# 로그 파일 경로
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = os.path.join(log_dir, f'{log_prefix}_{timestamp}.log')
# 로거 생성
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 기존 핸들러 제거 (중복 방지)
logger.handlers.clear()
# 파일 핸들러
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(file_level)
file_handler.setFormatter(logging.Formatter(log_format))
# 콘솔 핸들러
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(console_level)
console_handler.setFormatter(logging.Formatter(log_format))
# 핸들러 추가
logger.addHandler(file_handler)
logger.addHandler(console_handler)
logger.info(f"로거 '{name}' 초기화 완료")
logger.info(f"로그 파일: {log_file}")
return logger
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
class LoggerMixin:
def setup_logger(
self,
logger_name: Optional[str] = None,
log_dir: Optional[str] = None,
log_prefix: Optional[str] = None
):
"""
클래스용 로거 설정
Args:
logger_name: 로거 이름 (기본값: 클래스명)
log_dir: 로그 디렉토리 (기본값: self.project_dir/logs)
log_prefix: 로그 파일 접두사 (기본값: 클래스명)
"""
if logger_name is None:
logger_name = self.__class__.__name__
if log_dir is None:
log_dir = os.path.join(getattr(self, 'project_dir', '.'), 'logs')
if log_prefix is None:
log_prefix = self.__class__.__name__.lower()
self.logger = setup_logger(
name=logger_name,
log_dir=log_dir,
log_prefix=log_prefix
)
def log_debug(self, message: str):
"""디버그 로그"""
if hasattr(self, 'logger'):
self.logger.debug(message)
def log_info(self, message: str):
"""정보 로그"""
if hasattr(self, 'logger'):
self.logger.info(message)
def log_warning(self, message: str):
"""경고 로그"""
if hasattr(self, 'logger'):
self.logger.warning(message)
def log_error(self, message: str, exc_info: bool = False):
"""에러 로그"""
if hasattr(self, 'logger'):
self.logger.error(message, exc_info=exc_info)
def log_critical(self, message: str):
"""치명적 에러 로그"""
if hasattr(self, 'logger'):
self.logger.critical(message)
def log_execution_time(logger: logging.Logger = None):
"""
함수 실행 시간을 로깅하는 데코레이터
Args:
logger: 사용할 로거 (없으면 함수명으로 새로 생성)
"""
import functools
import time
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__name__)
start_time = time.time()
logger.debug(f"{func.__name__} 시작")
try:
result = func(*args, **kwargs)
elapsed = time.time() - start_time
logger.info(f"{func.__name__} 완료 (실행시간: {elapsed:.2f}초)")
return result
except Exception as e:
elapsed = time.time() - start_time
logger.error(f"{func.__name__} 실패 (실행시간: {elapsed:.2f}초): {str(e)}")
raise
return wrapper
return decorator
def log_exception(logger: logging.Logger = None):
"""
예외를 로깅하는 데코레이터
Args:
logger: 사용할 로거
"""
import functools
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__name__)
try:
return func(*args, **kwargs)
except Exception as e:
logger.error(f"{func.__name__}에서 예외 발생: {str(e)}", exc_info=True)
raise
return wrapper
return decorator

View File

@ -0,0 +1,576 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
네이버 블로그 크롤러 모듈
블로그 콘텐츠 수집 이미지 다운로드 기능
"""
import os
import time
import re
import json
import requests
import hashlib
from datetime import datetime
from typing import List, Dict, Optional, Tuple, Set
from urllib.parse import urlparse, parse_qs, urlunparse, urlencode
from dataclasses import dataclass
from PIL import Image
from io import BytesIO
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from .logger_utils import LoggerMixin, log_execution_time, log_exception
@dataclass
class ImageFilterConfig:
"""이미지 필터링 설정"""
min_width: int = 400
min_height: int = 400
min_file_size_kb: int = 10 # 최소 파일 크기 (KB)
max_file_size_mb: int = 10 # 최대 파일 크기 (MB)
allowed_formats: Set[str] = None
require_both_dimensions: bool = False # True면 width AND height 모두 조건 만족, False면 OR
def __post_init__(self):
if self.allowed_formats is None:
self.allowed_formats = {'.jpg', '.jpeg', '.png', '.webp'}
def clean_image_url(url: str) -> str:
"""
이미지 URL 정리 (불필요한 파라미터 제거)
Args:
url: 원본 URL
Returns:
정리된 URL
"""
# autoRotate=true&type=w800& 제거
url = url.replace("autoRotate=true&type=w800&", "")
url = url.replace("autoRotate=true&", "")
url = url.replace("type=w800&", "")
# URL 파싱하여 파라미터 정리
try:
parsed = urlparse(url)
params = parse_qs(parsed.query)
# 불필요한 파라미터 제거
params.pop('autoRotate', None)
# type 파라미터 처리
if 'type' in params:
# 작은 사이즈면 큰 사이즈로 변경
if params['type'][0] in ['f', 'f120', 'f240', 'w240', 'w500']:
params['type'] = ['w800']
# 정리된 URL 재구성
new_query = urlencode(params, doseq=True)
url = urlunparse((
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
new_query,
parsed.fragment
))
except:
pass
return url
class NaverBlogCrawler(LoggerMixin):
"""네이버 블로그 크롤러"""
def __init__(self, driver: webdriver.Chrome, wait: WebDriverWait, project_dir: str,
image_filter: ImageFilterConfig = None):
"""
초기화
Args:
driver: 웹드라이버
wait: WebDriverWait 객체
project_dir: 프로젝트 디렉토리
image_filter: 이미지 필터 설정
"""
self.driver = driver
self.wait = wait
self.project_dir = project_dir
self.image_filter = image_filter or ImageFilterConfig()
# 중복 이미지 방지를 위한 해시 추적
self.downloaded_hashes: Set[str] = set()
self.image_metadata: List[Dict] = []
# 로거 설정
self.setup_logger(log_prefix='blog_crawler')
@log_execution_time()
def visit_and_extract_blog(self, url: str, title: str, index: int) -> Optional[Dict]:
"""
블로그 방문 내용 추출
Args:
url: 블로그 URL
title: 블로그 제목
index: 인덱스
Returns:
블로그 정보 딕셔너리
"""
blog_info = {
'index': index,
'title': title,
'blog_title': '',
'url': url,
'content': '',
'images': [],
'extracted_at': datetime.now().isoformat(),
'is_ad': False,
'ad_phrases': []
}
try:
# 새 탭 열기
original_window = self.driver.current_window_handle
self.driver.execute_script("window.open('');")
self.driver.switch_to.window(self.driver.window_handles[-1])
# 블로그 페이지 로드
self.driver.get(url)
time.sleep(3)
# 페이지 타이틀 추출
try:
blog_info['blog_title'] = self.driver.title
except:
blog_info['blog_title'] = "타이틀 없음"
# 네이버 블로그 iframe 처리
if "blog.naver.com" in url:
try:
self.driver.switch_to.frame("mainFrame")
self.log_debug("네이버 블로그 mainFrame 전환 성공")
except:
pass
# 본문 추출
content_text = self._extract_blog_content()
if content_text:
blog_info['content'] = content_text[:2000]
# 광고성 문구 검색
ad_keywords = ['광고', '협찬', '제공받', '초대받', '스폰서', '파트너스', '수수료', '원고료']
for keyword in ad_keywords:
if keyword in content_text:
blog_info['is_ad'] = True
idx = content_text.find(keyword)
start = max(0, idx - 30)
end = min(len(content_text), idx + 30)
blog_info['ad_phrases'].append(content_text[start:end].strip())
# 이미지 URL 수집
blog_info['images'] = self._collect_blog_images()
# 스크린샷 저장
self._save_screenshot(f"blog_{index}")
except Exception as e:
self.log_error(f"블로그 내용 추출 중 오류: {e}")
finally:
# 탭 닫고 원래 창으로 돌아가기
self.driver.close()
self.driver.switch_to.window(original_window)
return blog_info
def _extract_blog_content(self) -> str:
"""블로그 본문 추출"""
content_selectors = [
".se-main-container",
"div.se-component",
"div[class*='postViewArea']",
"#postViewArea",
".post-view",
".article-view",
".entry-content",
"article",
"main",
"[role='article']",
".content",
"#content"
]
content_text = ""
for selector in content_selectors:
try:
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
for elem in elements:
text = elem.text.strip()
if len(text) > 100:
content_text += text + "\n\n"
if len(content_text) > 2000:
break
except:
continue
return content_text
def _collect_blog_images(self) -> List[str]:
"""블로그 내 이미지 URL 수집"""
image_urls = []
try:
img_selectors = [
"img[src*='blogfiles']",
"img[src*='postfiles']",
"img[src*='phinf']",
".se-image img",
".post-view img",
"article img",
".content img"
]
seen_urls = set()
for selector in img_selectors:
images = self.driver.find_elements(By.CSS_SELECTOR, selector)
for img in images:
try:
src = img.get_attribute("src")
if (src and src.startswith("http") and
src not in seen_urls and
not any(skip in src.lower() for skip in ['icon', 'emoji', 'button', 'logo'])):
original_url = clean_image_url(src)
seen_urls.add(original_url)
image_urls.append(original_url)
if len(image_urls) >= 10:
break
except:
continue
self.log_debug(f"블로그에서 {len(image_urls)}개 이미지 URL 수집")
except Exception as e:
self.log_error(f"이미지 수집 중 오류: {e}")
return image_urls
@log_execution_time()
def download_all_images(self, results: Dict) -> Dict:
"""
모든 이미지 다운로드
Args:
results: 크롤링 결과 딕셔너리
Returns:
다운로드 정보 딕셔너리
"""
self.log_info("\n=== 이미지 다운로드 시작 ===")
# 타임스탬프로 폴더 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
download_dir = os.path.join(self.project_dir, 'images', f"{results['query']}_{timestamp}")
os.makedirs(download_dir, exist_ok=True)
all_urls = []
# 사진 탭에서 수집한 URL
photo_urls = [(url, f"photo_{i+1}") for i, url in enumerate(results.get('photo_urls', []))]
all_urls.extend(photo_urls)
# 블로그에서 수집한 URL
for blog_idx, blog in enumerate(results.get('detailed_blogs', [])):
for img_idx, url in enumerate(blog.get('images', [])):
all_urls.append((url, f"blog{blog_idx+1}_img{img_idx+1}"))
# 중복 제거
unique_urls = {}
for url, prefix in all_urls:
cleaned_url = clean_image_url(url)
if cleaned_url not in unique_urls:
unique_urls[cleaned_url] = prefix
self.log_info(f"{len(unique_urls)}개의 고유 이미지 다운로드 예정")
self.log_info(f"필터 조건: {self._get_filter_description()}")
# 통계 추적
stats = {
'success': 0,
'duplicate': 0,
'size_filtered': 0,
'file_size_filtered': 0,
'format_filtered': 0,
'network_error': 0,
'other_error': 0,
'file_exists': 0
}
# 다운로드 실행
for url, prefix in unique_urls.items():
success, reason = self._download_image(url, download_dir, prefix, stats['success'] + 1)
# 통계 업데이트
if success:
if reason == "파일존재":
stats['file_exists'] += 1
else:
stats['success'] += 1
else:
if "중복" in reason:
stats['duplicate'] += 1
elif "크기부족" in reason:
stats['size_filtered'] += 1
elif "파일크기" in reason:
stats['file_size_filtered'] += 1
elif "포맷" in reason:
stats['format_filtered'] += 1
elif "네트워크" in reason:
stats['network_error'] += 1
else:
stats['other_error'] += 1
# 메타데이터 저장
self._save_image_metadata(download_dir)
# 통계 로깅
total_saved = stats['success'] + stats['file_exists']
total_filtered = stats['duplicate'] + stats['size_filtered'] + stats['file_size_filtered'] + stats['format_filtered']
self.log_info(f"✓ 이미지 다운로드 완료:")
self.log_info(f" 저장: {total_saved}개 (신규: {stats['success']}개, 기존: {stats['file_exists']}개)")
self.log_info(f" 필터링: {total_filtered}")
self.log_info(f"저장 위치: {download_dir}")
# 다운로드 정보 반환
return {
'download_dir': download_dir,
'total_images': len(unique_urls),
'downloaded': total_saved,
'statistics': stats,
'filter_config': {
'min_width': self.image_filter.min_width,
'min_height': self.image_filter.min_height,
'min_file_size_kb': self.image_filter.min_file_size_kb,
'max_file_size_mb': self.image_filter.max_file_size_mb,
'allowed_formats': list(self.image_filter.allowed_formats),
'require_both_dimensions': self.image_filter.require_both_dimensions
},
'timestamp': timestamp
}
def _download_image(self, url: str, save_dir: str, prefix: str, index: int) -> Tuple[bool, str]:
"""개별 이미지 다운로드"""
try:
# 파일 확장자 추출
parsed_url = urlparse(url)
path = parsed_url.path
ext = os.path.splitext(path)[1].lower()
# 확장자가 없거나 이상한 경우 기본값
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
ext = '.jpg'
# 파일명 생성
filename = f"{prefix}_{index:03d}{ext}"
filepath = os.path.join(save_dir, filename)
# 이미 존재하면 스킵
if os.path.exists(filepath):
self.log_debug(f"이미 존재: {filename}")
return True, "파일존재"
# 다운로드
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://map.naver.com/'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# 이미지 해시 계산 (중복 체크용)
image_hash = self._calculate_image_hash(response.content)
# 중복 이미지 체크
if self._is_duplicate_image(image_hash):
self.log_debug(f"중복 이미지 스킵: {filename}")
return False, "중복"
# 고급 이미지 필터링
is_valid, width, height, reason = self._check_image_advanced(response.content)
if not is_valid:
self.log_debug(f"필터링 제외: {filename} - {reason}")
return False, reason
# 이미지 저장
with open(filepath, 'wb') as f:
f.write(response.content)
# 중복 방지를 위해 해시 추가
self.downloaded_hashes.add(image_hash)
# 메타데이터 기록
self._add_image_metadata(
filename=filename,
url=url,
width=width,
height=height,
file_size=len(response.content),
image_hash=image_hash,
download_time=datetime.now().isoformat()
)
self.log_debug(f"다운로드 성공: {filename} ({width}x{height}, {len(response.content)/1024:.1f}KB)")
return True, "성공"
except requests.exceptions.RequestException as e:
self.log_warning(f"다운로드 실패: {url[:50]}... - {str(e)}")
return False, f"네트워크오류: {str(e)}"
except Exception as e:
self.log_error(f"이미지 저장 중 오류: {str(e)}")
return False, f"저장오류: {str(e)}"
def _check_image_advanced(self, image_data: bytes) -> Tuple[bool, int, int, str]:
"""고급 이미지 필터링 검사"""
try:
with Image.open(BytesIO(image_data)) as img:
width, height = img.size
format_ext = f".{img.format.lower()}" if img.format else ".unknown"
# 파일 크기 체크 (KB)
file_size_kb = len(image_data) / 1024
file_size_mb = file_size_kb / 1024
# 크기 조건 체크
if self.image_filter.require_both_dimensions:
size_ok = width >= self.image_filter.min_width and height >= self.image_filter.min_height
else:
size_ok = width >= self.image_filter.min_width or height >= self.image_filter.min_height
# 파일 크기 조건 체크
file_size_ok = (self.image_filter.min_file_size_kb <= file_size_kb <=
self.image_filter.max_file_size_mb * 1024)
# 포맷 조건 체크
format_ok = format_ext in self.image_filter.allowed_formats
is_valid = size_ok and file_size_ok and format_ok
# 실패 이유 반환
reasons = []
if not size_ok:
reasons.append(f"크기부족: {width}x{height}")
if not file_size_ok:
reasons.append(f"파일크기: {file_size_kb:.1f}KB")
if not format_ok:
reasons.append(f"포맷: {format_ext}")
failure_reason = ", ".join(reasons) if reasons else "통과"
return is_valid, width, height, failure_reason
except Exception as e:
self.log_debug(f"이미지 분석 실패: {e}")
return False, 0, 0, f"분석실패: {str(e)}"
def _calculate_image_hash(self, image_data: bytes) -> str:
"""이미지 데이터의 MD5 해시값 계산"""
return hashlib.md5(image_data).hexdigest()
def _is_duplicate_image(self, image_hash: str) -> bool:
"""중복 이미지인지 확인"""
return image_hash in self.downloaded_hashes
def _add_image_metadata(self, filename: str, url: str, width: int, height: int,
file_size: int, image_hash: str, download_time: str):
"""이미지 메타데이터 추가"""
metadata = {
'filename': filename,
'original_url': url,
'width': width,
'height': height,
'file_size_bytes': file_size,
'file_size_kb': round(file_size / 1024, 2),
'hash': image_hash,
'download_time': download_time,
'format': os.path.splitext(filename)[1].lower()
}
self.image_metadata.append(metadata)
def _save_image_metadata(self, download_dir: str):
"""이미지 메타데이터를 JSON 파일로 저장"""
if not self.image_metadata:
return
metadata_file = os.path.join(download_dir, 'image_metadata.json')
metadata_summary = {
'collection_info': {
'total_images': len(self.image_metadata),
'collection_time': datetime.now().isoformat(),
'filter_config': {
'min_width': self.image_filter.min_width,
'min_height': self.image_filter.min_height,
'min_file_size_kb': self.image_filter.min_file_size_kb,
'max_file_size_mb': self.image_filter.max_file_size_mb,
'allowed_formats': list(self.image_filter.allowed_formats),
'require_both_dimensions': self.image_filter.require_both_dimensions
}
},
'statistics': {
'total_file_size_mb': round(sum(img['file_size_bytes'] for img in self.image_metadata) / 1024 / 1024, 2),
'average_width': round(sum(img['width'] for img in self.image_metadata) / len(self.image_metadata), 1),
'average_height': round(sum(img['height'] for img in self.image_metadata) / len(self.image_metadata), 1),
'format_distribution': self._get_format_distribution()
},
'images': self.image_metadata
}
with open(metadata_file, 'w', encoding='utf-8') as f:
json.dump(metadata_summary, f, ensure_ascii=False, indent=2)
self.log_info(f"메타데이터 저장: {metadata_file}")
def _get_format_distribution(self) -> Dict[str, int]:
"""이미지 포맷 분포 계산"""
formats = {}
for img in self.image_metadata:
fmt = img['format']
formats[fmt] = formats.get(fmt, 0) + 1
return formats
def _get_filter_description(self) -> str:
"""필터 설정 설명 반환"""
conditions = []
if self.image_filter.require_both_dimensions:
conditions.append(f"크기 {self.image_filter.min_width}x{self.image_filter.min_height} 이상(AND)")
else:
conditions.append(f"가로 {self.image_filter.min_width}px 또는 세로 {self.image_filter.min_height}px 이상(OR)")
conditions.append(f"파일크기 {self.image_filter.min_file_size_kb}KB~{self.image_filter.max_file_size_mb}MB")
conditions.append(f"포맷 {', '.join(sorted(self.image_filter.allowed_formats))}")
return " | ".join(conditions)
def _save_screenshot(self, name: str):
"""스크린샷 저장"""
try:
filename = f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
filepath = os.path.join(self.project_dir, 'debug', filename)
self.driver.save_screenshot(filepath)
self.log_debug(f"스크린샷 저장: {filename}")
except:
pass

View File

@ -0,0 +1,310 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
네이버 지도 + 블로그 통합 크롤러 (리팩토링 버전)
메인 실행 파일
"""
import os
import sys
import platform
import json
import time
from datetime import datetime
from typing import Dict
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from .logger_utils import setup_logger, config
from .naver_map_crawler import NaverMapCrawler
from .naver_blog_crawler import NaverBlogCrawler, ImageFilterConfig
class NaverIntegratedCrawler:
def __init__(self, project_dir: str = r'/home/jhyeu/workspace/data',
image_filter: ImageFilterConfig = None):
"""
초기화
Args:
project_dir: 프로젝트 디렉토리
image_filter: 이미지 필터 설정
"""
self.project_dir = project_dir
self.image_filter = image_filter or ImageFilterConfig()
# 디렉토리 생성
os.makedirs(os.path.join(self.project_dir, 'debug'), exist_ok=True)
os.makedirs(os.path.join(self.project_dir, 'data'), exist_ok=True)
os.makedirs(os.path.join(self.project_dir, 'logs'), exist_ok=True)
os.makedirs(os.path.join(self.project_dir, 'images'), exist_ok=True)
# 로거 설정
self.logger = setup_logger(
'NaverIntegratedCrawler',
os.path.join(self.project_dir, 'logs'),
'integrated_crawler'
)
self.driver = None
self.wait = None
self.map_crawler = None
self.blog_crawler = None
def setup_driver(self):
"""웹드라이버 설정"""
self.logger.info("웹드라이버 설정 시작...")
# 설정 파일에서 웹드라이버 설정 읽기
webdriver_config = config.get('webdriver', {})
chrome_options = Options()
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 추가 안정성 옵션들
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument(f'--window-size={webdriver_config.get("window_size", "1920,1080")}')
chrome_options.add_argument('--disable-web-security')
chrome_options.add_argument('--allow-running-insecure-content')
# User-Agent 설정
user_agent = webdriver_config.get('user_agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
chrome_options.add_argument(f'--user-agent={user_agent}')
# 디버깅을 위해 headless 모드 설정
headless = webdriver_config.get('headless', False)
if headless or (platform.system() == 'Linux' and not os.environ.get('DISPLAY')):
chrome_options.add_argument('--headless')
self.logger.info(" - Headless 모드로 실행")
else:
self.logger.info(" - GUI 모드로 실행")
self.driver = webdriver.Chrome(options=chrome_options)
# 페이지 로드 타임아웃 설정
page_load_timeout = webdriver_config.get('page_load_timeout', 30)
implicit_wait = webdriver_config.get('implicit_wait', 10)
self.driver.set_page_load_timeout(page_load_timeout)
self.driver.implicitly_wait(implicit_wait)
self.wait = WebDriverWait(self.driver, 30) # 20 -> 30초로 증가
#self.driver.maximize_window()
# 자동화 감지 방지
self.driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
# 크롤러 인스턴스 생성
self.map_crawler = NaverMapCrawler(self.driver, self.wait, self.project_dir)
self.blog_crawler = NaverBlogCrawler(self.driver, self.wait, self.project_dir, self.image_filter)
self.logger.info(" ✓ 웹드라이버 초기화 완료\n")
def collect_all_info(self, query: str = "선돌막국수", max_blogs: int = 5) -> Dict:
"""
지도 정보와 블로그 정보 통합 수집
Args:
query: 검색어
max_blogs: 최대 블로그 수집 개수
Returns:
수집 결과 딕셔너리
"""
self.logger.info(f"=== {query} 통합 정보 수집 시작 ===\n")
results = {
'query': query,
'collection_time': datetime.now().isoformat(),
'place_info': {},
'map_blog_reviews': [],
'detailed_blogs': [],
'photo_urls': [],
'total_blogs_visited': 0
}
try:
# 1. 네이버 지도 검색
if not self.map_crawler.search_place(query):
self.logger.error("장소 검색 실패")
return results
# 2. 지도에서 기본 정보 수집
place_info = self.map_crawler.collect_place_info()
if place_info:
results['place_info'] = place_info
self.logger.info(f"✓ 장소 기본 정보 수집 완료: {place_info.get('name', 'N/A')}")
# 3. 사진 수집
self.logger.info("\n=== 사진 수집 시작 ===")
photo_urls = self.map_crawler.collect_photo_urls()
results['photo_urls'] = photo_urls
self.logger.info(f"✓ 사진 {len(photo_urls)}개 수집 완료\n")
# 4. 지도의 블로그 리뷰 수집
map_reviews = self.map_crawler.collect_map_blog_reviews()
results['map_blog_reviews'] = map_reviews
self.logger.info(f"✓ 지도에서 블로그 리뷰 {len(map_reviews)}개 수집\n")
# 5. 블로그 상세 정보 수집
blog_links = self.map_crawler.collect_blog_links(max_blogs)
if blog_links:
self.logger.info(f"{len(blog_links)}개의 블로그 링크 수집 완료\n")
# 각 블로그 방문
for idx, (title, url) in enumerate(blog_links):
self.logger.info(f"\n[{idx+1}/{len(blog_links)}] 블로그 방문 중...")
self.logger.info(f"제목: {title}")
self.logger.info(f"URL: {url[:80]}...")
blog_content = self.blog_crawler.visit_and_extract_blog(url, title, idx+1)
if blog_content:
results['detailed_blogs'].append(blog_content)
results['total_blogs_visited'] += 1
self.logger.info(f"✓ 내용 수집 완료 (사진 {len(blog_content['images'])}개 포함)")
# 다음 블로그를 위해 목록으로 돌아가기
if idx < len(blog_links) - 1:
self.map_crawler.return_to_blog_list()
time.sleep(1)
except Exception as e:
self.logger.error(f"\n오류 발생: {e}", exc_info=True)
# 결과 저장
self._save_integrated_results(results)
return results
def _save_integrated_results(self, results: Dict):
"""통합 결과 저장"""
# JSON 저장
json_file = os.path.join(self.project_dir, 'data',
f'integrated_result_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json')
with open(json_file, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
# 이미지 다운로드
download_info = self.blog_crawler.download_all_images(results)
results['image_download_info'] = download_info
# 텍스트 보고서 작성
self._generate_report(results)
self.logger.info(f"\n=== 수집 완료 ===")
self.logger.info(f"JSON 파일: {json_file}")
def _generate_report(self, results: Dict):
"""텍스트 보고서 생성"""
report_file = os.path.join(self.project_dir, 'data',
f'integrated_report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.txt')
with open(report_file, 'w', encoding='utf-8') as f:
f.write(f"=== {results['query']} 통합 수집 결과 ===\n\n")
f.write(f"수집 일시: {results['collection_time']}\n\n")
# 장소 정보
f.write("=== 장소 기본 정보 ===\n")
place = results['place_info']
if place:
f.write(f"상호명: {place.get('name', 'N/A')}\n")
f.write(f"주소: {place.get('address', 'N/A')}\n")
f.write(f"전화번호: {place.get('tel', 'N/A')}\n")
f.write(f"카테고리: {place.get('category', 'N/A')}\n")
f.write(f"영업시간: {place.get('business_hours', 'N/A')}\n")
f.write(f"홈페이지: {place.get('homepage', 'N/A')}\n")
f.write(f"설명: {place.get('description', 'N/A')}\n")
# 사진 정보
f.write(f"\n=== 사진 정보 ===\n")
f.write(f"수집한 사진 URL 수: {len(results['photo_urls'])}\n")
for i, url in enumerate(results['photo_urls'][:5], 1):
f.write(f"{i}. {url}\n")
if len(results['photo_urls']) > 5:
f.write(f"... 외 {len(results['photo_urls'])-5}\n")
# 지도 블로그 리뷰
f.write(f"\n=== 지도 블로그 리뷰 ===\n")
f.write(f"리뷰 수: {len(results['map_blog_reviews'])}\n")
ad_count = sum(1 for r in results['map_blog_reviews'] if r['is_ad'])
f.write(f"광고성 리뷰: {ad_count}\n\n")
for i, review in enumerate(results['map_blog_reviews'][:5], 1):
f.write(f"{i}. {review['title']}\n")
if review['is_ad']:
f.write(f" [광고] {', '.join(review['ad_phrases'])}\n")
f.write(f" 날짜: {review.get('date', 'N/A')}\n")
# 상세 블로그 정보
f.write(f"\n=== 상세 블로그 정보 ===\n")
f.write(f"방문한 블로그 수: {results['total_blogs_visited']}\n\n")
total_blog_images = 0
for blog in results['detailed_blogs']:
f.write(f"\n[{blog['index']}번째 블로그]\n")
f.write(f"제목: {blog['title']}\n")
f.write(f"블로그 타이틀: {blog['blog_title']}\n")
f.write(f"URL: {blog['url']}\n")
if blog['is_ad']:
f.write(f"광고성 포스트: YES\n")
f.write(f"광고 문구: {', '.join(blog['ad_phrases'][:2])}\n")
f.write(f"블로그 내 이미지: {len(blog['images'])}\n")
if blog['images']:
f.write("이미지 URL:\n")
for i, img_url in enumerate(blog['images'][:3], 1):
f.write(f" {i}. {img_url}\n")
if len(blog['images']) > 3:
f.write(f" ... 외 {len(blog['images'])-3}\n")
f.write(f"내용 (일부):\n{blog['content'][:300]}...\n")
f.write("-" * 80 + "\n")
total_blog_images += len(blog['images'])
# 통계
f.write(f"\n=== 수집 통계 ===\n")
f.write(f"총 수집된 이미지 URL: {len(results['photo_urls']) + total_blog_images}\n")
f.write(f" - 사진탭: {len(results['photo_urls'])}\n")
f.write(f" - 블로그: {total_blog_images}\n")
# 이미지 다운로드 정보
if 'image_download_info' in results:
dl_info = results['image_download_info']
f.write(f"\n=== 이미지 다운로드 ===\n")
f.write(f"다운로드 디렉토리: {dl_info.get('download_dir', 'N/A')}\n")
f.write(f"다운로드 성공: {dl_info.get('downloaded', 0)}\n")
f.write(f"전체 처리: {dl_info.get('total_images', 0)}\n")
if 'statistics' in dl_info:
stats = dl_info['statistics']
f.write(f"\n상세 통계:\n")
f.write(f" - 신규 저장: {stats.get('success', 0)}\n")
f.write(f" - 기존 파일: {stats.get('file_exists', 0)}\n")
f.write(f" - 중복 제거: {stats.get('duplicate', 0)}\n")
f.write(f" - 크기 필터링: {stats.get('size_filtered', 0)}\n")
f.write(f" - 파일크기 필터링: {stats.get('file_size_filtered', 0)}\n")
f.write(f" - 포맷 필터링: {stats.get('format_filtered', 0)}\n")
# 광고성 포스트 통계
ad_blogs = sum(1 for b in results['detailed_blogs'] if b['is_ad'])
f.write(f"\n광고성 포스트: {ad_blogs}/{len(results['detailed_blogs'])}")
if results['detailed_blogs']:
f.write(f"({ad_blogs/len(results['detailed_blogs'])*100:.1f}%)\n")
self.logger.info(f"보고서 저장: {report_file}")
def close(self):
"""리소스 정리"""
if self.driver:
self.driver.quit()
self.logger.info("웹드라이버 종료")

View File

@ -0,0 +1,573 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
네이버 지도 크롤러 모듈
지도 검색 장소 정보 수집 기능
"""
import os
import time
import re
from datetime import datetime
from typing import List, Dict, Optional
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from .logger_utils import LoggerMixin, log_execution_time, log_exception, config
class NaverMapCrawler(LoggerMixin):
"""네이버 지도 크롤러"""
def __init__(self, driver: webdriver.Chrome, wait: WebDriverWait, project_dir: str):
"""
초기화
Args:
driver: 웹드라이버
wait: WebDriverWait 객체
project_dir: 프로젝트 디렉토리
"""
self.driver = driver
self.wait = wait
self.project_dir = project_dir
self.current_frame = None
# 로거 설정
self.setup_logger(log_prefix='map_crawler')
@log_execution_time()
def search_place(self, query: str) -> bool:
"""
장소 검색
Args:
query: 검색어
Returns:
성공 여부
"""
try:
# 로딩 지연 시간 설정 읽기
loading_delays = config.get('loading_delays', {})
self.log_info(f"네이버 지도 접속 및 '{query}' 검색 시작...")
self.driver.get("https://map.naver.com/")
# 초기 페이지 로드 대기 시간 증가
initial_wait = loading_delays.get('initial_page_load', 10)
self.log_info(f"페이지 로드 대기: {initial_wait}")
time.sleep(initial_wait)
self._save_screenshot("01_main_page")
# 검색창 찾기 (재시도 로직 추가)
search_box = None
for attempt in range(3):
search_box = self._find_search_box()
if search_box:
break
self.log_warning(f"검색창 찾기 시도 {attempt + 1}/3 실패, 재시도...")
time.sleep(3)
if not search_box:
self.log_error("검색창을 찾을 수 없습니다")
return False
# 검색 수행
self.log_info(f"검색어 입력: {query}")
search_box.clear()
time.sleep(1)
search_box.send_keys(query)
time.sleep(1)
search_box.send_keys(Keys.RETURN)
# 검색 후 대기 시간 증가
after_search_wait = loading_delays.get('after_search', 15)
self.log_info(f"검색 결과 로드 대기: {after_search_wait}")
time.sleep(after_search_wait)
self._save_screenshot("02_search_result")
# searchIframe 로드 대기 (시간 증가)
try:
iframe_wait = loading_delays.get('iframe_wait', 5)
self.wait.until(EC.presence_of_element_located((By.ID, "searchIframe")))
self.log_info("searchIframe 로드 확인")
time.sleep(iframe_wait)
except:
self.log_warning("searchIframe을 찾을 수 없음")
# 검색 결과 클릭
self.log_info("검색 결과 클릭 시도...")
result_wait = loading_delays.get('search_result_wait', 10)
time.sleep(result_wait)
if self._click_search_result():
# entryIframe이 나타날 때까지 대기 (최대 10초)
try:
self.driver.switch_to.default_content()
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, "entryIframe"))
)
self.log_info("entryIframe 로드 확인")
# iframe으로 전환하고 장소명 요소 대기
self.driver.switch_to.frame("entryIframe")
WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".GHAhO, .place_name, h2.place_name"))
)
self.driver.switch_to.default_content()
self.log_info("장소 상세 페이지 로드 완료")
except TimeoutException:
self.log_warning("장소 상세 페이지 로드 타임아웃")
self._save_screenshot("03_place_detail")
return True
else:
self.log_error("검색 결과 클릭 실패")
return False
except Exception as e:
self.log_error(f"검색 중 오류: {e}", exc_info=True)
return False
@log_exception()
def collect_place_info(self) -> Dict:
"""
장소 기본 정보 수집
Returns:
장소 정보 딕셔너리
"""
place_info = {
'name': '',
'address': '',
'tel': '',
'category': '',
'business_hours': '',
'homepage': '',
'description': '',
'collected_at': datetime.now().isoformat()
}
try:
# entryIframe으로 전환
self._ensure_correct_frame("entryIframe")
# 동적 대기로 변경됨
# 정보 수집 매핑
info_mappings = {
'name': [".GHAhO", ".place_name", "h2.place_name", "._3XamX"],
'address': ["span.LDgIH", ".address", "._2yqUQ", "span[class*='addr']"],
'category': [".DJJvD", ".category", "._3ocDE"]
}
# 각 정보 수집
for field, selectors in info_mappings.items():
for selector in selectors:
try:
elem = self.driver.find_element(By.CSS_SELECTOR, selector)
place_info[field] = elem.text.strip()
if place_info[field]:
break
except:
continue
# 전화번호 수집 (특별 처리)
place_info['tel'] = self._extract_phone_number()
# 영업시간
try:
hours_elem = self.driver.find_element(By.CSS_SELECTOR, "._1APRQ")
place_info['business_hours'] = hours_elem.text.strip()
except:
pass
# 홈페이지
try:
homepage_elem = self.driver.find_element(By.CSS_SELECTOR, "a[class*='homepage']")
place_info['homepage'] = homepage_elem.get_attribute('href')
except:
pass
# 설명
try:
desc_elem = self.driver.find_element(By.CSS_SELECTOR, ".T8RMe")
place_info['description'] = desc_elem.text.strip()
except:
pass
self.log_info(f"장소 정보 수집 완료: {place_info['name']} - {place_info['address']}")
except Exception as e:
self.log_error(f"장소 정보 수집 중 오류: {e}")
return place_info
def collect_photo_urls(self) -> List[str]:
"""
사진 탭에서 사진 URL 수집
Returns:
사진 URL 리스트
"""
photo_urls = []
try:
self._ensure_correct_frame("entryIframe")
# 사진 탭 클릭
if not self._click_tab("사진"):
self.log_warning("사진 탭을 찾을 수 없습니다")
return photo_urls
time.sleep(3)
# 사진 URL 수집
photo_selectors = [
"img[class*='photo']",
"img[class*='thumb']",
"img[src*='phinf']",
"img[src*='blogfiles']"
]
all_images = []
for selector in photo_selectors:
images = self.driver.find_elements(By.CSS_SELECTOR, selector)
all_images.extend(images)
seen_urls = set()
for img in all_images[:20]:
try:
src = img.get_attribute("src") or img.get_attribute("data-src")
if src and src.startswith("http") and src not in seen_urls:
from naver_blog_crawler import clean_image_url
original_url = clean_image_url(src)
seen_urls.add(original_url)
photo_urls.append(original_url)
except:
continue
self.log_info(f"사진 탭에서 {len(photo_urls)}개 URL 수집")
except Exception as e:
self.log_error(f"사진 수집 중 오류: {e}")
return photo_urls
def collect_map_blog_reviews(self) -> List[Dict]:
"""
지도에서 블로그 리뷰 정보 수집
Returns:
블로그 리뷰 리스트
"""
reviews = []
try:
self._ensure_correct_frame("entryIframe")
# 블로그 탭 클릭
if not self._click_tab("블로그"):
self.log_warning("블로그 탭을 찾을 수 없습니다")
return reviews
time.sleep(3)
# 블로그 리뷰 요소 찾기
review_elems = self.driver.find_elements(By.CSS_SELECTOR, "li._2kAri")
for idx, elem in enumerate(review_elems[:10]):
try:
review_text = elem.text.strip()
lines = review_text.split('\n')
review_info = {
'title': lines[0] if lines else '',
'preview': ' '.join(lines[1:3]) if len(lines) > 1 else '',
'date': '',
'is_ad': False,
'ad_phrases': []
}
# 날짜 찾기
date_match = re.search(r'(\d{4}\.\d{1,2}\.\d{1,2})', review_text)
if date_match:
review_info['date'] = date_match.group(1)
# 광고성 문구 검색
ad_keywords = ['광고', '협찬', '제공받', '초대받', '스폰서', '파트너스', '수수료']
full_text = review_info['title'] + ' ' + review_info['preview']
for keyword in ad_keywords:
if keyword in full_text:
review_info['is_ad'] = True
idx = full_text.find(keyword)
start = max(0, idx - 20)
end = min(len(full_text), idx + 30)
review_info['ad_phrases'].append(full_text[start:end].strip())
reviews.append(review_info)
self.log_debug(f"리뷰 수집: {review_info['title'][:30]}...")
except Exception as e:
self.log_debug(f"리뷰 추출 중 오류: {e}")
continue
self.log_info(f"지도에서 블로그 리뷰 {len(reviews)}개 수집")
except Exception as e:
self.log_error(f"블로그 리뷰 수집 중 오류: {e}")
return reviews
def collect_blog_links(self, max_count: int = 5) -> List[tuple]:
"""
블로그 링크 수집
Args:
max_count: 최대 수집 개수
Returns:
(제목, URL) 튜플 리스트
"""
blog_links = []
self._ensure_correct_frame("entryIframe")
review_selectors = [
"li._2kAri",
"li[class*='blog']",
"ul[class*='list'] > li",
".place_section_content li"
]
review_elements = []
for selector in review_selectors:
try:
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
if elements:
valid_elements = []
for elem in elements:
try:
text = elem.text.strip()
if text and len(text) > 20 and '\n' in text:
valid_elements.append(elem)
except:
continue
if valid_elements:
review_elements = valid_elements
self.log_info(f"유효한 리뷰 요소 {len(valid_elements)}개 발견")
break
except:
continue
# 블로그 링크 추출
for elem in review_elements[:max_count]:
try:
text = elem.text.strip()
lines = text.split('\n')
title = lines[0] if lines else "제목 없음"
# URL 추출
url = self._extract_url_from_element(elem)
if url:
blog_links.append((title, url))
self.log_debug(f"블로그 링크 수집: {title[:30]}...")
except Exception as e:
self.log_debug(f"링크 추출 중 오류: {e}")
continue
self.log_info(f"{len(blog_links)}개의 블로그 링크 수집")
return blog_links
def _find_search_box(self):
"""검색창 찾기"""
self.log_debug("검색창 찾기 시도 중...")
time.sleep(2)
selectors = [
"input.input_search",
"input[placeholder*='검색']",
"input[type='search']",
"#search-input",
"input[name='query']",
"//input[@placeholder]",
"//input[contains(@class, 'search')]"
]
for selector in selectors:
try:
if selector.startswith('//'):
elem = self.driver.find_element(By.XPATH, selector)
else:
elem = self.driver.find_element(By.CSS_SELECTOR, selector)
if elem.is_displayed() and elem.is_enabled():
self.log_debug(f"검색창 발견: {selector}")
return elem
except:
continue
return None
def _click_search_result(self) -> bool:
"""검색 결과 클릭"""
# 동적 대기로 변경
# searchIframe 전환 시도
try:
self.driver.switch_to.default_content()
self.wait.until(EC.presence_of_element_located((By.ID, "searchIframe")))
self.driver.switch_to.frame("searchIframe")
self.log_debug("searchIframe으로 전환 성공")
except:
self.log_debug("searchIframe 전환 실패")
selectors = [
"a.place_bluelink",
"span.place_bluelink",
"//span[contains(@class, 'place')]",
"//a[contains(@class, 'place')]",
"li[class*='item'] a",
"div[class*='item'] a"
]
for selector in selectors:
try:
if selector.startswith('//'):
elements = self.driver.find_elements(By.XPATH, selector)
else:
elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
if elements:
elem = elements[0]
self.driver.execute_script("arguments[0].scrollIntoView(true);", elem)
time.sleep(1)
try:
elem.click()
except:
self.driver.execute_script("arguments[0].click();", elem)
self.log_debug(f"검색 결과 클릭 성공: {selector}")
return True
except:
continue
self.driver.switch_to.default_content()
return False
def _click_tab(self, tab_name: str) -> bool:
"""탭 클릭"""
tab_selectors = [
f"//a[contains(text(), '{tab_name}')]",
f"//span[contains(text(), '{tab_name}')]",
f"a[href*='{tab_name.lower()}']"
]
for selector in tab_selectors:
try:
if selector.startswith('//'):
elem = self.driver.find_element(By.XPATH, selector)
else:
elem = self.driver.find_element(By.CSS_SELECTOR, selector)
if elem.is_displayed():
self.driver.execute_script("arguments[0].click();", elem)
self.log_debug(f"{tab_name} 탭 클릭 성공")
return True
except:
continue
return False
def _extract_phone_number(self) -> str:
"""전화번호 추출"""
tel_selectors = ["span.xlx7Q", "a[href^='tel:']", "._3ZA0S", ".phone"]
for selector in tel_selectors:
try:
elem = self.driver.find_element(By.CSS_SELECTOR, selector)
tel_text = elem.text.strip()
tel_match = re.search(r'(\d{2,4}[-.]?\d{3,4}[-.]?\d{4})', tel_text)
if tel_match:
return tel_match.group(1)
except:
continue
return ''
def _extract_url_from_element(self, elem) -> Optional[str]:
"""요소에서 URL 추출"""
# a 태그에서 href 찾기
try:
link_elements = elem.find_elements(By.TAG_NAME, "a")
for link_elem in link_elements:
href = link_elem.get_attribute("href")
if href and href.startswith("http"):
return href
except:
pass
# onclick 속성에서 URL 추출
try:
onclick = elem.get_attribute("onclick")
if onclick and "http" in onclick:
url_match = re.search(r'(https?://[^\s\'"]+)', onclick)
if url_match:
return url_match.group(1)
except:
pass
return None
def _ensure_correct_frame(self, target_frame: str = None):
"""올바른 프레임으로 전환"""
if target_frame != self.current_frame:
if target_frame is None:
self.driver.switch_to.default_content()
self.current_frame = None
self.log_debug("기본 프레임으로 전환")
else:
try:
self.driver.switch_to.default_content()
self.wait.until(EC.presence_of_element_located((By.ID, target_frame)))
self.driver.switch_to.frame(target_frame)
self.current_frame = target_frame
self.log_debug(f"{target_frame}으로 전환 성공")
except Exception as e:
self.log_debug(f"{target_frame} 전환 실패: {e}")
def _save_screenshot(self, name: str):
"""스크린샷 저장"""
try:
filename = f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
filepath = os.path.join(self.project_dir, 'debug', filename)
self.driver.save_screenshot(filepath)
self.log_debug(f"스크린샷 저장: {filename}")
except:
pass
def return_to_blog_list(self):
"""블로그 목록으로 돌아가기"""
try:
self._ensure_correct_frame(None)
self._ensure_correct_frame("entryIframe")
self.log_debug("블로그 목록으로 복귀")
except Exception as e:
self.log_error(f"블로그 목록 복귀 중 오류: {e}")