From 27d9a775131ab21ef50a1d7988b489924ff2a77f Mon Sep 17 00:00:00 2001 From: KWON Date: Fri, 19 Dec 2025 10:15:55 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=ED=9B=85=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 ++ .gitignore | 23 ++- README.md | 164 ++++++++++++++++++++-- app/api_server.py | 293 +++++++++++++++++++++++++++++++++++++++ app/requirements.txt | 14 ++ app/weather_capture.py | 194 +++++++++++++++++++++++++- build/app/Dockerfile | 20 ++- build/app/entrypoint.sh | 23 +++ build/app/run.sh | 28 ---- build/webhook/Dockerfile | 30 ---- docker-compose.yml | 21 +-- webhook/webhook.py | 97 ------------- 12 files changed, 715 insertions(+), 202 deletions(-) create mode 100644 app/api_server.py create mode 100644 build/app/entrypoint.sh delete mode 100644 build/app/run.sh delete mode 100644 build/webhook/Dockerfile delete mode 100644 webhook/webhook.py diff --git a/.env.example b/.env.example index 64fdd0e..dc3d4dc 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,13 @@ SERVICE_KEY=your_weather_api_key_here MATTERMOST_URL=https://mattermost.example.com MATTERMOST_TOKEN=your-personal-access-token MATTERMOST_CHANNEL_ID=channel_id + +# ===================================== +# 웹서버 설정 +# ===================================== + +# 웹훅 도메인 (이미지 URL 생성 시 사용) +DOMAIN=https://webhook.firstgarden.co.kr + +# Flask 디버그 모드 (개발 시만 1로 설정, 운영은 0) +FLASK_DEBUG=0 diff --git a/.gitignore b/.gitignore index bee058d..ee715da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,19 @@ -config.sample.py +# 환경 변수 .env -**/__pycache__/ -*.pyc -naver_review/build/ -*.spec -data/weather_capture_*.png + +# IDE .vscode/ + +# Python +__pycache__/ +*.pyc +*.spec + +# 프로젝트 특화 +data/weather_capture_*.png +logs/cron.log +logs/flask.log + +# 레거시 (사용 안 함) +config.sample.py +naver_review/ diff --git a/README.md b/README.md index afb11e7..ca83ec6 100644 --- a/README.md +++ b/README.md @@ -15,28 +15,30 @@ project-root/ │ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블) │ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지 │ -├── app/ # gnu-autouploader 앱 소스 (Dockerfile에서 복사) +├── app/ # gnu-autouploader + API 서버 (통합) │ ├── gnu_autoupload.py # 메인 실행 스크립트 (Selenium → FTP → DB) -│ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처 +│ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처 + 강우량 추출 │ ├── weather.py # 기상청 API 데이터 처리 및 sqlite 저장 │ ├── send_message.py # Mattermost 알림 발송 │ ├── selenium_manager.py # Selenium 브라우저 관리 +│ ├── api_server.py # Flask 기반 카카오 챗봇 웹훅 서버 ⭐ │ ├── config.py # 설정값 로드 (DB, FTP, API KEY 등) │ ├── requirements.txt # Python 의존성 │ └── run.sh # 수동 실행용 셸 스크립트 (개발 시 사용) │ -├── webhook/ # Synology Chat 웹훅 응답 서버 -│ └── webhook.py # Flask 기반 응답 서버 +├── webhook/ # (더 이상 사용 안 함 - app/api_server.py로 통합) +│ └── webhook.py # (참고용 아카이브) │ ├── build/ │ ├── app/ -│ │ ├── Dockerfile # gnu-autouploader 컨테이너 이미지 +│ │ ├── Dockerfile # gnu-autouploader + Flask 통합 이미지 +│ │ ├── entrypoint.sh # Flask + Cron 동시 실행 스크립트 ⭐ │ │ └── run.sh # (위의 app/run.sh와 동일) │ └── webhook/ -│ └── Dockerfile # webhook 서버용 Dockerfile +│ └── Dockerfile # (더 이상 사용 안 함) │ ├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정) -├── docker-compose.yml # Docker Compose 서비스 정의 +├── docker-compose.yml # Docker Compose 서비스 정의 (gnu-autouploader만) └── README.md # 프로젝트 문서 ``` @@ -51,15 +53,30 @@ project-root/ - `docker exec` 또는 `run.sh`로 수동 실행 가능 - **오류 발생 시**: Mattermost으로 알림 발송 -### `app/weather_capture.py` -- Selenium을 사용해 기상청 날씨누리 웹 페이지 캡처 -- '최근발표시각' 표시 (출처 명시) +### `app/weather_capture.py` ⭐ (개선) +- **이전**: Selenium으로 웹페이지 캡처만 수행 +- **현재**: + - Selenium으로 기상청 웹페이지 접근 + - **페이지에서 강우량 데이터 자동 추출** (10시~21시) + - '-'는 0mm, '~1'은 0.5mm로 자동 계산 + - **추출된 강우량을 SQLite에 저장** (`rainfall_capture`, `rainfall_summary` 테이블) + - 웹페이지 이미지 캡처 저장 ### `app/weather.py` - 기상청 API에서 시간별 강수량 데이터 수집 - 10:00 ~ 22:00 영업시간 강수 데이터 HTML 테이블 생성 - SQLite DB에 저장 +### `webhook/webhook.py` ⭐ (개선) +- **Flask 기반 카카오 챇봇 응답 서버** +- **주요 기능**: + - **당일 조회**: 09:00 캡처된 **실제 강우량 데이터** 응답 + - **미래 날짜 조회**: 기상청 API 기반 **예보 강우량** 응답 + - 예보 시 "변동될 수 있음" 경고 문구 표시 + - 10mm 초과 시 이벤트 적용 안내 + - 날씨 캡처 이미지 함께 전송 +- **통합**: 현재 `app/api_server.py`로 통합되어 동일 컨테이너에서 실행 + ### `app/config.py` - 환경 변수 로드 (`.env` 또는 컨테이너 환경 변수) - 필수 변수 부재 시 즉시 오류 출력 후 종료 @@ -134,6 +151,107 @@ docker exec -it gnu-autouploader /app/run.sh --- +--- + +## 🎯 시스템 동작 플로우 + +### 아키텍처 (통합 구조) +``` +[gnu-autouploader 컨테이너] (포트 5151:5000 노출) +├─ Crontab 데몬 (포그라운드) +│ └─ 매일 09:00 크론 작업 실행 +│ +└─ Flask 웹서버 (백그라운드) + └─ 포트 5000에서 지속 실행 +``` + +### 일일 작업 플로우 +``` +매일 09:00 + ↓ +[Crontab 작업 시작] + ├─ 1. weather_capture.py 실행 + │ ├─ Selenium으로 기상청 웹페이지 접근 + │ ├─ 강우량 데이터 추출 (10시~21시) ⭐ + │ ├─ SQLite 저장 + │ └─ 웹페이지 이미지 캡처 저장 + ├─ 2. weather.py 실행 (선택적) + │ └─ API 기반 시간별 강수 데이터 저장 + └─ 3. gnu_autoupload.py 실행 + ├─ 캡처된 이미지 FTP 업로드 + └─ 그누보드 게시글 자동 등록 + +동시에 실행 중: Flask 웹서버 + ↓ (사용자 요청 시) +[Flask 웹훅 엔드포인트] + ├─ "오늘의 강우량은?" → SQLite에서 실제 데이터 응답 ✓ + ├─ "내일 강우량은?" → API 예보 데이터 + 경고 문구 응답 ⚠️ + └─ "강우량 10mm 초과?" → 이벤트 적용 여부 자동 판단 +``` + +--- + +## 🗄️ SQLite 데이터베이스 스키마 + +### `rainfall_capture` (웹페이지 캡처 데이터) +```sql +CREATE TABLE rainfall_capture ( + id INTEGER PRIMARY KEY, + date TEXT, -- 'YYYYMMDD' + hour INTEGER, -- 10~21 (10시~21시) + rainfall REAL -- mm 단위 +); +``` + +### `rainfall_summary` (일일 합계) +```sql +CREATE TABLE rainfall_summary ( + id INTEGER PRIMARY KEY, + date TEXT UNIQUE, -- 'YYYYMMDD' + total_rainfall REAL, -- mm 단위 + capture_time TEXT -- '2025-12-19 09:00:00' +); +``` + +--- + +## 💬 카카오 챗봇 응답 예시 + +### 당일 조회 (실제 데이터) +``` +📅 12월 19일(금) +📊 실제 강수량 (09:00 캡처 기준) + +10:00 → ☀️ 강수 없음 +11:00 → ☀️ 강수 없음 +12:00 → 0.5mm +... +21:00 → 2.3mm + +💧 총 강수량: 5.2mm +❌ 이벤트 기준(10mm 초과)을 충족하지 않음 + +[날씨 캡처 이미지] +``` + +### 미래 날짜 조회 (API 예보) +``` +📅 12월 20일(토) +📊 예보 강수량 (08:00 발표 기준) + +10:00 → 1.2mm +11:00 → 2.1mm +... +21:00 → 0.8mm + +💧 총 강수량: 12.5mm +✅ 식음료 2만원 이상 결제 시 무료입장권 제공 + +⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다. +``` + +--- + ## ⚙️ 크론탭 설정 Docker 컨테이너 내부에서 매일 **09:00**에 자동 실행됩니다. @@ -197,10 +315,28 @@ pip install -r app/requirements.txt python app/gnu_autoupload.py ``` -### Docker 내 직접 실행 +### Docker 내 수동 실행 ```bash -docker exec -it gnu-autouploader bash -cd /app -python gnu_autoupload.py +# 날씨 캡처 + 강우량 추출 +docker exec gnu-autouploader /usr/bin/python /app/weather_capture.py + +# 메인 작업 (게시글 등록) +docker exec gnu-autouploader /usr/bin/python /app/gnu_autoupload.py + +# 기상청 API 데이터 (선택사항) +docker exec gnu-autouploader /usr/bin/python /app/weather.py +``` + +### 로그 확인 + +```bash +# Crontab + Flask 통합 로그 +docker-compose logs -f gnu-autouploader + +# Crontab 실행 로그만 +docker exec gnu-autouploader tail -f /logs/cron.log + +# Flask 웹서버 로그 +docker exec gnu-autouploader tail -f /logs/flask.log ``` diff --git a/app/api_server.py b/app/api_server.py new file mode 100644 index 0000000..aaaee9a --- /dev/null +++ b/app/api_server.py @@ -0,0 +1,293 @@ +import os +import requests +import json +import re +from flask import Flask, request, jsonify, send_from_directory, make_response +import sqlite3 +from datetime import datetime, timedelta +from config import serviceKey +import logging + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# 환경 변수에서 설정값 불러오기 +DB_PATH = '/data/weather.sqlite' +DOMAIN = os.getenv('DOMAIN', 'http://localhost:5000') +debug_env = os.getenv('FLASK_DEBUG', '0') +DEBUG_MODE = debug_env == '1' + +def parse_rainfall_value(value): + """강수량 텍스트를 숫자로 변환""" + if not value or value == '-': + return 0.0 + elif '~' in value: + return 0.5 + else: + try: + return float(re.search(r'[\d.]+', value).group()) + except (AttributeError, ValueError): + return 0.0 + +def get_captured_rainfall(date): + """ + 캡처된 강수량 데이터 조회 (당일 기준) + + Args: + date: 'YYYYMMDD' 형식 + + Returns: + tuple: (시간별_강수량_목록, 총강수량, 캡처_시각) + """ + try: + conn = sqlite3.connect(DB_PATH) + curs = conn.cursor() + + curs.execute(''' + SELECT hour, rainfall FROM rainfall_capture + WHERE date = ? ORDER BY hour + ''', (date,)) + hourly_data = curs.fetchall() + + curs.execute(''' + SELECT total_rainfall, capture_time FROM rainfall_summary + WHERE date = ? + ''', (date,)) + summary = curs.fetchone() + + conn.close() + + total = summary[0] if summary else 0.0 + capture_time = summary[1] if summary else None + + return hourly_data, total, capture_time + except Exception as e: + logger.error(f"캡처 강수량 조회 실패: {e}") + return [], 0.0, None + +def get_forecast_rainfall(date): + """ + 기상청 API를 통한 강수량 예보 조회 + + Args: + date: 'YYYYMMDD' 형식 + + Returns: + tuple: (시간별_강수량_목록, 총강수량) 또는 ([], 0.0) + """ + try: + # 기상청 초단기 예보 API + url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst" + + # 예보는 08:50 발표 기준 + params = { + 'serviceKey': serviceKey, + 'numOfRows': '1000', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': date, + 'base_time': '0850', + 'nx': '57', + 'ny': '130' + } + + response = requests.get(url, params=params, timeout=10) + data = response.json() + + if data['response']['header']['resultCode'] != '00': + return [], 0.0 + + rainfall_by_hour = {} + for item in data['response']['body']['items']['item']: + if item['category'] == 'RN1': # 1시간 강수량 + hour = int(item['fcstTime'][:2]) + if 10 <= hour <= 21: # 10시~21시(오후 9시) + rainfall_by_hour[hour] = parse_rainfall_value(item['fcstValue']) + + hourly_list = [(h, rainfall_by_hour.get(h, 0.0)) for h in range(10, 22)] + total = sum(rain for _, rain in hourly_list) + + return hourly_list, total + except Exception as e: + logger.error(f"API 강수량 예보 조회 실패: {e}") + return [], 0.0 + +def get_rainfall_data(date_str): + """ + 날짜별 강수량 데이터 조회 + - 당일(오늘): 09:00 캡처된 실제 데이터 + - 미래 날짜: 기상청 예보 API + + Args: + date_str: 'YYYYMMDD' 형식 + + Returns: + dict: { + 'date': 날짜, + 'is_forecast': 예보 여부, + 'hourly_data': [(hour, rainfall), ...], + 'total': 총강수량, + 'note': 추가 설명 + } + """ + today = datetime.now().strftime('%Y%m%d') + is_forecast = date_str > today + + if is_forecast: + # 미래 날짜: API 예보 + hourly, total = get_forecast_rainfall(date_str) + note = "⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다." + else: + # 당일 이상: 캡처 데이터 + hourly, total, timestamp = get_captured_rainfall(date_str) + note = None + + return { + 'date': date_str, + 'is_forecast': is_forecast, + 'hourly_data': hourly, + 'total': total, + 'note': note + } + +# 정적 파일 서빙: /data/ 경로로 이미지 접근 가능하게 함 +@app.route('/data/') +def serve_data_file(filename): + return send_from_directory('/data', filename) + +@app.route('/webhook', methods=['POST']) +def webhook(): + """ + 카카오 챗봇 웹훅 + + 사용자가 요청한 날짜의 강수량 정보 응답 + - 당일: 09:00 캡처 데이터 + - 미래: 기상청 API 예보 + """ + try: + data = request.get_json(silent=True) + + # 사용자 요청 날짜 파싱 (기본값: 오늘) + today = datetime.now().strftime('%Y%m%d') + query_date = today + + # 사용자 발화에서 날짜 추출 시도 + if data and 'userRequest' in data and 'utterance' in data['userRequest']: + utterance = data['userRequest']['utterance'].strip() + + # 내일, 모레 등의 상대 날짜 파싱 + if '내일' in utterance: + query_date = (datetime.now() + timedelta(days=1)).strftime('%Y%m%d') + elif '모레' in utterance: + query_date = (datetime.now() + timedelta(days=2)).strftime('%Y%m%d') + elif '오늘' in utterance or utterance in ['', None]: + query_date = today + else: + # YYYYMMDD 형식의 날짜 찾기 + date_match = re.search(r'(\d{8})', utterance) + if date_match: + query_date = date_match.group(1) + + rainfall_info = get_rainfall_data(query_date) + + # 응답 메시지 구성 + date_obj = datetime.strptime(query_date, '%Y%m%d') + date_str = date_obj.strftime('%m월 %d일(%a)') + + # 강수량 상세 정보 + lines = [f"📅 {date_str}"] + if rainfall_info['is_forecast']: + lines.append("📊 예보 강수량 (08:00 발표 기준)") + else: + lines.append("📊 실제 강수량 (09:00 캡처 기준)") + + lines.append("") + + if rainfall_info['hourly_data']: + for hour, rainfall in rainfall_info['hourly_data']: + if isinstance(hour, tuple): # (hour, rainfall) 튜플인 경우 + hour, rainfall = hour + rain_str = f"{rainfall:.1f}mm" if rainfall > 0 else "☀️ 강수 없음" + lines.append(f"{hour:02d}:00 → {rain_str}") + lines.append("") + lines.append(f"💧 총 강수량: {rainfall_info['total']:.1f}mm") + + # 이벤트 적용 여부 + if rainfall_info['total'] > 10: + lines.append("✅ 식음료 2만원 이상 결제 시 무료입장권 제공") + else: + lines.append("❌ 이벤트 기준(10mm 초과)을 충족하지 않음") + else: + lines.append("데이터를 찾을 수 없습니다.") + + if rainfall_info['note']: + lines.append("") + lines.append(rainfall_info['note']) + + response_text = '\n'.join(lines) + + # 이미지 포함 여부 확인 (당일만) + if not rainfall_info['is_forecast']: + image_filename = f"weather_capture_{query_date}.png" + image_path = f"/data/{image_filename}" + outputs = [{ + "simpleText": { + "text": response_text + } + }] + if os.path.isfile(image_path): + image_url = f"{DOMAIN}/data/{image_filename}" + outputs.append({ + "image": { + "imageUrl": image_url, + "altText": f"{date_str} 날씨 캡처" + } + }) + else: + outputs = [{ + "simpleText": { + "text": response_text + } + }] + + response_body = { + "version": "2.0", + "template": { + "outputs": outputs + } + } + + resp = make_response(jsonify(response_body)) + resp.headers['Content-Type'] = 'application/json; charset=utf-8' + return resp + + except Exception as e: + logger.error(f"웹훅 처리 중 오류: {e}", exc_info=True) + error_body = { + "version": "2.0", + "template": { + "outputs": [{ + "simpleText": { + "text": f"❌ 오류가 발생했습니다: {str(e)}\n관리자에게 문의하세요." + } + }] + } + } + resp = make_response(jsonify(error_body)) + resp.headers['Content-Type'] = 'application/json; charset=utf-8' + return resp + +# 헬스 체크 엔드포인트 +@app.route('/health', methods=['GET']) +def health_check(): + return jsonify({'status': 'ok', 'timestamp': datetime.now().isoformat()}), 200 + +if __name__ == '__main__': + logger.info("Flask 웹서버 시작 (포트 5000)") + app.run(host='0.0.0.0', port=5000, debug=DEBUG_MODE) diff --git a/app/requirements.txt b/app/requirements.txt index a7b13a9..b3cae08 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -8,9 +8,23 @@ # datetime # External packages (pip install 필요) +# 이미지 처리 Pillow + +# 데이터베이스 PyMySQL + +# FTP ftputil + +# HTTP 요청 requests + +# 웹 자동화 selenium + +# 환경 변수 python-dotenv + +# 웹 프레임워크 +flask diff --git a/app/weather_capture.py b/app/weather_capture.py index 6365df1..69dd6a6 100644 --- a/app/weather_capture.py +++ b/app/weather_capture.py @@ -2,6 +2,9 @@ import logging import os import sys import time +import sqlite3 +import re +from datetime import datetime from config import TODAY from selenium_manager import SeleniumManager @@ -15,9 +18,191 @@ logger = logging.getLogger(__name__) WEATHER_URL = 'https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948' OUTPUT_DIR = '/data' OUTPUT_FILENAME = f'weather_capture_{TODAY}.png' +DB_PATH = '/data/weather.sqlite' + +def parse_rainfall(value): + """ + 강수량 텍스트를 숫자(mm)로 변환 + - '-'는 0.0 + - '~1'은 0.5 + - 숫자만 있으면 float로 변환 + """ + if not value or value.strip() == '-': + return 0.0 + elif '~' in value: # '~1mm' 형태 + return 0.5 + else: + try: + return float(re.search(r'[\d.]+', value).group()) + except (AttributeError, ValueError): + logger.warning(f"강수량 파싱 실패: {value}") + return 0.0 + +def extract_rainfall_from_page(driver): + """ + Selenium driver에서 시간별 강수량 데이터 추출 + 10시~21시(오후 9시) 데이터만 수집 + + Returns: + dict: {시간(int): 강수량(float)} 형태, 또는 None (실패 시) + """ + try: + logger.info("페이지에서 강수량 데이터 추출 시작...") + time.sleep(1) # 페이지 로드 대기 + + # 테이블에서 시간별 강수량 추출 + # 기상청 웹사이트 구조에 맞게 조정 필요 + rainfall_data = {} + + # 방법 1: 테이블 행(tr) 순회 + try: + rows = driver.find_elements("xpath", "//table//tr") + if not rows: + logger.warning("테이블 행을 찾을 수 없음, 대체 방법 시도...") + return extract_rainfall_alternative(driver) + + for row in rows: + try: + # 각 행에서 시간과 강수량 추출 + cells = row.find_elements("tag name", "td") + if len(cells) >= 2: + time_cell = cells[0].text.strip() + rain_cell = cells[1].text.strip() + + # 시간 파싱 (HH:00 형태) + time_match = re.search(r'(\d{1,2}):?00?', time_cell) + if time_match: + hour = int(time_match.group(1)) + if 10 <= hour <= 21: # 10시~21시(오후 9시) + rainfall = parse_rainfall(rain_cell) + rainfall_data[hour] = rainfall + logger.info(f" {hour:02d}:00 → {rainfall}mm") + except Exception as e: + logger.debug(f"행 파싱 중 오류: {e}") + continue + + except Exception as e: + logger.warning(f"테이블 파싱 실패: {e}, 대체 방법 시도...") + return extract_rainfall_alternative(driver) + + if rainfall_data: + total = sum(rainfall_data.values()) + logger.info(f"총 강수량: {total:.1f}mm") + return rainfall_data + else: + logger.warning("추출된 강수량 데이터가 없음") + return None + + except Exception as e: + logger.error(f"강수량 데이터 추출 중 오류: {type(e).__name__}: {e}", exc_info=True) + return None + +def extract_rainfall_alternative(driver): + """ + 대체 방법: span/div 엘리먼트에서 강수량 추출 + """ + try: + logger.info("대체 방법으로 강수량 추출 시도...") + # 기상청 사이트의 실제 구조에 맞게 조정 + rainfall_data = {} + + # 시간 레이블 찾기 + hour_elements = driver.find_elements("xpath", "//span[contains(text(), '시')]") + for elem in hour_elements: + try: + text = elem.text + match = re.search(r'(\d{1,2})시', text) + if match: + hour = int(match.group(1)) + if 10 <= hour <= 21: + # 시간 엘리먼트 다음 형제에서 강수량 찾기 + parent = elem.find_element("xpath", "./ancestor::*[position()=3]") + rain_elem = parent.find_element("xpath", ".//span[last()]") + rainfall = parse_rainfall(rain_elem.text) + rainfall_data[hour] = rainfall + logger.info(f" {hour:02d}:00 → {rainfall}mm") + except Exception as e: + logger.debug(f"대체 방법 파싱 중 오류: {e}") + continue + + return rainfall_data if rainfall_data else None + except Exception as e: + logger.error(f"대체 방법 실패: {e}") + return None + +def save_rainfall_to_db(rainfall_data): + """ + 추출한 강수량 데이터를 SQLite DB에 저장 + + Args: + rainfall_data: {시간(int): 강수량(float)} 딕셔너리 + + Returns: + bool: 성공 여부 + """ + if not rainfall_data: + logger.warning("저장할 강수량 데이터가 없음") + return False + + try: + os.makedirs(os.path.dirname(DB_PATH) or '/data', exist_ok=True) + conn = sqlite3.connect(DB_PATH) + curs = conn.cursor() + + # 테이블 생성 + curs.execute(''' + CREATE TABLE IF NOT EXISTS rainfall_capture ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + hour INTEGER NOT NULL, + rainfall REAL NOT NULL, + UNIQUE(date, hour) + ) + ''') + + curs.execute(''' + CREATE TABLE IF NOT EXISTS rainfall_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE, + total_rainfall REAL NOT NULL, + capture_time TEXT NOT NULL + ) + ''') + + # 기존 데이터 삭제 + curs.execute('DELETE FROM rainfall_capture WHERE date = ?', (TODAY,)) + curs.execute('DELETE FROM rainfall_summary WHERE date = ?', (TODAY,)) + + # 시간별 강수량 저장 + total_rainfall = 0.0 + for hour in sorted(rainfall_data.keys()): + rainfall = rainfall_data[hour] + curs.execute( + 'INSERT INTO rainfall_capture (date, hour, rainfall) VALUES (?, ?, ?)', + (TODAY, hour, rainfall) + ) + total_rainfall += rainfall + logger.info(f"DB 저장: {TODAY} {hour:02d}:00 → {rainfall}mm") + + # 합계 저장 + capture_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + curs.execute( + 'INSERT INTO rainfall_summary (date, total_rainfall, capture_time) VALUES (?, ?, ?)', + (TODAY, total_rainfall, capture_time) + ) + + conn.commit() + conn.close() + + logger.info(f"[DB 저장 완료] {TODAY} 총 강수량: {total_rainfall:.1f}mm") + return True + + except Exception as e: + logger.error(f"DB 저장 중 오류: {type(e).__name__}: {e}", exc_info=True) + return False def capture_weather(): - """기상청 날씨 정보 캡처""" + """기상청 날씨 정보 캡처 및 강수량 데이터 추출""" # 저장 경로 설정 output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME) @@ -47,6 +232,13 @@ def capture_weather(): # 페이지 반영 대기 time.sleep(2) + # 강수량 데이터 추출 + rainfall_data = extract_rainfall_from_page(manager.driver) + if rainfall_data: + save_rainfall_to_db(rainfall_data) + else: + logger.warning("강수량 데이터 추출 실패 (캡처는 진행)") + # 스크린샷 저장 logger.info(f"스크린샷 저장 시도: {output_path}") if manager.take_element_screenshot(manager.WEATHER_SELECTORS['target_element'], output_path): diff --git a/build/app/Dockerfile b/build/app/Dockerfile index 34c8c11..c57ab54 100644 --- a/build/app/Dockerfile +++ b/build/app/Dockerfile @@ -28,7 +28,8 @@ RUN pip install --no-cache-dir --upgrade pip && \ pillow \ pyvirtualdisplay \ requests \ - python-dotenv + python-dotenv \ + flask WORKDIR /app @@ -39,10 +40,15 @@ COPY app/ /app/ # 로그 디렉토리 생성 RUN mkdir -p /logs && chmod 777 /logs -# Crontab 설정: 매일 09:00에 절대 경로로 Python 실행 -# cron은 컨테이너의 환경 변수를 상속받으므로 env_file로 주입된 변수들을 사용 가능 -RUN echo "0 9 * * * /usr/bin/python /app/gnu_autoupload.py >> /logs/cron.log 2>&1" | crontab - && \ - chmod 666 /logs +# Entrypoint 스크립트를 사용하여 Flask + Cron 동시 실행 +COPY build/app/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Cron을 포그라운드에서 실행 (docker logs에 출력되도록) -CMD ["/usr/sbin/cron", "-f"] +# Crontab 설정: 매일 09:00에 절대 경로로 Python 실행 +RUN echo "0 9 * * * /usr/bin/python /app/gnu_autoupload.py >> /logs/cron.log 2>&1" | crontab - + +# 포트 노출 (Flask) +EXPOSE 5000 + +# Entrypoint 실행 (Flask + Cron) +ENTRYPOINT ["/entrypoint.sh"] diff --git a/build/app/entrypoint.sh b/build/app/entrypoint.sh new file mode 100644 index 0000000..2f5961e --- /dev/null +++ b/build/app/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Entrypoint 스크립트: Flask 웹서버 + Crontab 동시 실행 + +set -e + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] ========================================" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] gnu-autouploader 컨테이너 시작" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] ========================================" + +# Flask 웹서버를 백그라운드에서 시작 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Flask 웹서버 시작 (포트 5000)..." +cd /app +/usr/bin/python -m flask run --host=0.0.0.0 --port=5000 >> /logs/flask.log 2>&1 & +FLASK_PID=$! +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Flask PID: $FLASK_PID" + +# 함정 설정: 스크립트 종료 시 Flask도 종료 +trap "kill $FLASK_PID 2>/dev/null; exit" SIGTERM SIGINT + +# Crontab 데몬을 포그라운드에서 실행 (docker logs에 출력) +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Crontab 데몬 시작..." +/usr/sbin/cron -f diff --git a/build/app/run.sh b/build/app/run.sh deleted file mode 100644 index 6c0cfe8..0000000 --- a/build/app/run.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -# 이 스크립트는 수동 실행 시 사용됩니다. -# Crontab은 python을 직접 실행하므로 이 스크립트를 거치지 않습니다. - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" -} - -log "========================================" -log "날씨 정보 자동 게시글 생성 시작" -log "========================================" - -cd /app -if [ -f "gnu_autoupload.py" ]; then - /usr/bin/python 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 "========================================" diff --git a/build/webhook/Dockerfile b/build/webhook/Dockerfile deleted file mode 100644 index 5a43488..0000000 --- a/build/webhook/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# Dockerfile for webhook server (Ubuntu 22.04 + Python Flask) - -FROM ubuntu:22.04 - -ENV DEBIAN_FRONTEND=noninteractive \ - LANG=ko_KR.UTF-8 \ - LANGUAGE=ko_KR:ko \ - LC_ALL=ko_KR.UTF-8 - -# 기본 패키지 설치 및 로케일 설정 -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - locales tzdata python3 python3-pip curl ca-certificates && \ - locale-gen ko_KR.UTF-8 && \ - ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ - dpkg-reconfigure --frontend noninteractive tzdata && \ - ln -sf /usr/bin/python3 /usr/bin/python && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# Flask 설치 -RUN pip3 install --no-cache-dir flask requests - -# 작업 디렉토리 -WORKDIR /app - -# 외부 접속 허용 포트 -EXPOSE 5000 - -# Flask 앱 실행 -CMD ["python3", "webhook.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 148fa0d..3133eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,25 +9,8 @@ services: - ./data:/data - ./logs:/logs - ./.env:/app/.env:ro -# - ./app:/app + ports: + - "5151:5000" env_file: - .env restart: unless-stopped - - fg-webhook: - build: - context: ./build/webhook - dockerfile: Dockerfile - image: reg.firstgarden.co.kr/fg-webhook:latest - container_name: fg-webhook - volumes: - - ./data:/data - - ./webhook:/app - ports: - - 5151:5000 - environment: - - DOMAIN=https://webhook.firstgarden.co.kr - - FLASK_DEBUG=1 #디버그 활성화 - #environment: - # - DOMAIN=https://webhook.firstgarden.co.kr - restart: unless-stopped diff --git a/webhook/webhook.py b/webhook/webhook.py deleted file mode 100644 index 216e0c2..0000000 --- a/webhook/webhook.py +++ /dev/null @@ -1,97 +0,0 @@ -import os -from flask import Flask, request, jsonify, send_from_directory, make_response -import sqlite3 -from datetime import datetime - -app = Flask(__name__) - -# 환경 변수에서 설정값 불러오기 -DB_PATH = '/data/weather.sqlite' -DOMAIN = os.getenv('DOMAIN', 'http://localhost:5000') -debug_env = os.getenv('FLASK_DEBUG', '0') -DEBUG_MODE = debug_env == '1' - -def get_rain_data(date): - conn = sqlite3.connect(DB_PATH) - curs = conn.cursor() - - curs.execute('SELECT time, rainfall FROM precipitation WHERE date = ? ORDER BY time', (date,)) - time_rain_list = curs.fetchall() - - curs.execute('SELECT total_rainfall FROM precipitation_summary WHERE date = ?', (date,)) - row = curs.fetchone() - total_rainfall = row[0] if row else 0.0 - - conn.close() - return time_rain_list, total_rainfall - -# 정적 파일 서빙: /data/ 경로로 이미지 접근 가능하게 함 -@app.route('/data/') -def serve_data_file(filename): - return send_from_directory('/data', filename) - -@app.route('/webhook', methods=['POST']) -def webhook(): - try: - data = request.get_json(silent=True) # 사용자 발화가 필요한 경우: data['userRequest']['utterance'] - today = datetime.today().strftime('%Y%m%d') - time_rain_list, total_rainfall = get_rain_data(today) - - # 메시지 구성 - if not time_rain_list: - response_text = f"{today} 날짜의 강수량 데이터가 없습니다." - else: - lines = [] - for time_str, rain in time_rain_list: - rain_display = f"{rain}mm" if rain > 0 else "강수 없음" - lines.append(f"{time_str} → {rain_display}") - lines.append(f"\n영업시간 내 총 강수량은 {total_rainfall:.1f}mm 입니다.") - response_text = '\n'.join(lines) - - # 이미지 포함 여부 확인 - image_filename = f"weather_capture_{today}.png" - image_path = f"/data/{image_filename}" - outputs = [{ - "simpleText": { - "text": response_text - } - }] - if os.path.isfile(image_path): - image_url = f"{DOMAIN}/data/{image_filename}" - outputs.append({ - "image": { - "imageUrl": image_url, - "altText": "오늘의 날씨 캡처 이미지" - } - }) - - # 응답 본문 구성 (version을 최상단에) - response_body = { - "version": "2.0", - "template": { - "outputs": outputs - } - } - - # 응답 헤더 설정 - resp = make_response(jsonify(response_body)) - resp.headers['Content-Type'] = 'application/json; charset=utf-8' - return resp - - except Exception as e: - error_body = { - "version": "2.0", - "template": { - "outputs": [{ - "simpleText": { - "text": f"서버 오류가 발생했습니다: {str(e)}" - } - }] - } - } - resp = make_response(jsonify(error_body)) - resp.headers['Content-Type'] = 'application/json; charset=utf-8' - return resp - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=DEBUG_MODE)