.env를 crontab에서 인식하지 못하는 문제 수정
This commit is contained in:
70
app/config.py
Normal file
70
app/config.py
Normal 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
398
app/gnu_autoupload.py
Normal 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
16
app/requirements.txt
Normal 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
178
app/selenium_manager.py
Normal 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
119
app/send_message.py
Normal 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
203
app/weather.py
Normal 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
65
app/weather_capture.py
Normal 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)
|
||||
Reference in New Issue
Block a user