1627 lines
69 KiB
Python
1627 lines
69 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
카카오맵 리뷰 분석 API (로깅 강화 버전)
|
|
ConfigMap과 Secret을 활용한 Kubernetes 배포용
|
|
|
|
app/main.py
|
|
"""
|
|
|
|
import os
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional, List, Dict, Any
|
|
import asyncio
|
|
import threading
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import sys
|
|
import subprocess
|
|
import platform
|
|
|
|
# 기존 분석기 import
|
|
from bs4 import BeautifulSoup
|
|
import re
|
|
import time
|
|
|
|
# =============================================================================
|
|
# .env 파일 로딩 (다른 import보다 먼저)
|
|
# =============================================================================
|
|
from dotenv import load_dotenv
|
|
|
|
# .env 파일에서 환경변수 로드
|
|
load_dotenv()
|
|
|
|
# =============================================================================
|
|
# 로깅 설정 (가장 먼저)
|
|
# =============================================================================
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.StreamHandler(sys.stdout)
|
|
]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# =============================================================================
|
|
# 시스템 정보 로깅
|
|
# =============================================================================
|
|
logger.info("="*60)
|
|
logger.info("카카오맵 리뷰 분석 API 시작")
|
|
logger.info("="*60)
|
|
logger.info(f"Python 버전: {sys.version}")
|
|
logger.info(f"플랫폼: {platform.platform()}")
|
|
logger.info(f"아키텍처: {platform.architecture()}")
|
|
logger.info(f"현재 작업 디렉토리: {os.getcwd()}")
|
|
|
|
# Python 경로 확인
|
|
logger.info("Python 경로 정보:")
|
|
for i, path in enumerate(sys.path):
|
|
logger.info(f" [{i}] {path}")
|
|
|
|
# =============================================================================
|
|
# 패키지 설치 상태 확인
|
|
# =============================================================================
|
|
def check_package_installation():
|
|
"""설치된 패키지 확인"""
|
|
logger.info("패키지 설치 상태 확인 중...")
|
|
|
|
required_packages = [
|
|
'selenium', 'webdriver-manager', 'beautifulsoup4',
|
|
'fastapi', 'uvicorn', 'pydantic'
|
|
]
|
|
|
|
for package in required_packages:
|
|
try:
|
|
result = subprocess.run([sys.executable, '-m', 'pip', 'show', package],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
version_line = [line for line in result.stdout.split('\n') if line.startswith('Version:')]
|
|
version = version_line[0].split(':')[1].strip() if version_line else 'Unknown'
|
|
logger.info(f"✅ {package}: {version}")
|
|
else:
|
|
logger.error(f"❌ {package}: 설치되지 않음")
|
|
except Exception as e:
|
|
logger.error(f"❌ {package} 확인 실패: {e}")
|
|
|
|
check_package_installation()
|
|
|
|
# =============================================================================
|
|
# Selenium 관련 import (세분화된 로깅)
|
|
# =============================================================================
|
|
logger.info("Selenium 관련 모듈 import 시작...")
|
|
|
|
SELENIUM_AVAILABLE = False
|
|
SELENIUM_IMPORT_ERRORS = []
|
|
|
|
# 각 모듈을 개별적으로 import하여 어디서 실패하는지 확인
|
|
selenium_modules = [
|
|
('selenium', 'selenium'),
|
|
('selenium.webdriver', 'webdriver'),
|
|
('selenium.webdriver.chrome.options', 'Options'),
|
|
('selenium.webdriver.common.by', 'By'),
|
|
('selenium.webdriver.support.ui', 'WebDriverWait'),
|
|
('selenium.webdriver.support', 'expected_conditions as EC'),
|
|
('webdriver_manager.chrome', 'ChromeDriverManager'),
|
|
('selenium.webdriver.chrome.service', 'Service')
|
|
]
|
|
|
|
imported_modules = {}
|
|
|
|
for module_path, import_name in selenium_modules:
|
|
try:
|
|
logger.info(f" 📦 {module_path}.{import_name} import 시도...")
|
|
if import_name == 'selenium':
|
|
import selenium
|
|
imported_modules['selenium'] = selenium
|
|
logger.info(f" ✅ selenium 버전: {selenium.__version__}")
|
|
elif import_name == 'webdriver':
|
|
from selenium import webdriver
|
|
imported_modules['webdriver'] = webdriver
|
|
logger.info(f" ✅ webdriver import 성공")
|
|
elif import_name == 'Options':
|
|
from selenium.webdriver.chrome.options import Options
|
|
imported_modules['Options'] = Options
|
|
logger.info(f" ✅ Options import 성공")
|
|
elif import_name == 'By':
|
|
from selenium.webdriver.common.by import By
|
|
imported_modules['By'] = By
|
|
logger.info(f" ✅ By import 성공")
|
|
elif import_name == 'WebDriverWait':
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
imported_modules['WebDriverWait'] = WebDriverWait
|
|
logger.info(f" ✅ WebDriverWait import 성공")
|
|
elif import_name == 'expected_conditions as EC':
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
imported_modules['EC'] = EC
|
|
logger.info(f" ✅ expected_conditions import 성공")
|
|
elif import_name == 'ChromeDriverManager':
|
|
from webdriver_manager.chrome import ChromeDriverManager
|
|
imported_modules['ChromeDriverManager'] = ChromeDriverManager
|
|
logger.info(f" ✅ ChromeDriverManager import 성공")
|
|
elif import_name == 'Service':
|
|
from selenium.webdriver.chrome.service import Service
|
|
imported_modules['Service'] = Service
|
|
logger.info(f" ✅ Service import 성공")
|
|
|
|
except ImportError as e:
|
|
error_msg = f"{module_path}.{import_name} import 실패: {e}"
|
|
logger.error(f" ❌ {error_msg}")
|
|
SELENIUM_IMPORT_ERRORS.append(error_msg)
|
|
except Exception as e:
|
|
error_msg = f"{module_path}.{import_name} import 중 예외 발생: {e}"
|
|
logger.error(f" ❌ {error_msg}")
|
|
SELENIUM_IMPORT_ERRORS.append(error_msg)
|
|
|
|
# 모든 모듈이 성공적으로 import되었는지 확인
|
|
required_modules = ['webdriver', 'Options', 'By', 'WebDriverWait', 'EC', 'ChromeDriverManager', 'Service']
|
|
missing_modules = [mod for mod in required_modules if mod not in imported_modules]
|
|
|
|
if missing_modules:
|
|
logger.error(f"❌ 누락된 모듈들: {missing_modules}")
|
|
SELENIUM_AVAILABLE = False
|
|
else:
|
|
logger.info("✅ 모든 Selenium 모듈 import 성공!")
|
|
SELENIUM_AVAILABLE = True
|
|
|
|
# 전역 변수로 설정
|
|
webdriver = imported_modules['webdriver']
|
|
Options = imported_modules['Options']
|
|
By = imported_modules['By']
|
|
WebDriverWait = imported_modules['WebDriverWait']
|
|
EC = imported_modules['EC']
|
|
ChromeDriverManager = imported_modules['ChromeDriverManager']
|
|
Service = imported_modules['Service']
|
|
|
|
logger.info(f"Selenium 사용 가능 여부: {SELENIUM_AVAILABLE}")
|
|
if SELENIUM_IMPORT_ERRORS:
|
|
logger.error("Import 오류 목록:")
|
|
for error in SELENIUM_IMPORT_ERRORS:
|
|
logger.error(f" - {error}")
|
|
|
|
# =============================================================================
|
|
# Chrome 설치 상태 확인
|
|
# =============================================================================
|
|
def check_chrome_installation():
|
|
"""Chrome 브라우저 설치 상태 확인"""
|
|
logger.info("Chrome 브라우저 설치 상태 확인 중...")
|
|
|
|
chrome_paths = [
|
|
'/usr/bin/google-chrome',
|
|
'/usr/bin/google-chrome-stable',
|
|
'/usr/bin/chromium-browser',
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
|
|
]
|
|
|
|
chrome_found = False
|
|
for path in chrome_paths:
|
|
if os.path.exists(path):
|
|
logger.info(f" ✅ Chrome 발견: {path}")
|
|
chrome_found = True
|
|
|
|
# 버전 확인 시도
|
|
try:
|
|
result = subprocess.run([path, '--version'], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
logger.info(f" 📋 Chrome 버전: {result.stdout.strip()}")
|
|
except Exception as e:
|
|
logger.warning(f" ⚠️ Chrome 버전 확인 실패: {e}")
|
|
break
|
|
|
|
if not chrome_found:
|
|
logger.error(" ❌ Chrome 브라우저가 설치되지 않음")
|
|
|
|
# 시스템 명령어로 확인 시도
|
|
try:
|
|
result = subprocess.run(['which', 'google-chrome'], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
logger.info(f" ✅ PATH에서 Chrome 발견: {result.stdout.strip()}")
|
|
chrome_found = True
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
result = subprocess.run(['google-chrome', '--version'], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
logger.info(f" ✅ 명령어로 Chrome 실행 가능: {result.stdout.strip()}")
|
|
chrome_found = True
|
|
except Exception as e:
|
|
logger.error(f" ❌ Chrome 명령어 실행 실패: {e}")
|
|
|
|
return chrome_found
|
|
|
|
chrome_available = check_chrome_installation()
|
|
|
|
# =============================================================================
|
|
# 환경 변수 설정
|
|
# =============================================================================
|
|
class Config:
|
|
"""환경 변수 기반 설정 클래스"""
|
|
|
|
# 애플리케이션 메타데이터
|
|
APP_TITLE = os.getenv("APP_TITLE", "카카오맵 리뷰 분석 API")
|
|
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
|
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "교육 목적 전용 - 실제 서비스 사용 금지")
|
|
|
|
# 서버 설정
|
|
HOST = os.getenv("HOST", "0.0.0.0")
|
|
PORT = int(os.getenv("PORT", "8000"))
|
|
WORKERS = int(os.getenv("WORKERS", "1"))
|
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
|
|
|
|
# API 기본값
|
|
DEFAULT_MAX_TIME = int(os.getenv("DEFAULT_MAX_TIME", "300"))
|
|
DEFAULT_DAYS_LIMIT = int(os.getenv("DEFAULT_DAYS_LIMIT", "7"))
|
|
MAX_DAYS_LIMIT = int(os.getenv("MAX_DAYS_LIMIT", "365"))
|
|
MIN_MAX_TIME = int(os.getenv("MIN_MAX_TIME", "60"))
|
|
MAX_MAX_TIME = int(os.getenv("MAX_MAX_TIME", "1800"))
|
|
|
|
# Chrome 옵션 (ConfigMap에서 멀티라인 문자열로 받음)
|
|
CHROME_OPTIONS_RAW = os.getenv("CHROME_OPTIONS", """
|
|
--headless
|
|
--no-sandbox
|
|
--disable-dev-shm-usage
|
|
--disable-gpu
|
|
--window-size=1920,1080
|
|
--disable-extensions
|
|
--disable-plugins
|
|
--disable-usb-keyboard-detect
|
|
--no-first-run
|
|
--no-default-browser-check
|
|
--disable-logging
|
|
--log-level=3
|
|
""")
|
|
|
|
@property
|
|
def CHROME_OPTIONS_LIST(self):
|
|
"""Chrome 옵션을 리스트로 파싱"""
|
|
return [opt.strip() for opt in self.CHROME_OPTIONS_RAW.strip().split('\n') if opt.strip()]
|
|
|
|
# 스크롤링 설정
|
|
SCROLL_CHECK_INTERVAL = int(os.getenv("SCROLL_CHECK_INTERVAL", "3"))
|
|
SCROLL_NO_CHANGE_LIMIT = int(os.getenv("SCROLL_NO_CHANGE_LIMIT", "8"))
|
|
SCROLL_WAIT_TIME_SHORT = float(os.getenv("SCROLL_WAIT_TIME_SHORT", "1.5"))
|
|
SCROLL_WAIT_TIME_LONG = float(os.getenv("SCROLL_WAIT_TIME_LONG", "2.0"))
|
|
|
|
# 법적 경고
|
|
LEGAL_WARNING_ENABLED = os.getenv("LEGAL_WARNING_ENABLED", "true").lower() == "true"
|
|
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "admin@example.com")
|
|
|
|
# 건강 체크
|
|
HEALTH_CHECK_TIMEOUT = int(os.getenv("HEALTH_CHECK_TIMEOUT", "5"))
|
|
|
|
# Secret 값들 (현재는 사용하지 않지만 향후 확장용)
|
|
EXTERNAL_API_KEY = os.getenv("EXTERNAL_API_KEY", "")
|
|
DB_USERNAME = os.getenv("DB_USERNAME", "")
|
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
|
JWT_SECRET = os.getenv("JWT_SECRET", "")
|
|
|
|
# 설정 인스턴스
|
|
config = Config()
|
|
|
|
logger.info("환경 설정 로드 완료:")
|
|
logger.info(f" - Chrome 옵션 개수: {len(config.CHROME_OPTIONS_LIST)}")
|
|
logger.info(f" - 기본 최대 시간: {config.DEFAULT_MAX_TIME}초")
|
|
logger.info(f" - 로그 레벨: {config.LOG_LEVEL}")
|
|
|
|
# FastAPI 앱 초기화 (환경변수 사용)
|
|
app = FastAPI(
|
|
title=config.APP_TITLE,
|
|
description=f"""
|
|
**⚠️ 중요 법적 경고사항 ⚠️**
|
|
|
|
{config.APP_DESCRIPTION}
|
|
|
|
**진단 정보:**
|
|
- Selenium 사용 가능: {SELENIUM_AVAILABLE}
|
|
- Chrome 설치됨: {chrome_available}
|
|
- Import 오류 개수: {len(SELENIUM_IMPORT_ERRORS)}
|
|
|
|
**법적 위험:**
|
|
- 카카오 이용약관 위반 → 계정 정지, 법적 조치
|
|
- 개인정보보호법(PIPA) 위반 → 과태료 최대 3억원
|
|
- 저작권 및 데이터베이스권 침해 → 손해배상
|
|
- 업무방해죄 → 5년 이하 징역 또는 1천500만원 이하 벌금
|
|
|
|
**합법적 대안:**
|
|
- 카카오 공식 API 활용 (developers.kakao.com)
|
|
- 점주 대상 자체 가게 관리 서비스 개발
|
|
- 사용자 동의 기반 데이터 수집 앱
|
|
- 카카오와 정식 파트너십 체결
|
|
|
|
**이 API를 실제 서비스에 사용하지 마세요!**
|
|
|
|
**환경:** 로컬 개발
|
|
**버전:** {config.APP_VERSION}
|
|
**연락처:** {config.CONTACT_EMAIL}
|
|
""",
|
|
version=config.APP_VERSION,
|
|
contact={
|
|
"name": "관리자",
|
|
"email": config.CONTACT_EMAIL
|
|
},
|
|
license_info={
|
|
"name": "Educational Use Only",
|
|
"url": "https://developers.kakao.com"
|
|
}
|
|
)
|
|
|
|
# Pydantic 모델 정의 (기존과 동일)
|
|
class ReviewAnalysisRequest(BaseModel):
|
|
"""리뷰 분석 요청 모델"""
|
|
store_id: str = Field(
|
|
...,
|
|
description="카카오맵 가게 ID (예: 501745730)",
|
|
example="501745730",
|
|
min_length=1,
|
|
max_length=20
|
|
)
|
|
days_limit: Optional[int] = Field(
|
|
None,
|
|
description=f"며칠 이후의 리뷰만 수집할지 (None이면 모든 날짜, 최대 {config.MAX_DAYS_LIMIT}일)",
|
|
example=config.DEFAULT_DAYS_LIMIT,
|
|
ge=1,
|
|
le=config.MAX_DAYS_LIMIT
|
|
)
|
|
max_time: int = Field(
|
|
config.DEFAULT_MAX_TIME,
|
|
description=f"최대 스크롤 시간(초, {config.MIN_MAX_TIME}-{config.MAX_MAX_TIME}초)",
|
|
example=config.DEFAULT_MAX_TIME,
|
|
ge=config.MIN_MAX_TIME,
|
|
le=config.MAX_MAX_TIME
|
|
)
|
|
|
|
class ReviewerStats(BaseModel):
|
|
"""리뷰어 통계 정보"""
|
|
reviews: Optional[int] = None
|
|
average_rating: Optional[float] = None
|
|
followers: Optional[int] = None
|
|
|
|
class ReviewData(BaseModel):
|
|
"""개별 리뷰 데이터"""
|
|
reviewer_name: str
|
|
reviewer_level: str
|
|
reviewer_stats: ReviewerStats
|
|
rating: int
|
|
date: str
|
|
content: str
|
|
badges: List[str]
|
|
likes: int
|
|
photo_count: int
|
|
has_photos: bool
|
|
|
|
class StoreInfo(BaseModel):
|
|
"""가게 정보 (수정됨: id 필드 추가)"""
|
|
id: str = Field(description="가게 ID")
|
|
name: str
|
|
category: str
|
|
rating: str
|
|
review_count: str
|
|
status: str
|
|
address: str
|
|
|
|
class DateFilter(BaseModel):
|
|
"""날짜 필터 정보"""
|
|
cutoff_date: Optional[str] = None
|
|
filtered: bool
|
|
|
|
class ReviewAnalysisResponse(BaseModel):
|
|
"""리뷰 분석 응답 모델"""
|
|
success: bool = Field(description="분석 성공 여부")
|
|
message: str = Field(description="응답 메시지")
|
|
store_info: Optional[StoreInfo] = None
|
|
reviews: List[ReviewData] = []
|
|
analysis_date: str = Field(description="분석 수행 날짜시간")
|
|
total_reviews: int = Field(description="수집된 총 리뷰 수")
|
|
analysis_method: str = "selenium"
|
|
date_filter: DateFilter
|
|
execution_time: float = Field(description="실행 시간(초)")
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""에러 응답 모델"""
|
|
success: bool = False
|
|
error: str
|
|
message: str
|
|
timestamp: str
|
|
|
|
# 분석기 클래스 (로깅 강화)
|
|
class KakaoReviewAnalyzerAPI:
|
|
"""로깅 강화된 카카오맵 리뷰 분석기"""
|
|
|
|
def __init__(self):
|
|
self.store_info = {}
|
|
self.reviews = []
|
|
self.cutoff_date = None
|
|
self.config = config
|
|
logger.info("KakaoReviewAnalyzerAPI 인스턴스 생성 완료")
|
|
|
|
def analyze_by_store_id(self, store_id: str, days_limit: Optional[int] = None, max_scroll_time: int = None):
|
|
"""Selenium을 사용한 동적 리뷰 분석 (ChromeDriver 경로 수정)"""
|
|
|
|
logger.info("="*60)
|
|
logger.info("analyze_by_store_id 함수 시작")
|
|
logger.info("="*60)
|
|
|
|
if max_scroll_time is None:
|
|
max_scroll_time = self.config.DEFAULT_MAX_TIME
|
|
|
|
logger.info(f"입력 파라미터:")
|
|
logger.info(f" - store_id: {store_id}")
|
|
logger.info(f" - days_limit: {days_limit}")
|
|
logger.info(f" - max_scroll_time: {max_scroll_time}")
|
|
|
|
# 날짜 제한 설정
|
|
if days_limit is not None:
|
|
self.cutoff_date = datetime.now() - timedelta(days=days_limit)
|
|
logger.info(f"수집 기준일 설정: {self.cutoff_date.strftime('%Y.%m.%d')} 이후 리뷰만 수집")
|
|
else:
|
|
self.cutoff_date = None
|
|
logger.info("날짜 제한 없음: 모든 날짜의 리뷰 수집")
|
|
|
|
target_url = f"https://place.map.kakao.com/{store_id}#comment"
|
|
logger.info(f"대상 URL 생성: {target_url}")
|
|
|
|
driver = None
|
|
temp_profile = None
|
|
step_counter = 0
|
|
|
|
try:
|
|
# Step 1: Chrome 옵션 설정
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] Chrome 옵션 설정 중...")
|
|
|
|
if not SELENIUM_AVAILABLE:
|
|
raise Exception(f"Selenium을 사용할 수 없음. Import 오류: {SELENIUM_IMPORT_ERRORS}")
|
|
|
|
options = Options()
|
|
logger.info(f" Chrome Options 객체 생성 완료")
|
|
|
|
for i, option in enumerate(self.config.CHROME_OPTIONS_LIST):
|
|
logger.info(f" 옵션 [{i+1}] 추가: {option}")
|
|
options.add_argument(option)
|
|
|
|
# User Agent 추가
|
|
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
logger.info(f" User Agent 설정: {user_agent}")
|
|
options.add_argument(f'--user-agent={user_agent}')
|
|
|
|
# 추가 최적화 옵션들
|
|
experimental_options = ['enable-logging']
|
|
logger.info(f" 실험적 옵션 제외: {experimental_options}")
|
|
options.add_experimental_option('excludeSwitches', experimental_options)
|
|
options.add_experimental_option('useAutomationExtension', False)
|
|
|
|
logger.info(f" Chrome 옵션 설정 완료 (총 {len(self.config.CHROME_OPTIONS_LIST)+1}개)")
|
|
|
|
# Step 2: WebDriver 초기화 (ChromeDriverManager 사용 중단)
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] Chrome WebDriver 초기화 중...")
|
|
|
|
# 🔧 시스템에 설치된 ChromeDriver 사용
|
|
chromedriver_paths = [
|
|
"/usr/local/bin/chromedriver", # Dockerfile에서 설치한 경로
|
|
"/usr/bin/chromedriver",
|
|
"/opt/chromedriver/chromedriver",
|
|
"./chromedriver"
|
|
]
|
|
|
|
driver_path = None
|
|
for path in chromedriver_paths:
|
|
if os.path.exists(path) and os.access(path, os.X_OK):
|
|
logger.info(f" ChromeDriver 발견: {path}")
|
|
# 버전 확인
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
logger.info(f" ChromeDriver 버전: {result.stdout.strip()}")
|
|
driver_path = path
|
|
break
|
|
except Exception as version_error:
|
|
logger.warning(f" {path} 버전 확인 실패: {version_error}")
|
|
continue
|
|
|
|
if not driver_path:
|
|
# 마지막 수단: ChromeDriverManager 사용하되 올바른 실행 파일 찾기
|
|
logger.info(" 시스템 ChromeDriver 없음 - ChromeDriverManager 사용...")
|
|
try:
|
|
# 🔧 ChromeDriverManager 개선
|
|
os.environ['WDM_LOCAL'] = '/tmp/.wdm'
|
|
downloaded_path = ChromeDriverManager(cache_valid_range=7).install()
|
|
logger.info(f" 다운로드된 경로: {downloaded_path}")
|
|
|
|
# 다운로드된 디렉토리에서 실제 실행 파일 찾기
|
|
download_dir = os.path.dirname(downloaded_path)
|
|
possible_paths = [
|
|
os.path.join(download_dir, "chromedriver"),
|
|
os.path.join(download_dir, "chromedriver-linux64", "chromedriver"),
|
|
downloaded_path
|
|
]
|
|
|
|
for candidate_path in possible_paths:
|
|
if os.path.exists(candidate_path) and os.access(candidate_path, os.X_OK):
|
|
# 파일이 실제 실행 파일인지 확인 (텍스트 파일이 아닌지)
|
|
try:
|
|
with open(candidate_path, 'rb') as f:
|
|
header = f.read(4)
|
|
# ELF 헤더 확인 (리눅스 실행 파일)
|
|
if header.startswith(b'\x7fELF'):
|
|
logger.info(f" 실행 가능한 ChromeDriver 발견: {candidate_path}")
|
|
driver_path = candidate_path
|
|
break
|
|
except Exception as file_check_error:
|
|
logger.warning(f" 파일 확인 실패: {candidate_path} - {file_check_error}")
|
|
continue
|
|
|
|
if not driver_path:
|
|
raise Exception(f"다운로드된 디렉토리에서 실행 가능한 chromedriver를 찾을 수 없음: {download_dir}")
|
|
|
|
except Exception as cdm_error:
|
|
logger.error(f" ChromeDriverManager 실패: {cdm_error}")
|
|
raise Exception(f"ChromeDriver를 찾을 수 없음. 시스템에 chromedriver를 설치하거나 Docker 이미지를 다시 빌드하세요.")
|
|
|
|
# Service 객체 생성
|
|
logger.info(f" ChromeDriver 경로 사용: {driver_path}")
|
|
service = Service(driver_path)
|
|
|
|
# 🔧 Chrome 실행 환경 설정
|
|
logger.info(" Chrome 실행 환경 설정...")
|
|
|
|
# 임시 프로필 디렉토리 생성
|
|
import tempfile
|
|
temp_profile = tempfile.mkdtemp(prefix='chrome_profile_')
|
|
logger.info(f" 임시 Chrome 프로필 생성: {temp_profile}")
|
|
|
|
# Chrome 옵션에 프로필 경로 및 추가 안정성 옵션 추가
|
|
options.add_argument(f'--user-data-dir={temp_profile}')
|
|
options.add_argument('--disable-web-security')
|
|
options.add_argument('--allow-running-insecure-content')
|
|
options.add_argument('--disable-blink-features=AutomationControlled')
|
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
options.add_experimental_option('useAutomationExtension', False)
|
|
|
|
logger.info(" 추가 안정성 옵션 적용 완료")
|
|
|
|
# WebDriver 생성
|
|
logger.info(" Chrome WebDriver 인스턴스 생성 중...")
|
|
try:
|
|
driver = webdriver.Chrome(service=service, options=options)
|
|
logger.info(" Chrome WebDriver 객체 생성 완료")
|
|
|
|
# 🔧 즉시 연결 상태 확인
|
|
logger.info(" Chrome 연결 상태 확인...")
|
|
driver.set_page_load_timeout(30)
|
|
driver.set_script_timeout(30)
|
|
driver.implicitly_wait(10)
|
|
|
|
# 기본 페이지로 이동하여 연결 테스트
|
|
logger.info(" 연결 테스트용 빈 페이지 로드...")
|
|
driver.get("data:text/html,<html><head><title>Test</title></head><body><h1>Connection Test</h1></body></html>")
|
|
|
|
# 페이지 제목 확인으로 연결 검증
|
|
test_title = driver.title
|
|
logger.info(f" 연결 테스트 성공 - 페이지 제목: {test_title}")
|
|
|
|
if not test_title:
|
|
raise Exception("Chrome 연결은 되었으나 페이지 로드 실패")
|
|
|
|
logger.info(" Chrome WebDriver 연결 확인 완료")
|
|
|
|
# Chrome 정보 확인
|
|
try:
|
|
capabilities = driver.capabilities
|
|
chrome_version = capabilities.get('browserVersion', 'Unknown')
|
|
driver_version = capabilities.get('chrome', {}).get('chromedriverVersion', 'Unknown')
|
|
logger.info(f" Chrome 버전: {chrome_version}")
|
|
logger.info(f" ChromeDriver 버전: {driver_version}")
|
|
|
|
# 창 크기 확인
|
|
window_size = driver.get_window_size()
|
|
logger.info(f" 현재 창 크기: {window_size}")
|
|
except Exception as info_error:
|
|
logger.warning(f" Chrome 정보 확인 실패: {info_error}")
|
|
|
|
except Exception as driver_error:
|
|
logger.error(f" Chrome WebDriver 생성/연결 실패: {driver_error}")
|
|
|
|
# 리소스 정리
|
|
if driver:
|
|
try:
|
|
driver.quit()
|
|
except:
|
|
pass
|
|
|
|
if temp_profile and os.path.exists(temp_profile):
|
|
try:
|
|
import shutil
|
|
shutil.rmtree(temp_profile)
|
|
logger.info(" 임시 프로필 디렉토리 정리 완료")
|
|
except Exception as cleanup_error:
|
|
logger.warning(f" 임시 프로필 정리 실패: {cleanup_error}")
|
|
|
|
error_details = {
|
|
"error_type": "WEBDRIVER_CONNECTION_FAILED",
|
|
"chrome_options_count": len(self.config.CHROME_OPTIONS_LIST),
|
|
"driver_path": driver_path,
|
|
"temp_profile": temp_profile,
|
|
"system_info": {
|
|
"checked_paths": chromedriver_paths,
|
|
"chrome_exists": os.path.exists("/usr/bin/google-chrome"),
|
|
"wdm_cache": os.path.exists("/tmp/.wdm")
|
|
}
|
|
}
|
|
raise Exception(f"Chrome WebDriver 연결 실패: {driver_error}\n상세 정보: {error_details}")
|
|
|
|
# Step 3: 페이지 로드
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] 페이지 로드 시작...")
|
|
logger.info(f" 요청 URL: {target_url}")
|
|
|
|
start_time = time.time()
|
|
try:
|
|
driver.get(target_url)
|
|
load_time = time.time() - start_time
|
|
logger.info(f" 페이지 로드 완료 (소요시간: {load_time:.2f}초)")
|
|
|
|
# 페이지 정보 확인
|
|
current_url = driver.current_url
|
|
page_title = driver.title
|
|
logger.info(f" 현재 URL: {current_url}")
|
|
logger.info(f" 페이지 제목: {page_title}")
|
|
|
|
if "404" in page_title or "오류" in page_title:
|
|
raise Exception(f"유효하지 않은 페이지: {page_title}")
|
|
|
|
# Body 로드 대기
|
|
logger.info(" 페이지 body 로드 대기 중...")
|
|
WebDriverWait(driver, 15).until(
|
|
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
|
)
|
|
logger.info(" 페이지 body 로드 완료")
|
|
|
|
except Exception as page_error:
|
|
logger.error(f" 페이지 로드 실패: {page_error}")
|
|
current_url = driver.current_url if driver else "N/A"
|
|
raise Exception(f"페이지 로드 실패 - 요청 URL: {target_url}, 현재 URL: {current_url}, 오류: {page_error}")
|
|
|
|
# Step 4: 후기 탭 클릭 (이하 동일)
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] 후기 탭 클릭 시도...")
|
|
|
|
review_tab_selectors = [
|
|
"//a[@href='#comment']",
|
|
"//a[contains(text(), '후기')]",
|
|
"//button[contains(text(), '후기')]",
|
|
"//*[@id='comment']",
|
|
"//a[contains(@class, 'comment')]"
|
|
]
|
|
|
|
review_tab_found = False
|
|
for i, selector in enumerate(review_tab_selectors):
|
|
try:
|
|
logger.info(f" 선택자 [{i+1}] 시도: {selector}")
|
|
review_tab = WebDriverWait(driver, 3).until(
|
|
EC.element_to_be_clickable((By.XPATH, selector))
|
|
)
|
|
logger.info(f" 후기 탭 발견: {selector}")
|
|
driver.execute_script("arguments[0].click();", review_tab)
|
|
time.sleep(3)
|
|
logger.info(f" 후기 탭 클릭 성공")
|
|
review_tab_found = True
|
|
break
|
|
except Exception as e:
|
|
logger.info(f" 선택자 [{i+1}] 실패: {e}")
|
|
continue
|
|
|
|
if not review_tab_found:
|
|
logger.warning(" 후기 탭을 찾을 수 없음 - 기본 페이지에서 진행")
|
|
|
|
# Step 5: 리뷰 목록 로드 확인
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] 리뷰 목록 로드 확인...")
|
|
|
|
review_list_selectors = [
|
|
".list_review",
|
|
"[class*='review']",
|
|
"[class*='comment']",
|
|
".review_list",
|
|
".comment_list"
|
|
]
|
|
|
|
review_list_found = False
|
|
for i, selector in enumerate(review_list_selectors):
|
|
try:
|
|
logger.info(f" 리뷰 목록 선택자 [{i+1}] 시도: {selector}")
|
|
elements = driver.find_elements(By.CSS_SELECTOR, selector)
|
|
if elements:
|
|
logger.info(f" 리뷰 목록 발견: {selector} (요소 {len(elements)}개)")
|
|
review_list_found = True
|
|
break
|
|
except Exception as e:
|
|
logger.info(f" 리뷰 목록 선택자 [{i+1}] 실패: {e}")
|
|
|
|
if not review_list_found:
|
|
logger.warning(" 리뷰 목록을 찾을 수 없음 - HTML에서 직접 추출 시도")
|
|
|
|
# Step 6: 스크롤링
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] 스크롤링 시작...")
|
|
logger.info(f" 최대 스크롤 시간: {max_scroll_time}초")
|
|
|
|
try:
|
|
self._smart_infinite_scroll(driver, max_scroll_time)
|
|
logger.info(" 스크롤링 완료")
|
|
except Exception as scroll_error:
|
|
logger.error(f" 스크롤링 중 오류: {scroll_error}")
|
|
logger.info(" 스크롤링 실패 - 현재까지 로드된 내용으로 분석 진행")
|
|
|
|
# Step 7: HTML 분석
|
|
step_counter += 1
|
|
logger.info(f"[단계 {step_counter}] HTML 내용 분석...")
|
|
|
|
html_content = driver.page_source
|
|
html_length = len(html_content)
|
|
logger.info(f" HTML 내용 추출 완료: {html_length:,}자")
|
|
|
|
if html_length < 1000:
|
|
raise Exception(f"HTML 내용이 너무 짧음: {html_length}자")
|
|
|
|
# HTML 분석 수행
|
|
logger.info(" HTML 파싱 및 데이터 추출 시작...")
|
|
store_info, reviews = self.analyze_html_content(html_content, store_id)
|
|
|
|
# 결과 검증
|
|
if not store_info:
|
|
logger.warning(" 가게 정보 추출 실패 - 기본값 설정")
|
|
store_info = {
|
|
'id': store_id,
|
|
'name': f'Store_{store_id}',
|
|
'category': '',
|
|
'rating': '',
|
|
'review_count': '',
|
|
'status': '',
|
|
'address': ''
|
|
}
|
|
else:
|
|
logger.info(f" 가게 정보 추출 성공: {store_info.get('name', 'N/A')}")
|
|
|
|
if not reviews:
|
|
logger.warning(" 리뷰 데이터 추출 실패")
|
|
reviews = []
|
|
else:
|
|
logger.info(f" 리뷰 데이터 추출 성공: {len(reviews)}개")
|
|
|
|
logger.info("="*60)
|
|
logger.info("analyze_by_store_id 함수 완료")
|
|
logger.info(f"최종 결과: 가게 정보={store_info.get('name', 'N/A')}, 리뷰={len(reviews)}개")
|
|
logger.info("="*60)
|
|
|
|
return store_info, reviews
|
|
|
|
except Exception as e:
|
|
logger.error("="*60)
|
|
logger.error(f"[단계 {step_counter}] 오류 발생!")
|
|
logger.error(f"오류 내용: {str(e)}")
|
|
logger.error("="*60)
|
|
|
|
# 상세 디버그 정보 수집
|
|
debug_info = {
|
|
"failed_step": step_counter,
|
|
"store_id": store_id,
|
|
"target_url": target_url,
|
|
"max_scroll_time": max_scroll_time,
|
|
"days_limit": days_limit,
|
|
"selenium_available": SELENIUM_AVAILABLE,
|
|
"chrome_available": chrome_available,
|
|
"chrome_options_count": len(self.config.CHROME_OPTIONS_LIST),
|
|
"import_errors": SELENIUM_IMPORT_ERRORS,
|
|
"driver_status": "initialized" if driver else "not_initialized",
|
|
"temp_profile": temp_profile
|
|
}
|
|
|
|
if driver:
|
|
try:
|
|
debug_info.update({
|
|
"current_url": driver.current_url,
|
|
"page_title": driver.title,
|
|
"window_size": driver.get_window_size()
|
|
})
|
|
except:
|
|
debug_info["driver_info"] = "driver_not_accessible"
|
|
|
|
logger.error("디버그 정보:")
|
|
for key, value in debug_info.items():
|
|
logger.error(f" {key}: {value}")
|
|
|
|
raise Exception(f"리뷰 분석 실패 (단계 {step_counter}): {str(e)}")
|
|
|
|
finally:
|
|
# 🔧 리소스 정리 강화
|
|
logger.info("리소스 정리 중...")
|
|
|
|
# WebDriver 정리
|
|
if driver:
|
|
try:
|
|
logger.info(" Chrome WebDriver 종료 중...")
|
|
driver.quit()
|
|
logger.info(" Chrome WebDriver 종료 완료")
|
|
except Exception as cleanup_error:
|
|
logger.warning(f" WebDriver 정리 중 오류: {cleanup_error}")
|
|
else:
|
|
logger.info(" WebDriver가 초기화되지 않았음 - 정리 작업 불필요")
|
|
|
|
# 임시 프로필 디렉토리 정리
|
|
if temp_profile and os.path.exists(temp_profile):
|
|
try:
|
|
import shutil
|
|
shutil.rmtree(temp_profile)
|
|
logger.info(f" 임시 프로필 디렉토리 정리 완료: {temp_profile}")
|
|
except Exception as cleanup_error:
|
|
logger.warning(f" 임시 프로필 정리 실패: {cleanup_error}")
|
|
|
|
logger.info("리소스 정리 완료")
|
|
|
|
def _smart_infinite_scroll(self, driver, max_time):
|
|
"""환경변수 기반 스마트 무한 스크롤링 (로깅 강화)"""
|
|
logger.info("스크롤링 세부 설정:")
|
|
logger.info(f" - 체크 간격: {self.config.SCROLL_CHECK_INTERVAL}회마다")
|
|
logger.info(f" - 변화 없음 제한: {self.config.SCROLL_NO_CHANGE_LIMIT}회")
|
|
logger.info(f" - 대기 시간 (짧음): {self.config.SCROLL_WAIT_TIME_SHORT}초")
|
|
logger.info(f" - 대기 시간 (김): {self.config.SCROLL_WAIT_TIME_LONG}초")
|
|
|
|
start_time = time.time()
|
|
scroll_count = 0
|
|
no_change_count = 0
|
|
last_review_count = 0
|
|
|
|
while True:
|
|
scroll_count += 1
|
|
elapsed_time = time.time() - start_time
|
|
|
|
# 시간 제한 확인
|
|
if elapsed_time > max_time:
|
|
logger.info(f"스크롤링 종료: 시간 제한 도달 ({max_time}초)")
|
|
break
|
|
|
|
try:
|
|
# 현재 리뷰 개수 확인
|
|
review_elements = driver.find_elements(By.CSS_SELECTOR, "ul.list_review li")
|
|
current_review_count = len(review_elements)
|
|
|
|
# 10회마다 진행 상황 로깅
|
|
if scroll_count % 10 == 0:
|
|
logger.info(f"스크롤링 진행: {scroll_count}회 | 리뷰 {current_review_count}개 | {elapsed_time:.1f}초 경과")
|
|
|
|
# 날짜 기준 확인
|
|
if self.cutoff_date and scroll_count % self.config.SCROLL_CHECK_INTERVAL == 0:
|
|
logger.info(f"날짜 기준 확인 중... (스크롤 {scroll_count}회)")
|
|
date_cutoff_reached, oldest_date = self._check_date_cutoff_realtime(driver)
|
|
if date_cutoff_reached:
|
|
logger.info(f"스크롤링 종료: 날짜 기준 도달 ({oldest_date})")
|
|
break
|
|
|
|
# 리뷰 개수 변화 확인
|
|
if current_review_count == last_review_count:
|
|
no_change_count += 1
|
|
if no_change_count >= self.config.SCROLL_NO_CHANGE_LIMIT:
|
|
logger.info(f"스크롤링 종료: 더 이상 새로운 리뷰 없음 (총 {current_review_count}개)")
|
|
break
|
|
else:
|
|
no_change_count = 0
|
|
last_review_count = current_review_count
|
|
|
|
# 스크롤 실행
|
|
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
|
|
|
# 대기 시간 조정
|
|
wait_time = (self.config.SCROLL_WAIT_TIME_SHORT if current_review_count < 30
|
|
else self.config.SCROLL_WAIT_TIME_LONG)
|
|
time.sleep(wait_time)
|
|
|
|
except Exception as e:
|
|
logger.error(f"스크롤 {scroll_count}회 실행 실패: {e}")
|
|
break
|
|
|
|
# 최종 결과
|
|
final_count = len(driver.find_elements(By.CSS_SELECTOR, "ul.list_review li"))
|
|
logger.info(f"스크롤링 최종 결과:")
|
|
logger.info(f" - 총 스크롤 횟수: {scroll_count}회")
|
|
logger.info(f" - 최종 리뷰 개수: {final_count}개")
|
|
logger.info(f" - 총 소요 시간: {elapsed_time:.1f}초")
|
|
|
|
def _check_date_cutoff_realtime(self, driver):
|
|
"""실시간 날짜 기준 확인 (로깅 강화)"""
|
|
if not self.cutoff_date:
|
|
return False, None
|
|
|
|
try:
|
|
date_elements = driver.find_elements(By.CSS_SELECTOR, "ul.list_review li .txt_date")
|
|
recent_dates = date_elements[-10:] if len(date_elements) > 10 else date_elements
|
|
|
|
logger.debug(f"날짜 확인: 최근 {len(recent_dates)}개 날짜 요소 검사")
|
|
|
|
for i, date_elem in enumerate(recent_dates):
|
|
try:
|
|
date_text = date_elem.text.strip()
|
|
if date_text.endswith('.'):
|
|
date_text = date_text[:-1]
|
|
|
|
if not date_text:
|
|
continue
|
|
|
|
review_date = datetime.strptime(date_text, '%Y.%m.%d')
|
|
logger.debug(f" 날짜 [{i+1}]: {date_text} vs 기준일 {self.cutoff_date.strftime('%Y.%m.%d')}")
|
|
|
|
if review_date < self.cutoff_date:
|
|
logger.info(f"날짜 기준 충족: {date_text} < {self.cutoff_date.strftime('%Y.%m.%d')}")
|
|
return True, date_text
|
|
|
|
except (ValueError, Exception) as e:
|
|
logger.debug(f" 날짜 파싱 실패 [{i+1}]: {date_text} - {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.warning(f"날짜 확인 중 오류: {e}")
|
|
|
|
return False, None
|
|
|
|
def analyze_html_content(self, html_content, store_id):
|
|
"""HTML 내용 분석 (수정됨: store_id 파라미터 추가)"""
|
|
logger.info("HTML 내용 분석 시작...")
|
|
|
|
try:
|
|
soup = BeautifulSoup(html_content, 'html.parser')
|
|
logger.info("BeautifulSoup 파싱 완료")
|
|
|
|
# 가게 정보 추출
|
|
logger.info("가게 정보 추출 중...")
|
|
self.store_info = self._extract_store_info(soup, store_id)
|
|
logger.info(f"가게 정보 추출 완료: {self.store_info}")
|
|
|
|
# 리뷰 추출
|
|
logger.info("리뷰 목록 추출 중...")
|
|
self.reviews = self._extract_reviews(soup)
|
|
logger.info(f"리뷰 추출 완료: {len(self.reviews)}개")
|
|
|
|
return self.store_info, self.reviews
|
|
|
|
except Exception as e:
|
|
logger.error(f"HTML 분석 중 오류: {e}")
|
|
raise
|
|
|
|
def _extract_store_info(self, soup, store_id):
|
|
"""가게 정보 추출 (수정됨: id 필드 추가)"""
|
|
store_info = {
|
|
'id': store_id, # 추가: 가게 ID 설정
|
|
'name': '',
|
|
'category': '',
|
|
'rating': '',
|
|
'review_count': '',
|
|
'status': '',
|
|
'address': ''
|
|
}
|
|
|
|
# 가게명 (수정: '장소명' 제거)
|
|
store_name = soup.select_one('h3.tit_place')
|
|
if store_name:
|
|
name_text = store_name.get_text(strip=True)
|
|
# '장소명' 텍스트 제거
|
|
name_text = name_text.replace('장소명', '').strip()
|
|
store_info['name'] = name_text
|
|
logger.info(f" 가게ID: {store_info['id']}")
|
|
logger.info(f" 가게명: {store_info['name']}")
|
|
else:
|
|
logger.warning(" 가게명을 찾을 수 없음")
|
|
|
|
# 카테고리 (수정: '장소 카테고리' 제거)
|
|
category = soup.select_one('span.info_cate')
|
|
if category:
|
|
category_text = category.get_text(strip=True)
|
|
# '장소 카테고리' 텍스트 제거
|
|
category_text = category_text.replace('장소 카테고리', '').strip()
|
|
store_info['category'] = category_text
|
|
logger.info(f" 카테고리: {store_info['category']}")
|
|
else:
|
|
logger.warning(" 카테고리를 찾을 수 없음")
|
|
|
|
# 별점
|
|
rating = soup.select_one('span.num_star')
|
|
if rating:
|
|
store_info['rating'] = rating.get_text(strip=True)
|
|
logger.info(f" 별점: {store_info['rating']}")
|
|
else:
|
|
logger.warning(" 별점을 찾을 수 없음")
|
|
|
|
# 후기 수
|
|
review_count = soup.select_one('span.info_num')
|
|
if review_count:
|
|
count_text = review_count.get_text(strip=True)
|
|
numbers = re.findall(r'\d+', count_text)
|
|
if numbers:
|
|
store_info['review_count'] = numbers[0]
|
|
logger.info(f" 후기 수: {store_info['review_count']}")
|
|
else:
|
|
logger.warning(" 후기 수를 찾을 수 없음")
|
|
|
|
# 영업 상태
|
|
status = soup.select_one('span.info_state')
|
|
if status:
|
|
store_info['status'] = status.get_text(strip=True)
|
|
logger.info(f" 영업 상태: {store_info['status']}")
|
|
else:
|
|
logger.warning(" 영업 상태를 찾을 수 없음")
|
|
|
|
# 주소
|
|
address_meta = soup.find('meta', property='og:description')
|
|
if address_meta:
|
|
store_info['address'] = address_meta.get('content', '')
|
|
logger.info(f" 주소: {store_info['address']}")
|
|
else:
|
|
logger.warning(" 주소를 찾을 수 없음")
|
|
|
|
return store_info
|
|
|
|
def _extract_reviews(self, soup):
|
|
"""리뷰 목록 추출 (로깅 강화)"""
|
|
reviews = []
|
|
|
|
review_list = soup.select_one('ul.list_review')
|
|
if not review_list:
|
|
logger.warning("리뷰 목록 컨테이너를 찾을 수 없음")
|
|
return reviews
|
|
|
|
review_items = review_list.find_all('li', recursive=False)
|
|
logger.info(f"발견된 리뷰 아이템: {len(review_items)}개")
|
|
|
|
skipped_count = 0
|
|
processed_count = 0
|
|
|
|
for i, item in enumerate(review_items):
|
|
try:
|
|
if (i + 1) % 10 == 0:
|
|
logger.info(f"리뷰 처리 진행: {i+1}/{len(review_items)}")
|
|
|
|
review_data = self._extract_single_review(item)
|
|
if review_data and review_data.get('reviewer_name'):
|
|
if self._is_review_within_date_range(review_data):
|
|
reviews.append(review_data)
|
|
processed_count += 1
|
|
else:
|
|
skipped_count += 1
|
|
if skipped_count > 5:
|
|
logger.info(f"연속 오래된 리뷰로 추출 중단 (총 {len(reviews)}개)")
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.warning(f"리뷰 #{i+1} 추출 실패: {e}")
|
|
|
|
logger.info(f"리뷰 추출 완료: 처리됨={processed_count}개, 제외됨={skipped_count}개, 총={len(reviews)}개")
|
|
return reviews
|
|
|
|
def _extract_single_review(self, item):
|
|
"""개별 리뷰 데이터 추출 (수정됨: reviewer_name에서 '리뷰어 이름, ' 제거)"""
|
|
review_data = {
|
|
'reviewer_name': '',
|
|
'reviewer_level': '',
|
|
'reviewer_stats': {},
|
|
'rating': 0,
|
|
'date': '',
|
|
'content': '',
|
|
'badges': [],
|
|
'likes': 0,
|
|
'photo_count': 0,
|
|
'has_photos': False
|
|
}
|
|
|
|
# 리뷰어 이름 (수정: '리뷰어 이름, ' 제거)
|
|
name_elem = item.select_one('span.name_user')
|
|
if name_elem:
|
|
name_text = name_elem.get_text(strip=True)
|
|
# '리뷰어 이름, ' 텍스트 제거 (더 확실한 방법)
|
|
if '리뷰어 이름,' in name_text:
|
|
review_data['reviewer_name'] = name_text.replace('리뷰어 이름,', '').strip()
|
|
else:
|
|
review_data['reviewer_name'] = name_text
|
|
|
|
# 리뷰어 레벨
|
|
level_elem = item.select_one('span.txt_badge')
|
|
if level_elem:
|
|
review_data['reviewer_level'] = level_elem.get_text(strip=True)
|
|
|
|
# 리뷰어 통계
|
|
detail_list = item.select_one('ul.list_detail')
|
|
if detail_list:
|
|
stats_json = {}
|
|
for li in detail_list.find_all('li'):
|
|
stat = li.get_text(strip=True)
|
|
if '후기' in stat:
|
|
numbers = re.findall(r'\d+', stat)
|
|
if numbers:
|
|
stats_json['reviews'] = int(numbers[0])
|
|
elif '별점평균' in stat:
|
|
numbers = re.findall(r'\d+\.?\d*', stat)
|
|
if numbers:
|
|
stats_json['average_rating'] = float(numbers[0])
|
|
elif '팔로워' in stat:
|
|
numbers = re.findall(r'\d+', stat)
|
|
if numbers:
|
|
stats_json['followers'] = int(numbers[0])
|
|
review_data['reviewer_stats'] = stats_json
|
|
|
|
# 별점
|
|
star_wrapper = item.select_one('span.wrap_grade')
|
|
if star_wrapper:
|
|
stars = star_wrapper.select('span.figure_star.on')
|
|
review_data['rating'] = len(stars)
|
|
|
|
# 날짜
|
|
date_elem = item.select_one('span.txt_date')
|
|
if date_elem:
|
|
date_text = date_elem.get_text(strip=True)
|
|
if date_text.endswith('.'):
|
|
date_text = date_text[:-1]
|
|
review_data['date'] = date_text
|
|
|
|
# 리뷰 내용
|
|
content_elem = item.select_one('p.desc_review')
|
|
if content_elem:
|
|
review_data['content'] = content_elem.get_text(strip=True)
|
|
|
|
# 태그/배지
|
|
badges = item.select('span.badge_point')
|
|
for badge in badges:
|
|
badge_text = badge.get_text(strip=True)
|
|
if badge_text and badge_text not in review_data['badges']:
|
|
review_data['badges'].append(badge_text)
|
|
|
|
# 좋아요 수
|
|
like_btn = item.select_one('button .txt_btn')
|
|
if like_btn:
|
|
like_text = like_btn.get_text(strip=True)
|
|
try:
|
|
review_data['likes'] = int(like_text)
|
|
except ValueError:
|
|
review_data['likes'] = 0
|
|
|
|
# 사진 개수
|
|
photos = item.select('.review_thumb img, .thumb_img img')
|
|
review_data['photo_count'] = len(photos)
|
|
review_data['has_photos'] = len(photos) > 0
|
|
|
|
return review_data
|
|
|
|
def _is_review_within_date_range(self, review_data):
|
|
"""리뷰가 날짜 범위 내에 있는지 확인"""
|
|
if not self.cutoff_date:
|
|
return True
|
|
|
|
date_str = review_data.get('date', '')
|
|
if not date_str:
|
|
return True
|
|
|
|
try:
|
|
if date_str.endswith('.'):
|
|
date_str = date_str[:-1]
|
|
review_date = datetime.strptime(date_str, '%Y.%m.%d')
|
|
return review_date >= self.cutoff_date
|
|
except ValueError:
|
|
return True
|
|
|
|
# API 엔드포인트
|
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
async def root():
|
|
"""메인 페이지 (진단 정보 포함)"""
|
|
|
|
# 진단 정보 생성
|
|
diagnosis_info = f"""
|
|
<div class="diagnosis">
|
|
<h3>🔧 시스템 진단 정보</h3>
|
|
<ul>
|
|
<li><strong>Selenium 사용 가능:</strong> {'✅ 예' if SELENIUM_AVAILABLE else '❌ 아니오'}</li>
|
|
<li><strong>Chrome 설치됨:</strong> {'✅ 예' if chrome_available else '❌ 아니오'}</li>
|
|
<li><strong>Import 오류 개수:</strong> {len(SELENIUM_IMPORT_ERRORS)}</li>
|
|
<li><strong>Python 버전:</strong> {sys.version.split()[0]}</li>
|
|
<li><strong>플랫폼:</strong> {platform.platform()}</li>
|
|
</ul>
|
|
|
|
{f'''
|
|
<h4>❌ Import 오류 목록:</h4>
|
|
<ul>
|
|
{''.join([f"<li>{error}</li>" for error in SELENIUM_IMPORT_ERRORS])}
|
|
</ul>
|
|
''' if SELENIUM_IMPORT_ERRORS else ''}
|
|
</div>
|
|
"""
|
|
|
|
warning_section = ""
|
|
if config.LEGAL_WARNING_ENABLED:
|
|
warning_section = f"""
|
|
<div class="warning">
|
|
<h2>⚠️ 중요 법적 경고사항</h2>
|
|
<p><strong>{config.APP_DESCRIPTION}</strong></p>
|
|
<ul>
|
|
<li>실제 웹사이트 크롤링은 불법입니다</li>
|
|
<li>카카오 이용약관 위반 위험</li>
|
|
<li>개인정보보호법 위반 위험</li>
|
|
<li>업무방해죄 처벌 가능</li>
|
|
</ul>
|
|
<p><strong>합법적 대안을 사용하세요:</strong></p>
|
|
<ul>
|
|
<li>카카오 공식 API (developers.kakao.com)</li>
|
|
<li>정식 파트너십 체결</li>
|
|
<li>사용자 동의 기반 데이터 수집</li>
|
|
</ul>
|
|
</div>
|
|
"""
|
|
|
|
return f"""
|
|
<html>
|
|
<head>
|
|
<title>{config.APP_TITLE}</title>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
.warning {{ background: #fff3cd; border: 1px solid #ffeaa7; padding: 20px; margin: 20px 0; border-radius: 5px; }}
|
|
.diagnosis {{ background: #e8f4fd; border: 1px solid #bee5eb; padding: 20px; margin: 20px 0; border-radius: 5px; }}
|
|
.header {{ background: #007bff; color: white; padding: 20px; border-radius: 5px; }}
|
|
.info {{ background: #e8f4fd; border: 1px solid #bee5eb; padding: 15px; margin: 15px 0; border-radius: 5px; }}
|
|
.link {{ display: inline-block; background: #28a745; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 10px 5px; }}
|
|
.error {{ background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; margin: 15px 0; border-radius: 5px; color: #721c24; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🔍 {config.APP_TITLE}</h1>
|
|
<p>버전: {config.APP_VERSION}</p>
|
|
<p>환경: 로컬 개발</p>
|
|
<p>연락처: {config.CONTACT_EMAIL}</p>
|
|
</div>
|
|
|
|
{diagnosis_info}
|
|
|
|
{warning_section}
|
|
|
|
<div class="info">
|
|
<h2>🔧 환경 설정 정보</h2>
|
|
<ul>
|
|
<li><strong>기본 최대 시간:</strong> {config.DEFAULT_MAX_TIME}초</li>
|
|
<li><strong>기본 날짜 제한:</strong> {config.DEFAULT_DAYS_LIMIT}일</li>
|
|
<li><strong>스크롤 체크 간격:</strong> {config.SCROLL_CHECK_INTERVAL}회</li>
|
|
<li><strong>Chrome 옵션:</strong> {len(config.CHROME_OPTIONS_LIST)}개 설정</li>
|
|
<li><strong>로그 레벨:</strong> {config.LOG_LEVEL}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<h2>📚 API 문서</h2>
|
|
<a href="/docs" class="link">Swagger UI 문서</a>
|
|
<a href="/redoc" class="link">ReDoc 문서</a>
|
|
<a href="/health" class="link">헬스 체크</a>
|
|
<a href="/diagnostic" class="link">진단 정보</a>
|
|
|
|
<h2>🛠️ 사용 방법</h2>
|
|
<p><strong>POST /analyze</strong> - 리뷰 분석 수행</p>
|
|
<pre>
|
|
{{
|
|
"store_id": "501745730",
|
|
"days_limit": {config.DEFAULT_DAYS_LIMIT},
|
|
"max_time": {config.DEFAULT_MAX_TIME}
|
|
}}
|
|
</pre>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.post(
|
|
"/analyze",
|
|
response_model=ReviewAnalysisResponse,
|
|
summary="카카오맵 리뷰 분석 (로깅 강화)",
|
|
description=f"""
|
|
**⚠️ {config.APP_DESCRIPTION}**
|
|
|
|
**진단 정보:**
|
|
- Selenium 사용 가능: {SELENIUM_AVAILABLE}
|
|
- Chrome 설치됨: {chrome_available}
|
|
- Import 오류 개수: {len(SELENIUM_IMPORT_ERRORS)}
|
|
|
|
지정한 카카오맵 가게의 리뷰를 분석합니다.
|
|
|
|
**주요 기능:**
|
|
- 실시간 리뷰 수집 및 분석
|
|
- 날짜 범위 필터링 (최대 {config.MAX_DAYS_LIMIT}일)
|
|
- 감정 분석 및 통계
|
|
- JSON 형태 응답
|
|
- 상세한 로깅 및 디버그 정보
|
|
|
|
**응답 시간:** 설정에 따라 {config.MIN_MAX_TIME//60}-{config.MAX_MAX_TIME//60}분 소요
|
|
|
|
**환경:** 로컬 개발 버전
|
|
""",
|
|
responses={
|
|
200: {"description": "분석 성공", "model": ReviewAnalysisResponse},
|
|
400: {"description": "잘못된 요청", "model": ErrorResponse},
|
|
500: {"description": "서버 오류", "model": ErrorResponse}
|
|
}
|
|
)
|
|
async def analyze_reviews(request: ReviewAnalysisRequest):
|
|
"""리뷰 분석 API (수정됨: environment 섹션 제거)"""
|
|
|
|
logger.info("="*60)
|
|
logger.info("analyze_reviews API 호출")
|
|
logger.info("="*60)
|
|
logger.info(f"요청 파라미터: {request}")
|
|
|
|
# Selenium 사용 가능성 확인
|
|
if not SELENIUM_AVAILABLE:
|
|
error_detail = {
|
|
"success": False,
|
|
"error": "SELENIUM_NOT_AVAILABLE",
|
|
"message": "Selenium이 설치되지 않았습니다. pip install selenium webdriver-manager",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"debug_info": {
|
|
"import_errors": SELENIUM_IMPORT_ERRORS,
|
|
"python_version": sys.version,
|
|
"platform": platform.platform(),
|
|
"chrome_available": chrome_available
|
|
}
|
|
}
|
|
logger.error("Selenium 사용 불가능!")
|
|
logger.error(f"Import 오류: {SELENIUM_IMPORT_ERRORS}")
|
|
|
|
raise HTTPException(status_code=500, detail=error_detail)
|
|
|
|
# Chrome 사용 가능성 확인
|
|
if not chrome_available:
|
|
error_detail = {
|
|
"success": False,
|
|
"error": "CHROME_NOT_AVAILABLE",
|
|
"message": "Chrome 브라우저가 설치되지 않았습니다.",
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
logger.error("Chrome 브라우저 사용 불가능!")
|
|
raise HTTPException(status_code=500, detail=error_detail)
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
logger.info(f"리뷰 분석 시작: store_id={request.store_id}, days_limit={request.days_limit}, max_time={request.max_time}")
|
|
|
|
# 분석기 실행
|
|
def run_analysis():
|
|
analyzer = KakaoReviewAnalyzerAPI()
|
|
return analyzer.analyze_by_store_id(
|
|
store_id=request.store_id,
|
|
days_limit=request.days_limit,
|
|
max_scroll_time=request.max_time
|
|
)
|
|
|
|
# 스레드에서 실행
|
|
loop = asyncio.get_event_loop()
|
|
store_info, reviews = await loop.run_in_executor(None, run_analysis)
|
|
|
|
execution_time = time.time() - start_time
|
|
|
|
# 응답 데이터 구성 (environment 섹션 제거됨)
|
|
response_data = ReviewAnalysisResponse(
|
|
success=True,
|
|
message="분석이 성공적으로 완료되었습니다.",
|
|
store_info=StoreInfo(**store_info) if store_info else None,
|
|
reviews=[
|
|
ReviewData(
|
|
reviewer_name=review['reviewer_name'],
|
|
reviewer_level=review['reviewer_level'],
|
|
reviewer_stats=ReviewerStats(**review['reviewer_stats']),
|
|
rating=review['rating'],
|
|
date=review['date'],
|
|
content=review['content'],
|
|
badges=review['badges'],
|
|
likes=review['likes'],
|
|
photo_count=review['photo_count'],
|
|
has_photos=review['has_photos']
|
|
) for review in reviews
|
|
],
|
|
analysis_date=datetime.now().isoformat(),
|
|
total_reviews=len(reviews),
|
|
date_filter=DateFilter(
|
|
cutoff_date=(datetime.now() - timedelta(days=request.days_limit)).isoformat() if request.days_limit else None,
|
|
filtered=request.days_limit is not None
|
|
),
|
|
execution_time=execution_time
|
|
)
|
|
|
|
logger.info(f"분석 완료: {len(reviews)}개 리뷰, {execution_time:.1f}초 소요")
|
|
logger.info("="*60)
|
|
return response_data
|
|
|
|
except Exception as e:
|
|
execution_time = time.time() - start_time
|
|
logger.error(f"분석 실패: {str(e)}")
|
|
logger.error("="*60)
|
|
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"success": False,
|
|
"error": "ANALYSIS_FAILED",
|
|
"message": f"리뷰 분석 중 오류가 발생했습니다: {str(e)}",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"execution_time": execution_time,
|
|
"debug_info": {
|
|
"selenium_available": SELENIUM_AVAILABLE,
|
|
"chrome_available": chrome_available,
|
|
"import_errors": SELENIUM_IMPORT_ERRORS,
|
|
"request_params": {
|
|
"store_id": request.store_id,
|
|
"days_limit": request.days_limit,
|
|
"max_time": request.max_time
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
@app.get("/diagnostic", summary="상세 진단 정보", description="시스템 상태를 상세히 진단합니다.")
|
|
async def diagnostic():
|
|
"""상세 진단 정보 제공"""
|
|
|
|
# 패키지 버전 확인
|
|
package_info = {}
|
|
required_packages = ['selenium', 'webdriver-manager', 'beautifulsoup4', 'fastapi', 'uvicorn', 'pydantic']
|
|
|
|
for package in required_packages:
|
|
try:
|
|
result = subprocess.run([sys.executable, '-m', 'pip', 'show', package],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
version_line = [line for line in result.stdout.split('\n') if line.startswith('Version:')]
|
|
version = version_line[0].split(':')[1].strip() if version_line else 'Unknown'
|
|
package_info[package] = {"installed": True, "version": version}
|
|
else:
|
|
package_info[package] = {"installed": False, "version": None}
|
|
except Exception as e:
|
|
package_info[package] = {"installed": False, "error": str(e)}
|
|
|
|
return {
|
|
"system_info": {
|
|
"python_version": sys.version,
|
|
"platform": platform.platform(),
|
|
"architecture": platform.architecture(),
|
|
"current_directory": os.getcwd(),
|
|
"python_path": sys.path[:5] # 처음 5개만
|
|
},
|
|
"selenium_info": {
|
|
"available": SELENIUM_AVAILABLE,
|
|
"import_errors": SELENIUM_IMPORT_ERRORS,
|
|
"imported_modules": list(imported_modules.keys()) if SELENIUM_AVAILABLE else []
|
|
},
|
|
"chrome_info": {
|
|
"available": chrome_available,
|
|
"common_paths_checked": [
|
|
'/usr/bin/google-chrome',
|
|
'/usr/bin/google-chrome-stable',
|
|
'/usr/bin/chromium-browser',
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
]
|
|
},
|
|
"package_info": package_info,
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
@app.get(
|
|
"/health",
|
|
summary="헬스 체크",
|
|
description="API 서버 상태를 확인합니다."
|
|
)
|
|
async def health_check():
|
|
"""헬스 체크 엔드포인트 (진단 정보 포함)"""
|
|
return {
|
|
"status": "healthy",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"selenium_available": SELENIUM_AVAILABLE,
|
|
"chrome_available": chrome_available,
|
|
"version": config.APP_VERSION,
|
|
"message": f"{config.APP_TITLE}이 정상 작동 중입니다." if SELENIUM_AVAILABLE else "Selenium 모듈 문제로 제한된 기능만 사용 가능합니다."
|
|
}
|
|
|
|
@app.get("/config", summary="환경 설정 확인", description="현재 적용된 환경 변수 설정을 확인합니다.")
|
|
async def get_config():
|
|
"""환경 설정 확인 엔드포인트"""
|
|
return {
|
|
"app_info": {
|
|
"title": config.APP_TITLE,
|
|
"version": config.APP_VERSION,
|
|
"description": config.APP_DESCRIPTION
|
|
},
|
|
"server_config": {
|
|
"host": config.HOST,
|
|
"port": config.PORT,
|
|
"workers": config.WORKERS,
|
|
"log_level": config.LOG_LEVEL
|
|
},
|
|
"api_defaults": {
|
|
"default_max_time": config.DEFAULT_MAX_TIME,
|
|
"default_days_limit": config.DEFAULT_DAYS_LIMIT,
|
|
"max_days_limit": config.MAX_DAYS_LIMIT,
|
|
"min_max_time": config.MIN_MAX_TIME,
|
|
"max_max_time": config.MAX_MAX_TIME
|
|
},
|
|
"scroll_settings": {
|
|
"check_interval": config.SCROLL_CHECK_INTERVAL,
|
|
"no_change_limit": config.SCROLL_NO_CHANGE_LIMIT,
|
|
"wait_time_short": config.SCROLL_WAIT_TIME_SHORT,
|
|
"wait_time_long": config.SCROLL_WAIT_TIME_LONG
|
|
},
|
|
"chrome_options": {
|
|
"count": len(config.CHROME_OPTIONS_LIST),
|
|
"options": config.CHROME_OPTIONS_LIST
|
|
},
|
|
"features": {
|
|
"legal_warning_enabled": config.LEGAL_WARNING_ENABLED,
|
|
"contact_email": config.CONTACT_EMAIL,
|
|
"health_check_timeout": config.HEALTH_CHECK_TIMEOUT
|
|
},
|
|
"diagnosis": {
|
|
"selenium_available": SELENIUM_AVAILABLE,
|
|
"chrome_available": chrome_available,
|
|
"import_error_count": len(SELENIUM_IMPORT_ERRORS)
|
|
},
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
@app.get("/legal-warning", summary="법적 경고사항", description="API 사용 시 주의해야 할 법적 사항을 안내합니다.")
|
|
async def legal_warning():
|
|
"""법적 경고사항 (환경변수 활용)"""
|
|
return {
|
|
"warning": f"⚠️ {config.APP_DESCRIPTION}",
|
|
"legal_risks": [
|
|
"카카오 이용약관 위반 → 계정 정지, 법적 조치",
|
|
"개인정보보호법(PIPA) 위반 → 과태료 최대 3억원",
|
|
"저작권 및 데이터베이스권 침해 → 손해배상",
|
|
"업무방해죄 → 5년 이하 징역 또는 1천500만원 이하 벌금"
|
|
],
|
|
"legal_alternatives": [
|
|
"카카오 공식 API 활용 (developers.kakao.com)",
|
|
"점주 대상 자체 가게 관리 서비스 개발",
|
|
"사용자 동의 기반 데이터 수집 앱",
|
|
"카카오와 정식 파트너십 체결"
|
|
],
|
|
"message": "이 API를 실제 서비스에 사용하지 마세요!",
|
|
"contact": config.CONTACT_EMAIL,
|
|
"version": config.APP_VERSION,
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
print("🚨 " + "="*60)
|
|
print(" ⚠️ 중요 법적 경고사항 ⚠️")
|
|
print("="*64)
|
|
print(f"❌ {config.APP_DESCRIPTION}")
|
|
print("❌ 실제 웹사이트 크롤링은 불법입니다.")
|
|
print("✅ 카카오 공식 API를 사용하세요: developers.kakao.com")
|
|
print("="*64)
|
|
print()
|
|
|
|
print(f"🚀 {config.APP_TITLE} 서버 시작")
|
|
print(f"📊 진단 정보:")
|
|
print(f" - Selenium 사용 가능: {SELENIUM_AVAILABLE}")
|
|
print(f" - Chrome 설치됨: {chrome_available}")
|
|
print(f" - Import 오류: {len(SELENIUM_IMPORT_ERRORS)}개")
|
|
print()
|
|
print(f"📚 Swagger 문서: http://{config.HOST}:{config.PORT}/docs")
|
|
print(f"📖 ReDoc 문서: http://{config.HOST}:{config.PORT}/redoc")
|
|
print(f"🏠 메인 페이지: http://{config.HOST}:{config.PORT}/")
|
|
print(f"🔧 진단 정보: http://{config.HOST}:{config.PORT}/diagnostic")
|
|
print()
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host=config.HOST,
|
|
port=config.PORT,
|
|
log_level=config.LOG_LEVEL
|
|
)
|
|
|