웹훅 통합 및 프로젝트 구조 변경 및 개선

This commit is contained in:
2025-12-19 10:15:55 +09:00
parent d2fbfa46c1
commit 27d9a77513
12 changed files with 715 additions and 202 deletions

View File

@ -37,3 +37,13 @@ SERVICE_KEY=your_weather_api_key_here
MATTERMOST_URL=https://mattermost.example.com MATTERMOST_URL=https://mattermost.example.com
MATTERMOST_TOKEN=your-personal-access-token MATTERMOST_TOKEN=your-personal-access-token
MATTERMOST_CHANNEL_ID=channel_id MATTERMOST_CHANNEL_ID=channel_id
# =====================================
# 웹서버 설정
# =====================================
# 웹훅 도메인 (이미지 URL 생성 시 사용)
DOMAIN=https://webhook.firstgarden.co.kr
# Flask 디버그 모드 (개발 시만 1로 설정, 운영은 0)
FLASK_DEBUG=0

23
.gitignore vendored
View File

@ -1,8 +1,19 @@
config.sample.py # 환경 변수
.env .env
**/__pycache__/
*.pyc # IDE
naver_review/build/
*.spec
data/weather_capture_*.png
.vscode/ .vscode/
# Python
__pycache__/
*.pyc
*.spec
# 프로젝트 특화
data/weather_capture_*.png
logs/cron.log
logs/flask.log
# 레거시 (사용 안 함)
config.sample.py
naver_review/

164
README.md
View File

@ -15,28 +15,30 @@ project-root/
│ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블) │ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블)
│ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지 │ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지
├── app/ # gnu-autouploader 앱 소스 (Dockerfile에서 복사) ├── app/ # gnu-autouploader + API 서버 (통합)
│ ├── gnu_autoupload.py # 메인 실행 스크립트 (Selenium → FTP → DB) │ ├── gnu_autoupload.py # 메인 실행 스크립트 (Selenium → FTP → DB)
│ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처 │ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처 + 강우량 추출
│ ├── weather.py # 기상청 API 데이터 처리 및 sqlite 저장 │ ├── weather.py # 기상청 API 데이터 처리 및 sqlite 저장
│ ├── send_message.py # Mattermost 알림 발송 │ ├── send_message.py # Mattermost 알림 발송
│ ├── selenium_manager.py # Selenium 브라우저 관리 │ ├── selenium_manager.py # Selenium 브라우저 관리
│ ├── api_server.py # Flask 기반 카카오 챗봇 웹훅 서버 ⭐
│ ├── config.py # 설정값 로드 (DB, FTP, API KEY 등) │ ├── config.py # 설정값 로드 (DB, FTP, API KEY 등)
│ ├── requirements.txt # Python 의존성 │ ├── requirements.txt # Python 의존성
│ └── run.sh # 수동 실행용 셸 스크립트 (개발 시 사용) │ └── run.sh # 수동 실행용 셸 스크립트 (개발 시 사용)
├── webhook/ # Synology Chat 웹훅 응답 서버 ├── webhook/ # (더 이상 사용 안 함 - app/api_server.py로 통합)
│ └── webhook.py # Flask 기반 응답 서버 │ └── webhook.py # (참고용 아카이브)
├── build/ ├── build/
│ ├── app/ │ ├── app/
│ │ ├── Dockerfile # gnu-autouploader 컨테이너 이미지 │ │ ├── Dockerfile # gnu-autouploader + Flask 통합 이미지
│ │ ├── entrypoint.sh # Flask + Cron 동시 실행 스크립트 ⭐
│ │ └── run.sh # (위의 app/run.sh와 동일) │ │ └── run.sh # (위의 app/run.sh와 동일)
│ └── webhook/ │ └── webhook/
│ └── Dockerfile # webhook 서버용 Dockerfile │ └── Dockerfile # (더 이상 사용 안 함)
├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정) ├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정)
├── docker-compose.yml # Docker Compose 서비스 정의 ├── docker-compose.yml # Docker Compose 서비스 정의 (gnu-autouploader만)
└── README.md # 프로젝트 문서 └── README.md # 프로젝트 문서
``` ```
@ -51,15 +53,30 @@ project-root/
- `docker exec` 또는 `run.sh`로 수동 실행 가능 - `docker exec` 또는 `run.sh`로 수동 실행 가능
- **오류 발생 시**: Mattermost으로 알림 발송 - **오류 발생 시**: Mattermost으로 알림 발송
### `app/weather_capture.py` ### `app/weather_capture.py` ⭐ (개선)
- Selenium을 사용해 기상청 날씨누리 페이지 캡처 - **이전**: Selenium으로 웹페이지 캡처만 수행
- '최근발표시각' 표시 (출처 명시) - **현재**:
- Selenium으로 기상청 웹페이지 접근
- **페이지에서 강우량 데이터 자동 추출** (10시~21시)
- '-'는 0mm, '~1'은 0.5mm로 자동 계산
- **추출된 강우량을 SQLite에 저장** (`rainfall_capture`, `rainfall_summary` 테이블)
- 웹페이지 이미지 캡처 저장
### `app/weather.py` ### `app/weather.py`
- 기상청 API에서 시간별 강수량 데이터 수집 - 기상청 API에서 시간별 강수량 데이터 수집
- 10:00 ~ 22:00 영업시간 강수 데이터 HTML 테이블 생성 - 10:00 ~ 22:00 영업시간 강수 데이터 HTML 테이블 생성
- SQLite DB에 저장 - SQLite DB에 저장
### `webhook/webhook.py` ⭐ (개선)
- **Flask 기반 카카오 챇봇 응답 서버**
- **주요 기능**:
- **당일 조회**: 09:00 캡처된 **실제 강우량 데이터** 응답
- **미래 날짜 조회**: 기상청 API 기반 **예보 강우량** 응답
- 예보 시 "변동될 수 있음" 경고 문구 표시
- 10mm 초과 시 이벤트 적용 안내
- 날씨 캡처 이미지 함께 전송
- **통합**: 현재 `app/api_server.py`로 통합되어 동일 컨테이너에서 실행
### `app/config.py` ### `app/config.py`
- 환경 변수 로드 (`.env` 또는 컨테이너 환경 변수) - 환경 변수 로드 (`.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**에 자동 실행됩니다. Docker 컨테이너 내부에서 매일 **09:00**에 자동 실행됩니다.
@ -197,10 +315,28 @@ pip install -r app/requirements.txt
python app/gnu_autoupload.py python app/gnu_autoupload.py
``` ```
### Docker 내 직접 실행 ### Docker 내 수동 실행
```bash ```bash
docker exec -it gnu-autouploader bash # 날씨 캡처 + 강우량 추출
cd /app docker exec gnu-autouploader /usr/bin/python /app/weather_capture.py
python gnu_autoupload.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
``` ```

293
app/api_server.py Normal file
View File

@ -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/<path:filename>')
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)

View File

@ -8,9 +8,23 @@
# datetime # datetime
# External packages (pip install 필요) # External packages (pip install 필요)
# 이미지 처리
Pillow Pillow
# 데이터베이스
PyMySQL PyMySQL
# FTP
ftputil ftputil
# HTTP 요청
requests requests
# 웹 자동화
selenium selenium
# 환경 변수
python-dotenv python-dotenv
# 웹 프레임워크
flask

View File

@ -2,6 +2,9 @@ import logging
import os import os
import sys import sys
import time import time
import sqlite3
import re
from datetime import datetime
from config import TODAY from config import TODAY
from selenium_manager import SeleniumManager 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' WEATHER_URL = 'https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948'
OUTPUT_DIR = '/data' OUTPUT_DIR = '/data'
OUTPUT_FILENAME = f'weather_capture_{TODAY}.png' 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(): def capture_weather():
"""기상청 날씨 정보 캡처""" """기상청 날씨 정보 캡처 및 강수량 데이터 추출"""
# 저장 경로 설정 # 저장 경로 설정
output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME) output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME)
@ -47,6 +232,13 @@ def capture_weather():
# 페이지 반영 대기 # 페이지 반영 대기
time.sleep(2) 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}") logger.info(f"스크린샷 저장 시도: {output_path}")
if manager.take_element_screenshot(manager.WEATHER_SELECTORS['target_element'], output_path): if manager.take_element_screenshot(manager.WEATHER_SELECTORS['target_element'], output_path):

View File

@ -28,7 +28,8 @@ RUN pip install --no-cache-dir --upgrade pip && \
pillow \ pillow \
pyvirtualdisplay \ pyvirtualdisplay \
requests \ requests \
python-dotenv python-dotenv \
flask
WORKDIR /app WORKDIR /app
@ -39,10 +40,15 @@ COPY app/ /app/
# 로그 디렉토리 생성 # 로그 디렉토리 생성
RUN mkdir -p /logs && chmod 777 /logs RUN mkdir -p /logs && chmod 777 /logs
# Crontab 설정: 매일 09:00에 절대 경로로 Python 실행 # Entrypoint 스크립트를 사용하여 Flask + Cron 동시 실행
# cron은 컨테이너의 환경 변수를 상속받으므로 env_file로 주입된 변수들을 사용 가능 COPY build/app/entrypoint.sh /entrypoint.sh
RUN echo "0 9 * * * /usr/bin/python /app/gnu_autoupload.py >> /logs/cron.log 2>&1" | crontab - && \ RUN chmod +x /entrypoint.sh
chmod 666 /logs
# Cron을 포그라운드에서 실행 (docker logs에 출력되도록) # Crontab 설정: 매일 09:00에 절대 경로로 Python 실행
CMD ["/usr/sbin/cron", "-f"] 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"]

23
build/app/entrypoint.sh Normal file
View File

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

View File

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

View File

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

View File

@ -9,25 +9,8 @@ services:
- ./data:/data - ./data:/data
- ./logs:/logs - ./logs:/logs
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
# - ./app:/app ports:
- "5151:5000"
env_file: env_file:
- .env - .env
restart: unless-stopped 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

View File

@ -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/<path:filename>')
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)