.env를 crontab에서 인식하지 못하는 문제 수정

This commit is contained in:
2025-12-19 10:02:54 +09:00
parent 338c0c0d1c
commit d2fbfa46c1
15 changed files with 318 additions and 152 deletions

70
app/config.py Normal file
View File

@ -0,0 +1,70 @@
import os
import sys
from datetime import datetime
# .env 파일 로드 (python-dotenv 사용)
# Docker 환경: docker-compose.yml에서 env_file로 환경 변수 주입 + volume mount로 .env 파일 접근
# 로컬 개발 환경: python-dotenv로 .env 파일 로드
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
TODAY = datetime.now().strftime('%Y%m%d')
def _get_required_env(key: str, description: str = "") -> str:
"""필수 환경 변수 조회. 없으면 에러 출력 후 종료"""
value = os.getenv(key)
if not value:
desc = f" ({description})" if description else ""
error_msg = f"[ERROR] 필수 환경 변수가 설정되지 않았습니다: {key}{desc}"
print(error_msg)
sys.exit(1)
return value
def _get_optional_env(key: str) -> str:
"""선택적 환경 변수 조회. 없으면 빈 문자열 반환"""
return os.getenv(key, '')
# 게시판 설정 (필수)
MAIN = {
'board': _get_required_env('BOARD_ID', '게시판 ID'),
'ca_name': _get_required_env('BOARD_CA_NAME', '게시판 카테고리'),
'subject': '',
'content': _get_required_env('BOARD_CONTENT', '게시판 기본 내용'),
'mb_id': _get_required_env('BOARD_MB_ID', '게시자 ID'),
'nickname': _get_required_env('BOARD_NICKNAME', '게시자 닉네임'),
'file1': '',
'file2': '',
}
# 데이터베이스 설정 (필수)
DB_CONFIG = {
'HOST': _get_required_env('DB_HOST', 'MySQL 호스트'),
'USER': _get_required_env('DB_USER', 'MySQL 사용자명'),
'DBNAME': _get_required_env('DB_NAME', 'MySQL 데이터베이스명'),
'PASS': _get_required_env('DB_PASSWORD', 'MySQL 비밀번호'),
'CHARSET': _get_optional_env('DB_CHARSET') or 'utf8mb4',
}
# FTP 설정 (필수)
FTP_CONFIG = {
'HOST': _get_required_env('FTP_HOST', 'FTP 호스트'),
'USER': _get_required_env('FTP_USER', 'FTP 사용자명'),
'PASS': _get_required_env('FTP_PASSWORD', 'FTP 비밀번호'),
'UPLOAD_DIR': _get_required_env('FTP_UPLOAD_DIR', 'FTP 업로드 디렉토리'),
}
# 날씨 API 서비스 키 (필수)
serviceKey = _get_required_env('SERVICE_KEY', '기상청 API 서비스 키')
# Mattermost 설정 (선택적 - 없어도 실행 가능하지만 알림은 미발송)
MATTERMOST_CONFIG = {
'URL': _get_optional_env('MATTERMOST_URL'),
'TOKEN': _get_optional_env('MATTERMOST_TOKEN'),
'CHANNEL_ID': _get_optional_env('MATTERMOST_CHANNEL_ID'),
}

398
app/gnu_autoupload.py Normal file
View File

@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""
gnu_autoupload.py
기능:
1. Selenium을 이용해 날씨 정보를 캡처 (weather_capture.py 호출)
2. FTP를 이용해 이미지 업로드
3. 그누보드 DB에 게시글 및 첨부파일 정보 자동 등록
4. Mattermost으로 결과 알림 발송
"""
import os
import sys
import time
import subprocess
import tempfile
import hashlib
import logging
from datetime import datetime
from PIL import Image
import pymysql
import ftputil
import traceback
from config import DB_CONFIG, FTP_CONFIG, MAIN, MATTERMOST_CONFIG
from weather import get_precipitation_summary
from send_message import MessageSender
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# ---------------------------
# MessageSender 초기화
# ---------------------------
def init_message_sender():
"""Mattermost 메시지 발송기 초기화"""
try:
mattermost_url = MATTERMOST_CONFIG.get('URL', '')
mattermost_token = MATTERMOST_CONFIG.get('TOKEN', '')
mattermost_channel_id = MATTERMOST_CONFIG.get('CHANNEL_ID', '')
# 설정 확인 로그
logger.info(f"Mattermost 설정: URL={'설정됨' if mattermost_url else '미설정'}, "
f"TOKEN={'설정됨' if mattermost_token else '미설정'}, "
f"CHANNEL_ID={'설정됨' if mattermost_channel_id else '미설정'}")
if not mattermost_url:
logger.warning("Mattermost URL이 설정되지 않았습니다")
return None
msg_sender = MessageSender(
mattermost_url=mattermost_url,
mattermost_token=mattermost_token,
mattermost_channel_id=mattermost_channel_id
)
return msg_sender
except Exception as e:
logger.warning(f"MessageSender 초기화 실패: {e}")
return None
# ---------------------------
# 이미지 캡처 함수
# ---------------------------
def capture_image(script_path, output_path, max_attempts=5, msg_sender=None):
"""
이미지 캡처 시도
Args:
script_path: weather_capture.py 경로
output_path: 캡처 이미지 저장 경로
max_attempts: 최대 시도 횟수
msg_sender: MessageSender 인스턴스
Returns:
tuple: (성공여부, 에러메시지)
"""
for attempt in range(max_attempts):
logger.info(f"이미지 캡처 시도 {attempt + 1}/{max_attempts}")
try:
result = subprocess.run(
[sys.executable, script_path],
check=True,
capture_output=True,
text=True,
timeout=60
)
if os.path.isfile(output_path):
logger.info(f"이미지 캡처 성공: {output_path}")
return True, None
except subprocess.TimeoutExpired:
logger.warning(f"캡처 타임아웃 (시도 {attempt + 1}/{max_attempts})")
except subprocess.CalledProcessError as e:
logger.error(f"weather_capture.py 실행 실패: {e.stderr}")
except Exception as e:
logger.error(f"예상치 못한 오류: {type(e).__name__}: {e}")
time.sleep(2)
# 모든 시도 실패
final_error = f"❌ **날씨 이미지 캡처 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"🔄 시도 횟수: {max_attempts}\n"\
f"📁 출력 경로: `{output_path}`\n"\
f"⚠️ 파일이 생성되지 않았습니다."
logger.error(final_error.replace('\n', ' '))
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(final_error, platforms=['mattermost'])
return False, final_error
# ---------------------------
# 파일 관련 유틸 함수
# ---------------------------
def file_type(ext):
"""확장자에 따른 파일 타입 코드 반환"""
return {
'gif': '1', 'jpeg': '2', 'jpg': '2', 'png': '3', 'swf': '4',
'psd': '5', 'bmp': '6', 'tif': '7', 'tiff': '7', 'jpc': '9',
'jp2': '10', 'jpx': '11', 'jb2': '12', 'swc': '13', 'iff': '14',
'wbmp': '15', 'xbm': '16'
}.get(ext.lower(), '0')
def get_filename(filename):
"""타임스탬프와 해시를 포함한 파일명 생성"""
ms = datetime.now().microsecond
encoded_name = filename.encode('utf-8')
return f'{ms}_{hashlib.sha1(encoded_name).hexdigest()}'
def file_upload(filename, bf_file, msg_sender=None):
"""
FTP 파일 업로드
Args:
filename: 원본 파일 경로
bf_file: FTP에 저장될 파일명
msg_sender: MessageSender 인스턴스
Returns:
bool: 성공 여부
"""
try:
with ftputil.FTPHost(FTP_CONFIG['HOST'], FTP_CONFIG['USER'], FTP_CONFIG['PASS']) as fh:
fh.chdir(FTP_CONFIG['UPLOAD_DIR'])
fh.upload(filename, bf_file)
logger.info(f"FTP 업로드 완료: {filename}{bf_file}")
return True
except Exception as e:
error_msg = f"❌ **FTP 파일 업로드 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📁 파일: `{filename}`\n"\
f"🗂️ 대상: `{bf_file}`\n"\
f"⚠️ 오류: `{type(e).__name__}: {e}`"
logger.error(error_msg.replace('\n', ' '))
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False
# ---------------------------
# 게시글 작성 함수
# ---------------------------
def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_list=None, msg_sender=None):
"""
그누보드 게시글 및 첨부파일 등록
Args:
board: 게시판 ID
subject: 제목
content: 내용
mb_id: 게시자 ID
nickname: 닉네임
ca_name: 카테고리 이름 (선택사항)
file_list: 첨부파일 경로 리스트 (선택사항)
msg_sender: MessageSender 인스턴스
Returns:
tuple: (성공여부, 에러메시지)
"""
conn = None
try:
# DB 연결 정보 디버깅 로그
logger.info(f"DB 연결 시도: HOST={DB_CONFIG['HOST']}, USER={DB_CONFIG['USER']}, DB={DB_CONFIG['DBNAME']}")
conn = pymysql.connect(
host=DB_CONFIG['HOST'],
user=DB_CONFIG['USER'],
db=DB_CONFIG['DBNAME'],
password=DB_CONFIG['PASS'],
charset=DB_CONFIG['CHARSET']
)
curs = conn.cursor()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 게시글 번호 조회
curs.execute(f"SELECT wr_num FROM g5_write_{board}")
wr_num = str(int(curs.fetchone()[0]) - 1)
# 게시글 삽입
curs.execute(f"""INSERT INTO g5_write_{board} SET wr_num = {wr_num},
wr_reply = '', wr_comment = 0, ca_name = %s, wr_option = 'html1', wr_subject = %s,
wr_content = %s, wr_link1 = '', wr_link2 = '', wr_link1_hit = 0, wr_link2_hit = 0,
wr_hit = 1, wr_good = 0, wr_nogood = 0, mb_id = %s, wr_password = '',
wr_name = %s, wr_email = '', wr_homepage = '',
wr_datetime = %s, wr_last = %s,
wr_ip = '111.111.111.111',
wr_comment_reply = '', wr_facebook_user = '', wr_twitter_user = '',
wr_1 = '', wr_2 = '', wr_3 = '', wr_4 = '', wr_5 = '', wr_6 = '', wr_7 = '', wr_8 = '', wr_9 = '', wr_10 = ''
""",
(ca_name, subject, content, mb_id, nickname, now, now))
# 게시글 ID 조회
curs.execute(f"SELECT wr_id FROM g5_write_{board} ORDER BY wr_id DESC LIMIT 1")
wr_id = str(curs.fetchone()[0])
# 부모 글 ID 업데이트
curs.execute(f"UPDATE g5_write_{board} SET wr_parent = {wr_id} WHERE wr_id = {wr_id}")
# 새 게시글 알림 추가
curs.execute(f"""INSERT INTO g5_board_new (bo_table, wr_id, wr_parent, bn_datetime, mb_id)
VALUES (%s, %s, %s, %s, %s)""", (board, wr_id, wr_id, now, mb_id))
# 게시판 글 수 업데이트
curs.execute(f"SELECT bo_count_write FROM g5_board WHERE bo_table = %s", (board,))
bo_count_write = int(curs.fetchone()[0])
curs.execute(f"UPDATE g5_board SET bo_count_write = %s WHERE bo_table = %s",
(bo_count_write + 1, board))
# 첨부파일 처리
file_count = 0
if file_list:
for idx, file in enumerate(file_list):
if not os.path.isfile(file):
logger.warning(f"파일 없음: {file}")
continue
ext = os.path.splitext(file)[1].lstrip('.')
bf_file = f"{get_filename(file)}.{ext}"
if file_upload(file, bf_file, msg_sender):
img_type = file_type(ext)
width, height = (0, 0)
if img_type != '0':
try:
with Image.open(file) as img:
width, height = img.size
except Exception as e:
logger.warning(f"이미지 크기 조회 실패: {e}")
size = os.path.getsize(file)
curs.execute(f"""INSERT INTO g5_board_file SET bo_table = %s, wr_id = %s, bf_no = %s,
bf_source = %s, bf_file = %s, bf_content = '', bf_download = 0,
bf_filesize = %s, bf_width = %s, bf_height = %s, bf_type = %s, bf_datetime = %s""",
(board, wr_id, idx, os.path.basename(file), bf_file,
size, width, height, img_type, now))
file_count += 1
else:
raise Exception(f"파일 업로드 실패: {file}")
# 게시글 파일 수 업데이트
curs.execute(f"UPDATE g5_write_{board} SET wr_file = %s WHERE wr_id = %s", (file_count, wr_id))
conn.commit()
logger.info("게시글 및 첨부파일 등록 완료")
# 성공 메시지 전송
success_msg = f"✅ **게시글 등록 완료**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"📎 첨부파일: {file_count}"
if msg_sender:
msg_sender.send(success_msg, platforms=['mattermost'])
return True, None
except Exception as e:
if conn:
conn.rollback()
# 에러 메시지 생성
error_detail = traceback.format_exc()
error_msg = f"❌ **게시글 등록 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"⚠️ 오류 유형: `{type(e).__name__}`\n"\
f"💬 오류 메시지: {str(e)}\n"\
f"```\n{error_detail}\n```"
logger.error(f"게시글 등록 실패: {type(e).__name__}: {e}")
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False, error_msg
finally:
if conn:
conn.close()
# ---------------------------
# 메인 실행 함수
# ---------------------------
def main():
"""메인 실행 함수"""
logger.info("=== 날씨 정보 게시글 자동 생성 시작 ===")
# MessageSender 초기화
msg_sender = init_message_sender()
if not msg_sender:
logger.warning("Mattermost 알림이 비활성화됩니다")
try:
# 무료입장 조건에 대해서만 안내함.
MAIN["content"] = """
<p>Rainy Day 이벤트 적용안내</p>
<p><b>10:00 ~ 22:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.</b></p>
<p>레이니데이 이벤트 정보 확인</p>
<p><a href="https://firstgarden.co.kr/news/60">이벤트 정보 보기</a></p>
"""
today = datetime.today().strftime('%Y%m%d')
data_dir = '/data' # 파일 저장 및 업로드 디렉토리
capture_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'weather_capture.py')
weather_filename = f'weather_capture_{today}.png'
weather_file = os.path.join(data_dir, weather_filename)
thumb_file = os.path.join(data_dir, 'thumb.jpg')
# 이미지 캡처
success, error = capture_image(capture_script, weather_file, msg_sender=msg_sender)
if not success:
logger.error("이미지 캡처 실패로 게시글 작성 중단")
return
# FTP 업로드 디렉토리 설정
FTP_CONFIG['UPLOAD_DIR'] = f"/www/data/file/{MAIN['board']}/"
MAIN['subject'] = f"{datetime.now().strftime('%Y-%m-%d')} 날씨정보"
MAIN['file1'] = thumb_file
MAIN['file2'] = weather_file
file_list = [MAIN['file1'], MAIN['file2']]
# 게시글 작성
success, error = write_board(
board=MAIN['board'],
subject=MAIN['subject'],
content=MAIN['content'],
mb_id=MAIN['mb_id'],
nickname=MAIN['nickname'],
ca_name=MAIN['ca_name'],
file_list=file_list,
msg_sender=msg_sender
)
if success:
logger.info("=== 날씨 정보 게시글 자동 생성 완료 ===")
else:
logger.error("게시글 작성 실패")
except Exception as e:
error_msg = f"❌ **예상치 못한 오류 발생**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"⚠️ 오류: `{type(e).__name__}: {e}`\n"\
f"```\n{traceback.format_exc()}\n```"
logger.error(error_msg.replace('\n', ' '))
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
if __name__ == "__main__":
main()

16
app/requirements.txt Normal file
View File

@ -0,0 +1,16 @@
# Standard library modules (자동 포함, 설치 불필요)
# os
# sys
# time
# subprocess
# tempfile
# hashlib
# datetime
# External packages (pip install 필요)
Pillow
PyMySQL
ftputil
requests
selenium
python-dotenv

178
app/selenium_manager.py Normal file
View File

@ -0,0 +1,178 @@
import logging
import os
import shutil
import tempfile
from contextlib import contextmanager
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
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.common.exceptions import StaleElementReferenceException, TimeoutException
# 로깅 설정
logger = logging.getLogger(__name__)
class SeleniumManager:
"""Selenium WebDriver 관리 클래스"""
WEATHER_SELECTORS = {
'tab_button': (By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/div[1]/div/div/a[2]'),
'list_button': (By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/ul/div[1]/a[2]'),
'target_element': (By.XPATH, '/html/body/div[1]/main/div[2]/div[1]'),
}
def __init__(self, headless=True, window_size=(1802, 1467), timeout=10):
"""
Args:
headless: 헤드리스 모드 여부
window_size: 브라우저 윈도우 크기
timeout: WebDriverWait 기본 타임아웃 (초)
"""
self.headless = headless
self.window_size = window_size
self.timeout = timeout
self.temp_dir = None
self.driver = None
def _setup_chrome_options(self):
"""Chrome 옵션 설정"""
options = Options()
if self.headless:
options.add_argument('--headless')
options.add_argument(f'--window-size={self.window_size[0]},{self.window_size[1]}')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-gpu')
# 임시 사용자 데이터 디렉토리 생성 (중복 실행 문제 방지)
self.temp_dir = tempfile.mkdtemp()
options.add_argument(f'--user-data-dir={self.temp_dir}')
return options
def start(self):
"""WebDriver 시작"""
try:
options = self._setup_chrome_options()
self.driver = webdriver.Chrome(options=options)
logger.info("Selenium WebDriver 시작됨")
except Exception as e:
logger.error(f"WebDriver 시작 실패: {e}")
self.cleanup()
raise
def cleanup(self):
"""WebDriver 종료 및 임시 파일 정리"""
if self.driver:
try:
self.driver.quit()
logger.info("WebDriver 종료됨")
except Exception as e:
logger.warning(f"WebDriver 종료 중 오류: {e}")
if self.temp_dir and os.path.exists(self.temp_dir):
try:
shutil.rmtree(self.temp_dir)
logger.info(f"임시 디렉토리 삭제됨: {self.temp_dir}")
except Exception as e:
logger.warning(f"임시 디렉토리 삭제 실패: {e}")
@contextmanager
def managed_driver(self):
"""Context manager를 통한 자동 정리"""
try:
self.start()
yield self.driver
finally:
self.cleanup()
def wait(self):
"""WebDriverWait 인스턴스 반환"""
return WebDriverWait(self.driver, self.timeout)
def click_with_retry(self, selector, max_retries=5, sleep_time=1):
"""
재시도 로직을 포함한 요소 클릭
Args:
selector: (By, xpath) 튜플
max_retries: 최대 재시도 횟수
sleep_time: 재시도 사이의 대기 시간 (초)
Returns:
성공 여부
"""
for attempt in range(max_retries):
try:
wait = self.wait()
element = wait.until(EC.presence_of_element_located(selector))
wait.until(EC.element_to_be_clickable(selector))
element.click()
logger.info(f"요소 클릭 성공: {selector}")
return True
except StaleElementReferenceException:
logger.warning(f"시도 {attempt + 1}: StaleElementReferenceException 발생, 재시도 중...")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
except TimeoutException:
logger.warning(f"시도 {attempt + 1}: TimeoutException 발생, 재시도 중...")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
except Exception as e:
logger.error(f"시도 {attempt + 1}: 예상치 못한 오류 {type(e).__name__}: {e}")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
logger.error(f"최대 재시도 횟수 초과: {selector}")
return False
def get_element(self, selector):
"""
요소 선택 및 반환
Args:
selector: (By, xpath) 튜플
Returns:
WebElement 또는 None
"""
try:
wait = self.wait()
element = wait.until(EC.presence_of_element_located(selector))
logger.info(f"요소 획득 성공: {selector}")
return element
except TimeoutException:
logger.error(f"요소 대기 시간 초과: {selector}")
return None
except Exception as e:
logger.error(f"요소 획득 실패 {type(e).__name__}: {e}")
return None
def take_element_screenshot(self, selector, output_path):
"""
요소 스크린샷 저장
Args:
selector: (By, xpath) 튜플
output_path: 저장 경로
Returns:
성공 여부
"""
try:
element = self.get_element(selector)
if element is None:
return False
element.screenshot(output_path)
logger.info(f"스크린샷 저장 성공: {output_path}")
return True
except Exception as e:
logger.error(f"스크린샷 저장 실패 {type(e).__name__}: {e}")
return False

119
app/send_message.py Normal file
View File

@ -0,0 +1,119 @@
import requests
class MessageSender:
def __init__(self,
mattermost_url: str = "", mattermost_token: str = "", mattermost_channel_id: str = "",
synology_webhook_url: str = "",
telegram_bot_token: str = "", telegram_chat_id: str = ""):
self.mattermost_url = mattermost_url.rstrip('/')
self.mattermost_token = mattermost_token
self.mattermost_channel_id = mattermost_channel_id
self.synology_webhook_url = synology_webhook_url
self.telegram_bot_token = telegram_bot_token
self.telegram_chat_id = telegram_chat_id
def send(self, message: str, platforms=None, use_webhook: bool = False):
"""
메시지 전송
:param message: 전송할 메시지
:param platforms: 전송 플랫폼 리스트 (예: ['mattermost', 'telegram'])
:param use_webhook: mattermost에서 웹훅 사용 여부 (mattermost 전용)
"""
if not platforms:
print("[WARN] 전송할 플랫폼이 지정되지 않았습니다. 메시지를 보내지 않습니다.")
return False
if isinstance(platforms, str):
platforms = [platforms]
success = True
for platform in platforms:
p = platform.lower()
if p == "mattermost":
result = self._send_to_mattermost(message, use_webhook)
elif p == "synology":
result = self._send_to_synology_chat(message)
elif p == "telegram":
result = self._send_to_telegram(message)
else:
print(f"[ERROR] 지원하지 않는 플랫폼입니다: {p}")
result = False
if not result:
success = False
return success
def _send_to_mattermost(self, message: str, use_webhook: bool):
try:
# Mattermost URL 검증
if not self.mattermost_url or not self.mattermost_url.startswith(('http://', 'https://')):
print(f"[ERROR] Mattermost URL이 유효하지 않습니다: {self.mattermost_url}")
return False
if use_webhook:
response = requests.post(
self.mattermost_url,
json={"text": message},
headers={"Content-Type": "application/json"}
)
else:
url = f"{self.mattermost_url}/api/v4/posts"
headers = {
"Authorization": f"Bearer {self.mattermost_token}",
"Content-Type": "application/json"
}
payload = {
"channel_id": self.mattermost_channel_id,
"message": message
}
response = requests.post(url, json=payload, headers=headers)
if response.status_code not in [200, 201]:
print(f"[ERROR] Mattermost 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Mattermost 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Mattermost 전송 예외: {e}")
return False
def _send_to_synology_chat(self, message: str):
try:
payload = {"text": message}
headers = {"Content-Type": "application/json"}
response = requests.post(self.synology_webhook_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"[ERROR] Synology Chat 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Synology Chat 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Synology Chat 전송 예외: {e}")
return False
def _send_to_telegram(self, message: str):
try:
url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
payload = {
"chat_id": self.telegram_chat_id,
"text": message,
"parse_mode": "Markdown"
}
response = requests.post(url, data=payload)
if response.status_code != 200:
print(f"[ERROR] Telegram 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Telegram 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Telegram 전송 예외: {e}")
return False

203
app/weather.py Normal file
View File

@ -0,0 +1,203 @@
import requests
import json
import re
import sqlite3
import os
from datetime import datetime
from config import serviceKey, TODAY
# 디버그 모드 여부 설정
# True일 경우 DB 저장 및 HTML 반환 없이, 콘솔에 평문 출력만 수행
debug = False
def parse_precip(value):
"""
강수량 텍스트를 숫자(mm)로 변환하는 함수
- '강수없음'은 0.0으로 처리
- '1mm 미만'은 0.5로 간주
- 그 외는 숫자 추출하여 float 반환
"""
if value == '강수없음':
return 0.0
elif '1mm 미만' in value:
return 0.5
else:
match = re.search(r"[\d.]+", str(value))
return float(match.group()) if match else 0.0
def get_ultra_data():
"""
기상청 초단기 예보 API 호출 및 처리 함수
- base_time: 08:50 고정 (config로도 가능)
- 'RN1' 카테고리(1시간 강수량) 데이터 필터링
- 오늘 날짜, 10시부터 22시 사이 시간별 강수량 수집
- 반환: {시간(정수): 강수량(mm)} 딕셔너리
"""
url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst"
params = {
'serviceKey': serviceKey,
'numOfRows': '1000',
'pageNo': '1',
'dataType': 'JSON',
'base_date': TODAY,
'base_time': '0850',
'nx': '57',
'ny': '130'
}
response = requests.get(url, params=params)
data = response.json()
if debug:
print("[DEBUG] 초단기예보 응답 JSON:", json.dumps(data, ensure_ascii=False, indent=2))
result = {}
for item in data['response']['body']['items']['item']:
if item['category'] == 'RN1' and item['fcstDate'] == TODAY:
hour = int(item['fcstTime'][:2])
if 10 <= hour <= 22:
result[hour] = parse_precip(item['fcstValue'])
return result
def get_vilage_data():
"""
기상청 단기 예보 API 호출 및 처리 함수
- base_time: 08:00 고정 (config로도 가능)
- 'PCP' 카테고리(강수량) 데이터 필터링
- 오늘 날짜, 10시부터 22시 사이 시간별 강수량 수집
- 초단기 예보에 없는 시간 보완용으로 활용
- 반환: {시간(정수): 강수량(mm)} 딕셔너리
"""
url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"
params = {
'serviceKey': serviceKey,
'numOfRows': '1000',
'pageNo': '1',
'dataType': 'JSON',
'base_date': TODAY,
'base_time': '0800',
'nx': '57',
'ny': '130'
}
response = requests.get(url, params=params)
data = response.json()
if debug:
print("[DEBUG] 단기예보 응답 JSON:", json.dumps(data, ensure_ascii=False, indent=2))
result = {}
for item in data['response']['body']['items']['item']:
if item['category'] == 'PCP' and item['fcstDate'] == TODAY:
hour = int(item['fcstTime'][:2])
# 초단기 데이터가 우선이므로 중복 시간은 제외
if 10 <= hour <= 22 and hour not in result:
result[hour] = parse_precip(item['fcstValue'])
return result
def get_precipitation_summary():
"""
초단기예보와 단기예보를 혼합하여 10시부터 22시까지 시간별 예상 강수량 요약 생성
- debug 모드일 경우 평문 출력만 수행하며 DB 저장 및 HTML 반환은 하지 않음
- debug 모드가 아닐 경우 HTML 포맷으로 테이블 생성 후 DB 저장 및 HTML 반환
"""
ultra = get_ultra_data() # 초단기 예보 데이터
vilage = get_vilage_data() # 단기 예보 데이터
total_rainfall = 0.0
time_precip_list = []
if debug:
# 디버그 모드: 콘솔에 시간별 강수량과 총합 출력
print(f"[DEBUG MODE] {TODAY} 10:00 ~ 22:00 예상 강수량")
for hour in range(10, 23):
# 초단기예보 우선, 없으면 단기예보 사용
mm = ultra.get(hour, vilage.get(hour, 0.0))
time_str = f"{hour:02d}:00"
print(f"{time_str}{mm}mm")
total_rainfall += mm
print(f"영업시간 총 예상 강수량: {total_rainfall:.1f}mm")
return None # DB 저장, HTML 반환 없이 종료
# debug가 False인 경우 HTML 테이블 생성 및 DB 저장
lines = [
'<div class="weatherinfo" style="max-width: 100%; overflow-x: auto; padding: 10px;">',
'<h3 style="font-size: 1.8em; text-align: center; margin: 20px 0;">[10:00 ~ 22:00 예상 강수량]</h3>',
'<table style="border-collapse: collapse; width: 100%; max-width: 400px; margin: 0 auto;">',
'<thead><tr>',
'<th style="border: 1px solid #333; padding: 2px; background-color: #f0f0f0;">시간</th>',
'<th style="border: 1px solid #333; padding: 2px; background-color: #f0f0f0;">강수량</th>',
'</tr></thead><tbody>'
]
for hour in range(10, 23):
mm = ultra.get(hour, vilage.get(hour, 0.0))
time_str = f"{hour:02d}:00"
time_precip_list.append((time_str, mm))
lines.append(
f'<tr><td style="border: 1px solid #333; text-align: center;">{time_str}</td>'
f'<td style="border: 1px solid #333; text-align: center;">{mm}mm</td></tr>'
)
total_rainfall += mm
lines.append(
f'<tr><td colspan="2" style="border: 1px solid #333; text-align: center; font-weight: bold;">'
f'영업시간 총 예상 강수량: {total_rainfall:.1f}mm</td></tr>'
)
lines.append('</tbody></table><p style="text-align:right; font-size: 0.8em;">08:50 초단기 + 08:00 단기 예보 기준</p></div>')
html_summary = ''.join(lines)
# SQLite DB에 강수량 데이터 저장
save_weather_to_sqlite(TODAY, time_precip_list, total_rainfall)
print(f"[HTML 생성 완료] {TODAY} 강수 요약 HTML 생성됨")
return html_summary
def save_weather_to_sqlite(date, time_precip_list, total_rainfall):
"""
강수량 데이터를 SQLite DB에 저장하는 함수
- 테이블이 없으면 생성
- 동일 날짜 데이터는 기존 삭제 후 삽입
"""
db_path = '/data/weather.sqlite' # DB 파일 경로
os.makedirs(os.path.dirname(db_path), exist_ok=True) # 폴더 없으면 생성
conn = sqlite3.connect(db_path)
curs = conn.cursor()
# 강수량 상세 데이터 테이블 생성
curs.execute('''
CREATE TABLE IF NOT EXISTS precipitation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
time TEXT NOT NULL,
rainfall REAL NOT NULL
)
''')
# 강수량 요약 데이터 테이블 생성
curs.execute('''
CREATE TABLE IF NOT EXISTS precipitation_summary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
total_rainfall REAL NOT NULL
)
''')
# 동일 날짜 기존 데이터 삭제
curs.execute('DELETE FROM precipitation WHERE date = ?', (date,))
curs.execute('DELETE FROM precipitation_summary WHERE date = ?', (date,))
# 시간별 강수량 데이터 삽입
curs.executemany('INSERT INTO precipitation (date, time, rainfall) VALUES (?, ?, ?)',
[(date, t, r) for t, r in time_precip_list])
# 총 강수량 요약 데이터 삽입
curs.execute('INSERT INTO precipitation_summary (date, total_rainfall) VALUES (?, ?)', (date, total_rainfall))
conn.commit()
conn.close()
print(f"[DB 저장 완료] {date} 강수량 데이터 저장됨")
if __name__ == "__main__":
# 메인 실행: 함수 호출만 (결과는 디버그 모드에 따라 다름)
get_precipitation_summary()

65
app/weather_capture.py Normal file
View File

@ -0,0 +1,65 @@
import logging
import os
import sys
import time
from config import TODAY
from selenium_manager import SeleniumManager
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
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'
def capture_weather():
"""기상청 날씨 정보 캡처"""
# 저장 경로 설정
output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME)
# 저장 디렉토리 생성 (없으면)
os.makedirs(OUTPUT_DIR, exist_ok=True)
manager = SeleniumManager()
try:
with manager.managed_driver():
logger.info(f"URL 접속: {WEATHER_URL}")
manager.driver.get(WEATHER_URL)
# 첫 번째 탭 클릭
logger.info("첫 번째 탭 클릭 시도...")
if not manager.click_with_retry(manager.WEATHER_SELECTORS['tab_button']):
logger.error("첫 번째 탭 클릭 실패")
return False
# 두 번째 항목 클릭
logger.info("두 번째 항목 클릭 시도...")
if not manager.click_with_retry(manager.WEATHER_SELECTORS['list_button']):
logger.error("두 번째 항목 클릭 실패")
return False
# 페이지 반영 대기
time.sleep(2)
# 스크린샷 저장
logger.info(f"스크린샷 저장 시도: {output_path}")
if manager.take_element_screenshot(manager.WEATHER_SELECTORS['target_element'], output_path):
logger.info(f"[캡처 완료] 저장 위치: {output_path}")
return True
else:
logger.error("스크린샷 저장 실패")
return False
except Exception as e:
logger.error(f"[오류] 날씨 캡처 중 오류 발생: {type(e).__name__}: {e}", exc_info=True)
return False
if __name__ == '__main__':
success = capture_weather()
sys.exit(0 if success else 1)