웹훅 통합 및 프로젝트 구조 변경 및 개선

This commit is contained in:
2025-12-19 10:15:55 +09:00
parent d2fbfa46c1
commit 27d9a77513
12 changed files with 715 additions and 202 deletions

293
app/api_server.py Normal file
View File

@ -0,0 +1,293 @@
import os
import requests
import json
import re
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 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'
}
response = requests.get(url, params=params, timeout=10)
data = response.json()
if data['response']['header']['resultCode'] != '00':
return [], 0.0
rainfall_by_hour = {}
for item in data['response']['body']['items']['item']:
if item['category'] == 'RN1': # 1시간 강수량
hour = int(item['fcstTime'][:2])
if 10 <= hour <= 21: # 10시~21시(오후 9시)
rainfall_by_hour[hour] = parse_rainfall_value(item['fcstValue'])
hourly_list = [(h, rainfall_by_hour.get(h, 0.0)) for h in range(10, 22)]
total = sum(rain for _, rain in hourly_list)
return hourly_list, total
except Exception as e:
logger.error(f"API 강수량 예보 조회 실패: {e}")
return [], 0.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("✅ 식음료 2만원 이상 결제 시 무료입장권 제공")
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)

View File

@ -8,9 +8,23 @@
# datetime
# External packages (pip install 필요)
# 이미지 처리
Pillow
# 데이터베이스
PyMySQL
# FTP
ftputil
# HTTP 요청
requests
# 웹 자동화
selenium
# 환경 변수
python-dotenv
# 웹 프레임워크
flask

View File

@ -2,6 +2,9 @@ import logging
import os
import sys
import time
import sqlite3
import re
from datetime import datetime
from config import TODAY
from selenium_manager import SeleniumManager
@ -15,9 +18,191 @@ logger = logging.getLogger(__name__)
WEATHER_URL = 'https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948'
OUTPUT_DIR = '/data'
OUTPUT_FILENAME = f'weather_capture_{TODAY}.png'
DB_PATH = '/data/weather.sqlite'
def parse_rainfall(value):
"""
강수량 텍스트를 숫자(mm)로 변환
- '-'는 0.0
- '~1'은 0.5
- 숫자만 있으면 float로 변환
"""
if not value or value.strip() == '-':
return 0.0
elif '~' in value: # '~1mm' 형태
return 0.5
else:
try:
return float(re.search(r'[\d.]+', value).group())
except (AttributeError, ValueError):
logger.warning(f"강수량 파싱 실패: {value}")
return 0.0
def extract_rainfall_from_page(driver):
"""
Selenium driver에서 시간별 강수량 데이터 추출
10시~21시(오후 9시) 데이터만 수집
Returns:
dict: {시간(int): 강수량(float)} 형태, 또는 None (실패 시)
"""
try:
logger.info("페이지에서 강수량 데이터 추출 시작...")
time.sleep(1) # 페이지 로드 대기
# 테이블에서 시간별 강수량 추출
# 기상청 웹사이트 구조에 맞게 조정 필요
rainfall_data = {}
# 방법 1: 테이블 행(tr) 순회
try:
rows = driver.find_elements("xpath", "//table//tr")
if not rows:
logger.warning("테이블 행을 찾을 수 없음, 대체 방법 시도...")
return extract_rainfall_alternative(driver)
for row in rows:
try:
# 각 행에서 시간과 강수량 추출
cells = row.find_elements("tag name", "td")
if len(cells) >= 2:
time_cell = cells[0].text.strip()
rain_cell = cells[1].text.strip()
# 시간 파싱 (HH:00 형태)
time_match = re.search(r'(\d{1,2}):?00?', time_cell)
if time_match:
hour = int(time_match.group(1))
if 10 <= hour <= 21: # 10시~21시(오후 9시)
rainfall = parse_rainfall(rain_cell)
rainfall_data[hour] = rainfall
logger.info(f" {hour:02d}:00 → {rainfall}mm")
except Exception as e:
logger.debug(f"행 파싱 중 오류: {e}")
continue
except Exception as e:
logger.warning(f"테이블 파싱 실패: {e}, 대체 방법 시도...")
return extract_rainfall_alternative(driver)
if rainfall_data:
total = sum(rainfall_data.values())
logger.info(f"총 강수량: {total:.1f}mm")
return rainfall_data
else:
logger.warning("추출된 강수량 데이터가 없음")
return None
except Exception as e:
logger.error(f"강수량 데이터 추출 중 오류: {type(e).__name__}: {e}", exc_info=True)
return None
def extract_rainfall_alternative(driver):
"""
대체 방법: span/div 엘리먼트에서 강수량 추출
"""
try:
logger.info("대체 방법으로 강수량 추출 시도...")
# 기상청 사이트의 실제 구조에 맞게 조정
rainfall_data = {}
# 시간 레이블 찾기
hour_elements = driver.find_elements("xpath", "//span[contains(text(), '')]")
for elem in hour_elements:
try:
text = elem.text
match = re.search(r'(\d{1,2})시', text)
if match:
hour = int(match.group(1))
if 10 <= hour <= 21:
# 시간 엘리먼트 다음 형제에서 강수량 찾기
parent = elem.find_element("xpath", "./ancestor::*[position()=3]")
rain_elem = parent.find_element("xpath", ".//span[last()]")
rainfall = parse_rainfall(rain_elem.text)
rainfall_data[hour] = rainfall
logger.info(f" {hour:02d}:00 → {rainfall}mm")
except Exception as e:
logger.debug(f"대체 방법 파싱 중 오류: {e}")
continue
return rainfall_data if rainfall_data else None
except Exception as e:
logger.error(f"대체 방법 실패: {e}")
return None
def save_rainfall_to_db(rainfall_data):
"""
추출한 강수량 데이터를 SQLite DB에 저장
Args:
rainfall_data: {시간(int): 강수량(float)} 딕셔너리
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 = ?', (TODAY,))
curs.execute('DELETE FROM rainfall_summary WHERE date = ?', (TODAY,))
# 시간별 강수량 저장
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 (?, ?, ?)',
(TODAY, hour, rainfall)
)
total_rainfall += rainfall
logger.info(f"DB 저장: {TODAY} {hour:02d}:00 → {rainfall}mm")
# 합계 저장
capture_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
curs.execute(
'INSERT INTO rainfall_summary (date, total_rainfall, capture_time) VALUES (?, ?, ?)',
(TODAY, total_rainfall, capture_time)
)
conn.commit()
conn.close()
logger.info(f"[DB 저장 완료] {TODAY} 총 강수량: {total_rainfall:.1f}mm")
return True
except Exception as e:
logger.error(f"DB 저장 중 오류: {type(e).__name__}: {e}", exc_info=True)
return False
def capture_weather():
"""기상청 날씨 정보 캡처"""
"""기상청 날씨 정보 캡처 및 강수량 데이터 추출"""
# 저장 경로 설정
output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME)
@ -47,6 +232,13 @@ def capture_weather():
# 페이지 반영 대기
time.sleep(2)
# 강수량 데이터 추출
rainfall_data = extract_rainfall_from_page(manager.driver)
if rainfall_data:
save_rainfall_to_db(rainfall_data)
else:
logger.warning("강수량 데이터 추출 실패 (캡처는 진행)")
# 스크린샷 저장
logger.info(f"스크린샷 저장 시도: {output_path}")
if manager.take_element_screenshot(manager.WEATHER_SELECTORS['target_element'], output_path):