웹훅 통합 및 프로젝트 구조 변경 및 개선
This commit is contained in:
10
.env.example
10
.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
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@ -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/
|
||||
|
||||
164
README.md
164
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
|
||||
```
|
||||
|
||||
293
app/api_server.py
Normal file
293
app/api_server.py
Normal 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)
|
||||
@ -8,9 +8,23 @@
|
||||
# datetime
|
||||
|
||||
# External packages (pip install 필요)
|
||||
# 이미지 처리
|
||||
Pillow
|
||||
|
||||
# 데이터베이스
|
||||
PyMySQL
|
||||
|
||||
# FTP
|
||||
ftputil
|
||||
|
||||
# HTTP 요청
|
||||
requests
|
||||
|
||||
# 웹 자동화
|
||||
selenium
|
||||
|
||||
# 환경 변수
|
||||
python-dotenv
|
||||
|
||||
# 웹 프레임워크
|
||||
flask
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"]
|
||||
|
||||
23
build/app/entrypoint.sh
Normal file
23
build/app/entrypoint.sh
Normal 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
|
||||
@ -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 "========================================"
|
||||
@ -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"]
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
Reference in New Issue
Block a user