435 lines
14 KiB
Python
435 lines
14 KiB
Python
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/<path:filename>')
|
|
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)
|