feat: initial commit - unified FGTools from static, weather, mattermost-noti
This commit is contained in:
110
.env.sample
Normal file
110
.env.sample
Normal file
@ -0,0 +1,110 @@
|
||||
# ===================================================================
|
||||
# FGTools - First Garden 통합 도구 설정 파일
|
||||
# ===================================================================
|
||||
# 이 파일을 복사하여 .env 파일로 저장하고 실제 값을 입력하세요.
|
||||
# cp .env.sample .env
|
||||
# ===================================================================
|
||||
|
||||
# ===== 공통 설정 =====
|
||||
# 디버그 모드 (true: 개발 환경, false: 운영 환경)
|
||||
DEBUG=false
|
||||
# 로그 레벨 (DEBUG, INFO, WARNING, ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
# 병렬 처리 워커 수
|
||||
MAX_WORKERS=4
|
||||
|
||||
# ===== 메인 데이터베이스 설정 (MySQL/MariaDB) =====
|
||||
# 정적 데이터 관리 시스템에서 사용
|
||||
DB_HOST=localhost
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=your_db_name
|
||||
DB_CHARSET=utf8mb4
|
||||
|
||||
# 테이블 접두사 (예: fg_manager_static_)
|
||||
TABLE_PREFIX=fg_manager_static_
|
||||
|
||||
# ===== 공공데이터포털 API 설정 =====
|
||||
# https://data.go.kr 에서 발급받은 API 키
|
||||
DATA_API_SERVICE_KEY=your_data_api_service_key
|
||||
DATA_API_START_DATE=20170101
|
||||
DATA_API_END_DATE=20250701
|
||||
|
||||
# 대기질 측정소명 (쉼표로 구분)
|
||||
AIR_STATION_NAMES=운정
|
||||
|
||||
# 기상청 관측소 ID (쉼표로 구분)
|
||||
WEATHER_STN_IDS=99
|
||||
|
||||
# ===== Google Analytics 4 설정 =====
|
||||
# GA4 API를 통한 웹사이트 방문자 데이터 수집
|
||||
GA4_API_TOKEN=your_ga4_api_token
|
||||
GA4_PROPERTY_ID=384052726
|
||||
GA4_SERVICE_ACCOUNT_FILE=./conf/service-account-credentials.json
|
||||
GA4_START_DATE=20170101
|
||||
GA4_END_DATE=20990731
|
||||
GA4_MAX_ROWS_PER_REQUEST=10000
|
||||
|
||||
# ===== POS 시스템 설정 =====
|
||||
# 방문객 카테고리 (쉼표로 구분)
|
||||
VISITOR_CATEGORIES=입장료,티켓,기업제휴
|
||||
|
||||
# UPSolution POS 연동 정보
|
||||
UPSOLUTION_ID=your_upsolution_id
|
||||
UPSOLUTION_CODE=1112
|
||||
UPSOLUTION_PW=your_password
|
||||
|
||||
# ===== 방문객 예측 모델 가중치 =====
|
||||
FORECAST_VISITOR_MULTIPLIER=0.5
|
||||
FORECAST_WEIGHT_MIN_TEMP=1.0
|
||||
FORECAST_WEIGHT_MAX_TEMP=1.0
|
||||
FORECAST_WEIGHT_PRECIPITATION=10.0
|
||||
FORECAST_WEIGHT_HUMIDITY=1.0
|
||||
FORECAST_WEIGHT_PM25=1.0
|
||||
FORECAST_WEIGHT_HOLIDAY=20
|
||||
|
||||
# 기존 데이터 덮어쓰기 여부
|
||||
FORCE_UPDATE=false
|
||||
|
||||
# ===== Weather 서비스 설정 =====
|
||||
# 날씨 캡처 및 그누보드 연동용
|
||||
|
||||
# 기상청 API 서비스 키 (공공데이터포털)
|
||||
SERVICE_KEY=your_service_key
|
||||
|
||||
# FTP 설정 (이미지 업로드용)
|
||||
FTP_HOST=your_ftp_host
|
||||
FTP_USER=your_ftp_user
|
||||
FTP_PASSWORD=your_ftp_password
|
||||
FTP_UPLOAD_DIR=/path/to/upload
|
||||
|
||||
# 그누보드 게시판 설정
|
||||
BOARD_ID=your_board_id
|
||||
BOARD_CA_NAME=category_name
|
||||
BOARD_CONTENT=게시판 기본 내용
|
||||
BOARD_MB_ID=admin
|
||||
BOARD_NICKNAME=관리자
|
||||
|
||||
# ===== Mattermost 알림 설정 =====
|
||||
# 메시지 알림 서비스 연동
|
||||
MATTERMOST_URL=https://mattermost.example.com
|
||||
MATTERMOST_TOKEN=your_bot_token
|
||||
MATTERMOST_CHANNEL_ID=your_channel_id
|
||||
MATTERMOST_WEBHOOK_URL=https://mattermost.example.com/hooks/xxx
|
||||
|
||||
# ===== Telegram 알림 설정 (선택) =====
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# ===== Synology Chat 설정 (선택) =====
|
||||
SYNOLOGY_CHAT_URL=https://your-synology.com/webapi/entry.cgi
|
||||
SYNOLOGY_CHAT_TOKEN=your_token
|
||||
|
||||
# ===== Notion API 설정 =====
|
||||
# Notion 웹훅 알림 연동
|
||||
NOTION_API_SECRET=your_notion_api_secret
|
||||
|
||||
# ===== Flask 설정 =====
|
||||
FLASK_SECRET_KEY=your_secret_key_here
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
83
.gitignore
vendored
Normal file
83
.gitignore
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
# ===================================================================
|
||||
# .gitignore - FGTools
|
||||
# ===================================================================
|
||||
# Git 추적에서 제외할 파일 및 폴더 목록
|
||||
# ===================================================================
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# 가상환경
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 테스트/커버리지
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# 로그
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 데이터 파일
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# 임시 파일
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# 업로드/출력 폴더
|
||||
uploads/
|
||||
output/
|
||||
|
||||
# OS 파일
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 시크릿 파일 (절대 커밋 금지)
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
service-account-credentials.json
|
||||
config.yaml
|
||||
|
||||
# Docker 볼륨
|
||||
db_data/
|
||||
176
README.md
Normal file
176
README.md
Normal file
@ -0,0 +1,176 @@
|
||||
# ===================================================================
|
||||
# FGTools - First Garden 통합 도구
|
||||
# ===================================================================
|
||||
# 퍼스트가든 운영을 위한 통합 도구 모음입니다.
|
||||
# 기상 데이터, 방문객 분석, 알림 등의 기능을 제공합니다.
|
||||
# ===================================================================
|
||||
|
||||
## 📌 개요
|
||||
|
||||
FGTools는 퍼스트가든 운영에 필요한 다양한 도구들을 통합한 프로젝트입니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
- **날씨 서비스**: 기상청 API를 통한 날씨 예보 및 ASOS 종관기상 데이터 수집
|
||||
- **분석 서비스**: Google Analytics 4, 대기질 데이터 수집 및 방문객 예측
|
||||
- **알림 서비스**: Notion 웹훅 처리 및 Mattermost/Telegram 알림 발송
|
||||
- **대시보드**: 수집된 데이터를 조회하고 시각화하는 웹 인터페이스
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
fgtools/
|
||||
├── core/ # 핵심 공통 모듈
|
||||
│ ├── config.py # 통합 설정 관리
|
||||
│ ├── database.py # 데이터베이스 연결 관리
|
||||
│ ├── logging_utils.py # 로깅 유틸리티
|
||||
│ ├── http_client.py # HTTP 클라이언트 (재시도 지원)
|
||||
│ └── message_sender.py # 다중 플랫폼 메시지 발송
|
||||
│
|
||||
├── services/ # 도메인 서비스
|
||||
│ ├── weather/ # 기상 데이터 서비스
|
||||
│ │ ├── forecast.py # 예보 API (초단기/단기/중기)
|
||||
│ │ ├── asos.py # ASOS 종관기상 데이터
|
||||
│ │ └── precipitation.py # 강수량 서비스
|
||||
│ │
|
||||
│ ├── analytics/ # 분석 서비스
|
||||
│ │ ├── ga4.py # Google Analytics 4
|
||||
│ │ ├── air_quality.py # 대기질 데이터
|
||||
│ │ └── visitor_forecast.py # 방문객 예측
|
||||
│ │
|
||||
│ └── notification/ # 알림 서비스
|
||||
│ ├── notion.py # Notion 웹훅 처리
|
||||
│ └── mattermost.py # Mattermost 알림
|
||||
│
|
||||
├── apps/ # 웹 애플리케이션
|
||||
│ ├── dashboard/ # 대시보드 API
|
||||
│ ├── weather_api/ # 날씨 API 서버
|
||||
│ └── webhook/ # 웹훅 수신 서버
|
||||
│
|
||||
├── .env.sample # 환경변수 샘플
|
||||
├── requirements.txt # Python 의존성
|
||||
└── docker-compose.yml # Docker 구성
|
||||
```
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 1. 환경 설정
|
||||
|
||||
```bash
|
||||
# 저장소 클론
|
||||
git clone https://git.siane.kr/firstgarden/fgtools.git
|
||||
cd fgtools
|
||||
|
||||
# 가상환경 생성 및 활성화
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# 또는
|
||||
.\venv\Scripts\activate # Windows
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 설정 파일 생성
|
||||
|
||||
```bash
|
||||
# .env 파일 생성
|
||||
cp .env.sample .env
|
||||
|
||||
# .env 파일을 편집하여 실제 값 입력
|
||||
# - 데이터베이스 접속 정보
|
||||
# - API 키
|
||||
# - 알림 설정 등
|
||||
```
|
||||
|
||||
### 3. 애플리케이션 실행
|
||||
|
||||
```bash
|
||||
# 대시보드 서버 실행
|
||||
python -m apps.dashboard.app
|
||||
|
||||
# 날씨 API 서버 실행
|
||||
python -m apps.weather_api.app
|
||||
|
||||
# 웹훅 수신 서버 실행
|
||||
python -m apps.webhook.app
|
||||
```
|
||||
|
||||
### 4. Docker로 실행
|
||||
|
||||
```bash
|
||||
# Docker Compose로 모든 서비스 실행
|
||||
docker-compose up -d
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 📖 API 문서
|
||||
|
||||
### Dashboard API (포트 5000)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/dashboard/health` | GET | 헬스 체크 |
|
||||
| `/api/dashboard/stats` | GET | 통계 조회 |
|
||||
| `/api/dashboard/weather/forecast` | GET | 날씨 예보 |
|
||||
| `/api/dashboard/weather/precipitation` | GET | 강수량 예보 |
|
||||
|
||||
### Weather API (포트 5001)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/weather/health` | GET | 헬스 체크 |
|
||||
| `/api/weather/precipitation` | GET | 시간별 강수량 |
|
||||
| `/api/weather/forecast/ultra` | GET | 초단기예보 |
|
||||
| `/api/weather/forecast/vilage` | GET | 단기예보 |
|
||||
| `/api/weather/forecast/midterm` | GET | 중기예보 |
|
||||
|
||||
### Webhook API (포트 5002)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/webhook/health` | GET | 헬스 체크 |
|
||||
| `/webhook/notion` | POST | Notion 웹훅 수신 |
|
||||
| `/webhook/notify` | POST | 알림 발송 |
|
||||
|
||||
## ⚙️ 환경변수 설명
|
||||
|
||||
주요 환경변수는 `.env.sample` 파일을 참고하세요.
|
||||
|
||||
| 변수명 | 설명 | 필수 |
|
||||
|--------|------|------|
|
||||
| `DB_HOST` | 데이터베이스 호스트 | ✅ |
|
||||
| `DB_USER` | 데이터베이스 사용자 | ✅ |
|
||||
| `DB_PASSWORD` | 데이터베이스 비밀번호 | ✅ |
|
||||
| `DATA_API_SERVICE_KEY` | 공공데이터포털 API 키 | ✅ |
|
||||
| `MATTERMOST_URL` | Mattermost 서버 URL | ❌ |
|
||||
| `NOTION_API_SECRET` | Notion API 시크릿 | ❌ |
|
||||
|
||||
## 🔧 개발
|
||||
|
||||
### 코드 스타일
|
||||
|
||||
- Python 3.10 이상
|
||||
- Type hints 사용
|
||||
- Docstring 필수
|
||||
- Black 포매터 권장
|
||||
|
||||
### 테스트
|
||||
|
||||
```bash
|
||||
# 테스트 실행
|
||||
pytest tests/
|
||||
|
||||
# 커버리지 포함
|
||||
pytest --cov=. tests/
|
||||
```
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
Private - First Garden Internal Use Only
|
||||
|
||||
## 📞 연락처
|
||||
|
||||
기술 지원: dev@firstgarden.kr
|
||||
15
apps/__init__.py
Normal file
15
apps/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
# ===================================================================
|
||||
# apps/__init__.py
|
||||
# FGTools 애플리케이션 패키지 초기화
|
||||
# ===================================================================
|
||||
# 웹 애플리케이션 엔드포인트들을 제공합니다:
|
||||
# - dashboard: 정적 데이터 대시보드
|
||||
# - weather_api: 날씨 API 서버
|
||||
# - webhook: 웹훅 수신 서버
|
||||
# ===================================================================
|
||||
|
||||
__all__ = [
|
||||
'dashboard',
|
||||
'weather_api',
|
||||
'webhook',
|
||||
]
|
||||
8
apps/dashboard/__init__.py
Normal file
8
apps/dashboard/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
# ===================================================================
|
||||
# apps/dashboard/__init__.py
|
||||
# 대시보드 앱 패키지 초기화
|
||||
# ===================================================================
|
||||
|
||||
from .app import create_app, dashboard_bp, run_server
|
||||
|
||||
__all__ = ['create_app', 'dashboard_bp', 'run_server']
|
||||
205
apps/dashboard/app.py
Normal file
205
apps/dashboard/app.py
Normal file
@ -0,0 +1,205 @@
|
||||
# ===================================================================
|
||||
# apps/dashboard/app.py
|
||||
# 정적 데이터 대시보드 웹 애플리케이션
|
||||
# ===================================================================
|
||||
# 방문객, 날씨, 대기질, 예측 데이터를 시각화하는 대시보드입니다.
|
||||
# Flask Blueprint 기반으로 구현되어 통합 앱에 포함할 수 있습니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
정적 데이터 대시보드 웹 애플리케이션
|
||||
|
||||
방문객 통계, 날씨 정보, 대기질 데이터 등을 조회하고
|
||||
시각화하는 대시보드를 제공합니다.
|
||||
|
||||
사용 예시:
|
||||
from apps.dashboard.app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.run()
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, Blueprint, jsonify, request, render_template
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from core.config import get_config
|
||||
from core.logging_utils import get_logger, setup_logging
|
||||
from core.database import get_engine, DBSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard')
|
||||
|
||||
|
||||
@dashboard_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
헬스 체크 엔드포인트
|
||||
|
||||
Returns:
|
||||
상태 정보 JSON
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'service': 'dashboard'
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/stats', methods=['GET'])
|
||||
def get_stats():
|
||||
"""
|
||||
기본 통계 조회
|
||||
|
||||
Query Parameters:
|
||||
start_date: 시작 날짜 (YYYY-MM-DD)
|
||||
end_date: 종료 날짜 (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
통계 데이터 JSON
|
||||
"""
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
# TODO: 실제 통계 조회 구현
|
||||
return jsonify({
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'total_visitors': 0,
|
||||
'average_visitors': 0,
|
||||
'data': []
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/weather/forecast', methods=['GET'])
|
||||
def get_weather_forecast():
|
||||
"""
|
||||
날씨 예보 조회
|
||||
|
||||
Returns:
|
||||
날씨 예보 데이터 JSON
|
||||
"""
|
||||
from services.weather import get_daily_vilage_forecast
|
||||
|
||||
config = get_config()
|
||||
service_key = config.data_api.get('service_key', '')
|
||||
|
||||
if not service_key:
|
||||
return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500
|
||||
|
||||
try:
|
||||
forecast = get_daily_vilage_forecast(service_key)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': forecast
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"날씨 예보 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@dashboard_bp.route('/weather/precipitation', methods=['GET'])
|
||||
def get_precipitation():
|
||||
"""
|
||||
강수량 예보 조회
|
||||
|
||||
Query Parameters:
|
||||
format: 출력 형식 (json, html, text)
|
||||
|
||||
Returns:
|
||||
강수량 데이터
|
||||
"""
|
||||
from services.weather import PrecipitationService
|
||||
|
||||
output_format = request.args.get('format', 'json')
|
||||
|
||||
try:
|
||||
service = PrecipitationService()
|
||||
data = service.get_precipitation_info(output_format=output_format)
|
||||
|
||||
if output_format == 'html':
|
||||
return data, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
elif output_format == 'text':
|
||||
return data, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||
else:
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"강수량 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@dashboard_bp.errorhandler(HTTPException)
|
||||
def handle_exception(e):
|
||||
"""HTTP 예외 핸들러"""
|
||||
return jsonify({
|
||||
'error': e.description,
|
||||
'status_code': e.code
|
||||
}), e.code
|
||||
|
||||
|
||||
def create_app(config_override: dict = None) -> Flask:
|
||||
"""
|
||||
Flask 애플리케이션 팩토리
|
||||
|
||||
Args:
|
||||
config_override: 설정 덮어쓰기 딕셔너리
|
||||
|
||||
Returns:
|
||||
Flask 앱 인스턴스
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 설정 로드
|
||||
config = get_config()
|
||||
|
||||
app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret')
|
||||
app.config['JSON_AS_ASCII'] = False
|
||||
|
||||
if config_override:
|
||||
app.config.update(config_override)
|
||||
|
||||
# 로깅 설정
|
||||
setup_logging('apps.dashboard', level=config.log_level)
|
||||
|
||||
# Blueprint 등록
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
# 루트 엔드포인트
|
||||
@app.route('/')
|
||||
def index():
|
||||
return jsonify({
|
||||
'name': 'FGTools Dashboard API',
|
||||
'version': '1.0.0',
|
||||
'endpoints': [
|
||||
'/api/dashboard/health',
|
||||
'/api/dashboard/stats',
|
||||
'/api/dashboard/weather/forecast',
|
||||
'/api/dashboard/weather/precipitation',
|
||||
]
|
||||
})
|
||||
|
||||
logger.info("Dashboard 앱 초기화 완료")
|
||||
return app
|
||||
|
||||
|
||||
def run_server(host: str = '0.0.0.0', port: int = 5000, debug: bool = False):
|
||||
"""
|
||||
개발 서버 실행
|
||||
|
||||
Args:
|
||||
host: 바인딩 호스트
|
||||
port: 포트 번호
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
app = create_app()
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = get_config()
|
||||
run_server(
|
||||
host=config.flask.get('host', '0.0.0.0'),
|
||||
port=config.flask.get('port', 5000),
|
||||
debug=config.debug
|
||||
)
|
||||
8
apps/weather_api/__init__.py
Normal file
8
apps/weather_api/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
# ===================================================================
|
||||
# apps/weather_api/__init__.py
|
||||
# 날씨 API 앱 패키지 초기화
|
||||
# ===================================================================
|
||||
|
||||
from .app import create_app, weather_bp, run_server
|
||||
|
||||
__all__ = ['create_app', 'weather_bp', 'run_server']
|
||||
243
apps/weather_api/app.py
Normal file
243
apps/weather_api/app.py
Normal file
@ -0,0 +1,243 @@
|
||||
# ===================================================================
|
||||
# apps/weather_api/app.py
|
||||
# 날씨 API 서버 애플리케이션
|
||||
# ===================================================================
|
||||
# 기상청 API 데이터를 제공하는 REST API 서버입니다.
|
||||
# 강수량 예보, 날씨 캡처 등의 기능을 제공합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
날씨 API 서버 애플리케이션
|
||||
|
||||
기상청 API 데이터를 조회하고 가공하여 제공하는 API 서버입니다.
|
||||
|
||||
사용 예시:
|
||||
from apps.weather_api.app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.run()
|
||||
"""
|
||||
|
||||
from flask import Flask, Blueprint, jsonify, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from core.config import get_config
|
||||
from core.logging_utils import get_logger, setup_logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
weather_bp = Blueprint('weather', __name__, url_prefix='/api/weather')
|
||||
|
||||
|
||||
@weather_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""헬스 체크"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'service': 'weather-api'
|
||||
})
|
||||
|
||||
|
||||
@weather_bp.route('/precipitation', methods=['GET'])
|
||||
def get_precipitation():
|
||||
"""
|
||||
시간별 강수량 예보 조회
|
||||
|
||||
Query Parameters:
|
||||
date: 조회 날짜 (YYYYMMDD, 기본: 오늘)
|
||||
format: 출력 형식 (json, html, text)
|
||||
|
||||
Returns:
|
||||
강수량 예보 데이터
|
||||
"""
|
||||
from services.weather import PrecipitationService
|
||||
|
||||
target_date = request.args.get('date')
|
||||
output_format = request.args.get('format', 'json')
|
||||
|
||||
try:
|
||||
service = PrecipitationService()
|
||||
data = service.get_precipitation_info(
|
||||
target_date=target_date,
|
||||
output_format=output_format
|
||||
)
|
||||
|
||||
if output_format == 'html':
|
||||
return data, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
elif output_format == 'text':
|
||||
return data, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"강수량 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@weather_bp.route('/forecast/ultra', methods=['GET'])
|
||||
def get_ultra_forecast():
|
||||
"""
|
||||
초단기예보 조회
|
||||
|
||||
Returns:
|
||||
초단기예보 데이터
|
||||
"""
|
||||
from services.weather import get_daily_ultra_forecast
|
||||
|
||||
config = get_config()
|
||||
service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '')
|
||||
|
||||
if not service_key:
|
||||
return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500
|
||||
|
||||
try:
|
||||
data = get_daily_ultra_forecast(service_key)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'type': 'ultra',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"초단기예보 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@weather_bp.route('/forecast/vilage', methods=['GET'])
|
||||
def get_vilage_forecast():
|
||||
"""
|
||||
단기예보 조회
|
||||
|
||||
Returns:
|
||||
단기예보 데이터
|
||||
"""
|
||||
from services.weather import get_daily_vilage_forecast
|
||||
|
||||
config = get_config()
|
||||
service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '')
|
||||
|
||||
if not service_key:
|
||||
return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500
|
||||
|
||||
try:
|
||||
data = get_daily_vilage_forecast(service_key)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'type': 'vilage',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"단기예보 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@weather_bp.route('/forecast/midterm', methods=['GET'])
|
||||
def get_midterm_forecast():
|
||||
"""
|
||||
중기예보 조회
|
||||
|
||||
Query Parameters:
|
||||
reg_id: 지역 코드 (기본: 11B20305)
|
||||
|
||||
Returns:
|
||||
중기예보 데이터
|
||||
"""
|
||||
from services.weather import get_midterm_forecast as fetch_midterm
|
||||
|
||||
config = get_config()
|
||||
service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '')
|
||||
reg_id = request.args.get('reg_id', '11B20305')
|
||||
|
||||
if not service_key:
|
||||
return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500
|
||||
|
||||
try:
|
||||
precip_probs, raw_data = fetch_midterm(service_key, reg_id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'type': 'midterm',
|
||||
'precipitation_probability': precip_probs,
|
||||
'raw_data': raw_data
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"중기예보 조회 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@weather_bp.errorhandler(HTTPException)
|
||||
def handle_exception(e):
|
||||
"""HTTP 예외 핸들러"""
|
||||
return jsonify({
|
||||
'error': e.description,
|
||||
'status_code': e.code
|
||||
}), e.code
|
||||
|
||||
|
||||
def create_app(config_override: dict = None) -> Flask:
|
||||
"""
|
||||
Flask 애플리케이션 팩토리
|
||||
|
||||
Args:
|
||||
config_override: 설정 덮어쓰기 딕셔너리
|
||||
|
||||
Returns:
|
||||
Flask 앱 인스턴스
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 설정 로드
|
||||
config = get_config()
|
||||
|
||||
app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret')
|
||||
app.config['JSON_AS_ASCII'] = False
|
||||
|
||||
if config_override:
|
||||
app.config.update(config_override)
|
||||
|
||||
# 로깅 설정
|
||||
setup_logging('apps.weather_api', level=config.log_level)
|
||||
|
||||
# Blueprint 등록
|
||||
app.register_blueprint(weather_bp)
|
||||
|
||||
# 루트 엔드포인트
|
||||
@app.route('/')
|
||||
def index():
|
||||
return jsonify({
|
||||
'name': 'FGTools Weather API',
|
||||
'version': '1.0.0',
|
||||
'endpoints': [
|
||||
'/api/weather/health',
|
||||
'/api/weather/precipitation',
|
||||
'/api/weather/forecast/ultra',
|
||||
'/api/weather/forecast/vilage',
|
||||
'/api/weather/forecast/midterm',
|
||||
]
|
||||
})
|
||||
|
||||
logger.info("Weather API 앱 초기화 완료")
|
||||
return app
|
||||
|
||||
|
||||
def run_server(host: str = '0.0.0.0', port: int = 5001, debug: bool = False):
|
||||
"""
|
||||
개발 서버 실행
|
||||
|
||||
Args:
|
||||
host: 바인딩 호스트
|
||||
port: 포트 번호
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
app = create_app()
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = get_config()
|
||||
run_server(
|
||||
host=config.flask.get('host', '0.0.0.0'),
|
||||
port=5001,
|
||||
debug=config.debug
|
||||
)
|
||||
8
apps/webhook/__init__.py
Normal file
8
apps/webhook/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
# ===================================================================
|
||||
# apps/webhook/__init__.py
|
||||
# 웹훅 수신 앱 패키지 초기화
|
||||
# ===================================================================
|
||||
|
||||
from .app import create_app, webhook_bp, run_server
|
||||
|
||||
__all__ = ['create_app', 'webhook_bp', 'run_server']
|
||||
238
apps/webhook/app.py
Normal file
238
apps/webhook/app.py
Normal file
@ -0,0 +1,238 @@
|
||||
# ===================================================================
|
||||
# apps/webhook/app.py
|
||||
# 웹훅 수신 서버 애플리케이션
|
||||
# ===================================================================
|
||||
# Notion 등 외부 서비스의 웹훅을 수신하고 처리합니다.
|
||||
# Mattermost 등으로 알림을 발송합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
웹훅 수신 서버 애플리케이션
|
||||
|
||||
Notion 등 외부 서비스의 웹훅을 수신하고
|
||||
Mattermost 등으로 알림을 발송합니다.
|
||||
|
||||
사용 예시:
|
||||
from apps.webhook.app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.run()
|
||||
"""
|
||||
|
||||
from flask import Flask, Blueprint, jsonify, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from core.config import get_config
|
||||
from core.logging_utils import get_logger, setup_logging
|
||||
from services.notification import NotionWebhookHandler, MattermostNotifier
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
webhook_bp = Blueprint('webhook', __name__, url_prefix='/webhook')
|
||||
|
||||
|
||||
@webhook_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""헬스 체크"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'service': 'webhook-receiver'
|
||||
})
|
||||
|
||||
|
||||
@webhook_bp.route('/notion', methods=['POST'])
|
||||
def handle_notion_webhook():
|
||||
"""
|
||||
Notion 웹훅 수신 엔드포인트
|
||||
|
||||
Notion에서 발생한 이벤트를 수신하고
|
||||
Mattermost로 알림을 발송합니다.
|
||||
|
||||
Returns:
|
||||
처리 결과 JSON
|
||||
"""
|
||||
# JSON 데이터 파싱
|
||||
try:
|
||||
event_data = request.get_json()
|
||||
except Exception as e:
|
||||
logger.error(f"JSON 파싱 실패: {e}")
|
||||
return jsonify({'error': 'Invalid JSON'}), 400
|
||||
|
||||
if not event_data:
|
||||
return jsonify({'error': 'Empty request body'}), 400
|
||||
|
||||
# 이벤트 처리
|
||||
try:
|
||||
handler = NotionWebhookHandler()
|
||||
message = handler.handle_event(event_data)
|
||||
|
||||
if message:
|
||||
# Mattermost로 알림 발송
|
||||
notifier = MattermostNotifier.from_config()
|
||||
sent = notifier.send_message(message)
|
||||
|
||||
if sent:
|
||||
logger.info("Notion 이벤트 처리 및 알림 발송 완료")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message_sent': True
|
||||
})
|
||||
else:
|
||||
logger.warning("알림 발송 실패")
|
||||
return jsonify({
|
||||
'status': 'partial',
|
||||
'message_sent': False,
|
||||
'reason': 'Message delivery failed'
|
||||
})
|
||||
else:
|
||||
logger.info("이벤트 무시됨 (필터 조건 불충족)")
|
||||
return jsonify({
|
||||
'status': 'ignored',
|
||||
'reason': 'Event filtered out'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 처리 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@webhook_bp.route('/test', methods=['POST'])
|
||||
def test_webhook():
|
||||
"""
|
||||
테스트 웹훅 엔드포인트
|
||||
|
||||
수신된 데이터를 그대로 반환합니다.
|
||||
디버깅 용도로 사용합니다.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
logger.info(f"테스트 웹훅 수신: {data}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'received',
|
||||
'data': data,
|
||||
'headers': dict(request.headers)
|
||||
})
|
||||
|
||||
|
||||
@webhook_bp.route('/notify', methods=['POST'])
|
||||
def send_notification():
|
||||
"""
|
||||
알림 발송 엔드포인트
|
||||
|
||||
직접 메시지를 발송할 수 있는 엔드포인트입니다.
|
||||
|
||||
Request Body:
|
||||
message: 발송할 메시지 (필수)
|
||||
platform: 발송 플랫폼 (mattermost, telegram, synology)
|
||||
channel_id: 채널 ID (선택)
|
||||
|
||||
Returns:
|
||||
발송 결과 JSON
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
except Exception:
|
||||
return jsonify({'error': 'Invalid JSON'}), 400
|
||||
|
||||
if not data or 'message' not in data:
|
||||
return jsonify({'error': 'Message is required'}), 400
|
||||
|
||||
message = data['message']
|
||||
platform = data.get('platform', 'mattermost')
|
||||
channel_id = data.get('channel_id')
|
||||
|
||||
try:
|
||||
if platform == 'mattermost':
|
||||
notifier = MattermostNotifier.from_config()
|
||||
success = notifier.send_message(message, channel_id=channel_id)
|
||||
else:
|
||||
# 다른 플랫폼은 MessageSender 사용
|
||||
from core.message_sender import MessageSender
|
||||
sender = MessageSender.from_config()
|
||||
success = sender.send(message, platforms=[platform])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success' if success else 'failed',
|
||||
'platform': platform
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"알림 발송 실패: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@webhook_bp.errorhandler(HTTPException)
|
||||
def handle_exception(e):
|
||||
"""HTTP 예외 핸들러"""
|
||||
return jsonify({
|
||||
'error': e.description,
|
||||
'status_code': e.code
|
||||
}), e.code
|
||||
|
||||
|
||||
def create_app(config_override: dict = None) -> Flask:
|
||||
"""
|
||||
Flask 애플리케이션 팩토리
|
||||
|
||||
Args:
|
||||
config_override: 설정 덮어쓰기 딕셔너리
|
||||
|
||||
Returns:
|
||||
Flask 앱 인스턴스
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 설정 로드
|
||||
config = get_config()
|
||||
|
||||
app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret')
|
||||
app.config['JSON_AS_ASCII'] = False
|
||||
|
||||
if config_override:
|
||||
app.config.update(config_override)
|
||||
|
||||
# 로깅 설정
|
||||
setup_logging('apps.webhook', level=config.log_level)
|
||||
|
||||
# Blueprint 등록
|
||||
app.register_blueprint(webhook_bp)
|
||||
|
||||
# 루트 엔드포인트
|
||||
@app.route('/')
|
||||
def index():
|
||||
return jsonify({
|
||||
'name': 'FGTools Webhook Receiver',
|
||||
'version': '1.0.0',
|
||||
'endpoints': [
|
||||
'/webhook/health',
|
||||
'/webhook/notion',
|
||||
'/webhook/test',
|
||||
'/webhook/notify',
|
||||
]
|
||||
})
|
||||
|
||||
logger.info("Webhook 앱 초기화 완료")
|
||||
return app
|
||||
|
||||
|
||||
def run_server(host: str = '0.0.0.0', port: int = 5002, debug: bool = False):
|
||||
"""
|
||||
개발 서버 실행
|
||||
|
||||
Args:
|
||||
host: 바인딩 호스트
|
||||
port: 포트 번호
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
app = create_app()
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = get_config()
|
||||
run_server(
|
||||
host=config.flask.get('host', '0.0.0.0'),
|
||||
port=5002,
|
||||
debug=config.debug
|
||||
)
|
||||
29
core/__init__.py
Normal file
29
core/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
# ===================================================================
|
||||
# core/__init__.py
|
||||
# FGTools 핵심 모듈 패키지 초기화
|
||||
# ===================================================================
|
||||
# 공통으로 사용되는 핵심 모듈들을 제공합니다:
|
||||
# - config: 환경설정 관리
|
||||
# - database: 데이터베이스 연결 및 세션 관리
|
||||
# - logging_utils: 로깅 유틸리티
|
||||
# - http_client: HTTP 요청 재시도 클라이언트
|
||||
# - message_sender: 다중 플랫폼 메시지 발송
|
||||
# ===================================================================
|
||||
|
||||
from .config import Config, get_config
|
||||
from .database import get_engine, get_session, DBSession
|
||||
from .logging_utils import setup_logging, get_logger
|
||||
from .http_client import create_retry_session
|
||||
from .message_sender import MessageSender
|
||||
|
||||
__all__ = [
|
||||
'Config',
|
||||
'get_config',
|
||||
'get_engine',
|
||||
'get_session',
|
||||
'DBSession',
|
||||
'setup_logging',
|
||||
'get_logger',
|
||||
'create_retry_session',
|
||||
'MessageSender',
|
||||
]
|
||||
267
core/config.py
Normal file
267
core/config.py
Normal file
@ -0,0 +1,267 @@
|
||||
# ===================================================================
|
||||
# core/config.py
|
||||
# FGTools 통합 설정 관리 모듈
|
||||
# ===================================================================
|
||||
# 환경변수(.env)를 기반으로 모든 설정을 통합 관리합니다.
|
||||
# python-dotenv를 사용하여 .env 파일을 자동으로 로드합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
FGTools 설정 관리 모듈
|
||||
|
||||
환경변수 기반의 통합 설정 관리를 제공합니다.
|
||||
.env 파일에서 설정을 로드하고, 기본값 및 타입 변환을 지원합니다.
|
||||
|
||||
사용 예시:
|
||||
from core.config import get_config
|
||||
config = get_config()
|
||||
db_host = config.database['host']
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
from functools import lru_cache
|
||||
|
||||
# .env 파일 로드
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
환경변수 기반 설정 관리 클래스
|
||||
|
||||
모든 설정은 환경변수에서 로드되며, 기본값을 제공합니다.
|
||||
민감한 정보는 .env 파일에 저장하고 .gitignore에 추가하세요.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""설정 초기화 및 환경변수 로드"""
|
||||
self._load_all_configs()
|
||||
|
||||
def _get_env(self, key: str, default: str = '', required: bool = False) -> str:
|
||||
"""
|
||||
환경변수 조회
|
||||
|
||||
Args:
|
||||
key: 환경변수 키
|
||||
default: 기본값
|
||||
required: 필수 여부 (True면 없을 시 에러)
|
||||
|
||||
Returns:
|
||||
환경변수 값
|
||||
|
||||
Raises:
|
||||
SystemExit: 필수 환경변수가 없을 경우
|
||||
"""
|
||||
value = os.getenv(key, default)
|
||||
if required and not value:
|
||||
print(f"[ERROR] 필수 환경변수가 설정되지 않았습니다: {key}")
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
def _get_bool(self, key: str, default: bool = False) -> bool:
|
||||
"""환경변수를 불리언으로 변환"""
|
||||
return self._get_env(key, str(default)).lower() in ('true', '1', 'yes')
|
||||
|
||||
def _get_int(self, key: str, default: int = 0) -> int:
|
||||
"""환경변수를 정수로 변환"""
|
||||
try:
|
||||
return int(self._get_env(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def _get_float(self, key: str, default: float = 0.0) -> float:
|
||||
"""환경변수를 실수로 변환"""
|
||||
try:
|
||||
return float(self._get_env(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def _get_list(self, key: str, default: str = '', separator: str = ',') -> List[str]:
|
||||
"""환경변수를 리스트로 변환 (구분자로 분리)"""
|
||||
value = self._get_env(key, default)
|
||||
return [item.strip() for item in value.split(separator) if item.strip()]
|
||||
|
||||
def _get_int_list(self, key: str, default: str = '') -> List[int]:
|
||||
"""환경변수를 정수 리스트로 변환"""
|
||||
str_list = self._get_list(key, default)
|
||||
result = []
|
||||
for item in str_list:
|
||||
try:
|
||||
result.append(int(item))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
def _load_all_configs(self):
|
||||
"""모든 설정 로드"""
|
||||
# 공통 설정
|
||||
self.debug = self._get_bool('DEBUG', False)
|
||||
self.log_level = self._get_env('LOG_LEVEL', 'INFO')
|
||||
self.max_workers = self._get_int('MAX_WORKERS', 4)
|
||||
|
||||
# 데이터베이스 설정
|
||||
self.database = {
|
||||
'host': self._get_env('DB_HOST', 'localhost'),
|
||||
'user': self._get_env('DB_USER', 'firstgarden'),
|
||||
'password': self._get_env('DB_PASSWORD', ''),
|
||||
'name': self._get_env('DB_NAME', 'firstgarden'),
|
||||
'charset': self._get_env('DB_CHARSET', 'utf8mb4'),
|
||||
}
|
||||
|
||||
self.table_prefix = self._get_env('TABLE_PREFIX', 'fg_manager_static_')
|
||||
|
||||
# 공공데이터포털 API 설정
|
||||
self.data_api = {
|
||||
'service_key': self._get_env('DATA_API_SERVICE_KEY', ''),
|
||||
'start_date': self._get_env('DATA_API_START_DATE', '20170101'),
|
||||
'end_date': self._get_env('DATA_API_END_DATE', '20250701'),
|
||||
'air_stations': self._get_list('AIR_STATION_NAMES', '운정'),
|
||||
'weather_station_ids': self._get_int_list('WEATHER_STN_IDS', '99'),
|
||||
}
|
||||
|
||||
# GA4 설정
|
||||
self.ga4 = {
|
||||
'api_token': self._get_env('GA4_API_TOKEN', ''),
|
||||
'property_id': self._get_int('GA4_PROPERTY_ID', 0),
|
||||
'service_account_file': self._get_env('GA4_SERVICE_ACCOUNT_FILE', './conf/service-account-credentials.json'),
|
||||
'start_date': self._get_env('GA4_START_DATE', '20170101'),
|
||||
'end_date': self._get_env('GA4_END_DATE', '20990731'),
|
||||
'max_rows_per_request': self._get_int('GA4_MAX_ROWS_PER_REQUEST', 10000),
|
||||
}
|
||||
|
||||
# POS 설정
|
||||
self.pos = {
|
||||
'visitor_categories': self._get_list('VISITOR_CATEGORIES', '입장료,티켓,기업제휴'),
|
||||
}
|
||||
|
||||
# UPSolution 설정
|
||||
self.upsolution = {
|
||||
'id': self._get_env('UPSOLUTION_ID', ''),
|
||||
'code': self._get_env('UPSOLUTION_CODE', ''),
|
||||
'pw': self._get_env('UPSOLUTION_PW', ''),
|
||||
}
|
||||
|
||||
# 예측 가중치 설정
|
||||
self.forecast_weight = {
|
||||
'visitor_multiplier': self._get_float('FORECAST_VISITOR_MULTIPLIER', 0.5),
|
||||
'min_temp': self._get_float('FORECAST_WEIGHT_MIN_TEMP', 1.0),
|
||||
'max_temp': self._get_float('FORECAST_WEIGHT_MAX_TEMP', 1.0),
|
||||
'precipitation': self._get_float('FORECAST_WEIGHT_PRECIPITATION', 10.0),
|
||||
'humidity': self._get_float('FORECAST_WEIGHT_HUMIDITY', 1.0),
|
||||
'pm25': self._get_float('FORECAST_WEIGHT_PM25', 1.0),
|
||||
'holiday': self._get_int('FORECAST_WEIGHT_HOLIDAY', 20),
|
||||
}
|
||||
|
||||
self.force_update = self._get_bool('FORCE_UPDATE', False)
|
||||
|
||||
# Weather 서비스 설정
|
||||
self.weather_service = {
|
||||
'service_key': self._get_env('SERVICE_KEY', ''),
|
||||
}
|
||||
|
||||
# FTP 설정
|
||||
self.ftp = {
|
||||
'host': self._get_env('FTP_HOST', ''),
|
||||
'user': self._get_env('FTP_USER', ''),
|
||||
'password': self._get_env('FTP_PASSWORD', ''),
|
||||
'upload_dir': self._get_env('FTP_UPLOAD_DIR', ''),
|
||||
}
|
||||
|
||||
# 게시판 설정
|
||||
self.board = {
|
||||
'id': self._get_env('BOARD_ID', ''),
|
||||
'ca_name': self._get_env('BOARD_CA_NAME', ''),
|
||||
'content': self._get_env('BOARD_CONTENT', ''),
|
||||
'mb_id': self._get_env('BOARD_MB_ID', ''),
|
||||
'nickname': self._get_env('BOARD_NICKNAME', ''),
|
||||
}
|
||||
|
||||
# Mattermost 설정
|
||||
self.mattermost = {
|
||||
'url': self._get_env('MATTERMOST_URL', ''),
|
||||
'token': self._get_env('MATTERMOST_TOKEN', ''),
|
||||
'channel_id': self._get_env('MATTERMOST_CHANNEL_ID', ''),
|
||||
'webhook_url': self._get_env('MATTERMOST_WEBHOOK_URL', ''),
|
||||
}
|
||||
|
||||
# Telegram 설정
|
||||
self.telegram = {
|
||||
'bot_token': self._get_env('TELEGRAM_BOT_TOKEN', ''),
|
||||
'chat_id': self._get_env('TELEGRAM_CHAT_ID', ''),
|
||||
}
|
||||
|
||||
# Synology Chat 설정
|
||||
self.synology = {
|
||||
'chat_url': self._get_env('SYNOLOGY_CHAT_URL', ''),
|
||||
'chat_token': self._get_env('SYNOLOGY_CHAT_TOKEN', ''),
|
||||
}
|
||||
|
||||
# Notion 설정
|
||||
self.notion = {
|
||||
'api_secret': self._get_env('NOTION_API_SECRET', ''),
|
||||
}
|
||||
|
||||
# Flask 설정
|
||||
self.flask = {
|
||||
'secret_key': self._get_env('FLASK_SECRET_KEY', 'dev-secret-key'),
|
||||
'host': self._get_env('FLASK_HOST', '0.0.0.0'),
|
||||
'port': self._get_int('FLASK_PORT', 5000),
|
||||
}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
설정값 조회 (딕셔너리 스타일)
|
||||
|
||||
Args:
|
||||
key: 설정 키 (점 표기법 지원, 예: 'database.host')
|
||||
default: 기본값
|
||||
|
||||
Returns:
|
||||
설정값
|
||||
"""
|
||||
keys = key.split('.')
|
||||
value = self
|
||||
for k in keys:
|
||||
if hasattr(value, k):
|
||||
value = getattr(value, k)
|
||||
elif isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
_config_instance: Optional[Config] = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_config() -> Config:
|
||||
"""
|
||||
설정 싱글톤 인스턴스 반환
|
||||
|
||||
처음 호출 시 Config 인스턴스를 생성하고 캐시합니다.
|
||||
이후 호출에서는 캐시된 인스턴스를 반환합니다.
|
||||
|
||||
Returns:
|
||||
Config 인스턴스
|
||||
"""
|
||||
return Config()
|
||||
|
||||
|
||||
def reload_config() -> Config:
|
||||
"""
|
||||
설정 다시 로드 (캐시 무효화)
|
||||
|
||||
환경변수가 변경된 후 설정을 다시 로드할 때 사용합니다.
|
||||
|
||||
Returns:
|
||||
새로운 Config 인스턴스
|
||||
"""
|
||||
get_config.cache_clear()
|
||||
return get_config()
|
||||
280
core/database.py
Normal file
280
core/database.py
Normal file
@ -0,0 +1,280 @@
|
||||
# ===================================================================
|
||||
# core/database.py
|
||||
# FGTools 데이터베이스 연결 관리 모듈
|
||||
# ===================================================================
|
||||
# SQLAlchemy를 사용하여 MySQL/MariaDB 연결을 관리합니다.
|
||||
# 연결 풀링, 자동 재연결, 컨텍스트 매니저를 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
데이터베이스 연결 관리 모듈
|
||||
|
||||
SQLAlchemy 기반의 MySQL/MariaDB 연결 관리를 제공합니다.
|
||||
연결 풀링, 자동 재연결, 트랜잭션 관리를 지원합니다.
|
||||
|
||||
사용 예시:
|
||||
from core.database import DBSession, get_engine
|
||||
|
||||
# 컨텍스트 매니저 사용 (권장)
|
||||
with DBSession() as session:
|
||||
result = session.execute(text("SELECT 1"))
|
||||
|
||||
# 엔진 직접 사용
|
||||
engine = get_engine()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine, text, event, exc
|
||||
from sqlalchemy.pool import QueuePool
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session, Session
|
||||
|
||||
from .config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 엔진 및 세션 팩토리 캐시
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
_scoped_session = None
|
||||
|
||||
|
||||
def _build_db_url() -> str:
|
||||
"""
|
||||
데이터베이스 연결 URL 생성
|
||||
|
||||
Returns:
|
||||
SQLAlchemy 연결 URL 문자열
|
||||
"""
|
||||
config = get_config()
|
||||
db = config.database
|
||||
|
||||
return (
|
||||
f"mysql+pymysql://{db['user']}:{db['password']}"
|
||||
f"@{db['host']}/{db['name']}?charset={db['charset']}"
|
||||
)
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""
|
||||
SQLAlchemy 엔진 반환 (싱글톤)
|
||||
|
||||
연결 풀 설정:
|
||||
- pool_size: 기본 연결 수 (10)
|
||||
- max_overflow: 추가 가능한 연결 수 (20)
|
||||
- pool_recycle: 연결 재활용 시간 (1시간)
|
||||
- pool_pre_ping: 연결 전 상태 확인
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Engine 인스턴스
|
||||
"""
|
||||
global _engine
|
||||
|
||||
if _engine is None:
|
||||
db_url = _build_db_url()
|
||||
|
||||
_engine = create_engine(
|
||||
db_url,
|
||||
poolclass=QueuePool,
|
||||
pool_pre_ping=True, # 연결 전 핸들 확인 (끊어진 연결 방지)
|
||||
pool_recycle=3600, # 1시간마다 연결 재생성
|
||||
pool_size=10, # 기본 연결 풀 크기
|
||||
max_overflow=20, # 추가 오버플로우 연결 수
|
||||
echo=get_config().debug, # 디버그 모드에서만 SQL 출력
|
||||
connect_args={
|
||||
'connect_timeout': 10,
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
)
|
||||
|
||||
# 연결 이벤트 리스너 등록
|
||||
_setup_event_listeners()
|
||||
|
||||
logger.info("데이터베이스 엔진 초기화 완료")
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def _setup_event_listeners():
|
||||
"""SQLAlchemy 이벤트 리스너 설정"""
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def on_connect(dbapi_conn, connection_record):
|
||||
"""데이터베이스 연결 성공 시 호출"""
|
||||
logger.debug("DB 연결 성공")
|
||||
|
||||
@event.listens_for(_engine, "checkout")
|
||||
def on_checkout(dbapi_conn, connection_record, connection_proxy):
|
||||
"""연결 풀에서 연결을 가져올 때 호출"""
|
||||
logger.debug("DB 연결 체크아웃")
|
||||
|
||||
@event.listens_for(_engine, "checkin")
|
||||
def on_checkin(dbapi_conn, connection_record):
|
||||
"""연결을 풀에 반환할 때 호출"""
|
||||
logger.debug("DB 연결 체크인")
|
||||
|
||||
|
||||
def get_session_factory():
|
||||
"""
|
||||
세션 팩토리 반환
|
||||
|
||||
Returns:
|
||||
SQLAlchemy sessionmaker 인스턴스
|
||||
"""
|
||||
global _session_factory
|
||||
|
||||
if _session_factory is None:
|
||||
_session_factory = sessionmaker(bind=get_engine())
|
||||
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_scoped_session():
|
||||
"""
|
||||
스레드 안전한 스코프 세션 반환
|
||||
|
||||
멀티스레드 환경에서 각 스레드가 독립적인 세션을 사용하도록 보장합니다.
|
||||
|
||||
Returns:
|
||||
SQLAlchemy scoped_session 인스턴스
|
||||
"""
|
||||
global _scoped_session
|
||||
|
||||
if _scoped_session is None:
|
||||
_scoped_session = scoped_session(get_session_factory())
|
||||
|
||||
return _scoped_session
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
"""
|
||||
새로운 데이터베이스 세션 생성
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Session 인스턴스
|
||||
|
||||
Raises:
|
||||
exc.DatabaseError: 연결 실패 시
|
||||
"""
|
||||
session = get_session_factory()()
|
||||
|
||||
try:
|
||||
# 연결 테스트
|
||||
session.execute(text('SELECT 1'))
|
||||
except exc.DatabaseError as e:
|
||||
logger.error(f"DB 연결 실패: {e}")
|
||||
session.close()
|
||||
raise
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def close_session():
|
||||
"""스코프 세션 종료 및 정리"""
|
||||
global _scoped_session
|
||||
|
||||
if _scoped_session is not None:
|
||||
_scoped_session.remove()
|
||||
|
||||
|
||||
class DBSession:
|
||||
"""
|
||||
데이터베이스 세션 컨텍스트 매니저
|
||||
|
||||
with 문을 사용하여 세션의 생성, 커밋/롤백, 종료를 자동으로 관리합니다.
|
||||
|
||||
사용 예시:
|
||||
with DBSession() as session:
|
||||
result = session.execute(text("SELECT * FROM users"))
|
||||
for row in result:
|
||||
print(row)
|
||||
|
||||
Attributes:
|
||||
session: SQLAlchemy Session 인스턴스
|
||||
"""
|
||||
|
||||
def __init__(self, auto_commit: bool = True):
|
||||
"""
|
||||
Args:
|
||||
auto_commit: 정상 종료 시 자동 커밋 여부 (기본: True)
|
||||
"""
|
||||
self.session: Optional[Session] = None
|
||||
self.auto_commit = auto_commit
|
||||
|
||||
def __enter__(self) -> Session:
|
||||
"""세션 생성 및 반환"""
|
||||
self.session = get_session()
|
||||
return self.session
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""
|
||||
세션 종료 처리
|
||||
|
||||
예외 발생 시 롤백, 정상 종료 시 커밋(auto_commit=True인 경우)
|
||||
"""
|
||||
if self.session is not None:
|
||||
if exc_type is not None:
|
||||
# 예외 발생 시 롤백
|
||||
self.session.rollback()
|
||||
logger.error(f"트랜잭션 롤백: {exc_type.__name__}: {exc_val}")
|
||||
elif self.auto_commit:
|
||||
# 정상 종료 시 커밋
|
||||
try:
|
||||
self.session.commit()
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
logger.error(f"커밋 실패, 롤백 수행: {e}")
|
||||
raise
|
||||
|
||||
self.session.close()
|
||||
|
||||
# 예외 전파하지 않음 (False 반환)
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_transaction():
|
||||
"""
|
||||
데이터베이스 트랜잭션 컨텍스트 매니저 (함수형)
|
||||
|
||||
DBSession 클래스와 동일한 기능을 함수형으로 제공합니다.
|
||||
|
||||
사용 예시:
|
||||
with db_transaction() as session:
|
||||
session.execute(text("INSERT INTO users (name) VALUES (:name)"), {"name": "test"})
|
||||
|
||||
Yields:
|
||||
SQLAlchemy Session 인스턴스
|
||||
"""
|
||||
session = get_session()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"트랜잭션 실패: {e}")
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def dispose_engine():
|
||||
"""
|
||||
엔진 및 연결 풀 정리
|
||||
|
||||
애플리케이션 종료 시 또는 연결 초기화가 필요할 때 호출합니다.
|
||||
"""
|
||||
global _engine, _session_factory, _scoped_session
|
||||
|
||||
if _scoped_session is not None:
|
||||
_scoped_session.remove()
|
||||
_scoped_session = None
|
||||
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
|
||||
_session_factory = None
|
||||
|
||||
logger.info("데이터베이스 엔진 정리 완료")
|
||||
332
core/http_client.py
Normal file
332
core/http_client.py
Normal file
@ -0,0 +1,332 @@
|
||||
# ===================================================================
|
||||
# core/http_client.py
|
||||
# FGTools HTTP 클라이언트 유틸리티 모듈
|
||||
# ===================================================================
|
||||
# 자동 재시도, 타임아웃, 연결 풀링을 지원하는 HTTP 클라이언트입니다.
|
||||
# requests 라이브러리 기반으로 안정적인 API 호출을 제공합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
HTTP 클라이언트 유틸리티 모듈
|
||||
|
||||
자동 재시도, 지수 백오프, 타임아웃 설정을 지원하는
|
||||
안정적인 HTTP 클라이언트를 제공합니다.
|
||||
|
||||
사용 예시:
|
||||
from core.http_client import create_retry_session, get_json
|
||||
|
||||
# 재시도 세션 사용
|
||||
session = create_retry_session(retries=3)
|
||||
response = session.get("https://api.example.com/data")
|
||||
|
||||
# 간편 JSON 요청
|
||||
data = get_json("https://api.example.com/data", params={"key": "value"})
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
from functools import wraps
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 기본 설정 상수
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_RETRIES = 3
|
||||
DEFAULT_BACKOFF_FACTOR = 0.5
|
||||
RETRY_STATUS_CODES = [429, 500, 502, 503, 504]
|
||||
|
||||
|
||||
def create_retry_session(
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
|
||||
status_forcelist: Optional[list] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT
|
||||
) -> requests.Session:
|
||||
"""
|
||||
자동 재시도를 지원하는 requests 세션 생성
|
||||
|
||||
실패한 요청을 지수 백오프 방식으로 자동 재시도합니다.
|
||||
연결 풀링을 통해 다수의 요청을 효율적으로 처리합니다.
|
||||
|
||||
Args:
|
||||
retries: 최대 재시도 횟수
|
||||
backoff_factor: 재시도 간격 배수 (0.5면 0.5, 1, 2초...)
|
||||
status_forcelist: 재시도할 HTTP 상태 코드 목록
|
||||
timeout: 요청 타임아웃 (초)
|
||||
|
||||
Returns:
|
||||
설정된 requests.Session 인스턴스
|
||||
|
||||
사용 예시:
|
||||
session = create_retry_session(retries=5, timeout=60)
|
||||
response = session.get("https://api.example.com/data")
|
||||
"""
|
||||
if status_forcelist is None:
|
||||
status_forcelist = RETRY_STATUS_CODES
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
# 재시도 전략 설정
|
||||
retry_strategy = Retry(
|
||||
total=retries,
|
||||
read=retries,
|
||||
connect=retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=status_forcelist,
|
||||
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
|
||||
raise_on_status=False
|
||||
)
|
||||
|
||||
# HTTP/HTTPS 어댑터에 재시도 전략 적용
|
||||
adapter = HTTPAdapter(
|
||||
max_retries=retry_strategy,
|
||||
pool_connections=10,
|
||||
pool_maxsize=10
|
||||
)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
# 기본 타임아웃 설정 (request 메서드 래핑)
|
||||
original_request = session.request
|
||||
|
||||
def request_with_timeout(*args, **kwargs):
|
||||
kwargs.setdefault('timeout', timeout)
|
||||
return original_request(*args, **kwargs)
|
||||
|
||||
session.request = request_with_timeout
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def retry_on_exception(
|
||||
max_retries: int = DEFAULT_RETRIES,
|
||||
delay: float = 1.0,
|
||||
backoff: float = 2.0,
|
||||
exceptions: tuple = (Exception,)
|
||||
) -> Callable:
|
||||
"""
|
||||
함수 실행 실패 시 재시도하는 데코레이터
|
||||
|
||||
지정된 예외가 발생하면 지수 백오프 방식으로 재시도합니다.
|
||||
|
||||
Args:
|
||||
max_retries: 최대 재시도 횟수
|
||||
delay: 초기 재시도 지연 시간 (초)
|
||||
backoff: 재시도마다 지연 시간 배수
|
||||
exceptions: 재시도할 예외 타입들
|
||||
|
||||
Returns:
|
||||
데코레이터 함수
|
||||
|
||||
사용 예시:
|
||||
@retry_on_exception(max_retries=3, delay=1.0)
|
||||
def fetch_data():
|
||||
return requests.get("https://api.example.com").json()
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = delay * (backoff ** attempt)
|
||||
logger.warning(
|
||||
f"{func.__name__} 재시도 {attempt + 1}/{max_retries} "
|
||||
f"({wait_time:.1f}초 대기): {e}"
|
||||
)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
logger.error(f"{func.__name__} 모든 재시도 실패: {e}")
|
||||
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def get_json(
|
||||
url: str,
|
||||
params: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
JSON API 요청 간편 함수
|
||||
|
||||
GET 요청을 보내고 JSON 응답을 파싱하여 반환합니다.
|
||||
실패 시 None을 반환하고 에러를 로깅합니다.
|
||||
|
||||
Args:
|
||||
url: 요청 URL
|
||||
params: 쿼리 파라미터
|
||||
headers: 요청 헤더
|
||||
timeout: 타임아웃 (초)
|
||||
retries: 최대 재시도 횟수
|
||||
|
||||
Returns:
|
||||
JSON 응답 딕셔너리 또는 None
|
||||
"""
|
||||
session = create_retry_session(retries=retries, timeout=timeout)
|
||||
|
||||
try:
|
||||
response = session.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"HTTP 요청 실패: {url} - {e}")
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error(f"JSON 파싱 실패: {url} - {e}")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def post_json(
|
||||
url: str,
|
||||
data: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
JSON POST 요청 간편 함수
|
||||
|
||||
POST 요청을 보내고 JSON 응답을 파싱하여 반환합니다.
|
||||
|
||||
Args:
|
||||
url: 요청 URL
|
||||
data: form 데이터
|
||||
json_data: JSON 데이터
|
||||
headers: 요청 헤더
|
||||
timeout: 타임아웃 (초)
|
||||
retries: 최대 재시도 횟수
|
||||
|
||||
Returns:
|
||||
JSON 응답 딕셔너리 또는 None
|
||||
"""
|
||||
session = create_retry_session(retries=retries, timeout=timeout)
|
||||
|
||||
try:
|
||||
response = session.post(url, data=data, json=json_data, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"HTTP POST 요청 실패: {url} - {e}")
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error(f"JSON 파싱 실패: {url} - {e}")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""
|
||||
API 클라이언트 기본 클래스
|
||||
|
||||
특정 API 서비스에 대한 클라이언트를 구현할 때 상속하여 사용합니다.
|
||||
공통적인 인증, 베이스 URL, 에러 처리 로직을 제공합니다.
|
||||
|
||||
사용 예시:
|
||||
class WeatherAPIClient(APIClient):
|
||||
def __init__(self, api_key):
|
||||
super().__init__("https://api.weather.go.kr")
|
||||
self.api_key = api_key
|
||||
|
||||
def get_forecast(self, city):
|
||||
return self.get("/forecast", params={"city": city, "key": self.api_key})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
default_headers: Optional[Dict] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
base_url: API 기본 URL
|
||||
default_headers: 기본 요청 헤더
|
||||
timeout: 기본 타임아웃
|
||||
retries: 기본 재시도 횟수
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.default_headers = default_headers or {}
|
||||
self.timeout = timeout
|
||||
self.session = create_retry_session(retries=retries, timeout=timeout)
|
||||
|
||||
def _build_url(self, endpoint: str) -> str:
|
||||
"""엔드포인트를 포함한 전체 URL 생성"""
|
||||
if endpoint.startswith('http'):
|
||||
return endpoint
|
||||
return f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
|
||||
def _merge_headers(self, headers: Optional[Dict]) -> Dict:
|
||||
"""기본 헤더와 요청 헤더 병합"""
|
||||
merged = self.default_headers.copy()
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
return merged
|
||||
|
||||
def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None
|
||||
) -> Optional[requests.Response]:
|
||||
"""GET 요청"""
|
||||
url = self._build_url(endpoint)
|
||||
merged_headers = self._merge_headers(headers)
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=params, headers=merged_headers)
|
||||
return response
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"GET 요청 실패: {url} - {e}")
|
||||
return None
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None
|
||||
) -> Optional[requests.Response]:
|
||||
"""POST 요청"""
|
||||
url = self._build_url(endpoint)
|
||||
merged_headers = self._merge_headers(headers)
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
url, data=data, json=json_data, headers=merged_headers
|
||||
)
|
||||
return response
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"POST 요청 실패: {url} - {e}")
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""세션 종료"""
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
248
core/logging_utils.py
Normal file
248
core/logging_utils.py
Normal file
@ -0,0 +1,248 @@
|
||||
# ===================================================================
|
||||
# core/logging_utils.py
|
||||
# FGTools 로깅 유틸리티 모듈
|
||||
# ===================================================================
|
||||
# 일관된 로그 포맷과 설정을 제공하는 로깅 유틸리티입니다.
|
||||
# 파일 및 콘솔 출력, 로그 레벨 설정을 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
로깅 유틸리티 모듈
|
||||
|
||||
일관된 로그 포맷과 핸들러 설정을 제공합니다.
|
||||
콘솔 출력, 파일 저장, 로그 로테이션을 지원합니다.
|
||||
|
||||
사용 예시:
|
||||
from core.logging_utils import get_logger, setup_logging
|
||||
|
||||
# 간단한 로거 사용
|
||||
logger = get_logger(__name__)
|
||||
logger.info("메시지")
|
||||
|
||||
# 상세 설정이 필요한 경우
|
||||
logger = setup_logging("my_module", level="DEBUG", log_file="app.log")
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Optional
|
||||
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
|
||||
|
||||
from .config import get_config
|
||||
|
||||
# 로그 포맷 상수
|
||||
DEFAULT_FORMAT = '[%(asctime)s] %(name)s - %(levelname)s: %(message)s'
|
||||
DETAILED_FORMAT = '[%(asctime)s] %(name)s (%(filename)s:%(lineno)d) - %(levelname)s: %(message)s'
|
||||
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# 로그 디렉토리
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
|
||||
|
||||
|
||||
def _ensure_log_dir():
|
||||
"""로그 디렉토리 생성"""
|
||||
if not os.path.exists(LOG_DIR):
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
name: str,
|
||||
level: Optional[str] = None,
|
||||
log_file: Optional[str] = None,
|
||||
log_format: str = DEFAULT_FORMAT,
|
||||
max_bytes: int = 10 * 1024 * 1024, # 10MB
|
||||
backup_count: int = 5,
|
||||
console_output: bool = True
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
로거 설정 및 반환
|
||||
|
||||
일관된 포맷으로 로거를 설정합니다. 콘솔 출력과 파일 저장을
|
||||
동시에 지원하며, 로그 로테이션을 자동으로 처리합니다.
|
||||
|
||||
Args:
|
||||
name: 로거 이름 (보통 __name__ 사용)
|
||||
level: 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
None이면 환경 설정에서 로드
|
||||
log_file: 로그 파일명 (None이면 파일 저장 안 함)
|
||||
log_format: 로그 메시지 포맷
|
||||
max_bytes: 로그 파일 최대 크기 (로테이션 기준)
|
||||
backup_count: 보관할 백업 파일 수
|
||||
console_output: 콘솔 출력 여부
|
||||
|
||||
Returns:
|
||||
설정된 Logger 인스턴스
|
||||
"""
|
||||
# 설정에서 기본 레벨 로드
|
||||
if level is None:
|
||||
try:
|
||||
level = get_config().log_level
|
||||
except Exception:
|
||||
level = 'INFO'
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# 이미 핸들러가 있으면 레벨만 설정하고 반환
|
||||
if logger.handlers:
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
return logger
|
||||
|
||||
# 로그 레벨 설정
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# 포매터 생성
|
||||
formatter = logging.Formatter(log_format, datefmt=DATE_FORMAT)
|
||||
|
||||
# 콘솔 핸들러 추가
|
||||
if console_output:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러 추가 (요청 시)
|
||||
if log_file:
|
||||
_ensure_log_dir()
|
||||
file_path = os.path.join(LOG_DIR, log_file)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 부모 로거로 전파 방지
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
간편 로거 반환
|
||||
|
||||
기본 설정으로 로거를 반환합니다. 이미 설정된 로거가 있으면 재사용합니다.
|
||||
|
||||
Args:
|
||||
name: 로거 이름 (보통 __name__ 사용)
|
||||
|
||||
Returns:
|
||||
Logger 인스턴스
|
||||
"""
|
||||
return setup_logging(name)
|
||||
|
||||
|
||||
def setup_file_logger(
|
||||
name: str,
|
||||
log_file: str,
|
||||
level: str = 'INFO',
|
||||
rotation: str = 'size', # 'size' 또는 'time'
|
||||
when: str = 'midnight', # rotation='time'일 때 사용
|
||||
interval: int = 1, # rotation='time'일 때 사용
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
파일 전용 로거 설정
|
||||
|
||||
콘솔 출력 없이 파일에만 로그를 기록합니다.
|
||||
크기 기반 또는 시간 기반 로테이션을 선택할 수 있습니다.
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
log_file: 로그 파일명
|
||||
level: 로그 레벨
|
||||
rotation: 로테이션 방식 ('size' 또는 'time')
|
||||
when: 시간 기반 로테이션 주기 (midnight, H, D, W0-W6)
|
||||
interval: 로테이션 간격
|
||||
|
||||
Returns:
|
||||
설정된 Logger 인스턴스
|
||||
"""
|
||||
_ensure_log_dir()
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
formatter = logging.Formatter(DETAILED_FORMAT, datefmt=DATE_FORMAT)
|
||||
file_path = os.path.join(LOG_DIR, log_file)
|
||||
|
||||
if rotation == 'time':
|
||||
# 시간 기반 로테이션 (예: 매일 자정)
|
||||
handler = TimedRotatingFileHandler(
|
||||
file_path,
|
||||
when=when,
|
||||
interval=interval,
|
||||
backupCount=30,
|
||||
encoding='utf-8'
|
||||
)
|
||||
else:
|
||||
# 크기 기반 로테이션
|
||||
handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
handler.setLevel(log_level)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
class LogContext:
|
||||
"""
|
||||
로그 컨텍스트 관리자
|
||||
|
||||
특정 작업의 시작과 종료를 자동으로 로깅합니다.
|
||||
작업 소요 시간도 함께 기록됩니다.
|
||||
|
||||
사용 예시:
|
||||
with LogContext(logger, "데이터 처리"):
|
||||
process_data()
|
||||
# 출력:
|
||||
# [시작] 데이터 처리
|
||||
# [완료] 데이터 처리 (소요시간: 1.23초)
|
||||
"""
|
||||
|
||||
def __init__(self, logger: logging.Logger, task_name: str, level: int = logging.INFO):
|
||||
"""
|
||||
Args:
|
||||
logger: Logger 인스턴스
|
||||
task_name: 작업 이름
|
||||
level: 로그 레벨
|
||||
"""
|
||||
self.logger = logger
|
||||
self.task_name = task_name
|
||||
self.level = level
|
||||
self.start_time = None
|
||||
|
||||
def __enter__(self):
|
||||
"""작업 시작 로깅"""
|
||||
import time
|
||||
self.start_time = time.time()
|
||||
self.logger.log(self.level, f"[시작] {self.task_name}")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""작업 종료 로깅"""
|
||||
import time
|
||||
elapsed = time.time() - self.start_time
|
||||
|
||||
if exc_type is not None:
|
||||
self.logger.error(f"[실패] {self.task_name} - {exc_type.__name__}: {exc_val}")
|
||||
else:
|
||||
self.logger.log(self.level, f"[완료] {self.task_name} (소요시간: {elapsed:.2f}초)")
|
||||
|
||||
return False # 예외 전파
|
||||
331
core/message_sender.py
Normal file
331
core/message_sender.py
Normal file
@ -0,0 +1,331 @@
|
||||
# ===================================================================
|
||||
# core/message_sender.py
|
||||
# FGTools 다중 플랫폼 메시지 발송 모듈
|
||||
# ===================================================================
|
||||
# Mattermost, Telegram, Synology Chat 등 다양한 플랫폼으로
|
||||
# 메시지를 발송하는 통합 클래스를 제공합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
다중 플랫폼 메시지 발송 모듈
|
||||
|
||||
Mattermost, Telegram, Synology Chat 등 다양한 메시징 플랫폼으로
|
||||
메시지를 발송하는 기능을 제공합니다.
|
||||
|
||||
사용 예시:
|
||||
from core.message_sender import MessageSender, send_notification
|
||||
|
||||
# 클래스 직접 사용
|
||||
sender = MessageSender.from_config()
|
||||
sender.send("알림 메시지", platforms=['mattermost', 'telegram'])
|
||||
|
||||
# 간편 함수 사용
|
||||
send_notification("알림 메시지", platforms=['mattermost'])
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from .config import get_config
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MessageSender:
|
||||
"""
|
||||
다중 플랫폼 메시지 발송 클래스
|
||||
|
||||
Mattermost, Telegram, Synology Chat 등 여러 플랫폼으로
|
||||
동시에 메시지를 발송할 수 있습니다.
|
||||
|
||||
Attributes:
|
||||
mattermost_url: Mattermost 서버 URL
|
||||
mattermost_token: Mattermost Bot 토큰
|
||||
mattermost_channel_id: Mattermost 채널 ID
|
||||
mattermost_webhook_url: Mattermost 웹훅 URL (선택)
|
||||
telegram_bot_token: Telegram Bot 토큰
|
||||
telegram_chat_id: Telegram 채팅 ID
|
||||
synology_webhook_url: Synology Chat 웹훅 URL
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mattermost_url: str = "",
|
||||
mattermost_token: str = "",
|
||||
mattermost_channel_id: str = "",
|
||||
mattermost_webhook_url: str = "",
|
||||
telegram_bot_token: str = "",
|
||||
telegram_chat_id: str = "",
|
||||
synology_webhook_url: str = ""
|
||||
):
|
||||
"""
|
||||
메시지 발송자 초기화
|
||||
|
||||
Args:
|
||||
mattermost_url: Mattermost 서버 URL (예: https://mattermost.example.com)
|
||||
mattermost_token: Mattermost Bot 인증 토큰
|
||||
mattermost_channel_id: 메시지를 보낼 채널 ID
|
||||
mattermost_webhook_url: 웹훅 URL (웹훅 방식 사용 시)
|
||||
telegram_bot_token: Telegram Bot API 토큰
|
||||
telegram_chat_id: Telegram 채팅방 ID
|
||||
synology_webhook_url: Synology Chat 웹훅 URL
|
||||
"""
|
||||
# Mattermost 설정
|
||||
self.mattermost_url = mattermost_url.rstrip('/') if mattermost_url else ""
|
||||
self.mattermost_token = mattermost_token
|
||||
self.mattermost_channel_id = mattermost_channel_id
|
||||
self.mattermost_webhook_url = mattermost_webhook_url
|
||||
|
||||
# Telegram 설정
|
||||
self.telegram_bot_token = telegram_bot_token
|
||||
self.telegram_chat_id = telegram_chat_id
|
||||
|
||||
# Synology Chat 설정
|
||||
self.synology_webhook_url = synology_webhook_url
|
||||
|
||||
@classmethod
|
||||
def from_config(cls) -> 'MessageSender':
|
||||
"""
|
||||
설정 파일에서 메시지 발송자 생성
|
||||
|
||||
환경변수에서 모든 메시징 설정을 로드하여 인스턴스를 생성합니다.
|
||||
|
||||
Returns:
|
||||
설정이 적용된 MessageSender 인스턴스
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
return cls(
|
||||
mattermost_url=config.mattermost.get('url', ''),
|
||||
mattermost_token=config.mattermost.get('token', ''),
|
||||
mattermost_channel_id=config.mattermost.get('channel_id', ''),
|
||||
mattermost_webhook_url=config.mattermost.get('webhook_url', ''),
|
||||
telegram_bot_token=config.telegram.get('bot_token', ''),
|
||||
telegram_chat_id=config.telegram.get('chat_id', ''),
|
||||
synology_webhook_url=config.synology.get('chat_url', ''),
|
||||
)
|
||||
|
||||
def send(
|
||||
self,
|
||||
message: str,
|
||||
platforms: Optional[Union[str, List[str]]] = None,
|
||||
use_webhook: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
지정된 플랫폼으로 메시지 발송
|
||||
|
||||
여러 플랫폼에 동시에 메시지를 보낼 수 있습니다.
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지 내용
|
||||
platforms: 발송할 플랫폼 목록 (['mattermost', 'telegram', 'synology'])
|
||||
None이면 모든 설정된 플랫폼으로 발송
|
||||
use_webhook: Mattermost 웹훅 사용 여부 (API 대신)
|
||||
|
||||
Returns:
|
||||
모든 발송 성공 시 True, 하나라도 실패 시 False
|
||||
"""
|
||||
# 플랫폼이 지정되지 않으면 설정된 모든 플랫폼으로 발송
|
||||
if platforms is None:
|
||||
platforms = self._get_configured_platforms()
|
||||
|
||||
if not platforms:
|
||||
logger.warning("전송할 플랫폼이 지정되지 않았습니다.")
|
||||
return False
|
||||
|
||||
# 문자열이면 리스트로 변환
|
||||
if isinstance(platforms, str):
|
||||
platforms = [platforms]
|
||||
|
||||
success = True
|
||||
for platform in platforms:
|
||||
platform_lower = platform.lower()
|
||||
|
||||
if platform_lower == "mattermost":
|
||||
result = self._send_to_mattermost(message, use_webhook)
|
||||
elif platform_lower == "telegram":
|
||||
result = self._send_to_telegram(message)
|
||||
elif platform_lower == "synology":
|
||||
result = self._send_to_synology(message)
|
||||
else:
|
||||
logger.error(f"지원하지 않는 플랫폼: {platform}")
|
||||
result = False
|
||||
|
||||
if not result:
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
def _get_configured_platforms(self) -> List[str]:
|
||||
"""설정된 플랫폼 목록 반환"""
|
||||
platforms = []
|
||||
|
||||
if self.mattermost_url and (self.mattermost_token or self.mattermost_webhook_url):
|
||||
platforms.append('mattermost')
|
||||
if self.telegram_bot_token and self.telegram_chat_id:
|
||||
platforms.append('telegram')
|
||||
if self.synology_webhook_url:
|
||||
platforms.append('synology')
|
||||
|
||||
return platforms
|
||||
|
||||
def _send_to_mattermost(self, message: str, use_webhook: bool = False) -> bool:
|
||||
"""
|
||||
Mattermost로 메시지 발송
|
||||
|
||||
웹훅 또는 API 방식으로 메시지를 발송합니다.
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
use_webhook: 웹훅 사용 여부 (False면 API 사용)
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
try:
|
||||
if use_webhook and self.mattermost_webhook_url:
|
||||
# 웹훅 방식
|
||||
response = requests.post(
|
||||
self.mattermost_webhook_url,
|
||||
json={"text": message},
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
else:
|
||||
# API 방식
|
||||
if not self._validate_mattermost_config():
|
||||
return False
|
||||
|
||||
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, timeout=10)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info("Mattermost 메시지 전송 완료")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Mattermost 전송 실패: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("Mattermost 전송 타임아웃")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Mattermost 전송 예외: {e}")
|
||||
return False
|
||||
|
||||
def _validate_mattermost_config(self) -> bool:
|
||||
"""Mattermost API 설정 검증"""
|
||||
if not self.mattermost_url or not self.mattermost_url.startswith(('http://', 'https://')):
|
||||
logger.error(f"Mattermost URL이 유효하지 않습니다: {self.mattermost_url}")
|
||||
return False
|
||||
if not self.mattermost_token:
|
||||
logger.error("Mattermost 토큰이 설정되지 않았습니다.")
|
||||
return False
|
||||
if not self.mattermost_channel_id:
|
||||
logger.error("Mattermost 채널 ID가 설정되지 않았습니다.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _send_to_telegram(self, message: str) -> bool:
|
||||
"""
|
||||
Telegram으로 메시지 발송
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
if not self.telegram_bot_token or not self.telegram_chat_id:
|
||||
logger.error("Telegram 설정이 완료되지 않았습니다.")
|
||||
return False
|
||||
|
||||
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, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Telegram 메시지 전송 완료")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram 전송 실패: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Telegram 전송 예외: {e}")
|
||||
return False
|
||||
|
||||
def _send_to_synology(self, message: str) -> bool:
|
||||
"""
|
||||
Synology Chat으로 메시지 발송
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
if not self.synology_webhook_url:
|
||||
logger.error("Synology Chat 웹훅 URL이 설정되지 않았습니다.")
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = {"text": message}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
response = requests.post(
|
||||
self.synology_webhook_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Synology Chat 메시지 전송 완료")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Synology Chat 전송 실패: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Synology Chat 전송 예외: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_notification(
|
||||
message: str,
|
||||
platforms: Optional[Union[str, List[str]]] = None,
|
||||
use_webhook: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
알림 메시지 발송 간편 함수
|
||||
|
||||
설정 파일에서 자동으로 설정을 로드하여 메시지를 발송합니다.
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
platforms: 발송할 플랫폼 목록 (None이면 모든 설정된 플랫폼)
|
||||
use_webhook: Mattermost 웹훅 사용 여부
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
|
||||
사용 예시:
|
||||
send_notification("서버 점검 알림", platforms=['mattermost'])
|
||||
"""
|
||||
sender = MessageSender.from_config()
|
||||
return sender.send(message, platforms=platforms, use_webhook=use_webhook)
|
||||
62
requirements.txt
Normal file
62
requirements.txt
Normal file
@ -0,0 +1,62 @@
|
||||
# ===================================================================
|
||||
# requirements.txt
|
||||
# FGTools 통합 프로젝트 의존성
|
||||
# ===================================================================
|
||||
# 모든 서비스에서 사용하는 패키지들의 통합 목록입니다.
|
||||
# pip install -r requirements.txt
|
||||
# ===================================================================
|
||||
|
||||
# ===== Web Framework =====
|
||||
flask>=3.0.0
|
||||
|
||||
# ===== Database =====
|
||||
sqlalchemy>=2.0.23
|
||||
pymysql>=1.1.0
|
||||
|
||||
# ===== HTTP & API =====
|
||||
requests>=2.31.0
|
||||
urllib3>=1.26.0
|
||||
|
||||
# ===== Data Processing =====
|
||||
pandas>=2.1.3
|
||||
openpyxl>=3.1.2
|
||||
xlrd>=2.0.1
|
||||
|
||||
# ===== Configuration =====
|
||||
pyyaml>=6.0.1
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# ===== Date & Time =====
|
||||
pytz>=2023.3
|
||||
python-dateutil>=2.8.2
|
||||
|
||||
# ===== Google Analytics =====
|
||||
google-analytics-data>=0.18.5
|
||||
|
||||
# ===== Forecasting (Optional) =====
|
||||
# 방문객 예측 기능 사용 시 설치
|
||||
# prophet>=1.1.5
|
||||
# statsmodels>=0.14.0
|
||||
# scikit-learn>=1.3.2
|
||||
|
||||
# ===== Visualization (Optional) =====
|
||||
# 데이터 시각화 사용 시 설치
|
||||
# matplotlib>=3.8.2
|
||||
|
||||
# ===== Web Automation (Optional) =====
|
||||
# 날씨 캡처 기능 사용 시 설치
|
||||
# selenium>=4.0.0
|
||||
# pillow>=10.0.0
|
||||
|
||||
# ===== FTP (Optional) =====
|
||||
# 파일 업로드 기능 사용 시 설치
|
||||
# ftputil>=5.0.0
|
||||
|
||||
# ===== GUI (Optional) =====
|
||||
# GUI 도구 사용 시 설치
|
||||
# customtkinter>=5.2.0
|
||||
# tkcalendar>=1.6.1
|
||||
|
||||
# ===== Utilities =====
|
||||
tabulate>=0.9.0
|
||||
watchdog>=3.0.0
|
||||
17
services/__init__.py
Normal file
17
services/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
# ===================================================================
|
||||
# services/__init__.py
|
||||
# FGTools 서비스 패키지 초기화
|
||||
# ===================================================================
|
||||
# 도메인별 서비스 모듈들을 제공합니다:
|
||||
# - weather: 기상 데이터 서비스
|
||||
# - pos: POS 데이터 서비스
|
||||
# - analytics: 분석 서비스 (GA4, 대기질, 예측)
|
||||
# - notification: 알림 서비스 (Notion, Mattermost)
|
||||
# ===================================================================
|
||||
|
||||
__all__ = [
|
||||
'weather',
|
||||
'pos',
|
||||
'analytics',
|
||||
'notification',
|
||||
]
|
||||
18
services/analytics/__init__.py
Normal file
18
services/analytics/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
# ===================================================================
|
||||
# services/analytics/__init__.py
|
||||
# 분석 서비스 패키지 초기화
|
||||
# ===================================================================
|
||||
# GA4, 대기질, 방문객 예측 등 분석 관련 서비스를 제공합니다.
|
||||
# ===================================================================
|
||||
|
||||
from .ga4 import GA4Client, GA4DataCollector
|
||||
from .air_quality import AirQualityCollector, get_air_quality
|
||||
from .visitor_forecast import VisitorForecaster
|
||||
|
||||
__all__ = [
|
||||
'GA4Client',
|
||||
'GA4DataCollector',
|
||||
'AirQualityCollector',
|
||||
'get_air_quality',
|
||||
'VisitorForecaster',
|
||||
]
|
||||
426
services/analytics/air_quality.py
Normal file
426
services/analytics/air_quality.py
Normal file
@ -0,0 +1,426 @@
|
||||
# ===================================================================
|
||||
# services/analytics/air_quality.py
|
||||
# 대기질 데이터 수집 서비스 모듈
|
||||
# ===================================================================
|
||||
# 한국환경공단 API를 통해 대기질(미세먼지) 데이터를 수집합니다.
|
||||
# 측정소별 PM2.5, PM10, SO2, CO, NO2, O3 데이터를 저장합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
대기질 데이터 수집 서비스 모듈
|
||||
|
||||
한국환경공단 공공데이터 API를 통해 대기질 데이터를 수집합니다.
|
||||
측정소별 일평균 대기오염물질 농도를 조회할 수 있습니다.
|
||||
|
||||
사용 예시:
|
||||
from services.analytics.air_quality import AirQualityCollector, get_air_quality
|
||||
|
||||
# 간단한 데이터 조회
|
||||
data = get_air_quality(service_key, '운정', '20240101', '20240131')
|
||||
|
||||
# 자동 데이터 수집 및 저장
|
||||
collector = AirQualityCollector(config, engine, table)
|
||||
collector.run()
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from sqlalchemy import select, func, and_, Table
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.engine import Engine, Connection
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.http_client import create_retry_session
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# API URL
|
||||
AIR_QUALITY_API_URL = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
|
||||
|
||||
|
||||
def get_air_quality(
|
||||
service_key: str,
|
||||
station_name: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
num_of_rows: int = 100,
|
||||
page_no: int = 1
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
대기질 데이터 조회
|
||||
|
||||
한국환경공단 API를 호출하여 측정소별 대기질 데이터를 조회합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
station_name: 측정소명 (예: '운정', '서울')
|
||||
start_date: 시작 날짜 (YYYYMMDD)
|
||||
end_date: 종료 날짜 (YYYYMMDD)
|
||||
num_of_rows: 페이지당 결과 수
|
||||
page_no: 페이지 번호
|
||||
|
||||
Returns:
|
||||
대기질 데이터 리스트
|
||||
|
||||
데이터 항목:
|
||||
- msurDt: 측정일 (YYYY-MM-DD)
|
||||
- pm25Value: 초미세먼지 농도 (㎍/㎥)
|
||||
- pm10Value: 미세먼지 농도 (㎍/㎥)
|
||||
- so2Value: 아황산가스 농도 (ppm)
|
||||
- coValue: 일산화탄소 농도 (ppm)
|
||||
- no2Value: 이산화질소 농도 (ppm)
|
||||
- o3Value: 오존 농도 (ppm)
|
||||
"""
|
||||
params = {
|
||||
'serviceKey': service_key,
|
||||
'returnType': 'json',
|
||||
'numOfRows': str(num_of_rows),
|
||||
'pageNo': str(page_no),
|
||||
'inqBginDt': start_date,
|
||||
'inqEndDt': end_date,
|
||||
'msrstnName': station_name,
|
||||
}
|
||||
|
||||
session = create_retry_session(retries=3)
|
||||
|
||||
try:
|
||||
response = session.get(AIR_QUALITY_API_URL, params=params, timeout=20)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
items = data.get('response', {}).get('body', {}).get('items', [])
|
||||
logger.debug(f"대기질 데이터 조회: {station_name}, {len(items)}건")
|
||||
return items if items else []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"대기질 API 요청 실패: {e}")
|
||||
traceback.print_exc()
|
||||
return []
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class AirQualityCollector:
|
||||
"""
|
||||
대기질 데이터 자동 수집기
|
||||
|
||||
설정에 따라 대기질 데이터를 자동으로 수집하고 DB에 저장합니다.
|
||||
|
||||
Attributes:
|
||||
api_key: API 서비스 키
|
||||
station_list: 측정소 목록
|
||||
engine: SQLAlchemy 엔진
|
||||
table: 대상 테이블
|
||||
start_date: 수집 시작일
|
||||
force_update: 강제 업데이트 여부
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
|
||||
# 캐시 파일 경로
|
||||
CACHE_FILE = 'cache/air_num_rows.json'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
table: Table,
|
||||
api_key: Optional[str] = None,
|
||||
station_list: Optional[List[str]] = None,
|
||||
start_date: Optional[str] = None,
|
||||
force_update: bool = False,
|
||||
debug: bool = False
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
engine: SQLAlchemy 엔진
|
||||
table: 대상 테이블
|
||||
api_key: API 키 (None이면 설정에서 로드)
|
||||
station_list: 측정소 목록 (None이면 설정에서 로드)
|
||||
start_date: 수집 시작일 (YYYYMMDD)
|
||||
force_update: 기존 데이터 덮어쓰기 여부
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
self.api_key = api_key or config.data_api.get('service_key', '')
|
||||
self.station_list = station_list or config.data_api.get('air_stations', ['운정'])
|
||||
|
||||
if start_date:
|
||||
self.start_date = datetime.strptime(start_date, '%Y%m%d').date()
|
||||
else:
|
||||
self.start_date = datetime.strptime(
|
||||
config.data_api.get('start_date', '20170101'), '%Y%m%d'
|
||||
).date()
|
||||
|
||||
self.engine = engine
|
||||
self.table = table
|
||||
self.force_update = force_update
|
||||
self.debug = debug
|
||||
|
||||
self.yesterday = (datetime.now() - timedelta(days=1)).date()
|
||||
self.session = create_retry_session(retries=3)
|
||||
|
||||
# 캐시 로드
|
||||
self._num_rows_cache = self._load_cache()
|
||||
|
||||
def _load_cache(self) -> Dict:
|
||||
"""캐시 파일 로드"""
|
||||
try:
|
||||
cache_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
self.CACHE_FILE
|
||||
)
|
||||
if os.path.exists(cache_path):
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_cache(self):
|
||||
"""캐시 파일 저장"""
|
||||
try:
|
||||
cache_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
self.CACHE_FILE
|
||||
)
|
||||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._num_rows_cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 저장 실패: {e}")
|
||||
|
||||
def get_latest_date(self, conn: Connection, station: str) -> Optional[date]:
|
||||
"""
|
||||
특정 측정소의 가장 최근 저장 날짜 조회
|
||||
|
||||
Args:
|
||||
conn: DB 연결
|
||||
station: 측정소명
|
||||
|
||||
Returns:
|
||||
최근 날짜 또는 None
|
||||
"""
|
||||
try:
|
||||
stmt = select(func.max(self.table.c.date)).where(
|
||||
self.table.c.station == station
|
||||
)
|
||||
result = conn.execute(stmt).scalar()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"최근 날짜 조회 실패: {e}")
|
||||
return None
|
||||
|
||||
def parse_item_to_record(self, item: Dict, station: str) -> Optional[Dict]:
|
||||
"""
|
||||
API 응답 아이템을 DB 레코드로 변환
|
||||
|
||||
Args:
|
||||
item: API 응답 아이템
|
||||
station: 측정소명
|
||||
|
||||
Returns:
|
||||
DB 레코드 딕셔너리 또는 None
|
||||
"""
|
||||
try:
|
||||
item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date()
|
||||
except Exception as e:
|
||||
logger.warning(f"날짜 파싱 오류: {item.get('msurDt')} - {e}")
|
||||
return None
|
||||
|
||||
def safe_float(val):
|
||||
"""안전한 float 변환"""
|
||||
try:
|
||||
return float(val) if val else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return {
|
||||
'date': item_date,
|
||||
'station': station,
|
||||
'pm25': safe_float(item.get('pm25Value')),
|
||||
'pm10': safe_float(item.get('pm10Value')),
|
||||
'so2': safe_float(item.get('so2Value')),
|
||||
'co': safe_float(item.get('coValue')),
|
||||
'no2': safe_float(item.get('no2Value')),
|
||||
'o3': safe_float(item.get('o3Value')),
|
||||
}
|
||||
|
||||
def save_items_to_db(
|
||||
self,
|
||||
items: List[Dict],
|
||||
conn: Connection,
|
||||
station: str
|
||||
) -> int:
|
||||
"""
|
||||
데이터 항목들을 DB에 저장
|
||||
|
||||
Args:
|
||||
items: 저장할 데이터 리스트
|
||||
conn: DB 연결
|
||||
station: 측정소명
|
||||
|
||||
Returns:
|
||||
저장된 레코드 수
|
||||
"""
|
||||
saved_count = 0
|
||||
|
||||
for item in items:
|
||||
data = self.parse_item_to_record(item, station)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
item_date = data['date']
|
||||
|
||||
if self.debug:
|
||||
logger.debug(f"[DEBUG] {item_date} [{station}] 저장 시도: {data}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if self.force_update:
|
||||
# UPSERT
|
||||
stmt = mysql_insert(self.table).values(**data)
|
||||
stmt = stmt.on_duplicate_key_update(**data)
|
||||
conn.execute(stmt)
|
||||
logger.info(f"{item_date} [{station}] 저장/업데이트 완료")
|
||||
else:
|
||||
# 중복 확인 후 삽입
|
||||
sel = select(self.table.c.date).where(
|
||||
and_(
|
||||
self.table.c.date == item_date,
|
||||
self.table.c.station == station
|
||||
)
|
||||
)
|
||||
if conn.execute(sel).fetchone():
|
||||
logger.debug(f"{item_date} [{station}] 이미 존재, 생략")
|
||||
continue
|
||||
|
||||
conn.execute(self.table.insert().values(**data))
|
||||
logger.info(f"{item_date} [{station}] 저장 완료")
|
||||
|
||||
saved_count += 1
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(f"중복 오류: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"저장 실패: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
return saved_count
|
||||
|
||||
def find_optimal_num_rows(self, station_name: str, date_str: str) -> int:
|
||||
"""
|
||||
최적의 numOfRows 파라미터 값 탐색
|
||||
|
||||
API 서버 상태에 따라 최대 허용 rows 수가 다를 수 있어
|
||||
적절한 값을 탐색합니다.
|
||||
|
||||
Args:
|
||||
station_name: 측정소명
|
||||
date_str: 날짜 (YYYYMMDD)
|
||||
|
||||
Returns:
|
||||
최적의 numOfRows 값 (100~1000)
|
||||
"""
|
||||
# 캐시 확인
|
||||
cache_key = f"{station_name}:{date_str}"
|
||||
if cache_key in self._num_rows_cache:
|
||||
cached_val = int(self._num_rows_cache[cache_key])
|
||||
logger.debug(f"캐시된 numOfRows 사용: {cached_val}")
|
||||
return cached_val
|
||||
|
||||
# 점진적으로 감소하며 테스트
|
||||
max_rows = 1000
|
||||
min_rows = 100
|
||||
|
||||
while max_rows >= min_rows:
|
||||
try:
|
||||
params = {
|
||||
'serviceKey': self.api_key,
|
||||
'returnType': 'json',
|
||||
'numOfRows': str(max_rows),
|
||||
'pageNo': '1',
|
||||
'inqBginDt': date_str,
|
||||
'inqEndDt': date_str,
|
||||
'msrstnName': station_name,
|
||||
}
|
||||
response = self.session.get(AIR_QUALITY_API_URL, params=params, timeout=20)
|
||||
response.raise_for_status()
|
||||
response.json() # JSON 파싱 테스트
|
||||
|
||||
# 성공 - 캐시에 저장
|
||||
self._num_rows_cache[cache_key] = max_rows
|
||||
self._save_cache()
|
||||
|
||||
logger.debug(f"numOfRows 최대값: {max_rows}")
|
||||
return max_rows
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"numOfRows={max_rows} 실패: {e}, 재시도...")
|
||||
max_rows -= 100
|
||||
|
||||
logger.warning("기본값 100 사용")
|
||||
return 100
|
||||
|
||||
def run(self) -> int:
|
||||
"""
|
||||
데이터 수집 및 저장 실행
|
||||
|
||||
모든 측정소에 대해 데이터를 수집하고 DB에 저장합니다.
|
||||
|
||||
Returns:
|
||||
총 저장된 레코드 수
|
||||
"""
|
||||
total_saved = 0
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
for station_name in self.station_list:
|
||||
logger.info(f"측정소 처리 시작: {station_name}")
|
||||
|
||||
# 시작일 결정
|
||||
latest_date = self.get_latest_date(conn, station_name)
|
||||
if latest_date:
|
||||
start_date = latest_date + timedelta(days=1)
|
||||
else:
|
||||
start_date = self.start_date
|
||||
|
||||
if start_date > self.yesterday:
|
||||
logger.info(f"{station_name}: 최신 데이터 존재 ({latest_date})")
|
||||
continue
|
||||
|
||||
# 최적 numOfRows 탐색
|
||||
optimal_rows = self.find_optimal_num_rows(
|
||||
station_name,
|
||||
start_date.strftime('%Y%m%d')
|
||||
)
|
||||
|
||||
# 청크 단위로 데이터 수집
|
||||
current_start = start_date
|
||||
while current_start <= self.yesterday:
|
||||
current_end = min(
|
||||
current_start + timedelta(days=optimal_rows - 1),
|
||||
self.yesterday
|
||||
)
|
||||
|
||||
logger.info(f"{station_name}: {current_start} ~ {current_end} 수집")
|
||||
|
||||
items = get_air_quality(
|
||||
self.api_key,
|
||||
station_name,
|
||||
current_start.strftime('%Y%m%d'),
|
||||
current_end.strftime('%Y%m%d'),
|
||||
num_of_rows=optimal_rows
|
||||
)
|
||||
|
||||
if items:
|
||||
saved = self.save_items_to_db(items, conn, station_name)
|
||||
total_saved += saved
|
||||
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
logger.info(f"대기질 데이터 수집 완료: 총 {total_saved}건 저장")
|
||||
return total_saved
|
||||
401
services/analytics/ga4.py
Normal file
401
services/analytics/ga4.py
Normal file
@ -0,0 +1,401 @@
|
||||
# ===================================================================
|
||||
# services/analytics/ga4.py
|
||||
# Google Analytics 4 데이터 수집 서비스 모듈
|
||||
# ===================================================================
|
||||
# GA4 API를 통해 웹사이트 방문자 데이터를 수집하고 DB에 저장합니다.
|
||||
# 병렬 처리를 통해 대량 데이터 수집 성능을 최적화합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
Google Analytics 4 데이터 수집 서비스 모듈
|
||||
|
||||
GA4 Data API를 사용하여 웹사이트 분석 데이터를 수집합니다.
|
||||
일별 세션, 사용자, 이벤트 등 다양한 메트릭을 조회할 수 있습니다.
|
||||
|
||||
사용 예시:
|
||||
from services.analytics.ga4 import GA4Client, GA4DataCollector
|
||||
|
||||
# 간단한 데이터 조회
|
||||
client = GA4Client(property_id, service_account_file)
|
||||
data = client.get_daily_sessions('2024-01-01', '2024-01-31')
|
||||
|
||||
# 자동 데이터 수집 및 저장
|
||||
collector = GA4DataCollector(config)
|
||||
collector.collect_and_save()
|
||||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from dateutil.parser import parse as parse_date
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import select, func, Table
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# GA4 라이브러리 임포트 (설치 필요)
|
||||
try:
|
||||
from google.analytics.data import BetaAnalyticsDataClient
|
||||
from google.analytics.data_v1beta.types import (
|
||||
DateRange, Dimension, Metric, RunReportRequest
|
||||
)
|
||||
GA4_AVAILABLE = True
|
||||
except ImportError:
|
||||
GA4_AVAILABLE = False
|
||||
logger.warning("google-analytics-data 패키지가 설치되지 않았습니다.")
|
||||
|
||||
|
||||
class GA4Client:
|
||||
"""
|
||||
Google Analytics 4 API 클라이언트
|
||||
|
||||
GA4 Data API를 통해 리포트 데이터를 조회합니다.
|
||||
|
||||
Attributes:
|
||||
property_id: GA4 속성 ID
|
||||
client: BetaAnalyticsDataClient 인스턴스
|
||||
max_rows: API 요청당 최대 행 수
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
property_id: int,
|
||||
service_account_file: Optional[str] = None,
|
||||
max_rows: int = 10000
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
property_id: GA4 속성 ID
|
||||
service_account_file: 서비스 계정 JSON 파일 경로
|
||||
max_rows: 요청당 최대 행 수
|
||||
|
||||
Raises:
|
||||
ImportError: google-analytics-data 패키지 미설치 시
|
||||
Exception: 인증 실패 시
|
||||
"""
|
||||
if not GA4_AVAILABLE:
|
||||
raise ImportError(
|
||||
"GA4 기능을 사용하려면 google-analytics-data 패키지를 설치하세요: "
|
||||
"pip install google-analytics-data"
|
||||
)
|
||||
|
||||
self.property_id = property_id
|
||||
self.max_rows = max_rows
|
||||
|
||||
# 서비스 계정 인증 설정
|
||||
if service_account_file:
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file
|
||||
logger.info(f"GA4 클라이언트 초기화 - 인증파일: {service_account_file}")
|
||||
|
||||
try:
|
||||
self.client = BetaAnalyticsDataClient()
|
||||
logger.info("GA4 클라이언트 초기화 완료")
|
||||
except Exception as e:
|
||||
logger.error(f"GA4 클라이언트 초기화 실패: {e}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
def run_report(
|
||||
self,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
dimensions: List[str],
|
||||
metrics: List[str],
|
||||
limit: Optional[int] = None
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
GA4 리포트 실행
|
||||
|
||||
Args:
|
||||
start_date: 시작 날짜 (YYYY-MM-DD)
|
||||
end_date: 종료 날짜 (YYYY-MM-DD)
|
||||
dimensions: 차원 목록 (예: ['date', 'city'])
|
||||
metrics: 메트릭 목록 (예: ['sessions', 'activeUsers'])
|
||||
limit: 결과 제한 (None이면 max_rows 사용)
|
||||
|
||||
Returns:
|
||||
RunReportResponse 또는 None (실패 시)
|
||||
"""
|
||||
if limit is None:
|
||||
limit = self.max_rows
|
||||
|
||||
logger.debug(f"GA4 리포트 요청: {start_date} ~ {end_date}, dims={dimensions}, metrics={metrics}")
|
||||
|
||||
try:
|
||||
request = RunReportRequest(
|
||||
property=f"properties/{self.property_id}",
|
||||
dimensions=[Dimension(name=d) for d in dimensions],
|
||||
metrics=[Metric(name=m) for m in metrics],
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
limit=limit,
|
||||
)
|
||||
response = self.client.run_report(request)
|
||||
logger.info(f"GA4 리포트 응답: {len(response.rows)}건")
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GA4 리포트 요청 실패: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def get_daily_sessions(
|
||||
self,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
일별 세션 데이터 조회
|
||||
|
||||
Args:
|
||||
start_date: 시작 날짜 (YYYY-MM-DD)
|
||||
end_date: 종료 날짜 (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
일별 세션 데이터 리스트
|
||||
[{'date': date, 'sessions': int, 'activeUsers': int}, ...]
|
||||
"""
|
||||
response = self.run_report(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
dimensions=['date'],
|
||||
metrics=['sessions', 'activeUsers']
|
||||
)
|
||||
|
||||
if response is None:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for row in response.rows:
|
||||
date_str = row.dimension_values[0].value
|
||||
result.append({
|
||||
'date': datetime.strptime(date_str, "%Y%m%d").date(),
|
||||
'sessions': int(row.metric_values[0].value),
|
||||
'activeUsers': int(row.metric_values[1].value)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def detect_max_rows(self) -> int:
|
||||
"""
|
||||
API에서 지원하는 최대 행 수 감지
|
||||
|
||||
Returns:
|
||||
최대 행 수 (감지 실패 시 기본값 10000)
|
||||
"""
|
||||
try:
|
||||
request = RunReportRequest(
|
||||
property=f"properties/{self.property_id}",
|
||||
dimensions=[Dimension(name="date")],
|
||||
metrics=[Metric(name="sessions")],
|
||||
date_ranges=[DateRange(start_date="2024-01-01", end_date="2024-12-31")],
|
||||
limit=100000
|
||||
)
|
||||
response = self.client.run_report(request)
|
||||
n_rows = len(response.rows)
|
||||
logger.info(f"최대 행 수 감지: {n_rows}")
|
||||
return n_rows
|
||||
except Exception as e:
|
||||
logger.warning(f"최대 행 수 감지 실패: {e}")
|
||||
return 10000
|
||||
|
||||
|
||||
class GA4DataCollector:
|
||||
"""
|
||||
GA4 데이터 자동 수집기
|
||||
|
||||
설정에 따라 GA4 데이터를 자동으로 수집하고 DB에 저장합니다.
|
||||
|
||||
Attributes:
|
||||
client: GA4Client 인스턴스
|
||||
engine: SQLAlchemy 엔진
|
||||
table: 대상 테이블
|
||||
force_update: 강제 업데이트 여부
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine,
|
||||
table: Table,
|
||||
property_id: Optional[int] = None,
|
||||
service_account_file: Optional[str] = None,
|
||||
force_update: bool = False,
|
||||
debug: bool = False
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
engine: SQLAlchemy 엔진
|
||||
table: 대상 테이블
|
||||
property_id: GA4 속성 ID (None이면 설정에서 로드)
|
||||
service_account_file: 서비스 계정 파일 (None이면 설정에서 로드)
|
||||
force_update: 기존 데이터 덮어쓰기 여부
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
if property_id is None:
|
||||
property_id = config.ga4.get('property_id')
|
||||
if service_account_file is None:
|
||||
service_account_file = config.ga4.get('service_account_file')
|
||||
|
||||
self.client = GA4Client(property_id, service_account_file)
|
||||
self.engine = engine
|
||||
self.table = table
|
||||
self.force_update = force_update
|
||||
self.debug = debug
|
||||
|
||||
# 설정에서 날짜 범위 로드
|
||||
self.config_start_date = datetime.strptime(
|
||||
config.ga4.get('start_date', '20170101'), '%Y%m%d'
|
||||
).date()
|
||||
self.config_end_date = datetime.strptime(
|
||||
config.ga4.get('end_date', '20991231'), '%Y%m%d'
|
||||
).date()
|
||||
|
||||
def get_latest_date_from_db(self) -> Optional[date]:
|
||||
"""DB에서 가장 최근 저장된 날짜 조회"""
|
||||
with self.engine.connect() as conn:
|
||||
stmt = select(func.max(self.table.c.date))
|
||||
result = conn.execute(stmt).scalar()
|
||||
logger.info(f"DB 기준 마지막 저장 날짜: {result}")
|
||||
return result
|
||||
|
||||
def determine_date_range(self) -> Tuple[date, date]:
|
||||
"""
|
||||
수집할 날짜 범위 결정
|
||||
|
||||
Returns:
|
||||
(시작일, 종료일) 튜플
|
||||
"""
|
||||
yesterday = datetime.now().date() - timedelta(days=1)
|
||||
actual_end = min(yesterday, self.config_end_date)
|
||||
|
||||
if self.force_update:
|
||||
actual_start = self.config_start_date
|
||||
else:
|
||||
latest_db_date = self.get_latest_date_from_db()
|
||||
if latest_db_date is not None:
|
||||
actual_start = latest_db_date + timedelta(days=1)
|
||||
else:
|
||||
actual_start = self.config_start_date
|
||||
|
||||
return actual_start, actual_end
|
||||
|
||||
def save_response_to_db(
|
||||
self,
|
||||
response,
|
||||
dimension_names: List[str],
|
||||
metric_names: List[str]
|
||||
) -> int:
|
||||
"""
|
||||
GA4 응답 데이터를 DB에 저장
|
||||
|
||||
Args:
|
||||
response: GA4 RunReportResponse
|
||||
dimension_names: 차원 이름 목록
|
||||
metric_names: 메트릭 이름 목록
|
||||
|
||||
Returns:
|
||||
저장된 레코드 수
|
||||
"""
|
||||
if response is None:
|
||||
return 0
|
||||
|
||||
saved_count = 0
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
for row in response.rows:
|
||||
data = {}
|
||||
|
||||
# 차원 처리
|
||||
for i, dim_name in enumerate(dimension_names):
|
||||
try:
|
||||
val = row.dimension_values[i].value
|
||||
if dim_name == "date":
|
||||
if len(val) == 8:
|
||||
val = datetime.strptime(val, "%Y%m%d").date()
|
||||
else:
|
||||
val = parse_date(val).date()
|
||||
data[dim_name] = val
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.warning(f"차원 처리 오류 ({dim_name}): {e}")
|
||||
|
||||
# 메트릭 처리
|
||||
for i, met_name in enumerate(metric_names):
|
||||
try:
|
||||
data[met_name] = int(row.metric_values[i].value)
|
||||
except (IndexError, ValueError):
|
||||
data[met_name] = None
|
||||
|
||||
# DB 저장
|
||||
if self.debug:
|
||||
logger.debug(f"[DEBUG] 저장할 데이터: {data}")
|
||||
continue
|
||||
|
||||
try:
|
||||
stmt = mysql_insert(self.table).values(**data)
|
||||
stmt = stmt.on_duplicate_key_update(**data)
|
||||
conn.execute(stmt)
|
||||
saved_count += 1
|
||||
except IntegrityError as e:
|
||||
logger.error(f"중복 오류: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"저장 실패: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
return saved_count
|
||||
|
||||
def collect_and_save(
|
||||
self,
|
||||
dimensions: List[str] = ['date'],
|
||||
metrics: List[str] = ['sessions', 'activeUsers'],
|
||||
chunk_days: int = 30
|
||||
) -> int:
|
||||
"""
|
||||
데이터 수집 및 저장 실행
|
||||
|
||||
Args:
|
||||
dimensions: 수집할 차원 목록
|
||||
metrics: 수집할 메트릭 목록
|
||||
chunk_days: 청크 크기 (일)
|
||||
|
||||
Returns:
|
||||
총 저장된 레코드 수
|
||||
"""
|
||||
start_date, end_date = self.determine_date_range()
|
||||
|
||||
if start_date > end_date:
|
||||
logger.info("최신 데이터가 이미 존재합니다.")
|
||||
return 0
|
||||
|
||||
logger.info(f"GA4 데이터 수집 시작: {start_date} ~ {end_date}")
|
||||
|
||||
total_saved = 0
|
||||
current_start = start_date
|
||||
|
||||
while current_start <= end_date:
|
||||
current_end = min(current_start + timedelta(days=chunk_days - 1), end_date)
|
||||
|
||||
logger.info(f"청크 처리: {current_start} ~ {current_end}")
|
||||
|
||||
response = self.client.run_report(
|
||||
start_date=current_start.strftime("%Y-%m-%d"),
|
||||
end_date=current_end.strftime("%Y-%m-%d"),
|
||||
dimensions=dimensions,
|
||||
metrics=metrics
|
||||
)
|
||||
|
||||
if response:
|
||||
saved = self.save_response_to_db(response, dimensions, metrics)
|
||||
total_saved += saved
|
||||
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
logger.info(f"GA4 데이터 수집 완료: 총 {total_saved}건 저장")
|
||||
return total_saved
|
||||
300
services/analytics/visitor_forecast.py
Normal file
300
services/analytics/visitor_forecast.py
Normal file
@ -0,0 +1,300 @@
|
||||
# ===================================================================
|
||||
# services/analytics/visitor_forecast.py
|
||||
# 방문객 예측 서비스 모듈
|
||||
# ===================================================================
|
||||
# 날씨, 휴일, 과거 데이터를 기반으로 방문객 수를 예측합니다.
|
||||
# 간단한 가중치 기반 모델과 Prophet 시계열 모델을 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
방문객 예측 서비스 모듈
|
||||
|
||||
날씨 조건, 휴일 여부, 과거 방문 패턴을 분석하여
|
||||
미래 방문객 수를 예측합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.analytics.visitor_forecast import VisitorForecaster
|
||||
|
||||
forecaster = VisitorForecaster(config)
|
||||
predictions = forecaster.predict_weekly()
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class VisitorForecaster:
|
||||
"""
|
||||
방문객 예측 클래스
|
||||
|
||||
다양한 요소를 고려하여 방문객 수를 예측합니다.
|
||||
|
||||
Attributes:
|
||||
weights: 예측 가중치 설정
|
||||
visitor_multiplier: 최종 예측값 조정 계수
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
weights: Optional[Dict] = None,
|
||||
visitor_multiplier: float = 0.5
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
weights: 예측 가중치 (None이면 설정에서 로드)
|
||||
visitor_multiplier: 예측값 조정 계수
|
||||
"""
|
||||
if weights is None:
|
||||
config = get_config()
|
||||
forecast_config = config.forecast_weight
|
||||
|
||||
self.weights = {
|
||||
'min_temp': forecast_config.get('min_temp', 1.0),
|
||||
'max_temp': forecast_config.get('max_temp', 1.0),
|
||||
'precipitation': forecast_config.get('precipitation', 10.0),
|
||||
'humidity': forecast_config.get('humidity', 1.0),
|
||||
'pm25': forecast_config.get('pm25', 1.0),
|
||||
'holiday': forecast_config.get('holiday', 20),
|
||||
}
|
||||
self.visitor_multiplier = forecast_config.get('visitor_multiplier', 0.5)
|
||||
else:
|
||||
self.weights = weights
|
||||
self.visitor_multiplier = visitor_multiplier
|
||||
|
||||
def calculate_weather_impact(
|
||||
self,
|
||||
min_temp: float,
|
||||
max_temp: float,
|
||||
precipitation: float,
|
||||
humidity: float,
|
||||
pm25: Optional[float] = None
|
||||
) -> float:
|
||||
"""
|
||||
날씨 조건에 따른 방문객 영향도 계산
|
||||
|
||||
각 날씨 요소가 방문객 수에 미치는 영향을 계산합니다.
|
||||
높은 값일수록 방문객 수 감소를 의미합니다.
|
||||
|
||||
Args:
|
||||
min_temp: 최저 기온 (℃)
|
||||
max_temp: 최고 기온 (℃)
|
||||
precipitation: 강수량 (mm)
|
||||
humidity: 습도 (%)
|
||||
pm25: 초미세먼지 농도 (㎍/㎥)
|
||||
|
||||
Returns:
|
||||
날씨 영향도 점수 (높을수록 부정적)
|
||||
"""
|
||||
impact = 0.0
|
||||
|
||||
# 기온 영향 (너무 낮거나 높으면 부정적)
|
||||
# 최적 온도: 15~25℃
|
||||
if min_temp < 0:
|
||||
impact += abs(min_temp) * self.weights['min_temp']
|
||||
elif min_temp < 10:
|
||||
impact += (10 - min_temp) * self.weights['min_temp'] * 0.3
|
||||
|
||||
if max_temp > 35:
|
||||
impact += (max_temp - 35) * self.weights['max_temp']
|
||||
elif max_temp > 30:
|
||||
impact += (max_temp - 30) * self.weights['max_temp'] * 0.5
|
||||
|
||||
# 강수량 영향 (비가 오면 크게 부정적)
|
||||
if precipitation > 0:
|
||||
impact += precipitation * self.weights['precipitation']
|
||||
|
||||
# 습도 영향
|
||||
if humidity > 80:
|
||||
impact += (humidity - 80) * self.weights['humidity'] * 0.1
|
||||
|
||||
# 미세먼지 영향
|
||||
if pm25 is not None:
|
||||
if pm25 > 75: # 나쁨 기준
|
||||
impact += (pm25 - 75) * self.weights['pm25'] * 0.5
|
||||
elif pm25 > 35: # 보통 기준
|
||||
impact += (pm25 - 35) * self.weights['pm25'] * 0.2
|
||||
|
||||
return impact
|
||||
|
||||
def calculate_holiday_impact(self, is_holiday: bool, is_weekend: bool) -> float:
|
||||
"""
|
||||
휴일/주말에 따른 방문객 영향도 계산
|
||||
|
||||
Args:
|
||||
is_holiday: 공휴일 여부
|
||||
is_weekend: 주말 여부
|
||||
|
||||
Returns:
|
||||
휴일 영향도 (양수: 방문객 증가, 음수: 감소)
|
||||
"""
|
||||
if is_holiday:
|
||||
return self.weights['holiday']
|
||||
elif is_weekend:
|
||||
return self.weights['holiday'] * 0.7
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
def predict_visitors(
|
||||
self,
|
||||
base_visitors: float,
|
||||
weather_data: Dict,
|
||||
is_holiday: bool = False,
|
||||
is_weekend: bool = False
|
||||
) -> float:
|
||||
"""
|
||||
방문객 수 예측
|
||||
|
||||
기준 방문객 수에 날씨와 휴일 영향을 적용하여 예측합니다.
|
||||
|
||||
Args:
|
||||
base_visitors: 기준 방문객 수 (과거 평균)
|
||||
weather_data: 날씨 데이터 딕셔너리
|
||||
- min_temp: 최저 기온
|
||||
- max_temp: 최고 기온
|
||||
- precipitation: 강수량 (또는 sumRn)
|
||||
- humidity: 습도 (또는 avgRhm)
|
||||
- pm25: 미세먼지 (선택)
|
||||
is_holiday: 공휴일 여부
|
||||
is_weekend: 주말 여부
|
||||
|
||||
Returns:
|
||||
예측 방문객 수
|
||||
"""
|
||||
# 날씨 데이터 추출
|
||||
min_temp = weather_data.get('min_temp', weather_data.get('minTa', 15))
|
||||
max_temp = weather_data.get('max_temp', weather_data.get('maxTa', 25))
|
||||
precipitation = weather_data.get('precipitation', weather_data.get('sumRn', 0))
|
||||
humidity = weather_data.get('humidity', weather_data.get('avgRhm', 50))
|
||||
pm25 = weather_data.get('pm25')
|
||||
|
||||
# 영향도 계산
|
||||
weather_impact = self.calculate_weather_impact(
|
||||
min_temp, max_temp, precipitation, humidity, pm25
|
||||
)
|
||||
holiday_impact = self.calculate_holiday_impact(is_holiday, is_weekend)
|
||||
|
||||
# 예측값 계산
|
||||
# 날씨 영향은 감소 효과, 휴일 영향은 증가 효과
|
||||
adjustment = holiday_impact - weather_impact
|
||||
|
||||
# 조정 계수 적용
|
||||
predicted = base_visitors * (1 + adjustment / 100 * self.visitor_multiplier)
|
||||
|
||||
# 최소값 보장
|
||||
return max(0, predicted)
|
||||
|
||||
def predict_weekly(
|
||||
self,
|
||||
base_visitors: float,
|
||||
weekly_weather: Dict[str, Dict],
|
||||
holidays: Optional[List[date]] = None
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
주간 방문객 예측
|
||||
|
||||
Args:
|
||||
base_visitors: 기준 방문객 수
|
||||
weekly_weather: 일별 날씨 데이터 {YYYYMMDD: weather_data}
|
||||
holidays: 휴일 목록
|
||||
|
||||
Returns:
|
||||
일별 예측 방문객 {YYYYMMDD: visitors}
|
||||
"""
|
||||
if holidays is None:
|
||||
holidays = []
|
||||
|
||||
predictions = {}
|
||||
|
||||
for date_str, weather in weekly_weather.items():
|
||||
try:
|
||||
dt = datetime.strptime(date_str, '%Y%m%d').date()
|
||||
is_holiday = dt in holidays
|
||||
is_weekend = dt.weekday() >= 5
|
||||
|
||||
predicted = self.predict_visitors(
|
||||
base_visitors,
|
||||
weather,
|
||||
is_holiday,
|
||||
is_weekend
|
||||
)
|
||||
|
||||
predictions[date_str] = round(predicted)
|
||||
|
||||
logger.debug(
|
||||
f"{date_str}: 기준={base_visitors}, "
|
||||
f"휴일={is_holiday}, 주말={is_weekend}, "
|
||||
f"예측={predicted:.0f}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"예측 실패 ({date_str}): {e}")
|
||||
predictions[date_str] = base_visitors
|
||||
|
||||
return predictions
|
||||
|
||||
def analyze_prediction_factors(
|
||||
self,
|
||||
weather_data: Dict,
|
||||
is_holiday: bool = False,
|
||||
is_weekend: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
예측 요인 분석
|
||||
|
||||
각 요인이 예측에 미치는 영향을 분석합니다.
|
||||
|
||||
Args:
|
||||
weather_data: 날씨 데이터
|
||||
is_holiday: 공휴일 여부
|
||||
is_weekend: 주말 여부
|
||||
|
||||
Returns:
|
||||
요인별 영향 분석 결과
|
||||
"""
|
||||
min_temp = weather_data.get('min_temp', weather_data.get('minTa', 15))
|
||||
max_temp = weather_data.get('max_temp', weather_data.get('maxTa', 25))
|
||||
precipitation = weather_data.get('precipitation', weather_data.get('sumRn', 0))
|
||||
humidity = weather_data.get('humidity', weather_data.get('avgRhm', 50))
|
||||
pm25 = weather_data.get('pm25')
|
||||
|
||||
analysis = {
|
||||
'weather': {
|
||||
'min_temp': {
|
||||
'value': min_temp,
|
||||
'impact': abs(min_temp) * self.weights['min_temp'] if min_temp < 0 else 0
|
||||
},
|
||||
'max_temp': {
|
||||
'value': max_temp,
|
||||
'impact': (max_temp - 35) * self.weights['max_temp'] if max_temp > 35 else 0
|
||||
},
|
||||
'precipitation': {
|
||||
'value': precipitation,
|
||||
'impact': precipitation * self.weights['precipitation'] if precipitation > 0 else 0
|
||||
},
|
||||
'humidity': {
|
||||
'value': humidity,
|
||||
'impact': (humidity - 80) * self.weights['humidity'] * 0.1 if humidity > 80 else 0
|
||||
}
|
||||
},
|
||||
'holiday': {
|
||||
'is_holiday': is_holiday,
|
||||
'is_weekend': is_weekend,
|
||||
'impact': self.calculate_holiday_impact(is_holiday, is_weekend)
|
||||
},
|
||||
'total_weather_impact': self.calculate_weather_impact(
|
||||
min_temp, max_temp, precipitation, humidity, pm25
|
||||
),
|
||||
'total_holiday_impact': self.calculate_holiday_impact(is_holiday, is_weekend)
|
||||
}
|
||||
|
||||
if pm25 is not None:
|
||||
analysis['weather']['pm25'] = {
|
||||
'value': pm25,
|
||||
'impact': (pm25 - 75) * self.weights['pm25'] * 0.5 if pm25 > 75 else 0
|
||||
}
|
||||
|
||||
return analysis
|
||||
15
services/notification/__init__.py
Normal file
15
services/notification/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
# ===================================================================
|
||||
# services/notification/__init__.py
|
||||
# 알림 서비스 패키지 초기화
|
||||
# ===================================================================
|
||||
# Notion 웹훅 처리 및 다양한 알림 서비스를 제공합니다.
|
||||
# ===================================================================
|
||||
|
||||
from .notion import NotionWebhookHandler, get_page_details
|
||||
from .mattermost import MattermostNotifier
|
||||
|
||||
__all__ = [
|
||||
'NotionWebhookHandler',
|
||||
'get_page_details',
|
||||
'MattermostNotifier',
|
||||
]
|
||||
335
services/notification/mattermost.py
Normal file
335
services/notification/mattermost.py
Normal file
@ -0,0 +1,335 @@
|
||||
# ===================================================================
|
||||
# services/notification/mattermost.py
|
||||
# Mattermost 알림 서비스 모듈
|
||||
# ===================================================================
|
||||
# Mattermost로 알림 메시지를 발송하는 전용 서비스입니다.
|
||||
# 웹훅 및 API 방식을 모두 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
Mattermost 알림 서비스 모듈
|
||||
|
||||
Mattermost 채널로 알림 메시지를 발송합니다.
|
||||
웹훅과 Bot API 두 가지 방식을 지원합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.notification.mattermost import MattermostNotifier
|
||||
|
||||
notifier = MattermostNotifier.from_config()
|
||||
notifier.send_message("서버 점검 알림입니다.")
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import requests
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MattermostNotifier:
|
||||
"""
|
||||
Mattermost 알림 발송 클래스
|
||||
|
||||
Mattermost 채널로 메시지를 발송합니다.
|
||||
웹훅 방식과 Bot API 방식을 모두 지원합니다.
|
||||
|
||||
Attributes:
|
||||
server_url: Mattermost 서버 URL
|
||||
bot_token: Bot 인증 토큰
|
||||
channel_id: 기본 채널 ID
|
||||
webhook_url: 웹훅 URL
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str = "",
|
||||
bot_token: str = "",
|
||||
channel_id: str = "",
|
||||
webhook_url: str = ""
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
server_url: Mattermost 서버 URL (예: https://mattermost.example.com)
|
||||
bot_token: Bot 인증 토큰
|
||||
channel_id: 기본 채널 ID
|
||||
webhook_url: Incoming 웹훅 URL
|
||||
"""
|
||||
self.server_url = server_url.rstrip('/') if server_url else ""
|
||||
self.bot_token = bot_token
|
||||
self.channel_id = channel_id
|
||||
self.webhook_url = webhook_url
|
||||
|
||||
@classmethod
|
||||
def from_config(cls) -> 'MattermostNotifier':
|
||||
"""
|
||||
설정에서 인스턴스 생성
|
||||
|
||||
Returns:
|
||||
설정이 적용된 MattermostNotifier 인스턴스
|
||||
"""
|
||||
config = get_config()
|
||||
mm_config = config.mattermost
|
||||
|
||||
return cls(
|
||||
server_url=mm_config.get('url', ''),
|
||||
bot_token=mm_config.get('token', ''),
|
||||
channel_id=mm_config.get('channel_id', ''),
|
||||
webhook_url=mm_config.get('webhook_url', ''),
|
||||
)
|
||||
|
||||
def _validate_api_config(self) -> bool:
|
||||
"""API 방식 설정 검증"""
|
||||
if not self.server_url:
|
||||
logger.error("Mattermost 서버 URL이 설정되지 않았습니다.")
|
||||
return False
|
||||
if not self.server_url.startswith(('http://', 'https://')):
|
||||
logger.error(f"유효하지 않은 서버 URL: {self.server_url}")
|
||||
return False
|
||||
if not self.bot_token:
|
||||
logger.error("Bot 토큰이 설정되지 않았습니다.")
|
||||
return False
|
||||
if not self.channel_id:
|
||||
logger.error("채널 ID가 설정되지 않았습니다.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
message: str,
|
||||
channel_id: Optional[str] = None,
|
||||
use_webhook: bool = False,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
props: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
메시지 발송
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
channel_id: 채널 ID (None이면 기본 채널)
|
||||
use_webhook: 웹훅 사용 여부
|
||||
attachments: 첨부 데이터 (Mattermost 형식)
|
||||
props: 추가 속성
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
if use_webhook:
|
||||
return self._send_via_webhook(message, attachments, props)
|
||||
else:
|
||||
return self._send_via_api(message, channel_id, attachments, props)
|
||||
|
||||
def _send_via_webhook(
|
||||
self,
|
||||
message: str,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
props: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
웹훅으로 메시지 발송
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
attachments: 첨부 데이터
|
||||
props: 추가 속성
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
if not self.webhook_url:
|
||||
logger.error("웹훅 URL이 설정되지 않았습니다.")
|
||||
return False
|
||||
|
||||
payload = {"text": message}
|
||||
|
||||
if attachments:
|
||||
payload["attachments"] = attachments
|
||||
if props:
|
||||
payload["props"] = props
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Mattermost 웹훅 전송 성공")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"웹훅 전송 실패: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("웹훅 전송 타임아웃")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"웹훅 전송 예외: {e}")
|
||||
return False
|
||||
|
||||
def _send_via_api(
|
||||
self,
|
||||
message: str,
|
||||
channel_id: Optional[str] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
props: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Bot API로 메시지 발송
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
channel_id: 채널 ID
|
||||
attachments: 첨부 데이터
|
||||
props: 추가 속성
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
if not self._validate_api_config():
|
||||
return False
|
||||
|
||||
target_channel = channel_id or self.channel_id
|
||||
url = f"{self.server_url}/api/v4/posts"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.bot_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"channel_id": target_channel,
|
||||
"message": message
|
||||
}
|
||||
|
||||
if attachments:
|
||||
payload["props"] = payload.get("props", {})
|
||||
payload["props"]["attachments"] = attachments
|
||||
if props:
|
||||
payload["props"] = {**payload.get("props", {}), **props}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info("Mattermost API 전송 성공")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"API 전송 실패: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("API 전송 타임아웃")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"API 전송 예외: {e}")
|
||||
return False
|
||||
|
||||
def send_formatted_message(
|
||||
self,
|
||||
title: str,
|
||||
text: str,
|
||||
color: str = "#3498db",
|
||||
fields: Optional[List[Dict]] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
use_webhook: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
서식화된 메시지 발송 (Attachment 사용)
|
||||
|
||||
Args:
|
||||
title: 메시지 제목
|
||||
text: 메시지 본문
|
||||
color: 테두리 색상 (hex)
|
||||
fields: 필드 목록 [{"title": "", "value": "", "short": bool}]
|
||||
channel_id: 채널 ID
|
||||
use_webhook: 웹훅 사용 여부
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
attachment = {
|
||||
"fallback": f"{title}: {text}",
|
||||
"color": color,
|
||||
"title": title,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
if fields:
|
||||
attachment["fields"] = fields
|
||||
|
||||
return self.send_message(
|
||||
message="",
|
||||
channel_id=channel_id,
|
||||
use_webhook=use_webhook,
|
||||
attachments=[attachment]
|
||||
)
|
||||
|
||||
def send_alert(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
level: str = "info",
|
||||
channel_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
알림 메시지 발송 (레벨에 따른 색상)
|
||||
|
||||
Args:
|
||||
title: 알림 제목
|
||||
message: 알림 내용
|
||||
level: 알림 레벨 (info, warning, error, success)
|
||||
channel_id: 채널 ID
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
colors = {
|
||||
"info": "#3498db",
|
||||
"warning": "#f39c12",
|
||||
"error": "#e74c3c",
|
||||
"success": "#27ae60"
|
||||
}
|
||||
|
||||
icons = {
|
||||
"info": "ℹ️",
|
||||
"warning": "⚠️",
|
||||
"error": "🚨",
|
||||
"success": "✅"
|
||||
}
|
||||
|
||||
color = colors.get(level, colors["info"])
|
||||
icon = icons.get(level, icons["info"])
|
||||
|
||||
return self.send_formatted_message(
|
||||
title=f"{icon} {title}",
|
||||
text=message,
|
||||
color=color,
|
||||
channel_id=channel_id
|
||||
)
|
||||
|
||||
|
||||
def send_mattermost_notification(
|
||||
message: str,
|
||||
channel_id: Optional[str] = None,
|
||||
use_webhook: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Mattermost 알림 간편 함수
|
||||
|
||||
설정에서 자동으로 설정을 로드하여 메시지를 발송합니다.
|
||||
|
||||
Args:
|
||||
message: 발송할 메시지
|
||||
channel_id: 채널 ID (None이면 기본 채널)
|
||||
use_webhook: 웹훅 사용 여부
|
||||
|
||||
Returns:
|
||||
발송 성공 여부
|
||||
"""
|
||||
notifier = MattermostNotifier.from_config()
|
||||
return notifier.send_message(message, channel_id, use_webhook)
|
||||
344
services/notification/notion.py
Normal file
344
services/notification/notion.py
Normal file
@ -0,0 +1,344 @@
|
||||
# ===================================================================
|
||||
# services/notification/notion.py
|
||||
# Notion 웹훅 처리 서비스 모듈
|
||||
# ===================================================================
|
||||
# Notion 웹훅 이벤트를 처리하고 알림 메시지를 생성합니다.
|
||||
# 페이지 생성, 수정, 삭제 등의 이벤트를 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
Notion 웹훅 처리 서비스 모듈
|
||||
|
||||
Notion 웹훅 이벤트를 수신하고 처리하여 알림 메시지를 생성합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.notification.notion import NotionWebhookHandler
|
||||
|
||||
handler = NotionWebhookHandler(api_secret)
|
||||
message = handler.handle_event(event_data)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import requests
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Notion API 설정
|
||||
NOTION_API_BASE = "https://api.notion.com/v1"
|
||||
NOTION_VERSION = "2022-06-28"
|
||||
|
||||
# 이벤트 타입별 라벨
|
||||
EVENT_TYPE_LABELS = {
|
||||
"page.created": "📝 새 페이지 생성",
|
||||
"page.content_updated": "✏️ 페이지 내용 수정",
|
||||
"page.properties_updated": "🔄 페이지 속성 변경",
|
||||
"page.deleted": "🗑️ 페이지 삭제",
|
||||
"page.restored": "♻️ 페이지 복구",
|
||||
"page.moved": "📁 페이지 이동",
|
||||
"page.locked": "🔒 페이지 잠금",
|
||||
"page.unlocked": "🔓 페이지 잠금 해제",
|
||||
"database.created": "📊 새 데이터베이스 생성",
|
||||
"database.content_updated": "📊 데이터베이스 업데이트",
|
||||
"block.created": "➕ 블록 생성",
|
||||
"block.changed": "📝 블록 변경",
|
||||
"block.deleted": "➖ 블록 삭제",
|
||||
"comment.created": "💬 새 댓글",
|
||||
"comment.updated": "💬 댓글 수정",
|
||||
"comment.deleted": "💬 댓글 삭제",
|
||||
}
|
||||
|
||||
|
||||
def get_page_details(page_id: str, api_secret: str) -> Optional[Dict]:
|
||||
"""
|
||||
Notion 페이지 상세 정보 조회
|
||||
|
||||
Args:
|
||||
page_id: 페이지 ID
|
||||
api_secret: Notion API 시크릿
|
||||
|
||||
Returns:
|
||||
페이지 상세 정보 또는 None
|
||||
"""
|
||||
url = f"{NOTION_API_BASE}/pages/{page_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_secret}",
|
||||
"Notion-Version": NOTION_VERSION,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"페이지 조회 실패: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"페이지 조회 예외: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_database_details(database_id: str, api_secret: str) -> Optional[Dict]:
|
||||
"""
|
||||
Notion 데이터베이스 상세 정보 조회
|
||||
|
||||
Args:
|
||||
database_id: 데이터베이스 ID
|
||||
api_secret: Notion API 시크릿
|
||||
|
||||
Returns:
|
||||
데이터베이스 상세 정보 또는 None
|
||||
"""
|
||||
url = f"{NOTION_API_BASE}/databases/{database_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_secret}",
|
||||
"Notion-Version": NOTION_VERSION,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"데이터베이스 조회 실패: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"데이터베이스 조회 예외: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class NotionWebhookHandler:
|
||||
"""
|
||||
Notion 웹훅 이벤트 핸들러
|
||||
|
||||
Notion에서 전송하는 웹훅 이벤트를 처리하고
|
||||
알림 메시지를 생성합니다.
|
||||
|
||||
Attributes:
|
||||
api_secret: Notion API 시크릿
|
||||
allowed_workspaces: 허용된 워크스페이스 이름 목록
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_secret: Optional[str] = None,
|
||||
allowed_workspaces: Optional[List[str]] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_secret: Notion API 시크릿 (None이면 설정에서 로드)
|
||||
allowed_workspaces: 허용된 워크스페이스 목록 (None이면 모두 허용)
|
||||
"""
|
||||
if api_secret is None:
|
||||
config = get_config()
|
||||
api_secret = config.notion.get('api_secret', '')
|
||||
|
||||
self.api_secret = api_secret
|
||||
self.allowed_workspaces = allowed_workspaces or []
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_secret}",
|
||||
"Notion-Version": NOTION_VERSION,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def fetch_entity_detail(self, entity_type: str, entity_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
엔티티 상세 정보 조회
|
||||
|
||||
Args:
|
||||
entity_type: 엔티티 타입 ('page', 'database', 'block' 등)
|
||||
entity_id: 엔티티 ID
|
||||
|
||||
Returns:
|
||||
엔티티 상세 정보 또는 None
|
||||
"""
|
||||
if entity_type == "page":
|
||||
url = f"{NOTION_API_BASE}/pages/{entity_id}"
|
||||
elif entity_type == "database":
|
||||
url = f"{NOTION_API_BASE}/databases/{entity_id}"
|
||||
elif entity_type == "block":
|
||||
url = f"{NOTION_API_BASE}/blocks/{entity_id}"
|
||||
else:
|
||||
logger.warning(f"지원하지 않는 엔티티 타입: {entity_type}")
|
||||
return None
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"엔티티 조회 실패: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"엔티티 조회 예외: {e}")
|
||||
return None
|
||||
|
||||
def extract_title_from_properties(self, properties: Dict) -> str:
|
||||
"""
|
||||
속성에서 제목 추출
|
||||
|
||||
Args:
|
||||
properties: Notion 속성 딕셔너리
|
||||
|
||||
Returns:
|
||||
제목 문자열 (없으면 기본값)
|
||||
"""
|
||||
# 일반적인 제목 속성 이름들
|
||||
title_keys = ['Name', 'name', '이름', '제목', 'Title', '작업 이름']
|
||||
|
||||
for key in title_keys:
|
||||
if key in properties:
|
||||
prop = properties[key]
|
||||
if prop.get('type') == 'title':
|
||||
title_arr = prop.get('title', [])
|
||||
if title_arr:
|
||||
return ''.join(t.get('plain_text', '') for t in title_arr)
|
||||
|
||||
# title 타입 속성 찾기
|
||||
for prop in properties.values():
|
||||
if prop.get('type') == 'title':
|
||||
title_arr = prop.get('title', [])
|
||||
if title_arr:
|
||||
return ''.join(t.get('plain_text', '') for t in title_arr)
|
||||
|
||||
return "(제목 없음)"
|
||||
|
||||
def extract_editor_from_properties(self, properties: Dict) -> str:
|
||||
"""
|
||||
속성에서 편집자 정보 추출
|
||||
|
||||
Args:
|
||||
properties: Notion 속성 딕셔너리
|
||||
|
||||
Returns:
|
||||
편집자 이름 (없으면 기본값)
|
||||
"""
|
||||
editor_keys = ['최종 편집자', 'Last edited by', 'Editor']
|
||||
|
||||
for key in editor_keys:
|
||||
if key in properties:
|
||||
prop = properties[key]
|
||||
if prop.get('type') == 'last_edited_by':
|
||||
editor = prop.get('last_edited_by', {})
|
||||
return editor.get('name', '(알 수 없음)')
|
||||
|
||||
return "(알 수 없음)"
|
||||
|
||||
def handle_event(self, event: Dict) -> Optional[str]:
|
||||
"""
|
||||
Notion 웹훅 이벤트 처리
|
||||
|
||||
Args:
|
||||
event: 웹훅 이벤트 데이터
|
||||
|
||||
Returns:
|
||||
알림 메시지 또는 None (무시할 이벤트)
|
||||
"""
|
||||
# 이벤트 로깅
|
||||
logger.info(f"수신된 Notion 이벤트: {json.dumps(event, ensure_ascii=False)[:500]}")
|
||||
|
||||
# 워크스페이스 확인
|
||||
workspace_id = event.get('workspace_id')
|
||||
if not workspace_id:
|
||||
logger.warning("workspace_id 없음 - 이벤트 무시")
|
||||
return None
|
||||
|
||||
# 허용된 워크스페이스 확인 (설정된 경우)
|
||||
if self.allowed_workspaces:
|
||||
workspace_name = self.get_workspace_name(workspace_id)
|
||||
if workspace_name not in self.allowed_workspaces:
|
||||
logger.info(f"허용되지 않은 워크스페이스: {workspace_name}")
|
||||
return None
|
||||
|
||||
# 이벤트 정보 추출
|
||||
event_type = event.get('type', 'unknown')
|
||||
timestamp = event.get('timestamp')
|
||||
|
||||
# 시간 포맷팅
|
||||
readable_time = None
|
||||
if timestamp:
|
||||
try:
|
||||
readable_time = datetime.fromisoformat(
|
||||
timestamp.replace('Z', '+00:00')
|
||||
).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
readable_time = timestamp
|
||||
|
||||
# 엔티티 정보
|
||||
entity = event.get('entity', {})
|
||||
entity_id = entity.get('id', 'unknown')
|
||||
entity_type = entity.get('type', 'unknown')
|
||||
|
||||
# 상세 정보 조회
|
||||
detail = self.fetch_entity_detail(entity_type, entity_id)
|
||||
|
||||
if not detail:
|
||||
logger.warning("상세 정보 조회 실패")
|
||||
return None
|
||||
|
||||
# 정보 추출
|
||||
properties = detail.get('properties', {})
|
||||
page_title = self.extract_title_from_properties(properties)
|
||||
editor_name = self.extract_editor_from_properties(properties)
|
||||
page_url = detail.get('url', 'URL 없음')
|
||||
|
||||
# 이벤트 라벨
|
||||
event_label = EVENT_TYPE_LABELS.get(event_type, f"📢 Notion 이벤트: `{event_type}`")
|
||||
|
||||
# 메시지 구성
|
||||
message = (
|
||||
f"{event_label}\n"
|
||||
f"- 🕒 시간: {readable_time or '알 수 없음'}\n"
|
||||
f"- 📄 페이지: {page_title}\n"
|
||||
f"- 👤 작업자: {editor_name}\n"
|
||||
f"- 🔗 [바로가기]({page_url})"
|
||||
)
|
||||
|
||||
logger.info(f"생성된 메시지: {message[:200]}...")
|
||||
return message
|
||||
|
||||
def get_workspace_name(self, workspace_id: str) -> str:
|
||||
"""
|
||||
워크스페이스 이름 조회
|
||||
|
||||
Note: Notion API에서 별도의 워크스페이스 조회 엔드포인트가 없어
|
||||
현재는 하드코딩되어 있습니다.
|
||||
|
||||
Args:
|
||||
workspace_id: 워크스페이스 ID
|
||||
|
||||
Returns:
|
||||
워크스페이스 이름
|
||||
"""
|
||||
# TODO: 실제 API가 지원되면 구현
|
||||
return "퍼스트가든"
|
||||
|
||||
def create_summary_from_page(self, page_data: Dict) -> str:
|
||||
"""
|
||||
페이지 데이터에서 요약 메시지 생성
|
||||
|
||||
Args:
|
||||
page_data: 페이지 상세 데이터
|
||||
|
||||
Returns:
|
||||
요약 메시지
|
||||
"""
|
||||
properties = page_data.get('properties', {})
|
||||
title = self.extract_title_from_properties(properties)
|
||||
url = page_data.get('url', 'URL 없음')
|
||||
|
||||
return f"📌 노션 페이지 업데이트됨\n**제목**: {title}\n🔗 [바로가기]({url})"
|
||||
31
services/weather/__init__.py
Normal file
31
services/weather/__init__.py
Normal file
@ -0,0 +1,31 @@
|
||||
# ===================================================================
|
||||
# services/weather/__init__.py
|
||||
# 기상 데이터 서비스 패키지 초기화
|
||||
# ===================================================================
|
||||
# 기상청 API를 통한 날씨 데이터 수집 및 처리 서비스입니다.
|
||||
# 초단기예보, 단기예보, 중기예보, ASOS 종관기상 데이터를 지원합니다.
|
||||
# ===================================================================
|
||||
|
||||
from .forecast import (
|
||||
get_ultra_forecast,
|
||||
get_vilage_forecast,
|
||||
get_daily_ultra_forecast,
|
||||
get_daily_vilage_forecast,
|
||||
get_midterm_forecast,
|
||||
get_midterm_temperature,
|
||||
get_weekly_precip,
|
||||
)
|
||||
from .asos import get_asos_weather
|
||||
from .precipitation import PrecipitationService
|
||||
|
||||
__all__ = [
|
||||
'get_ultra_forecast',
|
||||
'get_vilage_forecast',
|
||||
'get_daily_ultra_forecast',
|
||||
'get_daily_vilage_forecast',
|
||||
'get_midterm_forecast',
|
||||
'get_midterm_temperature',
|
||||
'get_weekly_precip',
|
||||
'get_asos_weather',
|
||||
'PrecipitationService',
|
||||
]
|
||||
383
services/weather/asos.py
Normal file
383
services/weather/asos.py
Normal file
@ -0,0 +1,383 @@
|
||||
# ===================================================================
|
||||
# services/weather/asos.py
|
||||
# 기상청 ASOS 종관기상 데이터 서비스 모듈
|
||||
# ===================================================================
|
||||
# 기상청 종관기상관측(ASOS) API를 통해 일별 기상 데이터를 조회합니다.
|
||||
# 과거 날씨 데이터 수집 및 DB 저장 기능을 제공합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
기상청 ASOS 종관기상 데이터 서비스 모듈
|
||||
|
||||
기상청 공공데이터포털의 종관기상관측(ASOS) API를 통해
|
||||
일별 기상 데이터(기온, 강수량, 습도 등)를 조회합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.weather.asos import get_asos_weather, ASOSDataCollector
|
||||
|
||||
# 단일 기간 조회
|
||||
data = get_asos_weather(service_key, stn_id=99, start_dt='20240101', end_dt='20240131')
|
||||
|
||||
# 수집기를 통한 자동 데이터 수집
|
||||
collector = ASOSDataCollector(service_key)
|
||||
collector.collect_and_save([99])
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Generator, Tuple, Any
|
||||
|
||||
from sqlalchemy import select, Table
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.http_client import create_retry_session
|
||||
from core.config import get_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ASOS API URL
|
||||
ASOS_API_URL = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList"
|
||||
|
||||
# 시간 관련 컬럼 (음수 값을 null로 처리)
|
||||
HRMT_KEYS = [
|
||||
"minTaHrmt", "maxTaHrmt", "mi10MaxRnHrmt", "hr1MaxRnHrmt",
|
||||
"maxInsWsHrmt", "maxWsHrmt", "minRhmHrmt", "maxPsHrmt",
|
||||
"minPsHrmt", "hr1MaxIcsrHrmt", "ddMefsHrmt", "ddMesHrmt"
|
||||
]
|
||||
|
||||
|
||||
def fetch_date_range_chunks(
|
||||
start_dt: str,
|
||||
end_dt: str,
|
||||
chunk_days: int = 10
|
||||
) -> Generator[Tuple[str, str], None, None]:
|
||||
"""
|
||||
날짜 범위를 청크 단위로 분할
|
||||
|
||||
대량의 데이터를 조회할 때 API 요청을 분할하여 처리합니다.
|
||||
|
||||
Args:
|
||||
start_dt: 시작 날짜 (YYYYMMDD)
|
||||
end_dt: 종료 날짜 (YYYYMMDD)
|
||||
chunk_days: 청크 크기 (일 단위)
|
||||
|
||||
Yields:
|
||||
(시작일, 종료일) 튜플
|
||||
"""
|
||||
current_start = datetime.strptime(start_dt, "%Y%m%d")
|
||||
end_date = datetime.strptime(end_dt, "%Y%m%d")
|
||||
|
||||
while current_start <= end_date:
|
||||
current_end = min(current_start + timedelta(days=chunk_days - 1), end_date)
|
||||
yield current_start.strftime("%Y%m%d"), current_end.strftime("%Y%m%d")
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
|
||||
def get_asos_weather(
|
||||
service_key: str,
|
||||
stn_id: int,
|
||||
start_dt: str,
|
||||
end_dt: str,
|
||||
session = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
ASOS 종관기상 데이터 조회
|
||||
|
||||
기상청 ASOS API를 호출하여 일별 기상 데이터를 조회합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
stn_id: 관측 지점 ID
|
||||
start_dt: 시작 날짜 (YYYYMMDD)
|
||||
end_dt: 종료 날짜 (YYYYMMDD)
|
||||
session: requests 세션 (재사용용)
|
||||
|
||||
Returns:
|
||||
일별 기상 데이터 리스트
|
||||
|
||||
데이터 항목:
|
||||
- tm: 일시 (YYYY-MM-DD)
|
||||
- avgTa: 평균 기온 (℃)
|
||||
- minTa: 최저 기온 (℃)
|
||||
- maxTa: 최고 기온 (℃)
|
||||
- sumRn: 일 강수량 (mm)
|
||||
- avgRhm: 평균 상대습도 (%)
|
||||
- avgWs: 평균 풍속 (m/s)
|
||||
등
|
||||
"""
|
||||
if session is None:
|
||||
session = create_retry_session(retries=3)
|
||||
|
||||
params = {
|
||||
"serviceKey": service_key,
|
||||
"pageNo": "1",
|
||||
"numOfRows": "500",
|
||||
"dataType": "JSON",
|
||||
"dataCd": "ASOS",
|
||||
"dateCd": "DAY",
|
||||
"startDt": start_dt,
|
||||
"endDt": end_dt,
|
||||
"stnIds": str(stn_id),
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
response = session.get(ASOS_API_URL, params=params, headers=headers, timeout=20)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get("response", {}).get("body", {}).get("items", {}).get("item", [])
|
||||
|
||||
if items is None:
|
||||
return []
|
||||
|
||||
# 단일 항목인 경우 리스트로 변환
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
|
||||
logger.debug(f"ASOS 데이터 조회 완료: 지점={stn_id}, 건수={len(items)}")
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ASOS API 요청 실패: {e}")
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
class ASOSDataCollector:
|
||||
"""
|
||||
ASOS 데이터 자동 수집기
|
||||
|
||||
설정에 따라 ASOS 데이터를 자동으로 수집하고 DB에 저장합니다.
|
||||
마지막 저장 일자를 확인하여 중복 없이 증분 수집합니다.
|
||||
|
||||
Attributes:
|
||||
service_key: API 서비스 키
|
||||
force_update: 기존 데이터 덮어쓰기 여부
|
||||
debug: 디버그 모드 (True면 실제 저장 안 함)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_key: Optional[str] = None,
|
||||
force_update: bool = False,
|
||||
debug: bool = False
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
service_key: API 키 (None이면 설정에서 로드)
|
||||
force_update: 기존 데이터 덮어쓰기 여부
|
||||
debug: 디버그 모드
|
||||
"""
|
||||
if service_key is None:
|
||||
config = get_config()
|
||||
service_key = config.data_api['service_key']
|
||||
|
||||
self.service_key = service_key
|
||||
self.force_update = force_update
|
||||
self.debug = debug
|
||||
self.session = create_retry_session(retries=3)
|
||||
|
||||
def get_latest_date_from_db(self, conn: Connection, table: Table) -> Optional[date]:
|
||||
"""
|
||||
DB에서 가장 최근 저장된 날짜 조회
|
||||
|
||||
Args:
|
||||
conn: DB 연결
|
||||
table: 대상 테이블
|
||||
|
||||
Returns:
|
||||
최근 날짜 또는 None
|
||||
"""
|
||||
sel = select(table.c.date).order_by(table.c.date.desc()).limit(1)
|
||||
result = conn.execute(sel).fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
def parse_item_to_record(self, item: Dict, table: Table) -> Optional[Dict]:
|
||||
"""
|
||||
API 응답 아이템을 DB 레코드로 변환
|
||||
|
||||
Args:
|
||||
item: API 응답 아이템
|
||||
table: 대상 테이블
|
||||
|
||||
Returns:
|
||||
DB 레코드 딕셔너리 또는 None
|
||||
"""
|
||||
date_str = item.get("tm")
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
record_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
logger.warning(f"날짜 파싱 실패: {date_str}")
|
||||
return None
|
||||
|
||||
data = {"date": record_date}
|
||||
|
||||
for key in table.c.keys():
|
||||
if key == "date":
|
||||
continue
|
||||
|
||||
value = item.get(key)
|
||||
|
||||
# 빈 값 처리
|
||||
if value in ["", None, "-"]:
|
||||
data[key] = None
|
||||
continue
|
||||
|
||||
try:
|
||||
# 시간 관련 컬럼 또는 특수 컬럼 처리
|
||||
if key in HRMT_KEYS or key == "iscs":
|
||||
fval = float(value)
|
||||
data[key] = str(int(fval)) if fval >= 0 else None
|
||||
elif key == "stnId":
|
||||
data[key] = int(float(value))
|
||||
else:
|
||||
data[key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
data[key] = None
|
||||
|
||||
return data
|
||||
|
||||
def save_items_to_db(
|
||||
self,
|
||||
items: List[Dict],
|
||||
conn: Connection,
|
||||
table: Table
|
||||
) -> int:
|
||||
"""
|
||||
데이터 항목들을 DB에 저장
|
||||
|
||||
Args:
|
||||
items: 저장할 데이터 리스트
|
||||
conn: DB 연결
|
||||
table: 대상 테이블
|
||||
|
||||
Returns:
|
||||
저장된 레코드 수
|
||||
"""
|
||||
saved_count = 0
|
||||
|
||||
for item in items:
|
||||
data = self.parse_item_to_record(item, table)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
record_date = data['date']
|
||||
|
||||
if self.debug:
|
||||
logger.debug(f"[DEBUG] {record_date} DB 저장 시도: {data}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if self.force_update:
|
||||
# UPSERT: 존재하면 업데이트, 없으면 삽입
|
||||
stmt = mysql_insert(table).values(**data)
|
||||
stmt = stmt.on_duplicate_key_update(**data)
|
||||
conn.execute(stmt)
|
||||
logger.info(f"{record_date} 저장/업데이트 완료")
|
||||
else:
|
||||
# 중복 확인 후 삽입
|
||||
sel = select(table.c.date).where(table.c.date == record_date)
|
||||
if conn.execute(sel).fetchone():
|
||||
logger.debug(f"{record_date} 이미 존재, 저장 생략")
|
||||
continue
|
||||
|
||||
conn.execute(table.insert().values(**data))
|
||||
logger.info(f"{record_date} 저장 완료")
|
||||
|
||||
saved_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"저장 실패 ({record_date}): {e}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
return saved_count
|
||||
|
||||
def collect_and_save(
|
||||
self,
|
||||
stn_ids: List[int],
|
||||
engine,
|
||||
table: Table,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
chunk_days: int = 1000
|
||||
) -> int:
|
||||
"""
|
||||
데이터 수집 및 저장 실행
|
||||
|
||||
Args:
|
||||
stn_ids: 관측 지점 ID 리스트
|
||||
engine: SQLAlchemy 엔진
|
||||
table: 대상 테이블
|
||||
start_date: 시작 날짜 (None이면 자동 계산)
|
||||
end_date: 종료 날짜 (None이면 자동 계산)
|
||||
chunk_days: 청크 크기
|
||||
|
||||
Returns:
|
||||
총 저장된 레코드 수
|
||||
"""
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
|
||||
# 종료일 계산 (오전 11시 이전이면 전전일, 이후면 전일)
|
||||
if end_date is None:
|
||||
if now.hour < 11:
|
||||
end_dt = (today - timedelta(days=2)).strftime("%Y%m%d")
|
||||
logger.info(f"오전 11시 이전, 전전일까지 조회: {end_dt}")
|
||||
else:
|
||||
end_dt = (today - timedelta(days=1)).strftime("%Y%m%d")
|
||||
logger.info(f"전일까지 조회: {end_dt}")
|
||||
else:
|
||||
end_dt = end_date
|
||||
|
||||
total_saved = 0
|
||||
|
||||
with engine.begin() as conn:
|
||||
# 시작일 계산
|
||||
if start_date is None:
|
||||
latest_date = self.get_latest_date_from_db(conn, table)
|
||||
if latest_date:
|
||||
start_dt = (latest_date + timedelta(days=1)).strftime("%Y%m%d")
|
||||
logger.info(f"마지막 저장일: {latest_date}, 시작일: {start_dt}")
|
||||
else:
|
||||
config = get_config()
|
||||
start_dt = config.data_api.get('start_date', '20170101')
|
||||
logger.info(f"저장된 데이터 없음, 기본 시작일: {start_dt}")
|
||||
else:
|
||||
start_dt = start_date
|
||||
|
||||
# 날짜 검증
|
||||
if start_dt > end_dt:
|
||||
logger.info("최신 데이터가 이미 존재합니다.")
|
||||
return 0
|
||||
|
||||
# 각 관측 지점별 데이터 수집
|
||||
for stn_id in stn_ids:
|
||||
for chunk_start, chunk_end in fetch_date_range_chunks(start_dt, end_dt, chunk_days):
|
||||
logger.info(f"지점 {stn_id} 데이터 요청: {chunk_start} ~ {chunk_end}")
|
||||
|
||||
items = get_asos_weather(
|
||||
self.service_key,
|
||||
stn_id,
|
||||
chunk_start,
|
||||
chunk_end,
|
||||
self.session
|
||||
)
|
||||
|
||||
if items:
|
||||
saved = self.save_items_to_db(items, conn, table)
|
||||
total_saved += saved
|
||||
else:
|
||||
logger.warning(f"지점 {stn_id} {chunk_start}~{chunk_end} 데이터 없음")
|
||||
|
||||
logger.info(f"총 {total_saved}건 저장 완료")
|
||||
return total_saved
|
||||
530
services/weather/forecast.py
Normal file
530
services/weather/forecast.py
Normal file
@ -0,0 +1,530 @@
|
||||
# ===================================================================
|
||||
# services/weather/forecast.py
|
||||
# 기상청 예보 API 서비스 모듈
|
||||
# ===================================================================
|
||||
# 기상청 공공데이터포털 API를 통해 다양한 예보 데이터를 조회합니다:
|
||||
# - 초단기예보: 향후 6시간 이내 예보
|
||||
# - 단기예보: 향후 3일간 예보
|
||||
# - 중기예보: 3~10일 후 예보
|
||||
# ===================================================================
|
||||
"""
|
||||
기상청 예보 API 서비스 모듈
|
||||
|
||||
기상청 공공데이터포털 API를 통해 초단기, 단기, 중기 예보 데이터를 조회합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.weather.forecast import get_daily_vilage_forecast, get_midterm_forecast
|
||||
|
||||
# 단기 예보 조회
|
||||
forecast = get_daily_vilage_forecast(service_key)
|
||||
|
||||
# 중기 예보 조회
|
||||
precip_probs, raw_data = get_midterm_forecast(service_key)
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
import requests
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.http_client import create_retry_session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 기상청 API 기본 URL
|
||||
VILAGE_FCST_URL = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0"
|
||||
MID_FCST_URL = "http://apis.data.go.kr/1360000/MidFcstInfoService"
|
||||
|
||||
# 기본 좌표 (파주 지역)
|
||||
DEFAULT_NX = 57
|
||||
DEFAULT_NY = 130
|
||||
|
||||
|
||||
def parse_precip(value: Any) -> float:
|
||||
"""
|
||||
강수량 텍스트를 숫자(mm)로 변환
|
||||
|
||||
기상청 API에서 반환하는 강수량 텍스트를 파싱합니다.
|
||||
|
||||
Args:
|
||||
value: 강수량 값 ('강수없음', '1mm 미만', '3.5mm' 등)
|
||||
|
||||
Returns:
|
||||
강수량 (mm). 파싱 실패 시 0.0
|
||||
|
||||
Examples:
|
||||
>>> parse_precip('강수없음')
|
||||
0.0
|
||||
>>> parse_precip('1mm 미만')
|
||||
0.5
|
||||
>>> parse_precip('3.5')
|
||||
3.5
|
||||
"""
|
||||
if value == '강수없음':
|
||||
return 0.0
|
||||
elif '1mm 미만' in str(value):
|
||||
return 0.5
|
||||
else:
|
||||
# 숫자 추출 시도
|
||||
match = re.search(r"[\d.]+", str(value))
|
||||
if match:
|
||||
try:
|
||||
return float(match.group())
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_latest_base_datetime(now: Optional[datetime] = None) -> Tuple[str, str]:
|
||||
"""
|
||||
최신 발표 시각 계산
|
||||
|
||||
단기예보 API의 base_time은 특정 시간에만 발표됩니다.
|
||||
현재 시간 기준 가장 최근 발표 시각을 계산합니다.
|
||||
|
||||
Args:
|
||||
now: 기준 시간 (None이면 현재 시간)
|
||||
|
||||
Returns:
|
||||
(base_date, base_time) 튜플 (YYYYMMDD, HHMM)
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
# 발표 시각 목록 (하루 8회)
|
||||
base_times = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"]
|
||||
|
||||
# 현재 시간에 맞는 가장 최근 발표 시각 찾기
|
||||
candidate = None
|
||||
for bt in reversed(base_times):
|
||||
hour = int(bt[:2])
|
||||
minute = int(bt[2:])
|
||||
|
||||
if (now.hour > hour) or (now.hour == hour and now.minute >= minute + 10):
|
||||
candidate = bt
|
||||
break
|
||||
|
||||
# 적합한 시각이 없으면 전날 마지막 발표 사용
|
||||
if candidate is None:
|
||||
candidate = "2300"
|
||||
now -= timedelta(days=1)
|
||||
|
||||
base_date = now.strftime("%Y%m%d")
|
||||
return base_date, candidate
|
||||
|
||||
|
||||
def get_ultra_forecast(
|
||||
service_key: str,
|
||||
base_date: Optional[str] = None,
|
||||
base_time: Optional[str] = None,
|
||||
nx: int = DEFAULT_NX,
|
||||
ny: int = DEFAULT_NY
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
초단기예보 조회 (향후 6시간)
|
||||
|
||||
기상청 초단기예보 API를 호출하여 원시 데이터를 반환합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
base_date: 발표 날짜 (YYYYMMDD). None이면 현재 날짜
|
||||
base_time: 발표 시각 (HHMM). None이면 자동 계산
|
||||
nx: 격자 X 좌표
|
||||
ny: 격자 Y 좌표
|
||||
|
||||
Returns:
|
||||
예보 아이템 리스트 (기상청 API 원시 응답)
|
||||
"""
|
||||
if base_date is None or base_time is None:
|
||||
base_date, base_time = get_latest_base_datetime()
|
||||
|
||||
url = f"{VILAGE_FCST_URL}/getUltraSrtFcst"
|
||||
params = {
|
||||
'serviceKey': service_key,
|
||||
'numOfRows': '1000',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': base_date,
|
||||
'base_time': base_time,
|
||||
'nx': str(nx),
|
||||
'ny': str(ny)
|
||||
}
|
||||
|
||||
try:
|
||||
session = create_retry_session(retries=3)
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get('response', {}).get('body', {}).get('items', {}).get('item', [])
|
||||
|
||||
logger.debug(f"초단기예보 조회 완료: {len(items)}건")
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"초단기예보 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_vilage_forecast(
|
||||
service_key: str,
|
||||
base_date: Optional[str] = None,
|
||||
base_time: str = "0200",
|
||||
nx: int = DEFAULT_NX,
|
||||
ny: int = DEFAULT_NY
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
단기예보 조회 (향후 3일)
|
||||
|
||||
기상청 단기예보 API를 호출하여 원시 데이터를 반환합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
base_date: 발표 날짜 (YYYYMMDD). None이면 현재 날짜
|
||||
base_time: 발표 시각 (기본: 0200)
|
||||
nx: 격자 X 좌표
|
||||
ny: 격자 Y 좌표
|
||||
|
||||
Returns:
|
||||
예보 아이템 리스트 (기상청 API 원시 응답)
|
||||
"""
|
||||
if base_date is None:
|
||||
base_date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
url = f"{VILAGE_FCST_URL}/getVilageFcst"
|
||||
params = {
|
||||
'serviceKey': service_key,
|
||||
'numOfRows': '1000',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': base_date,
|
||||
'base_time': base_time,
|
||||
'nx': str(nx),
|
||||
'ny': str(ny)
|
||||
}
|
||||
|
||||
try:
|
||||
session = create_retry_session(retries=3)
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get('response', {}).get('body', {}).get('items', {}).get('item', [])
|
||||
|
||||
logger.debug(f"단기예보 조회 완료: {len(items)}건")
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"단기예보 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_daily_ultra_forecast(service_key: str) -> Dict[str, Dict]:
|
||||
"""
|
||||
초단기예보 일별 요약 데이터 조회
|
||||
|
||||
초단기예보 데이터를 날짜별로 집계하여 반환합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
|
||||
Returns:
|
||||
날짜별 요약 딕셔너리
|
||||
{날짜: {'sumRn': 강수량합계, 'minTa': 최저기온, 'maxTa': 최고기온, 'avgRhm': 평균습도}}
|
||||
"""
|
||||
base_date, base_time = get_latest_base_datetime()
|
||||
items = get_ultra_forecast(service_key, base_date, base_time)
|
||||
|
||||
if not items:
|
||||
return {}
|
||||
|
||||
# 날짜별 데이터 집계
|
||||
daily_data: Dict[str, Dict] = {}
|
||||
|
||||
for item in items:
|
||||
dt = item.get('fcstDate', '')
|
||||
cat = item.get('category', '')
|
||||
val = item.get('fcstValue', '')
|
||||
|
||||
if not dt:
|
||||
continue
|
||||
|
||||
if dt not in daily_data:
|
||||
daily_data[dt] = {'sumRn': 0, 'temps': [], 'rhm': []}
|
||||
|
||||
# 카테고리별 처리
|
||||
if cat == 'RN1': # 1시간 강수량
|
||||
daily_data[dt]['sumRn'] += parse_precip(val)
|
||||
elif cat == 'T1H': # 기온
|
||||
try:
|
||||
daily_data[dt]['temps'].append(float(val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif cat == 'REH': # 습도
|
||||
try:
|
||||
daily_data[dt]['rhm'].append(float(val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 집계 결과 변환
|
||||
result = {}
|
||||
for dt, vals in daily_data.items():
|
||||
temps = vals['temps']
|
||||
rhm = vals['rhm']
|
||||
|
||||
result[dt] = {
|
||||
'sumRn': round(vals['sumRn'], 2),
|
||||
'minTa': min(temps) if temps else 0,
|
||||
'maxTa': max(temps) if temps else 0,
|
||||
'avgRhm': round(sum(rhm) / len(rhm), 1) if rhm else 0
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_daily_vilage_forecast(service_key: str) -> Dict[str, Dict]:
|
||||
"""
|
||||
단기예보 일별 요약 데이터 조회
|
||||
|
||||
단기예보 데이터를 날짜별로 집계하여 반환합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
|
||||
Returns:
|
||||
날짜별 요약 딕셔너리
|
||||
{날짜: {'sumRn': 강수량합계, 'minTa': 최저기온, 'maxTa': 최고기온, 'avgRhm': 평균습도}}
|
||||
"""
|
||||
base_date, _ = get_latest_base_datetime()
|
||||
items = get_vilage_forecast(service_key, base_date)
|
||||
|
||||
if not items:
|
||||
return {}
|
||||
|
||||
# 날짜별 데이터 집계
|
||||
daily_data: Dict[str, Dict] = {}
|
||||
|
||||
for item in items:
|
||||
dt = item.get('fcstDate', '')
|
||||
cat = item.get('category', '')
|
||||
val = item.get('fcstValue', '')
|
||||
|
||||
if not dt:
|
||||
continue
|
||||
|
||||
if dt not in daily_data:
|
||||
daily_data[dt] = {'sumRn': 0, 'minTa': [], 'maxTa': [], 'rhm': []}
|
||||
|
||||
# 카테고리별 처리
|
||||
if cat == 'PCP': # 강수량
|
||||
daily_data[dt]['sumRn'] += parse_precip(val)
|
||||
elif cat == 'TMN': # 최저기온
|
||||
try:
|
||||
daily_data[dt]['minTa'].append(float(val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif cat == 'TMX': # 최고기온
|
||||
try:
|
||||
daily_data[dt]['maxTa'].append(float(val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif cat == 'REH': # 습도
|
||||
try:
|
||||
daily_data[dt]['rhm'].append(float(val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 집계 결과 변환
|
||||
result = {}
|
||||
for dt, vals in daily_data.items():
|
||||
min_ta = vals['minTa']
|
||||
max_ta = vals['maxTa']
|
||||
rhm = vals['rhm']
|
||||
|
||||
result[dt] = {
|
||||
'sumRn': round(vals['sumRn'], 2),
|
||||
'minTa': min(min_ta) if min_ta else 0,
|
||||
'maxTa': max(max_ta) if max_ta else 0,
|
||||
'avgRhm': round(sum(rhm) / len(rhm), 1) if rhm else 0
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_midterm_forecast(
|
||||
service_key: str,
|
||||
reg_id: str = '11B20305'
|
||||
) -> Tuple[Dict[int, int], Dict]:
|
||||
"""
|
||||
중기 강수확률 예보 조회 (3~10일 후)
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
reg_id: 예보 지역 코드 (기본: 파주)
|
||||
|
||||
Returns:
|
||||
(강수확률 딕셔너리, 원시 응답) 튜플
|
||||
강수확률: {일수: 확률} (예: {3: 30, 4: 20, ...})
|
||||
"""
|
||||
url = f"{MID_FCST_URL}/getMidLandFcst"
|
||||
|
||||
# 발표 시각 계산 (06시 또는 18시)
|
||||
now = datetime.now()
|
||||
if now.hour < 6:
|
||||
tm_fc = (now - timedelta(days=1)).strftime("%Y%m%d") + "1800"
|
||||
elif now.hour < 18:
|
||||
tm_fc = now.strftime("%Y%m%d") + "0600"
|
||||
else:
|
||||
tm_fc = now.strftime("%Y%m%d") + "1800"
|
||||
|
||||
params = {
|
||||
'serviceKey': service_key,
|
||||
'regId': reg_id,
|
||||
'tmFc': tm_fc,
|
||||
'numOfRows': '10',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
}
|
||||
|
||||
try:
|
||||
session = create_retry_session(retries=3)
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get('response', {}).get('body', {}).get('items', {}).get('item', [])
|
||||
|
||||
if not items:
|
||||
logger.warning(f"중기예보 응답 없음: tmFc={tm_fc}, regId={reg_id}")
|
||||
return {}, {}
|
||||
|
||||
item = items[0]
|
||||
|
||||
# 3~10일 후 강수확률 추출
|
||||
precip_probs = {}
|
||||
for day in range(3, 11):
|
||||
key = f'rnSt{day}'
|
||||
try:
|
||||
precip_probs[day] = int(item.get(key, 0))
|
||||
except (ValueError, TypeError):
|
||||
precip_probs[day] = 0
|
||||
|
||||
logger.debug(f"중기 강수확률 조회 완료: {precip_probs}")
|
||||
return precip_probs, item
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"중기예보 조회 실패: {e}")
|
||||
return {}, {}
|
||||
|
||||
|
||||
def get_midterm_temperature(
|
||||
service_key: str,
|
||||
reg_id: str = '11B20305'
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
"""
|
||||
중기 기온 예보 조회 (3~10일 후)
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
reg_id: 예보 지역 코드 (기본: 파주)
|
||||
|
||||
Returns:
|
||||
일자별 기온 딕셔너리
|
||||
{일수: {'min': 최저기온, 'max': 최고기온}}
|
||||
"""
|
||||
url = f"{MID_FCST_URL}/getMidTa"
|
||||
|
||||
# 발표 시각 계산
|
||||
now = datetime.now()
|
||||
if now.hour < 6:
|
||||
tm_fc = (now - timedelta(days=1)).strftime("%Y%m%d") + "1800"
|
||||
elif now.hour < 18:
|
||||
tm_fc = now.strftime("%Y%m%d") + "0600"
|
||||
else:
|
||||
tm_fc = now.strftime("%Y%m%d") + "1800"
|
||||
|
||||
params = {
|
||||
'serviceKey': service_key,
|
||||
'regId': reg_id,
|
||||
'tmFc': tm_fc,
|
||||
'pageNo': '1',
|
||||
'numOfRows': '10',
|
||||
'dataType': 'JSON'
|
||||
}
|
||||
|
||||
try:
|
||||
session = create_retry_session(retries=3)
|
||||
response = session.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get('response', {}).get('body', {}).get('items', {}).get('item', [])
|
||||
|
||||
if not items:
|
||||
logger.warning(f"중기기온예보 응답 없음: tmFc={tm_fc}, regId={reg_id}")
|
||||
return {}
|
||||
|
||||
item = items[0]
|
||||
|
||||
# 3~10일 후 기온 추출
|
||||
temps = {}
|
||||
for day in range(3, 11):
|
||||
min_key = f'taMin{day}'
|
||||
max_key = f'taMax{day}'
|
||||
try:
|
||||
temps[day] = {
|
||||
'min': int(item.get(min_key, 0)),
|
||||
'max': int(item.get(max_key, 0))
|
||||
}
|
||||
except (ValueError, TypeError):
|
||||
temps[day] = {'min': 0, 'max': 0}
|
||||
|
||||
logger.debug(f"중기 기온 조회 완료: {temps}")
|
||||
return temps
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"중기기온예보 조회 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_weekly_precip(service_key: str) -> Dict[str, float]:
|
||||
"""
|
||||
금주 일요일까지의 일별 예상 강수량 조회
|
||||
|
||||
단기예보와 중기예보를 조합하여 금주 일요일까지의 강수량을 예측합니다.
|
||||
|
||||
Args:
|
||||
service_key: 공공데이터포털 API 키
|
||||
|
||||
Returns:
|
||||
날짜별 예상 강수량 딕셔너리 {YYYYMMDD: 강수량(mm)}
|
||||
"""
|
||||
today = date.today()
|
||||
sunday = today + timedelta(days=(6 - today.weekday()))
|
||||
|
||||
result = {}
|
||||
|
||||
# 단기예보 데이터 (오늘~+2일)
|
||||
vilage_data = get_daily_vilage_forecast(service_key)
|
||||
for dt, vals in vilage_data.items():
|
||||
dt_date = datetime.strptime(dt, "%Y%m%d").date()
|
||||
if dt_date <= sunday:
|
||||
result[dt] = vals['sumRn']
|
||||
|
||||
# 중기예보 강수확률 (3일 이후)
|
||||
precip_probs, _ = get_midterm_forecast(service_key)
|
||||
for day, prob in precip_probs.items():
|
||||
target_date = today + timedelta(days=day)
|
||||
if target_date <= sunday:
|
||||
dt = target_date.strftime("%Y%m%d")
|
||||
if dt not in result:
|
||||
# 강수확률을 기반으로 예상 강수량 추정
|
||||
# (간단한 휴리스틱: 확률 * 0.1mm)
|
||||
result[dt] = round(prob * 0.1, 1)
|
||||
|
||||
return result
|
||||
343
services/weather/precipitation.py
Normal file
343
services/weather/precipitation.py
Normal file
@ -0,0 +1,343 @@
|
||||
# ===================================================================
|
||||
# services/weather/precipitation.py
|
||||
# 강수량 데이터 서비스 모듈
|
||||
# ===================================================================
|
||||
# 시간별 강수량 예보 데이터를 조회하고 요약 정보를 생성합니다.
|
||||
# HTML 테이블 생성 및 SQLite/MySQL 저장을 지원합니다.
|
||||
# ===================================================================
|
||||
"""
|
||||
강수량 데이터 서비스 모듈
|
||||
|
||||
시간별 강수량 예보 데이터를 조회하고 요약 정보를 생성합니다.
|
||||
다양한 출력 형식(HTML, JSON, 텍스트)을 지원합니다.
|
||||
|
||||
사용 예시:
|
||||
from services.weather.precipitation import PrecipitationService
|
||||
|
||||
service = PrecipitationService(service_key)
|
||||
summary = service.get_daily_summary()
|
||||
html = service.generate_html_table()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
from core.logging_utils import get_logger
|
||||
from core.config import get_config
|
||||
from .forecast import parse_precip, get_ultra_forecast, get_vilage_forecast
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PrecipitationService:
|
||||
"""
|
||||
강수량 데이터 서비스 클래스
|
||||
|
||||
초단기예보와 단기예보를 조합하여 시간별 강수량 예보를 제공합니다.
|
||||
|
||||
Attributes:
|
||||
service_key: 기상청 API 서비스 키
|
||||
start_hour: 집계 시작 시간 (기본: 10)
|
||||
end_hour: 집계 종료 시간 (기본: 22)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_key: Optional[str] = None,
|
||||
start_hour: int = 10,
|
||||
end_hour: int = 22
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
service_key: API 키 (None이면 설정에서 로드)
|
||||
start_hour: 집계 시작 시간
|
||||
end_hour: 집계 종료 시간
|
||||
"""
|
||||
if service_key is None:
|
||||
config = get_config()
|
||||
service_key = config.weather_service.get('service_key') or config.data_api.get('service_key', '')
|
||||
|
||||
self.service_key = service_key
|
||||
self.start_hour = start_hour
|
||||
self.end_hour = end_hour
|
||||
|
||||
def get_hourly_ultra_data(self, target_date: Optional[str] = None) -> Dict[int, float]:
|
||||
"""
|
||||
초단기예보에서 시간별 강수량 추출
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
|
||||
Returns:
|
||||
시간별 강수량 딕셔너리 {시간: 강수량(mm)}
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
items = get_ultra_forecast(self.service_key)
|
||||
result = {}
|
||||
|
||||
for item in items:
|
||||
if item.get('category') != 'RN1':
|
||||
continue
|
||||
if item.get('fcstDate') != target_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
hour = int(item['fcstTime'][:2])
|
||||
if self.start_hour <= hour <= self.end_hour:
|
||||
result[hour] = parse_precip(item['fcstValue'])
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def get_hourly_vilage_data(self, target_date: Optional[str] = None) -> Dict[int, float]:
|
||||
"""
|
||||
단기예보에서 시간별 강수량 추출
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
|
||||
Returns:
|
||||
시간별 강수량 딕셔너리 {시간: 강수량(mm)}
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
items = get_vilage_forecast(self.service_key, base_date=target_date)
|
||||
result = {}
|
||||
|
||||
for item in items:
|
||||
if item.get('category') != 'PCP':
|
||||
continue
|
||||
if item.get('fcstDate') != target_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
hour = int(item['fcstTime'][:2])
|
||||
if self.start_hour <= hour <= self.end_hour:
|
||||
result[hour] = parse_precip(item['fcstValue'])
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def get_daily_summary(
|
||||
self,
|
||||
target_date: Optional[str] = None
|
||||
) -> Tuple[List[Tuple[str, float]], float]:
|
||||
"""
|
||||
일별 강수량 요약 조회
|
||||
|
||||
초단기예보를 우선으로 하고, 없는 시간대는 단기예보로 보완합니다.
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
|
||||
Returns:
|
||||
(시간별 강수량 리스트, 총 강수량) 튜플
|
||||
시간별: [('10:00', 0.5), ('11:00', 0.0), ...]
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 초단기예보 데이터 (우선)
|
||||
ultra_data = self.get_hourly_ultra_data(target_date)
|
||||
|
||||
# 단기예보 데이터 (보완용)
|
||||
vilage_data = self.get_hourly_vilage_data(target_date)
|
||||
|
||||
time_precip_list = []
|
||||
total_rainfall = 0.0
|
||||
|
||||
for hour in range(self.start_hour, self.end_hour + 1):
|
||||
# 초단기예보 우선, 없으면 단기예보 사용
|
||||
rainfall = ultra_data.get(hour, vilage_data.get(hour, 0.0))
|
||||
time_str = f"{hour:02d}:00"
|
||||
time_precip_list.append((time_str, rainfall))
|
||||
total_rainfall += rainfall
|
||||
|
||||
return time_precip_list, round(total_rainfall, 1)
|
||||
|
||||
def generate_html_table(
|
||||
self,
|
||||
target_date: Optional[str] = None,
|
||||
title: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
강수량 요약 HTML 테이블 생성
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
title: 테이블 제목 (None이면 기본 제목)
|
||||
|
||||
Returns:
|
||||
HTML 문자열
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
time_precip_list, total_rainfall = self.get_daily_summary(target_date)
|
||||
|
||||
if title is None:
|
||||
title = f"[{self.start_hour}:00 ~ {self.end_hour}:00 예상 강수량]"
|
||||
|
||||
lines = [
|
||||
'<div class="weatherinfo" style="max-width: 100%; overflow-x: auto; padding: 10px;">',
|
||||
f'<h3 style="font-size: 1.8em; text-align: center; margin: 20px 0;">{title}</h3>',
|
||||
'<table style="border-collapse: collapse; width: 100%; max-width: 400px; margin: 0 auto;">',
|
||||
'<thead><tr>',
|
||||
'<th style="border: 1px solid #333; padding: 2px; background-color: #f0f0f0;">시간</th>',
|
||||
'<th style="border: 1px solid #333; padding: 2px; background-color: #f0f0f0;">강수량</th>',
|
||||
'</tr></thead><tbody>'
|
||||
]
|
||||
|
||||
for time_str, rainfall in time_precip_list:
|
||||
lines.append(
|
||||
f'<tr><td style="border: 1px solid #333; text-align: center;">{time_str}</td>'
|
||||
f'<td style="border: 1px solid #333; text-align: center;">{rainfall}mm</td></tr>'
|
||||
)
|
||||
|
||||
lines.append(
|
||||
f'<tr><td colspan="2" style="border: 1px solid #333; text-align: center; font-weight: bold;">'
|
||||
f'총 예상 강수량: {total_rainfall:.1f}mm</td></tr>'
|
||||
)
|
||||
lines.append('</tbody></table>')
|
||||
lines.append('<p style="text-align:right; font-size: 0.8em;">초단기 + 단기 예보 기준</p>')
|
||||
lines.append('</div>')
|
||||
|
||||
return ''.join(lines)
|
||||
|
||||
def generate_text_summary(
|
||||
self,
|
||||
target_date: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
강수량 요약 텍스트 생성
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
|
||||
Returns:
|
||||
텍스트 요약 문자열
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
time_precip_list, total_rainfall = self.get_daily_summary(target_date)
|
||||
|
||||
lines = [
|
||||
f"📅 {target_date} 예상 강수량",
|
||||
f"⏰ {self.start_hour}:00 ~ {self.end_hour}:00",
|
||||
"-" * 20
|
||||
]
|
||||
|
||||
for time_str, rainfall in time_precip_list:
|
||||
lines.append(f"{time_str} → {rainfall}mm")
|
||||
|
||||
lines.append("-" * 20)
|
||||
lines.append(f"☔ 총 예상 강수량: {total_rainfall:.1f}mm")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def save_to_sqlite(
|
||||
self,
|
||||
target_date: Optional[str] = None,
|
||||
db_path: str = '/data/weather.sqlite'
|
||||
):
|
||||
"""
|
||||
강수량 데이터를 SQLite에 저장
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
db_path: SQLite 데이터베이스 파일 경로
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
time_precip_list, total_rainfall = self.get_daily_summary(target_date)
|
||||
|
||||
# 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 테이블 생성
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS precipitation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
rainfall REAL NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS precipitation_summary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL UNIQUE,
|
||||
total_rainfall REAL NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# 기존 데이터 삭제
|
||||
cursor.execute('DELETE FROM precipitation WHERE date = ?', (target_date,))
|
||||
cursor.execute('DELETE FROM precipitation_summary WHERE date = ?', (target_date,))
|
||||
|
||||
# 시간별 데이터 삽입
|
||||
cursor.executemany(
|
||||
'INSERT INTO precipitation (date, time, rainfall) VALUES (?, ?, ?)',
|
||||
[(target_date, t, r) for t, r in time_precip_list]
|
||||
)
|
||||
|
||||
# 총 강수량 삽입
|
||||
cursor.execute(
|
||||
'INSERT INTO precipitation_summary (date, total_rainfall) VALUES (?, ?)',
|
||||
(target_date, total_rainfall)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info(f"SQLite 저장 완료: {target_date}, 총 {total_rainfall}mm")
|
||||
|
||||
def get_precipitation_info(
|
||||
self,
|
||||
target_date: Optional[str] = None,
|
||||
output_format: str = 'dict'
|
||||
) -> Any:
|
||||
"""
|
||||
강수량 정보 조회 (다양한 형식 지원)
|
||||
|
||||
Args:
|
||||
target_date: 대상 날짜 (YYYYMMDD). None이면 오늘
|
||||
output_format: 출력 형식 ('dict', 'html', 'text', 'json')
|
||||
|
||||
Returns:
|
||||
요청된 형식의 강수량 정보
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
if output_format == 'html':
|
||||
return self.generate_html_table(target_date)
|
||||
elif output_format == 'text':
|
||||
return self.generate_text_summary(target_date)
|
||||
elif output_format == 'json':
|
||||
time_precip_list, total = self.get_daily_summary(target_date)
|
||||
return {
|
||||
'date': target_date,
|
||||
'hourly': [{'time': t, 'rainfall': r} for t, r in time_precip_list],
|
||||
'total': total
|
||||
}
|
||||
else:
|
||||
time_precip_list, total = self.get_daily_summary(target_date)
|
||||
return {
|
||||
'date': target_date,
|
||||
'hourly': time_precip_list,
|
||||
'total': total
|
||||
}
|
||||
Reference in New Issue
Block a user