Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f61eb5e23 | |||
| fe5a8c8b1c | |||
| f79ffcec63 |
47
.env.example
47
.env.example
@ -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
20
.gitignore
vendored
@ -1,19 +1,7 @@
|
||||
# 환경 변수
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
config.py
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
naver_review/build/
|
||||
*.spec
|
||||
|
||||
# 프로젝트 특화
|
||||
data/weather_capture_*.png
|
||||
logs/cron.log
|
||||
logs/flask.log
|
||||
|
||||
# 레거시 (사용 안 함)
|
||||
config.sample.py
|
||||
naver_review/
|
||||
.vscode/
|
||||
|
||||
554
README.md
554
README.md
@ -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/
|
||||
├── logs/ # 크론 실행 로그 저장 경로
|
||||
│ └── cron.log # Crontab 실행 로그
|
||||
│
|
||||
├── data/ # SQLite DB, 캡처 이미지 저장 경로 (공용 볼륨)
|
||||
├── data/ # sqlite DB, 캡처 이미지 저장 경로 (공용 볼륨)
|
||||
│ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블)
|
||||
│ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지
|
||||
│
|
||||
├── app/ # gnu-autouploader + API 서버 (통합)
|
||||
├── autouploader/ # gnu-autouploader 앱 소스
|
||||
│ ├── 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 # 수동 실행용 셸 스크립트 (개발 시 사용)
|
||||
│ ├── config.py # 설정값 (DB, FTP, API KEY 등)
|
||||
│ └── run.sh # 자동 실행용 셸 스크립트 (cron에서 호출)
|
||||
│
|
||||
├── webhook/ # (더 이상 사용 안 함 - app/api_server.py로 통합)
|
||||
│ └── webhook.py # (참고용 아카이브)
|
||||
├── webhook/ # 카카오 챗봇 응답 서버
|
||||
│ ├── webhook.py # Flask 기반 응답 서버
|
||||
│ └── config.py # 환경 설정 (예: IMAGE_SERVER_URL)
|
||||
│
|
||||
├── build/
|
||||
│ ├── app/
|
||||
│ │ ├── Dockerfile # gnu-autouploader + Flask 통합 이미지
|
||||
│ │ ├── entrypoint.sh # Flask + Cron 동시 실행 스크립트 ⭐
|
||||
│ │ └── run.sh # (위의 app/run.sh와 동일)
|
||||
│ ├── autouploader/
|
||||
│ │ └── Dockerfile # gnu-autouploader용 Dockerfile
|
||||
│ └── webhook/
|
||||
│ └── Dockerfile # (더 이상 사용 안 함)
|
||||
│ └── Dockerfile # webhook 서버용 Dockerfile
|
||||
│
|
||||
├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정)
|
||||
├── docker-compose.yml # Docker Compose 서비스 정의 (gnu-autouploader만)
|
||||
├── docker-compose.yml # 전체 서비스 구성 정의
|
||||
└── 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
|
||||
```
|
||||
|
||||
@ -1,434 +0,0 @@
|
||||
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
|
||||
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 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를 통한 강수량 예보 조회
|
||||
|
||||
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'
|
||||
}
|
||||
,
|
||||
'data_status': 'ok' | 'generating' | 'unavailable'
|
||||
}
|
||||
"""
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
is_forecast = date_str > today
|
||||
data_status = 'ok'
|
||||
|
||||
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 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
|
||||
|
||||
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):
|
||||
"""
|
||||
날짜별 강수량 데이터 조회
|
||||
- 당일(오늘): 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)
|
||||
@ -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'),
|
||||
}
|
||||
@ -1,417 +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}개\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, 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')
|
||||
)
|
||||
|
||||
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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
33
autouploader/config.sample.py
Normal file
33
autouploader/config.sample.py
Normal 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'
|
||||
277
autouploader/gnu_autoupload.py
Normal file
277
autouploader/gnu_autoupload.py
Normal 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
3
autouploader/run.sh
Normal 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
|
||||
80
autouploader/weather_capture.py
Normal file
80
autouploader/weather_capture.py
Normal 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()
|
||||
@ -1,25 +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
|
||||
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"
|
||||
|
||||
# 함정 설정: 스크립트 종료 시 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
|
||||
@ -20,35 +20,23 @@ RUN apt-get update && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Python 패키지 설치
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
RUN pip install --no-cache-dir \
|
||||
selenium>=4.10 \
|
||||
pymysql \
|
||||
ftputil \
|
||||
pillow \
|
||||
pyvirtualdisplay \
|
||||
requests \
|
||||
python-dotenv \
|
||||
flask
|
||||
requests
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN fc-cache -f -v
|
||||
|
||||
COPY app/ /app/
|
||||
COPY run.sh /app/run.sh
|
||||
RUN chmod +x /app/run.sh
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
RUN mkdir -p /logs && chmod 777 /logs
|
||||
# crontab 등록
|
||||
RUN echo "0 9 * * * /app/run.sh >> /proc/1/fd/1 2>&1" | crontab -
|
||||
|
||||
# Entrypoint 스크립트를 사용하여 Flask + Cron 동시 실행
|
||||
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"]
|
||||
CMD ["/bin/bash", "-c", "cron -f"]
|
||||
3
build/autouploader/run.sh
Normal file
3
build/autouploader/run.sh
Normal 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
30
build/webhook/Dockerfile
Normal 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"]
|
||||
@ -1,16 +1,29 @@
|
||||
services:
|
||||
gnu-autouploader:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./build/app/Dockerfile
|
||||
context: ./build/autouploader
|
||||
dockerfile: Dockerfile
|
||||
image: reg.firstgarden.co.kr/gnu-autouploader:latest
|
||||
container_name: gnu-autouploader
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./logs:/logs
|
||||
- ./.env:/app/.env:ro
|
||||
ports:
|
||||
- "5151:5000"
|
||||
env_file:
|
||||
- .env
|
||||
- ./autouploader:/app
|
||||
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
|
||||
|
||||
97
webhook/webhook.py
Normal file
97
webhook/webhook.py
Normal 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)
|
||||
Reference in New Issue
Block a user