From 3ee3161f06553a34b854914ddc8540901430271f Mon Sep 17 00:00:00 2001 From: KWON Date: Thu, 11 Dec 2025 13:16:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BA=A1=EC=B2=98=EA=B0=80=20=EC=A0=9C?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20matter?= =?UTF-8?q?most=EB=A1=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autouploader/config.sample.py | 47 +++--- autouploader/gnu_autoupload.py | 251 ++++++++++++++++++++++--------- autouploader/requirements.txt | 15 ++ autouploader/selenium_manager.py | 178 ++++++++++++++++++++++ autouploader/send_message.py | 114 ++++++++++++++ autouploader/weather_capture.py | 129 +++++++--------- build/autouploader/Dockerfile | 13 +- build/autouploader/run.sh | 28 +++- 8 files changed, 604 insertions(+), 171 deletions(-) create mode 100644 autouploader/requirements.txt create mode 100644 autouploader/selenium_manager.py create mode 100644 autouploader/send_message.py diff --git a/autouploader/config.sample.py b/autouploader/config.sample.py index 78b21d6..714077c 100644 --- a/autouploader/config.sample.py +++ b/autouploader/config.sample.py @@ -2,32 +2,41 @@ from datetime import datetime TODAY = datetime.now().strftime('%Y%m%d') +# FTP 서버 설정 FTP_CONFIG = { - 'HOST' : 'FTP_HOST', - 'USER' : 'FTP_USER', - 'PASS' : 'FTP_PW', - 'UPLOAD_DIR' : 'UPLOAD_DIR' -} -DB_CONFIG = { - 'HOST' : 'HOST', - 'USER' : 'DBUSER', - 'DBNAME' : 'DBNAME', - 'PASS' : 'DB_PW' - 'CHARSET'='utf8mb4', + 'HOST' : 'ftp.example.com', + 'USER' : 'ftp_username', + 'PASS' : 'ftp_password', + 'UPLOAD_DIR' : '/data/file/news/' } -### -#subject, content, file1, file2는 스크립트 진행중 설정함 -### +# 데이터베이스 설정 (MySQL) +DB_CONFIG = { + 'HOST' : 'db.example.com', + 'USER' : 'db_username', + 'DBNAME' : 'database_name', + 'PASS' : 'db_password', + 'CHARSET': 'utf8mb4', +} + +# 게시판 설정 (subject, content, file1, file2는 스크립트 진행중 설정됨) MAIN = { - 'board' : '게시판 ID', - 'ca_name' : '카테고리가 있는경우 카테고리 이름, 없으면 비움', + 'board' : 'news', + 'ca_name' : 'category_name', 'subject' : '', 'content' : '', - 'mb_id' : '게시자 ID', - 'nickname' : '닉네임', + 'mb_id' : 'user_id', + 'nickname' : 'user_nickname', 'file1' : '', 'file2' : '', } -serviceKey = 'serviceKey' +# 날씨 API 서비스 키 +serviceKey = 'your_weather_api_key_here' + +# Mattermost 알림 설정 +MATTERMOST_CONFIG = { + 'URL': 'https://mattermost.example.com', + 'TOKEN': 'your-personal-access-token', + 'CHANNEL_ID': 'channel_id', +} diff --git a/autouploader/gnu_autoupload.py b/autouploader/gnu_autoupload.py index 033a013..dab3fc8 100644 --- a/autouploader/gnu_autoupload.py +++ b/autouploader/gnu_autoupload.py @@ -6,6 +6,7 @@ gnu_autoupload.py 1. Selenium을 이용해 날씨 정보를 캡처 (weather_capture.py 호출) 2. FTP를 이용해 이미지 업로드 3. 그누보드 DB에 게시글 및 첨부파일 정보 자동 등록 +4. Mattermost으로 결과 알림 발송 """ import os @@ -14,14 +15,40 @@ 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 +from config import DB_CONFIG, FTP_CONFIG, MAIN, MATTERMOST_CONFIG from weather import get_precipitation_summary -from send_message import MessageSender # MessageSender 클래스 임포트 +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: + msg_sender = MessageSender( + mattermost_url=MATTERMOST_CONFIG.get('URL', ''), + mattermost_token=MATTERMOST_CONFIG.get('TOKEN', ''), + mattermost_channel_id=MATTERMOST_CONFIG.get('CHANNEL_ID', '') + ) + return msg_sender + except Exception as e: + logger.warning(f"MessageSender 초기화 실패: {e}") + return None # --------------------------- @@ -31,35 +58,38 @@ 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): - print(f"[{datetime.now().strftime('%H:%M:%S')}] 이미지 캡처 시도 {attempt + 1}/{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 # 60초 타임아웃 설정 + timeout=60 ) if os.path.isfile(output_path): - print(f"[성공] 이미지가 정상적으로 캡처되었습니다: {output_path}") + logger.info(f"이미지 캡처 성공: {output_path}") return True, None except subprocess.TimeoutExpired: - error_msg = f"캡처 스크립트 실행 타임아웃 (시도 {attempt + 1}/{max_attempts})" - print(f"[오류] {error_msg}") + logger.warning(f"캡처 타임아웃 (시도 {attempt + 1}/{max_attempts})") except subprocess.CalledProcessError as e: - error_msg = f"weather_capture.py 실행 실패:\n{e.stderr if e.stderr else str(e)}" - print(f"[오류] {error_msg}") + logger.error(f"weather_capture.py 실행 실패: {e.stderr}") except Exception as e: - error_msg = f"예상치 못한 오류: {type(e).__name__}: {e}" - print(f"[오류] {error_msg}") + logger.error(f"예상치 못한 오류: {type(e).__name__}: {e}") time.sleep(2) @@ -70,7 +100,7 @@ def capture_image(script_path, output_path, max_attempts=5, msg_sender=None): f"📁 출력 경로: `{output_path}`\n"\ f"⚠️ 파일이 생성되지 않았습니다." - print(f"[실패] {max_attempts}회 시도 후에도 이미지가 생성되지 않았습니다.") + logger.error(final_error.replace('\n', ' ')) # Mattermost 알림 전송 if msg_sender: @@ -78,10 +108,12 @@ def capture_image(script_path, output_path, max_attempts=5, msg_sender=None): 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', @@ -91,28 +123,61 @@ def file_type(ext): 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): +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) - print(f"[업로드 완료] '{filename}' → '{bf_file}' 로 FTP 업로드 완료") + logger.info(f"FTP 업로드 완료: {filename} → {bf_file}") return True except Exception as e: - print(f"[FTP 오류] {type(e).__name__}: {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: (성공여부, 에러메시지) @@ -129,9 +194,11 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis 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, @@ -144,31 +211,44 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis """, (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): + if file_upload(file, bf_file, msg_sender): img_type = file_type(ext) width, height = (0, 0) + if img_type != '0': - with Image.open(file) as img: - width, height = img.size + 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, @@ -180,9 +260,22 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis 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() - print("[성공] 게시글과 첨부파일 등록 완료") + + 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: @@ -199,10 +292,7 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis f"💬 오류 메시지: {str(e)}\n"\ f"```\n{error_detail}\n```" - if "FTP" in str(e) or "파일 업로드" in str(e): - print(f"[FTP 오류] {e}") - else: - print(f"[DB 오류] {type(e).__name__}: {e}") + logger.error(f"게시글 등록 실패: {type(e).__name__}: {e}") # Mattermost 알림 전송 if msg_sender: @@ -219,58 +309,73 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis # 메인 실행 함수 # --------------------------- def main(): - - # 기상청 API로 얻어오는 데이터와 캡처의 데이터가 다르므로 내용에 대해 업데이트 하지 않음. - # 날씨 정보 문자열 얻기 - #weather_content = get_precipitation_summary() + """메인 실행 함수""" + logger.info("=== 날씨 정보 게시글 자동 생성 시작 ===") + + # MessageSender 초기화 + msg_sender = init_message_sender() + + if not msg_sender: + logger.warning("Mattermost 알림이 비활성화됩니다") - # MAIN['content'] 업데이트 - #MAIN['content'] = weather_content - - # 무료입장 조건에 대해서만 안내함. - MAIN["content"] = """ -

Rainy Day 이벤트 적용안내

-

10:00 ~ 22:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.

-

레이니데이 이벤트 정보 확인

-

이벤트 정보 보기

- """ - - 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') - - if not capture_image(capture_script, weather_file): - return - - 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']] - - 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 - ) - try: - ## weather_file만 삭제, thumb.jpg는 삭제하지 않음 - #if os.path.isfile(weather_file): - # os.remove(weather_file) - # print(f"[정리 완료] 캡처 이미지 삭제됨: {weather_file}") - pass + # 무료입장 조건에 대해서만 안내함. + MAIN["content"] = """ +

Rainy Day 이벤트 적용안내

+

10:00 ~ 22:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.

+

레이니데이 이벤트 정보 확인

+

이벤트 정보 보기

+ """ + + 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: - print(f"[삭제 오류] {type(e).__name__}: {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__": diff --git a/autouploader/requirements.txt b/autouploader/requirements.txt new file mode 100644 index 0000000..714a4db --- /dev/null +++ b/autouploader/requirements.txt @@ -0,0 +1,15 @@ +# Standard library modules (자동 포함, 설치 불필요) +# os +# sys +# time +# subprocess +# tempfile +# hashlib +# datetime + +# External packages (pip install 필요) +Pillow +PyMySQL +ftputil +requests +selenium \ No newline at end of file diff --git a/autouploader/selenium_manager.py b/autouploader/selenium_manager.py new file mode 100644 index 0000000..fe9ded9 --- /dev/null +++ b/autouploader/selenium_manager.py @@ -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 \ No newline at end of file diff --git a/autouploader/send_message.py b/autouploader/send_message.py new file mode 100644 index 0000000..41bc50f --- /dev/null +++ b/autouploader/send_message.py @@ -0,0 +1,114 @@ +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: + 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 diff --git a/autouploader/weather_capture.py b/autouploader/weather_capture.py index bf7e602..6365df1 100644 --- a/autouploader/weather_capture.py +++ b/autouploader/weather_capture.py @@ -1,80 +1,65 @@ -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 -from datetime import datetime +import logging import os +import sys import time -import tempfile - from config import TODAY +from selenium_manager import SeleniumManager -# 크롬 옵션 설정 -options = Options() -options.add_argument('--headless') -options.add_argument('--window-size=1802,1467') -options.add_argument('--no-sandbox') -options.add_argument('--disable-dev-shm-usage') -options.add_argument('--disable-gpu') +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) -# 임시 사용자 데이터 디렉토리 생성 및 지정 (중복 실행 문제 방지) -temp_dir = tempfile.mkdtemp() -options.add_argument(f'--user-data-dir={temp_dir}') - -driver = webdriver.Chrome(options=options) - -try: - script_dir = os.path.dirname(os.path.abspath(__file__)) - - driver.get('https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948') - - wait = WebDriverWait(driver, 10) - - # 첫 번째 탭 클릭 (안전하게 클릭 대기) - tab_button = wait.until(EC.element_to_be_clickable( - (By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/div[1]/div/div/a[2]') - )) - tab_button.click() - - # 두 번째 항목 클릭 - stale element 대비 최대 5회 재시도 - list_button_xpath = '//*[@id="digital-forecast"]/div[1]/div[3]/ul/div[1]/a[2]' - for attempt in range(5): - try: - list_button = wait.until(EC.presence_of_element_located((By.XPATH, list_button_xpath))) - wait.until(EC.element_to_be_clickable((By.XPATH, list_button_xpath))) - list_button.click() - break - except StaleElementReferenceException: - print(f"시도 {attempt+1}: stale element 참조 오류 발생, 재시도 중...") - time.sleep(1) - except TimeoutException: - print(f"시도 {attempt+1}: 요소 대기 시간 초과, 재시도 중...") - time.sleep(1) - else: - print("두 번째 항목 클릭 실패. 스크립트 종료.") - driver.quit() - exit(1) - - time.sleep(2) # 페이지 반영 대기 - - # 캡처 대상 요소 대기 후 찾기 - target_element = wait.until(EC.presence_of_element_located( - (By.XPATH, '/html/body/div[1]/main/div[2]/div[1]') - )) +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(): + """기상청 날씨 정보 캡처""" + # 저장 경로 설정 - # 기존 - # save_path = os.path.join(script_dir, f'weather_capture_{TODAY}.png') - # 수정 - save_path = os.path.join('/data', f'weather_capture_{TODAY}.png') + 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 - - # 요소 스크린샷 저장 - target_element.screenshot(save_path) - - print(f'[캡처 완료] 저장 위치: {save_path}') - -finally: - driver.quit() +if __name__ == '__main__': + success = capture_weather() + sys.exit(0 if success else 1) diff --git a/build/autouploader/Dockerfile b/build/autouploader/Dockerfile index 685c363..79e313d 100644 --- a/build/autouploader/Dockerfile +++ b/build/autouploader/Dockerfile @@ -20,7 +20,8 @@ RUN apt-get update && \ apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Python 패키지 설치 -RUN pip install --no-cache-dir \ +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir \ selenium>=4.10 \ pymysql \ ftputil \ @@ -28,15 +29,17 @@ RUN pip install --no-cache-dir \ pyvirtualdisplay \ requests - WORKDIR /app RUN fc-cache -f -v -COPY run.sh /app/run.sh +# autouploader 디렉토리의 모든 Python 스크립트 및 설정 파일 복사 +COPY autouploader/ /app/ + RUN chmod +x /app/run.sh -# crontab 등록 -RUN echo "0 9 * * * /app/run.sh >> /proc/1/fd/1 2>&1" | crontab - +# crontab 등록 (docker logs에 출력되도록 설정) +RUN echo "0 9 * * * /app/run.sh 2>&1" | crontab - +# Cron 로그를 docker logs로 보내기 위해 포그라운드에서 실행 CMD ["/bin/bash", "-c", "cron -f"] diff --git a/build/autouploader/run.sh b/build/autouploader/run.sh index 0a292ce..36cded5 100644 --- a/build/autouploader/run.sh +++ b/build/autouploader/run.sh @@ -1,3 +1,27 @@ #!/bin/bash -echo "run.sh 실행됨: $(date '+%Y-%m-%d %H:%M:%S')" -python3 /data/gnu_autoupload.py >> /proc/1/fd/1 2>&1 + +# 로그 출력 함수 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +log "========================================" +log "날씨 정보 자동 게시글 생성 시작" +log "========================================" + +# Python 스크립트 실행 +cd /app +if [ -f "gnu_autoupload.py" ]; then + python3 /app/gnu_autoupload.py 2>&1 + EXIT_CODE=$? + if [ $EXIT_CODE -eq 0 ]; then + log "✅ 실행 완료 (종료 코드: $EXIT_CODE)" + else + log "❌ 실행 실패 (종료 코드: $EXIT_CODE)" + fi +else + log "❌ 오류: gnu_autoupload.py 파일을 찾을 수 없습니다" + exit 1 +fi + +log "========================================"