6 Commits

Author SHA1 Message Date
f134467be7 feat: 게시글 등록 후 메시지에 캡처파일 다운로드 링크 추가
- 게시글 등록 완료 메시지에 캡처파일 다운로드 링크 포함
- 메시지 포맷 개선 (제목을 마크다운 링크로 표시)
- 캡처파일 정보와 다운로드 경로를 메시지에 명확히 표시
2025-12-23 13:28:49 +09:00
58cff598a7 오류 수정 2025-12-22 17:41:31 +09:00
91cd2ad7b2 웹훅 API 사용법과 응답 내용 수정 2025-12-19 10:56:38 +09:00
cda9cd6543 파일 업로드 시 mattermost로 알리는 부분에서 이미지 파일과 url을 함께 전송하도록 수정. 2025-12-19 10:39:43 +09:00
bfd7d06e5b URL 업데이트 2025-12-19 10:29:11 +09:00
1beb98ed4f env 예시가 제대로 되어있지 않은 부분 수정. 2025-12-19 10:27:13 +09:00
6 changed files with 408 additions and 45 deletions

View File

@ -2,8 +2,10 @@
# 필수 설정 항목 (반드시 작성해야 함)
# =====================================
# 사이트 기본 URL
URL=https://example.com
# 게시판 설정
BOARD_ID=news
BOARD_ID=게시판이름
BOARD_CA_NAME=카테고리명
BOARD_CONTENT=글 내용
BOARD_MB_ID=user_id
@ -13,7 +15,7 @@ BOARD_NICKNAME=user_nickname
# Docker 환경: DB_HOST는 docker-compose의 service name 사용 (예: db, mysql)
# 일반 환경: 실제 MySQL 호스트 IP/도메인 사용 (예: 192.168.1.100, db.example.com)
# Synology 환경: 호스트 IP 또는 도메인 명시 (localhost 사용 불가)
DB_HOST=db
DB_HOST=db.example.com
DB_USER=db_username
DB_PASSWORD=db_password
DB_NAME=database_name
@ -38,12 +40,8 @@ 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)
# DOMAIN: 카카오 챗봇에서 이미지 URL 생성 시 사용
# FLASK_DEBUG: 0(운영), 1(개발)
DOMAIN=https://webhook.example.com
FLASK_DEBUG=0

211
README.md
View File

@ -67,15 +67,19 @@ project-root/
- 10:00 ~ 22:00 영업시간 강수 데이터 HTML 테이블 생성
- SQLite DB에 저장
### `webhook/webhook.py` ⭐ (개선)
- **Flask 기반 카카오 봇 응답 서버**
### `app/api_server.py` ⭐ (Flask 웹훅 서버)
- **Flask 기반 카카오 봇 응답 서버**
- **포트**: 5000 (docker-compose에서 5151로 노출)
- **주요 기능**:
- **당일 조회**: 09:00 캡처된 **실제 강우량 데이터** 응답
- **미래 날짜 조회**: 기상청 API 기반 **예보 강우량** 응답
- 예보 시 "변동될 수 있음" 경고 문구 표시
- 10mm 초과 시 이벤트 적용 안내
- 날씨 캡처 이미지 함께 전송
- **통합**: 현재 `app/api_server.py`로 통합되어 동일 컨테이너에서 실행
- **주요 엔드포인트**:
- `POST /webhook` - Kakaotalk 챗봇 웹훅
- `GET /health` - 헬스 체크
- `GET /data/<filename>` - 캡처 이미지 조회
### `app/config.py`
- 환경 변수 로드 (`.env` 또는 컨테이너 환경 변수)
@ -215,7 +219,206 @@ CREATE TABLE rainfall_summary (
---
## 💬 카카오 챗봇 응답 예시
## <EFBFBD> 웹훅 API 사용법
### 웹훅 엔드포인트
**URL**: `https://webhook.firstgarden.co.kr/webhook` (또는 `DOMAIN/webhook`)
**요청 방식**: `POST`
### 1. 카카오 챗봇 연동 (자동)
#### 요청 형식 (카카오로부터)
```json
{
"userRequest": {
"text": "12월 19일 강우량은?",
"user": {
"id": "user_123",
"properties": {}
}
}
}
```
#### 응답 형식 (카카오에게)
```json
{
"version": "2.0",
"template": {
"outputs": [
{
"simpleText": {
"text": "📅 12월 19일(금)...(응답 내용)..."
}
},
{
"simpleImage": {
"imageUrl": "https://webhook.firstgarden.co.kr/data/weather_capture_20251219.png",
"altText": "날씨 이미지"
}
}
]
}
}
```
### 2. 직접 API 호출 (수동 테스트)
#### cURL로 테스트
```bash
# 당일 조회
curl -X POST https://webhook.firstgarden.co.kr/webhook \
-H "Content-Type: application/json" \
-d '{"userRequest": {"text": "오늘 레이니데이 적용?"}}'
# 특정 날짜 조회
curl -X POST https://webhook.firstgarden.co.kr/webhook \
-H "Content-Type: application/json" \
-d '{"userRequest": {"text": "12월 20일 레이니데이"}}'
# 헬스 체크
curl https://webhook.firstgarden.co.kr/health
```
#### Python으로 테스트
```python
import requests
webhook_url = "https://webhook.firstgarden.co.kr/webhook"
payload = {
"userRequest": {
"text": "내일 레이니데이?"
}
}
response = requests.post(webhook_url, json=payload)
print(response.json())
```
### 3. 응답 분석 규칙
챗봇은 사용자 입력에서 **날짜 패턴**을 자동으로 감지합니다:
| 입력 예시 | 인식 날짜 | 데이터 출처 |
|---------|---------|----------|
| "오늘 강우량" | 당일 | SQLite (09:00 캡처) ✅ 실제값 |
| "12월 19일" | 2025-12-19 | 당일이면 SQLite, 미래면 API ⚠️ |
| "내일" | 내일 | API 예보 |
| "모레" | 모레 | API 예보 |
| "12월 25일" | 2025-12-25 | API 예보 |
| "날짜 지정 없음" | 당일 | SQLite (09:00 캡처) |
### 4. 웹훅 응답 예시
#### 당일 실제 데이터 (SQLite)
```
📅 12월 19일(금)
📊 실제 강수량 (09:00 캡처 기준)
10:00 → ☀️ 강수 없음
11:00 → ☀️ 강수 없음
12:00 → 0.5mm
13:00 → 0.5mm
14:00 → 1.2mm
...
21:00 → 2.3mm
💧 총 강수량: 5.2mm
❌ 이벤트 기준(10mm 초과)을 충족하지 않음
🔗 게시글 링크: https://firstgarden.co.kr/news/123
```
#### 미래 날짜 예보 (API)
```
📅 12월 20일(토)
📊 예보 강수량 (08:00 발표 기준)
10:00 → 1.2mm
11:00 → 2.1mm
12:00 → 3.5mm
...
21:00 → 0.8mm
💧 총 강수량: 12.5mm
✅ 레이니데이 적용 가능
⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다.
```
#### 이미지 첨부 (자동)
```
응답에 자동으로 캡처된 날씨 이미지가 함께 전송됩니다.
```
### 5. 헬스 체크
```bash
# 엔드포인트 상태 확인
curl https://webhook.firstgarden.co.kr/health
# 응답
{
"status": "healthy",
"timestamp": "2025-12-19 10:30:45"
}
```
### 6. 이미지 직접 조회
```bash
# 캡처 이미지 조회 (브라우저에서도 열기 가능)
https://webhook.firstgarden.co.kr/data/weather_capture_20251219.png
```
---
## 🎯 카카오 챗봇 설정
### 1. 카카오 디벨로퍼 콘솔에서
1. [카카오 디벨로퍼 콘솔](https://developers.kakao.com/) 접속
2. 앱 생성 → "채팅" 선택
3. **구성 > 채팅 설정** 이동
4. **스킬 추가** 클릭
- 스킬명: `레이니데이 이벤트`
- URL: `https://webhook.firstgarden.co.kr/webhook`
- HTTP 메서드: `POST`
5. **인텐트** 설정 (예시)
- "강우량은?", "날씨는?", "이벤트 조건?", "무료 입장?", "강수량", "강우" 등
### 2. 테스트 채널에서 확인
- 카카오톡 채팅 → 챗봇에 메시지 전송 → 웹훅이 자동으로 응답
---
## 📊 게시글 등록 완료 알림
`gnu_autoupload.py`가 성공적으로 게시글을 등록할 때, Mattermost 알림이 발송됩니다:
```
✅ **게시글 등록 완료**
📅 날짜: 2025-12-19 09:05:30
📋 게시판: `news`
📝 제목: 2025-12-19 날씨정보
📎 첨부파일: 2개
🖼️ 캡처파일: `weather_capture_20251219.png` (1024.5KB)
🔗 게시글 링크: https://firstgarden.co.kr/news/123
```
**포함 정보**:
- 등록 일시
- 게시판 ID
- 게시글 제목
- 첨부파일 개수
- 캡처 이미지 정보
- **게시글 직접 링크** (`URL/BOARD_ID/wr_id` 형식)
---
## <20>💬 카카오 챗봇 응답 예시
### 당일 조회 (실제 데이터)
```

View File

@ -2,6 +2,7 @@ import os
import requests
import json
import re
import importlib.util
from flask import Flask, request, jsonify, send_from_directory, make_response
import sqlite3
from datetime import datetime, timedelta
@ -71,6 +72,126 @@ def get_captured_rainfall(date):
logger.error(f"캡처 강수량 조회 실패: {e}")
return [], 0.0, None
def generate_weather_data(date):
"""
날씨 데이터가 없을 때 Selenium으로 데이터 추출하여 SQLite에만 저장
(캡처 이미지 저장, FTP 업로드, 게시글 등록은 하지 않음)
Args:
date: 'YYYYMMDD' 형식
Returns:
bool: 성공 여부
"""
try:
logger.info(f"날씨 데이터 추출 시작 ({date})...")
# weather_capture 모듈에서 필요한 함수들을 동적으로 임포트
import importlib.util
spec = importlib.util.spec_from_file_location(
"weather_capture",
"/app/weather_capture.py"
)
weather_capture = importlib.util.module_from_spec(spec)
spec.loader.exec_module(weather_capture)
# Selenium으로 강우량 데이터 추출
from selenium_manager import SeleniumManager
selenium_mgr = SeleniumManager()
driver = selenium_mgr.get_driver()
try:
driver.get(weather_capture.WEATHER_URL)
rainfall_data = weather_capture.extract_rainfall_from_page(driver)
if rainfall_data:
# SQLite에만 저장 (이미지 저장, FTP 업로드는 하지 않음)
success = save_rainfall_to_sqlite(rainfall_data, date)
logger.info(f"날씨 데이터 추출 완료 ({date}): {success}")
return success
else:
logger.warning(f"강수량 데이터 추출 실패 ({date})")
return False
finally:
selenium_mgr.close_driver()
except Exception as e:
logger.error(f"날씨 데이터 추출 중 오류: {type(e).__name__}: {e}")
return False
def save_rainfall_to_sqlite(rainfall_data, date):
"""
추출한 강수량 데이터를 SQLite DB에만 저장
(이미지 저장, FTP 업로드, 게시글 등록 없음)
Args:
rainfall_data: {시간(int): 강수량(float)} 딕셔너리
date: 'YYYYMMDD' 형식
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 = ?', (date,))
curs.execute('DELETE FROM rainfall_summary WHERE date = ?', (date,))
# 시간별 강수량 저장
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 (?, ?, ?)',
(date, hour, rainfall)
)
total_rainfall += rainfall
# 합계 저장
capture_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
curs.execute(
'INSERT INTO rainfall_summary (date, total_rainfall, capture_time) VALUES (?, ?, ?)',
(date, total_rainfall, capture_time)
)
conn.commit()
conn.close()
logger.info(f"SQLite 저장 완료: {date} 총 강수량 {total_rainfall:.1f}mm")
return True
except Exception as e:
logger.error(f"SQLite 저장 실패: {type(e).__name__}: {e}")
return False
def get_forecast_rainfall(date):
"""
기상청 API를 통한 강수량 예보 조회
@ -96,27 +217,47 @@ def get_forecast_rainfall(date):
'nx': '57',
'ny': '130'
}
,
'data_status': 'ok' | 'generating' | 'unavailable'
}
"""
today = datetime.now().strftime('%Y%m%d')
is_forecast = date_str > today
data_status = 'ok'
response = requests.get(url, params=params, timeout=10)
data = response.json()
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)
if data['response']['header']['resultCode'] != '00':
return [], 0.0
# 데이터가 없을 경우 생성 시도
if not hourly:
logger.warning(f"날씨 캡처 데이터 없음 ({date_str}), 데이터 생성 시도...")
if generate_weather_data(date_str):
# 다시 조회
hourly, total, timestamp = get_captured_rainfall(date_str)
if hourly:
data_status = 'generating'
note = "💡 방금 날씨 데이터를 생성했습니다."
else:
data_status = 'unavailable'
note = "⚠️ 날씨 데이터를 생성했으나 조회할 수 없습니다. 나중에 다시 시도해주세요."
else:
data_status = 'unavailable'
note = "⚠️ 날씨 데이터를 생성할 수 없습니다. 09:00에 자동으로 생성되며, 나중에 다시 시도해주세요."
else:
note = None
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
return {
'date': date_str,
'is_forecast': is_forecast,
'hourly_data': hourly,
'total': total,
'note': note,
'data_status': data_status.0
def get_rainfall_data(date_str):
"""
@ -220,7 +361,7 @@ def webhook():
# 이벤트 적용 여부
if rainfall_info['total'] > 10:
lines.append("✅ 식음료 2만원 이상 결제 시 무료입장권 제공")
lines.append("✅ 레이니데이 이벤트 기준(10mm 초과) 충족")
else:
lines.append("❌ 이벤트 기준(10mm 초과)을 충족하지 않음")
else:

View File

@ -178,7 +178,7 @@ def file_upload(filename, bf_file, msg_sender=None):
# ---------------------------
# 게시글 작성 함수
# ---------------------------
def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_list=None, msg_sender=None):
def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_list=None, msg_sender=None, url=None):
"""
그누보드 게시글 및 첨부파일 등록
@ -191,9 +191,10 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis
ca_name: 카테고리 이름 (선택사항)
file_list: 첨부파일 경로 리스트 (선택사항)
msg_sender: MessageSender 인스턴스
url: 게시판 URL (선택사항)
Returns:
tuple: (성공여부, 에러메시지)
tuple: (성공여부, 에러메시지, wr_id)
"""
conn = None
try:
@ -287,12 +288,28 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"📎 첨부파일: {file_count}"
f"📎 첨부파일: {file_count}\n"
# 캡처파일 정보 및 다운로드 링크 추가 (file_list에 2개 이상의 파일이 있을 경우)
if file_list and len(file_list) > 1 and os.path.isfile(file_list[1]):
capture_filename = os.path.basename(file_list[1])
capture_size = os.path.getsize(file_list[1]) / 1024 # KB 단위
success_msg += f"🖼️ **캡처파일**: `{capture_filename}` ({capture_size:.1f}KB)\n"
# 캡처파일 다운로드 링크 추가 (첫번째 첨부파일의 FTP 경로 기반)
if url and board:
capture_download_url = f"{url.rstrip('/')}/data/file/{board}/{os.path.basename(file_list[1])}"
success_msg += f"📥 **다운로드**: [캡처파일 보기]({capture_download_url})\n"
# 게시글 링크 추가
if url and board:
post_url = f"{url.rstrip('/')}/{board}/{wr_id}"
success_msg += f"🔗 **게시글 링크**: [{subject}]({post_url})"
if msg_sender:
msg_sender.send(success_msg, platforms=['mattermost'])
return True, None
return True, None, wr_id
except Exception as e:
if conn:
@ -314,7 +331,7 @@ def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_lis
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False, error_msg
return False, error_msg, None
finally:
if conn:
@ -335,10 +352,11 @@ def main():
logger.warning("Mattermost 알림이 비활성화됩니다")
try:
# 무료입장 조건에 대해서만 안내함.
MAIN["content"] = """
# .env의 BOARD_CONTENT 사용 (또는 기본값 설정)
if not MAIN.get("content"):
MAIN["content"] = """
<p>Rainy Day 이벤트 적용안내</p>
<p><b>10:00 ~ 22:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.</b></p>
<p><b>10:00 ~ 21:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.</b></p>
<p>레이니데이 이벤트 정보 확인</p>
<p><a href="https://firstgarden.co.kr/news/60">이벤트 정보 보기</a></p>
"""
@ -366,7 +384,7 @@ def main():
file_list = [MAIN['file1'], MAIN['file2']]
# 게시글 작성
success, error = write_board(
wr_id = write_board(
board=MAIN['board'],
subject=MAIN['subject'],
content=MAIN['content'],
@ -374,7 +392,8 @@ def main():
nickname=MAIN['nickname'],
ca_name=MAIN['ca_name'],
file_list=file_list,
msg_sender=msg_sender
msg_sender=msg_sender,
url=MAIN.get('url')
)
if success:

View File

@ -15,7 +15,7 @@ logging.basicConfig(
)
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/%EA%B2%BD%EA%B8%B0%20%ED%8C%8C%EC%A3%BC%EC%8B%9C%20%EC%83%81%EC%A7%80%EC%84%9D%EB%8F%99/SCH/%ED%8D%BC%EC%8A%A4%ED%8A%B8%EA%B0%80%EB%93%A0'
OUTPUT_DIR = '/data'
OUTPUT_FILENAME = f'weather_capture_{TODAY}.png'
DB_PATH = '/data/weather.sqlite'

View File

@ -11,6 +11,8 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] ========================================"
# Flask 웹서버를 백그라운드에서 시작
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Flask 웹서버 시작 (포트 5000)..."
cd /app
export FLASK_APP=api_server.py
export PYTHONUNBUFFERED=1
/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"