feat: initial commit - unified FGTools from static, weather, mattermost-noti

This commit is contained in:
2025-12-31 09:56:37 +09:00
commit 4ff5dba4b1
29 changed files with 5786 additions and 0 deletions

17
services/__init__.py Normal file
View File

@ -0,0 +1,17 @@
# ===================================================================
# services/__init__.py
# FGTools 서비스 패키지 초기화
# ===================================================================
# 도메인별 서비스 모듈들을 제공합니다:
# - weather: 기상 데이터 서비스
# - pos: POS 데이터 서비스
# - analytics: 분석 서비스 (GA4, 대기질, 예측)
# - notification: 알림 서비스 (Notion, Mattermost)
# ===================================================================
__all__ = [
'weather',
'pos',
'analytics',
'notification',
]

View 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',
]

View 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
View 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

View 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

View 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',
]

View 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)

View 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})"

View 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
View 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

View 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

View 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
}