import os import requests import json import re import importlib.util from flask import Flask, request, jsonify, send_from_directory, make_response import sqlite3 from datetime import datetime, timedelta from config import serviceKey import logging # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) app = Flask(__name__) # 환경 변수에서 설정값 불러오기 DB_PATH = '/data/weather.sqlite' DOMAIN = os.getenv('DOMAIN', 'http://localhost:5000') debug_env = os.getenv('FLASK_DEBUG', '0') DEBUG_MODE = debug_env == '1' def parse_rainfall_value(value): """강수량 텍스트를 숫자로 변환""" if not value or value == '-': return 0.0 elif '~' in value: return 0.5 else: try: return float(re.search(r'[\d.]+', value).group()) except (AttributeError, ValueError): return 0.0 def get_captured_rainfall(date): """ 캡처된 강수량 데이터 조회 (당일 기준) Args: date: 'YYYYMMDD' 형식 Returns: tuple: (시간별_강수량_목록, 총강수량, 캡처_시각) """ try: conn = sqlite3.connect(DB_PATH) curs = conn.cursor() curs.execute(''' SELECT hour, rainfall FROM rainfall_capture WHERE date = ? ORDER BY hour ''', (date,)) hourly_data = curs.fetchall() curs.execute(''' SELECT total_rainfall, capture_time FROM rainfall_summary WHERE date = ? ''', (date,)) summary = curs.fetchone() conn.close() total = summary[0] if summary else 0.0 capture_time = summary[1] if summary else None return hourly_data, total, capture_time except Exception as e: logger.error(f"캡처 강수량 조회 실패: {e}") return [], 0.0, None def generate_weather_data(date): """ 날씨 데이터가 없을 때 Selenium으로 데이터 추출하여 SQLite에만 저장 (캡처 이미지 저장, FTP 업로드, 게시글 등록은 하지 않음) Args: date: 'YYYYMMDD' 형식 Returns: bool: 성공 여부 """ try: logger.info(f"날씨 데이터 추출 시작 ({date})...") # weather_capture 모듈에서 필요한 함수들을 동적으로 임포트 import importlib.util spec = importlib.util.spec_from_file_location( "weather_capture", "/app/weather_capture.py" ) weather_capture = importlib.util.module_from_spec(spec) spec.loader.exec_module(weather_capture) # Selenium으로 강우량 데이터 추출 from selenium_manager import SeleniumManager selenium_mgr = SeleniumManager() driver = selenium_mgr.get_driver() try: driver.get(weather_capture.WEATHER_URL) rainfall_data = weather_capture.extract_rainfall_from_page(driver) if rainfall_data: # SQLite에만 저장 (이미지 저장, FTP 업로드는 하지 않음) success = save_rainfall_to_sqlite(rainfall_data, date) logger.info(f"날씨 데이터 추출 완료 ({date}): {success}") return success else: logger.warning(f"강수량 데이터 추출 실패 ({date})") return False finally: selenium_mgr.close_driver() except Exception as e: logger.error(f"날씨 데이터 추출 중 오류: {type(e).__name__}: {e}") return False def save_rainfall_to_sqlite(rainfall_data, date): """ 추출한 강수량 데이터를 SQLite DB에만 저장 (이미지 저장, FTP 업로드, 게시글 등록 없음) Args: rainfall_data: {시간(int): 강수량(float)} 딕셔너리 date: 'YYYYMMDD' 형식 Returns: bool: 성공 여부 """ if not rainfall_data: logger.warning("저장할 강수량 데이터가 없음") return False try: os.makedirs(os.path.dirname(DB_PATH) or '/data', exist_ok=True) conn = sqlite3.connect(DB_PATH) curs = conn.cursor() # 테이블 생성 curs.execute(''' CREATE TABLE IF NOT EXISTS rainfall_capture ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, hour INTEGER NOT NULL, rainfall REAL NOT NULL, UNIQUE(date, hour) ) ''') curs.execute(''' CREATE TABLE IF NOT EXISTS rainfall_summary ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL UNIQUE, total_rainfall REAL NOT NULL, capture_time TEXT NOT NULL ) ''') # 기존 데이터 삭제 curs.execute('DELETE FROM rainfall_capture WHERE date = ?', (date,)) curs.execute('DELETE FROM rainfall_summary WHERE date = ?', (date,)) # 시간별 강수량 저장 total_rainfall = 0.0 for hour in sorted(rainfall_data.keys()): rainfall = rainfall_data[hour] curs.execute( 'INSERT INTO rainfall_capture (date, hour, rainfall) VALUES (?, ?, ?)', (date, hour, rainfall) ) total_rainfall += rainfall # 합계 저장 capture_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') curs.execute( 'INSERT INTO rainfall_summary (date, total_rainfall, capture_time) VALUES (?, ?, ?)', (date, total_rainfall, capture_time) ) conn.commit() conn.close() logger.info(f"SQLite 저장 완료: {date} 총 강수량 {total_rainfall:.1f}mm") return True except Exception as e: logger.error(f"SQLite 저장 실패: {type(e).__name__}: {e}") return False def get_forecast_rainfall(date): """ 기상청 API를 통한 강수량 예보 조회 Args: date: 'YYYYMMDD' 형식 Returns: tuple: (시간별_강수량_목록, 총강수량) 또는 ([], 0.0) """ try: # 기상청 초단기 예보 API url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst" # 예보는 08:50 발표 기준 params = { 'serviceKey': serviceKey, 'numOfRows': '1000', 'pageNo': '1', 'dataType': 'JSON', 'base_date': date, 'base_time': '0850', 'nx': '57', 'ny': '130' } , 'data_status': 'ok' | 'generating' | 'unavailable' } """ today = datetime.now().strftime('%Y%m%d') is_forecast = date_str > today data_status = 'ok' if is_forecast: # 미래 날짜: API 예보 hourly, total = get_forecast_rainfall(date_str) note = "⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다." else: # 당일 이상: 캡처 데이터 hourly, total, timestamp = get_captured_rainfall(date_str) # 데이터가 없을 경우 생성 시도 if not hourly: logger.warning(f"날씨 캡처 데이터 없음 ({date_str}), 데이터 생성 시도...") if generate_weather_data(date_str): # 다시 조회 hourly, total, timestamp = get_captured_rainfall(date_str) if hourly: data_status = 'generating' note = "💡 방금 날씨 데이터를 생성했습니다." else: data_status = 'unavailable' note = "⚠️ 날씨 데이터를 생성했으나 조회할 수 없습니다. 나중에 다시 시도해주세요." else: data_status = 'unavailable' note = "⚠️ 날씨 데이터를 생성할 수 없습니다. 09:00에 자동으로 생성되며, 나중에 다시 시도해주세요." else: note = None return { 'date': date_str, 'is_forecast': is_forecast, 'hourly_data': hourly, 'total': total, 'note': note, 'data_status': data_status.0 def get_rainfall_data(date_str): """ 날짜별 강수량 데이터 조회 - 당일(오늘): 09:00 캡처된 실제 데이터 - 미래 날짜: 기상청 예보 API Args: date_str: 'YYYYMMDD' 형식 Returns: dict: { 'date': 날짜, 'is_forecast': 예보 여부, 'hourly_data': [(hour, rainfall), ...], 'total': 총강수량, 'note': 추가 설명 } """ today = datetime.now().strftime('%Y%m%d') is_forecast = date_str > today if is_forecast: # 미래 날짜: API 예보 hourly, total = get_forecast_rainfall(date_str) note = "⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다." else: # 당일 이상: 캡처 데이터 hourly, total, timestamp = get_captured_rainfall(date_str) note = None return { 'date': date_str, 'is_forecast': is_forecast, 'hourly_data': hourly, 'total': total, 'note': note } # 정적 파일 서빙: /data/ 경로로 이미지 접근 가능하게 함 @app.route('/data/') def serve_data_file(filename): return send_from_directory('/data', filename) @app.route('/webhook', methods=['POST']) def webhook(): """ 카카오 챗봇 웹훅 사용자가 요청한 날짜의 강수량 정보 응답 - 당일: 09:00 캡처 데이터 - 미래: 기상청 API 예보 """ try: data = request.get_json(silent=True) # 사용자 요청 날짜 파싱 (기본값: 오늘) today = datetime.now().strftime('%Y%m%d') query_date = today # 사용자 발화에서 날짜 추출 시도 if data and 'userRequest' in data and 'utterance' in data['userRequest']: utterance = data['userRequest']['utterance'].strip() # 내일, 모레 등의 상대 날짜 파싱 if '내일' in utterance: query_date = (datetime.now() + timedelta(days=1)).strftime('%Y%m%d') elif '모레' in utterance: query_date = (datetime.now() + timedelta(days=2)).strftime('%Y%m%d') elif '오늘' in utterance or utterance in ['', None]: query_date = today else: # YYYYMMDD 형식의 날짜 찾기 date_match = re.search(r'(\d{8})', utterance) if date_match: query_date = date_match.group(1) rainfall_info = get_rainfall_data(query_date) # 응답 메시지 구성 date_obj = datetime.strptime(query_date, '%Y%m%d') date_str = date_obj.strftime('%m월 %d일(%a)') # 강수량 상세 정보 lines = [f"📅 {date_str}"] if rainfall_info['is_forecast']: lines.append("📊 예보 강수량 (08:00 발표 기준)") else: lines.append("📊 실제 강수량 (09:00 캡처 기준)") lines.append("") if rainfall_info['hourly_data']: for hour, rainfall in rainfall_info['hourly_data']: if isinstance(hour, tuple): # (hour, rainfall) 튜플인 경우 hour, rainfall = hour rain_str = f"{rainfall:.1f}mm" if rainfall > 0 else "☀️ 강수 없음" lines.append(f"{hour:02d}:00 → {rain_str}") lines.append("") lines.append(f"💧 총 강수량: {rainfall_info['total']:.1f}mm") # 이벤트 적용 여부 if rainfall_info['total'] > 10: lines.append("✅ 레이니데이 이벤트 기준(10mm 초과) 충족") else: lines.append("❌ 이벤트 기준(10mm 초과)을 충족하지 않음") else: lines.append("데이터를 찾을 수 없습니다.") if rainfall_info['note']: lines.append("") lines.append(rainfall_info['note']) response_text = '\n'.join(lines) # 이미지 포함 여부 확인 (당일만) if not rainfall_info['is_forecast']: image_filename = f"weather_capture_{query_date}.png" image_path = f"/data/{image_filename}" outputs = [{ "simpleText": { "text": response_text } }] if os.path.isfile(image_path): image_url = f"{DOMAIN}/data/{image_filename}" outputs.append({ "image": { "imageUrl": image_url, "altText": f"{date_str} 날씨 캡처" } }) else: outputs = [{ "simpleText": { "text": response_text } }] response_body = { "version": "2.0", "template": { "outputs": outputs } } resp = make_response(jsonify(response_body)) resp.headers['Content-Type'] = 'application/json; charset=utf-8' return resp except Exception as e: logger.error(f"웹훅 처리 중 오류: {e}", exc_info=True) error_body = { "version": "2.0", "template": { "outputs": [{ "simpleText": { "text": f"❌ 오류가 발생했습니다: {str(e)}\n관리자에게 문의하세요." } }] } } resp = make_response(jsonify(error_body)) resp.headers['Content-Type'] = 'application/json; charset=utf-8' return resp # 헬스 체크 엔드포인트 @app.route('/health', methods=['GET']) def health_check(): return jsonify({'status': 'ok', 'timestamp': datetime.now().isoformat()}), 200 if __name__ == '__main__': logger.info("Flask 웹서버 시작 (포트 5000)") app.run(host='0.0.0.0', port=5000, debug=DEBUG_MODE)