commit 380af2156c0cd8409e89ef2b76b517cd6c50b39b Author: KWON Date: Tue Jun 24 17:02:50 2025 +0900 first-commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4acd06b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6837c1 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +## 퍼스트가든용 기상청 API를 활용해 공지사항 자동 등록하는 이미지 +기상정보에 따른 이벤트 진행에 대한 정확한 기준 부여를 위해 기상청 API를 사용, 영업시간 내 강수정보를 파악하고 해당 공지를 올리기 위한 프로젝트. + +### weather.py +- 기상청 API를 활용해 데이터 출력 + +### weather_capture.py +- 기상청 날씨누리 단기예보 페이지를 캡처하여 이미지로 저장 +- 클레임 방지를 위해 '최근발표시각'을 표시하여 캡처함 + +### gnu_autoupload.py +- 위 파일들의 데이터를 그누보드 게시판에 등록하는 일을 수행 + +### config.sample.py +- 환경정보 저장(DB정보 등), config.py 로 파일명 변경하여 사용 diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..719b09c --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu:22.04 + +# 패키지 설치 +RUN apt-get update && \ + apt-get install -y python3 python3-pip cron curl unzip wget xvfb \ + libglib2.0-0 libnss3 libgconf-2-4 libfontconfig1 libxss1 libxshmfence1 \ + libasound2 libxtst6 libappindicator3-1 fonts-liberation libu2f-udev && \ + pip3 install pymysql ftputil pillow selenium + +# 크롬 및 드라이버 설치 +RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ + apt-get install -y ./google-chrome-stable_current_amd64.deb && \ + rm google-chrome-stable_current_amd64.deb + +RUN CHROME_VERSION=$(google-chrome --version | grep -oP '\d+\.\d+\.\d+') && \ + wget -O /tmp/chromedriver.zip https://chromedriver.storage.googleapis.com/${CHROME_VERSION}/chromedriver_linux64.zip && \ + unzip /tmp/chromedriver.zip -d /usr/local/bin && \ + chmod +x /usr/local/bin/chromedriver && \ + rm /tmp/chromedriver.zip + +# 작업 디렉토리 설정 +WORKDIR /data + +# 크론탭 등록 +COPY ./build/crontab.txt /etc/cron.d/autoupload-cron +RUN chmod 0644 /etc/cron.d/autoupload-cron && \ + crontab /etc/cron.d/autoupload-cron + +CMD ["cron", "-f"] \ No newline at end of file diff --git a/build/crontab.txt b/build/crontab.txt new file mode 100644 index 0000000..ac312d4 --- /dev/null +++ b/build/crontab.txt @@ -0,0 +1 @@ +0 9 * * * python3 /data/gnu_autoupload.py >> /var/log/gnu_autoupload.log 2>&1 \ No newline at end of file diff --git a/data/config.sample.py b/data/config.sample.py new file mode 100644 index 0000000..7f35046 --- /dev/null +++ b/data/config.sample.py @@ -0,0 +1,12 @@ +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' +} \ No newline at end of file diff --git a/data/gnu_autoupload.py b/data/gnu_autoupload.py new file mode 100644 index 0000000..e942f34 --- /dev/null +++ b/data/gnu_autoupload.py @@ -0,0 +1,178 @@ +import pymysql, ftputil, hashlib, os, sys +from datetime import datetime +from PIL import Image +import config import * +import subprocess +import time +import os + +# 오늘 날짜 기반으로 파일명 생성 +today_str = datetime.today().strftime('%Y%m%d') +base_dir = os.path.dirname(os.path.abspath(__file__)) # 현재 파이썬 파일의 경로 +capture_script = os.path.join(base_dir, 'weather_capture.py') +weather_filename = f'weather_capture_{today_str}.png' +weather_file = os.path.join(base_dir, weather_filename) + +# 파일 존재 확인 및 생성 루프 +while not os.path.isfile(weather_file): + print(f"[{datetime.now().strftime('%H:%M:%S')}] {weather_file} 파일이 없습니다. {capture_script} 실행 중...") + subprocess.call(['python', capture_script]) + #time.sleep(1) + +print(f"[{datetime.now().strftime('%H:%M:%S')}] {weather_file} 파일을 확인했습니다. 계속 진행합니다.") + +# 파일 업로드 경로 설정 +FTP_CONFIG['UPLOAD_DIR'] = f"/www/data/file/{MAIN['board']}/" + +# 게시글 제목을 오늘 날짜를 반영하도록 수정 +MAIN['subject'] = f"[{datetime.now().strftime('%Y-%m-%d')}] 날씨 정보" +MAIN['file1'] = MAIN['file2'] = weather_file + +def file_type(x): # 그누보드의 bf_type 값을 반환하는 함수입니다. (디폴트 : 0) + 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(x.lower(), '0') + +def file_upload(filename, bf_file): # FTP를 이용하여 파일을 업로드하는 함수입니다. + 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, callback = None) + print(f"[업로드 완료] {filename}") + except Exception as e: + print(f"[FTP 오류] {type(e).__name__}: {e}") + + return + +def get_filename(filename): # 파일명을 변환하는 함수입니다. + ms = datetime.now().microsecond + encoded_name = filename.encode('utf-8') + result = f'{ms}_{hashlib.sha1(encoded_name).hexdigest()}' + return result + +def board_write(board, subject, content, mb_id, nickname, ca_name=None, file_list=None): + conn = pymysql.connect(host=DB_CONFIG['HOST'], + user=DB_CONFIG['USER'], + db=DB_CONFIG['DBNAME'], + password=DB_CONFIG['PASS'], + charset='utf8') + curs = conn.cursor() + + sql = f"SELECT wr_num FROM g5_write_{board}" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + wr_num = str(int(curs.fetchone()[0]) - 1) + print(f"[결과] wr_num: {wr_num}") + + now = datetime.today().strftime('%Y-%m-%d %H:%M:%S') + + sql = f"""INSERT INTO g5_write_{board} SET wr_num = {wr_num}, + wr_reply = '', wr_comment = 0, ca_name = '{ca_name}', wr_option = 'html1', wr_subject = '{subject}', + wr_content = '{content}', wr_link1 = '', wr_link2 = '', + wr_link1_hit = 0, wr_link2_hit = 0, wr_hit = 1, wr_good = 0, wr_nogood = 0, + mb_id = '{mb_id}', wr_password = '', wr_name = '{nickname}', wr_email = '', wr_homepage = '', + wr_datetime = '{now}', wr_last = '{now}', 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 = '' + """ + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print("[결과] 게시글 INSERT 완료") + + sql = f"SELECT wr_id FROM g5_write_{board}" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + wr_id = str(curs.fetchall()[-1][0]) + conn.commit() + print(f"[결과] wr_id: {wr_id}") + + sql = f"UPDATE g5_write_{board} SET wr_parent = {wr_id} WHERE wr_id = {wr_id}" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print("[결과] wr_parent 업데이트 완료") + + sql = f"""INSERT INTO g5_board_new (bo_table, wr_id, wr_parent, bn_datetime, mb_id) + VALUES ('{board}', '{wr_id}', '{wr_id}', '{now}', '{mb_id}')""" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print("[결과] 새글 INSERT 완료") + + sql = f"SELECT bo_count_write FROM g5_board WHERE bo_table = '{board}'" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + bo_count_write = str(int(curs.fetchone()[0])) + conn.commit() + print(f"[결과] 현재 게시글 수: {bo_count_write}") + + sql = f"UPDATE g5_board SET bo_count_write = {bo_count_write} + 1 WHERE bo_table = '{board}'" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print("[결과] 게시글 수 증가 완료") + + if not file_list: + print("[종료] 첨부파일 없음. 연결 종료.") + conn.close() + sys.exit() + + file_count = len(file_list) + for cnt, file in enumerate(file_list): + ext = os.path.splitext(file)[1].lstrip('.') + bf_file = f'{get_filename(file)}.{ext}' + file_upload(file, bf_file) + type = file_type(ext) + + if type != '0': + im = Image.open(file) + width, height = im.size + else: + width, height = 0, 0 + + size = os.path.getsize(file) + sql = f"""INSERT INTO g5_board_file SET bo_table = '{board}', wr_id = '{wr_id}', + bf_no = '{cnt}', bf_source = '{weather_filename}', bf_file = '{bf_file}', + bf_content = '', bf_download = 0, bf_filesize = '{size}', + bf_width = '{width}', bf_height = '{height}', bf_type = '{type}', bf_datetime = '{now}'""" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print(f"[결과] 파일({file}) 업로드 정보 INSERT 완료") + + sql = f"UPDATE g5_write_{board} SET wr_file = '{file_count}' WHERE wr_id = '{wr_id}'" + print(f"[쿼리 실행] {sql}") + curs.execute(sql) + conn.commit() + print(f"[결과] 첨부파일 수 wr_file = {file_count} 업데이트 완료") + + conn.close() + print("[종료] MySQL 연결 종료") + + +def main(): + board = MAIN['board'] + ca_name = MAIN['ca_name'] + subject = MAIN['subject'] + content = MAIN['content'] + mb_id = MAIN['mb_id'] + nickname = MAIN['nickname'] + file_list = [MAIN['file1'], MAIN['file2']] +# file_list = [MAIN['file1']] + board_write(board, subject, content, mb_id, nickname, ca_name, file_list) + + +if __name__ == "__main__": + main() + + # main() 완료 후 이미지 파일 삭제 + if os.path.isfile(weather_file): + try: + os.remove(weather_file) + print(f"[삭제 완료] {weather_file} 파일을 삭제했습니다.") + except Exception as e: + print(f"[삭제 오류] {type(e).__name__}: {e}") + else: + print(f"[파일 없음] {weather_file} 파일을 찾을 수 없습니다.") diff --git a/data/weather.py b/data/weather.py new file mode 100644 index 0000000..4d0986d --- /dev/null +++ b/data/weather.py @@ -0,0 +1,56 @@ +import requests +import json +import re +from datetime import datetime + +base_date = datetime.now().strftime('%Y%m%d') # 오늘 날짜 +serviceKey = 'mHrZoSnzVc+2S4dpCe3A1CgI9cAu1BRttqRdoEy9RGbnKAKyQT4sqcESDqqY3grgBGQMuLeEgWIS3Qxi8rcDVA==' + +url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst" + +params = { + 'serviceKey': serviceKey, + 'numOfRows': '1000', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': base_date, + 'base_time': '0800', # 02:00 부터 3시간 단위 발표 + 'nx': '57', + 'ny': '130' +} + +response = requests.get(url, params=params) + +def parse_precip(value): + if value == '강수없음': + return 0.0 + elif '1mm 미만' in value: + return 0.5 + else: + # 숫자만 추출 (예: '1.0mm' → 1.0) + match = re.search(r"[\d.]+", value) + if match: + return float(match.group()) + else: + return 0.0 + +try: + data = response.json() + total_rainfall = 0.0 + + print("📅 시간대별 강수량 (단기예보 기준):") + + for item in data['response']['body']['items']['item']: + if item['category'] == 'PCP' and item['fcstDate'] == base_date: + time = item['fcstTime'] + if 900 < int(time) < 2300: + value = item['fcstValue'] + mm = parse_precip(value) + print(f" {time}시 → {mm}mm") + total_rainfall += mm + + print(f"\n🌧️ 총 예상 강수량: {total_rainfall:.1f}mm") + +except json.decoder.JSONDecodeError: + print("⚠️ 응답이 JSON 형식이 아닙니다.") + print(response.text) \ No newline at end of file diff --git a/data/weather_capture.py b/data/weather_capture.py new file mode 100644 index 0000000..cab9a20 --- /dev/null +++ b/data/weather_capture.py @@ -0,0 +1,43 @@ +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.common.action_chains import ActionChains +from datetime import datetime +import os +import time + +# 현재 스크립트 경로 기준 저장 +script_dir = os.path.dirname(os.path.abspath(__file__)) + +# 브라우저 옵션 설정 +options = Options() +options.add_argument('--headless') +options.add_argument('--window-size=1802,1467') + +driver = webdriver.Chrome(options=options) +driver.get('https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948') +time.sleep(5) + +# 첫 번째 탭 클릭 +tab_button = driver.find_element(By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/div[1]/div/div/a[2]') +ActionChains(driver).move_to_element(tab_button).click().perform() +time.sleep(2) + +# 두 번째 항목 클릭 +list_button = driver.find_element(By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/ul/div[1]/a[2]') +ActionChains(driver).move_to_element(list_button).click().perform() +time.sleep(2) + +# 캡처 대상 요소 찾기 +target_element = driver.find_element(By.XPATH, '/html/body/div[2]/section/div/div[2]') + +# 파일 저장 경로 설정 +#timestamp = datetime.now().strftime('%Y%m%d_%H%M') #시간까지 표시 +timestamp = datetime.now().strftime('%Y%m%d') #날짜만 표시 +save_path = os.path.join(script_dir, f'weather_capture_{timestamp}.png') + +# 요소 스크린샷 저장 +target_element.screenshot(save_path) + +driver.quit() +print(f'📸 캡처 완료! 저장 위치: {save_path}') \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5fb7743 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.8" + +services: + gnu-autouploader: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: gnu-autouploader + volumes: + - ./data:/data + restart: unless-stopped \ No newline at end of file