Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d9a77513 | |||
| d2fbfa46c1 | |||
| 338c0c0d1c | |||
| 985f6310f3 | |||
| 3ee3161f06 | |||
| f786f4a1fe | |||
| 9ffb3e5c25 | |||
| b33aa8c0a2 | |||
| 29bdcdc774 | |||
| eab2ffbf6a |
49
.env.example
Normal file
49
.env.example
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# =====================================
|
||||||
|
# 필수 설정 항목 (반드시 작성해야 함)
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
# 게시판 설정
|
||||||
|
BOARD_ID=news
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# 웹서버 설정
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
# 웹훅 도메인 (이미지 URL 생성 시 사용)
|
||||||
|
DOMAIN=https://webhook.firstgarden.co.kr
|
||||||
|
|
||||||
|
# Flask 디버그 모드 (개발 시만 1로 설정, 운영은 0)
|
||||||
|
FLASK_DEBUG=0
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@ -1,6 +1,19 @@
|
|||||||
config.py
|
# 환경 변수
|
||||||
**/__pycache__/
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
naver_review/build/
|
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
# 프로젝트 특화
|
||||||
data/weather_capture_*.png
|
data/weather_capture_*.png
|
||||||
|
logs/cron.log
|
||||||
|
logs/flask.log
|
||||||
|
|
||||||
|
# 레거시 (사용 안 함)
|
||||||
|
config.sample.py
|
||||||
|
naver_review/
|
||||||
|
|||||||
351
README.md
351
README.md
@ -1,41 +1,342 @@
|
|||||||
## 퍼스트가든용 기상청 API를 활용해 공지사항 자동 등록하는 이미지
|
## 퍼스트가든용 기상청 API를 활용해 공지사항 자동 등록 시스템
|
||||||
기상정보에 따른 이벤트 진행에 대한 정확한 기준 부여를 위해 기상청 API를 사용, 영업시간 내 강수정보를 파악하고 해당 공지를 올리기 위한 프로젝트.
|
|
||||||
|
|
||||||
### weather.py
|
기상정보에 따른 이벤트 진행에 대한 정확한 기준 부여를 위해 기상청 API를 사용하여, 영업시간 내 강수정보를 파악하고 자동으로 공지를 올리기 위한 프로젝트.
|
||||||
- 기상청 API를 활용해 데이터 출력
|
|
||||||
|
|
||||||
### weather_capture.py
|
---
|
||||||
- 기상청 날씨누리 단기예보 페이지를 캡처하여 이미지로 저장
|
|
||||||
- 클레임 방지를 위해 '최근발표시각'을 표시하여 캡처함
|
|
||||||
|
|
||||||
### gnu_autoupload.py
|
## 📁 폴더 구조
|
||||||
- 위 파일들의 데이터를 그누보드 게시판에 등록하는 일을 수행
|
|
||||||
|
|
||||||
### config.sample.py
|
```
|
||||||
- 환경정보 저장(DB정보 등), config.py 로 파일명 변경하여 사용
|
|
||||||
|
|
||||||
### 폴더 구조
|
|
||||||
project-root/
|
project-root/
|
||||||
├── data/ # sqlite DB, 캡처 이미지 저장 경로 (공용 볼륨)
|
├── logs/ # 크론 실행 로그 저장 경로
|
||||||
|
│ └── cron.log # Crontab 실행 로그
|
||||||
|
│
|
||||||
|
├── data/ # SQLite DB, 캡처 이미지 저장 경로 (공용 볼륨)
|
||||||
│ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블)
|
│ ├── weather.sqlite # 날씨 DB (precipitation, summary 테이블)
|
||||||
│ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지
|
│ └── weather_capture_YYYYMMDD.png # 일자별 날씨 캡처 이미지
|
||||||
│
|
│
|
||||||
├── autouploader/ # gnu-autouploader 앱 소스
|
├── app/ # gnu-autouploader + API 서버 (통합)
|
||||||
│ ├── gnu_autoupload.py # 메인 실행 스크립트 (Selenium → FTP → DB)
|
│ ├── gnu_autoupload.py # 메인 실행 스크립트 (Selenium → FTP → DB)
|
||||||
│ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처
|
│ ├── weather_capture.py # Selenium 기반 날씨 이미지 캡처 + 강우량 추출
|
||||||
│ ├── weather.py # 기상청 API 데이터 처리 및 sqlite 저장
|
│ ├── weather.py # 기상청 API 데이터 처리 및 sqlite 저장
|
||||||
│ ├── config.py # 설정값 (DB, FTP, API KEY 등)
|
│ ├── send_message.py # Mattermost 알림 발송
|
||||||
│ └── run.sh # 자동 실행용 셸 스크립트 (cron에서 호출)
|
│ ├── selenium_manager.py # Selenium 브라우저 관리
|
||||||
|
│ ├── api_server.py # Flask 기반 카카오 챗봇 웹훅 서버 ⭐
|
||||||
|
│ ├── config.py # 설정값 로드 (DB, FTP, API KEY 등)
|
||||||
|
│ ├── requirements.txt # Python 의존성
|
||||||
|
│ └── run.sh # 수동 실행용 셸 스크립트 (개발 시 사용)
|
||||||
│
|
│
|
||||||
├── webhook/ # 카카오 챗봇 응답 서버
|
├── webhook/ # (더 이상 사용 안 함 - app/api_server.py로 통합)
|
||||||
│ ├── webhook.py # Flask 기반 응답 서버
|
│ └── webhook.py # (참고용 아카이브)
|
||||||
│ └── config.py # 환경 설정 (예: IMAGE_SERVER_URL)
|
|
||||||
│
|
│
|
||||||
├── build/
|
├── build/
|
||||||
│ ├── autouploader/
|
│ ├── app/
|
||||||
│ │ └── Dockerfile # gnu-autouploader용 Dockerfile
|
│ │ ├── Dockerfile # gnu-autouploader + Flask 통합 이미지
|
||||||
|
│ │ ├── entrypoint.sh # Flask + Cron 동시 실행 스크립트 ⭐
|
||||||
|
│ │ └── run.sh # (위의 app/run.sh와 동일)
|
||||||
│ └── webhook/
|
│ └── webhook/
|
||||||
│ └── Dockerfile # webhook 서버용 Dockerfile
|
│ └── Dockerfile # (더 이상 사용 안 함)
|
||||||
│
|
│
|
||||||
├── docker-compose.yml # 전체 서비스 구성 정의
|
├── .env.example # 환경 변수 템플릿 (.env로 복사하여 수정)
|
||||||
|
├── 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에 저장
|
||||||
|
|
||||||
|
### `webhook/webhook.py` ⭐ (개선)
|
||||||
|
- **Flask 기반 카카오 챇봇 응답 서버**
|
||||||
|
- **주요 기능**:
|
||||||
|
- **당일 조회**: 09:00 캡처된 **실제 강우량 데이터** 응답
|
||||||
|
- **미래 날짜 조회**: 기상청 API 기반 **예보 강우량** 응답
|
||||||
|
- 예보 시 "변동될 수 있음" 경고 문구 표시
|
||||||
|
- 10mm 초과 시 이벤트 적용 안내
|
||||||
|
- 날씨 캡처 이미지 함께 전송
|
||||||
|
- **통합**: 현재 `app/api_server.py`로 통합되어 동일 컨테이너에서 실행
|
||||||
|
|
||||||
|
### `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'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 카카오 챗봇 응답 예시
|
||||||
|
|
||||||
|
### 당일 조회 (실제 데이터)
|
||||||
|
```
|
||||||
|
📅 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
|
||||||
|
```
|
||||||
|
|||||||
293
app/api_server.py
Normal file
293
app/api_server.py
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from flask import Flask, request, jsonify, send_from_directory, make_response
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from config import serviceKey
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# 환경 변수에서 설정값 불러오기
|
||||||
|
DB_PATH = '/data/weather.sqlite'
|
||||||
|
DOMAIN = os.getenv('DOMAIN', 'http://localhost:5000')
|
||||||
|
debug_env = os.getenv('FLASK_DEBUG', '0')
|
||||||
|
DEBUG_MODE = debug_env == '1'
|
||||||
|
|
||||||
|
def parse_rainfall_value(value):
|
||||||
|
"""강수량 텍스트를 숫자로 변환"""
|
||||||
|
if not value or value == '-':
|
||||||
|
return 0.0
|
||||||
|
elif '~' in value:
|
||||||
|
return 0.5
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return float(re.search(r'[\d.]+', value).group())
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_captured_rainfall(date):
|
||||||
|
"""
|
||||||
|
캡처된 강수량 데이터 조회 (당일 기준)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: 'YYYYMMDD' 형식
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (시간별_강수량_목록, 총강수량, 캡처_시각)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
curs = conn.cursor()
|
||||||
|
|
||||||
|
curs.execute('''
|
||||||
|
SELECT hour, rainfall FROM rainfall_capture
|
||||||
|
WHERE date = ? ORDER BY hour
|
||||||
|
''', (date,))
|
||||||
|
hourly_data = curs.fetchall()
|
||||||
|
|
||||||
|
curs.execute('''
|
||||||
|
SELECT total_rainfall, capture_time FROM rainfall_summary
|
||||||
|
WHERE date = ?
|
||||||
|
''', (date,))
|
||||||
|
summary = curs.fetchone()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total = summary[0] if summary else 0.0
|
||||||
|
capture_time = summary[1] if summary else None
|
||||||
|
|
||||||
|
return hourly_data, total, capture_time
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"캡처 강수량 조회 실패: {e}")
|
||||||
|
return [], 0.0, None
|
||||||
|
|
||||||
|
def get_forecast_rainfall(date):
|
||||||
|
"""
|
||||||
|
기상청 API를 통한 강수량 예보 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: 'YYYYMMDD' 형식
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (시간별_강수량_목록, 총강수량) 또는 ([], 0.0)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 기상청 초단기 예보 API
|
||||||
|
url = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst"
|
||||||
|
|
||||||
|
# 예보는 08:50 발표 기준
|
||||||
|
params = {
|
||||||
|
'serviceKey': serviceKey,
|
||||||
|
'numOfRows': '1000',
|
||||||
|
'pageNo': '1',
|
||||||
|
'dataType': 'JSON',
|
||||||
|
'base_date': date,
|
||||||
|
'base_time': '0850',
|
||||||
|
'nx': '57',
|
||||||
|
'ny': '130'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, params=params, timeout=10)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data['response']['header']['resultCode'] != '00':
|
||||||
|
return [], 0.0
|
||||||
|
|
||||||
|
rainfall_by_hour = {}
|
||||||
|
for item in data['response']['body']['items']['item']:
|
||||||
|
if item['category'] == 'RN1': # 1시간 강수량
|
||||||
|
hour = int(item['fcstTime'][:2])
|
||||||
|
if 10 <= hour <= 21: # 10시~21시(오후 9시)
|
||||||
|
rainfall_by_hour[hour] = parse_rainfall_value(item['fcstValue'])
|
||||||
|
|
||||||
|
hourly_list = [(h, rainfall_by_hour.get(h, 0.0)) for h in range(10, 22)]
|
||||||
|
total = sum(rain for _, rain in hourly_list)
|
||||||
|
|
||||||
|
return hourly_list, total
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"API 강수량 예보 조회 실패: {e}")
|
||||||
|
return [], 0.0
|
||||||
|
|
||||||
|
def get_rainfall_data(date_str):
|
||||||
|
"""
|
||||||
|
날짜별 강수량 데이터 조회
|
||||||
|
- 당일(오늘): 09:00 캡처된 실제 데이터
|
||||||
|
- 미래 날짜: 기상청 예보 API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: 'YYYYMMDD' 형식
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'date': 날짜,
|
||||||
|
'is_forecast': 예보 여부,
|
||||||
|
'hourly_data': [(hour, rainfall), ...],
|
||||||
|
'total': 총강수량,
|
||||||
|
'note': 추가 설명
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
is_forecast = date_str > today
|
||||||
|
|
||||||
|
if is_forecast:
|
||||||
|
# 미래 날짜: API 예보
|
||||||
|
hourly, total = get_forecast_rainfall(date_str)
|
||||||
|
note = "⚠️ 이는 기상청 08:00 발표 예보입니다. 실제 이벤트 적용 기준은 당일 09:00 캡처 데이터입니다."
|
||||||
|
else:
|
||||||
|
# 당일 이상: 캡처 데이터
|
||||||
|
hourly, total, timestamp = get_captured_rainfall(date_str)
|
||||||
|
note = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'date': date_str,
|
||||||
|
'is_forecast': is_forecast,
|
||||||
|
'hourly_data': hourly,
|
||||||
|
'total': total,
|
||||||
|
'note': note
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 파일 서빙: /data/ 경로로 이미지 접근 가능하게 함
|
||||||
|
@app.route('/data/<path:filename>')
|
||||||
|
def serve_data_file(filename):
|
||||||
|
return send_from_directory('/data', filename)
|
||||||
|
|
||||||
|
@app.route('/webhook', methods=['POST'])
|
||||||
|
def webhook():
|
||||||
|
"""
|
||||||
|
카카오 챗봇 웹훅
|
||||||
|
|
||||||
|
사용자가 요청한 날짜의 강수량 정보 응답
|
||||||
|
- 당일: 09:00 캡처 데이터
|
||||||
|
- 미래: 기상청 API 예보
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
|
||||||
|
# 사용자 요청 날짜 파싱 (기본값: 오늘)
|
||||||
|
today = datetime.now().strftime('%Y%m%d')
|
||||||
|
query_date = today
|
||||||
|
|
||||||
|
# 사용자 발화에서 날짜 추출 시도
|
||||||
|
if data and 'userRequest' in data and 'utterance' in data['userRequest']:
|
||||||
|
utterance = data['userRequest']['utterance'].strip()
|
||||||
|
|
||||||
|
# 내일, 모레 등의 상대 날짜 파싱
|
||||||
|
if '내일' in utterance:
|
||||||
|
query_date = (datetime.now() + timedelta(days=1)).strftime('%Y%m%d')
|
||||||
|
elif '모레' in utterance:
|
||||||
|
query_date = (datetime.now() + timedelta(days=2)).strftime('%Y%m%d')
|
||||||
|
elif '오늘' in utterance or utterance in ['', None]:
|
||||||
|
query_date = today
|
||||||
|
else:
|
||||||
|
# YYYYMMDD 형식의 날짜 찾기
|
||||||
|
date_match = re.search(r'(\d{8})', utterance)
|
||||||
|
if date_match:
|
||||||
|
query_date = date_match.group(1)
|
||||||
|
|
||||||
|
rainfall_info = get_rainfall_data(query_date)
|
||||||
|
|
||||||
|
# 응답 메시지 구성
|
||||||
|
date_obj = datetime.strptime(query_date, '%Y%m%d')
|
||||||
|
date_str = date_obj.strftime('%m월 %d일(%a)')
|
||||||
|
|
||||||
|
# 강수량 상세 정보
|
||||||
|
lines = [f"📅 {date_str}"]
|
||||||
|
if rainfall_info['is_forecast']:
|
||||||
|
lines.append("📊 예보 강수량 (08:00 발표 기준)")
|
||||||
|
else:
|
||||||
|
lines.append("📊 실제 강수량 (09:00 캡처 기준)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if rainfall_info['hourly_data']:
|
||||||
|
for hour, rainfall in rainfall_info['hourly_data']:
|
||||||
|
if isinstance(hour, tuple): # (hour, rainfall) 튜플인 경우
|
||||||
|
hour, rainfall = hour
|
||||||
|
rain_str = f"{rainfall:.1f}mm" if rainfall > 0 else "☀️ 강수 없음"
|
||||||
|
lines.append(f"{hour:02d}:00 → {rain_str}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"💧 총 강수량: {rainfall_info['total']:.1f}mm")
|
||||||
|
|
||||||
|
# 이벤트 적용 여부
|
||||||
|
if rainfall_info['total'] > 10:
|
||||||
|
lines.append("✅ 식음료 2만원 이상 결제 시 무료입장권 제공")
|
||||||
|
else:
|
||||||
|
lines.append("❌ 이벤트 기준(10mm 초과)을 충족하지 않음")
|
||||||
|
else:
|
||||||
|
lines.append("데이터를 찾을 수 없습니다.")
|
||||||
|
|
||||||
|
if rainfall_info['note']:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(rainfall_info['note'])
|
||||||
|
|
||||||
|
response_text = '\n'.join(lines)
|
||||||
|
|
||||||
|
# 이미지 포함 여부 확인 (당일만)
|
||||||
|
if not rainfall_info['is_forecast']:
|
||||||
|
image_filename = f"weather_capture_{query_date}.png"
|
||||||
|
image_path = f"/data/{image_filename}"
|
||||||
|
outputs = [{
|
||||||
|
"simpleText": {
|
||||||
|
"text": response_text
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
if os.path.isfile(image_path):
|
||||||
|
image_url = f"{DOMAIN}/data/{image_filename}"
|
||||||
|
outputs.append({
|
||||||
|
"image": {
|
||||||
|
"imageUrl": image_url,
|
||||||
|
"altText": f"{date_str} 날씨 캡처"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
outputs = [{
|
||||||
|
"simpleText": {
|
||||||
|
"text": response_text
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
|
response_body = {
|
||||||
|
"version": "2.0",
|
||||||
|
"template": {
|
||||||
|
"outputs": outputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = make_response(jsonify(response_body))
|
||||||
|
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"웹훅 처리 중 오류: {e}", exc_info=True)
|
||||||
|
error_body = {
|
||||||
|
"version": "2.0",
|
||||||
|
"template": {
|
||||||
|
"outputs": [{
|
||||||
|
"simpleText": {
|
||||||
|
"text": f"❌ 오류가 발생했습니다: {str(e)}\n관리자에게 문의하세요."
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = make_response(jsonify(error_body))
|
||||||
|
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# 헬스 체크 엔드포인트
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
return jsonify({'status': 'ok', 'timestamp': datetime.now().isoformat()}), 200
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("Flask 웹서버 시작 (포트 5000)")
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=DEBUG_MODE)
|
||||||
70
app/config.py
Normal file
70
app/config.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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'),
|
||||||
|
}
|
||||||
398
app/gnu_autoupload.py
Normal file
398
app/gnu_autoupload.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
# -*- 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):
|
||||||
|
"""
|
||||||
|
그누보드 게시글 및 첨부파일 등록
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board: 게시판 ID
|
||||||
|
subject: 제목
|
||||||
|
content: 내용
|
||||||
|
mb_id: 게시자 ID
|
||||||
|
nickname: 닉네임
|
||||||
|
ca_name: 카테고리 이름 (선택사항)
|
||||||
|
file_list: 첨부파일 경로 리스트 (선택사항)
|
||||||
|
msg_sender: MessageSender 인스턴스
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (성공여부, 에러메시지)
|
||||||
|
"""
|
||||||
|
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}개"
|
||||||
|
|
||||||
|
if msg_sender:
|
||||||
|
msg_sender.send(success_msg, platforms=['mattermost'])
|
||||||
|
|
||||||
|
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```"
|
||||||
|
|
||||||
|
logger.error(f"게시글 등록 실패: {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():
|
||||||
|
"""메인 실행 함수"""
|
||||||
|
logger.info("=== 날씨 정보 게시글 자동 생성 시작 ===")
|
||||||
|
|
||||||
|
# MessageSender 초기화
|
||||||
|
msg_sender = init_message_sender()
|
||||||
|
|
||||||
|
if not msg_sender:
|
||||||
|
logger.warning("Mattermost 알림이 비활성화됩니다")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 무료입장 조건에 대해서만 안내함.
|
||||||
|
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')
|
||||||
|
|
||||||
|
# 이미지 캡처
|
||||||
|
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']]
|
||||||
|
|
||||||
|
# 게시글 작성
|
||||||
|
success, error = 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
30
app/requirements.txt
Normal file
30
app/requirements.txt
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Standard library modules (자동 포함, 설치 불필요)
|
||||||
|
# os
|
||||||
|
# sys
|
||||||
|
# time
|
||||||
|
# subprocess
|
||||||
|
# tempfile
|
||||||
|
# hashlib
|
||||||
|
# datetime
|
||||||
|
|
||||||
|
# External packages (pip install 필요)
|
||||||
|
# 이미지 처리
|
||||||
|
Pillow
|
||||||
|
|
||||||
|
# 데이터베이스
|
||||||
|
PyMySQL
|
||||||
|
|
||||||
|
# FTP
|
||||||
|
ftputil
|
||||||
|
|
||||||
|
# HTTP 요청
|
||||||
|
requests
|
||||||
|
|
||||||
|
# 웹 자동화
|
||||||
|
selenium
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
python-dotenv
|
||||||
|
|
||||||
|
# 웹 프레임워크
|
||||||
|
flask
|
||||||
178
app/selenium_manager.py
Normal file
178
app/selenium_manager.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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
|
||||||
119
app/send_message.py
Normal file
119
app/send_message.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
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
|
||||||
257
app/weather_capture.py
Normal file
257
app/weather_capture.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
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'
|
||||||
|
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)
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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'
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# 이미지 캡처 함수
|
|
||||||
# ---------------------------
|
|
||||||
def capture_image(script_path, output_path, max_attempts=5):
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 이미지 캡처 시도 {attempt + 1}/{max_attempts}")
|
|
||||||
try:
|
|
||||||
subprocess.run([sys.executable, script_path], check=True)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"[오류] weather_capture.py 실행 실패: {e}")
|
|
||||||
if os.path.isfile(output_path):
|
|
||||||
print(f"[성공] 이미지가 정상적으로 캡처되었습니다: {output_path}")
|
|
||||||
return True
|
|
||||||
time.sleep(2)
|
|
||||||
print(f"[실패] {max_attempts}회 시도 후에도 이미지가 생성되지 않았습니다.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# 파일 관련 유틸 함수
|
|
||||||
# ---------------------------
|
|
||||||
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):
|
|
||||||
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"[FTP 오류] 파일 업로드 실패: {file}")
|
|
||||||
|
|
||||||
curs.execute(f"UPDATE g5_write_{board} SET wr_file = %s WHERE wr_id = %s", (file_count, wr_id))
|
|
||||||
conn.commit()
|
|
||||||
print("[성공] 게시글과 첨부파일 등록 완료")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
if "[FTP 오류]" in str(e):
|
|
||||||
print(f"[FTP 오류] {e}")
|
|
||||||
else:
|
|
||||||
print(f"[DB 오류] {type(e).__name__}: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
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>
|
|
||||||
"""
|
|
||||||
|
|
||||||
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()
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
echo "run.sh 실행됨: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
python3 /data/gnu_autoupload.py >> /proc/1/fd/1 2>&1
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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[2]/section/div/div[2]')
|
|
||||||
))
|
|
||||||
|
|
||||||
# 저장 경로 설정
|
|
||||||
# 기존
|
|
||||||
# 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()
|
|
||||||
@ -20,23 +20,35 @@ 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 \
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
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 run.sh /app/run.sh
|
COPY app/ /app/
|
||||||
RUN chmod +x /app/run.sh
|
|
||||||
|
|
||||||
# crontab 등록
|
# 로그 디렉토리 생성
|
||||||
RUN echo "0 9 * * * /app/run.sh >> /proc/1/fd/1 2>&1" | crontab -
|
RUN mkdir -p /logs && chmod 777 /logs
|
||||||
|
|
||||||
CMD ["/bin/bash", "-c", "cron -f"]
|
# 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"]
|
||||||
23
build/app/entrypoint.sh
Normal file
23
build/app/entrypoint.sh
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Entrypoint 스크립트: Flask 웹서버 + Crontab 동시 실행
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ========================================"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] gnu-autouploader 컨테이너 시작"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ========================================"
|
||||||
|
|
||||||
|
# Flask 웹서버를 백그라운드에서 시작
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Flask 웹서버 시작 (포트 5000)..."
|
||||||
|
cd /app
|
||||||
|
/usr/bin/python -m flask run --host=0.0.0.0 --port=5000 >> /logs/flask.log 2>&1 &
|
||||||
|
FLASK_PID=$!
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Flask PID: $FLASK_PID"
|
||||||
|
|
||||||
|
# 함정 설정: 스크립트 종료 시 Flask도 종료
|
||||||
|
trap "kill $FLASK_PID 2>/dev/null; exit" SIGTERM SIGINT
|
||||||
|
|
||||||
|
# Crontab 데몬을 포그라운드에서 실행 (docker logs에 출력)
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Crontab 데몬 시작..."
|
||||||
|
/usr/sbin/cron -f
|
||||||
@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
echo "run.sh 실행됨: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
python3 /data/gnu_autoupload.py >> /proc/1/fd/1 2>&1
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# Dockerfile for webhook server (Ubuntu 22.04 + Python Flask)
|
|
||||||
|
|
||||||
FROM ubuntu:22.04
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
LANG=ko_KR.UTF-8 \
|
|
||||||
LANGUAGE=ko_KR:ko \
|
|
||||||
LC_ALL=ko_KR.UTF-8
|
|
||||||
|
|
||||||
# 기본 패키지 설치 및 로케일 설정
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
locales tzdata python3 python3-pip curl ca-certificates && \
|
|
||||||
locale-gen ko_KR.UTF-8 && \
|
|
||||||
ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
|
|
||||||
dpkg-reconfigure --frontend noninteractive tzdata && \
|
|
||||||
ln -sf /usr/bin/python3 /usr/bin/python && \
|
|
||||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Flask 설치
|
|
||||||
RUN pip3 install --no-cache-dir flask requests
|
|
||||||
|
|
||||||
# 작업 디렉토리
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 외부 접속 허용 포트
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Flask 앱 실행
|
|
||||||
CMD ["python3", "webhook.py"]
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB |
@ -1,29 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
gnu-autouploader:
|
gnu-autouploader:
|
||||||
build:
|
build:
|
||||||
context: ./build/autouploader
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: ./build/app/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
|
||||||
- ./autouploader:/app
|
- ./logs:/logs
|
||||||
restart: unless-stopped
|
- ./.env:/app/.env:ro
|
||||||
|
|
||||||
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:
|
ports:
|
||||||
- 5151:5000
|
- "5151:5000"
|
||||||
environment:
|
env_file:
|
||||||
- DOMAIN=https://webhook.firstgarden.co.kr
|
- .env
|
||||||
- FLASK_DEBUG=1 #디버그 활성화
|
|
||||||
#environment:
|
|
||||||
# - DOMAIN=https://webhook.firstgarden.co.kr
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
import os
|
|
||||||
from flask import Flask, request, jsonify, send_from_directory, make_response
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
# 환경 변수에서 설정값 불러오기
|
|
||||||
DB_PATH = '/data/weather.sqlite'
|
|
||||||
DOMAIN = os.getenv('DOMAIN', 'http://localhost:5000')
|
|
||||||
debug_env = os.getenv('FLASK_DEBUG', '0')
|
|
||||||
DEBUG_MODE = debug_env == '1'
|
|
||||||
|
|
||||||
def get_rain_data(date):
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
curs = conn.cursor()
|
|
||||||
|
|
||||||
curs.execute('SELECT time, rainfall FROM precipitation WHERE date = ? ORDER BY time', (date,))
|
|
||||||
time_rain_list = curs.fetchall()
|
|
||||||
|
|
||||||
curs.execute('SELECT total_rainfall FROM precipitation_summary WHERE date = ?', (date,))
|
|
||||||
row = curs.fetchone()
|
|
||||||
total_rainfall = row[0] if row else 0.0
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return time_rain_list, total_rainfall
|
|
||||||
|
|
||||||
# 정적 파일 서빙: /data/ 경로로 이미지 접근 가능하게 함
|
|
||||||
@app.route('/data/<path:filename>')
|
|
||||||
def serve_data_file(filename):
|
|
||||||
return send_from_directory('/data', filename)
|
|
||||||
|
|
||||||
@app.route('/webhook', methods=['POST'])
|
|
||||||
def webhook():
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=True) # 사용자 발화가 필요한 경우: data['userRequest']['utterance']
|
|
||||||
today = datetime.today().strftime('%Y%m%d')
|
|
||||||
time_rain_list, total_rainfall = get_rain_data(today)
|
|
||||||
|
|
||||||
# 메시지 구성
|
|
||||||
if not time_rain_list:
|
|
||||||
response_text = f"{today} 날짜의 강수량 데이터가 없습니다."
|
|
||||||
else:
|
|
||||||
lines = []
|
|
||||||
for time_str, rain in time_rain_list:
|
|
||||||
rain_display = f"{rain}mm" if rain > 0 else "강수 없음"
|
|
||||||
lines.append(f"{time_str} → {rain_display}")
|
|
||||||
lines.append(f"\n영업시간 내 총 강수량은 {total_rainfall:.1f}mm 입니다.")
|
|
||||||
response_text = '\n'.join(lines)
|
|
||||||
|
|
||||||
# 이미지 포함 여부 확인
|
|
||||||
image_filename = f"weather_capture_{today}.png"
|
|
||||||
image_path = f"/data/{image_filename}"
|
|
||||||
outputs = [{
|
|
||||||
"simpleText": {
|
|
||||||
"text": response_text
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
if os.path.isfile(image_path):
|
|
||||||
image_url = f"{DOMAIN}/data/{image_filename}"
|
|
||||||
outputs.append({
|
|
||||||
"image": {
|
|
||||||
"imageUrl": image_url,
|
|
||||||
"altText": "오늘의 날씨 캡처 이미지"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# 응답 본문 구성 (version을 최상단에)
|
|
||||||
response_body = {
|
|
||||||
"version": "2.0",
|
|
||||||
"template": {
|
|
||||||
"outputs": outputs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 응답 헤더 설정
|
|
||||||
resp = make_response(jsonify(response_body))
|
|
||||||
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
||||||
return resp
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_body = {
|
|
||||||
"version": "2.0",
|
|
||||||
"template": {
|
|
||||||
"outputs": [{
|
|
||||||
"simpleText": {
|
|
||||||
"text": f"서버 오류가 발생했습니다: {str(e)}"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp = make_response(jsonify(error_body))
|
|
||||||
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
||||||
return resp
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=DEBUG_MODE)
|
|
||||||
Reference in New Issue
Block a user