3 Commits

21 changed files with 581 additions and 2002 deletions

View File

@ -1,47 +0,0 @@
# =====================================
# 필수 설정 항목 (반드시 작성해야 함)
# =====================================
# 사이트 기본 URL
URL=https://example.com
# 게시판 설정
BOARD_ID=게시판이름
BOARD_CA_NAME=카테고리명
BOARD_CONTENT=글 내용
BOARD_MB_ID=user_id
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.example.com
DB_USER=db_username
DB_PASSWORD=db_password
DB_NAME=database_name
DB_CHARSET=utf8mb4
# FTP 설정
FTP_HOST=ftp.example.com
FTP_USER=ftp_username
FTP_PASSWORD=ftp_password
FTP_UPLOAD_DIR=/data/file/news/
# 날씨 API 서비스 키 (기상청 API)
SERVICE_KEY=your_weather_api_key_here
# =====================================
# 선택적 설정 항목 (없어도 실행 가능)
# =====================================
# Mattermost 알림 설정 (오류 발생 시 알림 받기 원할 때 설정)
# URL 반드시 http:// 또는 https://로 시작해야 함
MATTERMOST_URL=https://mattermost.example.com
MATTERMOST_TOKEN=your-personal-access-token
MATTERMOST_CHANNEL_ID=channel_id
# 웹서버 설정
# DOMAIN: 카카오 챗봇에서 이미지 URL 생성 시 사용
# FLASK_DEBUG: 0(운영), 1(개발)
DOMAIN=https://webhook.example.com
FLASK_DEBUG=0

20
.gitignore vendored
View File

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

554
README.md
View File

@ -1,545 +1,41 @@
## 퍼스트가든용 기상청 API를 활용해 공지사항 자동 등록 시스템 ## 퍼스트가든용 기상청 API를 활용해 공지사항 자동 등록하는 이미지
기상정보에 따른 이벤트 진행에 대한 정확한 기준 부여를 위해 기상청 API를 사용, 영업시간 내 강수정보를 파악하고 해당 공지를 올리기 위한 프로젝트.
기상정보에 따른 이벤트 진행에 대한 정확한 기준 부여를 위해 기상청 API를 사용하여, 영업시간 내 강수정보를 파악하고 자동으로 공지를 올리기 위한 프로젝트. ### weather.py
- 기상청 API를 활용해 데이터 출력
--- ### weather_capture.py
- 기상청 날씨누리 단기예보 페이지를 캡처하여 이미지로 저장
- 클레임 방지를 위해 '최근발표시각'을 표시하여 캡처함
## 📁 폴더 구조 ### gnu_autoupload.py
- 위 파일들의 데이터를 그누보드 게시판에 등록하는 일을 수행
``` ### config.sample.py
- 환경정보 저장(DB정보 등), config.py 로 파일명 변경하여 사용
### 폴더 구조
project-root/ project-root/
├── logs/ # 크론 실행 로그 저장 경로 ├── data/ # sqlite DB, 캡처 이미지 저장 경로 (공용 볼륨)
│ └── cron.log # Crontab 실행 로그
├── data/ # SQLite DB, 캡처 이미지 저장 경로 (공용 볼륨)
│ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블) │ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블)
│ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지 │ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지
├── app/ # gnu-autouploader + API 서버 (통합) ├── autouploader/ # gnu-autouploader 앱 소스
│ ├── 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 알림 발송 │ ├── config.py # 설정값 (DB, FTP, API KEY 등)
── selenium_manager.py # Selenium 브라우저 관리 ── run.sh # 자동 실행용 셸 스크립트 (cron에서 호출)
│ ├── api_server.py # Flask 기반 카카오 챗봇 웹훅 서버 ⭐
│ ├── config.py # 설정값 로드 (DB, FTP, API KEY 등)
│ ├── requirements.txt # Python 의존성
│ └── run.sh # 수동 실행용 셸 스크립트 (개발 시 사용)
├── webhook/ # (더 이상 사용 안 함 - app/api_server.py로 통합) ├── webhook/ # 카카오 챗봇 응답 서버
── webhook.py # (참고용 아카이브) ── webhook.py # Flask 기반 응답 서버
│ └── config.py # 환경 설정 (예: IMAGE_SERVER_URL)
├── build/ ├── build/
│ ├── app/ │ ├── autouploader/
│ │ ── Dockerfile # gnu-autouploader + Flask 통합 이미지 │ │ ── Dockerfile # gnu-autouploader용 Dockerfile
│ │ ├── entrypoint.sh # Flask + Cron 동시 실행 스크립트 ⭐
│ │ └── run.sh # (위의 app/run.sh와 동일)
│ └── webhook/ │ └── webhook/
│ └── Dockerfile # (더 이상 사용 안 함) │ └── Dockerfile # webhook 서버용 Dockerfile
├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정) ├── docker-compose.yml # 전체 서비스 구성 정의
├── docker-compose.yml # Docker Compose 서비스 정의 (gnu-autouploader만)
└── README.md # 프로젝트 문서 └── README.md # 프로젝트 문서
```
---
## 📋 주요 스크립트 설명
### `app/gnu_autoupload.py` (메인)
- **역할**: 날씨 캡처 → FTP 업로드 → 그누보드 DB에 게시글 등록
- **실행 방식**:
- 매일 09:00 Crontab 자동 실행
- `docker exec` 또는 `run.sh`로 수동 실행 가능
- **오류 발생 시**: Mattermost으로 알림 발송
### `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에 저장
### `app/api_server.py` ⭐ (Flask 웹훅 서버)
- **Flask 기반 카카오 챗봇 응답 서버**
- **포트**: 5000 (docker-compose에서 5151로 노출)
- **주요 기능**:
- **당일 조회**: 09:00 캡처된 **실제 강우량 데이터** 응답
- **미래 날짜 조회**: 기상청 API 기반 **예보 강우량** 응답
- 예보 시 "변동될 수 있음" 경고 문구 표시
- 10mm 초과 시 이벤트 적용 안내
- 날씨 캡처 이미지 함께 전송
- **주요 엔드포인트**:
- `POST /webhook` - Kakaotalk 챗봇 웹훅
- `GET /health` - 헬스 체크
- `GET /data/<filename>` - 캡처 이미지 조회
### `app/config.py`
- 환경 변수 로드 (`.env` 또는 컨테이너 환경 변수)
- 필수 변수 부재 시 즉시 오류 출력 후 종료
---
## 🚀 설치 & 실행
### 1. 환경 설정
```bash
# .env 파일 생성
cp .env.example .env
# 필수 정보 입력
vim .env
```
### 필수 환경 변수
```env
# 게시판 정보
BOARD_ID=news
BOARD_CA_NAME=카테고리명
BOARD_CONTENT=글 내용
BOARD_MB_ID=user_id
BOARD_NICKNAME=닉네임
# MySQL 연결 (로컬: localhost, Docker: db 서비스명, Synology: 실제 호스트 IP/도메인)
DB_HOST=localhost
DB_USER=db_user
DB_PASSWORD=db_password
DB_NAME=database_name
# FTP 업로드
FTP_HOST=ftp.example.com
FTP_USER=ftp_user
FTP_PASSWORD=ftp_password
FTP_UPLOAD_DIR=/data/file/news/
# 기상청 API
SERVICE_KEY=your_api_key_here
# Mattermost 알림 (선택사항)
MATTERMOST_URL=https://mattermost.example.com
MATTERMOST_TOKEN=token
MATTERMOST_CHANNEL_ID=channel_id
```
### 2. Docker Compose 실행
```bash
# 빌드 및 실행
docker-compose up -d
# 로그 확인
docker-compose logs -f gnu-autouploader
# 크론 실행 로그 확인
docker exec gnu-autouploader tail -f /logs/cron.log
```
### 3. 수동 실행
```bash
# 컨테이너에서 직접 실행
docker exec -it gnu-autouploader /usr/bin/python /app/gnu_autoupload.py
# 또는 run.sh 사용
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'
);
```
---
## <20> 웹훅 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>💬 카카오 챗봇 응답 예시
### 당일 조회 (실제 데이터)
```
📅 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**에 자동 실행됩니다.
```
0 9 * * * /usr/bin/python /app/gnu_autoupload.py >> /logs/cron.log 2>&1
```
**로그 확인:**
```bash
docker exec gnu-autouploader tail -f /logs/cron.log
```
---
## 🔍 문제 해결
### MySQL 연결 오류
- **오류**: `Can't connect to MySQL server on 'localhost'`
- **원인**: Docker 컨테이너에서 localhost는 컨테이너 자신을 가리킴
- **해결**:
- Docker 환경: `DB_HOST=db` (docker-compose 서비스명)
- Synology: `DB_HOST=192.168.x.x` (호스트 IP)
### Mattermost 알림 실패
- **오류**: `Invalid URL '/api/v4/posts': No scheme supplied`
- **원인**: URL에 `http://` 또는 `https://`가 없음
- **해결**: `MATTERMOST_URL=https://mattermost.example.com` 명시
### 크론탭에서 환경 변수 미로드
- **원인**: 기존 버전에서 crontab이 `.env` 파일 접근 불가
- **해결**: 현재는 `docker-compose.yml`에서 `.env`를 volume mount + env_file로 처리
---
## 📝 설정 변경 후 재배포
```bash
# 컨테이너 재시작
docker-compose restart gnu-autouploader
# 또는 재빌드
docker-compose up -d --build
```
---
## 🛠️ 개발 & 디버깅
### 로컬 테스트 (가상환경)
```bash
# 가상환경 생성
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 의존성 설치
pip install -r app/requirements.txt
# .env 파일 생성 후 직접 실행
python app/gnu_autoupload.py
```
### Docker 내 수동 실행
```bash
# 날씨 캡처 + 강우량 추출
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
```

View File

@ -1,293 +0,0 @@
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("✅ 레이니데이 이벤트 기준(10mm 초과) 충족")
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

@ -1,70 +0,0 @@
import os
import sys
from datetime import datetime
# .env 파일 로드 (python-dotenv 사용)
# Docker 환경: docker-compose.yml에서 env_file로 환경 변수 주입 + volume mount로 .env 파일 접근
# 로컬 개발 환경: python-dotenv로 .env 파일 로드
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
TODAY = datetime.now().strftime('%Y%m%d')
def _get_required_env(key: str, description: str = "") -> str:
"""필수 환경 변수 조회. 없으면 에러 출력 후 종료"""
value = os.getenv(key)
if not value:
desc = f" ({description})" if description else ""
error_msg = f"[ERROR] 필수 환경 변수가 설정되지 않았습니다: {key}{desc}"
print(error_msg)
sys.exit(1)
return value
def _get_optional_env(key: str) -> str:
"""선택적 환경 변수 조회. 없으면 빈 문자열 반환"""
return os.getenv(key, '')
# 게시판 설정 (필수)
MAIN = {
'board': _get_required_env('BOARD_ID', '게시판 ID'),
'ca_name': _get_required_env('BOARD_CA_NAME', '게시판 카테고리'),
'subject': '',
'content': _get_required_env('BOARD_CONTENT', '게시판 기본 내용'),
'mb_id': _get_required_env('BOARD_MB_ID', '게시자 ID'),
'nickname': _get_required_env('BOARD_NICKNAME', '게시자 닉네임'),
'file1': '',
'file2': '',
}
# 데이터베이스 설정 (필수)
DB_CONFIG = {
'HOST': _get_required_env('DB_HOST', 'MySQL 호스트'),
'USER': _get_required_env('DB_USER', 'MySQL 사용자명'),
'DBNAME': _get_required_env('DB_NAME', 'MySQL 데이터베이스명'),
'PASS': _get_required_env('DB_PASSWORD', 'MySQL 비밀번호'),
'CHARSET': _get_optional_env('DB_CHARSET') or 'utf8mb4',
}
# FTP 설정 (필수)
FTP_CONFIG = {
'HOST': _get_required_env('FTP_HOST', 'FTP 호스트'),
'USER': _get_required_env('FTP_USER', 'FTP 사용자명'),
'PASS': _get_required_env('FTP_PASSWORD', 'FTP 비밀번호'),
'UPLOAD_DIR': _get_required_env('FTP_UPLOAD_DIR', 'FTP 업로드 디렉토리'),
}
# 날씨 API 서비스 키 (필수)
serviceKey = _get_required_env('SERVICE_KEY', '기상청 API 서비스 키')
# Mattermost 설정 (선택적 - 없어도 실행 가능하지만 알림은 미발송)
MATTERMOST_CONFIG = {
'URL': _get_optional_env('MATTERMOST_URL'),
'TOKEN': _get_optional_env('MATTERMOST_TOKEN'),
'CHANNEL_ID': _get_optional_env('MATTERMOST_CHANNEL_ID'),
}

View File

@ -1,412 +0,0 @@
# -*- coding: utf-8 -*-
"""
gnu_autoupload.py
기능:
1. Selenium을 이용해 날씨 정보를 캡처 (weather_capture.py 호출)
2. FTP를 이용해 이미지 업로드
3. 그누보드 DB에 게시글 및 첨부파일 정보 자동 등록
4. Mattermost으로 결과 알림 발송
"""
import os
import sys
import time
import subprocess
import tempfile
import hashlib
import logging
from datetime import datetime
from PIL import Image
import pymysql
import ftputil
import traceback
from config import DB_CONFIG, FTP_CONFIG, MAIN, MATTERMOST_CONFIG
from weather import get_precipitation_summary
from send_message import MessageSender
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# ---------------------------
# MessageSender 초기화
# ---------------------------
def init_message_sender():
"""Mattermost 메시지 발송기 초기화"""
try:
mattermost_url = MATTERMOST_CONFIG.get('URL', '')
mattermost_token = MATTERMOST_CONFIG.get('TOKEN', '')
mattermost_channel_id = MATTERMOST_CONFIG.get('CHANNEL_ID', '')
# 설정 확인 로그
logger.info(f"Mattermost 설정: URL={'설정됨' if mattermost_url else '미설정'}, "
f"TOKEN={'설정됨' if mattermost_token else '미설정'}, "
f"CHANNEL_ID={'설정됨' if mattermost_channel_id else '미설정'}")
if not mattermost_url:
logger.warning("Mattermost URL이 설정되지 않았습니다")
return None
msg_sender = MessageSender(
mattermost_url=mattermost_url,
mattermost_token=mattermost_token,
mattermost_channel_id=mattermost_channel_id
)
return msg_sender
except Exception as e:
logger.warning(f"MessageSender 초기화 실패: {e}")
return None
# ---------------------------
# 이미지 캡처 함수
# ---------------------------
def capture_image(script_path, output_path, max_attempts=5, msg_sender=None):
"""
이미지 캡처 시도
Args:
script_path: weather_capture.py 경로
output_path: 캡처 이미지 저장 경로
max_attempts: 최대 시도 횟수
msg_sender: MessageSender 인스턴스
Returns:
tuple: (성공여부, 에러메시지)
"""
for attempt in range(max_attempts):
logger.info(f"이미지 캡처 시도 {attempt + 1}/{max_attempts}")
try:
result = subprocess.run(
[sys.executable, script_path],
check=True,
capture_output=True,
text=True,
timeout=60
)
if os.path.isfile(output_path):
logger.info(f"이미지 캡처 성공: {output_path}")
return True, None
except subprocess.TimeoutExpired:
logger.warning(f"캡처 타임아웃 (시도 {attempt + 1}/{max_attempts})")
except subprocess.CalledProcessError as e:
logger.error(f"weather_capture.py 실행 실패: {e.stderr}")
except Exception as e:
logger.error(f"예상치 못한 오류: {type(e).__name__}: {e}")
time.sleep(2)
# 모든 시도 실패
final_error = f"❌ **날씨 이미지 캡처 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"🔄 시도 횟수: {max_attempts}\n"\
f"📁 출력 경로: `{output_path}`\n"\
f"⚠️ 파일이 생성되지 않았습니다."
logger.error(final_error.replace('\n', ' '))
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(final_error, platforms=['mattermost'])
return False, final_error
# ---------------------------
# 파일 관련 유틸 함수
# ---------------------------
def file_type(ext):
"""확장자에 따른 파일 타입 코드 반환"""
return {
'gif': '1', 'jpeg': '2', 'jpg': '2', 'png': '3', 'swf': '4',
'psd': '5', 'bmp': '6', 'tif': '7', 'tiff': '7', 'jpc': '9',
'jp2': '10', 'jpx': '11', 'jb2': '12', 'swc': '13', 'iff': '14',
'wbmp': '15', 'xbm': '16'
}.get(ext.lower(), '0')
def get_filename(filename):
"""타임스탬프와 해시를 포함한 파일명 생성"""
ms = datetime.now().microsecond
encoded_name = filename.encode('utf-8')
return f'{ms}_{hashlib.sha1(encoded_name).hexdigest()}'
def file_upload(filename, bf_file, msg_sender=None):
"""
FTP 파일 업로드
Args:
filename: 원본 파일 경로
bf_file: FTP에 저장될 파일명
msg_sender: MessageSender 인스턴스
Returns:
bool: 성공 여부
"""
try:
with ftputil.FTPHost(FTP_CONFIG['HOST'], FTP_CONFIG['USER'], FTP_CONFIG['PASS']) as fh:
fh.chdir(FTP_CONFIG['UPLOAD_DIR'])
fh.upload(filename, bf_file)
logger.info(f"FTP 업로드 완료: {filename}{bf_file}")
return True
except Exception as e:
error_msg = f"❌ **FTP 파일 업로드 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📁 파일: `{filename}`\n"\
f"🗂️ 대상: `{bf_file}`\n"\
f"⚠️ 오류: `{type(e).__name__}: {e}`"
logger.error(error_msg.replace('\n', ' '))
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False
# ---------------------------
# 게시글 작성 함수
# ---------------------------
def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_list=None, msg_sender=None, url=None):
"""
그누보드 게시글 및 첨부파일 등록
Args:
board: 게시판 ID
subject: 제목
content: 내용
mb_id: 게시자 ID
nickname: 닉네임
ca_name: 카테고리 이름 (선택사항)
file_list: 첨부파일 경로 리스트 (선택사항)
msg_sender: MessageSender 인스턴스
url: 게시판 URL (선택사항)
Returns:
tuple: (성공여부, 에러메시지, wr_id)
"""
conn = None
try:
# DB 연결 정보 디버깅 로그
logger.info(f"DB 연결 시도: HOST={DB_CONFIG['HOST']}, USER={DB_CONFIG['USER']}, DB={DB_CONFIG['DBNAME']}")
conn = pymysql.connect(
host=DB_CONFIG['HOST'],
user=DB_CONFIG['USER'],
db=DB_CONFIG['DBNAME'],
password=DB_CONFIG['PASS'],
charset=DB_CONFIG['CHARSET']
)
curs = conn.cursor()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 게시글 번호 조회
curs.execute(f"SELECT wr_num FROM g5_write_{board}")
wr_num = str(int(curs.fetchone()[0]) - 1)
# 게시글 삽입
curs.execute(f"""INSERT INTO g5_write_{board} SET wr_num = {wr_num},
wr_reply = '', wr_comment = 0, ca_name = %s, wr_option = 'html1', wr_subject = %s,
wr_content = %s, wr_link1 = '', wr_link2 = '', wr_link1_hit = 0, wr_link2_hit = 0,
wr_hit = 1, wr_good = 0, wr_nogood = 0, mb_id = %s, wr_password = '',
wr_name = %s, wr_email = '', wr_homepage = '',
wr_datetime = %s, wr_last = %s,
wr_ip = '111.111.111.111',
wr_comment_reply = '', wr_facebook_user = '', wr_twitter_user = '',
wr_1 = '', wr_2 = '', wr_3 = '', wr_4 = '', wr_5 = '', wr_6 = '', wr_7 = '', wr_8 = '', wr_9 = '', wr_10 = ''
""",
(ca_name, subject, content, mb_id, nickname, now, now))
# 게시글 ID 조회
curs.execute(f"SELECT wr_id FROM g5_write_{board} ORDER BY wr_id DESC LIMIT 1")
wr_id = str(curs.fetchone()[0])
# 부모 글 ID 업데이트
curs.execute(f"UPDATE g5_write_{board} SET wr_parent = {wr_id} WHERE wr_id = {wr_id}")
# 새 게시글 알림 추가
curs.execute(f"""INSERT INTO g5_board_new (bo_table, wr_id, wr_parent, bn_datetime, mb_id)
VALUES (%s, %s, %s, %s, %s)""", (board, wr_id, wr_id, now, mb_id))
# 게시판 글 수 업데이트
curs.execute(f"SELECT bo_count_write FROM g5_board WHERE bo_table = %s", (board,))
bo_count_write = int(curs.fetchone()[0])
curs.execute(f"UPDATE g5_board SET bo_count_write = %s WHERE bo_table = %s",
(bo_count_write + 1, board))
# 첨부파일 처리
file_count = 0
if file_list:
for idx, file in enumerate(file_list):
if not os.path.isfile(file):
logger.warning(f"파일 없음: {file}")
continue
ext = os.path.splitext(file)[1].lstrip('.')
bf_file = f"{get_filename(file)}.{ext}"
if file_upload(file, bf_file, msg_sender):
img_type = file_type(ext)
width, height = (0, 0)
if img_type != '0':
try:
with Image.open(file) as img:
width, height = img.size
except Exception as e:
logger.warning(f"이미지 크기 조회 실패: {e}")
size = os.path.getsize(file)
curs.execute(f"""INSERT INTO g5_board_file SET bo_table = %s, wr_id = %s, bf_no = %s,
bf_source = %s, bf_file = %s, bf_content = '', bf_download = 0,
bf_filesize = %s, bf_width = %s, bf_height = %s, bf_type = %s, bf_datetime = %s""",
(board, wr_id, idx, os.path.basename(file), bf_file,
size, width, height, img_type, now))
file_count += 1
else:
raise Exception(f"파일 업로드 실패: {file}")
# 게시글 파일 수 업데이트
curs.execute(f"UPDATE g5_write_{board} SET wr_file = %s WHERE wr_id = %s", (file_count, wr_id))
conn.commit()
logger.info("게시글 및 첨부파일 등록 완료")
# 성공 메시지 전송
success_msg = f"✅ **게시글 등록 완료**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"📎 첨부파일: {file_count}"
# 캡처파일 정보 추가 (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"\n🖼️ 캡처파일: `{capture_filename}` ({capture_size:.1f}KB)"
# 게시글 링크 추가
if url and board:
post_url = f"{url.rstrip('/')}/{board}/{wr_id}"
success_msg += f"\n🔗 게시글 링크: {post_url}"
if msg_sender:
msg_sender.send(success_msg, platforms=['mattermost'])
return True, None, wr_id
except Exception as e:
if conn:
conn.rollback()
# 에러 메시지 생성
error_detail = traceback.format_exc()
error_msg = f"❌ **게시글 등록 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"⚠️ 오류 유형: `{type(e).__name__}`\n"\
f"💬 오류 메시지: {str(e)}\n"\
f"```\n{error_detail}\n```"
logger.error(f"게시글 등록 실패: {type(e).__name__}: {e}")
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False, error_msg, None
finally:
if conn:
conn.close()
# ---------------------------
# 메인 실행 함수
# ---------------------------
def main():
"""메인 실행 함수"""
logger.info("=== 날씨 정보 게시글 자동 생성 시작 ===")
# MessageSender 초기화
msg_sender = init_message_sender()
if not msg_sender:
logger.warning("Mattermost 알림이 비활성화됩니다")
try:
# .env의 BOARD_CONTENT 사용 (또는 기본값 설정)
if not MAIN.get("content"):
MAIN["content"] = """
<p>Rainy Day 이벤트 적용안내</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>
"""
today = datetime.today().strftime('%Y%m%d')
data_dir = '/data' # 파일 저장 및 업로드 디렉토리
capture_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'weather_capture.py')
weather_filename = f'weather_capture_{today}.png'
weather_file = os.path.join(data_dir, weather_filename)
thumb_file = os.path.join(data_dir, 'thumb.jpg')
# 이미지 캡처
success, error = capture_image(capture_script, weather_file, msg_sender=msg_sender)
if not success:
logger.error("이미지 캡처 실패로 게시글 작성 중단")
return
# FTP 업로드 디렉토리 설정
FTP_CONFIG['UPLOAD_DIR'] = f"/www/data/file/{MAIN['board']}/"
MAIN['subject'] = f"{datetime.now().strftime('%Y-%m-%d')} 날씨정보"
MAIN['file1'] = thumb_file
MAIN['file2'] = weather_file
file_list = [MAIN['file1'], MAIN['file2']]
# 게시글 작성, wr_id = write_board(
board=MAIN['board'],
subject=MAIN['subject'],
content=MAIN['content'],
mb_id=MAIN['mb_id'],
nickname=MAIN['nickname'],
ca_name=MAIN['ca_name'],
file_list=file_list,
msg_sender=msg_sender,
url=MAIN.get('url')
msg_sender=msg_sender
)
if success:
logger.info("=== 날씨 정보 게시글 자동 생성 완료 ===")
else:
logger.error("게시글 작성 실패")
except Exception as e:
error_msg = f"❌ **예상치 못한 오류 발생**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"⚠️ 오류: `{type(e).__name__}: {e}`\n"\
f"```\n{traceback.format_exc()}\n```"
logger.error(error_msg.replace('\n', ' '))
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
if __name__ == "__main__":
main()

View File

@ -1,30 +0,0 @@
# Standard library modules (자동 포함, 설치 불필요)
# os
# sys
# time
# subprocess
# tempfile
# hashlib
# datetime
# External packages (pip install 필요)
# 이미지 처리
Pillow
# 데이터베이스
PyMySQL
# FTP
ftputil
# HTTP 요청
requests
# 웹 자동화
selenium
# 환경 변수
python-dotenv
# 웹 프레임워크
flask

View File

@ -1,178 +0,0 @@
import logging
import os
import shutil
import tempfile
from contextlib import contextmanager
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
# 로깅 설정
logger = logging.getLogger(__name__)
class SeleniumManager:
"""Selenium WebDriver 관리 클래스"""
WEATHER_SELECTORS = {
'tab_button': (By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/div[1]/div/div/a[2]'),
'list_button': (By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/ul/div[1]/a[2]'),
'target_element': (By.XPATH, '/html/body/div[1]/main/div[2]/div[1]'),
}
def __init__(self, headless=True, window_size=(1802, 1467), timeout=10):
"""
Args:
headless: 헤드리스 모드 여부
window_size: 브라우저 윈도우 크기
timeout: WebDriverWait 기본 타임아웃 (초)
"""
self.headless = headless
self.window_size = window_size
self.timeout = timeout
self.temp_dir = None
self.driver = None
def _setup_chrome_options(self):
"""Chrome 옵션 설정"""
options = Options()
if self.headless:
options.add_argument('--headless')
options.add_argument(f'--window-size={self.window_size[0]},{self.window_size[1]}')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-gpu')
# 임시 사용자 데이터 디렉토리 생성 (중복 실행 문제 방지)
self.temp_dir = tempfile.mkdtemp()
options.add_argument(f'--user-data-dir={self.temp_dir}')
return options
def start(self):
"""WebDriver 시작"""
try:
options = self._setup_chrome_options()
self.driver = webdriver.Chrome(options=options)
logger.info("Selenium WebDriver 시작됨")
except Exception as e:
logger.error(f"WebDriver 시작 실패: {e}")
self.cleanup()
raise
def cleanup(self):
"""WebDriver 종료 및 임시 파일 정리"""
if self.driver:
try:
self.driver.quit()
logger.info("WebDriver 종료됨")
except Exception as e:
logger.warning(f"WebDriver 종료 중 오류: {e}")
if self.temp_dir and os.path.exists(self.temp_dir):
try:
shutil.rmtree(self.temp_dir)
logger.info(f"임시 디렉토리 삭제됨: {self.temp_dir}")
except Exception as e:
logger.warning(f"임시 디렉토리 삭제 실패: {e}")
@contextmanager
def managed_driver(self):
"""Context manager를 통한 자동 정리"""
try:
self.start()
yield self.driver
finally:
self.cleanup()
def wait(self):
"""WebDriverWait 인스턴스 반환"""
return WebDriverWait(self.driver, self.timeout)
def click_with_retry(self, selector, max_retries=5, sleep_time=1):
"""
재시도 로직을 포함한 요소 클릭
Args:
selector: (By, xpath) 튜플
max_retries: 최대 재시도 횟수
sleep_time: 재시도 사이의 대기 시간 (초)
Returns:
성공 여부
"""
for attempt in range(max_retries):
try:
wait = self.wait()
element = wait.until(EC.presence_of_element_located(selector))
wait.until(EC.element_to_be_clickable(selector))
element.click()
logger.info(f"요소 클릭 성공: {selector}")
return True
except StaleElementReferenceException:
logger.warning(f"시도 {attempt + 1}: StaleElementReferenceException 발생, 재시도 중...")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
except TimeoutException:
logger.warning(f"시도 {attempt + 1}: TimeoutException 발생, 재시도 중...")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
except Exception as e:
logger.error(f"시도 {attempt + 1}: 예상치 못한 오류 {type(e).__name__}: {e}")
if attempt < max_retries - 1:
import time
time.sleep(sleep_time)
logger.error(f"최대 재시도 횟수 초과: {selector}")
return False
def get_element(self, selector):
"""
요소 선택 및 반환
Args:
selector: (By, xpath) 튜플
Returns:
WebElement 또는 None
"""
try:
wait = self.wait()
element = wait.until(EC.presence_of_element_located(selector))
logger.info(f"요소 획득 성공: {selector}")
return element
except TimeoutException:
logger.error(f"요소 대기 시간 초과: {selector}")
return None
except Exception as e:
logger.error(f"요소 획득 실패 {type(e).__name__}: {e}")
return None
def take_element_screenshot(self, selector, output_path):
"""
요소 스크린샷 저장
Args:
selector: (By, xpath) 튜플
output_path: 저장 경로
Returns:
성공 여부
"""
try:
element = self.get_element(selector)
if element is None:
return False
element.screenshot(output_path)
logger.info(f"스크린샷 저장 성공: {output_path}")
return True
except Exception as e:
logger.error(f"스크린샷 저장 실패 {type(e).__name__}: {e}")
return False

View File

@ -1,119 +0,0 @@
import requests
class MessageSender:
def __init__(self,
mattermost_url: str = "", mattermost_token: str = "", mattermost_channel_id: str = "",
synology_webhook_url: str = "",
telegram_bot_token: str = "", telegram_chat_id: str = ""):
self.mattermost_url = mattermost_url.rstrip('/')
self.mattermost_token = mattermost_token
self.mattermost_channel_id = mattermost_channel_id
self.synology_webhook_url = synology_webhook_url
self.telegram_bot_token = telegram_bot_token
self.telegram_chat_id = telegram_chat_id
def send(self, message: str, platforms=None, use_webhook: bool = False):
"""
메시지 전송
:param message: 전송할 메시지
:param platforms: 전송 플랫폼 리스트 (예: ['mattermost', 'telegram'])
:param use_webhook: mattermost에서 웹훅 사용 여부 (mattermost 전용)
"""
if not platforms:
print("[WARN] 전송할 플랫폼이 지정되지 않았습니다. 메시지를 보내지 않습니다.")
return False
if isinstance(platforms, str):
platforms = [platforms]
success = True
for platform in platforms:
p = platform.lower()
if p == "mattermost":
result = self._send_to_mattermost(message, use_webhook)
elif p == "synology":
result = self._send_to_synology_chat(message)
elif p == "telegram":
result = self._send_to_telegram(message)
else:
print(f"[ERROR] 지원하지 않는 플랫폼입니다: {p}")
result = False
if not result:
success = False
return success
def _send_to_mattermost(self, message: str, use_webhook: bool):
try:
# Mattermost URL 검증
if not self.mattermost_url or not self.mattermost_url.startswith(('http://', 'https://')):
print(f"[ERROR] Mattermost URL이 유효하지 않습니다: {self.mattermost_url}")
return False
if use_webhook:
response = requests.post(
self.mattermost_url,
json={"text": message},
headers={"Content-Type": "application/json"}
)
else:
url = f"{self.mattermost_url}/api/v4/posts"
headers = {
"Authorization": f"Bearer {self.mattermost_token}",
"Content-Type": "application/json"
}
payload = {
"channel_id": self.mattermost_channel_id,
"message": message
}
response = requests.post(url, json=payload, headers=headers)
if response.status_code not in [200, 201]:
print(f"[ERROR] Mattermost 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Mattermost 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Mattermost 전송 예외: {e}")
return False
def _send_to_synology_chat(self, message: str):
try:
payload = {"text": message}
headers = {"Content-Type": "application/json"}
response = requests.post(self.synology_webhook_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"[ERROR] Synology Chat 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Synology Chat 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Synology Chat 전송 예외: {e}")
return False
def _send_to_telegram(self, message: str):
try:
url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
payload = {
"chat_id": self.telegram_chat_id,
"text": message,
"parse_mode": "Markdown"
}
response = requests.post(url, data=payload)
if response.status_code != 200:
print(f"[ERROR] Telegram 전송 실패: {response.status_code} {response.text}")
return False
else:
print("[INFO] Telegram 메시지 전송 완료")
return True
except Exception as e:
print(f"[ERROR] Telegram 전송 예외: {e}")
return False

View File

@ -1,257 +0,0 @@
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
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
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'
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)
# 저장 디렉토리 생성 (없으면)
os.makedirs(OUTPUT_DIR, exist_ok=True)
manager = SeleniumManager()
try:
with manager.managed_driver():
logger.info(f"URL 접속: {WEATHER_URL}")
manager.driver.get(WEATHER_URL)
# 첫 번째 탭 클릭
logger.info("첫 번째 탭 클릭 시도...")
if not manager.click_with_retry(manager.WEATHER_SELECTORS['tab_button']):
logger.error("첫 번째 탭 클릭 실패")
return False
# 두 번째 항목 클릭
logger.info("두 번째 항목 클릭 시도...")
if not manager.click_with_retry(manager.WEATHER_SELECTORS['list_button']):
logger.error("두 번째 항목 클릭 실패")
return False
# 페이지 반영 대기
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):
logger.info(f"[캡처 완료] 저장 위치: {output_path}")
return True
else:
logger.error("스크린샷 저장 실패")
return False
except Exception as e:
logger.error(f"[오류] 날씨 캡처 중 오류 발생: {type(e).__name__}: {e}", exc_info=True)
return False
if __name__ == '__main__':
success = capture_weather()
sys.exit(0 if success else 1)

View File

@ -0,0 +1,33 @@
from datetime import datetime
TODAY = datetime.now().strftime('%Y%m%d')
FTP_CONFIG = {
'HOST' : 'FTP_HOST',
'USER' : 'FTP_USER',
'PASS' : 'FTP_PW',
'UPLOAD_DIR' : 'UPLOAD_DIR'
}
DB_CONFIG = {
'HOST' : 'HOST',
'USER' : 'DBUSER',
'DBNAME' : 'DBNAME',
'PASS' : 'DB_PW'
'CHARSET'='utf8mb4',
}
###
#subject, content, file1, file2는 스크립트 진행중 설정함
###
MAIN = {
'board' : '게시판 ID',
'ca_name' : '카테고리가 있는경우 카테고리 이름, 없으면 비움',
'subject' : '',
'content' : '',
'mb_id' : '게시자 ID',
'nickname' : '닉네임',
'file1' : '',
'file2' : '',
}
serviceKey = 'serviceKey'

View File

@ -0,0 +1,277 @@
# -*- coding: utf-8 -*-
"""
gnu_autoupload.py
기능:
1. Selenium을 이용해 날씨 정보를 캡처 (weather_capture.py 호출)
2. FTP를 이용해 이미지 업로드
3. 그누보드 DB에 게시글 및 첨부파일 정보 자동 등록
"""
import os
import sys
import time
import subprocess
import tempfile
import hashlib
from datetime import datetime
from PIL import Image
import pymysql
import ftputil
from config import DB_CONFIG, FTP_CONFIG, MAIN
from weather import get_precipitation_summary
from send_message import MessageSender # MessageSender 클래스 임포트
# ---------------------------
# 이미지 캡처 함수
# ---------------------------
def capture_image(script_path, output_path, max_attempts=5, msg_sender=None):
"""
이미지 캡처 시도
Returns:
tuple: (성공여부, 에러메시지)
"""
for attempt in range(max_attempts):
print(f"[{datetime.now().strftime('%H:%M:%S')}] 이미지 캡처 시도 {attempt + 1}/{max_attempts}")
try:
result = subprocess.run(
[sys.executable, script_path],
check=True,
capture_output=True,
text=True,
timeout=60 # 60초 타임아웃 설정
)
if os.path.isfile(output_path):
print(f"[성공] 이미지가 정상적으로 캡처되었습니다: {output_path}")
return True, None
except subprocess.TimeoutExpired:
error_msg = f"캡처 스크립트 실행 타임아웃 (시도 {attempt + 1}/{max_attempts})"
print(f"[오류] {error_msg}")
except subprocess.CalledProcessError as e:
error_msg = f"weather_capture.py 실행 실패:\n{e.stderr if e.stderr else str(e)}"
print(f"[오류] {error_msg}")
except Exception as e:
error_msg = f"예상치 못한 오류: {type(e).__name__}: {e}"
print(f"[오류] {error_msg}")
time.sleep(2)
# 모든 시도 실패
final_error = f"❌ **날씨 이미지 캡처 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"🔄 시도 횟수: {max_attempts}\n"\
f"📁 출력 경로: `{output_path}`\n"\
f"⚠️ 파일이 생성되지 않았습니다."
print(f"[실패] {max_attempts}회 시도 후에도 이미지가 생성되지 않았습니다.")
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(final_error, platforms=['mattermost'])
return False, final_error
# ---------------------------
# 파일 관련 유틸 함수
# ---------------------------
def file_type(ext):
return {
'gif': '1', 'jpeg': '2', 'jpg': '2', 'png': '3', 'swf': '4',
'psd': '5', 'bmp': '6', 'tif': '7', 'tiff': '7', 'jpc': '9',
'jp2': '10', 'jpx': '11', 'jb2': '12', 'swc': '13', 'iff': '14',
'wbmp': '15', 'xbm': '16'
}.get(ext.lower(), '0')
def get_filename(filename):
ms = datetime.now().microsecond
encoded_name = filename.encode('utf-8')
return f'{ms}_{hashlib.sha1(encoded_name).hexdigest()}'
def file_upload(filename, bf_file):
try:
with ftputil.FTPHost(FTP_CONFIG['HOST'], FTP_CONFIG['USER'], FTP_CONFIG['PASS']) as fh:
fh.chdir(FTP_CONFIG['UPLOAD_DIR'])
fh.upload(filename, bf_file)
print(f"[업로드 완료] '{filename}''{bf_file}' 로 FTP 업로드 완료")
return True
except Exception as e:
print(f"[FTP 오류] {type(e).__name__}: {e}")
return False
# ---------------------------
# 게시글 작성 함수
# ---------------------------
def write_board(board, subject, content, mb_id, nickname, ca_name=None, file_list=None, msg_sender=None):
"""
게시글 작성
Returns:
tuple: (성공여부, 에러메시지)
"""
conn = None
try:
conn = pymysql.connect(
host=DB_CONFIG['HOST'],
user=DB_CONFIG['USER'],
db=DB_CONFIG['DBNAME'],
password=DB_CONFIG['PASS'],
charset=DB_CONFIG['CHARSET']
)
curs = conn.cursor()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
curs.execute(f"SELECT wr_num FROM g5_write_{board}")
wr_num = str(int(curs.fetchone()[0]) - 1)
curs.execute(f"""INSERT INTO g5_write_{board} SET wr_num = {wr_num},
wr_reply = '', wr_comment = 0, ca_name = %s, wr_option = 'html1', wr_subject = %s,
wr_content = %s, wr_link1 = '', wr_link2 = '', wr_link1_hit = 0, wr_link2_hit = 0,
wr_hit = 1, wr_good = 0, wr_nogood = 0, mb_id = %s, wr_password = '',
wr_name = %s, wr_email = '', wr_homepage = '',
wr_datetime = %s, wr_last = %s,
wr_ip = '111.111.111.111',
wr_comment_reply = '', wr_facebook_user = '', wr_twitter_user = '',
wr_1 = '', wr_2 = '', wr_3 = '', wr_4 = '', wr_5 = '', wr_6 = '', wr_7 = '', wr_8 = '', wr_9 = '', wr_10 = ''
""",
(ca_name, subject, content, mb_id, nickname, now, now))
curs.execute(f"SELECT wr_id FROM g5_write_{board} ORDER BY wr_id DESC LIMIT 1")
wr_id = str(curs.fetchone()[0])
curs.execute(f"UPDATE g5_write_{board} SET wr_parent = {wr_id} WHERE wr_id = {wr_id}")
curs.execute(f"""INSERT INTO g5_board_new (bo_table, wr_id, wr_parent, bn_datetime, mb_id)
VALUES (%s, %s, %s, %s, %s)""", (board, wr_id, wr_id, now, mb_id))
curs.execute(f"SELECT bo_count_write FROM g5_board WHERE bo_table = %s", (board,))
bo_count_write = int(curs.fetchone()[0])
curs.execute(f"UPDATE g5_board SET bo_count_write = %s WHERE bo_table = %s",
(bo_count_write + 1, board))
file_count = 0
if file_list:
for idx, file in enumerate(file_list):
ext = os.path.splitext(file)[1].lstrip('.')
bf_file = f"{get_filename(file)}.{ext}"
if file_upload(file, bf_file):
img_type = file_type(ext)
width, height = (0, 0)
if img_type != '0':
with Image.open(file) as img:
width, height = img.size
size = os.path.getsize(file)
curs.execute(f"""INSERT INTO g5_board_file SET bo_table = %s, wr_id = %s, bf_no = %s,
bf_source = %s, bf_file = %s, bf_content = '', bf_download = 0,
bf_filesize = %s, bf_width = %s, bf_height = %s, bf_type = %s, bf_datetime = %s""",
(board, wr_id, idx, os.path.basename(file), bf_file,
size, width, height, img_type, now))
file_count += 1
else:
raise Exception(f"파일 업로드 실패: {file}")
curs.execute(f"UPDATE g5_write_{board} SET wr_file = %s WHERE wr_id = %s", (file_count, wr_id))
conn.commit()
print("[성공] 게시글과 첨부파일 등록 완료")
return True, None
except Exception as e:
if conn:
conn.rollback()
# 에러 메시지 생성
error_detail = traceback.format_exc()
error_msg = f"❌ **게시글 등록 실패**\n\n"\
f"📅 날짜: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"\
f"📋 게시판: `{board}`\n"\
f"📝 제목: {subject}\n"\
f"⚠️ 오류 유형: `{type(e).__name__}`\n"\
f"💬 오류 메시지: {str(e)}\n"\
f"```\n{error_detail}\n```"
if "FTP" in str(e) or "파일 업로드" in str(e):
print(f"[FTP 오류] {e}")
else:
print(f"[DB 오류] {type(e).__name__}: {e}")
# Mattermost 알림 전송
if msg_sender:
msg_sender.send(error_msg, platforms=['mattermost'])
return False, error_msg
finally:
if conn:
conn.close()
# ---------------------------
# 메인 실행 함수
# ---------------------------
def main():
# 기상청 API로 얻어오는 데이터와 캡처의 데이터가 다르므로 내용에 대해 업데이트 하지 않음.
# 날씨 정보 문자열 얻기
#weather_content = get_precipitation_summary()
# MAIN['content'] 업데이트
#MAIN['content'] = weather_content
# 무료입장 조건에 대해서만 안내함.
MAIN["content"] = """
<p>Rainy Day 이벤트 적용안내</p>
<p><b>10:00 ~ 22:00까지의 예보를 합산하며, ~1mm인 경우 0.5mm로 계산합니다.</b></p>
<p>레이니데이 이벤트 정보 확인</p>
<p><a href="https://firstgarden.co.kr/news/60">이벤트 정보 보기</a></p>
"""
today = datetime.today().strftime('%Y%m%d')
data_dir = '/data' # 파일 저장 및 업로드 디렉토리
capture_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'weather_capture.py')
weather_filename = f'weather_capture_{today}.png'
weather_file = os.path.join(data_dir, weather_filename)
thumb_file = os.path.join(data_dir, 'thumb.jpg')
if not capture_image(capture_script, weather_file):
return
FTP_CONFIG['UPLOAD_DIR'] = f"/www/data/file/{MAIN['board']}/"
MAIN['subject'] = f"{datetime.now().strftime('%Y-%m-%d')} 날씨정보"
MAIN['file1'] = thumb_file
MAIN['file2'] = weather_file
file_list = [MAIN['file1'], MAIN['file2']]
write_board(
board=MAIN['board'],
subject=MAIN['subject'],
content=MAIN['content'],
mb_id=MAIN['mb_id'],
nickname=MAIN['nickname'],
ca_name=MAIN['ca_name'],
file_list=file_list
)
try:
## weather_file만 삭제, thumb.jpg는 삭제하지 않음
#if os.path.isfile(weather_file):
# os.remove(weather_file)
# print(f"[정리 완료] 캡처 이미지 삭제됨: {weather_file}")
pass
except Exception as e:
print(f"[삭제 오류] {type(e).__name__}: {e}")
if __name__ == "__main__":
main()

3
autouploader/run.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "run.sh 실행됨: $(date '+%Y-%m-%d %H:%M:%S')"
python3 /data/gnu_autoupload.py >> /proc/1/fd/1 2>&1

View File

@ -0,0 +1,80 @@
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from datetime import datetime
import os
import time
import tempfile
from config import TODAY
# 크롬 옵션 설정
options = Options()
options.add_argument('--headless')
options.add_argument('--window-size=1802,1467')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-gpu')
# 임시 사용자 데이터 디렉토리 생성 및 지정 (중복 실행 문제 방지)
temp_dir = tempfile.mkdtemp()
options.add_argument(f'--user-data-dir={temp_dir}')
driver = webdriver.Chrome(options=options)
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
driver.get('https://www.weather.go.kr/w/weather/forecast/short-term.do#dong/4148026200/37.73208578534846/126.79463099866948')
wait = WebDriverWait(driver, 10)
# 첫 번째 탭 클릭 (안전하게 클릭 대기)
tab_button = wait.until(EC.element_to_be_clickable(
(By.XPATH, '//*[@id="digital-forecast"]/div[1]/div[3]/div[1]/div/div/a[2]')
))
tab_button.click()
# 두 번째 항목 클릭 - stale element 대비 최대 5회 재시도
list_button_xpath = '//*[@id="digital-forecast"]/div[1]/div[3]/ul/div[1]/a[2]'
for attempt in range(5):
try:
list_button = wait.until(EC.presence_of_element_located((By.XPATH, list_button_xpath)))
wait.until(EC.element_to_be_clickable((By.XPATH, list_button_xpath)))
list_button.click()
break
except StaleElementReferenceException:
print(f"시도 {attempt+1}: stale element 참조 오류 발생, 재시도 중...")
time.sleep(1)
except TimeoutException:
print(f"시도 {attempt+1}: 요소 대기 시간 초과, 재시도 중...")
time.sleep(1)
else:
print("두 번째 항목 클릭 실패. 스크립트 종료.")
driver.quit()
exit(1)
time.sleep(2) # 페이지 반영 대기
# 캡처 대상 요소 대기 후 찾기
target_element = wait.until(EC.presence_of_element_located(
(By.XPATH, '/html/body/div[1]/main/div[2]/div[1]')
))
# 저장 경로 설정
# 기존
# save_path = os.path.join(script_dir, f'weather_capture_{TODAY}.png')
# 수정
save_path = os.path.join('/data', f'weather_capture_{TODAY}.png')
# 요소 스크린샷 저장
target_element.screenshot(save_path)
print(f'[캡처 완료] 저장 위치: {save_path}')
finally:
driver.quit()

View File

@ -1,23 +0,0 @@
#!/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

@ -20,35 +20,23 @@ RUN apt-get update && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Python 패키지 설치 # Python 패키지 설치
RUN pip install --no-cache-dir --upgrade pip && \ RUN pip install --no-cache-dir \
pip install --no-cache-dir \
selenium>=4.10 \ selenium>=4.10 \
pymysql \ pymysql \
ftputil \ ftputil \
pillow \ pillow \
pyvirtualdisplay \ pyvirtualdisplay \
requests \ requests
python-dotenv \
flask
WORKDIR /app WORKDIR /app
RUN fc-cache -f -v RUN fc-cache -f -v
COPY app/ /app/ COPY run.sh /app/run.sh
RUN chmod +x /app/run.sh
# 로그 디렉토리 생성 # crontab 등록
RUN mkdir -p /logs && chmod 777 /logs RUN echo "0 9 * * * /app/run.sh >> /proc/1/fd/1 2>&1" | crontab -
# Entrypoint 스크립트를 사용하여 Flask + Cron 동시 실행 CMD ["/bin/bash", "-c", "cron -f"]
COPY build/app/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 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"]

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "run.sh 실행됨: $(date '+%Y-%m-%d %H:%M:%S')"
python3 /data/gnu_autoupload.py >> /proc/1/fd/1 2>&1

30
build/webhook/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# 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

@ -1,16 +1,29 @@
services: services:
gnu-autouploader: gnu-autouploader:
build: build:
context: . context: ./build/autouploader
dockerfile: ./build/app/Dockerfile dockerfile: Dockerfile
image: reg.firstgarden.co.kr/gnu-autouploader:latest image: reg.firstgarden.co.kr/gnu-autouploader:latest
container_name: gnu-autouploader container_name: gnu-autouploader
volumes: volumes:
- ./data:/data - ./data:/data
- ./logs:/logs - ./autouploader:/app
- ./.env:/app/.env:ro restart: unless-stopped
ports:
- "5151:5000" fg-webhook:
env_file: build:
- .env 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 restart: unless-stopped

97
webhook/webhook.py Normal file
View File

@ -0,0 +1,97 @@
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)