캡처가 제대로 동작하지 않는 부분 수정, 실행 완료 시 mattermost로 메시지 발송 기능 추가.
This commit is contained in:
@ -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',
|
||||
}
|
||||
|
||||
@ -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__":
|
||||
|
||||
15
autouploader/requirements.txt
Normal file
15
autouploader/requirements.txt
Normal file
@ -0,0 +1,15 @@
|
||||
# Standard library modules (자동 포함, 설치 불필요)
|
||||
# os
|
||||
# sys
|
||||
# time
|
||||
# subprocess
|
||||
# tempfile
|
||||
# hashlib
|
||||
# datetime
|
||||
|
||||
# External packages (pip install 필요)
|
||||
Pillow
|
||||
PyMySQL
|
||||
ftputil
|
||||
requests
|
||||
selenium
|
||||
178
autouploader/selenium_manager.py
Normal file
178
autouploader/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
|
||||
114
autouploader/send_message.py
Normal file
114
autouploader/send_message.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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 "========================================"
|
||||
|
||||
Reference in New Issue
Block a user