캡처가 제대로 동작하지 않는 부분 수정, 실행 완료 시 mattermost로 메시지 발송 기능 추가.

This commit is contained in:
2025-12-11 13:16:47 +09:00
parent f786f4a1fe
commit 3ee3161f06
8 changed files with 604 additions and 171 deletions

View File

@ -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',
}

View File

@ -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"] = """
<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')
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"] = """
<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:
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__":

View File

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

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

View File

@ -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

View File

@ -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)

View File

@ -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"]

View File

@ -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 "========================================"