Compare commits
16 Commits
d8a3efab06
...
1724a80426
| Author | SHA1 | Date | |
|---|---|---|---|
| 1724a80426 | |||
| 072d038410 | |||
| e9a4f8e401 | |||
| 8991b92ac8 | |||
| 5c3b20522b | |||
| c9ed91d885 | |||
| c885bab007 | |||
| c736b2982f | |||
| 0e0b64f20f | |||
| cd684eae22 | |||
| 19103b3226 | |||
| c94ca954dd | |||
| ac0cbbc44a | |||
| a1f8c74ec8 | |||
| 3420bee2bf | |||
| 20ef587800 |
57
.gitignore
vendored
57
.gitignore
vendored
|
|
@ -1,34 +1,39 @@
|
||||||
# Environment
|
# Dependencies
|
||||||
.env
|
node_modules/
|
||||||
.env.local
|
.expo/
|
||||||
.env.*.local
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
# Virtual Environment
|
# Python cache
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
env/
|
|
||||||
ENV/
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
*.pyo
|
||||||
*.so
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
# IDE
|
# Database & Credentials
|
||||||
.idea/
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
secrets.json
|
||||||
|
|
||||||
|
# IDEs and Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
.idea/
|
||||||
*.swo
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
# OS
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Distribution / Build
|
# Temporary directories
|
||||||
dist/
|
temp_git/
|
||||||
build/
|
scratch/
|
||||||
*.egg-info/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
|
||||||
129
AGENTS.md
Normal file
129
AGENTS.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# SolarPower Agents Guide
|
||||||
|
|
||||||
|
This document provides context, commands, and guidelines for AI agents working on the SolarPower codebase.
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
SolarPower is an integrated solar power monitoring system consisting of:
|
||||||
|
- **Crawler**: Python-based data collector running on Oracle Cloud (formerly Synology NAS). Uses Tailscale to route via NAS IP to avoid blocks.
|
||||||
|
- **API Server**: FastAPI backend on Oracle Cloud acting as a middleware.
|
||||||
|
- **App**: React Native (Expo) mobile/web application for monitoring.
|
||||||
|
- **Database**: Supabase (PostgreSQL) for data persistence.
|
||||||
|
|
||||||
|
## 2. Directory Structure
|
||||||
|
- `crawler/`: Python crawlers and scheduler.
|
||||||
|
- `crawlers/`: Individual site crawler implementations.
|
||||||
|
- `crawler_manager.py`: Smart scheduler using SQLite (`crawler_manager.db`).
|
||||||
|
- `api_server/`: FastAPI backend.
|
||||||
|
- `app/routers/`: API endpoints (`plants`, `stats`, `upload`).
|
||||||
|
- `app/core/`: Configuration and utilities.
|
||||||
|
- `app/`: React Native Expo frontend.
|
||||||
|
- `components/`: Reusable UI components.
|
||||||
|
- `screens/`: Application screens.
|
||||||
|
|
||||||
|
## 3. Development Commands
|
||||||
|
|
||||||
|
### 📱 Mobile App (`app/`)
|
||||||
|
- **Environment**: Node.js, Expo
|
||||||
|
- **Install**: `npm install`
|
||||||
|
- **Run (Dev)**: `npx expo start`
|
||||||
|
- Press `w` for web, `a` for Android.
|
||||||
|
- **Build (Web)**: `npx expo export -p web`
|
||||||
|
- Outputs to `dist/` directory.
|
||||||
|
- Use this command for deployment builds.
|
||||||
|
- **Lint**: No strict ESLint config. Follow existing code patterns (functional components, hooks).
|
||||||
|
- **Test**:
|
||||||
|
- **E2E**: Use `playwright` skill/tool to verify the web build running on a local server.
|
||||||
|
- **Manual**: Run `npx expo start` and use Expo Go.
|
||||||
|
|
||||||
|
### ⚡ API Server (`api_server/`)
|
||||||
|
- **Environment**: Python 3.10+
|
||||||
|
- **Install**: `pip install -r requirements.txt`
|
||||||
|
- **Run (Dev)**: `uvicorn app.main:app --reload`
|
||||||
|
- Runs on port 8000 by default.
|
||||||
|
- **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api`
|
||||||
|
- **Docs**: Access Swagger UI at `http://localhost:8000/docs`
|
||||||
|
- **Test**:
|
||||||
|
- No automated test suite (pytest) currently.
|
||||||
|
- Verify endpoints manually using `curl` or Swagger UI.
|
||||||
|
|
||||||
|
### 🐍 Crawler (`crawler/`)
|
||||||
|
- **Environment**: Python 3.10+
|
||||||
|
- **Install**: Manual dependency installation (requests, python-dotenv, supabase).
|
||||||
|
- **Run (Manual)**: `python main.py`
|
||||||
|
- Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`.
|
||||||
|
- **Run (Force)**: `python main.py --force` (Ignores scheduler constraints)
|
||||||
|
- **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning)
|
||||||
|
- **Testing**:
|
||||||
|
- No standard test runner. Use standalone scripts in `tests/`.
|
||||||
|
- **Run Single Test**: `python tests/debug_kremc.py` (or similar script for specific site).
|
||||||
|
|
||||||
|
## 4. Code Style & Standards
|
||||||
|
|
||||||
|
### 🐍 Python (Crawler & API)
|
||||||
|
- **Style**: Follow PEP 8 standards.
|
||||||
|
- **Indentation**: 4 spaces.
|
||||||
|
- **Typing**: Use type hints where possible (`def func(a: int) -> str:`).
|
||||||
|
- **Frameworks**:
|
||||||
|
- **FastAPI**: Use Pydantic models for request/response schemas.
|
||||||
|
- **Crawler**: Use `crawler_manager` for scheduling. Do not hardcode run times.
|
||||||
|
- **Imports**: Group standard lib, third-party, then local modules.
|
||||||
|
- **Error Handling**:
|
||||||
|
- API: Raise `HTTPException` with clear status codes.
|
||||||
|
- Crawler: Catch exceptions per site to prevent one failure from stopping others.
|
||||||
|
- **Async**: API Server uses `async/await`. Crawler is synchronous (mostly).
|
||||||
|
- **Naming**: `snake_case` for functions/variables, `PascalCase` for classes.
|
||||||
|
|
||||||
|
### ⚛️ JavaScript/React Native (`app/`)
|
||||||
|
- **Style**: Functional Components with Hooks (`useState`, `useEffect`).
|
||||||
|
- **Indentation**: 2 spaces.
|
||||||
|
- **Quotes**: Single quotes `'` preferred for strings.
|
||||||
|
- **Semicolons**: Always use semicolons.
|
||||||
|
- **Naming**: `camelCase` for variables/functions, `PascalCase` for components.
|
||||||
|
- **Styling**: Use `StyleSheet.create({})`. Avoid inline style objects if possible.
|
||||||
|
- **Navigation**: Uses `@react-navigation`. Ensure strict typing for route params.
|
||||||
|
- **Libraries**:
|
||||||
|
- Charts: `react-native-gifted-charts`
|
||||||
|
- UI: `react-native-svg`, `expo-linear-gradient`
|
||||||
|
- **State**: Local state preferred. Keep components pure where possible.
|
||||||
|
|
||||||
|
## 5. Deployment Workflows
|
||||||
|
|
||||||
|
### 🚀 Web App Deployment
|
||||||
|
1. **Build**: Run `npx expo export -p web` locally to generate `dist/`.
|
||||||
|
2. **Upload**: Use SCP to transfer `dist/` to the server.
|
||||||
|
- Example: `scp -r dist user@server:~/dist_temp`
|
||||||
|
3. **Deploy**: Move the uploaded folder to the web root (`/var/www/html/dist`).
|
||||||
|
- Command: `ssh user@server "sudo rm -rf /var/www/html/dist && sudo mv ~/dist_temp /var/www/html/dist"`
|
||||||
|
4. **Refresh**: `sudo systemctl reload nginx` (optional, for cache clearing).
|
||||||
|
|
||||||
|
### ☁️ Server Deployment
|
||||||
|
1. **Push**: Commit and push changes to the remote Git repository.
|
||||||
|
2. **Pull**: SSH into the server and pull changes.
|
||||||
|
- Command: `ssh user@server "cd ~/solorpower_server && git pull"`
|
||||||
|
3. **Restart**: Restart the API service.
|
||||||
|
- Command: `sudo systemctl restart solar-api`
|
||||||
|
|
||||||
|
## 6. Architecture & Patterns
|
||||||
|
|
||||||
|
### 🧠 Crawler Scheduler (`crawler_manager.py`)
|
||||||
|
- **LEARNING Mode**: Runs every cycle to determine update patterns.
|
||||||
|
- **OPTIMIZED Mode**: Runs only during specific 10-minute windows around the learned update time.
|
||||||
|
- **Storage**: Uses local SQLite (`site_rules` table) to persist scheduling rules.
|
||||||
|
|
||||||
|
### 🌐 API Structure
|
||||||
|
- **Routers**:
|
||||||
|
- `plants`: Basic plant info and real-time status.
|
||||||
|
- `stats`: Aggregated statistics (daily, monthly, yearly).
|
||||||
|
- `upload`: Excel file processing for manual data entry.
|
||||||
|
- **CORS**: Configured to allow all origins (`*`) in development.
|
||||||
|
|
||||||
|
## 7. Critical Rules for Agents
|
||||||
|
1. **Timezones**: All internal logic should handle KST (UTC+9) correctly. Be careful with UTC conversions in DB interactions.
|
||||||
|
2. **Data Integrity**: `crawler_manager` prevents duplicate runs. Respect `should_run` checks.
|
||||||
|
3. **Error Recovery**: Crawlers must be robust against network failures (retry logic).
|
||||||
|
4. **Secrets**: Never commit `.env` files. Use `python-dotenv`.
|
||||||
|
5. **Testing**: Always verify changes locally before deploying.
|
||||||
|
- **App**: Check UI changes on local web server.
|
||||||
|
- **API**: Check endpoints response.
|
||||||
|
- **Crawler**: Run debug scripts.
|
||||||
|
6. **Networking**: When modifying crawlers, be aware that Oracle Cloud uses Tailscale to route traffic through the Synology NAS (Exit Node) to avoid IP blocks from monitoring sites. Ensure Tailscale is active on the host.
|
||||||
335
README.md
Normal file
335
README.md
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
# ☀️ SolarPower 태양광 발전 통합 관제 시스템
|
||||||
|
|
||||||
|
태양광 발전소의 실시간 발전 데이터를 수집하고 모니터링하는 통합 관제 시스템입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 목차
|
||||||
|
|
||||||
|
1. [시스템 개요](#시스템-개요)
|
||||||
|
2. [전체 아키텍처](#전체-아키텍처)
|
||||||
|
3. [인프라 구성](#인프라-구성)
|
||||||
|
4. [크롤러 (Synology NAS / PC)](#크롤러-synology-nas--pc)
|
||||||
|
5. [API 서버 (Oracle Cloud)](#api-서버-oracle-cloud)
|
||||||
|
6. [모바일 앱 (React Native/Expo)](#모바일-앱-react-nativeexpo)
|
||||||
|
7. [데이터베이스 (Supabase)](#데이터베이스-supabase)
|
||||||
|
8. [문제 해결 가이드](#문제-해결-가이드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시스템 개요
|
||||||
|
|
||||||
|
### 지원 발전소
|
||||||
|
|
||||||
|
| 호기 | 크롤러 타입 | 모니터링 시스템 | 비고 |
|
||||||
|
|------|------------|----------------|------|
|
||||||
|
| 1호기 | NREMS | nrems.co.kr | 1,2호기 통합 계정 |
|
||||||
|
| 2호기 | NREMS | nrems.co.kr | 인버터별 분리 처리 |
|
||||||
|
| 3호기 | NREMS | nrems.co.kr | |
|
||||||
|
| 4호기 | NREMS | nrems.co.kr | |
|
||||||
|
| 5호기 | KREMC | kremc.kr | JWT 인증, 월 단위 수집 |
|
||||||
|
| 6호기 | Sun-WMS | sun-wms.com | 암호화된 페이로드, 월 단위 수집 |
|
||||||
|
| 8호기 | Hyundai | hyundai-es.co.kr | 월별 통계 API 사용 (초고속) |
|
||||||
|
| 9호기 | NREMS | nrems.co.kr | |
|
||||||
|
| 10호기 | CMSolar | cmsolar2.kr | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph 외부시스템["외부 모니터링 시스템"]
|
||||||
|
NREMS["NREMS<br/>(1,2,3,4,9호기)"]
|
||||||
|
KREMC["KREMC<br/>(5호기)"]
|
||||||
|
SUNWMS["Sun-WMS<br/>(6호기)"]
|
||||||
|
HYUNDAI["Hyundai<br/>(8호기)"]
|
||||||
|
CMSOLAR["CMSolar<br/>(10호기)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CLOUD["Oracle Cloud (CRAWLER & API)"]
|
||||||
|
FASTAPI["FastAPI Server<br/>(Port 8000)"]
|
||||||
|
NGINX["Nginx Reverse Proxy<br/>(Port 443)"]
|
||||||
|
CRAWLER["Python Crawler<br/>(Tailscale Routing)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph NAS["Synology NAS (Exit Node)"]
|
||||||
|
TAILSCALE["Tailscale Node<br/>(Home IP Mapping)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SUPABASE["Supabase (PostgreSQL)"]
|
||||||
|
DB_PLANTS["plants 테이블"]
|
||||||
|
DB_LOGS["solar_logs 테이블"]
|
||||||
|
DB_DAILY["daily_stats 테이블"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CLIENT["클라이언트"]
|
||||||
|
APP["React Native App"]
|
||||||
|
WEB["Web Browser"]
|
||||||
|
end
|
||||||
|
|
||||||
|
NREMS --> CRAWLER
|
||||||
|
KREMC --> CRAWLER
|
||||||
|
SUNWMS --> CRAWLER
|
||||||
|
HYUNDAI --> CRAWLER
|
||||||
|
CMSOLAR --> CRAWLER
|
||||||
|
|
||||||
|
CRAWLER -.->|Tailscale Tunnel| TAILSCALE
|
||||||
|
TAILSCALE -.->|Proxy Request| 외부시스템
|
||||||
|
|
||||||
|
CRAWLER -->|INSERT| DB_LOGS
|
||||||
|
CRAWLER -->|UPSERT| DB_DAILY
|
||||||
|
|
||||||
|
FASTAPI -->|SELECT| DB_PLANTS
|
||||||
|
FASTAPI -->|SELECT| DB_LOGS
|
||||||
|
FASTAPI -->|SELECT| DB_DAILY
|
||||||
|
|
||||||
|
NGINX --> FASTAPI
|
||||||
|
|
||||||
|
APP -->|HTTPS| NGINX
|
||||||
|
WEB -->|HTTPS| NGINX
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인프라 구성
|
||||||
|
|
||||||
|
### 서버 접속 정보
|
||||||
|
|
||||||
|
| 서버 | 용도 | 접속 명령어 |
|
||||||
|
|------|------|------------|
|
||||||
|
| **Oracle Cloud** | API 서버 & 크롤러 | `ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212` |
|
||||||
|
| **Synology NAS** | 데이터 백업 & Tailscale Exit Node | `ssh -p2022 haneulai@192.168.45.151` |
|
||||||
|
|
||||||
|
### 도메인
|
||||||
|
|
||||||
|
| 도메인 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `https://solorpower.dadot.net` | API 서버 (Nginx 리버스 프록시) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 크롤러 (Oracle Cloud / PC)
|
||||||
|
|
||||||
|
### 위치
|
||||||
|
```
|
||||||
|
d:\dev\etc\SolorPower\crawler\ (Local PC)
|
||||||
|
/home/ubuntu/SolorPower/crawler/ (Oracle Cloud)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 네트워크 구성 (Tailscale)
|
||||||
|
일부 모니터링 사이트에서 Oracle Cloud IP가 차단되는 문제를 해결하기 위해 **Tailscale**을 사용하여 네트워크를 구성하였습니다.
|
||||||
|
- **주 호스트**: Oracle Cloud (크롤러 실행)
|
||||||
|
- **Exit Node**: Synology NAS (가정용 IP 제공)
|
||||||
|
- **동작**: 크롤링 요청 시 Tailscale 터널을 통해 NAS의 IP를 사용하여 외부 시스템에 접근합니다.
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
```
|
||||||
|
crawler/
|
||||||
|
├── main.py # 메인 실행 파일 (실시간 수집용)
|
||||||
|
├── crawler_gui.py # GUI 관리 도구 (과거 데이터 수집용) ← NEW!
|
||||||
|
├── config.py # 발전소/인증 설정
|
||||||
|
├── database.py # Supabase 연동
|
||||||
|
├── fetch_history.py # 과거 데이터 수집 로직
|
||||||
|
├── .env # 환경 변수 (SUPABASE_URL, SUPABASE_KEY)
|
||||||
|
└── crawlers/
|
||||||
|
├── __init__.py # 크롤러 라우팅
|
||||||
|
├── base.py # 공통 유틸리티
|
||||||
|
├── nrems.py # NREMS 크롤러
|
||||||
|
├── kremc.py # KREMC 크롤러
|
||||||
|
├── sun_wms.py # Sun-WMS 크롤러
|
||||||
|
├── hyundai.py # Hyundai 크롤러
|
||||||
|
└── cmsolar.py # CMSolar 크롤러
|
||||||
|
```
|
||||||
|
|
||||||
|
### GUI 관리 도구 (NEW)
|
||||||
|
과거 데이터를 편리하게 수집하고 복구하기 위한 GUI 도구가 추가되었습니다.
|
||||||
|
|
||||||
|
1. **실행**:
|
||||||
|
```bash
|
||||||
|
python crawler_gui.py
|
||||||
|
```
|
||||||
|
2. **기능**:
|
||||||
|
- 발전소별 실시간 상태 확인
|
||||||
|
- **과거 데이터 수집**: 날짜 범위를 지정하여 일별/월별 데이터 일괄 수집
|
||||||
|
- **로그 정리**: 미래 날짜로 잘못 들어간 오염 데이터 삭제 기능
|
||||||
|
|
||||||
|
### 자동 수집 (Cron)
|
||||||
|
```bash
|
||||||
|
# 30분마다 실행 (Oracle Cloud 환경)
|
||||||
|
*/30 * * * * cd /home/ubuntu/SolorPower/crawler && /home/ubuntu/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 크롤러별 개선 사항 (2026.01)
|
||||||
|
- **공통**: 미래 날짜 데이터(UTC/KST 혼동)가 저장되는 버그 수정 및 자동 청소 로직 추가.
|
||||||
|
- **NREMS**: 일별 데이터 수집 시 월 단위로 분할 요청하여 끊김 방지.
|
||||||
|
- **KREMC**: 5호기 인증 토큰 파싱 개선 및 월 단위 배치 수집 적용.
|
||||||
|
- **Sun-WMS**: 6호기 세션 유지 및 월 단위 수집 안정화.
|
||||||
|
- **Hyundai**: 8호기 `getSolraMonthWork` API를 활용하여 1년치 데이터를 수십 초 내에 수집하도록 최적화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 서버 (Oracle Cloud)
|
||||||
|
|
||||||
|
### 위치
|
||||||
|
```
|
||||||
|
/home/ubuntu/SolorPower/api_server/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 환경 설정 (.env)
|
||||||
|
```env
|
||||||
|
SUPABASE_URL=https://xxxx.supabase.co
|
||||||
|
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...
|
||||||
|
DEBUG=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 관리
|
||||||
|
`sudo systemctl restart solar-api`
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/plants/{plant_id}/stats` | 발전소 통계 조회 (일/월/년) |
|
||||||
|
| GET | `/plants/{plant_id}/stats/today` | 오늘(또는 특정일) 시간별 상세 조회 |
|
||||||
|
| POST | `/plants/{plant_id}/upload` | 엑셀 데이터 업로드 (일간) |
|
||||||
|
| POST | `/plants/{plant_id}/upload/monthly` | 엑셀 데이터 업로드 (월간) |
|
||||||
|
|
||||||
|
**파라미터 설명**:
|
||||||
|
- `period`: `day` (일별), `month` (월별), `year` (연도별)
|
||||||
|
- `year`: (옵션) 조회 기준 연도
|
||||||
|
- `month`: (옵션) `period=day`시 조회할 월
|
||||||
|
- `date`: (옵션) `stats/today`시 조회할 날짜 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 모바일 앱 & 웹 (React Native/Expo)
|
||||||
|
|
||||||
|
### 위치
|
||||||
|
```
|
||||||
|
d:\dev\etc\SolorPower\app\
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
- **실시간/통계 통합**: 대시보드 및 상세 차트(일/월/년).
|
||||||
|
- **날짜 네비게이션**: `◀ 2026년 1월 ▶` 등의 컨트롤로 자유로운 과거 데이터 탐색.
|
||||||
|
- **엑셀 업로드**:
|
||||||
|
- **일간**: `date`, `generation` 또는 `year, month, day, kwh` 포맷 지원.
|
||||||
|
- **월간**: `year, month, kwh` 포맷 지원.
|
||||||
|
- 별칭(`발전량`, `kwh` 등) 및 셀 병합 처리 지원.
|
||||||
|
- **반응형 웹 지원**: PC/모바일 브라우저 최적화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 웹 앱 배포 가이드 (Web Deployment)
|
||||||
|
|
||||||
|
React Native(Expo) 앱을 웹으로 빌드하고 Nginx 서버에 배포하는 과정입니다.
|
||||||
|
|
||||||
|
### 1. 웹 빌드 (Local PC)
|
||||||
|
```bash
|
||||||
|
cd d:\dev\etc\SolorPower\app
|
||||||
|
npm run export:web
|
||||||
|
```
|
||||||
|
- `dist` 폴더에 정적 웹 파일들이 생성됩니다.
|
||||||
|
|
||||||
|
### 2. 서버 업로드 (SCP)
|
||||||
|
빌드된 `dist` 폴더를 Oracle Cloud 서버의 웹 루트로 전송합니다.
|
||||||
|
*(참고: `/var/www/html/` 권한 문제로 인해 임시 폴더로 전송 후 `sudo`로 이동해야 할 수 있습니다)*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 홈 디렉토리로 전송
|
||||||
|
scp -i "~/.ssh/holdem_server.key" -r dist ubuntu@140.245.73.212:~/dist_temp
|
||||||
|
|
||||||
|
# 2. 서버 접속 후 이동
|
||||||
|
ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212
|
||||||
|
sudo rm -rf /var/www/html/dist
|
||||||
|
sudo mv ~/dist_temp /var/www/html/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Nginx 설정 (서버 분석 결과)
|
||||||
|
실제 운영 중인 Nginx 설정 (`/etc/nginx/sites-enabled/solorpower`)의 핵심 구조입니다.
|
||||||
|
|
||||||
|
**라우팅 규칙**:
|
||||||
|
- **Frontend**: `/var/www/html/dist` (React Static Files)
|
||||||
|
- **Backend**: `http://127.0.0.1:8000` (FastAPI)
|
||||||
|
- `/plants` (데이터/통계 API)
|
||||||
|
- `/docs`, `/openapi.json` (API 문서)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name solorpower.dadot.net;
|
||||||
|
# HTTPS 리다이렉트 생략...
|
||||||
|
|
||||||
|
listen 443 ssl;
|
||||||
|
# SSL 인증서 설정 생략...
|
||||||
|
|
||||||
|
# 1. React Frontend (정적 파일)
|
||||||
|
location / {
|
||||||
|
root /var/www/html/dist;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. FastAPI Backend (API 프록시)
|
||||||
|
# plants(데이터), docs(문서) 경로만 백엔드로 토스
|
||||||
|
location ~ ^/(plants|docs|openapi.json) {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 적용
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
이제 `https://solorpower.dadot.net` 에서 배포된 웹 앱을 확인할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터베이스 (Supabase)
|
||||||
|
|
||||||
|
### 주요 테이블
|
||||||
|
1. **plants**: 발전소 기본 정보.
|
||||||
|
2. **solar_logs**: 실시간 발전 로그 (10분/30분 단위). `created_at` 기준.
|
||||||
|
3. **daily_stats**: 일별 발전량 통계. `date` 기준. (과거 데이터는 여기에 저장됨)
|
||||||
|
4. **monthly_stats**: 월별 발전량 통계. `month` 기준.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 히스토리
|
||||||
|
|
||||||
|
### 2026-04-22
|
||||||
|
- **크롤러 인프라 이전**: 크롤러 실행 환경을 Synology NAS에서 Oracle Cloud로 이전.
|
||||||
|
- **네트워크 우회 설정**: Oracle Cloud IP 차단 이슈 대응을 위해 Tailscale을 도입, Synology NAS를 Exit Node로 설정하여 가정용 IP로 크롤링 수행.
|
||||||
|
- **안정성 강화**: 클라우드 환경 이전을 통해 NAS 리소스 부하 감소 및 수집 안전성 확보.
|
||||||
|
|
||||||
|
### 2026-01-30
|
||||||
|
- **데이터 복구 및 안정화**: 1월 28-29일 데이터 누락 복구 완료.
|
||||||
|
- **자동 통계 집계 개선**: `daily_summary.py`가 날짜 파라미터 없이 실행될 경우 자동으로 '어제' 날짜를 기준으로 집계하도록 수정. (새벽 실행 안전성 확보)
|
||||||
|
- **월간 통계 자동화**: 일일 집계 시 해당 월의 마지막 날인 경우, 자동으로 `monthly_stats`까지 집계/갱신하는 로직 추가.
|
||||||
|
- **Git 저장소 분리**:
|
||||||
|
- `crawler`: https://gitea.dadot.synology.me/haneulai/solorpower_crawler.git
|
||||||
|
- `app`: https://gitea.dadot.synology.me/haneulai/solorpower_app.git
|
||||||
|
|
||||||
|
### 2026-01-28
|
||||||
|
- **Stats API 고도화**: 날짜/월 탐색을 위한 파라미터(`month`, `date`) 추가.
|
||||||
|
- **연간 통계 개선**: 단일 연도 조회에서 '해당 연도 포함 최근 5년' 조회로 변경.
|
||||||
|
- **엑셀 업로드 유연성**: `year, month, day` 컬럼이 분리된 파일도 자동 인식 및 처리.
|
||||||
|
- **Frontend 개선**: 차트 상단 날짜 네비게이션(`◀ ▶`) 추가, 조회 기간 범위 표시 강화.
|
||||||
|
|
||||||
|
### 2026-01-27
|
||||||
|
- **Crawler 구조화**: `crawler_manager.py` 도입 (스마트 스케줄링, 학습/최적화 모드).
|
||||||
|
- **GUI & Documentation**: 관리자 GUI 기능 확장 및 `crawler_structure.md` 문서화.
|
||||||
|
- **과거 데이터 수집 최적화**: 모든 크롤러 월 단위 분할 수집 지원.
|
||||||
|
|
||||||
|
### 2026-01-23
|
||||||
|
- 통계 조회 API 추가 및 엑셀 업로드 API 구현.
|
||||||
|
|
||||||
|
### 2026-01-22
|
||||||
|
- 대시보드 필드명 매핑 수정.
|
||||||
|
|
||||||
34
api_server/.gitignore
vendored
Normal file
34
api_server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Distribution / Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
@ -9,7 +9,7 @@ from supabase import Client
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase
|
from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase, PlantAlertUpdateRequest
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/plants",
|
prefix="/plants",
|
||||||
|
|
@ -138,3 +138,40 @@ async def get_plant_detail(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}"
|
detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{company_id}/{plant_id}/alerts")
|
||||||
|
async def update_plant_alerts(
|
||||||
|
company_id: int,
|
||||||
|
plant_id: str,
|
||||||
|
alert_update: PlantAlertUpdateRequest,
|
||||||
|
db: Client = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
특정 발전소의 알림 활성화 상태를 변경합니다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = db.table("plants") \
|
||||||
|
.update({"alerts_enabled": alert_update.alerts_enabled}) \
|
||||||
|
.eq("id", plant_id) \
|
||||||
|
.eq("company_id", company_id) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not response.data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="발전소를 찾을 수 없거나 업데이트에 실패했습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "알림 설정이 변경되었습니다.",
|
||||||
|
"data": response.data[0]
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"알림 설정 변경 중 오류가 발생했습니다: {str(e)}"
|
||||||
|
)
|
||||||
|
|
@ -17,8 +17,13 @@ class PlantBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
capacity: Optional[float] = Field(None, description="발전 용량 (kW)")
|
capacity: Optional[float] = Field(None, description="발전 용량 (kW)")
|
||||||
location: Optional[str] = Field(None, description="발전소 위치")
|
location: Optional[str] = Field(None, description="발전소 위치")
|
||||||
|
alerts_enabled: Optional[bool] = Field(True, description="이상 알림 활성화 여부")
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class PlantAlertUpdateRequest(BaseModel):
|
||||||
|
alerts_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class SolarLogBase(BaseModel):
|
class SolarLogBase(BaseModel):
|
||||||
"""발전 로그 기본 정보 스키마"""
|
"""발전 로그 기본 정보 스키마"""
|
||||||
37
app/.gitignore
vendored
Normal file
37
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
*.jks
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.keystore
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# npm
|
||||||
|
npm-debug.log
|
||||||
|
package-lock.json
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/
|
||||||
|
!android/build.gradle
|
||||||
|
!android/settings.gradle
|
||||||
|
!android/app/build.gradle
|
||||||
|
!android/app/src/main/AndroidManifest.xml
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/
|
||||||
|
!ios/Podfile
|
||||||
|
|
||||||
|
# Web
|
||||||
|
web-build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
579
app/App.js
Normal file
579
app/App.js
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
useWindowDimensions,
|
||||||
|
RefreshControl,
|
||||||
|
Switch,
|
||||||
|
} from 'react-native';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import PlantDetailScreen from './screens/PlantDetailScreen';
|
||||||
|
import ComparisonScreen from './screens/ComparisonScreen';
|
||||||
|
|
||||||
|
const API_URL = 'https://solorpower.dadot.net/plants/1';
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
// 메인 대시보드 화면
|
||||||
|
function DashboardScreen({ navigation }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
const isPC = width >= 768;
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(API_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleToggleAlert = async (plant, newValue) => {
|
||||||
|
try {
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const newData = { ...prev };
|
||||||
|
newData.data = newData.data.map(p =>
|
||||||
|
p.id === plant.id ? { ...p, alerts_enabled: newValue } : p
|
||||||
|
);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`https://solorpower.dadot.net/plants/${plant.company_id || 1}/${plant.id}/alerts`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ alerts_enabled: newValue }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update alert settings');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const newData = { ...prev };
|
||||||
|
newData.data = newData.data.map(p =>
|
||||||
|
p.id === plant.id ? { ...p, alerts_enabled: !newValue } : p
|
||||||
|
);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
alert('알림 설정 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 발전소 상태 및 아이콘 결정
|
||||||
|
const getStatusInfo = (plant) => {
|
||||||
|
const latestLog = plant.latest_log;
|
||||||
|
const currentKw = latestLog?.current_kw || 0;
|
||||||
|
const status = latestLog?.status;
|
||||||
|
|
||||||
|
if (status === '오류' || status === 'error') {
|
||||||
|
return { icon: '🔴', label: '오류', color: '#EF4444' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 야간 시간대 확인 (18시 ~ 06시)
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const isNight = hour >= 18 || hour < 6;
|
||||||
|
|
||||||
|
if (currentKw === 0) {
|
||||||
|
if (isNight) {
|
||||||
|
return { icon: '🌙', label: '야간', color: '#4B5563' };
|
||||||
|
}
|
||||||
|
return { icon: '💤', label: '대기', color: '#6B7280' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { icon: '🟢', label: status || '정상', color: '#10B981' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 요약 데이터 계산
|
||||||
|
const getSummary = () => {
|
||||||
|
if (!data?.data || data.data.length === 0) {
|
||||||
|
return { totalOutput: 0, operatingRate: 0, totalPlants: 0, activePlants: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plants = data.data;
|
||||||
|
const totalPlants = plants.length;
|
||||||
|
let totalOutput = 0;
|
||||||
|
let activePlants = 0;
|
||||||
|
|
||||||
|
plants.forEach((plant) => {
|
||||||
|
const currentKw = plant.latest_log?.current_kw || 0;
|
||||||
|
totalOutput += currentKw;
|
||||||
|
if (currentKw > 0) {
|
||||||
|
activePlants++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const operatingRate = totalPlants > 0 ? (activePlants / totalPlants) * 100 : 0;
|
||||||
|
|
||||||
|
return { totalOutput, operatingRate, totalPlants, activePlants };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>데이터를 불러오는 중...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Text style={styles.errorIcon}>⚠️</Text>
|
||||||
|
<Text style={styles.errorText}>오류가 발생했습니다</Text>
|
||||||
|
<Text style={styles.errorDetail}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={fetchData}>
|
||||||
|
<Text style={styles.retryButtonText}>다시 시도</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = getSummary();
|
||||||
|
// 발전소 이름의 숫자 기준으로 정렬 (예: "태양과바람 1호기" -> 1)
|
||||||
|
const plants = [...(data?.data || [])].sort((a, b) => {
|
||||||
|
const getNumber = (str) => {
|
||||||
|
// 문자열에서 첫 번째 연속된 숫자를 추출
|
||||||
|
const match = str && str.match(/(\d+)/);
|
||||||
|
return match ? parseInt(match[1], 10) : Infinity;
|
||||||
|
};
|
||||||
|
const numA = getNumber(a.name);
|
||||||
|
const numB = getNumber(b.name);
|
||||||
|
|
||||||
|
if (numA !== numB) return numA - numB;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발전소 카드 클릭 핸들러
|
||||||
|
const handlePlantPress = (plant) => {
|
||||||
|
navigation.navigate('PlantDetail', { plant });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
|
||||||
|
{/* 상단 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerTop}>
|
||||||
|
<Text style={styles.headerTitle}>☀️ 태양광 발전 현황</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.compareButton}
|
||||||
|
onPress={() => navigation.navigate('Comparison')}
|
||||||
|
>
|
||||||
|
<Text style={styles.compareButtonText}>📊 전체 비교</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.headerSubtitle}>실시간 모니터링 대시보드</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<View style={[styles.summaryContainer, isPC && styles.summaryContainerPC]}>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardPrimary]}>
|
||||||
|
<Text style={styles.summaryLabel}>총 출력</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.totalOutput.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kW</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardSecondary]}>
|
||||||
|
<Text style={styles.summaryLabel}>가동률</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.operatingRate.toFixed(0)}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardTertiary]}>
|
||||||
|
<Text style={styles.summaryLabel}>가동 현황</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.activePlants}/{summary.totalPlants}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>발전소</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 발전소 목록 */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.cardContainer,
|
||||||
|
isPC && styles.cardContainerPC,
|
||||||
|
]}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{plants.map((plant, index) => {
|
||||||
|
const statusInfo = getStatusInfo(plant);
|
||||||
|
const latestLog = plant.latest_log;
|
||||||
|
const currentKw = latestLog?.current_kw || 0;
|
||||||
|
const dailyKwh = latestLog?.today_kwh || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={plant.id || index}
|
||||||
|
style={[styles.card, isPC && styles.cardPC]}
|
||||||
|
onPress={() => handlePlantPress(plant)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.statusIcon}>{statusInfo.icon}</Text>
|
||||||
|
<View style={styles.cardTitleContainer}>
|
||||||
|
<Text style={styles.cardTitle}>{plant.name}</Text>
|
||||||
|
<Text style={[styles.statusLabel, { color: statusInfo.color }]}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.alertToggleContainer}>
|
||||||
|
<Text style={styles.alertToggleLabel}>
|
||||||
|
{plant.alerts_enabled !== false ? '알림 ON' : '알림 OFF'}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={plant.alerts_enabled !== false}
|
||||||
|
onValueChange={(value) => handleToggleAlert(plant, value)}
|
||||||
|
trackColor={{ false: '#D1D5DB', true: '#3B82F6' }}
|
||||||
|
thumbColor={'#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>현재 출력</Text>
|
||||||
|
<Text style={styles.dataValuePrimary}>{currentKw.toFixed(1)} kW</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>금일 발전</Text>
|
||||||
|
<Text style={styles.dataValue}>{dailyKwh.toFixed(1)} kWh</Text>
|
||||||
|
</View>
|
||||||
|
{plant.capacity && (
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>설비 용량</Text>
|
||||||
|
<Text style={styles.dataValue}>{plant.capacity} kW</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
{plant.capacity && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{
|
||||||
|
width: `${Math.min((currentKw / plant.capacity) * 100, 100)}%`,
|
||||||
|
backgroundColor: statusInfo.color,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressText}>
|
||||||
|
{((currentKw / plant.capacity) * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 앱
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<NavigationContainer>
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="Dashboard" component={DashboardScreen} />
|
||||||
|
<Stack.Screen name="Comparison" component={ComparisonScreen} />
|
||||||
|
<Stack.Screen name="PlantDetail" component={PlantDetailScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
errorIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorDetail: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#1E40AF',
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
headerTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#93C5FD',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
summaryContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
summaryContainerPC: {
|
||||||
|
maxWidth: 800,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
summaryCardPrimary: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
},
|
||||||
|
summaryCardSecondary: {
|
||||||
|
backgroundColor: '#10B981',
|
||||||
|
},
|
||||||
|
summaryCardTertiary: {
|
||||||
|
backgroundColor: '#8B5CF6',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
summaryUnit: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cardContainerPC: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
maxWidth: 1200,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
cardPC: {
|
||||||
|
width: 'calc(33.333% - 11px)',
|
||||||
|
minWidth: 280,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
statusIcon: {
|
||||||
|
fontSize: 32,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
cardTitleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
arrowIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontWeight: '300',
|
||||||
|
},
|
||||||
|
alertToggleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
alertToggleLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
cardBody: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
dataRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dataLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
dataValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
dataValuePrimary: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#3B82F6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Progress Bar
|
||||||
|
progressContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
flex: 1,
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
width: 40,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
compareButton: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.4)',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
compareButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
26
app/app.json
Normal file
26
app/app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "태양광 발전 현황",
|
||||||
|
"slug": "solorpower-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"backgroundColor": "#1E40AF"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.solorpower.dashboard",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#1E40AF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/babel.config.js
Normal file
6
app/babel.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
325
app/components/UploadModal.js
Normal file
325
app/components/UploadModal.js
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
/**
|
||||||
|
* UploadModal.js - 과거 발전 데이터 엑셀 업로드 모달
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
// Force Update Check
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
|
||||||
|
const API_URL = 'https://solorpower.dadot.net';
|
||||||
|
|
||||||
|
export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [uploadType, setUploadType] = useState('daily'); // 'daily' | 'monthly'
|
||||||
|
|
||||||
|
// 파일 선택
|
||||||
|
const pickDocument = async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||||
|
'application/vnd.ms-excel', // .xls
|
||||||
|
],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setSelectedFile(result.assets[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('오류', '파일 선택 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업로드 실행
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
Alert.alert('알림', '먼저 파일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plantId) {
|
||||||
|
Alert.alert('알림', '발전소를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// 파일 추가
|
||||||
|
formData.append('file', {
|
||||||
|
uri: selectedFile.uri,
|
||||||
|
name: selectedFile.name,
|
||||||
|
type: selectedFile.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
|
||||||
|
// uploadType에 따라 엔드포인트 분기
|
||||||
|
const endpoint = uploadType === 'daily'
|
||||||
|
? `/plants/${plantId}/upload`
|
||||||
|
: `/plants/${plantId}/upload/monthly`;
|
||||||
|
|
||||||
|
console.log(`🚀 Uploading ${uploadType} data`);
|
||||||
|
console.log(" URL:", `${API_URL}${endpoint}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
Alert.alert('성공', result.message || '업로드가 완료되었습니다.');
|
||||||
|
setSelectedFile(null);
|
||||||
|
onUploadSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
Alert.alert('오류', result.detail || '업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('오류', `업로드 중 오류가 발생했습니다: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setUploadType('daily');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>📂 과거 데이터 업로드</Text>
|
||||||
|
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||||
|
<Text style={styles.closeButtonText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 타입 선택 탭 */}
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 16, backgroundColor: '#F3F4F6', borderRadius: 8, padding: 4 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: uploadType === 'daily' ? '#FFFFFF' : 'transparent',
|
||||||
|
shadowColor: uploadType === 'daily' ? '#000' : 'transparent',
|
||||||
|
shadowOpacity: uploadType === 'daily' ? 0.1 : 0,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: uploadType === 'daily' ? 2 : 0,
|
||||||
|
}}
|
||||||
|
onPress={() => setUploadType('daily')}
|
||||||
|
>
|
||||||
|
<Text style={{ fontWeight: uploadType === 'daily' ? '600' : '400', color: '#374151' }}>일간 (Daily)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: uploadType === 'monthly' ? '#FFFFFF' : 'transparent',
|
||||||
|
shadowColor: uploadType === 'monthly' ? '#000' : 'transparent',
|
||||||
|
shadowOpacity: uploadType === 'monthly' ? 0.1 : 0,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: uploadType === 'monthly' ? 2 : 0,
|
||||||
|
}}
|
||||||
|
onPress={() => setUploadType('monthly')}
|
||||||
|
>
|
||||||
|
<Text style={{ fontWeight: uploadType === 'monthly' ? '600' : '400', color: '#374151' }}>월간 (Monthly)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 안내 */}
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{uploadType === 'daily'
|
||||||
|
? "필수 컬럼: date (날짜), generation (발전량) 또는 year, month, day, kwh"
|
||||||
|
: "필수 컬럼: year (연도), month (월), kwh (발전량)\n* 합계/평균 행 자동 제외"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 발전소 ID 표시 */}
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={styles.infoLabel}>발전소 ID:</Text>
|
||||||
|
<Text style={styles.infoValue}>{plantId || '선택 안됨'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 파일 선택 */}
|
||||||
|
<TouchableOpacity style={styles.fileButton} onPress={pickDocument}>
|
||||||
|
<Text style={styles.fileButtonText}>
|
||||||
|
{selectedFile ? '📄 ' + selectedFile.name : '📁 엑셀 파일 선택 (.xlsx)'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 선택된 파일 정보 */}
|
||||||
|
{selectedFile && (
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileInfoText}>
|
||||||
|
크기: {(selectedFile.size / 1024).toFixed(1)} KB
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.uploadButton,
|
||||||
|
(!selectedFile || uploading) && styles.uploadButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={!selectedFile || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.uploadButtonText}>
|
||||||
|
{uploadType === 'daily' ? '일간 데이터 업로드' : '월간 데이터 업로드'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 취소 버튼 */}
|
||||||
|
<TouchableOpacity style={styles.cancelButton} onPress={handleClose}>
|
||||||
|
<Text style={styles.cancelButtonText}>취소</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
infoBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
fileButton: {
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
fileButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
fileInfoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
uploadButtonDisabled: {
|
||||||
|
backgroundColor: '#9CA3AF',
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
});
|
||||||
25
app/eas.json
Normal file
25
app/eas.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 3.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/package.json
Normal file
32
app/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "solorpower-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo/AppEntry.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
|
"@react-navigation/native": "^7.1.28",
|
||||||
|
"@react-navigation/native-stack": "^7.10.1",
|
||||||
|
"expo": "~52.0.0",
|
||||||
|
"expo-document-picker": "~13.0.3",
|
||||||
|
"expo-linear-gradient": "~14.0.2",
|
||||||
|
"expo-status-bar": "~2.0.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-native": "0.76.5",
|
||||||
|
"react-native-gifted-charts": "^1.4.70",
|
||||||
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
|
"react-native-screens": "^4.20.0",
|
||||||
|
"react-native-svg": "^15.15.1",
|
||||||
|
"react-native-web": "~0.19.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
432
app/screens/ComparisonScreen.js
Normal file
432
app/screens/ComparisonScreen.js
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { BarChart } from 'react-native-gifted-charts';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'https://solorpower.dadot.net';
|
||||||
|
|
||||||
|
export default function ComparisonScreen({ navigation }) {
|
||||||
|
const [period, setPeriod] = useState('day');
|
||||||
|
const [statsData, setStatsData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
const moveDate = (direction) => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
if (period === 'day') {
|
||||||
|
newDate.setDate(newDate.getDate() + direction);
|
||||||
|
} else if (period === 'month') {
|
||||||
|
newDate.setMonth(newDate.getMonth() + direction);
|
||||||
|
} else if (period === 'year') {
|
||||||
|
newDate.setFullYear(newDate.getFullYear() + direction);
|
||||||
|
}
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateDisplay = () => {
|
||||||
|
const y = currentDate.getFullYear();
|
||||||
|
const m = currentDate.getMonth() + 1;
|
||||||
|
const d = currentDate.getDate();
|
||||||
|
|
||||||
|
if (period === 'day') return `${y}년 ${m}월 ${d}일`;
|
||||||
|
if (period === 'month') return `${y}년 ${m}월`;
|
||||||
|
if (period === 'year') return `${y}년`;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const y = currentDate.getFullYear();
|
||||||
|
const m = currentDate.getMonth() + 1;
|
||||||
|
const d = currentDate.getDate();
|
||||||
|
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
let query = `period=${period}`;
|
||||||
|
if (period === 'day') {
|
||||||
|
query += `&date=${dateStr}`;
|
||||||
|
} else if (period === 'month') {
|
||||||
|
query += `&year=${y}&month=${m}`;
|
||||||
|
} else if (period === 'year') {
|
||||||
|
query += `&year=${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setStatsData(result.data || []);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setStatsData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period, currentDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
// Chart Data Preparation
|
||||||
|
const chartData = statsData.map(item => {
|
||||||
|
let label = item.plant_name;
|
||||||
|
const match = label.match(/(\d+호기)/);
|
||||||
|
if (match) {
|
||||||
|
label = match[1];
|
||||||
|
} else {
|
||||||
|
label = label.replace(/태양과바람|발전소/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: item.generation,
|
||||||
|
label: label,
|
||||||
|
frontColor: '#3B82F6',
|
||||||
|
topLabelComponent: () => (
|
||||||
|
<Text style={{ color: '#6B7280', fontSize: 10, marginBottom: 4 }}>
|
||||||
|
{item.generation.toFixed(0)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderTabs = () => (
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
{['day', 'month', 'year'].map((p) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p}
|
||||||
|
style={[styles.tab, period === p && styles.tabActive]}
|
||||||
|
onPress={() => setPeriod(p)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, period === p && styles.tabTextActive]}>
|
||||||
|
{p === 'day' ? '일간' : p === 'month' ? '월간' : '연간'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDateControl = () => (
|
||||||
|
<View style={styles.dateControl}>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(-1)} style={styles.arrowButton}>
|
||||||
|
<Text style={styles.arrowText}>◀</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.dateText}>{formatDateDisplay()}</Text>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(1)} style={styles.arrowButton}>
|
||||||
|
<Text style={styles.arrowText}>▶</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTable = () => (
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<Text style={[styles.columnHeader, { flex: 2 }]}>발전소</Text>
|
||||||
|
<Text style={[styles.columnHeader, { flex: 1.5 }]}>발전량</Text>
|
||||||
|
<Text style={[styles.columnHeader, { flex: 1.5 }]}>용량</Text>
|
||||||
|
<Text style={[styles.columnHeader, { flex: 1.5 }]}>시간</Text>
|
||||||
|
</View>
|
||||||
|
{statsData.map((item, index) => (
|
||||||
|
<View key={item.plant_id} style={[styles.tableRow, index % 2 === 1 && styles.tableRowAlt]}>
|
||||||
|
<Text style={[styles.cell, { flex: 2, textAlign: 'left' }]}>{item.plant_name}</Text>
|
||||||
|
<Text style={[styles.cell, { flex: 1.5 }]}>{item.generation.toFixed(1)}</Text>
|
||||||
|
<Text style={[styles.cell, { flex: 1.5, color: '#9CA3AF' }]}>{item.capacity}</Text>
|
||||||
|
<Text style={[styles.cell, { flex: 1.5, fontWeight: 'bold', color: '#10B981' }]}>
|
||||||
|
{item.generation_hours.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{statsData.length === 0 && (
|
||||||
|
<View style={styles.emptyRow}>
|
||||||
|
<Text style={styles.emptyText}>데이터가 없습니다</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartWidth = width - 40;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backButtonText}>← 뒤로</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>전체 발전소 비교</Text>
|
||||||
|
<View style={{ width: 60 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
||||||
|
{renderDateControl()}
|
||||||
|
{renderTabs()}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>데이터 불러오는 중...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>데이터 조회 실패</Text>
|
||||||
|
<Text style={styles.errorDetail}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
|
||||||
|
<Text style={styles.retryButtonText}>다시 시도</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.chartSection}>
|
||||||
|
<Text style={styles.sectionTitle}>📊 발전량 비교 (kWh)</Text>
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
{statsData.length > 0 ? (
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
width={chartWidth}
|
||||||
|
height={200}
|
||||||
|
barWidth={20}
|
||||||
|
spacing={15}
|
||||||
|
barBorderRadius={4}
|
||||||
|
frontColor="#3B82F6"
|
||||||
|
yAxisThickness={0}
|
||||||
|
xAxisThickness={1}
|
||||||
|
yAxisTextStyle={{ color: '#9CA3AF', fontSize: 10 }}
|
||||||
|
xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 10 }}
|
||||||
|
hideRules
|
||||||
|
showValuesAsTopLabel
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.noDataText}>표시할 데이터가 없습니다.</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.tableSection}>
|
||||||
|
<View style={styles.tableHeaderRow}>
|
||||||
|
<Text style={styles.sectionTitle}>📋 상세 현황</Text>
|
||||||
|
<Text style={styles.unitText}>시간 = 발전량 / 설비용량 {period === 'year' ? '/ 365' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
{renderTable()}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#1E40AF',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
dateControl: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
arrowButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
arrowText: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#4B5563',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 300,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
height: 300,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#EF4444',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorDetail: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
chartSection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
noDataText: {
|
||||||
|
color: '#9CA3AF',
|
||||||
|
marginVertical: 20,
|
||||||
|
},
|
||||||
|
tableSection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
tableHeaderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
unitText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
columnHeader: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#4B5563',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
tableRowAlt: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#1F2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyRow: {
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
791
app/screens/PlantDetailScreen.js
Normal file
791
app/screens/PlantDetailScreen.js
Normal file
|
|
@ -0,0 +1,791 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
useWindowDimensions,
|
||||||
|
Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { BarChart } from 'react-native-gifted-charts';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'https://solorpower.dadot.net';
|
||||||
|
|
||||||
|
export default function PlantDetailScreen({ route, navigation }) {
|
||||||
|
const { plant } = route.params;
|
||||||
|
const [period, setPeriod] = useState('today');
|
||||||
|
const [chartData, setChartData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [todayData, setTodayData] = useState(null);
|
||||||
|
const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, label: '', value: 0 });
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
// 탭 변경 시 날짜 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
// 날짜 이동
|
||||||
|
const moveDate = (direction) => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
if (period === 'today') {
|
||||||
|
newDate.setDate(newDate.getDate() + direction);
|
||||||
|
} else if (period === 'day') {
|
||||||
|
// 일간 탭: '월' 단위 이동
|
||||||
|
newDate.setMonth(newDate.getMonth() + direction);
|
||||||
|
} else if (period === 'month') {
|
||||||
|
// 월간 탭: '년' 단위 이동
|
||||||
|
newDate.setFullYear(newDate.getFullYear() + direction);
|
||||||
|
} else if (period === 'year') {
|
||||||
|
// 연간 탭: '년' 단위 이동 (일단 1년씩)
|
||||||
|
newDate.setFullYear(newDate.getFullYear() + direction);
|
||||||
|
}
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통계 데이터 조회
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const y = currentDate.getFullYear();
|
||||||
|
const m = currentDate.getMonth() + 1;
|
||||||
|
const d = currentDate.getDate();
|
||||||
|
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
if (period === 'today') {
|
||||||
|
// 오늘 실시간 데이터는 이미 전달받은 plant.latest_log 사용 (상단 카드용)
|
||||||
|
// 만약 과거 날짜를 선택했다면 상단 카드는 숨기거나 해당 날짜의 요약으로 대체해야 함.
|
||||||
|
// 여기선 '오늘'인 경우에만 상단 카드를 보여주는 식으로 처리 가능.
|
||||||
|
|
||||||
|
// 서버에서 시간별 데이터 조회
|
||||||
|
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats/today?date=${dateStr}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
const hourlyData = result.data || [];
|
||||||
|
|
||||||
|
// 24시간 데이터 포맷팅
|
||||||
|
const formattedData = [];
|
||||||
|
for (let i = 0; i <= 24; i++) {
|
||||||
|
if (i === 24) {
|
||||||
|
formattedData.push({ value: 0, label: '24시', frontColor: 'transparent' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = hourlyData.find(d => d.hour === i) || {};
|
||||||
|
// 현재 시간인지 확인 (오늘 날짜이고 현재 시간인 경우)
|
||||||
|
const isToday = new Date().toDateString() === currentDate.toDateString();
|
||||||
|
const isCurrentHour = isToday && (i === new Date().getHours());
|
||||||
|
const hasData = item.has_data || item.current_kw > 0;
|
||||||
|
|
||||||
|
let color = '#E5E7EB';
|
||||||
|
if (isCurrentHour) {
|
||||||
|
color = '#10B981';
|
||||||
|
} else if (hasData) {
|
||||||
|
color = '#3B82F6';
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedData.push({
|
||||||
|
value: item.current_kw || 0,
|
||||||
|
label: i % 2 === 0 ? `${i}시` : '',
|
||||||
|
frontColor: color,
|
||||||
|
onPress: () => i < 24 && showTooltip(item.current_kw || 0, `${i}시`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setChartData(formattedData);
|
||||||
|
|
||||||
|
// Today 탭이지만 과거 날짜인 경우 상단 요약 카드 데이터 갱신 필요
|
||||||
|
// API 결과에 daily total이 포함되어 있다고 가정하거나, hourly sum을 해야 함.
|
||||||
|
// 현재 API(stats/today)는 today_kwh를 주지만, 이는 그 시간대까지의 누적임.
|
||||||
|
// 마지막 시간대의 today_kwh가 그날의 총 발전량.
|
||||||
|
// 편의상 차트 데이터만 갱신.
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 기존 일간/월간/연간 조회
|
||||||
|
// 쿼리 파라미터 추가
|
||||||
|
let query = `period=${period}`;
|
||||||
|
if (period === 'day') {
|
||||||
|
// period=day -> year, month 필요
|
||||||
|
query += `&year=${y}&month=${m}`;
|
||||||
|
} else if (period === 'month') {
|
||||||
|
// period=month -> year 필요
|
||||||
|
query += `&year=${y}`;
|
||||||
|
} else if (period === 'year') {
|
||||||
|
// period=year -> year 필요 (기준 연도)
|
||||||
|
query += `&year=${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats?${query}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
const rawData = result.data || [];
|
||||||
|
const formattedData = [];
|
||||||
|
|
||||||
|
if (period === 'day') {
|
||||||
|
// 선택된 달의 마지막 날
|
||||||
|
const lastDay = new Date(y, m, 0).getDate();
|
||||||
|
const dataMap = {};
|
||||||
|
rawData.forEach(item => { dataMap[item.label] = item.value; });
|
||||||
|
|
||||||
|
for (let day = 1; day <= lastDay; day++) {
|
||||||
|
const ds = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const val = dataMap[ds] || 0;
|
||||||
|
const showLabel = (day === 1 || day % 5 === 0);
|
||||||
|
|
||||||
|
formattedData.push({
|
||||||
|
value: val,
|
||||||
|
label: showLabel ? `${day}` : '',
|
||||||
|
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
|
||||||
|
frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (period === 'month') {
|
||||||
|
// 1월 ~ 12월
|
||||||
|
const dataMap = {};
|
||||||
|
rawData.forEach(item => { dataMap[item.label] = item.value; });
|
||||||
|
|
||||||
|
for (let month = 1; month <= 12; month++) {
|
||||||
|
const ms = `${y}-${String(month).padStart(2, '0')}`;
|
||||||
|
const val = dataMap[ms] || 0;
|
||||||
|
|
||||||
|
formattedData.push({
|
||||||
|
value: val,
|
||||||
|
label: `${month}월`,
|
||||||
|
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -5 },
|
||||||
|
frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
|
||||||
|
renderTooltip: renderTooltip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// year (기존 유지)
|
||||||
|
rawData.forEach(item => {
|
||||||
|
formattedData.push({
|
||||||
|
value: item.value || 0,
|
||||||
|
label: formatLabel(item.label, period),
|
||||||
|
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
|
||||||
|
frontColor: item.value > 0 ? '#3B82F6' : '#E5E7EB',
|
||||||
|
renderTooltip: renderTooltip,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setChartData(formattedData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setChartData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [plant.id, period, currentDate]);
|
||||||
|
|
||||||
|
// 날짜 포맷팅 (YYYY년 MM월 DD일 등)
|
||||||
|
const formatDateDisplay = () => {
|
||||||
|
const y = currentDate.getFullYear();
|
||||||
|
const m = currentDate.getMonth() + 1;
|
||||||
|
const d = currentDate.getDate();
|
||||||
|
|
||||||
|
if (period === 'today') return `${y}년 ${m}월 ${d}일`;
|
||||||
|
if (period === 'day') return `${y}년 ${m}월`;
|
||||||
|
if (period === 'month') return `${y}년`;
|
||||||
|
if (period === 'year') return `${y - 4}년 ~ ${y}년`;
|
||||||
|
return `${y}년 기준`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기간 설명 (수정)
|
||||||
|
const getPeriodDescription = () => {
|
||||||
|
return formatDateDisplay();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 렌더링 (아래 날짜 컨트롤 추가)
|
||||||
|
// ... renderTabs는 그대로 두고, 호출하는 곳에서 컨트롤 추가
|
||||||
|
|
||||||
|
// 날짜 컨트롤 렌더링
|
||||||
|
const renderDateControl = () => (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginVertical: 12, gap: 16 }}>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(-1)} style={{ padding: 8 }}>
|
||||||
|
<Text style={{ fontSize: 20, color: '#4B5563' }}>◀</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: 'bold', color: '#1F2937' }}>
|
||||||
|
{formatDateDisplay()}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(1)} style={{ padding: 8 }}>
|
||||||
|
<Text style={{ fontSize: 20, color: '#4B5563' }}>▶</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
// 툴팁 표시
|
||||||
|
// 툴팁 렌더링 (커스텀)
|
||||||
|
const renderTooltip = (item, index) => {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: -6,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 10 }}>
|
||||||
|
{item.value.toFixed(1)}{period === 'today' ? 'kW' : 'kWh'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 라벨 포맷팅
|
||||||
|
const formatLabel = (label, period) => {
|
||||||
|
if (!label) return '';
|
||||||
|
|
||||||
|
if (period === 'day') {
|
||||||
|
const parts = label.split('-');
|
||||||
|
return parts.length >= 3 ? `${parseInt(parts[2], 10)}` : label;
|
||||||
|
} else if (period === 'month') {
|
||||||
|
const parts = label.split('-');
|
||||||
|
// 앞에 0 제거하고 '월' 추가 (예: 01 -> 1월)
|
||||||
|
return parts.length >= 2 ? `${parseInt(parts[1], 10)}월` : label;
|
||||||
|
} else {
|
||||||
|
return label.length === 4 ? `${label.slice(2)}년` : label;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 총 발전량 계산
|
||||||
|
const getTotalGeneration = () => {
|
||||||
|
return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 업로드 핸들러
|
||||||
|
const handleUpload = async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) return;
|
||||||
|
|
||||||
|
const file = result.assets[0];
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
if (file.file) {
|
||||||
|
// Web: 실제 File 객체 사용
|
||||||
|
formData.append('file', file.file);
|
||||||
|
} else {
|
||||||
|
// Native: URI 객체 사용
|
||||||
|
formData.append('file', {
|
||||||
|
uri: file.uri,
|
||||||
|
type: file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
name: file.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/upload/monthly`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(responseData.detail || '업로드 실패');
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'업로드 완료',
|
||||||
|
responseData.message || `${responseData.saved_count}건의 데이터가 저장되었습니다.`,
|
||||||
|
[{ text: '확인' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('업로드 실패', err.message, [{ text: '확인' }]);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 렌더링
|
||||||
|
const renderTabs = () => (
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
{[
|
||||||
|
{ key: 'today', label: '오늘' },
|
||||||
|
{ key: 'day', label: '일간' },
|
||||||
|
{ key: 'month', label: '월간' },
|
||||||
|
{ key: 'year', label: '연간' },
|
||||||
|
].map((tab) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={[styles.tab, period === tab.key && styles.tabActive]}
|
||||||
|
onPress={() => setPeriod(tab.key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, period === tab.key && styles.tabTextActive]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기간 설명 (수정 - 위에서 이미 정의됨, 중복 제거)
|
||||||
|
// const getPeriodDescription = ... (removed)
|
||||||
|
|
||||||
|
const chartWidth = Math.min(width - 60, 600);
|
||||||
|
|
||||||
|
// 막대 너비 및 간격 계산
|
||||||
|
let barWidth = 10;
|
||||||
|
let spacing = 10;
|
||||||
|
|
||||||
|
if (period === 'today') {
|
||||||
|
// 화면 꽉 차게 (25개 항목: 0~24시) - 스크롤 없애기 위함
|
||||||
|
const availableWidth = chartWidth - 20;
|
||||||
|
const itemWidth = availableWidth / 25;
|
||||||
|
barWidth = Math.max(4, itemWidth * 0.6); // 60% 바
|
||||||
|
spacing = Math.max(2, itemWidth * 0.4); // 40% 여백
|
||||||
|
} else if (period === 'year') {
|
||||||
|
barWidth = 40;
|
||||||
|
spacing = 20;
|
||||||
|
} else if (period === 'month') {
|
||||||
|
barWidth = 24;
|
||||||
|
spacing = 12;
|
||||||
|
} else { // day
|
||||||
|
barWidth = 10;
|
||||||
|
spacing = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backButtonText}>← 뒤로</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{plant.name}</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||||
|
{/* 날짜 컨트롤 */}
|
||||||
|
{renderDateControl()}
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
{renderTabs()}
|
||||||
|
|
||||||
|
{/* 오늘 실시간 현황 카드 (today 탭일 때만) */}
|
||||||
|
{period === 'today' && todayData && (
|
||||||
|
<View style={styles.todayCard}>
|
||||||
|
<View style={styles.todayHeader}>
|
||||||
|
<Text style={styles.todayTitle}>⚡ 실시간 발전 현황</Text>
|
||||||
|
<Text style={styles.todayTime}>갱신: {todayData.updatedAt}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.todayStats}>
|
||||||
|
<View style={styles.todayStat}>
|
||||||
|
<Text style={styles.todayStatValue}>{todayData.currentKw.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.todayStatLabel}>현재 출력 (kW)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.todayDivider} />
|
||||||
|
<View style={styles.todayStat}>
|
||||||
|
<Text style={styles.todayStatValue}>{todayData.todayKwh.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.todayStatLabel}>금일 발전량 (kWh)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<View style={styles.chartSection}>
|
||||||
|
<View style={styles.chartHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()})
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.chartHint}>막대를 터치하면 상세 정보</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>데이터 로딩 중...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>⚠️ 데이터를 불러올 수 없습니다</Text>
|
||||||
|
<Text style={styles.errorDetail}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
|
||||||
|
<Text style={styles.retryButtonText}>다시 시도</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>📭 표시할 데이터가 없습니다</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
{period === 'today' ? '크롤러가 데이터를 수집하면 표시됩니다' : '엑셀 파일을 업로드해 주세요'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
maxValue={Math.max(...chartData.map(d => d.value || 0)) * 1.2 || 10} // 툴팁 공간 확보 (20%)
|
||||||
|
width={chartWidth}
|
||||||
|
height={220}
|
||||||
|
barWidth={barWidth}
|
||||||
|
spacing={spacing}
|
||||||
|
barBorderRadius={4}
|
||||||
|
frontColor="#3B82F6"
|
||||||
|
yAxisThickness={1}
|
||||||
|
xAxisThickness={1}
|
||||||
|
yAxisColor="#E5E7EB"
|
||||||
|
xAxisColor="#E5E7EB"
|
||||||
|
yAxisTextStyle={{ color: '#6B7280', fontSize: 10 }}
|
||||||
|
xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 9 }}
|
||||||
|
noOfSections={5}
|
||||||
|
hideRules={false}
|
||||||
|
rulesColor="#F3F4F6"
|
||||||
|
showValuesAsTopLabel={false}
|
||||||
|
isAnimated
|
||||||
|
yAxisSuffix={period === 'today' ? '' : ''}
|
||||||
|
renderTooltip={renderTooltip}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 통계 요약 (today 제외) */}
|
||||||
|
{period !== 'today' && (
|
||||||
|
<View style={styles.summarySection}>
|
||||||
|
<Text style={styles.sectionTitle}>📈 통계 요약</Text>
|
||||||
|
<View style={styles.summaryCards}>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>총 발전량</Text>
|
||||||
|
<Text style={styles.summaryValue}>
|
||||||
|
{getTotalGeneration().toLocaleString(undefined, { maximumFractionDigits: 1 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kWh</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>데이터 수</Text>
|
||||||
|
<Text style={styles.summaryValue}>{chartData.length}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>건</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>설비 용량</Text>
|
||||||
|
<Text style={styles.summaryValue}>{plant.capacity || '-'}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kW</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
<View style={styles.uploadSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.uploadButtonText}>📂 과거 엑셀 데이터 업로드</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.uploadHint}>
|
||||||
|
* 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#1E40AF',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Today Card
|
||||||
|
todayCard: {
|
||||||
|
backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
backgroundColor: '#1E40AF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#1E40AF',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
todayHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
todayTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
todayTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#93C5FD',
|
||||||
|
},
|
||||||
|
todayStats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
todayStat: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
todayStatValue: {
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
todayStatLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#93C5FD',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
todayDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chart Section
|
||||||
|
chartSection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
chartHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
chartHint: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#EF4444',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorDetail: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Summary Section
|
||||||
|
summarySection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
summaryCards: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F0F9FF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1E40AF',
|
||||||
|
},
|
||||||
|
summaryUnit: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload Section
|
||||||
|
uploadSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
backgroundColor: '#10B981',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#10B981',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
uploadButtonDisabled: {
|
||||||
|
backgroundColor: '#9CA3AF',
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
uploadHint: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
});
|
||||||
33
app/server.log
Normal file
33
app/server.log
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
Starting up http-server, serving dist
|
||||||
|
|
||||||
|
http-server version: 14.1.1
|
||||||
|
|
||||||
|
http-server settings:
|
||||||
|
CORS: disabled
|
||||||
|
Cache: 3600 seconds
|
||||||
|
Connection Timeout: 120 seconds
|
||||||
|
Directory Listings: visible
|
||||||
|
AutoIndex: visible
|
||||||
|
Serve GZIP Files: false
|
||||||
|
Serve Brotli Files: false
|
||||||
|
Default File Extension: none
|
||||||
|
|
||||||
|
Available on:
|
||||||
|
http://172.31.160.1:3004
|
||||||
|
http://192.168.45.31:3004
|
||||||
|
http://127.0.0.1:3004
|
||||||
|
Hit CTRL-C to stop the server
|
||||||
|
|
||||||
|
[2026-02-12T01:44:51.238Z] "HEAD /" "curl/8.12.1"
|
||||||
|
(node:1368) [DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated
|
||||||
|
(Use `node --trace-deprecation ...` to show where the warning was created)
|
||||||
|
[2026-02-12T01:46:00.070Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:46:00.119Z] "GET /_expo/static/js/web/AppEntry-943aad0f5c4e74ff22d7c538b45fdd54.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:46:00.399Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:46:00.400Z] "GET /favicon.ico" Error (404): "Not found"
|
||||||
|
[2026-02-12T01:51:33.504Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:52:17.173Z] "HEAD /" "curl/8.12.1"
|
||||||
|
[2026-02-12T01:52:33.791Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:52:33.821Z] "GET /_expo/static/js/web/AppEntry-8dbed585399d63adcee0c00c59fe829b.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:52:34.014Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
[2026-02-12T01:52:34.015Z] "GET /favicon.ico" Error (404): "Not found"
|
||||||
49
crawler/.gitignore
vendored
Normal file
49
crawler/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Custom
|
||||||
|
*.log
|
||||||
|
*.sqlite3
|
||||||
|
crawler_manager.db
|
||||||
|
temp_env/
|
||||||
|
tests/db_dump.csv
|
||||||
|
tests/results.csv
|
||||||
|
tests/*_log.txt
|
||||||
50
crawler/DEVELOPMENT.md
Normal file
50
crawler/DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
|
||||||
|
# 윈도우 개발 환경 가이드
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
이 프로젝트는 Windows와 NAS(리눅스) 환경 모두에서 동작하도록 구성되어 있습니다.
|
||||||
|
NAS 배포 전 Windows 환경에서 테스트 및 데이터 복구를 수행할 수 있습니다.
|
||||||
|
|
||||||
|
## 2. 가상환경 (Windows)
|
||||||
|
윈도우용 가상환경은 `venv_win` 폴더에 구성되어 있습니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 가상환경 활성화
|
||||||
|
.\venv_win\Scripts\activate
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 개발 도구 (tools 폴더)
|
||||||
|
`tools/` 폴더에는 데이터 검증 및 복구를 위한 유틸리티 스크립트가 포함되어 있습니다.
|
||||||
|
|
||||||
|
### 3.1 DB 데이터 조회 (check_db.py)
|
||||||
|
특정 시간대의 Supabase 데이터가 정상적으로 저장되었는지 확인합니다.
|
||||||
|
- UTC 기준으로 조회하므로 KST 변환에 유의하세요.
|
||||||
|
- 사용법:
|
||||||
|
```powershell
|
||||||
|
python tools/check_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 로그 기반 데이터 복구 (recover_from_log.py)
|
||||||
|
`cron.log` 등의 로그 파일을 파싱하여 누락된 데이터를 DB에 다시 채워넣습니다.
|
||||||
|
- `clean_recover.py` 기능을 개선하여 포함했습니다.
|
||||||
|
- 사용법:
|
||||||
|
```powershell
|
||||||
|
python tools/recover_from_log.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 실행 및 테스트
|
||||||
|
메인 크롤러 실행:
|
||||||
|
```powershell
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
GUI 대시보드 실행 (테스트용):
|
||||||
|
```powershell
|
||||||
|
python crawler_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 배포 시 주의사항
|
||||||
|
- `crawler_manager.py`의 `site_data.db`는 로컬에 생성되므로 배포 시 제외하거나 초기화 상태로 배포하세요.
|
||||||
|
- `.env` 파일의 API 키가 만료되지 않았는지 확인하세요.
|
||||||
157
crawler/alert_manager.py
Normal file
157
crawler/alert_manager.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from config import TELEGRAM_BOT_TOKEN
|
||||||
|
|
||||||
|
class AlertManager:
|
||||||
|
"""
|
||||||
|
발전소 이상 감지 및 텔레그램 알림 관리
|
||||||
|
- 상태(정상/이상)를 DB에 저장하여 중복 알림 방지
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = None):
|
||||||
|
"""
|
||||||
|
DB 연결 및 테이블 초기화
|
||||||
|
"""
|
||||||
|
if db_path is None:
|
||||||
|
# crawler_manager와 같은 DB 파일 사용
|
||||||
|
db_path = Path(__file__).parent / "crawler_manager.db"
|
||||||
|
|
||||||
|
self.db_path = str(db_path)
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""알림 히스토리 테이블 생성"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# site_id: 발전소 ID
|
||||||
|
# alert_status: 'NORMAL' (정상), 'ALERT' (이상 발생 및 알림 전송됨)
|
||||||
|
# last_alert_time: 마지막 알림 전송 시간
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_history (
|
||||||
|
site_id TEXT PRIMARY KEY,
|
||||||
|
alert_status TEXT DEFAULT 'NORMAL',
|
||||||
|
last_alert_time TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def send_telegram_message(self, chat_id, message):
|
||||||
|
"""텔레그램 메시지 전송"""
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
print(" ⚠️ 텔레그램 토큰이 설정되지 않았습니다.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not chat_id:
|
||||||
|
# Chat ID가 설정되지 않은 경우 조용히 리턴 (로그는 호출부에서 처리)
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||||
|
try:
|
||||||
|
payload = {"chat_id": chat_id, "text": message}
|
||||||
|
response = requests.post(url, json=payload, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" 🔔 텔레그램 알림 전송 성공")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ❌ 텔레그램 전송 실패 ({response.status_code}): {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 텔레그램 전송 중 에러: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_and_alert(self, plant_info: dict, current_kw: float):
|
||||||
|
"""
|
||||||
|
발전량을 체크하고 필요 시 알림 전송
|
||||||
|
- 오전 10시 ~ 오후 5시에만 동작
|
||||||
|
- 상태 변경 시에만 알림 (중복 방지)
|
||||||
|
"""
|
||||||
|
# 1. 시간 체크 (오전 10시 ~ 오후 5시)
|
||||||
|
now = datetime.now()
|
||||||
|
if not (10 <= now.hour <= 17):
|
||||||
|
return
|
||||||
|
|
||||||
|
site_id = plant_info.get('id')
|
||||||
|
plant_name = plant_info.get('display_name', plant_info.get('name'))
|
||||||
|
chat_id = plant_info.get('telegram_chat_id')
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1.5. DB에서 알림 활성화 상태 확인
|
||||||
|
try:
|
||||||
|
from database import get_supabase_client
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client:
|
||||||
|
company_id = plant_info.get('company_id', 1)
|
||||||
|
resp = client.table("plants").select("alerts_enabled").eq("id", site_id).eq("company_id", company_id).execute()
|
||||||
|
if resp.data and resp.data[0].get('alerts_enabled') is False:
|
||||||
|
print(f" 🔇 [Alert] {plant_name}: 알림이 비활성화되어 있습니다.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 알림 설정 확인 중 오류: {e}")
|
||||||
|
|
||||||
|
# 2. 현재 DB 상태 확인
|
||||||
|
current_status = 'NORMAL'
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT alert_status FROM alert_history WHERE site_id = ?", (site_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
current_status = row[0]
|
||||||
|
else:
|
||||||
|
# 초기값 생성
|
||||||
|
cursor.execute("INSERT INTO alert_history (site_id, alert_status) VALUES (?, ?)", (site_id, 'NORMAL'))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 3. 상태 전이 로직
|
||||||
|
new_status = current_status
|
||||||
|
|
||||||
|
# [Case A] 발전량 0 (이상 감지)
|
||||||
|
if current_kw == 0:
|
||||||
|
if current_status == 'NORMAL':
|
||||||
|
# NORMAL -> ALERT: 알림 전송
|
||||||
|
print(f" 🚨 [Alert] {plant_name} 발전량 0kW 감지! 알림 전송 시도...")
|
||||||
|
|
||||||
|
if chat_id:
|
||||||
|
message = (
|
||||||
|
f"🚨 [긴급] 발전소 이상 감지!\n\n"
|
||||||
|
f"- 발전소: {plant_name}\n"
|
||||||
|
f"- 상태: 발전량 0kW\n"
|
||||||
|
f"- 시간: {now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
)
|
||||||
|
if self.send_telegram_message(chat_id, message):
|
||||||
|
new_status = 'ALERT'
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_name}: Chat ID 오류로 알림 실패")
|
||||||
|
# 전송 실패해도 상태를 ALERT로 할 것인가?
|
||||||
|
# 실패했다면 다음에 다시 시도해야 하므로 NORMAL 유지
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_name}: 설정된 Chat ID가 없습니다. (config.py 확인)")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 이미 ALERT 상태: 중복 알림 생략
|
||||||
|
pass
|
||||||
|
|
||||||
|
# [Case B] 발전량 > 0 (정상 복구)
|
||||||
|
else:
|
||||||
|
if current_status == 'ALERT':
|
||||||
|
# ALERT -> NORMAL: 상태 리셋
|
||||||
|
print(f" ✅ [Alert] {plant_name} 정상 복구됨 ({current_kw}kW)")
|
||||||
|
# 복구 알림은 옵션 (현재는 생략)
|
||||||
|
new_status = 'NORMAL'
|
||||||
|
|
||||||
|
# 4. 상태 변경 시 DB 업데이트
|
||||||
|
if new_status != current_status:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE alert_history
|
||||||
|
SET alert_status = ?, last_alert_time = ?
|
||||||
|
WHERE site_id = ?
|
||||||
|
""", (new_status, now.isoformat(), site_id))
|
||||||
|
conn.commit()
|
||||||
229
crawler/config.py
Normal file
229
crawler/config.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# ==========================================
|
||||||
|
# config.py - 다중 업체(Multi-Tenant) 설정 관리
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# [프록시 설정 - 클라우드 이전용]
|
||||||
|
# 오라클 서버 등 외부 망에서 접속할 때 NAS의 인터넷을 빌려 쓰기 위한 설정입니다.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
USE_PROXY = False # True로 변경하면 모든 크롤링이 아래 프록시를 경유합니다.
|
||||||
|
PROXY_URL = "http://100.83.7.81:3128"
|
||||||
|
PROXIES = {
|
||||||
|
"http": PROXY_URL,
|
||||||
|
"https": PROXY_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# [시스템 상수] 각 크롤러 시스템의 URL 및 엔드포인트
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
SYSTEM_CONSTANTS = {
|
||||||
|
'nrems': {
|
||||||
|
'api_url': 'http://www.nrems.co.kr/v2/local/proc/index_proc.php',
|
||||||
|
'detail_url': 'http://www.nrems.co.kr/v2/local/comp/cp_inv.php',
|
||||||
|
'inv_proc_url': 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
|
||||||
|
},
|
||||||
|
'kremc': {
|
||||||
|
'login_url': 'https://kremc.kr/api/v2.2/login',
|
||||||
|
'api_base': 'https://kremc.kr/api/v2.2',
|
||||||
|
'enso_type': '15001'
|
||||||
|
},
|
||||||
|
'sun_wms': {
|
||||||
|
'base_url': 'http://tb6.sun-wms.com',
|
||||||
|
'login_url': 'http://tb6.sun-wms.com/public/main/login_chk.php',
|
||||||
|
'data_url': 'http://tb6.sun-wms.com/public/main/realdata.php',
|
||||||
|
'statics_url': 'http://tb6.sun-wms.com/public/statics/statics.php'
|
||||||
|
},
|
||||||
|
'hyundai': {
|
||||||
|
'base_url': 'https://hs3.hyundai-es.co.kr',
|
||||||
|
'login_path': '/hismart/login',
|
||||||
|
'data_path': '/hismart/site/getSolraUnitedWork'
|
||||||
|
},
|
||||||
|
'cmsolar': {
|
||||||
|
'base_url': 'http://www.cmsolar2.kr',
|
||||||
|
'api_url': 'http://www.cmsolar2.kr',
|
||||||
|
'login_url': 'http://www.cmsolar2.kr/login_ok.php',
|
||||||
|
'data_url': 'http://www.cmsolar2.kr/plant/sub/report_ok.php'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# [텔레그램 봇 설정]
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# https://t.me/BotFather 로 생성한 봇 토큰
|
||||||
|
# 사용자는 봇에게 먼저 메시지를 보내야 Chat ID를 알 수 있습니다.
|
||||||
|
TELEGRAM_BOT_TOKEN = '8273363609:AAEGv4abJSORNkap6XO_mqbnBKemBOEjugI'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# [업체 목록] 업체 > 발전소 계층 구조
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
COMPANIES = [
|
||||||
|
{
|
||||||
|
'company_id': 'sunwind',
|
||||||
|
'company_name': '태양과바람',
|
||||||
|
'telegram_chat_id': -1003713715004, # 그룹이 슈퍼그룹으로 업그레이드되어 변경된 새 ID 적용
|
||||||
|
'plants': [
|
||||||
|
# NREMS 계열 - 1, 2호기 (분리 처리)
|
||||||
|
# id는 크롤러 내부에서 'nrems-01', 'nrems-02'로 분리 할당
|
||||||
|
{
|
||||||
|
'name': '1호기, 2호기',
|
||||||
|
'display_name': 'SPLIT_1_2',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {
|
||||||
|
'pscode': 'duce2023072288'
|
||||||
|
},
|
||||||
|
'options': {
|
||||||
|
'is_split': True
|
||||||
|
},
|
||||||
|
'start_date': '2014-03-31',
|
||||||
|
'capacity_kw': 100.0 # 1호기 50kW + 2호기 50kW
|
||||||
|
# id는 크롤러에서 동적 할당 (nrems-01, nrems-02)
|
||||||
|
},
|
||||||
|
# NREMS 계열 - 3호기
|
||||||
|
{
|
||||||
|
'id': 'nrems-03',
|
||||||
|
'name': '3호기',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {
|
||||||
|
'pscode': 'dc2023121086'
|
||||||
|
},
|
||||||
|
'options': {
|
||||||
|
'is_split': False
|
||||||
|
},
|
||||||
|
'start_date': '2015-12-22',
|
||||||
|
'capacity_kw': 99.82
|
||||||
|
},
|
||||||
|
# NREMS 계열 - 4호기
|
||||||
|
{
|
||||||
|
'id': 'nrems-04',
|
||||||
|
'name': '4호기',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {
|
||||||
|
'pscode': 'dc2023121085'
|
||||||
|
},
|
||||||
|
'options': {
|
||||||
|
'is_split': False
|
||||||
|
},
|
||||||
|
'start_date': '2017-01-11',
|
||||||
|
'capacity_kw': 88.2
|
||||||
|
},
|
||||||
|
# NREMS 계열 - 9호기
|
||||||
|
{
|
||||||
|
'id': 'nrems-09',
|
||||||
|
'name': '9호기',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {
|
||||||
|
'pscode': 'a2020061008'
|
||||||
|
},
|
||||||
|
'options': {
|
||||||
|
'is_split': False
|
||||||
|
},
|
||||||
|
'start_date': '2020-10-28',
|
||||||
|
'capacity_kw': 99.12
|
||||||
|
},
|
||||||
|
# KREMC - 5호기
|
||||||
|
{
|
||||||
|
'id': 'kremc-05',
|
||||||
|
'name': '5호기',
|
||||||
|
'type': 'kremc',
|
||||||
|
'auth': {
|
||||||
|
'user_id': '서대문도서관',
|
||||||
|
'password': 'sunhope5!'
|
||||||
|
},
|
||||||
|
'options': {
|
||||||
|
'cid': '10013000376',
|
||||||
|
'cityProvCode': '11',
|
||||||
|
'rgnCode': '11410',
|
||||||
|
'dongCode': '1141011700'
|
||||||
|
},
|
||||||
|
'start_date': '2018-06-28',
|
||||||
|
'capacity_kw': 42.7
|
||||||
|
},
|
||||||
|
# Sun-WMS - 6호기
|
||||||
|
{
|
||||||
|
'id': 'sunwms-06',
|
||||||
|
'name': '6호기',
|
||||||
|
'type': 'sun_wms',
|
||||||
|
'auth': {
|
||||||
|
'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==',
|
||||||
|
'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='
|
||||||
|
},
|
||||||
|
'options': {},
|
||||||
|
'start_date': '2019-12-30',
|
||||||
|
'capacity_kw': 49.9
|
||||||
|
},
|
||||||
|
# 현대 - 8호기
|
||||||
|
{
|
||||||
|
'id': 'hyundai-08',
|
||||||
|
'name': '8호기',
|
||||||
|
'type': 'hyundai',
|
||||||
|
'auth': {
|
||||||
|
'user_id': 'epecoop',
|
||||||
|
'password': 'sunhope0419',
|
||||||
|
'site_id': 'M0494'
|
||||||
|
},
|
||||||
|
'options': {},
|
||||||
|
'start_date': '2020-02-06',
|
||||||
|
'capacity_kw': 99.9
|
||||||
|
},
|
||||||
|
# CMSolar - 10호기
|
||||||
|
{
|
||||||
|
'id': 'cmsolar-10',
|
||||||
|
'name': '10호기',
|
||||||
|
'type': 'cmsolar',
|
||||||
|
'auth': {
|
||||||
|
'login_id': 'sy7144',
|
||||||
|
'login_pw': 'sy7144',
|
||||||
|
'site_no': '834'
|
||||||
|
},
|
||||||
|
'options': {},
|
||||||
|
'start_date': '2020-08-31',
|
||||||
|
'capacity_kw': 31.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# [헬퍼 함수] 평탄화된 발전소 리스트 반환
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
def get_all_plants():
|
||||||
|
"""
|
||||||
|
모든 업체의 발전소 정보를 평탄화하여 반환
|
||||||
|
"""
|
||||||
|
all_plants = []
|
||||||
|
|
||||||
|
for company in COMPANIES:
|
||||||
|
company_id = company.get('company_id', '')
|
||||||
|
company_name = company.get('company_name', '')
|
||||||
|
telegram_chat_id = company.get('telegram_chat_id')
|
||||||
|
|
||||||
|
for plant in company.get('plants', []):
|
||||||
|
plant_type = plant.get('type', '')
|
||||||
|
system_config = SYSTEM_CONSTANTS.get(plant_type, {})
|
||||||
|
|
||||||
|
plant_info = {
|
||||||
|
'company_id': company_id,
|
||||||
|
'company_name': company_name,
|
||||||
|
'telegram_chat_id': telegram_chat_id,
|
||||||
|
'id': plant.get('id', ''), # DB용 고유 ID
|
||||||
|
'name': plant.get('name', ''),
|
||||||
|
'display_name': plant.get('display_name', plant.get('name', '')),
|
||||||
|
'type': plant_type,
|
||||||
|
'auth': plant.get('auth', {}),
|
||||||
|
'options': plant.get('options', {}),
|
||||||
|
'start_date': plant.get('start_date', ''),
|
||||||
|
'capacity_kw': plant.get('capacity_kw', 0.0),
|
||||||
|
'system': system_config
|
||||||
|
}
|
||||||
|
|
||||||
|
all_plants.append(plant_info)
|
||||||
|
|
||||||
|
return all_plants
|
||||||
|
|
||||||
|
def get_plants_by_company(company_id):
|
||||||
|
"""특정 업체의 발전소만 반환"""
|
||||||
|
return [p for p in get_all_plants() if p['company_id'] == company_id]
|
||||||
|
|
||||||
|
def get_plants_by_type(plant_type):
|
||||||
|
"""특정 타입의 발전소만 반환"""
|
||||||
|
return [p for p in get_all_plants() if p['type'] == plant_type]
|
||||||
404
crawler/crawler_gui.py
Normal file
404
crawler/crawler_gui.py
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox, scrolledtext
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 프로젝트 루트 경로 추가
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(current_dir)
|
||||||
|
sys.path.append(project_root)
|
||||||
|
|
||||||
|
# 모듈 import 시도 (실패 시 예외처리)
|
||||||
|
try:
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawler_manager import CrawlerManager
|
||||||
|
except ImportError:
|
||||||
|
# GUI 단독 실행 시 더미 데이터 사용 가능하도록
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CrawlerControlPanel:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("☀️ 태양광 발전 통합 관제 시스템 [관리자 모드]")
|
||||||
|
self.root.geometry("1100x750")
|
||||||
|
self.root.configure(bg="#f0f2f5")
|
||||||
|
|
||||||
|
# 스타일 설정
|
||||||
|
self.setup_styles()
|
||||||
|
|
||||||
|
# 데이터 매니저 초기화
|
||||||
|
try:
|
||||||
|
self.manager = CrawlerManager(os.path.join(project_root, "crawler_manager.db"))
|
||||||
|
self.plants = get_all_plants()
|
||||||
|
except:
|
||||||
|
self.manager = None
|
||||||
|
self.plants = []
|
||||||
|
|
||||||
|
# 메인 레이아웃
|
||||||
|
self.create_layout()
|
||||||
|
|
||||||
|
# 초기 데이터 로드
|
||||||
|
self.refresh_monitor()
|
||||||
|
|
||||||
|
def setup_styles(self):
|
||||||
|
style = ttk.Style()
|
||||||
|
style.theme_use('clam')
|
||||||
|
|
||||||
|
# 프리미엄 색상 팔레트
|
||||||
|
colors = {
|
||||||
|
'primary': '#2563eb',
|
||||||
|
'secondary': '#64748b',
|
||||||
|
'success': '#16a34a',
|
||||||
|
'danger': '#dc2626',
|
||||||
|
'bg': '#f8fafc',
|
||||||
|
'card': '#ffffff'
|
||||||
|
}
|
||||||
|
|
||||||
|
style.configure("Header.TLabel", font=("Malgun Gothic", 16, "bold"), background="#f0f2f5", foreground="#1e293b")
|
||||||
|
style.configure("Section.TLabel", font=("Malgun Gothic", 12, "bold"), background="#f0f2f5", foreground="#334155")
|
||||||
|
|
||||||
|
style.configure("Card.TFrame", background="#ffffff", relief="flat")
|
||||||
|
|
||||||
|
# 트리뷰 스타일 (표)
|
||||||
|
style.configure("Treeview",
|
||||||
|
background="#ffffff",
|
||||||
|
fieldbackground="#ffffff",
|
||||||
|
font=("Malgun Gothic", 10),
|
||||||
|
rowheight=30
|
||||||
|
)
|
||||||
|
style.configure("Treeview.Heading",
|
||||||
|
font=("Malgun Gothic", 10, "bold"),
|
||||||
|
background="#e2e8f0",
|
||||||
|
foreground="#1e293b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 버튼 스타일
|
||||||
|
style.configure("Action.TButton", font=("Malgun Gothic", 10), padding=6)
|
||||||
|
style.map("Action.TButton", background=[("active", "#dbeafe")])
|
||||||
|
|
||||||
|
def create_layout(self):
|
||||||
|
# 상단 헤더
|
||||||
|
header_frame = ttk.Frame(self.root, padding="20 20 20 10")
|
||||||
|
header_frame.pack(fill="x")
|
||||||
|
|
||||||
|
ttk.Label(header_frame, text="⚡ SolorPower Crawler Control", style="Header.TLabel").pack(side="left")
|
||||||
|
|
||||||
|
status_frame = ttk.Frame(header_frame)
|
||||||
|
status_frame.pack(side="right")
|
||||||
|
self.status_label = ttk.Label(status_frame, text="🟢 시스템 대기중", font=("Malgun Gothic", 10), foreground="green")
|
||||||
|
self.status_label.pack()
|
||||||
|
|
||||||
|
# 메인 컨텐츠 (좌우 분할)
|
||||||
|
main_paned = ttk.PanedWindow(self.root, orient="horizontal")
|
||||||
|
main_paned.pack(fill="both", expand=True, padx=20, pady=10)
|
||||||
|
|
||||||
|
# 좌측 패널: 발전소 목록 및 제어
|
||||||
|
left_frame = ttk.Frame(main_paned)
|
||||||
|
main_paned.add(left_frame, weight=2)
|
||||||
|
|
||||||
|
# 우측 패널: 로그 및 상세 정보
|
||||||
|
right_frame = ttk.Frame(main_paned)
|
||||||
|
main_paned.add(right_frame, weight=1)
|
||||||
|
|
||||||
|
# --- 좌측 패널 구성 ---
|
||||||
|
# 1. 제어 버튼 그룹
|
||||||
|
control_frame = ttk.LabelFrame(left_frame, text="통합 제어", padding=15)
|
||||||
|
control_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
btn_grid = ttk.Frame(control_frame)
|
||||||
|
btn_grid.pack(fill="x")
|
||||||
|
|
||||||
|
ttk.Button(btn_grid, text="▶ 전체 수집 시작", command=self.run_all_crawlers, style="Action.TButton").pack(side="left", padx=5)
|
||||||
|
ttk.Button(btn_grid, text="🔄 새로고침", command=self.refresh_monitor, style="Action.TButton").pack(side="left", padx=5)
|
||||||
|
ttk.Button(btn_grid, text="📊 통계 요약 실행", command=self.run_daily_summary, style="Action.TButton").pack(side="left", padx=5)
|
||||||
|
|
||||||
|
# 2. 발전소 모니터링 테이블
|
||||||
|
table_frame = ttk.LabelFrame(left_frame, text="발전소 모니터링 현황", padding=10)
|
||||||
|
table_frame.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
columns = ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history")
|
||||||
|
self.tree = ttk.Treeview(table_frame, columns=columns, show="tree headings", selectmode="browse")
|
||||||
|
|
||||||
|
self.tree.heading("site_id", text="ID")
|
||||||
|
self.tree.heading("name", text="발전소명")
|
||||||
|
self.tree.heading("type", text="타입")
|
||||||
|
self.tree.heading("status", text="상태")
|
||||||
|
self.tree.heading("schedule", text="스케줄")
|
||||||
|
self.tree.heading("last_run", text="최근 실행")
|
||||||
|
self.tree.heading("action", text="개별 제어")
|
||||||
|
self.tree.heading("history", text="과거 데이터")
|
||||||
|
|
||||||
|
self.tree.column("site_id", width=80)
|
||||||
|
self.tree.column("name", width=150)
|
||||||
|
self.tree.column("type", width=80)
|
||||||
|
self.tree.column("status", width=80)
|
||||||
|
self.tree.column("schedule", width=100)
|
||||||
|
self.tree.column("last_run", width=140)
|
||||||
|
self.tree.column("action", width=80)
|
||||||
|
self.tree.column("history", width=80)
|
||||||
|
|
||||||
|
scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
|
||||||
|
self.tree.configure(yscroll=scrollbar.set)
|
||||||
|
|
||||||
|
self.tree.pack(side="left", fill="both", expand=True)
|
||||||
|
scrollbar.pack(side="right", fill="y")
|
||||||
|
|
||||||
|
# 우클릭 메뉴 (복구)
|
||||||
|
self.context_menu = tk.Menu(self.root, tearoff=0)
|
||||||
|
self.context_menu.add_command(label="▶ 이 사이트만 즉시 실행", command=self.run_selected_crawler)
|
||||||
|
self.context_menu.add_command(label="📑 상세 로그 보기", command=self.show_site_logs)
|
||||||
|
self.context_menu.add_separator()
|
||||||
|
self.context_menu.add_command(label="🔄 학습 모드로 리셋", command=self.reset_learning_mode)
|
||||||
|
|
||||||
|
# 이벤트 바인딩
|
||||||
|
self.tree.bind("<ButtonRelease-1>", self.on_tree_click)
|
||||||
|
self.tree.bind("<Button-3>", self.show_context_menu)
|
||||||
|
self.tree.bind("<Double-1>", lambda e: self.run_selected_crawler())
|
||||||
|
|
||||||
|
# --- 우측 패널 구성 ---
|
||||||
|
# 실시간 로그 뷰어
|
||||||
|
log_frame = ttk.LabelFrame(right_frame, text="실시간 시스템 로그", padding=10)
|
||||||
|
log_frame.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
self.log_text = scrolledtext.ScrolledText(log_frame, state='disabled', font=("Consolas", 9), bg="#1e293b", fg="#e2e8f0")
|
||||||
|
self.log_text.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# 태그 설정 (로그 색상)
|
||||||
|
self.log_text.tag_config("INFO", foreground="#60a5fa")
|
||||||
|
self.log_text.tag_config("SUCCESS", foreground="#4ade80")
|
||||||
|
self.log_text.tag_config("ERROR", foreground="#f87171")
|
||||||
|
self.log_text.tag_config("WARNING", foreground="#fbbf24")
|
||||||
|
|
||||||
|
def log(self, message, level="INFO"):
|
||||||
|
"""로그 창에 메시지 출력"""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
full_msg = f"[{timestamp}] {message}\n"
|
||||||
|
|
||||||
|
self.log_text.configure(state='normal')
|
||||||
|
self.log_text.insert("end", full_msg, level)
|
||||||
|
self.log_text.see("end")
|
||||||
|
self.log_text.configure(state='disabled')
|
||||||
|
|
||||||
|
def refresh_monitor(self):
|
||||||
|
"""테이블 데이터 새로고침"""
|
||||||
|
# 기존 항목 제거
|
||||||
|
for i in self.tree.get_children():
|
||||||
|
self.tree.delete(i)
|
||||||
|
|
||||||
|
if not self.manager:
|
||||||
|
self.log("DB 매니저 로드 실패", "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
# DB에서 최신 상태 조회
|
||||||
|
site_stats = {s['site_id']: s for s in self.manager.get_all_sites()}
|
||||||
|
|
||||||
|
# 중복 회사 노드 방지용
|
||||||
|
added_companies = set()
|
||||||
|
|
||||||
|
for plant in self.plants:
|
||||||
|
# 1,2호기 분리 로직 반영
|
||||||
|
is_split = plant.get('options', {}).get('is_split', False)
|
||||||
|
company_name = plant.get('company_name', '')
|
||||||
|
plant_name = plant.get('name', '')
|
||||||
|
|
||||||
|
sub_units = []
|
||||||
|
if is_split:
|
||||||
|
sub_units.append({'id': 'nrems-01', 'name': f'{company_name} 1호기', 'type': plant['type']})
|
||||||
|
sub_units.append({'id': 'nrems-02', 'name': f'{company_name} 2호기', 'type': plant['type']})
|
||||||
|
else:
|
||||||
|
plant_id = plant.get('id', '')
|
||||||
|
if plant_id:
|
||||||
|
sub_units.append({'id': plant_id, 'name': f'{company_name} {plant_name}', 'type': plant['type']})
|
||||||
|
|
||||||
|
for unit in sub_units:
|
||||||
|
site_id = unit['id']
|
||||||
|
stat = site_stats.get(site_id, {})
|
||||||
|
|
||||||
|
status_text = stat.get('status', 'UNREGISTERED')
|
||||||
|
schedule_text = f"매시 {stat.get('target_minute', -1)}분" if stat.get('target_minute', -1) >= 0 else "학습중"
|
||||||
|
last_run = stat.get('last_run', '-') or '-'
|
||||||
|
if last_run != '-':
|
||||||
|
try:
|
||||||
|
last_run = last_run.split('.')[0].replace('T', ' ') # 포맷팅
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 태그 설정 (색상)
|
||||||
|
row_tag = "normal"
|
||||||
|
if status_text == 'OPTIMIZED': row_tag = "optimized"
|
||||||
|
|
||||||
|
# 회사 노드 확인 및 생성
|
||||||
|
company_id = plant.get('company_id', 'unknown')
|
||||||
|
if company_id not in added_companies:
|
||||||
|
self.tree.insert("", "end", iid=company_id, text=company_name, values=(
|
||||||
|
"", company_name, "GROUP", "", "", "", "", ""
|
||||||
|
), open=True)
|
||||||
|
added_companies.add(company_id)
|
||||||
|
|
||||||
|
# 발전소 노드 추가 (회사 노드 하위)
|
||||||
|
self.tree.insert(company_id, "end", iid=site_id, values=(
|
||||||
|
site_id,
|
||||||
|
unit['name'],
|
||||||
|
unit['type'].upper(),
|
||||||
|
status_text,
|
||||||
|
schedule_text,
|
||||||
|
last_run,
|
||||||
|
"▶ 실행",
|
||||||
|
"📥 수집"
|
||||||
|
), tags=(row_tag,))
|
||||||
|
|
||||||
|
self.tree.tag_configure("optimized", foreground="#059669") # 진한 녹색
|
||||||
|
self.log("모니터링 상태 갱신 완료 (계층형)", "INFO")
|
||||||
|
|
||||||
|
def on_tree_click(self, event):
|
||||||
|
"""트리뷰 클릭 이벤트 처리"""
|
||||||
|
try:
|
||||||
|
region = self.tree.identify_region(event.x, event.y)
|
||||||
|
if region != "cell": return
|
||||||
|
|
||||||
|
col = self.tree.identify_column(event.x)
|
||||||
|
item_id = self.tree.identify_row(event.y)
|
||||||
|
|
||||||
|
if not item_id: return
|
||||||
|
|
||||||
|
# 컬럼 인덱스 확인 (columns 배열 기준 1-based, #1=site_id, ... #7=action, #8=history)
|
||||||
|
# Treeview columns: ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history")
|
||||||
|
# Display columns include transparent tree column if show="tree headings"
|
||||||
|
# identify_column returns '#N'.
|
||||||
|
# #1: site_id, #7: action, #8: history
|
||||||
|
|
||||||
|
if col == '#7': # Action (실행)
|
||||||
|
self.log(f"'{item_id}' 실행 요청", "INFO")
|
||||||
|
# TODO: 개별 실행
|
||||||
|
self.run_process_thread(["main.py", "--site", item_id], f"{item_id} 수집")
|
||||||
|
|
||||||
|
elif col == '#8': # History (과거 데이터)
|
||||||
|
# 그룹 노드는 제외
|
||||||
|
if self.tree.parent(item_id) == "":
|
||||||
|
return
|
||||||
|
if messagebox.askyesno("과거 데이터 수집", f"'{item_id}'의 과거 내역을 수집하시겠습니까?\n(시간별/일별/월별 전체)"):
|
||||||
|
self.run_process_thread(["fetch_history.py", item_id], f"{item_id} 히스토리 수집")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"클릭 처리 중 오류: {e}", "ERROR")
|
||||||
|
|
||||||
|
def show_context_menu(self, event):
|
||||||
|
item = self.tree.identify_row(event.y)
|
||||||
|
if item:
|
||||||
|
self.tree.selection_set(item)
|
||||||
|
self.context_menu.post(event.x_root, event.y_root)
|
||||||
|
|
||||||
|
def run_process_thread(self, cmd_list, description):
|
||||||
|
"""백그라운드 스레드에서 서브프로세스 실행"""
|
||||||
|
def task():
|
||||||
|
self.status_label.config(text=f"⏳ {description} 중...", foreground="orange")
|
||||||
|
self.log(f"{description} 시작...", "INFO")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# python 실행 경로 확보
|
||||||
|
python_exe = sys.executable
|
||||||
|
|
||||||
|
# 가상환경 venv/temp_env 사용 시 경로 조정
|
||||||
|
venv_python = os.path.join(project_root, "venv", "Scripts", "python.exe")
|
||||||
|
temp_env_python = os.path.join(current_dir, "temp_env", "Scripts", "python.exe")
|
||||||
|
|
||||||
|
if os.path.exists(temp_env_python):
|
||||||
|
python_exe = temp_env_python
|
||||||
|
elif os.path.exists(venv_python):
|
||||||
|
python_exe = venv_python
|
||||||
|
|
||||||
|
full_cmd = [python_exe] + cmd_list
|
||||||
|
|
||||||
|
# 서브프로세스 실행
|
||||||
|
process = subprocess.Popen(
|
||||||
|
full_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=current_dir,
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace' # 인코딩 에러 방지
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if "Error" in line or "fail" in line.lower():
|
||||||
|
self.log(line, "ERROR")
|
||||||
|
else:
|
||||||
|
self.log(line, "INFO")
|
||||||
|
|
||||||
|
if stderr:
|
||||||
|
self.log(f"STDERR: {stderr}", "WARNING")
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
self.log(f"{description} 완료 ✅", "SUCCESS")
|
||||||
|
else:
|
||||||
|
self.log(f"{description} 실패 (Exit Code: {process.returncode})", "ERROR")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"실행 오류: {e}", "ERROR")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.root.after(0, self.refresh_monitor)
|
||||||
|
self.root.after(0, lambda: self.status_label.config(text="🟢 시스템 대기중", foreground="green"))
|
||||||
|
|
||||||
|
thread = threading.Thread(target=task)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def run_all_crawlers(self):
|
||||||
|
"""전체 통합 크롤링 실행 (강제 모드)"""
|
||||||
|
if messagebox.askyesno("확인", "모든 발전소 데이터를 강제로 수집하시겠습니까?"):
|
||||||
|
self.run_process_thread(["main.py", "--force"], "전체 데이터 수집")
|
||||||
|
|
||||||
|
def run_selected_crawler(self):
|
||||||
|
"""선택된 단일 사이트 크롤링 (현재 main.py는 단일 실행 옵션이 없어서 전체를 돌리되, 추후 개선 필요)"""
|
||||||
|
# 임시로 단일 실행 기능이 없으므로 알림만 띄움 (추후 main.py에 --site 옵션 추가 필요)
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
|
||||||
|
site_id = selected[0]
|
||||||
|
# main.py 수정 없이 특정 사이트만 돌리기 어려우므로, 안내 메시지
|
||||||
|
# 실제로는 main.py에 인자 처리를 추가해야 함.
|
||||||
|
# 여기서는 전체 실행으로 대체하거나, 추후 main.py 업데이트 후 구현
|
||||||
|
|
||||||
|
# 임시 구현: main.py를 호출하되 필터링은 구현 안 되어있음.
|
||||||
|
# 이번 단계에서는 GUI 틀을 만드는 것이므로 전체 실행으로 트리거
|
||||||
|
self.log(f"'{site_id}' 단일 실행 요청 (현재는 전체 실행으로 동작)", "WARNING")
|
||||||
|
self.run_process_thread(["main.py", "--force"], f"'{site_id}' 데이터 수집")
|
||||||
|
|
||||||
|
def run_daily_summary(self):
|
||||||
|
"""일일 통계 집계 실행"""
|
||||||
|
self.run_process_thread(["daily_summary.py"], "일일 통계 집계")
|
||||||
|
|
||||||
|
def show_site_logs(self):
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if selected:
|
||||||
|
site_id = selected[0]
|
||||||
|
self.log(f"'{site_id}' 로그 조회 기능은 아직 구현되지 않았습니다.", "INFO")
|
||||||
|
|
||||||
|
def reset_learning_mode(self):
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if selected:
|
||||||
|
site_id = selected[0]
|
||||||
|
if self.manager.reset_to_learning(site_id):
|
||||||
|
self.log(f"'{site_id}' 학습 모드로 리셋 완료", "SUCCESS")
|
||||||
|
self.refresh_monitor()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
root = tk.Tk()
|
||||||
|
|
||||||
|
# 아이콘 설정 (옵션)
|
||||||
|
# try: root.iconbitmap("icon.ico")
|
||||||
|
# except: pass
|
||||||
|
|
||||||
|
app = CrawlerControlPanel(root)
|
||||||
|
root.mainloop()
|
||||||
456
crawler/crawler_manager.py
Normal file
456
crawler/crawler_manager.py
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawler_manager.py - 크롤링 스케줄 최적화 미들웨어
|
||||||
|
# ==========================================
|
||||||
|
# NAS 리소스 절약을 위해 SQLite 기반으로 각 사이트의
|
||||||
|
# 업데이트 패턴을 학습하고, 데이터가 실제로 변경된 시점에만 DB 저장
|
||||||
|
#
|
||||||
|
# [설계 원칙]
|
||||||
|
# - 크롤링(HTTP 요청) 자체는 항상 허용 (야간 제외)
|
||||||
|
# → 원격 서버가 언제 업데이트할지 모르므로 주기적으로 확인해야 함
|
||||||
|
# - DB 저장은 데이터가 실제로 변경되었을 때만 실행
|
||||||
|
# → 중복 저장 방지 + NAS I/O 절약
|
||||||
|
# - 업데이트 패턴 학습은 부가 기능 (로깅용)
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlerManager:
|
||||||
|
"""
|
||||||
|
크롤링 DB 저장을 최적화하는 매니저 클래스
|
||||||
|
|
||||||
|
- should_run: 야간(21시~05시) 여부만 체크 → False면 크롤링 자체를 스킵
|
||||||
|
- should_save: 데이터가 실제로 변경되었는지 확인 → False면 DB 저장 스킵
|
||||||
|
- analyze_and_optimize: 업데이트 패턴 학습 (로깅/모니터링 목적)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = None):
|
||||||
|
"""
|
||||||
|
DB 연결 및 테이블 초기화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: SQLite DB 파일 경로. 기본값은 스크립트와 같은 디렉토리의 crawler_manager.db
|
||||||
|
"""
|
||||||
|
if db_path is None:
|
||||||
|
db_path = Path(__file__).parent / "crawler_manager.db"
|
||||||
|
|
||||||
|
self.db_path = str(db_path)
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""테이블이 없으면 생성"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS site_rules (
|
||||||
|
site_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT DEFAULT 'LEARNING',
|
||||||
|
target_minute INTEGER DEFAULT -1,
|
||||||
|
start_date TEXT,
|
||||||
|
last_run TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_data (
|
||||||
|
site_id TEXT PRIMARY KEY,
|
||||||
|
kw REAL,
|
||||||
|
today_kwh REAL,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS update_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT,
|
||||||
|
detected_minute INTEGER,
|
||||||
|
detected_at TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""SQLite 연결 반환 (타임아웃 설정 추가)"""
|
||||||
|
return sqlite3.connect(self.db_path, timeout=10.0)
|
||||||
|
|
||||||
|
def _cleanup_old_history(self):
|
||||||
|
"""오래된 히스토리 정리 (30일 이상 지난 데이터 삭제)"""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
limit_date = (datetime.now() - timedelta(days=30)).isoformat()
|
||||||
|
cursor.execute("DELETE FROM update_history WHERE detected_at < ?", (limit_date,))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [CrawlerManager] 히스토리 정리 실패: {e}")
|
||||||
|
|
||||||
|
def register_site(self, site_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
새로운 사이트 등록
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자 (예: 'nrems-01')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 새로 등록되었으면 True, 이미 존재하면 False
|
||||||
|
"""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT 1 FROM site_rules WHERE site_id = ?", (site_id,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
return False
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO site_rules (site_id, status, target_minute, start_date, last_run)
|
||||||
|
VALUES (?, 'LEARNING', -1, ?, NULL)
|
||||||
|
""", (site_id, today))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f" 📝 [CrawlerManager] '{site_id}' 신규 등록 (LEARNING 모드)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def should_run(self, site_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
현재 시점에 해당 사이트를 크롤링(HTTP 요청)해야 하는지 판단.
|
||||||
|
|
||||||
|
[변경 사항]
|
||||||
|
이전: OPTIMIZED 상태면 특정 분(minute) 윈도우에서만 크롤링 허용
|
||||||
|
→ 문제: 원격 서버 업데이트 시점을 놓쳐 시계열 데이터 누락
|
||||||
|
현재: 야간(21시~05시)에만 False 반환, 그 외에는 항상 크롤링 허용
|
||||||
|
→ DB 저장 여부는 should_save()에서 별도 결정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 크롤링 실행 여부 (야간이면 False)
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
current_hour = now.hour
|
||||||
|
current_minute = now.minute
|
||||||
|
|
||||||
|
# 야간 모드: 21시 ~ 05시에는 크롤링 중지 (발전 없는 시간대)
|
||||||
|
if current_hour >= 21 or current_hour < 5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 히스토리 정리 (05시 정각에 1회)
|
||||||
|
if current_minute == 0 and current_hour == 5:
|
||||||
|
self._cleanup_old_history()
|
||||||
|
|
||||||
|
# 사이트 등록 (미등록 사이트 자동 등록)
|
||||||
|
self.register_site(site_id)
|
||||||
|
|
||||||
|
# 항상 크롤링 허용 (데이터 변경 여부는 should_save에서 판단)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def should_save(self, site_id: str, current_data: dict) -> bool:
|
||||||
|
"""
|
||||||
|
수집한 데이터를 DB에 저장해야 하는지 판단.
|
||||||
|
|
||||||
|
원격 서버의 데이터가 이전 수집 시점과 달라졌을 때만 True 반환.
|
||||||
|
이를 통해 중복 저장을 방지하고 NAS I/O를 절약.
|
||||||
|
|
||||||
|
[저장 조건]
|
||||||
|
- today_kwh(금일 발전량)가 증가했을 때: 반드시 저장 (핵심 지표)
|
||||||
|
- kw(현재 출력)가 변했을 때: 저장 (실시간 상태 반영)
|
||||||
|
- 마지막 저장 후 1시간 이상 경과했을 때: 강제 저장 (heartbeat)
|
||||||
|
→ 데이터가 정체돼도 최소 1시간에 1번은 기록 보장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자
|
||||||
|
current_data: {'kw': float, 'today': float}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: DB에 저장해야 하면 True
|
||||||
|
"""
|
||||||
|
new_kw = float(current_data.get('kw', 0))
|
||||||
|
new_today = float(current_data.get('today', 0))
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 이전 데이터 조회
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT kw, today_kwh, updated_at FROM site_data WHERE site_id = ?",
|
||||||
|
(site_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
should_save = False
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# 첫 수집 → 반드시 저장
|
||||||
|
should_save = True
|
||||||
|
else:
|
||||||
|
last_kw, last_today, last_updated_at = row
|
||||||
|
|
||||||
|
# 1. 금일 발전량이 증가했으면 저장
|
||||||
|
if new_today - last_today > 0.001:
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
# 2. 현재 출력(kW)이 변했으면 저장
|
||||||
|
elif abs(new_kw - last_kw) > 0.001:
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
# 3. 1시간 이상 저장 없었으면 강제 heartbeat 저장
|
||||||
|
elif last_updated_at:
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_updated_at)
|
||||||
|
if now - last_dt >= timedelta(hours=1):
|
||||||
|
should_save = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
if should_save:
|
||||||
|
# 현재 상태를 캐시에 업데이트
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO site_data (site_id, kw, today_kwh, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(site_id) DO UPDATE SET
|
||||||
|
kw = excluded.kw,
|
||||||
|
today_kwh = excluded.today_kwh,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""", (site_id, new_kw, new_today, now.isoformat()))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return should_save
|
||||||
|
|
||||||
|
def check_data_change(self, site_id: str, current_data: dict) -> bool:
|
||||||
|
"""
|
||||||
|
[하위 호환용] should_save의 별칭.
|
||||||
|
기존 main.py 코드와의 호환성을 위해 유지.
|
||||||
|
내부적으로 should_save를 호출하며, 패턴 분석도 함께 수행.
|
||||||
|
"""
|
||||||
|
return self.should_save(site_id, current_data)
|
||||||
|
|
||||||
|
def analyze_and_optimize(self, site_id: str):
|
||||||
|
"""
|
||||||
|
업데이트 패턴 분석 및 기록 (모니터링/로깅 목적).
|
||||||
|
데이터 변경이 감지되었을 때 호출하여 원격 서버의 업데이트 패턴을 학습.
|
||||||
|
이 정보는 현재 크롤링 스케줄 제어에는 사용하지 않으며,
|
||||||
|
향후 분석이나 시각화를 위한 참고 데이터로만 활용.
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
current_minute = now.minute
|
||||||
|
|
||||||
|
# 히스토리 기록
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO update_history (site_id, detected_minute, detected_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (site_id, current_minute, now.isoformat()))
|
||||||
|
|
||||||
|
# 최근 기록 조회 (최대 5개)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT detected_minute
|
||||||
|
FROM update_history
|
||||||
|
WHERE site_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 5
|
||||||
|
""", (site_id,))
|
||||||
|
|
||||||
|
minutes = [r[0] for r in cursor.fetchall()]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 패턴 분석 (최소 3회 이상 데이터 필요)
|
||||||
|
if len(minutes) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
recent = minutes[:3]
|
||||||
|
avg = sum(recent) / len(recent)
|
||||||
|
|
||||||
|
# 최대 편차가 5분 이내면 패턴 안정 (참고 정보로만 기록)
|
||||||
|
is_consistent = all(abs(m - avg) <= 5 for m in recent)
|
||||||
|
|
||||||
|
if is_consistent:
|
||||||
|
target = int(avg)
|
||||||
|
# 스케줄 제어에는 사용하지 않지만, 상태 기록은 유지 (모니터링용)
|
||||||
|
self._record_pattern(site_id, target)
|
||||||
|
else:
|
||||||
|
print(f" 📊 [CrawlerManager] '{site_id}' 패턴 분석 중... 최근: {recent}")
|
||||||
|
|
||||||
|
def _record_pattern(self, site_id: str, detected_minute: int):
|
||||||
|
"""
|
||||||
|
감지된 업데이트 패턴을 DB에 기록 (모니터링용).
|
||||||
|
크롤링 스케줄 제어에는 영향을 주지 않음.
|
||||||
|
"""
|
||||||
|
if not 0 <= detected_minute <= 59:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT status, target_minute FROM site_rules WHERE site_id = ?", (site_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0] == 'OPTIMIZED' and abs(row[1] - detected_minute) <= 2:
|
||||||
|
return # 이미 동일한 패턴 기록됨
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE site_rules
|
||||||
|
SET status = 'OPTIMIZED', target_minute = ?
|
||||||
|
WHERE site_id = ?
|
||||||
|
""", (detected_minute, site_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
print(f" 📌 [CrawlerManager] '{site_id}' 업데이트 패턴 감지: 매시 {detected_minute}분 경 (참고용)")
|
||||||
|
|
||||||
|
def update_optimization(self, site_id: str, detected_minute: int) -> bool:
|
||||||
|
"""
|
||||||
|
[하위 호환용] 패턴 기록 메서드.
|
||||||
|
내부적으로 _record_pattern을 호출.
|
||||||
|
"""
|
||||||
|
self._record_pattern(site_id, detected_minute)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def record_run(self, site_id: str):
|
||||||
|
"""
|
||||||
|
크롤링 성공 시 마지막 실행 시간 기록
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자
|
||||||
|
"""
|
||||||
|
now_str = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE site_rules
|
||||||
|
SET last_run = ?
|
||||||
|
WHERE site_id = ?
|
||||||
|
""", (now_str, site_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_site_info(self, site_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
사이트 정보 조회 (디버깅/모니터링용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 사이트 정보 또는 None
|
||||||
|
"""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT site_id, status, target_minute, start_date, last_run
|
||||||
|
FROM site_rules
|
||||||
|
WHERE site_id = ?
|
||||||
|
""", (site_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
"site_id": row[0],
|
||||||
|
"status": row[1],
|
||||||
|
"target_minute": row[2],
|
||||||
|
"start_date": row[3],
|
||||||
|
"last_run": row[4]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_sites(self) -> list:
|
||||||
|
"""
|
||||||
|
모든 사이트 정보 조회
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 모든 사이트 정보 리스트
|
||||||
|
"""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT site_id, status, target_minute, start_date, last_run
|
||||||
|
FROM site_rules
|
||||||
|
ORDER BY site_id
|
||||||
|
""")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"site_id": row[0],
|
||||||
|
"status": row[1],
|
||||||
|
"target_minute": row[2],
|
||||||
|
"start_date": row[3],
|
||||||
|
"last_run": row[4]
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def reset_to_learning(self, site_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
사이트를 다시 LEARNING 상태로 리셋
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: 사이트 식별자
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 리셋 성공 여부
|
||||||
|
"""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE site_rules
|
||||||
|
SET status = 'LEARNING', target_minute = -1
|
||||||
|
WHERE site_id = ?
|
||||||
|
""", (site_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# main.py 연동 방식 (변경 없음 - 하위 호환 유지)
|
||||||
|
# ==========================================
|
||||||
|
#
|
||||||
|
# main.py에서의 사용 흐름:
|
||||||
|
#
|
||||||
|
# 1. should_run(site_id)
|
||||||
|
# → 야간이면 False (크롤링 자체 스킵)
|
||||||
|
# → 그 외에는 항상 True (항상 HTTP 요청)
|
||||||
|
#
|
||||||
|
# 2. 크롤링(HTTP 요청) 실행
|
||||||
|
#
|
||||||
|
# 3. record_run(item_id) ← 크롤링 성공 기록
|
||||||
|
#
|
||||||
|
# 4. check_data_change(item_id, item) ← should_save와 동일
|
||||||
|
# → True: 데이터 변경됨 → DB 저장 진행
|
||||||
|
# → False: 변경 없음 → DB 저장 스킵
|
||||||
|
#
|
||||||
|
# 5. analyze_and_optimize(item_id) ← 패턴 학습 (선택적)
|
||||||
|
#
|
||||||
|
# ==========================================
|
||||||
|
# Cron 설정 (10분마다 실행 권장)
|
||||||
|
# ==========================================
|
||||||
|
# */10 * * * * cd /volume1/dev/SolorPower/crawler && \
|
||||||
|
# /volume1/dev/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
manager = CrawlerManager()
|
||||||
|
|
||||||
|
print("=== CrawlerManager 테스트 ===\n")
|
||||||
|
|
||||||
|
test_sites = ["nrems-01", "nrems-02", "kremc-05"]
|
||||||
|
for site_id in test_sites:
|
||||||
|
manager.register_site(site_id)
|
||||||
|
|
||||||
|
print("\n[등록된 사이트]")
|
||||||
|
for site in manager.get_all_sites():
|
||||||
|
print(f" {site['site_id']}: {site['status']} (target: {site['target_minute']}분)")
|
||||||
|
|
||||||
|
print("\n[should_run 테스트]")
|
||||||
|
for site_id in test_sites:
|
||||||
|
result = manager.should_run(site_id)
|
||||||
|
print(f" {site_id}: {'✅ 실행' if result else '⏭️ 스킵 (야간)'}")
|
||||||
|
|
||||||
|
print("\n[should_save 테스트]")
|
||||||
|
test_data = {'kw': 15.5, 'today': 120.0}
|
||||||
|
for site_id in test_sites:
|
||||||
|
result = manager.should_save(site_id, test_data)
|
||||||
|
print(f" {site_id}: {'✅ 저장' if result else '⏭️ 스킵 (변경 없음)'}")
|
||||||
|
|
||||||
|
print("\n=== 테스트 완료 ===")
|
||||||
110
crawler/crawler_structure.md
Normal file
110
crawler/crawler_structure.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Crawler 시스템 파일 구조 및 역할 정의
|
||||||
|
|
||||||
|
이 문서는 `crawler` 폴더 내의 각 파일과 모듈의 역할, 기능, 그리고 상호 작용 방식에 대해 자세히 설명합니다.
|
||||||
|
|
||||||
|
## 📁 디렉토리 구조 및 핵심 파일 요약
|
||||||
|
|
||||||
|
| 파일명 | 분류 | 핵심 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| **main.py** | Core | 크롤러 시스템의 메인 진입점. 전체 수집 프로세스 조율 |
|
||||||
|
| **config.py** | Config | 발전소 정보, 비밀번호, 시스템 상수 등 설정 관리 |
|
||||||
|
| **database.py** | Data | Supabase 데이터베이스 연결 및 CRUD 처리 |
|
||||||
|
| **crawler_manager.py** | Logic | 지능형 스케줄링 관리 (업데이트 패턴 학습 및 최적화) |
|
||||||
|
| **crawler_gui.py** | UI | 관리자용 대시보드 (윈도우 GUI), 모니터링 및 수동 제어 |
|
||||||
|
| **daily_summary.py** | Batch | 일일 발전 통계 집계 및 요약 테이블 저장 |
|
||||||
|
| **fetch_history.py** | Tool | 과거 데이터(Hourly, Daily) 수집 도구 |
|
||||||
|
| **sync_plants.py** | Tool | 발전소 메타 정보를 DB와 동기화 |
|
||||||
|
| **verify_data.py** | Test | 수집된 데이터의 무결성 검증 및 테스트 스크립트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 파일별 상세 역할 분석
|
||||||
|
|
||||||
|
### 1. 핵심 시스템 (Core System)
|
||||||
|
|
||||||
|
#### `main.py`
|
||||||
|
* **역할**: 전체 크롤링 시스템의 오케스트레이터(Orchestrator).
|
||||||
|
* **주요 기능**:
|
||||||
|
* `integrated_monitoring()` 함수를 통해 정의된 모든 발전소를 순회합니다.
|
||||||
|
* `CrawlerManager`를 통해 현재 시점에 실행해야 할 크롤러를 선별합니다.
|
||||||
|
* 각 발전소 타입에 맞는 크롤러 함수(`crawlers` 패키지)를 동적으로 호출합니다.
|
||||||
|
* 수집된 실시간 데이터를 콘솔에 출력하고, `database.py`를 통해 DB에 저장합니다.
|
||||||
|
* 발전량이 0인 경우 등 간단한 이상 감지 로직을 수행합니다.
|
||||||
|
* **실행 방식**: 스케줄러(Cron 등)에 의해 주기적으로 실행되거나, GUI에서 호출됩니다. `--force` 옵션으로 강제 실행 가능합니다.
|
||||||
|
|
||||||
|
#### `config.py`
|
||||||
|
* **역할**: 시스템 설정 및 발전소 정보의 단일 진실 공급원(Single Source of Truth).
|
||||||
|
* **주요 기능**:
|
||||||
|
* `SYSTEM_CONSTANTS`: 각 크롤러 시스템(NREMS, KREMC 등)의 URL 및 API 엔드포인트 정의.
|
||||||
|
* `COMPANIES`: 업체 및 산하 발전소들의 계층 구조, 인증 정보(ID/PW), 용량(Customer ID) 등을 JSON 구조로 관리.
|
||||||
|
* `get_all_plants()`: 계층화된 데이터를 크롤러가 사용하기 쉬운 평탄화(Flat)된 리스트로 변환하여 제공.
|
||||||
|
* **특이 사항**: 보안이 필요한 인증 정보가 포함되어 있어 관리에 주의가 필요합니다. 1, 2호기와 같이 하나의 계정으로 분리되는 발전소(`is_split`) 설정도 이곳에서 관리됩니다.
|
||||||
|
|
||||||
|
#### `crawler_manager.py` (Smart Scheduler)
|
||||||
|
* **역할**: 비효율적인 반복 호출을 줄이고 NAS 리소스를 절약하기 위한 미들웨어.
|
||||||
|
* **주요 기능**:
|
||||||
|
* **SQLite 기반 상태 관리**: `crawler_manager.db` 로컬 파일에 각 발전소의 상태 저장.
|
||||||
|
* **학습 모드(LEARNING)**: 초기에는 자주 실행하며 발전소 서버의 데이터 업데이트 주기 패턴을 학습.
|
||||||
|
* **최적화 모드(OPTIMIZED)**: 학습된 업데이트 시점(예: 매시 15분) 전후의 윈도우(Window)에만 크롤링을 허용.
|
||||||
|
* 야간(21시~05시) 크롤링 자동 차단 로직 포함.
|
||||||
|
|
||||||
|
### 2. 데이터 관리 (Data Management)
|
||||||
|
|
||||||
|
#### `database.py`
|
||||||
|
* **역할**: Supabase 클라우드 데이터베이스와의 인터페이스.
|
||||||
|
* **주요 기능**:
|
||||||
|
* Supabase 클라이언트 싱글턴 연결 관리.
|
||||||
|
* `save_to_supabase()`: 실시간 발전 데이터(`solar_logs`) 저장. 일일 통계(`daily_stats`) 단순 Upsert 처리.
|
||||||
|
* `save_history()`: 과거 내역 저장 시 사용되며, `solar_logs`(Hourly), `daily_stats`(Daily), `monthly_stats`(Monthly) 등 데이터 타입에 따라 적절한 테이블에 저장하고, 월별 통계 자동 갱신 트리거 로직을 포함합니다.
|
||||||
|
|
||||||
|
#### `daily_summary.py`
|
||||||
|
* **역할**: 수집된 로그 데이터를 기반으로 일일 최종 통계를 확정 짓는 배치 스크립트.
|
||||||
|
* **주요 기능**:
|
||||||
|
* 특정 날짜의 `solar_logs`를 모두 조회하여 발전소별 총 발전량, 피크 출력, 발전 시간(이용률)을 계산.
|
||||||
|
* 계산된 확정 데이터를 `daily_stats` 테이블에 저장.
|
||||||
|
* 주로 하루가 끝나는 시점이나 다음 날 새벽에 실행하여 데이터 정확도를 보정합니다.
|
||||||
|
|
||||||
|
### 3. 사용자 인터페이스 (User Interface)
|
||||||
|
|
||||||
|
#### `crawler_gui.py`
|
||||||
|
* **역할**: 윈도우 환경에서 크롤러 상태를 시각적으로 모니터링하고 제어하는 관리자 도구.
|
||||||
|
* **주요 기능**:
|
||||||
|
* `tkinter` 기반의 GUI 제공.
|
||||||
|
* 발전소별 현재 상태(대기, 실행중, 최적화 여부), 마지막 실행 시간 등을 트리 뷰(Tree View)로 표시.
|
||||||
|
* 개별/전체 크롤링 강제 실행, 히스토리 수집 명령, 학습 모드 리셋 등의 제어 기능 제공.
|
||||||
|
* 실시간 로그 창을 통해 백그라운드 프로세스(`subprocess`)의 실행 결과를 출력.
|
||||||
|
|
||||||
|
### 4. 도구 및 유틸리티 (Tools & Utilities)
|
||||||
|
|
||||||
|
#### `fetch_history.py`
|
||||||
|
* **역할**: 누락된 데이터나 초기 구축 시 과거 데이터를 수집하기 위한 스크립트.
|
||||||
|
* **주요 기능**:
|
||||||
|
* 특정 발전소 ID를 인자로 받아 과거 데이터를 조회.
|
||||||
|
* 각 크롤러 모듈(`crawlers/`)에 구현된 `fetch_history_hourly`, `fetch_history_daily` 등을 호출.
|
||||||
|
* 시간별(Hourly), 일별(Daily) 데이터를 수집하여 DB에 적재.
|
||||||
|
|
||||||
|
#### `sync_plants.py`
|
||||||
|
* **역할**: 로컬 코드(`config.py`)와 원격 DB(`plants` 테이블) 간의 메타 데이터 동기화.
|
||||||
|
* **주요 기능**:
|
||||||
|
* 새로운 발전소가 추가되거나 이름/용량이 변경되었을 때, `config.py`의 내용을 DB의 마스터 테이블에 반영(Upsert).
|
||||||
|
* NREMS 1, 2호기와 같이 논리적으로 분리해야 하는 발전소를 별도 레코드로 DB에 생성.
|
||||||
|
|
||||||
|
#### `verify_data.py`
|
||||||
|
* **역할**: 크롤링 로직 검증 및 데이터 무결성 테스트.
|
||||||
|
* **주요 기능**:
|
||||||
|
* 각 발전소별로 샘플 날짜(과거/현재)를 지정하여 실제 데이터를 가져와 봅니다.
|
||||||
|
* 시간별, 일별, 월별 합계가 논리적으로 맞는지 검증 포맷을 출력하여 개발자가 확인하기 쉽게 돕습니다.
|
||||||
|
|
||||||
|
### 5. 하위 폴더
|
||||||
|
|
||||||
|
#### `crawlers/` (폴더)
|
||||||
|
* **역할**: 실제 사이트별 크롤링 로직이 구현된 모듈들의 집합.
|
||||||
|
* **구성**:
|
||||||
|
* `nrems.py`, `kremc.py`, `hyundai.py`, `sun_wms.py`, `cmsolar.py` 등 사이트 타입별로 파일이 존재.
|
||||||
|
* 각 모듈은 공통적으로 `get_current_status()` (실시간), `fetch_history_*` (과거 내역) 등의 인터페이스를 구현해야 함.
|
||||||
|
|
||||||
|
#### `venv/`, `temp_env/` (폴더)
|
||||||
|
* **역할**: Python 가상 환경 폴더. 프로젝트 실행에 필요한 라이브러리(`requests`, `pandas`, `supabase` 등)가 설치됨.
|
||||||
|
|
||||||
|
---
|
||||||
|
*작성일: 2026-01-28*
|
||||||
20
crawler/crawlers/__init__.py
Normal file
20
crawler/crawlers/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# crawlers 패키지 초기화
|
||||||
|
|
||||||
|
from .nrems import fetch_data as fetch_nrems
|
||||||
|
from .kremc import fetch_data as fetch_kremc
|
||||||
|
from .sun_wms import fetch_data as fetch_sunwms
|
||||||
|
from .hyundai import fetch_data as fetch_hyundai
|
||||||
|
from .cmsolar import fetch_data as fetch_cmsolar
|
||||||
|
|
||||||
|
# 크롤러 타입별 매핑
|
||||||
|
CRAWLER_MAP = {
|
||||||
|
'nrems': fetch_nrems,
|
||||||
|
'kremc': fetch_kremc,
|
||||||
|
'sun_wms': fetch_sunwms,
|
||||||
|
'hyundai': fetch_hyundai,
|
||||||
|
'cmsolar': fetch_cmsolar
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_crawler(crawler_type):
|
||||||
|
"""크롤러 타입에 해당하는 fetch 함수 반환"""
|
||||||
|
return CRAWLER_MAP.get(crawler_type)
|
||||||
115
crawler/crawlers/base.py
Normal file
115
crawler/crawlers/base.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/base.py - 크롤러 공통 유틸리티
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def safe_float(value):
|
||||||
|
"""
|
||||||
|
안전한 float 변환
|
||||||
|
None, 빈 문자열, 콤마 포함 숫자 등을 처리
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
return float(str(value).replace(',', ''))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def create_session():
|
||||||
|
"""기본 설정된 requests 세션 생성"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 상위 경로의 config.py 불러오기 처리
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
try:
|
||||||
|
from config import USE_PROXY, PROXIES
|
||||||
|
except ImportError:
|
||||||
|
USE_PROXY = False
|
||||||
|
PROXIES = None
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
if USE_PROXY and PROXIES:
|
||||||
|
session.proxies.update(PROXIES)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def get_default_headers():
|
||||||
|
"""기본 HTTP 헤더 반환"""
|
||||||
|
return {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json, text/plain, */*'
|
||||||
|
}
|
||||||
|
|
||||||
|
def determine_status(current_kw):
|
||||||
|
"""발전량 기반 상태 결정"""
|
||||||
|
if current_kw > 0:
|
||||||
|
return "🟢 정상"
|
||||||
|
else:
|
||||||
|
return "💤 대기"
|
||||||
|
|
||||||
|
def format_result(name, kw, today, plant_id, status=None):
|
||||||
|
"""결과 딕셔너리 포맷 통일"""
|
||||||
|
if status is None:
|
||||||
|
status = determine_status(kw)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'kw': kw,
|
||||||
|
'today': today,
|
||||||
|
'id': plant_id,
|
||||||
|
'status': status
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_data_quality(data_list, value_key='generation_kwh'):
|
||||||
|
"""
|
||||||
|
데이터 품질 검증
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'is_valid': bool,
|
||||||
|
'warnings': list,
|
||||||
|
'all_zero': bool,
|
||||||
|
'duplicate_ratio': float
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not data_list or len(data_list) == 0:
|
||||||
|
return {
|
||||||
|
'is_valid': False,
|
||||||
|
'warnings': ['데이터 없음'],
|
||||||
|
'all_zero': True,
|
||||||
|
'duplicate_ratio': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
values = [safe_float(item.get(value_key, 0)) for item in data_list]
|
||||||
|
|
||||||
|
# 모두 0인 경우 체크
|
||||||
|
all_zero = all(v == 0 for v in values)
|
||||||
|
if all_zero:
|
||||||
|
warnings.append('모든 값이 0 - 실제 데이터가 아닐 가능성')
|
||||||
|
|
||||||
|
# 연속 중복 체크
|
||||||
|
if len(values) > 1:
|
||||||
|
duplicates = 0
|
||||||
|
for i in range(len(values) - 1):
|
||||||
|
if values[i] == values[i+1]:
|
||||||
|
duplicates += 1
|
||||||
|
|
||||||
|
duplicate_ratio = duplicates / (len(values) - 1)
|
||||||
|
|
||||||
|
if duplicate_ratio > 0.8:
|
||||||
|
warnings.append(f'연속 중복 비율 {duplicate_ratio*100:.1f}% - 실제 데이터가 아닐 가능성')
|
||||||
|
else:
|
||||||
|
duplicate_ratio = 0.0
|
||||||
|
|
||||||
|
is_valid = not all_zero and duplicate_ratio < 0.8
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': is_valid,
|
||||||
|
'warnings': warnings,
|
||||||
|
'all_zero': all_zero,
|
||||||
|
'duplicate_ratio': duplicate_ratio
|
||||||
|
}
|
||||||
512
crawler/crawlers/cmsolar.py
Normal file
512
crawler/crawlers/cmsolar.py
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
|
||||||
|
# HTML 테이블 파싱 방식
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from .base import create_session, safe_float
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '함안햇빛발전소')
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Site selection (Required for idx_ok.php)
|
||||||
|
base_url = system.get('base_url', 'http://www.cmsolar2.kr')
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 데이터 요청 (JSON Endpoint)
|
||||||
|
target_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(target_url, headers=headers)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# Handle potential encoding issues if needed, though requests usually guesses well
|
||||||
|
if res.encoding is None:
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# Parsing logic for [{"plant": {...}}] structure
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
plant_data = data[0].get('plant', {})
|
||||||
|
|
||||||
|
# Unit Conversion: W -> kW
|
||||||
|
curr_kw = safe_float(plant_data.get('now', 0)) / 1000.0
|
||||||
|
today_kwh = safe_float(plant_data.get('today', 0)) / 1000.0
|
||||||
|
|
||||||
|
# Status check
|
||||||
|
is_error = int(plant_data.get('inv_error', 0))
|
||||||
|
status = "🟢 정상" if is_error == 0 else "🔴 점검/고장"
|
||||||
|
|
||||||
|
# 0kW during day is suspicious but night is normal.
|
||||||
|
# If needed, override status based on time, but sticking to error flag is safer.
|
||||||
|
if curr_kw == 0 and status == "🟢 정상":
|
||||||
|
# Optional: Check if night time?
|
||||||
|
pass
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
print(f"❌ {plant_name} 데이터 형식 오류: {data}")
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
|
||||||
|
파라미터: mode=getPowers&type=daily&device=total&start=YYYY-MM-DD&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 실제 확인된 시간별 엔드포인트 (type=daily는 하루 치 시간별 데이터 반환)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'daily',
|
||||||
|
'device': 'total',
|
||||||
|
'start': date_str,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# HTML 테이블 파싱
|
||||||
|
html = res.text
|
||||||
|
|
||||||
|
# <tbody> 안의 <tr> 태그 찾기
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
|
||||||
|
# 각 <tr> 파싱 (시간과 발전량)
|
||||||
|
# <tr class="odd"><td>9</td><td>3.0</td>...
|
||||||
|
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.]+)</td>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
print(f" ✓ Found {len(matches)} hourly records for {date_str}")
|
||||||
|
|
||||||
|
for hour, kwh in matches:
|
||||||
|
generation_kwh = safe_float(kwh)
|
||||||
|
timestamp = f"{date_str} {hour.zfill(2)}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No tbody found for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error for {date_str}: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 일별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
|
||||||
|
파라미터: mode=getPowers&type=month&device=total&start=YYYY-MM-01&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 월 단위로 반복 (type=month는 한 달 치 일별 데이터 반환)
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
month_start = current_date.strftime('%Y-%m-01')
|
||||||
|
year = current_date.year
|
||||||
|
month = current_date.month
|
||||||
|
|
||||||
|
# 실제 확인된 일별 엔드포인트 (type=month)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'month',
|
||||||
|
'device': 'total',
|
||||||
|
'start': month_start,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# HTML 테이블 파싱
|
||||||
|
html = res.text
|
||||||
|
|
||||||
|
# <tbody> 안의 <tr> 태그 찾기
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
|
||||||
|
# 각 <tr> 파싱 (날짜와 발전량)
|
||||||
|
# <tr class="odd"><td>1</td><td>136.00</td>...
|
||||||
|
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.,]+)</td>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
print(f" ✓ Found {len(matches)} daily records for {month_start[:7]}")
|
||||||
|
|
||||||
|
for day, kwh in matches:
|
||||||
|
# 쉼표 제거
|
||||||
|
kwh_clean = kwh.replace(',', '')
|
||||||
|
generation_kwh = safe_float(kwh_clean)
|
||||||
|
|
||||||
|
date_str = f"{year:04d}-{month:02d}-{int(day):02d}"
|
||||||
|
|
||||||
|
# 날짜 범위 필터링
|
||||||
|
if date_str >= start_date and date_str <= end_date:
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {generation_kwh:.2f}kWh")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No tbody found for {month_start[:7]}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code} for {month_start[:7]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error for {month_start[:7]}: {e}")
|
||||||
|
|
||||||
|
# 다음 달로 이동
|
||||||
|
current_date = (current_date.replace(day=1) + relativedelta(months=1))
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
|
||||||
|
파라미터: mode=getPowers&type=year&device=total&start=YYYY-01-01&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
# 시작일자 체크
|
||||||
|
plant_start_date = plant_info.get('start_date', '2020-08-31')
|
||||||
|
plant_start_month = plant_start_date[:7] # YYYY-MM
|
||||||
|
|
||||||
|
# 실제 시작 월은 발전소 가동일 이후로 제한
|
||||||
|
if start_month < plant_start_month:
|
||||||
|
actual_start = plant_start_month
|
||||||
|
print(f" ℹ 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
|
||||||
|
else:
|
||||||
|
actual_start = start_month
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Monthly] {plant_name} ({actual_start} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 연도별로 반복 (type=year는 한 해 치 월별 데이터 반환)
|
||||||
|
current_month = datetime.strptime(actual_start, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
processed_years = set()
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
year = current_month.year
|
||||||
|
|
||||||
|
# 이미 처리한 연도는 스킵
|
||||||
|
if year in processed_years:
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_years.add(year)
|
||||||
|
year_start = f"{year}-01-01"
|
||||||
|
|
||||||
|
# 실제 확인된 월별 엔드포인트 (type=year)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'year',
|
||||||
|
'device': 'total',
|
||||||
|
'start': year_start,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# HTML 테이블 파싱
|
||||||
|
html = res.text
|
||||||
|
|
||||||
|
# <tbody> 안의 <tr> 태그 찾기
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
|
||||||
|
# 각 <tr> 파싱 (월과 발전량)
|
||||||
|
# <tr class="even"><td>1</td><td>2,836.00</td>...
|
||||||
|
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.,]+)</td>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
year_count = 0
|
||||||
|
for month, kwh in matches:
|
||||||
|
# 쉼표 제거
|
||||||
|
kwh_clean = kwh.replace(',', '')
|
||||||
|
generation_kwh = safe_float(kwh_clean)
|
||||||
|
|
||||||
|
month_str = f"{year:04d}-{int(month):02d}"
|
||||||
|
|
||||||
|
# 월 범위 필터링
|
||||||
|
if month_str >= actual_start and month_str <= end_month:
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': generation_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {generation_kwh:.1f}kWh")
|
||||||
|
year_count += 1
|
||||||
|
|
||||||
|
if year_count > 0:
|
||||||
|
print(f" → Collected {year_count} months from {year}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No tbody found for year {year}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code} for year {year}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error for year {year}: {e}")
|
||||||
|
|
||||||
|
# 다음 연도로 이동
|
||||||
|
current_month = current_month.replace(year=year+1, month=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
319
crawler/crawlers/cmsolar_old.py
Normal file
319
crawler/crawlers/cmsolar_old.py
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from .base import create_session
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/143.0.0.0 Safari/537.36',
|
||||||
|
'Referer': f'{base_url}/plant/index.php'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
try:
|
||||||
|
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
|
||||||
|
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 사이트 선택
|
||||||
|
try:
|
||||||
|
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 3. 데이터 요청
|
||||||
|
target_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(target_url, headers=headers)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
plant_data = data[0]['plant']
|
||||||
|
|
||||||
|
# 단위 변환 (W -> kW, Wh -> kWh)
|
||||||
|
curr_kw = float(plant_data.get('now', 0)) / 1000
|
||||||
|
today_kwh = float(plant_data.get('today', 0)) / 1000
|
||||||
|
|
||||||
|
is_error = int(plant_data.get('inv_error', 0))
|
||||||
|
status = "🟢 정상" if is_error == 0 else "🔴 점검/고장"
|
||||||
|
|
||||||
|
print(f" [CMSolar] {plant_name} 수집 완료: {round(curr_kw, 2)} kW")
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': round(curr_kw, 2),
|
||||||
|
'today': round(today_kwh, 2),
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 일별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Referer': f'{base_url}/plant/index.php'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
|
||||||
|
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
|
||||||
|
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 일별 데이터 엔드포인트 (추정)
|
||||||
|
daily_url = f"{base_url}/plant/sub/daily_data.php?date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(daily_url, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
daily_kwh = safe_float(data.get('today', data.get('daily', 0))) / 1000.0
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': daily_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {daily_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {date_str}: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 월별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from .base import safe_float
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Referer': f'{base_url}/plant/index.php'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
|
||||||
|
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
|
||||||
|
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_month = datetime.strptime(start_month, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# 월별 데이터 엔드포인트 (추정)
|
||||||
|
monthly_url = f"{base_url}/plant/sub/monthly_data.php?month={month_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(monthly_url, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
monthly_kwh = safe_float(data.get('month', data.get('monthly', 0))) / 1000.0
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {monthly_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {month_str}: {e}")
|
||||||
|
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: dict, 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 시간대별 데이터 레코드
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/143.0.0.0 Safari/537.36',
|
||||||
|
'Referer': f'{base_url}/plant/index.php'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
|
||||||
|
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
|
||||||
|
|
||||||
|
# 사이트 선택
|
||||||
|
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
|
||||||
|
print(f" ✓ Login successful")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 범위 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
# 시간대별 데이터 엔드포인트 (추정)
|
||||||
|
hourly_url = f"{base_url}/plant/sub/hourly_data.php?site={site_no}&date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(hourly_url, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'utf-8'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
|
||||||
|
|
||||||
|
if hourly_data and len(hourly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_data)} hourly records")
|
||||||
|
|
||||||
|
for item in hourly_data:
|
||||||
|
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
|
||||||
|
generation_wh = safe_float(item.get('energy', item.get('now', 0)))
|
||||||
|
generation_kwh = generation_wh / 1000.0 if generation_wh > 1000 else generation_wh
|
||||||
|
current_kw = safe_float(item.get('power', 0)) / 1000.0
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No hourly data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 날짜로
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
427
crawler/crawlers/cmsolar_old2.py
Normal file
427
crawler/crawlers/cmsolar_old2.py
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from .base import create_session, safe_float
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '함안햇빛발전소')
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('payload_id', '')
|
||||||
|
login_pw = auth.get('payload_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 데이터 요청
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, headers=headers)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
curr_kw = safe_float(data.get('current', data.get('power', 0)))
|
||||||
|
today_kwh = safe_float(data.get('today', data.get('generation', 0)))
|
||||||
|
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php
|
||||||
|
파라미터: mode=getPowers&type=daily&device=total&start=YYYY-MM-DD&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('payload_id', '')
|
||||||
|
login_pw = auth.get('payload_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 실제 확인된 시간별 엔드포인트 (type=daily는 하루 치 시간별 데이터 반환)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'daily',
|
||||||
|
'device': 'total',
|
||||||
|
'start': date_str,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 시간별 데이터 파싱
|
||||||
|
hourly_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
|
||||||
|
|
||||||
|
if isinstance(hourly_data, list) and len(hourly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_data)} hourly records for {date_str}")
|
||||||
|
|
||||||
|
for item in hourly_data:
|
||||||
|
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
|
||||||
|
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
|
||||||
|
current_kw = safe_float(item.get('kw', 0))
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code} for {date_str}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 일별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php
|
||||||
|
파라미터: mode=getPowers&type=month&device=total&start=YYYY-MM-DD&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('payload_id', '')
|
||||||
|
login_pw = auth.get('payload_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 월 단위로 반복 (type=month는 한 달 치 일별 데이터 반환)
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
month_start = current_date.strftime('%Y-%m-01')
|
||||||
|
|
||||||
|
# 실제 확인된 일별 엔드포인트 (type=month)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'month',
|
||||||
|
'device': 'total',
|
||||||
|
'start': month_start,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 일별 데이터 파싱
|
||||||
|
daily_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
|
||||||
|
|
||||||
|
if isinstance(daily_data, list) and len(daily_data) > 0:
|
||||||
|
print(f" ✓ Found {len(daily_data)} daily records for {month_start[:7]}")
|
||||||
|
|
||||||
|
for item in daily_data:
|
||||||
|
date_str = item.get('date', item.get('day', ''))
|
||||||
|
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
|
||||||
|
current_kw = safe_float(item.get('kw', 0))
|
||||||
|
|
||||||
|
# 날짜 범위 필터링
|
||||||
|
if date_str >= start_date and date_str <= end_date:
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {generation_kwh:.2f}kWh")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code} for {month_start[:7]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 달로 이동
|
||||||
|
current_date = (current_date.replace(day=1) + relativedelta(months=1))
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
CMSolar 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /plant/sub/report_ok.php
|
||||||
|
파라미터: mode=getPowers&type=year&device=total&start=YYYY-MM-DD&money=
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'cmsolar-10')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '10호기')
|
||||||
|
|
||||||
|
login_id = auth.get('payload_id', '')
|
||||||
|
login_pw = auth.get('payload_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# 실제 데이터 엔드포인트
|
||||||
|
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
|
||||||
|
data_url = f"{base_url}/plant/sub/report_ok.php"
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[CMSolar Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
# 사이트 선택 (필수!)
|
||||||
|
try:
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
print(" ✓ Site selected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Site selection error: {e}")
|
||||||
|
return results
|
||||||
|
# 연도별로 반복 (type=year는 한 해 치 월별 데이터 반환)
|
||||||
|
current_date = datetime.strptime(start_month + '-01', '%Y-%m-%d')
|
||||||
|
end_date = datetime.strptime(end_month + '-01', '%Y-%m-%d')
|
||||||
|
|
||||||
|
years_processed = set()
|
||||||
|
|
||||||
|
while current_date <= end_date:
|
||||||
|
year_start = current_date.strftime('%Y-01-01')
|
||||||
|
year = current_date.year
|
||||||
|
|
||||||
|
# 중복 연도 스킵
|
||||||
|
if year in years_processed:
|
||||||
|
current_date += relativedelta(months=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
years_processed.add(year)
|
||||||
|
|
||||||
|
# 실제 확인된 월별 엔드포인트 (type=year)
|
||||||
|
params = {
|
||||||
|
'mode': 'getPowers',
|
||||||
|
'type': 'year',
|
||||||
|
'device': 'total',
|
||||||
|
'start': year_start,
|
||||||
|
'money': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(data_url, params=params, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 월별 데이터 파싱
|
||||||
|
monthly_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
|
||||||
|
|
||||||
|
if isinstance(monthly_data, list) and len(monthly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(monthly_data)} monthly records for {year}")
|
||||||
|
|
||||||
|
for item in monthly_data:
|
||||||
|
month_str = item.get('month', item.get('date', ''))
|
||||||
|
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
|
||||||
|
|
||||||
|
# YYYY-MM 형식으로 정규화
|
||||||
|
if len(month_str) >= 7:
|
||||||
|
month_str = month_str[:7]
|
||||||
|
|
||||||
|
# 월 범위 필터링
|
||||||
|
if month_str >= start_month and month_str <= end_month:
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': generation_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {generation_kwh:.1f}kWh")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code} for {year}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
current_date += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
489
crawler/crawlers/hyundai.py
Normal file
489
crawler/crawlers/hyundai.py
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/hyundai.py - 현대 크롤러 (8호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from .base import create_session
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
현대 발전소 데이터 수집 (Hi-Smart 3.0)
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'hyundai-08')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '8호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
site_id = auth.get('site_id', '')
|
||||||
|
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
login_path = system.get('login_path', '')
|
||||||
|
data_path = system.get('data_path', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Origin': base_url,
|
||||||
|
'Referer': f'{base_url}/',
|
||||||
|
'X-ApiVersion': 'v1.0',
|
||||||
|
'X-App': 'HIWAY4VUETIFY',
|
||||||
|
'X-CallType': '0',
|
||||||
|
'X-Channel': 'WEB_PC',
|
||||||
|
'X-Lang': 'ko',
|
||||||
|
'X-Mid': 'login',
|
||||||
|
'X-VName': 'UI'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
login_urls = [
|
||||||
|
f"{base_url}{login_path}",
|
||||||
|
f"{base_url}{login_path}.json",
|
||||||
|
f"{base_url}{login_path}.do"
|
||||||
|
]
|
||||||
|
|
||||||
|
login_success = False
|
||||||
|
|
||||||
|
for url in login_urls:
|
||||||
|
try:
|
||||||
|
payload = {"user_id": user_id, "password": password}
|
||||||
|
res = session.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
auth_token = res.headers.get('x-auth-token')
|
||||||
|
if auth_token:
|
||||||
|
headers['x-auth-token'] = auth_token
|
||||||
|
print(f" [현대] 로그인 성공 & 토큰 확보!")
|
||||||
|
login_success = True
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not login_success:
|
||||||
|
print(f"❌ 현대 {plant_name} 로그인 실패")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 데이터 요청
|
||||||
|
try:
|
||||||
|
data_url = f"{base_url}{data_path}"
|
||||||
|
params = {'site_id': site_id}
|
||||||
|
|
||||||
|
# 데이터 요청용 헤더 업데이트
|
||||||
|
headers['X-Channel'] = 'WEB_PCWeb'
|
||||||
|
headers['X-Mid'] = 'siteWork'
|
||||||
|
|
||||||
|
res = session.get(data_url, params=params, headers=headers)
|
||||||
|
|
||||||
|
if res.status_code != 200:
|
||||||
|
print(f"❌ 현대 데이터 요청 실패 (코드: {res.status_code})")
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
if 'datas' in data and 'unitedSiteInfo' in data['datas']:
|
||||||
|
info = data['datas']['unitedSiteInfo']
|
||||||
|
|
||||||
|
curr_kw = float(info.get('PVPCS_Pac', '0').replace(',', ''))
|
||||||
|
today_kwh = float(info.get('PVPCS_Daily_P', '0').replace(',', ''))
|
||||||
|
|
||||||
|
print(f" [현대] {plant_name} 데이터: {curr_kw}kW / {today_kwh}kWh")
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 현대 데이터 구조가 다릅니다.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 현대 파싱 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
현대 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: {
|
||||||
|
'id': 'hyundai-08',
|
||||||
|
'name': '8호기',
|
||||||
|
'type': 'hyundai',
|
||||||
|
'auth': {'user_id': '...', 'password': '...', 'site_id': '...'},
|
||||||
|
'system': {'base_url': '...', 'login_path': '...', 'data_path': '...'},
|
||||||
|
'company_name': '태양과바람'
|
||||||
|
}
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{
|
||||||
|
'plant_id': 'hyundai-08',
|
||||||
|
'timestamp': '2026-01-15 14:00:00',
|
||||||
|
'generation_kwh': 123.5,
|
||||||
|
'current_kw': 15.2
|
||||||
|
}, ...]
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'hyundai-08')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '8호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
site_id = auth.get('site_id', '')
|
||||||
|
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
login_path = system.get('login_path', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Hyundai History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Origin': base_url,
|
||||||
|
'Referer': f'{base_url}/',
|
||||||
|
'X-ApiVersion': 'v1.0',
|
||||||
|
'X-App': 'HIWAY4VUETIFY',
|
||||||
|
'X-CallType': '0',
|
||||||
|
'X-Channel': 'WEB_PC',
|
||||||
|
'X-Lang': 'ko',
|
||||||
|
'X-Mid': 'login',
|
||||||
|
'X-VName': 'UI'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_urls = [
|
||||||
|
f"{base_url}{login_path}",
|
||||||
|
f"{base_url}{login_path}.json",
|
||||||
|
f"{base_url}{login_path}.do"
|
||||||
|
]
|
||||||
|
|
||||||
|
login_success = False
|
||||||
|
for url in login_urls:
|
||||||
|
try:
|
||||||
|
payload = {"user_id": user_id, "password": password}
|
||||||
|
res = session.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
auth_token = res.headers.get('x-auth-token')
|
||||||
|
if auth_token:
|
||||||
|
headers['x-auth-token'] = auth_token
|
||||||
|
print(f" ✓ Login successful")
|
||||||
|
login_success = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not login_success:
|
||||||
|
print(f" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 범위 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
headers['X-Mid'] = 'siteWork'
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
# getSolraDayWork 엔드포인트 사용 (20분 간격 데이터)
|
||||||
|
url = f"{base_url}/hismart/site/getSolraDayWork"
|
||||||
|
params = {
|
||||||
|
'site_id': site_id,
|
||||||
|
'startDate': date_str # YYYY-MM-DD 형식
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(url, params=params, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# solraDayWork 구조 파싱
|
||||||
|
day_work = data.get('datas', {}).get('solraDayWork', {})
|
||||||
|
run_data = day_work.get('runData', [])
|
||||||
|
run_time = day_work.get('runTime', [])
|
||||||
|
|
||||||
|
if run_data and run_time and len(run_data) == len(run_time):
|
||||||
|
print(f" ✓ Found {len(run_data)} records (20-min intervals)")
|
||||||
|
|
||||||
|
# runData와 runTime을 조합하여 시간대별 데이터 생성
|
||||||
|
for i in range(len(run_data)):
|
||||||
|
time_str = run_time[i] # "14:20" 형식
|
||||||
|
generation_kw = safe_float(run_data[i]) # kW 값
|
||||||
|
|
||||||
|
# timestamp 생성
|
||||||
|
timestamp = f"{date_str} {time_str}:00"
|
||||||
|
|
||||||
|
# 20분 간격 데이터를 그대로 저장 (또는 시간 단위로 집계 가능)
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kw, # 실제로는 순간 kW값
|
||||||
|
'current_kw': generation_kw
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" → Collected {len(run_data)} records")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 날짜로
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
현대 발전소의 일별 과거 데이터 수집 (월 단위 최적화)
|
||||||
|
getSolraMonthWork API를 사용하여 한 달치 일별 데이터를 한 번에 가져옴
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'hyundai-08')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '8호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
site_id = auth.get('site_id', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
login_path = system.get('login_path', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Hyundai Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
'X-ApiVersion': 'v1.0',
|
||||||
|
'X-App': 'HIWAY4VUETIFY',
|
||||||
|
'X-Channel': 'WEB_PC',
|
||||||
|
'X-Lang': 'ko',
|
||||||
|
'X-Mid': 'login',
|
||||||
|
'X-VName': 'UI'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_url = f"{base_url}{login_path}"
|
||||||
|
payload = {"user_id": user_id, "password": password}
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, json=payload, headers=headers)
|
||||||
|
auth_token = res.headers.get('x-auth-token')
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
headers['x-auth-token'] = auth_token
|
||||||
|
headers['X-Mid'] = 'siteWork'
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 월 단위 반복
|
||||||
|
current_month = datetime.strptime(start_date[:7], '%Y-%m') # YYYY-MM-01
|
||||||
|
end_month_dt = datetime.strptime(end_date[:7], '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
year = current_month.year
|
||||||
|
month = current_month.month
|
||||||
|
|
||||||
|
print(f" [Fetching] {month_str} ...", end="", flush=True)
|
||||||
|
|
||||||
|
url = f"{base_url}/hismart/site/getSolraMonthWork"
|
||||||
|
params = {'site_id': site_id, 'month': month_str}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(url, params=params, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
day_work = data.get('datas', {}).get('solraMonthWork', {})
|
||||||
|
run_data = day_work.get('runData', [])
|
||||||
|
|
||||||
|
if run_data:
|
||||||
|
count = 0
|
||||||
|
for day_idx, val in enumerate(run_data):
|
||||||
|
day = day_idx + 1
|
||||||
|
daily_total = safe_float(val)
|
||||||
|
|
||||||
|
# 유효한 날짜인지 확인 (예: 2월 30일 방지)
|
||||||
|
try:
|
||||||
|
# 해당 월의 마지막 날짜 확인
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
if day > last_day:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = f"{year}-{month:02d}-{day:02d}"
|
||||||
|
|
||||||
|
# 요청된 날짜 범위 내인지 확인
|
||||||
|
if date_str >= start_date and date_str <= end_date:
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': round(daily_total, 2)
|
||||||
|
})
|
||||||
|
count += 1
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" OK ({count} days)")
|
||||||
|
else:
|
||||||
|
print(f" No data")
|
||||||
|
else:
|
||||||
|
print(f" HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}")
|
||||||
|
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"\n[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
현대 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: 발전소 정보
|
||||||
|
start_month: str, 시작월 (YYYY-MM)
|
||||||
|
end_month: str, 종료월 (YYYY-MM)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{'plant_id': '...', 'month': '2026-01', 'generation_kwh': 12345.6}, ...]
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from .base import safe_float
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'hyundai-08')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '8호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
site_id = auth.get('site_id', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
login_path = system.get('login_path', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Hyundai Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
'X-ApiVersion': 'v1.0',
|
||||||
|
'X-App': 'HIWAY4VUETIFY',
|
||||||
|
'X-Channel': 'WEB_PC',
|
||||||
|
'X-Lang': 'ko',
|
||||||
|
'X-Mid': 'login',
|
||||||
|
'X-VName': 'UI'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_url = f"{base_url}{login_path}"
|
||||||
|
payload = {"user_id": user_id, "password": password}
|
||||||
|
res = session.post(login_url, json=payload, headers=headers)
|
||||||
|
auth_token = res.headers.get('x-auth-token')
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
headers['x-auth-token'] = auth_token
|
||||||
|
headers['X-Mid'] = 'siteWork'
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
|
||||||
|
current_month = datetime.strptime(start_month, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 실제 확인된 월별 엔드포인트: getSolraMonthWork
|
||||||
|
url = f"{base_url}/hismart/site/getSolraMonthWork"
|
||||||
|
params = {
|
||||||
|
'site_id': site_id,
|
||||||
|
'month': month_str # YYYY-MM 형식
|
||||||
|
}
|
||||||
|
|
||||||
|
res = session.get(url, params=params, headers=headers, verify=False, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# 응답 구조: datas.solraMonthWork.runData = 일별 발전량 배열
|
||||||
|
if 'datas' in data and 'solraMonthWork' in data['datas']:
|
||||||
|
month_data = data['datas']['solraMonthWork']
|
||||||
|
run_data = month_data.get('runData', [])
|
||||||
|
|
||||||
|
# runData는 해당 월의 일별 발전량 배열 → 합산
|
||||||
|
monthly_kwh = sum(run_data) if run_data else 0.0
|
||||||
|
|
||||||
|
print(f" ✓ {month_str}: {monthly_kwh:.1f}kWh (from {len(run_data)} days)")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_kwh
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {month_str}: {e}")
|
||||||
|
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
559
crawler/crawlers/kremc.py
Normal file
559
crawler/crawlers/kremc.py
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/kremc.py - KREMC 크롤러 (5호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib.parse
|
||||||
|
from .base import safe_float, create_session
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
KREMC 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'kremc-05')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '5호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
api_base = system.get('api_base', '')
|
||||||
|
enso_type = system.get('enso_type', '15001')
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = create_session()
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Origin': 'https://kremc.kr',
|
||||||
|
'Referer': 'https://kremc.kr/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
login_data = {'userId': user_id, 'password': password}
|
||||||
|
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if login_res.status_code != 200:
|
||||||
|
print(f" ⚠️ KREMC 로그인 실패: {login_res.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_json = login_res.json()
|
||||||
|
|
||||||
|
if login_json.get('status') == 200 or login_json.get('code') == 'S001':
|
||||||
|
data = login_json.get('data')
|
||||||
|
|
||||||
|
if isinstance(data, str) and len(data) > 10:
|
||||||
|
token = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
token = data.get('token') or data.get('accessToken') or data.get('jwt')
|
||||||
|
if not token:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ KREMC 로그인 실패: {login_json.get('message', 'Unknown')}")
|
||||||
|
return []
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f" [KREMC] 토큰 획득 성공")
|
||||||
|
|
||||||
|
# 2. API 헤더 설정
|
||||||
|
api_headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Auth-Token': token
|
||||||
|
}
|
||||||
|
|
||||||
|
installer_id_encoded = urllib.parse.quote(user_id)
|
||||||
|
|
||||||
|
# 3. 실시간 발전량 (kW)
|
||||||
|
latest_url = f"{api_base}/monitor/installer/gath/latest?installerId={installer_id_encoded}&ensoTypeCode={enso_type}"
|
||||||
|
latest_res = session.get(latest_url, headers=api_headers, timeout=10)
|
||||||
|
|
||||||
|
current_kw = 0.0
|
||||||
|
if latest_res.status_code == 200:
|
||||||
|
try:
|
||||||
|
latest_data = latest_res.json()
|
||||||
|
data = latest_data.get('data', {})
|
||||||
|
if isinstance(data, dict):
|
||||||
|
watts = safe_float(data.get('outpElcpFigr', 0))
|
||||||
|
current_kw = watts / 1000.0 if watts > 0 else 0.0
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. 일일 발전량 (kWh)
|
||||||
|
energy_url = f"{api_base}/monitor/installer/gath/energy?installerId={installer_id_encoded}&ensoTypeCode={enso_type}&cid="
|
||||||
|
energy_res = session.get(energy_url, headers=api_headers, timeout=10)
|
||||||
|
|
||||||
|
today_kwh = 0.0
|
||||||
|
if energy_res.status_code == 200:
|
||||||
|
try:
|
||||||
|
energy_data = energy_res.json()
|
||||||
|
data = energy_data.get('data', {})
|
||||||
|
if isinstance(data, dict):
|
||||||
|
today_kwh = safe_float(data.get('dayEnergy', 0))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f" [KREMC] {plant_name} 데이터: {current_kw} kW / {today_kwh} kWh")
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': current_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': '🟢 정상' if current_kw > 0 else '💤 대기'
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ KREMC 오류: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
KREMC 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: dict, 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 시간대별 데이터 레코드
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'kremc-05')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
options = plant_info.get('options', {})
|
||||||
|
plant_name = plant_info.get('name', '5호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
api_base = system.get('api_base', '')
|
||||||
|
enso_type = system.get('enso_type', '15001')
|
||||||
|
|
||||||
|
# KREMC 추가 파라미터
|
||||||
|
cid = options.get('cid', '10013000376')
|
||||||
|
city_prov_code = options.get('cityProvCode', '11')
|
||||||
|
rgn_code = options.get('rgnCode', '11410')
|
||||||
|
dong_code = options.get('dongCode', '1141011700')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[KREMC History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Origin': 'https://kremc.kr',
|
||||||
|
'Referer': 'https://kremc.kr/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = {'userId': user_id, 'password': password}
|
||||||
|
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if login_res.status_code != 200:
|
||||||
|
print(f" ✗ Login failed: {login_res.status_code}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
login_json = login_res.json()
|
||||||
|
|
||||||
|
if login_json.get('status') == 200 or login_json.get('code') == 'S001':
|
||||||
|
data = login_json.get('data')
|
||||||
|
|
||||||
|
if isinstance(data, str) and len(data) > 10:
|
||||||
|
token = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
token = data.get('token') or data.get('accessToken') or data.get('jwt')
|
||||||
|
if not token:
|
||||||
|
print(f" ✗ Token not found")
|
||||||
|
return results
|
||||||
|
else:
|
||||||
|
print(f" ✗ Invalid login data")
|
||||||
|
return results
|
||||||
|
else:
|
||||||
|
print(f" ✗ Login failed: {login_json.get('message', 'Unknown')}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(f" ✓ Login successful")
|
||||||
|
|
||||||
|
# API 헤더 설정
|
||||||
|
api_headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Auth-Token': token
|
||||||
|
}
|
||||||
|
|
||||||
|
# 날짜 범위 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
# 실제 확인된 시간별 엔드포인트
|
||||||
|
hourly_url = f"{api_base}/stat/userbyuser/meainDataList"
|
||||||
|
params = {
|
||||||
|
'cid': cid,
|
||||||
|
'userId': user_id,
|
||||||
|
'cityProvCode': city_prov_code,
|
||||||
|
'rgnCode': rgn_code,
|
||||||
|
'dongCode': dong_code,
|
||||||
|
'dateType': 'HH',
|
||||||
|
'startGathDtm': date_str,
|
||||||
|
'endGathDtm': date_str,
|
||||||
|
'ensoTypeCode': enso_type
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(hourly_url, params=params, headers=api_headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# KREMC 실제 응답 구조: data.userByTimeDataResultDtoList
|
||||||
|
hourly_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
|
||||||
|
|
||||||
|
if isinstance(hourly_list, list) and len(hourly_list) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_list)} hourly records")
|
||||||
|
|
||||||
|
for item in hourly_list:
|
||||||
|
# gathDtm: "00시", "01시", ..., "23시"
|
||||||
|
time_str = item.get('gathDtm', '')
|
||||||
|
hour = time_str.replace('시', '').zfill(2)
|
||||||
|
generation_kwh = safe_float(item.get('dayEnergy', 0))
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No hourly data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 날짜로
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Overall error: {e}")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
KREMC 발전소의 일별 과거 데이터 수집 (월 단위 분할)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import calendar
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'kremc-05')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
options = plant_info.get('options', {})
|
||||||
|
plant_name = plant_info.get('name', '5호기')
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
api_base = system.get('api_base', '')
|
||||||
|
enso_type = system.get('enso_type', '15001')
|
||||||
|
|
||||||
|
# KREMC 추가 파라미터
|
||||||
|
cid = options.get('cid', '10013000376')
|
||||||
|
city_prov_code = options.get('cityProvCode', '11')
|
||||||
|
rgn_code = options.get('rgnCode', '11410')
|
||||||
|
dong_code = options.get('dongCode', '1141011700')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[KREMC Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data = {'userId': user_id, 'password': password}
|
||||||
|
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if login_res.status_code != 200:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
login_json = login_res.json()
|
||||||
|
data = login_json.get('data')
|
||||||
|
token = data if isinstance(data, str) else data.get('token') if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print(" ✗ Token not found")
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
|
||||||
|
api_headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Auth-Token': token
|
||||||
|
}
|
||||||
|
|
||||||
|
# 월 단위 루프 적용
|
||||||
|
current_date_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_date_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
# 시작하는 달의 첫날로 맞춤 (단, 실제 요청 시에는 start_date 고려)
|
||||||
|
# 하지만 그냥 편의상 start_date가 속한 달부터 end_date가 속한 달까지 루프 돌면서
|
||||||
|
# API 요청 범위를 정교하게 자르는 게 좋음.
|
||||||
|
|
||||||
|
# 루프용 변수: 현재 처리 중인 기간의 시작일
|
||||||
|
loop_start = current_date_dt
|
||||||
|
|
||||||
|
while loop_start <= end_date_dt:
|
||||||
|
# 현재 달의 마지막 날 계산
|
||||||
|
last_day_of_month = calendar.monthrange(loop_start.year, loop_start.month)[1]
|
||||||
|
loop_end = loop_start.replace(day=last_day_of_month)
|
||||||
|
|
||||||
|
# 종료일이 전체 종료일보다 뒤면 조정
|
||||||
|
if loop_end > end_date_dt:
|
||||||
|
loop_end = end_date_dt
|
||||||
|
|
||||||
|
s_str = loop_start.strftime('%Y-%m-%d')
|
||||||
|
e_str = loop_end.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
print(f" [Fetching] {s_str} ~ {e_str} ...", end="", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
daily_url = f"{api_base}/stat/userbyuser/meainDataList"
|
||||||
|
params = {
|
||||||
|
'cid': cid,
|
||||||
|
'userId': user_id,
|
||||||
|
'cityProvCode': city_prov_code,
|
||||||
|
'rgnCode': rgn_code,
|
||||||
|
'dongCode': dong_code,
|
||||||
|
'dateType': 'DD',
|
||||||
|
'startGathDtm': s_str,
|
||||||
|
'endGathDtm': e_str,
|
||||||
|
'ensoTypeCode': enso_type
|
||||||
|
}
|
||||||
|
|
||||||
|
res = session.get(daily_url, params=params, headers=api_headers, timeout=15)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
daily_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
|
||||||
|
|
||||||
|
if daily_list:
|
||||||
|
count = 0
|
||||||
|
for item in daily_list:
|
||||||
|
# gathDtm: "2026-01-01" 형식
|
||||||
|
date_str = item.get('gathDtm', '')
|
||||||
|
generation_kwh = safe_float(item.get('dayEnergy', 0))
|
||||||
|
|
||||||
|
# 날짜 문자열 정리 (혹시 모를 공백 등 제거)
|
||||||
|
date_str = date_str.strip()
|
||||||
|
if len(date_str) > 10:
|
||||||
|
date_str = date_str[:10]
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
count += 1
|
||||||
|
print(f" OK ({count} days)")
|
||||||
|
else:
|
||||||
|
print(" No data")
|
||||||
|
else:
|
||||||
|
print(f" HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}")
|
||||||
|
|
||||||
|
# 다음 기간 설정 (현재 기간 끝 다음날)
|
||||||
|
loop_start = loop_end + timedelta(days=1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Overall Error: {e}")
|
||||||
|
|
||||||
|
print(f"\n[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
KREMC 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
⚠️ KREMC는 dateType=MM을 지원하지 않음 (500 에러)
|
||||||
|
→ 일별 데이터(dateType=DD)를 월별로 집계
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'kremc-05')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
options = plant_info.get('options', {})
|
||||||
|
plant_name = plant_info.get('name', '5호기')
|
||||||
|
|
||||||
|
# 시작일자 체크
|
||||||
|
plant_start_date = plant_info.get('start_date', '2018-06-28')
|
||||||
|
plant_start_month = plant_start_date[:7] # YYYY-MM
|
||||||
|
|
||||||
|
# 실제 시작 월은 발전소 가동일 이후로 제한
|
||||||
|
if start_month < plant_start_month:
|
||||||
|
actual_start = plant_start_month
|
||||||
|
print(f" ℹ 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
|
||||||
|
else:
|
||||||
|
actual_start = start_month
|
||||||
|
|
||||||
|
user_id = auth.get('user_id', '')
|
||||||
|
password = auth.get('password', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
api_base = system.get('api_base', '')
|
||||||
|
enso_type = system.get('enso_type', '15001')
|
||||||
|
|
||||||
|
# KREMC 추가 파라미터
|
||||||
|
cid = options.get('cid', '10013000376')
|
||||||
|
city_prov_code = options.get('cityProvCode', '11')
|
||||||
|
rgn_code = options.get('rgnCode', '11410')
|
||||||
|
dong_code = options.get('dongCode', '1141011700')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[KREMC Monthly] {plant_name} ({actual_start} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'userId': user_id, 'password': password}
|
||||||
|
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if login_res.status_code != 200:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
login_json = login_res.json()
|
||||||
|
data = login_json.get('data')
|
||||||
|
token = data if isinstance(data, str) else data.get('token') if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print(" ✗ Token not found")
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
|
||||||
|
api_headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Auth-Token': token
|
||||||
|
}
|
||||||
|
|
||||||
|
current_month = datetime.strptime(actual_start, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# 해당 월의 시작일과 마지막일 계산
|
||||||
|
first_day = current_month.strftime('%Y-%m-01')
|
||||||
|
if current_month.month == 12:
|
||||||
|
last_day = current_month.replace(day=31).strftime('%Y-%m-%d')
|
||||||
|
else:
|
||||||
|
next_month = current_month + relativedelta(months=1)
|
||||||
|
last_day = (next_month - relativedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# dateType=DD로 일별 데이터를 가져와서 합산
|
||||||
|
daily_url = f"{api_base}/stat/userbyuser/meainDataList"
|
||||||
|
params = {
|
||||||
|
'cid': cid,
|
||||||
|
'userId': user_id,
|
||||||
|
'cityProvCode': city_prov_code,
|
||||||
|
'rgnCode': rgn_code,
|
||||||
|
'dongCode': dong_code,
|
||||||
|
'dateType': 'DD',
|
||||||
|
'startGathDtm': first_day,
|
||||||
|
'endGathDtm': last_day,
|
||||||
|
'ensoTypeCode': enso_type
|
||||||
|
}
|
||||||
|
|
||||||
|
res = session.get(daily_url, params=params, headers=api_headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# KREMC 실제 응답 구조: data.userByTimeDataResultDtoList
|
||||||
|
daily_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
|
||||||
|
|
||||||
|
if isinstance(daily_list, list) and len(daily_list) > 0:
|
||||||
|
# 일별 데이터를 합산하여 월별 데이터 생성
|
||||||
|
monthly_total = sum([safe_float(item.get('dayEnergy', 0)) for item in daily_list])
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_total
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {monthly_total:.1f}kWh (from {len(daily_list)} days)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error for {month_str}: {e}")
|
||||||
|
|
||||||
|
# 다음 달로
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
618
crawler/crawlers/nrems.py
Normal file
618
crawler/crawlers/nrems.py
Normal file
|
|
@ -0,0 +1,618 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/nrems.py - NREMS 크롤러 (1,2,3,4,9호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from .base import safe_float, create_session, format_result
|
||||||
|
|
||||||
|
def _get_inverter_sums(session, pscode, system_config):
|
||||||
|
"""
|
||||||
|
1, 2호기 인버터별 일일 발전량 추출 (JSON API 사용)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
month_str = datetime.now().strftime('%Y-%m')
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Referer': f'http://www.nrems.co.kr/v2/local/comp/cp_inv_time.php?pscode={pscode}'
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'act': 'getList',
|
||||||
|
's_day': today_str,
|
||||||
|
's_date': today_str,
|
||||||
|
'e_date': today_str,
|
||||||
|
's_mon': month_str,
|
||||||
|
'e_mon': month_str,
|
||||||
|
'pscode': pscode,
|
||||||
|
'dispType': 'time'
|
||||||
|
}
|
||||||
|
|
||||||
|
inv_proc_url = system_config.get('inv_proc_url', '')
|
||||||
|
res = session.post(inv_proc_url, data=data, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
try:
|
||||||
|
json_data = res.json()
|
||||||
|
invlist = json_data.get('invlist', [])
|
||||||
|
|
||||||
|
sum_1 = 0.0
|
||||||
|
sum_2 = 0.0
|
||||||
|
|
||||||
|
for inv in invlist:
|
||||||
|
tidx = str(inv.get('tidx', ''))
|
||||||
|
sum_pw = safe_float(inv.get('sumPw'))
|
||||||
|
|
||||||
|
if tidx == '1':
|
||||||
|
sum_1 = sum_pw
|
||||||
|
elif tidx == '2':
|
||||||
|
sum_2 = sum_pw
|
||||||
|
|
||||||
|
if sum_1 > 0 or sum_2 > 0:
|
||||||
|
print(f" [API] 인버터 합계 추출 성공! (인버터1: {sum_1} kWh / 인버터2: {sum_2} kWh)")
|
||||||
|
return sum_1, sum_2
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ API 응답에 인버터 데이터 없음")
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f" ⚠️ JSON 파싱 실패")
|
||||||
|
return 0.0, 0.0
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ API 응답 오류: {res.status_code}")
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [에러] {e}")
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
NREMS 발전소 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: {
|
||||||
|
'id': 'nrems-03', # DB용 고유 ID (is_split인 경우 없음)
|
||||||
|
'name': '...',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {'pscode': '...'},
|
||||||
|
'options': {'is_split': True/False},
|
||||||
|
'system': {'api_url': '...', 'inv_proc_url': '...'},
|
||||||
|
'company_name': '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{'id': '...', 'name': '...', 'kw': 10.5, 'today': 100.0, 'status': '...'}]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', '') # DB용 고유 ID
|
||||||
|
pscode = plant_info['auth'].get('pscode', '')
|
||||||
|
is_split = plant_info['options'].get('is_split', False)
|
||||||
|
system_config = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 메인 데이터 요청
|
||||||
|
api_url = system_config.get('api_url', '')
|
||||||
|
res = session.post(api_url, data={'pscode': pscode}, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if res.status_code != 200:
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = res.json()
|
||||||
|
except:
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 데이터 찾기
|
||||||
|
ps_list = data.get('ps_status')
|
||||||
|
target_data = None
|
||||||
|
if isinstance(ps_list, list):
|
||||||
|
for item in ps_list:
|
||||||
|
code_in_res = item.get('pscode')
|
||||||
|
wmu_in_res = item.get('WMU_CODE')
|
||||||
|
|
||||||
|
# Case-insensitive comparison
|
||||||
|
if (code_in_res and code_in_res.lower() == pscode.lower()) or \
|
||||||
|
(wmu_in_res and wmu_in_res.lower() == pscode.lower()):
|
||||||
|
target_data = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_data and len(ps_list) > 0:
|
||||||
|
print(f" ⚠️ Target pscode '{pscode}' not found in response. Available: {[i.get('pscode') for i in ps_list]}")
|
||||||
|
target_data = ps_list[0] # Fallback
|
||||||
|
print(f" ⚠️ Using fallback: {target_data.get('pscode')}")
|
||||||
|
elif isinstance(ps_list, dict):
|
||||||
|
target_data = ps_list
|
||||||
|
if not target_data:
|
||||||
|
target_data = {}
|
||||||
|
|
||||||
|
total_kw = safe_float(target_data.get('KW'))
|
||||||
|
total_today = safe_float(target_data.get('TDayKWH'))
|
||||||
|
inverters = data.get('ivt_value', [])
|
||||||
|
|
||||||
|
# Case A: 1, 2호기 분리 처리
|
||||||
|
if is_split:
|
||||||
|
real_sum_1, real_sum_2 = _get_inverter_sums(session, pscode, system_config)
|
||||||
|
|
||||||
|
kw_1 = safe_float(inverters[0].get('KW')) if len(inverters) >= 1 else 0.0
|
||||||
|
kw_2 = safe_float(inverters[1].get('KW')) if len(inverters) >= 2 else 0.0
|
||||||
|
|
||||||
|
if (real_sum_1 + real_sum_2) > 0:
|
||||||
|
today_1 = real_sum_1
|
||||||
|
today_2 = real_sum_2
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 백업 로직(비율) 가동")
|
||||||
|
inv_total = kw_1 + kw_2
|
||||||
|
if inv_total > 0:
|
||||||
|
today_1 = total_today * (kw_1 / inv_total)
|
||||||
|
today_2 = total_today * (kw_2 / inv_total)
|
||||||
|
else:
|
||||||
|
today_1 = total_today / 2
|
||||||
|
today_2 = total_today / 2
|
||||||
|
|
||||||
|
# [중요] 1, 2호기는 ID를 강제 지정
|
||||||
|
results.append({
|
||||||
|
'id': 'nrems-01', # 1호기 고정 ID
|
||||||
|
'name': f'{company_name} 1호기',
|
||||||
|
'kw': kw_1,
|
||||||
|
'today': round(today_1, 2),
|
||||||
|
'status': "🟢 정상" if kw_1 > 0 else "💤 대기"
|
||||||
|
})
|
||||||
|
results.append({
|
||||||
|
'id': 'nrems-02', # 2호기 고정 ID
|
||||||
|
'name': f'{company_name} 2호기',
|
||||||
|
'kw': kw_2,
|
||||||
|
'today': round(today_2, 2),
|
||||||
|
'status': "🟢 정상" if kw_2 > 0 else "💤 대기"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Case B: 3, 4, 9호기
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'id': plant_id, # config에서 정의된 ID 사용
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': total_kw,
|
||||||
|
'today': total_today,
|
||||||
|
'status': "🟢 정상" if total_kw > 0 else "💤 대기"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ NREMS {plant_name} 오류: {e}")
|
||||||
|
if not is_split:
|
||||||
|
results.append({
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': 0.0,
|
||||||
|
'today': 0.0,
|
||||||
|
'status': '🔴 오류'
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
NREMS 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: {
|
||||||
|
'id': 'nrems-03',
|
||||||
|
'name': '...',
|
||||||
|
'type': 'nrems',
|
||||||
|
'auth': {'pscode': '...'},
|
||||||
|
'options': {'is_split': True/False},
|
||||||
|
'system': {'api_url': '...', 'inv_proc_url': '...'},
|
||||||
|
'company_name': '...'
|
||||||
|
}
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{
|
||||||
|
'plant_id': 'nrems-03',
|
||||||
|
'timestamp': '2026-01-15 14:00:00',
|
||||||
|
'generation_kwh': 123.5,
|
||||||
|
'current_kw': 15.2
|
||||||
|
}, ...]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', '')
|
||||||
|
pscode = plant_info['auth'].get('pscode', '')
|
||||||
|
is_split = plant_info['options'].get('is_split', False)
|
||||||
|
plant_name = plant_info.get('name', '')
|
||||||
|
|
||||||
|
# 날짜 범위 생성
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[NREMS Hourly] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: cp_inv_proc.php with dispType=time
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_time.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'getList',
|
||||||
|
's_day': date_str,
|
||||||
|
's_date': date_str,
|
||||||
|
'e_date': date_str,
|
||||||
|
's_mon': date_str[:7],
|
||||||
|
'e_mon': date_str[:7],
|
||||||
|
'pscode': pscode,
|
||||||
|
'dispType': 'time'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: pl_time_proc.php with act=empty
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/pl_time_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_time.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'empty',
|
||||||
|
's_date': date_str,
|
||||||
|
'pscode': pscode
|
||||||
|
}
|
||||||
|
|
||||||
|
response = session.post(url, data=payload, headers=headers, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 데이터 구조 확인
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: pwdata 키 사용
|
||||||
|
hourly_records = data.get('pwdata', [])
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: pdata 키 사용
|
||||||
|
hourly_records = data.get('pdata', [])
|
||||||
|
|
||||||
|
if hourly_records:
|
||||||
|
print(f" ✓ Found {len(hourly_records)} hourly records")
|
||||||
|
|
||||||
|
for hour_data in hourly_records:
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: DATE, PW1, PW2
|
||||||
|
hour = hour_data.get('DATE', '00')
|
||||||
|
inv1_gen = safe_float(hour_data.get('PW1', 0))
|
||||||
|
inv2_gen = safe_float(hour_data.get('PW2', 0))
|
||||||
|
|
||||||
|
# timestamp 생성
|
||||||
|
timestamp = f"{date_str} {str(hour).zfill(2)}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': 'nrems-01',
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': inv1_gen,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
results.append({
|
||||||
|
'plant_id': 'nrems-02',
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': inv2_gen,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: TIME, INV
|
||||||
|
time_str = hour_data.get('TIME', '00:00')
|
||||||
|
hour = time_str.split(':')[0] # "14:00" -> "14"
|
||||||
|
generation_kwh = safe_float(hour_data.get('INV', 0))
|
||||||
|
|
||||||
|
# timestamp 생성
|
||||||
|
timestamp = f"{date_str} {str(hour).zfill(2)}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" → Collected {len(hourly_records)} records")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No hourly data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
NREMS 발전소의 일별 과거 데이터 수집 (월 단위 루프)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{'plant_id': '...', 'date': '2026-01-15', 'generation_kwh': 123.5}, ...]
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', '')
|
||||||
|
pscode = plant_info['auth'].get('pscode', '')
|
||||||
|
is_split = plant_info['options'].get('is_split', False)
|
||||||
|
plant_name = plant_info.get('name', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[NREMS Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
current_dt = start_dt
|
||||||
|
|
||||||
|
while current_dt <= end_dt:
|
||||||
|
# 현재 처리할 달의 시작일과 종료일 계산
|
||||||
|
# 이번 달의 마지막 날
|
||||||
|
last_day_of_month = calendar.monthrange(current_dt.year, current_dt.month)[1]
|
||||||
|
chunk_end_dt = current_dt.replace(day=last_day_of_month)
|
||||||
|
|
||||||
|
# 요청 종료일이 전체 종료일보다 뒤면 전체 종료일로 제한
|
||||||
|
if chunk_end_dt > end_dt:
|
||||||
|
chunk_end_dt = end_dt
|
||||||
|
|
||||||
|
s_date_str = current_dt.strftime('%Y-%m-%d')
|
||||||
|
e_date_str = chunk_end_dt.strftime('%Y-%m-%d')
|
||||||
|
month_str = current_dt.strftime('%Y-%m')
|
||||||
|
|
||||||
|
print(f" [Fetching] {s_date_str} ~ {e_date_str} ...", end="", flush=True)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: cp_inv_proc.php with dispType=day
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_day.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'getList',
|
||||||
|
's_day': s_date_str, # s_day를 시작일로 변경
|
||||||
|
's_date': s_date_str,
|
||||||
|
'e_date': e_date_str,
|
||||||
|
's_mon': s_date_str[:7],
|
||||||
|
'e_mon': e_date_str[:7],
|
||||||
|
'pscode': pscode,
|
||||||
|
'dispType': 'day'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: pl_day_proc.php with s_day/e_day range
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/pl_day_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_day.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'empty',
|
||||||
|
's_day': s_date_str,
|
||||||
|
'e_day': e_date_str,
|
||||||
|
'pscode': pscode
|
||||||
|
}
|
||||||
|
|
||||||
|
response = session.post(url, data=payload, headers=headers, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 데이터 구조 확인
|
||||||
|
if is_split:
|
||||||
|
daily_records = data.get('pwdata', [])
|
||||||
|
else:
|
||||||
|
daily_records = data.get('pdata', [])
|
||||||
|
|
||||||
|
if daily_records:
|
||||||
|
count = 0
|
||||||
|
for day_data in daily_records:
|
||||||
|
# 날짜 추출
|
||||||
|
date_raw = day_data.get('DATE', '')
|
||||||
|
if not date_raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 날짜 형식 변환: "12-28" -> "2025-12-28" 보정
|
||||||
|
clean_date = date_raw
|
||||||
|
if '-' in date_raw and len(date_raw.split('-')[0]) <= 2:
|
||||||
|
mm, dd = date_raw.split('-')
|
||||||
|
year = current_dt.year
|
||||||
|
# 만약 12월 데이터인데 1월에 긁으면... 루프 변수 current_dt.year 사용하면 안전
|
||||||
|
clean_date = f"{year}-{mm.zfill(2)}-{dd.zfill(2)}"
|
||||||
|
|
||||||
|
if is_split:
|
||||||
|
inv1_gen = safe_float(day_data.get('PW1', 0))
|
||||||
|
inv2_gen = safe_float(day_data.get('PW2', 0))
|
||||||
|
|
||||||
|
results.append({'plant_id': 'nrems-01', 'date': clean_date, 'generation_kwh': inv1_gen})
|
||||||
|
results.append({'plant_id': 'nrems-02', 'date': clean_date, 'generation_kwh': inv2_gen})
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
generation_kwh = safe_float(day_data.get('INV', 0))
|
||||||
|
results.append({'plant_id': plant_id, 'date': clean_date, 'generation_kwh': generation_kwh})
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
print(f" OK ({count} days)")
|
||||||
|
else:
|
||||||
|
print(f" No data")
|
||||||
|
except Exception as json_err:
|
||||||
|
print(f" JSON Error: {json_err}")
|
||||||
|
else:
|
||||||
|
print(f" HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}")
|
||||||
|
|
||||||
|
# 다음 달 1일로 이동
|
||||||
|
current_dt = (current_dt.replace(day=1) + timedelta(days=32)).replace(day=1)
|
||||||
|
|
||||||
|
print(f"\n[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
NREMS 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: 발전소 정보
|
||||||
|
start_month: str, 시작월 (YYYY-MM)
|
||||||
|
end_month: str, 종료월 (YYYY-MM)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: [{'plant_id': '...', 'month': '2026-01', 'generation_kwh': 12345.6}, ...]
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', '')
|
||||||
|
pscode = plant_info['auth'].get('pscode', '')
|
||||||
|
is_split = plant_info['options'].get('is_split', False)
|
||||||
|
plant_name = plant_info.get('name', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[NREMS Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: cp_inv_proc.php with dispType=mon
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_month.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'getList',
|
||||||
|
's_day': f"{end_month}-01",
|
||||||
|
's_date': f"{start_month}-01",
|
||||||
|
'e_date': f"{end_month}-01",
|
||||||
|
's_mon': start_month,
|
||||||
|
'e_mon': end_month,
|
||||||
|
'pscode': pscode,
|
||||||
|
'dispType': 'mon'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: pl_month_proc.php with s_date/e_date (YYYY-MM)
|
||||||
|
url = 'http://www.nrems.co.kr/v2/local/proc/pl_month_proc.php'
|
||||||
|
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_month.php?pscode={pscode}'
|
||||||
|
payload = {
|
||||||
|
'act': 'empty',
|
||||||
|
's_date': start_month,
|
||||||
|
'e_date': end_month,
|
||||||
|
'pscode': pscode
|
||||||
|
}
|
||||||
|
|
||||||
|
response = session.post(url, data=payload, headers=headers, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 데이터 구조 확인
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: pwdata 키 사용
|
||||||
|
monthly_records = data.get('pwdata', [])
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: pdata 키 사용
|
||||||
|
monthly_records = data.get('pdata', [])
|
||||||
|
|
||||||
|
if monthly_records:
|
||||||
|
print(f" ✓ Found {len(monthly_records)} monthly records")
|
||||||
|
|
||||||
|
for month_data in monthly_records:
|
||||||
|
# 월 추출
|
||||||
|
month_str = month_data.get('DATE', '')
|
||||||
|
if not month_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_split:
|
||||||
|
# 1,2호기: PW1, PW2 분리
|
||||||
|
inv1_gen = safe_float(month_data.get('PW1', 0))
|
||||||
|
inv2_gen = safe_float(month_data.get('PW2', 0))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': 'nrems-01',
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': inv1_gen
|
||||||
|
})
|
||||||
|
results.append({
|
||||||
|
'plant_id': 'nrems-02',
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': inv2_gen
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: Unit1={inv1_gen}kWh, Unit2={inv2_gen}kWh")
|
||||||
|
else:
|
||||||
|
# 3,4,9호기: INV 단일값
|
||||||
|
generation_kwh = safe_float(month_data.get('INV', 0))
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': generation_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {generation_kwh}kWh")
|
||||||
|
|
||||||
|
print(f" → Collected {len(monthly_records)} records")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No monthly data found")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
print(f"\n[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
430
crawler/crawlers/sun_wms.py
Normal file
430
crawler/crawlers/sun_wms.py
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
|
||||||
|
# HTML 테이블 파싱 방식
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from .base import create_session, safe_float
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 데이터 요청
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
content = res.text
|
||||||
|
|
||||||
|
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
|
||||||
|
|
||||||
|
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
today_kwh = float(match_today.group(1)) if match_today else 0.0
|
||||||
|
|
||||||
|
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
|
||||||
|
파라미터: tab01=0&tab02=1&tab03=2&tord=1&s_day=YYYY-MM-DD
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# base_url 추출
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 실제 확인된 시간별 엔드포인트
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '1',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '1',
|
||||||
|
's_day': date_str
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# HTML 테이블 파싱
|
||||||
|
html = res.text
|
||||||
|
|
||||||
|
# <tbody> 안의 <tr> 태그 찾기
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
|
||||||
|
# 각 <tr> 파싱
|
||||||
|
tr_pattern = r'<tr>\s*<td>(\d{2}):00</td>\s*<td>([\d.]+)</td>\s*</tr>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
print(f" ✓ Found {len(matches)} hourly records")
|
||||||
|
|
||||||
|
for hour, kwh in matches:
|
||||||
|
generation_kwh = safe_float(kwh)
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No tbody found for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 일별 과거 데이터 수집 (월 단위 분할)
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
|
||||||
|
파라미터: tab01=0&tab02=2&tab03=2&tord=2&s_day=YYYY-MM-DD&e_day=YYYY-MM-DD
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import calendar
|
||||||
|
import re
|
||||||
|
from .base import safe_float, create_session
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
try:
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 월 단위 루프 적용
|
||||||
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
loop_start = start_dt
|
||||||
|
|
||||||
|
while loop_start <= end_dt:
|
||||||
|
# 현재 달의 마지막 날 계산
|
||||||
|
last_day_of_month = calendar.monthrange(loop_start.year, loop_start.month)[1]
|
||||||
|
loop_end = loop_start.replace(day=last_day_of_month)
|
||||||
|
|
||||||
|
# 종료일이 전체 종료일보다 뒤면 조정
|
||||||
|
if loop_end > end_dt:
|
||||||
|
loop_end = end_dt
|
||||||
|
|
||||||
|
s_str = loop_start.strftime('%Y-%m-%d')
|
||||||
|
e_str = loop_end.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
print(f" [Fetching] {s_str} ~ {e_str} ...", end="", flush=True)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '2',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '2',
|
||||||
|
's_day': s_str,
|
||||||
|
'e_day': e_str
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=15)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
html = res.text
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
tr_pattern = r'<tr>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d.]+)</td>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
count = 0
|
||||||
|
for date_str, kwh in matches:
|
||||||
|
generation_kwh = safe_float(kwh)
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': 0
|
||||||
|
})
|
||||||
|
count += 1
|
||||||
|
print(f" OK ({count} days)")
|
||||||
|
else:
|
||||||
|
print(" No data")
|
||||||
|
else:
|
||||||
|
print(" No tbody")
|
||||||
|
else:
|
||||||
|
print(f" HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}")
|
||||||
|
|
||||||
|
# 다음 기간 설정
|
||||||
|
loop_start = loop_end + timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
|
||||||
|
⚠️ 월별 데이터는 일별 데이터를 월별로 집계
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
# 시작일자 체크
|
||||||
|
plant_start_date = plant_info.get('start_date', '2019-12-30')
|
||||||
|
plant_start_month = plant_start_date[:7] # YYYY-MM
|
||||||
|
|
||||||
|
# 실제 시작 월은 발전소 가동일 이후로 제한
|
||||||
|
if start_month < plant_start_month:
|
||||||
|
actual_start = plant_start_month
|
||||||
|
print(f" ℹ 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
|
||||||
|
else:
|
||||||
|
actual_start = start_month
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# base_url 추출
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Monthly] {plant_name} ({actual_start} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 월 단위로 반복
|
||||||
|
current_month = datetime.strptime(actual_start, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# 해당 월의 시작일과 마지막일
|
||||||
|
first_day = current_month.strftime('%Y-%m-01')
|
||||||
|
if current_month.month == 12:
|
||||||
|
last_day = current_month.replace(day=31).strftime('%Y-%m-%d')
|
||||||
|
else:
|
||||||
|
next_month = current_month + relativedelta(months=1)
|
||||||
|
last_day = (next_month - relativedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 일별 엔드포인트로 한 달치 데이터 수집해서 합산
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '2',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '2',
|
||||||
|
's_day': first_day,
|
||||||
|
'e_day': last_day
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
# HTML 테이블 파싱
|
||||||
|
html = res.text
|
||||||
|
|
||||||
|
# <tbody> 안의 <tr> 태그 찾기
|
||||||
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
|
||||||
|
if tbody_match:
|
||||||
|
tbody_content = tbody_match.group(1)
|
||||||
|
|
||||||
|
# 각 <tr> 파싱 (날짜와 발전량)
|
||||||
|
tr_pattern = r'<tr>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d.]+)</td>'
|
||||||
|
matches = re.findall(tr_pattern, tbody_content)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
# 일별 데이터를 합산
|
||||||
|
monthly_total = sum([safe_float(kwh) for _, kwh in matches])
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_total
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {monthly_total:.1f}kWh (from {len(matches)} days)")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {month_str}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error for {month_str}: {e}")
|
||||||
|
|
||||||
|
# 다음 달로
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
343
crawler/crawlers/sun_wms.py.backup
Normal file
343
crawler/crawlers/sun_wms.py.backup
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from .base import create_session
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 데이터 요청
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
content = res.text
|
||||||
|
|
||||||
|
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
|
||||||
|
|
||||||
|
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
today_kwh = float(match_today.group(1)) if match_today else 0.0
|
||||||
|
|
||||||
|
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 일별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 일별 데이터 엔드포인트 (추정)
|
||||||
|
daily_url = f"{base_url}/public/chart/getDailyData.php?date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{daily_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
daily_kwh = safe_float(data.get('daily', data.get('today', 0)))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': daily_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {daily_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {date_str}: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 월별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_month = datetime.strptime(start_month, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# 월별 데이터 엔드포인트 (추정)
|
||||||
|
monthly_url = f"{base_url}/public/chart/getMonthlyData.php?month={month_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{monthly_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
monthly_kwh = safe_float(data.get('monthly', data.get('month', 0)))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {monthly_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {month_str}: {e}")
|
||||||
|
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: dict, 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 시간대별 데이터 레코드
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
print(f" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(f" ✓ Login successful")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 범위 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
# 시간대별 데이터 엔드포인트 (추정)
|
||||||
|
hourly_url = f"{base_url}/public/chart/getHourlyData.php?date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{hourly_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
|
||||||
|
|
||||||
|
if hourly_data and len(hourly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_data)} hourly records")
|
||||||
|
|
||||||
|
for item in hourly_data:
|
||||||
|
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
|
||||||
|
generation_kwh = safe_float(item.get('power', item.get('kwh', 0)))
|
||||||
|
current_kw = safe_float(item.get('kw', 0))
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No hourly data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 날짜로
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
359
crawler/crawlers/sun_wms_json.py
Normal file
359
crawler/crawlers/sun_wms_json.py
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from .base import create_session, safe_float
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 데이터 요청
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
content = res.text
|
||||||
|
|
||||||
|
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
|
||||||
|
|
||||||
|
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
today_kwh = float(match_today.group(1)) if match_today else 0.0
|
||||||
|
|
||||||
|
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php
|
||||||
|
파라미터: tab01=0&tab02=1&tab03=2&tord=1&s_day=YYYY-MM-DD
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# base_url 추출
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
if not base_url and 'http' in login_url:
|
||||||
|
base_url = login_url.split('/public')[0]
|
||||||
|
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 실제 확인된 시간별 엔드포인트
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '1',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '1',
|
||||||
|
's_day': date_str
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 시간별 데이터 파싱
|
||||||
|
hourly_data = data.get('data', []) or data.get('list', [])
|
||||||
|
|
||||||
|
if isinstance(hourly_data, list) and len(hourly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_data)} hourly records")
|
||||||
|
|
||||||
|
for item in hourly_data:
|
||||||
|
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
|
||||||
|
generation_kwh = safe_float(item.get('generation', item.get('kwh', 0)))
|
||||||
|
current_kw = safe_float(item.get('power', item.get('kw', 0)))
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 일별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php
|
||||||
|
파라미터: tab01=0&tab02=2&tab03=2&tord=2&s_day=YYYY-MM-DD&e_day=YYYY-MM-DD
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# base_url 추출
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
if not base_url and 'http' in login_url:
|
||||||
|
base_url = login_url.split('/public')[0]
|
||||||
|
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 실제 확인된 일별 엔드포인트
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '2',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '2',
|
||||||
|
's_day': start_date,
|
||||||
|
'e_day': end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 일별 데이터 파싱
|
||||||
|
daily_data = data.get('data', []) or data.get('list', [])
|
||||||
|
|
||||||
|
if isinstance(daily_data, list) and len(daily_data) > 0:
|
||||||
|
for item in daily_data:
|
||||||
|
date_str = item.get('date', item.get('day', ''))
|
||||||
|
generation_kwh = safe_float(item.get('generation', item.get('kwh', 0)))
|
||||||
|
current_kw = safe_float(item.get('power', item.get('kw', 0)))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {generation_kwh:.2f}kWh")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 월별 과거 데이터 수집
|
||||||
|
|
||||||
|
실제 엔드포인트: /public/statics/statics.php
|
||||||
|
파라미터: tab01=0&tab02=3&tab03=2&tord=3&s_day=YYYY-MM&e_day=YYYY-MM
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
|
||||||
|
# base_url 추출
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
if not base_url and 'http' in login_url:
|
||||||
|
base_url = login_url.split('/public')[0]
|
||||||
|
|
||||||
|
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 실제 확인된 월별 엔드포인트
|
||||||
|
params = {
|
||||||
|
'tab01': '0',
|
||||||
|
'tab02': '3',
|
||||||
|
'tab03': '2',
|
||||||
|
'tord': '3',
|
||||||
|
's_day': start_month,
|
||||||
|
'e_day': end_month
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(statics_url, params=params, headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
# 월별 데이터 파싱
|
||||||
|
monthly_data = data.get('data', []) or data.get('list', [])
|
||||||
|
|
||||||
|
if isinstance(monthly_data, list) and len(monthly_data) > 0:
|
||||||
|
for item in monthly_data:
|
||||||
|
month_str = item.get('month', item.get('date', ''))
|
||||||
|
generation_kwh = safe_float(item.get('generation', item.get('kwh', item.get('monthTotal', 0))))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str[:7] if len(month_str) >= 7 else month_str,
|
||||||
|
'generation_kwh': generation_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str[:7]}: {generation_kwh:.1f}kWh")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
343
crawler/crawlers/sun_wms_old.py
Normal file
343
crawler/crawlers/sun_wms_old.py
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
# ==========================================
|
||||||
|
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from .base import create_session
|
||||||
|
|
||||||
|
def fetch_data(plant_info):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소 데이터 수집
|
||||||
|
"""
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
company_name = plant_info.get('company_name', '태양과바람')
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 로그인
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 접속 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. 데이터 요청
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
content = res.text
|
||||||
|
|
||||||
|
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
|
||||||
|
|
||||||
|
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
|
||||||
|
today_kwh = float(match_today.group(1)) if match_today else 0.0
|
||||||
|
|
||||||
|
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f'{company_name} {plant_name}',
|
||||||
|
'kw': curr_kw,
|
||||||
|
'today': today_kwh,
|
||||||
|
'status': status
|
||||||
|
}]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {plant_name} 에러: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_daily(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 일별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 일별 데이터 엔드포인트 (추정)
|
||||||
|
daily_url = f"{base_url}/public/chart/getDailyData.php?date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{daily_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
daily_kwh = safe_float(data.get('daily', data.get('today', 0)))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'generation_kwh': daily_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {date_str}: {daily_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {date_str}: {e}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} daily records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_monthly(plant_info, start_month, end_month):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 월별 과거 데이터 수집
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code == 200:
|
||||||
|
print(" ✓ Login successful")
|
||||||
|
else:
|
||||||
|
print(" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
current_month = datetime.strptime(start_month, '%Y-%m')
|
||||||
|
end_month_dt = datetime.strptime(end_month, '%Y-%m')
|
||||||
|
|
||||||
|
while current_month <= end_month_dt:
|
||||||
|
month_str = current_month.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# 월별 데이터 엔드포인트 (추정)
|
||||||
|
monthly_url = f"{base_url}/public/chart/getMonthlyData.php?month={month_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{monthly_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
monthly_kwh = safe_float(data.get('monthly', data.get('month', 0)))
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'month': month_str,
|
||||||
|
'generation_kwh': monthly_kwh
|
||||||
|
})
|
||||||
|
print(f" ✓ {month_str}: {monthly_kwh}kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {month_str}: {e}")
|
||||||
|
|
||||||
|
current_month += relativedelta(months=1)
|
||||||
|
|
||||||
|
print(f"[Total] Collected {len(results)} monthly records\n")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history_hourly(plant_info, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Sun-WMS 발전소의 시간대별 과거 데이터 수집
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plant_info: dict, 발전소 정보
|
||||||
|
start_date: str, 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: str, 종료일 (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 시간대별 데이터 레코드
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .base import safe_float
|
||||||
|
import time
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 설정 추출
|
||||||
|
plant_id = plant_info.get('id', 'sunwms-06')
|
||||||
|
auth = plant_info.get('auth', {})
|
||||||
|
system = plant_info.get('system', {})
|
||||||
|
plant_name = plant_info.get('name', '6호기')
|
||||||
|
|
||||||
|
payload_id = auth.get('payload_id', '')
|
||||||
|
payload_pw = auth.get('payload_pw', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
base_url = system.get('base_url', '')
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 로그인
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
'act': 'loginChk',
|
||||||
|
'user_id': payload_id,
|
||||||
|
'user_pass': payload_pw
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
if res.status_code != 200:
|
||||||
|
print(f" ✗ Login failed")
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(f" ✓ Login successful")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Login error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 날짜 범위 반복
|
||||||
|
current_date = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
while current_date <= end_dt:
|
||||||
|
date_str = current_date.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n[Processing Date] {date_str}")
|
||||||
|
|
||||||
|
# 시간대별 데이터 엔드포인트 (추정)
|
||||||
|
hourly_url = f"{base_url}/public/chart/getHourlyData.php?date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = int(time.time() * 1000)
|
||||||
|
res = session.get(f"{hourly_url}&time={timestamp}", headers=headers, timeout=10)
|
||||||
|
res.encoding = 'euc-kr'
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
|
||||||
|
|
||||||
|
if hourly_data and len(hourly_data) > 0:
|
||||||
|
print(f" ✓ Found {len(hourly_data)} hourly records")
|
||||||
|
|
||||||
|
for item in hourly_data:
|
||||||
|
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
|
||||||
|
generation_kwh = safe_float(item.get('power', item.get('kwh', 0)))
|
||||||
|
current_kw = safe_float(item.get('kw', 0))
|
||||||
|
|
||||||
|
timestamp = f"{date_str} {hour}:00:00"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'generation_kwh': generation_kwh,
|
||||||
|
'current_kw': current_kw
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f" ⚠ No hourly data for {date_str}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ HTTP {res.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error: {e}")
|
||||||
|
|
||||||
|
# 다음 날짜로
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[Total] Collected {len(results)} hourly records")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return results
|
||||||
204
crawler/daily_summary.py
Normal file
204
crawler/daily_summary.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# ==========================================
|
||||||
|
# daily_summary.py - 일일 발전 통계 집계
|
||||||
|
# ==========================================
|
||||||
|
# solar_logs 데이터를 집계하여 daily_stats 테이블에 저장
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_plant_capacities(client) -> dict:
|
||||||
|
"""plants 테이블에서 용량 정보 조회"""
|
||||||
|
try:
|
||||||
|
result = client.table("plants").select("id, capacity").execute()
|
||||||
|
return {row['id']: row.get('capacity', 99.0) for row in result.data}
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 용량 조회 실패: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_daily_stats(date_str: str = None):
|
||||||
|
"""
|
||||||
|
특정 날짜의 발전 통계 집계
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: 집계 대상 날짜 (YYYY-MM-DD). 미지정 시 오늘.
|
||||||
|
"""
|
||||||
|
if date_str is None:
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
date_str = datetime.now(kst).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
print(f"\n📊 [일일 통계 집계] {date_str}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
print("❌ Supabase 연결 실패")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1. 용량 정보 조회
|
||||||
|
capacities = get_plant_capacities(client)
|
||||||
|
|
||||||
|
# 2. 해당일 로그 조회 (KST 날짜 범위를 UTC로 변환하여 쿼리)
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
start_kst = datetime.strptime(f"{date_str} 00:00:00", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
|
||||||
|
end_kst = datetime.strptime(f"{date_str} 23:59:59", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
|
||||||
|
start_utc = start_kst.astimezone(timezone.utc).isoformat()
|
||||||
|
end_utc = end_kst.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("plant_id, current_kw, today_kwh, created_at") \
|
||||||
|
.gte("created_at", start_utc) \
|
||||||
|
.lte("created_at", end_utc) \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not result.data:
|
||||||
|
print(" ⚠️ 해당 날짜의 로그가 없습니다.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
df = pd.DataFrame(result.data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 로그 조회 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 발전소별 통계 계산
|
||||||
|
stats_list = []
|
||||||
|
|
||||||
|
for plant_id, group in df.groupby('plant_id'):
|
||||||
|
# 당일 최댓값 today_kwh 사용 (마지막 로그가 아닌 최댓값으로 중간 리셋 보호)
|
||||||
|
total_generation = group['today_kwh'].max() if len(group) > 0 else 0
|
||||||
|
|
||||||
|
# 최대 출력
|
||||||
|
peak_kw = group['current_kw'].max() if len(group) > 0 else 0
|
||||||
|
|
||||||
|
# 이용률 시간 = 발전량 / 용량
|
||||||
|
capacity = capacities.get(plant_id, 99.0)
|
||||||
|
generation_hours = round(total_generation / capacity, 2) if capacity > 0 else 0
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'date': date_str,
|
||||||
|
'total_generation': round(total_generation, 2),
|
||||||
|
'peak_kw': round(peak_kw, 2),
|
||||||
|
'generation_hours': generation_hours
|
||||||
|
}
|
||||||
|
stats_list.append(stats)
|
||||||
|
|
||||||
|
# 출력
|
||||||
|
print(f" {plant_id}: {total_generation:.1f}kWh ({generation_hours:.1f}시간, 최대 {peak_kw:.1f}kW)")
|
||||||
|
|
||||||
|
# 4. daily_stats 테이블에 Upsert
|
||||||
|
if stats_list:
|
||||||
|
try:
|
||||||
|
result = client.table("daily_stats").upsert(
|
||||||
|
stats_list,
|
||||||
|
on_conflict="plant_id,date"
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"✅ {len(stats_list)}개 발전소 통계 저장 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 저장 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_monthly_stats(target_month: str):
|
||||||
|
"""
|
||||||
|
특정 월의 발전 통계 집계 (일간 데이터 합산)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_month: YYYY-MM
|
||||||
|
"""
|
||||||
|
print(f"\n📅 [월간 통계 집계] {target_month}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 모든 발전소 ID 조회
|
||||||
|
plants_res = client.table("plants").select("id").execute()
|
||||||
|
plant_ids = [p['id'] for p in plants_res.data]
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for pid in plant_ids:
|
||||||
|
# 2. 해당 월의 Daily 합계 조회
|
||||||
|
d_res = client.table("daily_stats").select("total_generation") \
|
||||||
|
.eq("plant_id", pid) \
|
||||||
|
.gte("date", f"{target_month}-01") \
|
||||||
|
.lte("date", f"{target_month}-31") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not d_res.data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_gen = sum(r.get('total_generation', 0) or 0 for r in d_res.data)
|
||||||
|
|
||||||
|
# 3. Monthly Upsert
|
||||||
|
client.table("monthly_stats").upsert({
|
||||||
|
"plant_id": pid,
|
||||||
|
"month": target_month,
|
||||||
|
"total_generation": round(total_gen, 2),
|
||||||
|
"updated_at": datetime.now().isoformat()
|
||||||
|
}, on_conflict="plant_id, month").execute()
|
||||||
|
|
||||||
|
print(f" {pid}: {total_gen:.1f}kWh (Month Total)")
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"✅ {updated_count}개 발전소 월간 통계 갱신 완료")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 월간 집계 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# 인자로 날짜 지정 가능: python daily_summary.py 2026-01-22
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
target_date = sys.argv[1]
|
||||||
|
else:
|
||||||
|
# 인자 없으면 '어제' 날짜를 기본값으로 사용
|
||||||
|
# (새벽에 실행하여 전날 데이터를 마감하는 시나리오)
|
||||||
|
yesterday = datetime.now() - timedelta(days=1)
|
||||||
|
target_date = yesterday.strftime('%Y-%m-%d')
|
||||||
|
print(f"ℹ️ 날짜 미지정 -> 어제({target_date}) 기준으로 집계합니다.")
|
||||||
|
|
||||||
|
# 1. 일간 통계 집계
|
||||||
|
success = calculate_daily_stats(target_date)
|
||||||
|
|
||||||
|
# 2. 월말 체크 및 월간 집계 트리거
|
||||||
|
# target_date가 해당 월의 마지막 날이면 월간 집계 실행
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
current_dt = datetime.strptime(target_date, '%Y-%m-%d')
|
||||||
|
import calendar
|
||||||
|
last_day = calendar.monthrange(current_dt.year, current_dt.month)[1]
|
||||||
|
|
||||||
|
if current_dt.day == last_day:
|
||||||
|
target_month = current_dt.strftime('%Y-%m')
|
||||||
|
print(f"\n🔔 월말({target_date}) 감지 -> {target_month} 월간 집계 실행")
|
||||||
|
calculate_monthly_stats(target_month)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 월간 집계 트리거 오류: {e}")
|
||||||
|
|
||||||
345
crawler/database.py
Normal file
345
crawler/database.py
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
# ==========================================
|
||||||
|
# database.py - Supabase 연동
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 환경 변수에서 Supabase 설정 로드
|
||||||
|
SUPABASE_URL = os.getenv('SUPABASE_URL', '')
|
||||||
|
SUPABASE_KEY = os.getenv('SUPABASE_KEY', '')
|
||||||
|
|
||||||
|
print(f"DEBUG: SUPABASE_URL prefix: {SUPABASE_URL[:15] if SUPABASE_URL else 'None'}")
|
||||||
|
|
||||||
|
_supabase_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_supabase_client():
|
||||||
|
"""Supabase 클라이언트 싱글턴 반환"""
|
||||||
|
global _supabase_client
|
||||||
|
|
||||||
|
if _supabase_client is None:
|
||||||
|
if not SUPABASE_URL or not SUPABASE_KEY:
|
||||||
|
print("⚠️ SUPABASE_URL 또는 SUPABASE_KEY가 설정되지 않았습니다.")
|
||||||
|
print(" .env 파일을 확인하거나 환경 변수를 설정하세요.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from supabase import create_client
|
||||||
|
_supabase_client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||||
|
print("✅ Supabase 연결 성공")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ supabase 패키지가 설치되지 않았습니다.")
|
||||||
|
print(" pip install supabase 실행하세요.")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Supabase 연결 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _supabase_client
|
||||||
|
|
||||||
|
def save_to_supabase(data_list):
|
||||||
|
"""
|
||||||
|
수집된 발전 데이터를 Supabase solar_logs 테이블에 저장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_list: [{'id': 'nrems-01', 'name': '...', 'kw': 10.5, 'today': 100.0, 'status': '...'}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 저장 성공 여부
|
||||||
|
"""
|
||||||
|
if not data_list:
|
||||||
|
print("[DB] 저장할 데이터가 없습니다.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client is None:
|
||||||
|
print("[DB 저장 생략] Supabase 연결 없음")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 저장할 레코드 생성
|
||||||
|
records = []
|
||||||
|
for item in data_list:
|
||||||
|
plant_id = item.get('id', '')
|
||||||
|
|
||||||
|
# id가 없는 경우 건너뛰기
|
||||||
|
if not plant_id:
|
||||||
|
print(f" ⚠️ '{item.get('name', 'Unknown')}' ID 없음, 건너뜀")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 한국 시간(KST) 타임스탬프 생성
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
kst_now = datetime.now(kst).isoformat()
|
||||||
|
|
||||||
|
status = item.get('status', '')
|
||||||
|
is_error = '오류' in status # '🔴 오류' 상태 감지
|
||||||
|
|
||||||
|
# [보호] 오류 상태 데이터는 solar_logs에는 기록하되 daily_stats는 건드리지 않음
|
||||||
|
# 단, solar_logs 기록 자체는 이상 이력 추적을 위해 유지
|
||||||
|
record = {
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'current_kw': float(item.get('kw', 0)),
|
||||||
|
'today_kwh': float(item.get('today', 0)),
|
||||||
|
'status': status,
|
||||||
|
'created_at': kst_now # 한국 시간으로 저장
|
||||||
|
}
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print("[DB] 저장할 유효한 레코드가 없습니다.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Supabase에 일괄 삽입 (solar_logs) - 오류 상태 포함 전체 기록
|
||||||
|
result = client.table("solar_logs").insert(records).execute()
|
||||||
|
|
||||||
|
print(f"✅ [DB] Supabase 저장 완료: {len(records)}건 (solar_logs)")
|
||||||
|
|
||||||
|
# daily_stats 테이블 업데이트 (Upsert)
|
||||||
|
# [보호 로직]
|
||||||
|
# 1. 오류 상태(크롤링 실패)인 경우 daily_stats 갱신 금지
|
||||||
|
# 2. today_kwh == 0인 경우 daily_stats 갱신 금지 (새벽 0 값으로 하루치 덮어쓰기 방지)
|
||||||
|
# 3. 야간 시간대(21:00~06:00 KST) daily_stats 갱신 금지 (일몰 이후 잔류값 보호)
|
||||||
|
# 4. DB에 이미 저장된 값보다 작은 경우 갱신 금지 (최댓값 보호)
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
kst_now_dt = datetime.now(kst)
|
||||||
|
kst_date_str = kst_now_dt.strftime("%Y-%m-%d")
|
||||||
|
kst_hour = kst_now_dt.hour
|
||||||
|
|
||||||
|
# 야간 시간대 차단 (21:00 ~ 익일 06:00 KST)
|
||||||
|
is_night = kst_hour >= 21 or kst_hour < 6
|
||||||
|
if is_night:
|
||||||
|
print(f" ⚠️ [야간 차단] KST {kst_hour:02d}시 → daily_stats 갱신 건너뜀 (일몰 후 잔류값 보호)")
|
||||||
|
else:
|
||||||
|
daily_records = []
|
||||||
|
|
||||||
|
# 기존 daily_stats 값 조회 (MAX 보호용)
|
||||||
|
try:
|
||||||
|
existing_res = client.table("daily_stats") \
|
||||||
|
.select("plant_id, total_generation") \
|
||||||
|
.eq("date", kst_date_str) \
|
||||||
|
.execute()
|
||||||
|
existing_map = {row['plant_id']: float(row.get('total_generation') or 0)
|
||||||
|
for row in existing_res.data}
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ [DB] 기존 daily_stats 조회 실패: {e}")
|
||||||
|
existing_map = {}
|
||||||
|
|
||||||
|
for item in data_list:
|
||||||
|
plant_id = item.get('id', '')
|
||||||
|
if not plant_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = item.get('status', '')
|
||||||
|
is_error = '오류' in status
|
||||||
|
today_val = float(item.get('today', 0))
|
||||||
|
|
||||||
|
# 오류 상태이거나 today_kwh가 0이면 daily_stats 갱신 건너뜀
|
||||||
|
if is_error:
|
||||||
|
print(f" ⚠️ [{plant_id}] 오류 상태 → daily_stats 갱신 건너뜀")
|
||||||
|
continue
|
||||||
|
if today_val == 0:
|
||||||
|
print(f" ⚠️ [{plant_id}] today_kwh=0 → daily_stats 갱신 건너뜀 (새벽/야간 추정)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# [MAX 보호] 기존 값보다 작으면 갱신 건너뜀
|
||||||
|
existing_val = existing_map.get(plant_id, 0)
|
||||||
|
if today_val <= existing_val:
|
||||||
|
print(f" ⚠️ [{plant_id}] 신규({today_val:.1f}) ≤ 기존({existing_val:.1f}) → daily_stats 갱신 건너뜀 (최댓값 보호)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
daily_records.append({
|
||||||
|
"plant_id": plant_id,
|
||||||
|
"date": kst_date_str,
|
||||||
|
"total_generation": today_val,
|
||||||
|
"created_at": kst_now
|
||||||
|
# updated_at은 자동으로 NOW()로 설정됨 (DB 기본값)
|
||||||
|
})
|
||||||
|
|
||||||
|
if daily_records:
|
||||||
|
try:
|
||||||
|
stats_result = client.table("daily_stats").upsert(daily_records, on_conflict="plant_id, date").execute()
|
||||||
|
print(f"✅ [DB] daily_stats 업데이트 완료: {len(daily_records)}건")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [DB] daily_stats 업데이트 실패: {e}")
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
print(f" → {r['plant_id']}: {r['current_kw']} kW / {r['today_kwh']} kWh")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [DB] Supabase 저장 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_to_console(data_list):
|
||||||
|
"""콘솔에 데이터 출력"""
|
||||||
|
if not data_list:
|
||||||
|
print("⚠️ 출력할 데이터가 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "=" * 75)
|
||||||
|
print("📊 [실시간 통합 현황판]")
|
||||||
|
print("=" * 75)
|
||||||
|
print(f"{'발전소명':<20} | {'현재출력(kW)':>12} | {'금일발전(kWh)':>12} | {'상태'}")
|
||||||
|
print("-" * 75)
|
||||||
|
|
||||||
|
total_kw = 0
|
||||||
|
total_today = 0
|
||||||
|
|
||||||
|
for d in data_list:
|
||||||
|
name = d.get('name', 'N/A')
|
||||||
|
kw = d.get('kw', 0)
|
||||||
|
today = d.get('today', 0)
|
||||||
|
status = d.get('status', '')
|
||||||
|
|
||||||
|
total_kw += kw
|
||||||
|
total_today += today
|
||||||
|
|
||||||
|
print(f"{name:<20} | {kw:>12.2f} | {today:>12.2f} | {status}")
|
||||||
|
|
||||||
|
print("-" * 75)
|
||||||
|
print(f"{'합계':<20} | {total_kw:>12.2f} | {total_today:>12.2f} |")
|
||||||
|
print("=" * 75)
|
||||||
|
|
||||||
|
def save_history(data_list, data_type='hourly'):
|
||||||
|
"""
|
||||||
|
과거 데이터 저장 (Hourly, Daily, Monthly)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_list: 데이터 리스트
|
||||||
|
data_type: 'hourly', 'daily', 'monthly'
|
||||||
|
"""
|
||||||
|
if not data_list:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
table_name = ""
|
||||||
|
records = []
|
||||||
|
|
||||||
|
if data_type == 'hourly':
|
||||||
|
table_name = "solar_logs"
|
||||||
|
for item in data_list:
|
||||||
|
# hourly 데이터는 timestamp 키를 가짐
|
||||||
|
ts = item.get('timestamp')
|
||||||
|
if ts:
|
||||||
|
ts_iso = ts.replace(' ', 'T')
|
||||||
|
# Check if future (simple string comparison works for ISO format if consistent, but datetime is safer)
|
||||||
|
# KST aware comparison
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
now_kst = datetime.now(kst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ts example: 2026-01-27 14:00:00. Assume input is local time (KST)
|
||||||
|
# We convert it to aware datetime
|
||||||
|
dt_ts = datetime.fromisoformat(ts_iso)
|
||||||
|
if dt_ts.tzinfo is None:
|
||||||
|
dt_ts = dt_ts.replace(tzinfo=kst)
|
||||||
|
|
||||||
|
if dt_ts > now_kst:
|
||||||
|
continue # Skip future data
|
||||||
|
except ValueError:
|
||||||
|
pass # robust date parsing needed if format varies
|
||||||
|
|
||||||
|
# Ensure timezone is sent to Supabase to prevent UTC assumption
|
||||||
|
final_created_at = dt_ts.isoformat()
|
||||||
|
|
||||||
|
if item.get('current_kw') is not None:
|
||||||
|
current_kw = float(item['current_kw'])
|
||||||
|
else:
|
||||||
|
current_kw = float(item.get('generation_kwh', 0))
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
'plant_id': item['plant_id'],
|
||||||
|
'created_at': final_created_at,
|
||||||
|
'current_kw': current_kw,
|
||||||
|
'today_kwh': float(item.get('generation_kwh', 0)),
|
||||||
|
'status': 'History'
|
||||||
|
})
|
||||||
|
|
||||||
|
elif data_type == 'daily':
|
||||||
|
table_name = "daily_stats"
|
||||||
|
for item in data_list:
|
||||||
|
records.append({
|
||||||
|
'plant_id': item['plant_id'],
|
||||||
|
'date': item['date'],
|
||||||
|
'total_generation': float(item.get('generation_kwh', 0))
|
||||||
|
# 'updated_at': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
elif data_type == 'monthly':
|
||||||
|
table_name = "monthly_stats"
|
||||||
|
for item in data_list:
|
||||||
|
records.append({
|
||||||
|
'plant_id': item['plant_id'],
|
||||||
|
'month': item['month'], # YYYY-MM
|
||||||
|
'total_generation': float(item.get('generation_kwh', 0)),
|
||||||
|
'updated_at': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# upsert 사용
|
||||||
|
if data_type == 'hourly':
|
||||||
|
client.table(table_name).insert(records).execute()
|
||||||
|
elif data_type == 'daily':
|
||||||
|
client.table(table_name).upsert(records, on_conflict="plant_id, date").execute()
|
||||||
|
|
||||||
|
# [Auto Update] Daily 데이터 저장 시 Monthly 통계 자동 갱신
|
||||||
|
# 1. 업데이트된 월 목록 추출
|
||||||
|
updated_months = set()
|
||||||
|
for rec in records:
|
||||||
|
try:
|
||||||
|
# date: YYYY-MM-DD
|
||||||
|
month_key = rec['date'][:7]
|
||||||
|
updated_months.add((rec['plant_id'], month_key))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if updated_months:
|
||||||
|
monthly_upserts = []
|
||||||
|
for (pid, m_key) in updated_months:
|
||||||
|
# 2. 해당 월의 Daily 합계 조회 (DB Aggregation)
|
||||||
|
import calendar
|
||||||
|
try:
|
||||||
|
year, month_int = map(int, m_key.split('-'))
|
||||||
|
_, last_day = calendar.monthrange(year, month_int)
|
||||||
|
except:
|
||||||
|
last_day = 31
|
||||||
|
|
||||||
|
d_res = client.table("daily_stats").select("total_generation") \
|
||||||
|
.eq("plant_id", pid) \
|
||||||
|
.gte("date", f"{m_key}-01") \
|
||||||
|
.lte("date", f"{m_key}-{last_day}") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
total_gen = sum(r['total_generation'] or 0 for r in d_res.data)
|
||||||
|
|
||||||
|
monthly_upserts.append({
|
||||||
|
"plant_id": pid,
|
||||||
|
"month": m_key,
|
||||||
|
"total_generation": round(total_gen, 2),
|
||||||
|
"updated_at": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Monthly Upsert
|
||||||
|
if monthly_upserts:
|
||||||
|
client.table("monthly_stats").upsert(monthly_upserts, on_conflict="plant_id, month").execute()
|
||||||
|
print(f" 🔄 [Sync] {len(monthly_upserts)}개월치 Monthly Stats 자동 갱신 완료")
|
||||||
|
|
||||||
|
elif data_type == 'monthly':
|
||||||
|
client.table(table_name).upsert(records, on_conflict="plant_id, month").execute()
|
||||||
|
|
||||||
|
print(f"✅ [History] {data_type} 데이터 {len(records)}건 저장 완료")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [History] 저장 실패 ({data_type}): {e}")
|
||||||
|
return False
|
||||||
138
crawler/fetch_history.py
Normal file
138
crawler/fetch_history.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Windows 인코딩 문제 해결
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
# 프로젝트 루트 경로 추가
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from database import save_history
|
||||||
|
|
||||||
|
def get_plant_config(target_id):
|
||||||
|
plants = get_all_plants()
|
||||||
|
for p in plants:
|
||||||
|
# 일반 매칭
|
||||||
|
if p.get('id') == target_id:
|
||||||
|
return p
|
||||||
|
|
||||||
|
# NREMS 분리 세대 매칭 (nrems-01, nrems-02)
|
||||||
|
if p.get('options', {}).get('is_split'):
|
||||||
|
if target_id == 'nrems-01':
|
||||||
|
p['id'] = 'nrems-01'
|
||||||
|
p['options']['split_index'] = 1
|
||||||
|
return p
|
||||||
|
elif target_id == 'nrems-02':
|
||||||
|
p['id'] = 'nrems-02'
|
||||||
|
p['options']['split_index'] = 2
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_and_save(plant_config):
|
||||||
|
plant_id = plant_config['id']
|
||||||
|
plant_type = plant_config['type']
|
||||||
|
plant_name = plant_config['name']
|
||||||
|
start_date_str = plant_config.get('start_date', '2020-01-01')
|
||||||
|
|
||||||
|
print(f"🚀 [{plant_name}] 과거 데이터 수집 시작 ({plant_id})")
|
||||||
|
print(f" 타입: {plant_type}, 가동개시일: {start_date_str}")
|
||||||
|
|
||||||
|
# 크롤러 모듈 동적 임포트
|
||||||
|
try:
|
||||||
|
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
|
||||||
|
except ImportError:
|
||||||
|
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
today_str = now.strftime("%Y-%m-%d")
|
||||||
|
current_year = now.year
|
||||||
|
current_month = now.month
|
||||||
|
|
||||||
|
# 1. 시간별 데이터 (Hourly): 이번 달 1일 ~ 오늘
|
||||||
|
# (역순으로 가져오라고 했지만, 크롤러는 start->end로 동작하므로 범위로 호출)
|
||||||
|
try:
|
||||||
|
h_start = now.replace(day=1).strftime("%Y-%m-%d")
|
||||||
|
h_end = today_str
|
||||||
|
print(f"\n⏳ [Hourly] 수집 : {h_start} ~ {h_end}")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_hourly'):
|
||||||
|
hourly_data = crawler_module.fetch_history_hourly(plant_config, h_start, h_end)
|
||||||
|
if hourly_data:
|
||||||
|
save_history(hourly_data, 'hourly')
|
||||||
|
else:
|
||||||
|
print(" 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" {plant_type}는 시간별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Hourly] 에러: {e}")
|
||||||
|
|
||||||
|
# 2. 일별 데이터 (Daily): 발전소 가동일 ~ 오늘
|
||||||
|
# API 서버가 daily_stats를 집계하여 월/년 통계를 보여주므로, daily 데이터를 전체 기간 수집해야 함.
|
||||||
|
try:
|
||||||
|
# d_start = f"{current_year}-01-01"
|
||||||
|
d_start = start_date_str # 가동 시작일부터 수집
|
||||||
|
d_end = today_str
|
||||||
|
print(f"\n⏳ [Daily] 수집 : {d_start} ~ {d_end}")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_daily'):
|
||||||
|
daily_data = crawler_module.fetch_history_daily(plant_config, d_start, d_end)
|
||||||
|
if daily_data:
|
||||||
|
save_history(daily_data, 'daily')
|
||||||
|
else:
|
||||||
|
print(" 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" {plant_type}는 일별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Daily] 에러: {e}")
|
||||||
|
|
||||||
|
# 3. 월별 데이터 (Monthly): 사용 안함 (API가 daily_stats 집계 사용)
|
||||||
|
# try:
|
||||||
|
# m_start_dt = datetime.strptime(start_date_str, "%Y-%m-%d")
|
||||||
|
# m_start = m_start_dt.strftime("%Y-%m")
|
||||||
|
# m_end = now.strftime("%Y-%m")
|
||||||
|
# print(f"\n⏳ [Monthly] 수집 : {m_start} ~ {m_end}")
|
||||||
|
#
|
||||||
|
# if hasattr(crawler_module, 'fetch_history_monthly'):
|
||||||
|
# monthly_data = crawler_module.fetch_history_monthly(plant_config, m_start, m_end)
|
||||||
|
# if monthly_data:
|
||||||
|
# save_history(monthly_data, 'monthly')
|
||||||
|
# else:
|
||||||
|
# print(" 데이터 없음")
|
||||||
|
# else:
|
||||||
|
# print(f" {plant_type}는 월별 이력 수집을 지원하지 않음")
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"❌ [Monthly] 에러: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Monthly] 에러: {e}")
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 모든 작업 완료")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python fetch_history.py <plant_id>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_plant_id = sys.argv[1]
|
||||||
|
cfg = get_plant_config(target_plant_id)
|
||||||
|
|
||||||
|
if cfg:
|
||||||
|
fetch_and_save(cfg)
|
||||||
|
else:
|
||||||
|
print(f"❌ 설정을 찾을 수 없습니다: {target_plant_id}")
|
||||||
203
crawler/main.py
Normal file
203
crawler/main.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
# ==========================================
|
||||||
|
# main.py - 태양광 발전 통합 관제 시스템
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# 환경 변수 로드 (최상단에서 실행)
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
print("✅ 환경 변수 로드 완료")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ python-dotenv가 설치되지 않았습니다. 환경 변수를 직접 설정하세요.")
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from database import save_to_supabase, save_to_console
|
||||||
|
from crawlers import get_crawler
|
||||||
|
from crawler_manager import CrawlerManager
|
||||||
|
from alert_manager import AlertManager
|
||||||
|
|
||||||
|
# 스마트 스케줄러 초기화
|
||||||
|
crawler_manager = CrawlerManager()
|
||||||
|
|
||||||
|
def extract_unit_number(name):
|
||||||
|
"""발전소 이름에서 호기 번호 추출 (정렬용)"""
|
||||||
|
match = re.search(r'(\d+)호기', name)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return 999
|
||||||
|
|
||||||
|
def integrated_monitoring(save_to_db=True, company_filter=None, force_run=False):
|
||||||
|
"""
|
||||||
|
통합 모니터링 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
save_to_db: True면 Supabase에 저장
|
||||||
|
company_filter: 특정 업체만 필터링 (예: 'sunwind')
|
||||||
|
force_run: True면 스케줄러 무시하고 강제 실행
|
||||||
|
"""
|
||||||
|
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
print(f"\n🚀 [통합 관제 시스템] 데이터 수집 시작... ({now_str})")
|
||||||
|
print("-" * 75)
|
||||||
|
|
||||||
|
# 평탄화된 발전소 목록 가져오기
|
||||||
|
all_plants = get_all_plants()
|
||||||
|
|
||||||
|
# 업체 필터링 (옵션)
|
||||||
|
if company_filter:
|
||||||
|
all_plants = [p for p in all_plants if p['company_id'] == company_filter]
|
||||||
|
print(f"📌 필터 적용: {company_filter}")
|
||||||
|
|
||||||
|
total_results = []
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
# 알림 매니저 초기화
|
||||||
|
alert_manager = AlertManager()
|
||||||
|
|
||||||
|
for plant in all_plants:
|
||||||
|
plant_type = plant['type']
|
||||||
|
plant_name = plant.get('display_name', plant.get('name', 'Unknown'))
|
||||||
|
company_id = plant.get('company_id', '')
|
||||||
|
company_name = plant.get('company_name', '')
|
||||||
|
|
||||||
|
# 크롤링 결과에서 생성되는 site_id 목록 (1,2호기 분리 처리 고려)
|
||||||
|
is_split = plant.get('options', {}).get('is_split', False)
|
||||||
|
if is_split:
|
||||||
|
site_ids = ['nrems-01', 'nrems-02']
|
||||||
|
else:
|
||||||
|
site_ids = [plant.get('id', '')]
|
||||||
|
|
||||||
|
# 야간 시간대 체크 (force_run이 아닌 경우)
|
||||||
|
if not force_run:
|
||||||
|
# 대표 site_id 하나로 야간 여부 확인 (모든 사이트 동일 조건)
|
||||||
|
representative_id = site_ids[0] if site_ids else ''
|
||||||
|
if representative_id and not crawler_manager.should_run(representative_id):
|
||||||
|
print(f" ⏭️ [{plant_type.upper()}] {plant_name} 스킵 (야간 시간대)")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"📡 [{plant_type.upper()}] {company_name} - {plant_name} 수집 중...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
crawler_func = get_crawler(plant_type)
|
||||||
|
if crawler_func:
|
||||||
|
data = crawler_func(plant)
|
||||||
|
if data:
|
||||||
|
# company_id, company_name 주입 + 변경 여부 판단
|
||||||
|
for item in data:
|
||||||
|
item['company_id'] = company_id
|
||||||
|
item['company_name'] = company_name
|
||||||
|
item['_data_changed'] = False # 기본값: 저장 안 함
|
||||||
|
|
||||||
|
item_id = item.get('id', '')
|
||||||
|
|
||||||
|
# 알림은 항상 체크 (0kW 감지 목적)
|
||||||
|
alert_info = plant.copy()
|
||||||
|
alert_info['id'] = item_id
|
||||||
|
alert_info['name'] = item.get('name', plant_name)
|
||||||
|
alert_manager.check_and_alert(alert_info, item.get('kw', 0))
|
||||||
|
|
||||||
|
if item_id:
|
||||||
|
# 크롤링 성공 기록 (항상)
|
||||||
|
crawler_manager.record_run(item_id)
|
||||||
|
|
||||||
|
# 데이터 변경 여부 확인
|
||||||
|
# → 원격 서버가 실제로 업데이트했는지 감지
|
||||||
|
# → True면 DB 저장 대상 / False면 중복 저장 방지
|
||||||
|
if crawler_manager.check_data_change(item_id, item):
|
||||||
|
crawler_manager.analyze_and_optimize(item_id)
|
||||||
|
item['_data_changed'] = True
|
||||||
|
else:
|
||||||
|
print(f" ⏸️ [{item_id}] 데이터 변경 없음, DB 저장 스킵")
|
||||||
|
|
||||||
|
# 변경된 항목만 DB 저장 대상에 포함
|
||||||
|
for item in data:
|
||||||
|
if item.pop('_data_changed', False):
|
||||||
|
total_results.append(item)
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 알 수 없는 크롤러 타입: {plant_type}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {plant_name} 실패: {e}")
|
||||||
|
|
||||||
|
# 정렬 (호기 번호 순)
|
||||||
|
total_results.sort(key=lambda x: extract_unit_number(x['name']))
|
||||||
|
|
||||||
|
# 중복 제거 (company_id + id 조합)
|
||||||
|
seen_keys = set()
|
||||||
|
unique_results = []
|
||||||
|
for item in total_results:
|
||||||
|
unique_key = f"{item.get('company_id', '')}_{item.get('id', '')}"
|
||||||
|
if unique_key not in seen_keys:
|
||||||
|
seen_keys.add(unique_key)
|
||||||
|
unique_results.append(item)
|
||||||
|
total_results = unique_results
|
||||||
|
|
||||||
|
print("-" * 75)
|
||||||
|
|
||||||
|
if skipped_count > 0:
|
||||||
|
print(f"📊 스킵된 사이트: {skipped_count}개 (야간 시간대)")
|
||||||
|
|
||||||
|
if total_results:
|
||||||
|
# 콘솔 출력
|
||||||
|
save_to_console(total_results)
|
||||||
|
|
||||||
|
# DB 저장
|
||||||
|
if save_to_db:
|
||||||
|
save_to_supabase(total_results)
|
||||||
|
|
||||||
|
# 이상 감지 로직
|
||||||
|
current_hour = datetime.now().hour
|
||||||
|
if 10 <= current_hour <= 17:
|
||||||
|
issues = [d['name'] for d in total_results if d.get('kw', 0) == 0]
|
||||||
|
if issues:
|
||||||
|
print("\n🚨 [이상 감지 리포트]")
|
||||||
|
for name in issues:
|
||||||
|
print(f" ⚠️ 경고: '{name}' 발전량이 0입니다! 확인 필요.")
|
||||||
|
else:
|
||||||
|
print("\n ✅ 현재 모든 발전소가 정상 가동 중입니다.")
|
||||||
|
else:
|
||||||
|
print("❌ 수집된 데이터가 없습니다.")
|
||||||
|
|
||||||
|
return total_results
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily_close(force=False):
|
||||||
|
"""
|
||||||
|
일일 마감 집계 실행 (KST 21:00~21:10 자동 트리거 또는 force=True)
|
||||||
|
solar_logs 데이터를 집계하여 daily_stats에 당일 최종값을 확정합니다.
|
||||||
|
"""
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
kst_now = datetime.now(kst)
|
||||||
|
kst_hour = kst_now.hour
|
||||||
|
kst_minute = kst_now.minute
|
||||||
|
|
||||||
|
is_close_window = (kst_hour == 21 and kst_minute < 10)
|
||||||
|
|
||||||
|
if force or is_close_window:
|
||||||
|
date_str = kst_now.strftime('%Y-%m-%d')
|
||||||
|
print(f"\n🌙 [KST {kst_hour:02d}:{kst_minute:02d}] 일일 마감 집계 트리거 → {date_str} 통계 확정")
|
||||||
|
try:
|
||||||
|
from daily_summary import calculate_daily_stats
|
||||||
|
calculate_daily_stats(date_str)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 마감 집계 실패: {e}")
|
||||||
|
else:
|
||||||
|
print(f" ℹ️ 마감 집계 스킵 (KST {kst_hour:02d}시 {kst_minute:02d}분, 대상 시간대 아님)")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 인자 처리: --force 옵션으로 스케줄러 무시
|
||||||
|
force_run = '--force' in sys.argv or '-f' in sys.argv
|
||||||
|
force_close = '--close' in sys.argv # 마감 집계 강제 실행
|
||||||
|
|
||||||
|
if force_run:
|
||||||
|
print("⚡ [강제 실행 모드] 스케줄러 무시하고 모든 사이트 크롤링")
|
||||||
|
|
||||||
|
integrated_monitoring(save_to_db=True, force_run=force_run)
|
||||||
|
|
||||||
|
# 마감 집계: 21:00~21:10 KST 자동 실행 또는 --close 옵션
|
||||||
|
run_daily_close(force=force_close)
|
||||||
|
|
||||||
139
crawler/scripts_archive/README.md
Normal file
139
crawler/scripts_archive/README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Scripts Archive - 2월 데이터 패치
|
||||||
|
|
||||||
|
## 📅 작업 일시
|
||||||
|
2026년 2월 27일
|
||||||
|
|
||||||
|
## 🎯 작업 목적
|
||||||
|
5호기(kremc-05)와 9호기(nrems-09)의 2월 데이터를 Supabase DB에 완전히 크롤링하고 저장
|
||||||
|
|
||||||
|
## ⚠️ 발견된 문제
|
||||||
|
1. **중복 저장**: 시간별 데이터가 5~6배 중복 저장됨
|
||||||
|
2. **current_kw = 0**: 과거 데이터의 current_kw가 0으로 저장되어 웹 차트가 안 나옴
|
||||||
|
|
||||||
|
## ✅ 완료된 작업
|
||||||
|
|
||||||
|
### 1. 데이터 크롤링 및 저장
|
||||||
|
- **대상**: 5호기, 9호기
|
||||||
|
- **기간**: 2026년 2월 1일 ~ 2월 27일
|
||||||
|
- **데이터 유형**: 시간별(hourly), 일별(daily), 월별(monthly)
|
||||||
|
|
||||||
|
### 2. 중복 데이터 정리
|
||||||
|
- 시간별 데이터가 중복 저장된 문제 발견 및 해결
|
||||||
|
- 각 시간대별로 최신 레코드만 유지하도록 정리
|
||||||
|
- **5호기**: 2,949건 중복 제거
|
||||||
|
- **9호기**: 2,839건 중복 제거
|
||||||
|
|
||||||
|
### 3. current_kw 업데이트 문제 해결
|
||||||
|
- **문제**: 과거 데이터의 current_kw가 0으로 저장되어 웹 차트가 표시되지 않음
|
||||||
|
- **원인**: 과거 데이터 크롤링 시 current_kw 필드가 0으로 저장됨
|
||||||
|
- **해결**: current_kw를 today_kwh(시간별 발전량) 값으로 업데이트
|
||||||
|
- **5호기**: 263건 업데이트
|
||||||
|
- **9호기**: 308건 업데이트
|
||||||
|
|
||||||
|
### 4. 최종 결과
|
||||||
|
#### 5호기 (kremc-05)
|
||||||
|
- ✅ 시간별 데이터: 646건
|
||||||
|
- ✅ 일별 데이터: 27건 (2/1~2/27)
|
||||||
|
- ✅ 2월 총 발전량: 3,702 kWh
|
||||||
|
- ✅ 일평균: 137.11 kWh
|
||||||
|
- ✅ 월별 통계: 자동 갱신 완료
|
||||||
|
|
||||||
|
#### 9호기 (nrems-09)
|
||||||
|
- ✅ 시간별 데이터: 646건
|
||||||
|
- ✅ 일별 데이터: 27건 (2/1~2/27)
|
||||||
|
- ✅ 2월 총 발전량: 9,230 kWh
|
||||||
|
- ✅ 일평균: 341.85 kWh
|
||||||
|
- ✅ 월별 통계: 자동 갱신 완료
|
||||||
|
|
||||||
|
## 📁 아카이브된 스크립트
|
||||||
|
|
||||||
|
### 1. `fetch_february.py`
|
||||||
|
- **목적**: 5호기와 9호기의 2월 전체 데이터 크롤링
|
||||||
|
- **기능**:
|
||||||
|
- 시간별 데이터 수집 (2/1~2/27)
|
||||||
|
- 일별 데이터 수집 (2/1~2/27)
|
||||||
|
- Supabase DB 저장
|
||||||
|
|
||||||
|
### 2. `verify_february_data.py`
|
||||||
|
- **목적**: Supabase DB에 저장된 2월 데이터 검증
|
||||||
|
- **기능**:
|
||||||
|
- 시간별/일별/월별 데이터 개수 확인
|
||||||
|
- 발전량 통계 집계
|
||||||
|
- 샘플 데이터 출력
|
||||||
|
|
||||||
|
### 3. `check_feb_gaps.py`
|
||||||
|
- **목적**: 2월 시간별 데이터의 날짜별 누락 확인
|
||||||
|
- **기능**:
|
||||||
|
- 2월 1일~27일 각 날짜의 시간별 데이터 개수 확인
|
||||||
|
- 완전 누락/부분 누락 날짜 보고
|
||||||
|
|
||||||
|
### 4. `clean_feb_duplicates.py`
|
||||||
|
- **목적**: 중복 저장된 시간별 데이터 정리
|
||||||
|
- **기능**:
|
||||||
|
- 같은 plant_id와 시간대의 중복 레코드 탐지
|
||||||
|
- 가장 최신 레코드만 유지, 나머지 삭제
|
||||||
|
- 날짜별 중복 제거 현황 출력
|
||||||
|
|
||||||
|
### 5. `fill_today_feb.py`
|
||||||
|
- **목적**: 2월 27일(오늘) 누락 시간대 보완
|
||||||
|
- **기능**:
|
||||||
|
- 현재 DB에 있는 시간대 확인
|
||||||
|
- 누락된 시간대만 추가 크롤링
|
||||||
|
- 일별 통계 업데이트
|
||||||
|
|
||||||
|
### 6. `check_current_kw.py`
|
||||||
|
- **목적**: DB에 저장된 시간별 데이터의 current_kw 값 확인
|
||||||
|
- **기능**:
|
||||||
|
- 특정 날짜의 시간별 데이터 조회
|
||||||
|
- current_kw와 today_kwh 값 비교
|
||||||
|
- current_kw=0인 레코드 개수 통계
|
||||||
|
|
||||||
|
### 7. `update_current_kw.py`
|
||||||
|
- **목적**: 2월 데이터의 current_kw를 today_kwh로 업데이트
|
||||||
|
- **기능**:
|
||||||
|
- current_kw가 0이고 today_kwh가 0이 아닌 레코드 탐지
|
||||||
|
- current_kw를 today_kwh 값으로 업데이트
|
||||||
|
- 날짜별 업데이트 현황 출력
|
||||||
|
- **배경**: 과거 데이터 크롤링 시 current_kw가 0으로 저장되어 웹 차트가 안 나오는 문제 해결
|
||||||
|
|
||||||
|
### 8. `test_api.py`
|
||||||
|
- **목적**: API 엔드포인트 호출 테스트
|
||||||
|
- **기능**:
|
||||||
|
- /plants/{plant_id}/stats/today 엔드포인트 테스트
|
||||||
|
- 시간별 데이터 응답 확인
|
||||||
|
- current_kw와 today_kwh 값 출력
|
||||||
|
|
||||||
|
### 9. `verify_feb_final.py`
|
||||||
|
- **목적**: 2월 데이터 최종 검증 (간단 버전)
|
||||||
|
- **기능**:
|
||||||
|
- 시간별/일별/월별 데이터 개수 확인
|
||||||
|
- 발전량 통계 요약
|
||||||
|
- DB 저장 상태 최종 확인
|
||||||
|
|
||||||
|
## 🔧 사용 방법
|
||||||
|
|
||||||
|
모든 스크립트는 crawler 가상환경에서 실행:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd d:\dev\etc\SolorPower\crawler
|
||||||
|
.\venv_win\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
# 스크립트 실행 예시
|
||||||
|
python scripts_archive/fetch_february.py
|
||||||
|
python scripts_archive/verify_february_data.py
|
||||||
|
python scripts_archive/check_feb_gaps.py
|
||||||
|
python scripts_archive/clean_feb_duplicates.py
|
||||||
|
python scripts_archive/fill_today_feb.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 참고사항
|
||||||
|
|
||||||
|
- 이 스크립트들은 일회성 패치 작업용입니다
|
||||||
|
- 정규 크롤링은 `main.py`와 `crawler_manager.py`를 사용하세요
|
||||||
|
- 유사한 데이터 패치 작업이 필요할 경우 이 스크립트들을 참고하여 수정 가능
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
- `clean_feb_duplicates.py`는 데이터를 삭제하므로 신중히 사용
|
||||||
|
- 중복 제거 전 반드시 DB 백업 권장
|
||||||
|
- 시간대 필터링 시 KST(UTC+9) 타임존 고려 필요
|
||||||
89
crawler/scripts_archive/check_current_kw.py
Normal file
89
crawler/scripts_archive/check_current_kw.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
DB에 저장된 시간별 데이터의 current_kw 값 확인
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def check_current_kw(plant_id, plant_name, date_str):
|
||||||
|
"""특정 날짜의 시간별 데이터 current_kw 값 확인"""
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🔍 [{plant_name}] {date_str} 시간별 데이터 확인")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 해당 날짜의 시간별 데이터 조회
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("created_at, current_kw, today_kwh") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{date_str}T00:00:00+09:00") \
|
||||||
|
.lt("created_at", f"{date_str}T23:59:59+09:00") \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.limit(30) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not result.data:
|
||||||
|
print(" ❌ 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" 총 {len(result.data)}건 (최대 30건만 표시)\n")
|
||||||
|
print(f" {'시간':<20} | {'current_kw':>12} | {'today_kwh':>12}")
|
||||||
|
print(f" {'-'*20}+{'-'*14}+{'-'*14}")
|
||||||
|
|
||||||
|
current_kw_zero_count = 0
|
||||||
|
current_kw_nonzero_count = 0
|
||||||
|
|
||||||
|
for record in result.data:
|
||||||
|
created_at = record['created_at']
|
||||||
|
current_kw = record.get('current_kw', 0) or 0
|
||||||
|
today_kwh = record.get('today_kwh', 0) or 0
|
||||||
|
|
||||||
|
if current_kw == 0:
|
||||||
|
current_kw_zero_count += 1
|
||||||
|
else:
|
||||||
|
current_kw_nonzero_count += 1
|
||||||
|
|
||||||
|
print(f" {created_at:<20} | {current_kw:>12.2f} | {today_kwh:>12.2f}")
|
||||||
|
|
||||||
|
print(f"\n 📊 통계:")
|
||||||
|
print(f" current_kw = 0: {current_kw_zero_count}건")
|
||||||
|
print(f" current_kw ≠ 0: {current_kw_nonzero_count}건")
|
||||||
|
|
||||||
|
if current_kw_zero_count == len(result.data):
|
||||||
|
print(f"\n ⚠️ 모든 current_kw 값이 0입니다!")
|
||||||
|
print(f" ⚠️ 과거 데이터는 current_kw 대신 today_kwh(시간별 발전량)가 저장됩니다.")
|
||||||
|
print(f" ⚠️ 차트는 today_kwh를 사용해야 합니다.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
dates = ['2026-02-25', '2026-02-01']
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
for date_str in dates:
|
||||||
|
check_current_kw(plant_id, plant_name, date_str)
|
||||||
|
|
||||||
|
print(f"\n{'='*70}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
91
crawler/scripts_archive/check_feb_gaps.py
Normal file
91
crawler/scripts_archive/check_feb_gaps.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""
|
||||||
|
2월 데이터 누락 확인 스크립트
|
||||||
|
정확히 어느 날짜의 데이터가 누락되었는지 확인
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def check_gaps(plant_id, plant_name):
|
||||||
|
"""날짜별 시간별 데이터 누락 확인"""
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🔍 [{plant_name}] 2월 시간별 데이터 누락 확인")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2월 1일부터 27일까지 확인
|
||||||
|
start = datetime(2026, 2, 1)
|
||||||
|
end = datetime(2026, 2, 27)
|
||||||
|
|
||||||
|
current = start
|
||||||
|
missing_dates = []
|
||||||
|
partial_dates = []
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 해당 날짜의 시간별 데이터 개수 확인
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("*", count='exact') \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{date_str}T00:00:00+09:00") \
|
||||||
|
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+09:00") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
count = result.count if hasattr(result, 'count') else len(result.data)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
missing_dates.append(date_str)
|
||||||
|
print(f" ❌ {date_str}: 데이터 없음")
|
||||||
|
elif count < 24:
|
||||||
|
partial_dates.append((date_str, count))
|
||||||
|
print(f" ⚠️ {date_str}: {count}건 (불완전)")
|
||||||
|
else:
|
||||||
|
print(f" ✅ {date_str}: {count}건")
|
||||||
|
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n📊 요약:")
|
||||||
|
print(f" 완전 누락: {len(missing_dates)}일")
|
||||||
|
print(f" 부분 누락: {len(partial_dates)}일")
|
||||||
|
|
||||||
|
if missing_dates:
|
||||||
|
print(f"\n 누락된 날짜:")
|
||||||
|
for d in missing_dates:
|
||||||
|
print(f" - {d}")
|
||||||
|
|
||||||
|
if partial_dates:
|
||||||
|
print(f"\n 부분 누락된 날짜:")
|
||||||
|
for d, c in partial_dates:
|
||||||
|
print(f" - {d}: {c}/24건")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
check_gaps(plant_id, plant_name)
|
||||||
|
|
||||||
|
print(f"\n{'='*70}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
108
crawler/scripts_archive/clean_feb_duplicates.py
Normal file
108
crawler/scripts_archive/clean_feb_duplicates.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""
|
||||||
|
2월 시간별 데이터 중복 제거 스크립트
|
||||||
|
같은 plant_id와 시간대에 중복된 데이터를 정리
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def clean_duplicates(plant_id, plant_name):
|
||||||
|
"""중복 데이터 제거 - 같은 시간대에 가장 최신 레코드만 유지"""
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🧹 [{plant_name}] 중복 데이터 정리 중...")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2월 1일부터 27일까지
|
||||||
|
start = datetime(2026, 2, 1)
|
||||||
|
end = datetime(2026, 2, 27)
|
||||||
|
|
||||||
|
total_deleted = 0
|
||||||
|
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 해당 날짜의 모든 시간별 데이터 가져오기
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("*") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{date_str}T00:00:00+09:00") \
|
||||||
|
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+09:00") \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not result.data:
|
||||||
|
current += timedelta(days=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 시간대별로 그룹화 (created_at의 시간 부분으로)
|
||||||
|
hour_groups = {}
|
||||||
|
for record in result.data:
|
||||||
|
# created_at에서 날짜+시간만 추출 (분/초 제거)
|
||||||
|
ts = record['created_at']
|
||||||
|
hour_key = ts[:13] # 2026-02-01T00 형식
|
||||||
|
|
||||||
|
if hour_key not in hour_groups:
|
||||||
|
hour_groups[hour_key] = []
|
||||||
|
hour_groups[hour_key].append(record)
|
||||||
|
|
||||||
|
# 각 시간대별로 중복 제거 (가장 최근 id만 유지)
|
||||||
|
deleted_count = 0
|
||||||
|
for hour_key, records in hour_groups.items():
|
||||||
|
if len(records) > 1:
|
||||||
|
# id 기준으로 정렬 (가장 큰 id가 최신)
|
||||||
|
records.sort(key=lambda x: x['id'], reverse=True)
|
||||||
|
|
||||||
|
# 첫 번째(최신)를 제외한 나머지 삭제
|
||||||
|
for old_record in records[1:]:
|
||||||
|
try:
|
||||||
|
client.table("solar_logs").delete().eq("id", old_record['id']).execute()
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 삭제 실패 (id: {old_record['id']}): {e}")
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
print(f" 🧹 {date_str}: {deleted_count}건 중복 제거 (남은 시간대: {len(hour_groups)}개)")
|
||||||
|
|
||||||
|
total_deleted += deleted_count
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 총 {total_deleted}건 중복 제거 완료")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🧹 2월 시간별 데이터 중복 제거 시작")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
clean_duplicates(plant_id, plant_name)
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🎉 중복 제거 완료!")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
135
crawler/scripts_archive/fetch_february.py
Normal file
135
crawler/scripts_archive/fetch_february.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
2월 데이터 크롤링 스크립트
|
||||||
|
5호기(kremc-05), 9호기(nrems-09)의 2월 일별/시간별 데이터를 수집합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Windows 인코딩 문제 해결
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
# 프로젝트 루트 경로 추가
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from database import save_history
|
||||||
|
|
||||||
|
def get_plant_config(target_id):
|
||||||
|
"""플랜트 설정 가져오기"""
|
||||||
|
plants = get_all_plants()
|
||||||
|
for p in plants:
|
||||||
|
if p.get('id') == target_id:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_february_data(plant_config):
|
||||||
|
"""2월 데이터 수집"""
|
||||||
|
plant_id = plant_config['id']
|
||||||
|
plant_type = plant_config['type']
|
||||||
|
plant_name = plant_config['name']
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"🚀 [{plant_name}] 2월 데이터 수집 시작 ({plant_id})")
|
||||||
|
print(f" 타입: {plant_type}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 크롤러 모듈 동적 임포트
|
||||||
|
try:
|
||||||
|
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
|
||||||
|
except ImportError:
|
||||||
|
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2월 데이터 범위 설정
|
||||||
|
now = datetime.now()
|
||||||
|
year = now.year
|
||||||
|
|
||||||
|
# 2월 1일부터 오늘까지 (또는 2월 말일까지)
|
||||||
|
start_date = f"{year}-02-01"
|
||||||
|
# 현재가 2월이면 오늘까지, 3월 이후면 2월 마지막 날까지
|
||||||
|
if now.month == 2:
|
||||||
|
end_date = now.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
# 2월 마지막 날 (윤년 고려)
|
||||||
|
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||||
|
end_date = f"{year}-02-29"
|
||||||
|
else:
|
||||||
|
end_date = f"{year}-02-28"
|
||||||
|
|
||||||
|
print(f"\n📅 수집 기간: {start_date} ~ {end_date}")
|
||||||
|
|
||||||
|
# 1. 시간별 데이터 수집
|
||||||
|
try:
|
||||||
|
print(f"\n⏳ [Hourly] 시간별 데이터 수집 중...")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_hourly'):
|
||||||
|
hourly_data = crawler_module.fetch_history_hourly(plant_config, start_date, end_date)
|
||||||
|
if hourly_data:
|
||||||
|
print(f" ✅ {len(hourly_data)}개 시간별 데이터 수집 완료")
|
||||||
|
save_history(hourly_data, 'hourly')
|
||||||
|
print(f" ✅ DB 저장 완료")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_type}는 시간별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Hourly] 에러: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# 2. 일별 데이터 수집
|
||||||
|
try:
|
||||||
|
print(f"\n⏳ [Daily] 일별 데이터 수집 중...")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_daily'):
|
||||||
|
daily_data = crawler_module.fetch_history_daily(plant_config, start_date, end_date)
|
||||||
|
if daily_data:
|
||||||
|
print(f" ✅ {len(daily_data)}개 일별 데이터 수집 완료")
|
||||||
|
save_history(daily_data, 'daily')
|
||||||
|
print(f" ✅ DB 저장 완료")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_type}는 일별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Daily] 에러: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 모든 작업 완료\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 실행 함수"""
|
||||||
|
target_plants = ['kremc-05', 'nrems-09'] # 5호기, 9호기
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🌞 2월 데이터 크롤링 시작")
|
||||||
|
print(f"대상: 5호기(kremc-05), 9호기(nrems-09)")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for plant_id in target_plants:
|
||||||
|
cfg = get_plant_config(plant_id)
|
||||||
|
|
||||||
|
if cfg:
|
||||||
|
fetch_february_data(cfg)
|
||||||
|
else:
|
||||||
|
print(f"❌ 설정을 찾을 수 없습니다: {plant_id}")
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🎉 모든 작업 완료!")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
127
crawler/scripts_archive/fill_today_feb.py
Normal file
127
crawler/scripts_archive/fill_today_feb.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""
|
||||||
|
2월 27일 누락 시간대 보완 크롤링
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from database import save_history, get_supabase_client
|
||||||
|
|
||||||
|
def get_plant_config(target_id):
|
||||||
|
plants = get_all_plants()
|
||||||
|
for p in plants:
|
||||||
|
if p.get('id') == target_id:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fill_today(plant_config):
|
||||||
|
plant_id = plant_config['id']
|
||||||
|
plant_type = plant_config['type']
|
||||||
|
plant_name = plant_config['name']
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"🚀 [{plant_name}] 오늘 데이터 보완 ({plant_id})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# 크롤러 모듈 동적 임포트
|
||||||
|
try:
|
||||||
|
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
|
||||||
|
except ImportError:
|
||||||
|
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 1. 현재 DB에 있는 시간대 확인
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client:
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("created_at") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{today}T00:00:00+09:00") \
|
||||||
|
.lt("created_at", f"{today}T23:59:59+09:00") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
existing_hours = set()
|
||||||
|
for rec in result.data:
|
||||||
|
hour = rec['created_at'][:13] # 2026-02-27T00 형식
|
||||||
|
existing_hours.add(hour)
|
||||||
|
|
||||||
|
print(f" 현재 DB에 있는 시간대: {len(existing_hours)}개")
|
||||||
|
print(f" {sorted(existing_hours)[:5]}... (샘플)")
|
||||||
|
|
||||||
|
# 2. 시간별 데이터 크롤링
|
||||||
|
try:
|
||||||
|
print(f"\n⏳ [Hourly] 오늘 시간별 데이터 수집 중...")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_hourly'):
|
||||||
|
hourly_data = crawler_module.fetch_history_hourly(plant_config, today, today)
|
||||||
|
if hourly_data:
|
||||||
|
print(f" ✅ {len(hourly_data)}개 시간별 데이터 수집 완료")
|
||||||
|
save_history(hourly_data, 'hourly')
|
||||||
|
print(f" ✅ DB 저장 완료")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_type}는 시간별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Hourly] 에러: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# 3. 일별 데이터도 업데이트
|
||||||
|
try:
|
||||||
|
print(f"\n⏳ [Daily] 오늘 일별 데이터 업데이트 중...")
|
||||||
|
|
||||||
|
if hasattr(crawler_module, 'fetch_history_daily'):
|
||||||
|
daily_data = crawler_module.fetch_history_daily(plant_config, today, today)
|
||||||
|
if daily_data:
|
||||||
|
print(f" ✅ {len(daily_data)}개 일별 데이터 수집 완료")
|
||||||
|
save_history(daily_data, 'daily')
|
||||||
|
print(f" ✅ DB 저장 완료")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ 데이터 없음")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ {plant_type}는 일별 이력 수집을 지원하지 않음")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ [Daily] 에러: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 작업 완료\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
target_plants = ['kremc-05', 'nrems-09']
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🌞 오늘 데이터 보완 크롤링")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for plant_id in target_plants:
|
||||||
|
cfg = get_plant_config(plant_id)
|
||||||
|
if cfg:
|
||||||
|
fill_today(cfg)
|
||||||
|
else:
|
||||||
|
print(f"❌ 설정을 찾을 수 없습니다: {plant_id}")
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🎉 모든 작업 완료!")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
74
crawler/scripts_archive/test_api.py
Normal file
74
crawler/scripts_archive/test_api.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
API 호출 테스트 - 5호기와 9호기의 2월 25일 시간별 데이터 확인
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
def test_api(plant_id, plant_name, date):
|
||||||
|
url = f"https://solorpower.dadot.net/plants/{plant_id}/stats/today?date={date}"
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🔍 [{plant_name}] API 호출: {date}")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print(f"URL: {url}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print(f"Status: {data.get('status')}")
|
||||||
|
print(f"Plant ID: {data.get('plant_id')}")
|
||||||
|
print(f"Date: {data.get('date')}")
|
||||||
|
print(f"Count: {data.get('count')}\n")
|
||||||
|
|
||||||
|
hourly_data = data.get('data', [])
|
||||||
|
|
||||||
|
# 데이터가 있는 시간대만 출력
|
||||||
|
has_data_count = 0
|
||||||
|
print("시간별 데이터 (데이터가 있는 시간만):")
|
||||||
|
for item in hourly_data:
|
||||||
|
if item.get('has_data'):
|
||||||
|
has_data_count += 1
|
||||||
|
print(f" {item['label']:>4}: current_kw={item['current_kw']:>8.2f}, today_kwh={item['today_kwh']:>8.2f}")
|
||||||
|
|
||||||
|
if has_data_count == 0:
|
||||||
|
print(" ❌ 데이터가 있는 시간대가 없습니다!")
|
||||||
|
|
||||||
|
# 전체 응답 출력
|
||||||
|
print("\n전체 응답:")
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(f"\n✅ 총 {has_data_count}개 시간대에 데이터 있음")
|
||||||
|
else:
|
||||||
|
print(f"❌ API 호출 실패")
|
||||||
|
print(response.text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 에러 발생: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🌐 API 호출 테스트")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
# 2월 25일 데이터 확인
|
||||||
|
test_api("kremc-05", "5호기", "2026-02-25")
|
||||||
|
test_api("nrems-09", "9호기", "2026-02-25")
|
||||||
|
|
||||||
|
# 2월 1일도 확인
|
||||||
|
test_api("kremc-05", "5호기", "2026-02-01")
|
||||||
|
test_api("nrems-09", "9호기", "2026-02-01")
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("테스트 완료")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
95
crawler/scripts_archive/update_current_kw.py
Normal file
95
crawler/scripts_archive/update_current_kw.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""
|
||||||
|
2월 데이터의 current_kw 업데이트
|
||||||
|
과거 데이터의 경우 current_kw = today_kwh (시간별 발전량)로 설정
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def update_current_kw(plant_id, plant_name):
|
||||||
|
"""2월 데이터의 current_kw를 today_kwh로 업데이트"""
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🔧 [{plant_name}] current_kw 업데이트 중...")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2월 1일부터 27일까지
|
||||||
|
start = datetime(2026, 2, 1)
|
||||||
|
end = datetime(2026, 2, 27)
|
||||||
|
|
||||||
|
total_updated = 0
|
||||||
|
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 해당 날짜의 모든 시간별 데이터 가져오기
|
||||||
|
result = client.table("solar_logs") \
|
||||||
|
.select("id, current_kw, today_kwh") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{date_str}T00:00:00") \
|
||||||
|
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not result.data:
|
||||||
|
current += timedelta(days=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# current_kw가 0이고 today_kwh가 0이 아닌 레코드만 업데이트
|
||||||
|
updated_count = 0
|
||||||
|
for record in result.data:
|
||||||
|
if record['current_kw'] == 0 and record['today_kwh'] != 0:
|
||||||
|
try:
|
||||||
|
# current_kw를 today_kwh로 업데이트
|
||||||
|
client.table("solar_logs") \
|
||||||
|
.update({"current_kw": record['today_kwh']}) \
|
||||||
|
.eq("id", record['id']) \
|
||||||
|
.execute()
|
||||||
|
updated_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 업데이트 실패 (id: {record['id']}): {e}")
|
||||||
|
|
||||||
|
if updated_count > 0:
|
||||||
|
print(f" ✅ {date_str}: {updated_count}건 업데이트")
|
||||||
|
|
||||||
|
total_updated += updated_count
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 총 {total_updated}건 업데이트 완료")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🔧 2월 데이터 current_kw 업데이트 시작")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
update_current_kw(plant_id, plant_name)
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🎉 current_kw 업데이트 완료!")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
crawler/scripts_archive/verify_feb_final.py
Normal file
81
crawler/scripts_archive/verify_feb_final.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""
|
||||||
|
2월 데이터 최종 검증
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def final_check():
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
print("❌ Supabase 연결 실패")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("📊 2월 데이터 최종 검증 결과")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
print(f"\n🏭 [{plant_name}] ({plant_id})")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
# 시간별 데이터
|
||||||
|
hourly = client.table("solar_logs") \
|
||||||
|
.select("*", count='exact') \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", "2026-02-01T00:00:00+09:00") \
|
||||||
|
.lte("created_at", "2026-02-27T23:59:59+09:00") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
hourly_count = hourly.count if hasattr(hourly, 'count') else len(hourly.data)
|
||||||
|
|
||||||
|
# 일별 데이터
|
||||||
|
daily = client.table("daily_stats") \
|
||||||
|
.select("*", count='exact') \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("date", "2026-02-01") \
|
||||||
|
.lte("date", "2026-02-27") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
daily_count = daily.count if hasattr(daily, 'count') else len(daily.data)
|
||||||
|
total_gen = sum(r.get('total_generation', 0) for r in daily.data)
|
||||||
|
avg_gen = total_gen / daily_count if daily_count > 0 else 0
|
||||||
|
|
||||||
|
# 월별 통계
|
||||||
|
monthly = client.table("monthly_stats") \
|
||||||
|
.select("*") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.eq("month", "2026-02") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
monthly_gen = monthly.data[0].get('total_generation', 0) if monthly.data else 0
|
||||||
|
|
||||||
|
print(f" ✅ 시간별 데이터 (Hourly): {hourly_count}건")
|
||||||
|
print(f" ✅ 일별 데이터 (Daily): {daily_count}건")
|
||||||
|
print(f" 📈 2월 총 발전량: {total_gen:,.2f} kWh")
|
||||||
|
print(f" 📈 일평균 발전량: {avg_gen:,.2f} kWh/day")
|
||||||
|
print(f" 📊 월별 통계: {monthly_gen:,.2f} kWh")
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("✅ 모든 데이터가 Supabase DB에 정상 저장되었습니다!")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
final_check()
|
||||||
180
crawler/scripts_archive/verify_february_data.py
Normal file
180
crawler/scripts_archive/verify_february_data.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
2월 데이터 검증 스크립트
|
||||||
|
5호기(kremc-05), 9호기(nrems-09)의 2월 데이터가 DB에 제대로 저장되었는지 확인
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Windows 인코딩 문제 해결
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
# 프로젝트 루트 경로 추가
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.append(current_dir)
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def verify_data(plant_id, plant_name):
|
||||||
|
"""특정 발전소의 2월 데이터 검증"""
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"🔍 [{plant_name}] 2월 데이터 검증 중...")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if client is None:
|
||||||
|
print("❌ Supabase 연결 실패")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2월 데이터 범위 설정
|
||||||
|
now = datetime.now()
|
||||||
|
year = now.year
|
||||||
|
|
||||||
|
start_date = f"{year}-02-01"
|
||||||
|
if now.month == 2:
|
||||||
|
end_date = now.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
# 2월 마지막 날
|
||||||
|
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||||
|
end_date = f"{year}-02-29"
|
||||||
|
else:
|
||||||
|
end_date = f"{year}-02-28"
|
||||||
|
|
||||||
|
month_str = f"{year}-02"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 시간별 데이터 확인 (solar_logs)
|
||||||
|
print(f"\n📊 [Hourly] 시간별 데이터 (solar_logs)")
|
||||||
|
print(f" 조회 기간: {start_date} ~ {end_date}")
|
||||||
|
|
||||||
|
hourly_result = client.table("solar_logs") \
|
||||||
|
.select("*", count='exact') \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("created_at", f"{start_date}T00:00:00+09:00") \
|
||||||
|
.lte("created_at", f"{end_date}T23:59:59+09:00") \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
hourly_count = hourly_result.count if hasattr(hourly_result, 'count') else len(hourly_result.data)
|
||||||
|
|
||||||
|
if hourly_count > 0:
|
||||||
|
print(f" ✅ 총 {hourly_count}건의 시간별 데이터 발견")
|
||||||
|
|
||||||
|
# 날짜별 카운트 집계
|
||||||
|
dates = {}
|
||||||
|
total_kwh = 0
|
||||||
|
for record in hourly_result.data:
|
||||||
|
date_str = record['created_at'][:10]
|
||||||
|
dates[date_str] = dates.get(date_str, 0) + 1
|
||||||
|
total_kwh += record.get('today_kwh', 0)
|
||||||
|
|
||||||
|
print(f" 📅 {len(dates)}일간의 데이터")
|
||||||
|
|
||||||
|
# 처음 3일과 마지막 3일 샘플 표시
|
||||||
|
sorted_dates = sorted(dates.keys())
|
||||||
|
print(f"\n [샘플 - 처음 3일]")
|
||||||
|
for d in sorted_dates[:3]:
|
||||||
|
print(f" {d}: {dates[d]}건")
|
||||||
|
|
||||||
|
if len(sorted_dates) > 6:
|
||||||
|
print(f" ... ({len(sorted_dates) - 6}일 생략) ...")
|
||||||
|
|
||||||
|
print(f"\n [샘플 - 마지막 3일]")
|
||||||
|
for d in sorted_dates[-3:]:
|
||||||
|
print(f" {d}: {dates[d]}건")
|
||||||
|
|
||||||
|
print(f"\n 💡 평균 발전량 합계: {total_kwh / len(hourly_result.data):.2f} kWh/시간")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 시간별 데이터가 없습니다!")
|
||||||
|
|
||||||
|
# 2. 일별 데이터 확인 (daily_stats)
|
||||||
|
print(f"\n📊 [Daily] 일별 데이터 (daily_stats)")
|
||||||
|
print(f" 조회 기간: {start_date} ~ {end_date}")
|
||||||
|
|
||||||
|
daily_result = client.table("daily_stats") \
|
||||||
|
.select("*", count='exact') \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.gte("date", start_date) \
|
||||||
|
.lte("date", end_date) \
|
||||||
|
.order("date", desc=False) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
daily_count = daily_result.count if hasattr(daily_result, 'count') else len(daily_result.data)
|
||||||
|
|
||||||
|
if daily_count > 0:
|
||||||
|
print(f" ✅ 총 {daily_count}건의 일별 데이터 발견")
|
||||||
|
|
||||||
|
total_generation = sum(r.get('total_generation', 0) for r in daily_result.data)
|
||||||
|
avg_generation = total_generation / daily_count if daily_count > 0 else 0
|
||||||
|
|
||||||
|
print(f" 📈 2월 총 발전량: {total_generation:.2f} kWh")
|
||||||
|
print(f" 📈 일평균 발전량: {avg_generation:.2f} kWh")
|
||||||
|
|
||||||
|
# 처음 5일과 마지막 5일 샘플 표시
|
||||||
|
print(f"\n [샘플 - 처음 5일]")
|
||||||
|
for record in daily_result.data[:5]:
|
||||||
|
print(f" {record['date']}: {record.get('total_generation', 0):.2f} kWh")
|
||||||
|
|
||||||
|
if len(daily_result.data) > 10:
|
||||||
|
print(f" ... ({len(daily_result.data) - 10}일 생략) ...")
|
||||||
|
|
||||||
|
print(f"\n [샘플 - 마지막 5일]")
|
||||||
|
for record in daily_result.data[-5:]:
|
||||||
|
print(f" {record['date']}: {record.get('total_generation', 0):.2f} kWh")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 일별 데이터가 없습니다!")
|
||||||
|
|
||||||
|
# 3. 월별 데이터 확인 (monthly_stats)
|
||||||
|
print(f"\n📊 [Monthly] 2월 월별 데이터 (monthly_stats)")
|
||||||
|
print(f" 조회 월: {month_str}")
|
||||||
|
|
||||||
|
monthly_result = client.table("monthly_stats") \
|
||||||
|
.select("*") \
|
||||||
|
.eq("plant_id", plant_id) \
|
||||||
|
.eq("month", month_str) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if monthly_result.data:
|
||||||
|
record = monthly_result.data[0]
|
||||||
|
print(f" ✅ 2월 월별 통계 발견")
|
||||||
|
print(f" 📈 총 발전량: {record.get('total_generation', 0):.2f} kWh")
|
||||||
|
print(f" 🕐 업데이트: {record.get('updated_at', 'N/A')}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 2월 월별 데이터가 없습니다!")
|
||||||
|
|
||||||
|
print(f"\n✅ [{plant_name}] 검증 완료\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 검증 중 오류 발생: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 실행 함수"""
|
||||||
|
|
||||||
|
plants = [
|
||||||
|
('kremc-05', '5호기'),
|
||||||
|
('nrems-09', '9호기')
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("🔍 2월 데이터 검증 시작")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
for plant_id, plant_name in plants:
|
||||||
|
verify_data(plant_id, plant_name)
|
||||||
|
|
||||||
|
print("="*70)
|
||||||
|
print("🎉 모든 검증 완료!")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
91
crawler/sync_plants.py
Normal file
91
crawler/sync_plants.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# ==========================================
|
||||||
|
# sync_plants.py - 발전소 정보 동기화
|
||||||
|
# ==========================================
|
||||||
|
# config.py의 발전소 정보를 Supabase plants 테이블에 Upsert
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
|
||||||
|
def sync_plants():
|
||||||
|
"""
|
||||||
|
로컬 config.py의 발전소 정보를 Supabase plants 테이블에 동기화
|
||||||
|
"""
|
||||||
|
print(f"\n🔄 [발전소 동기화] 시작... ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
print("❌ Supabase 연결 실패")
|
||||||
|
return False
|
||||||
|
|
||||||
|
plants = get_all_plants()
|
||||||
|
|
||||||
|
# 중복 제거 (is_split인 1,2호기는 별도 처리)
|
||||||
|
unique_plants = {}
|
||||||
|
for plant in plants:
|
||||||
|
plant_id = plant.get('id', '')
|
||||||
|
is_split = plant.get('options', {}).get('is_split', False)
|
||||||
|
|
||||||
|
if is_split:
|
||||||
|
# 1, 2호기 분리 (용량 N빵)
|
||||||
|
total_capacity = plant.get('capacity_kw', 100.0)
|
||||||
|
unit_capacity = total_capacity / 2
|
||||||
|
start_date = plant.get('start_date', '')
|
||||||
|
|
||||||
|
unique_plants['nrems-01'] = {
|
||||||
|
'id': 'nrems-01',
|
||||||
|
'name': f"{plant.get('company_name', '')} 1호기",
|
||||||
|
'type': plant.get('type', ''),
|
||||||
|
'capacity': unit_capacity,
|
||||||
|
'constructed_at': start_date,
|
||||||
|
'company_id': 1
|
||||||
|
}
|
||||||
|
unique_plants['nrems-02'] = {
|
||||||
|
'id': 'nrems-02',
|
||||||
|
'name': f"{plant.get('company_name', '')} 2호기",
|
||||||
|
'type': plant.get('type', ''),
|
||||||
|
'capacity': unit_capacity,
|
||||||
|
'constructed_at': start_date,
|
||||||
|
'company_id': 1
|
||||||
|
}
|
||||||
|
elif plant_id:
|
||||||
|
unique_plants[plant_id] = {
|
||||||
|
'id': plant_id,
|
||||||
|
'name': f"{plant.get('company_name', '')} {plant.get('name', '')}",
|
||||||
|
'type': plant.get('type', ''),
|
||||||
|
'capacity': plant.get('capacity_kw', 0.0),
|
||||||
|
'constructed_at': plant.get('start_date', ''),
|
||||||
|
'company_id': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for plant_id, plant_data in unique_plants.items():
|
||||||
|
try:
|
||||||
|
result = client.table("plants").upsert(
|
||||||
|
plant_data,
|
||||||
|
on_conflict="id"
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
print(f" ✅ {plant_data['name']} (용량: {plant_data['capacity']} kW)")
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {plant_id} 실패: {e}")
|
||||||
|
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"✅ 동기화 완료: {success_count}/{len(unique_plants)}개")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync_plants()
|
||||||
128
crawler/tests/check_missing_dates.py
Normal file
128
crawler/tests/check_missing_dates.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
1월 28, 29일 데이터 확인 스크립트
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
print("Starting checks...", flush=True)
|
||||||
|
|
||||||
|
# Add parent directory to path to import modules
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
parent_dir = os.path.dirname(current_dir)
|
||||||
|
sys.path.append(parent_dir)
|
||||||
|
|
||||||
|
print(f"Current dir: {current_dir}", flush=True)
|
||||||
|
print(f"Parent dir: {parent_dir}", flush=True)
|
||||||
|
print(f"Sys path: {sys.path}", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from crawlers import nrems, hyundai, kremc, sun_wms, cmsolar
|
||||||
|
from config import SYSTEM_CONSTANTS
|
||||||
|
print("Imports successful", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Import failed: {e}", flush=True)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_dates(plant_config, crawler_module, start_date, end_date):
|
||||||
|
plant_name = plant_config['name']
|
||||||
|
print(f"\n[{plant_name}] 데이터 확인: {start_date} ~ {end_date}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check daily data
|
||||||
|
daily_data = crawler_module.fetch_history_daily(plant_config, start_date, end_date)
|
||||||
|
|
||||||
|
if not daily_data:
|
||||||
|
print(" ❌ 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" 총 {len(daily_data)}일 데이터 수신")
|
||||||
|
for record in daily_data:
|
||||||
|
print(f" - 날짜: {record.get('date', 'Unknown')}, 발전량: {record.get('generation_kwh', 0)} kWh")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 오류 발생: {str(e)}")
|
||||||
|
# import traceback
|
||||||
|
# traceback.print_exc()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(">>> 1월 28, 29일 데이터 확인 <<<")
|
||||||
|
|
||||||
|
# Dates to check
|
||||||
|
start_date = '2026-01-28'
|
||||||
|
end_date = '2026-01-29'
|
||||||
|
|
||||||
|
test_plants = [
|
||||||
|
# NREMS 1,2호기 (분리)
|
||||||
|
({'id': 'nrems-01', 'name': '1호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072288'},
|
||||||
|
'options': {'is_split': True, 'unit_id': 1},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
({'id': 'nrems-02', 'name': '2호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072288'},
|
||||||
|
'options': {'is_split': True, 'unit_id': 2},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 3호기
|
||||||
|
({'id': 'nrems-03', 'name': '3호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'dc2023121086'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 4호기
|
||||||
|
({'id': 'nrems-04', 'name': '4호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072269'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 9호기
|
||||||
|
({'id': 'nrems-09', 'name': '9호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'a2020061008'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# KREMC 5호기
|
||||||
|
({'id': 'kremc-05', 'name': '5호기', 'type': 'kremc',
|
||||||
|
'auth': {'user_id': '서대문도서관', 'password': 'sunhope5!'},
|
||||||
|
'options': {'cid': '10013000376', 'cityProvCode': '11', 'rgnCode': '11410',
|
||||||
|
'dongCode': '1141011700', 'enso_type_code': '15001'},
|
||||||
|
'system': SYSTEM_CONSTANTS['kremc']}, kremc),
|
||||||
|
|
||||||
|
# Sun-WMS 6호기
|
||||||
|
({'id': 'sunwms-06', 'name': '6호기', 'type': 'sun_wms',
|
||||||
|
'auth': {'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==', 'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['sun_wms']}, sun_wms),
|
||||||
|
|
||||||
|
# Hyundai 8호기
|
||||||
|
({'id': 'hyundai-08', 'name': '8호기', 'type': 'hyundai',
|
||||||
|
'auth': {'user_id': 'epecoop', 'password': 'sunhope0419', 'site_id': 'M0494'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['hyundai']}, hyundai),
|
||||||
|
|
||||||
|
# CMSolar 10호기 (Fix login info from verify_data.py if valid, otherwise use config.py's)
|
||||||
|
# Using config.py's info but updated with values seen in verify_data.py which seemed to be used for testing
|
||||||
|
# verify_data.py had: 'login_id': 'smart3131', 'password': 'ehdrb!123'
|
||||||
|
# config.py has: 'login_id': 'sy7144', 'login_pw': 'sy7144'
|
||||||
|
# I should probably use what is in config.py OR verify_data.py. Let's try config.py first as it is the source of truth usually,
|
||||||
|
# BUT wait, verify_data.py was likely used recently.
|
||||||
|
# Let's check config.py again. Config.py has 'sy7144'. verify_data.py has 'smart3131'.
|
||||||
|
# The user history mentioned "Debugging Real-time Crawlers" and "CMSolar".
|
||||||
|
# Let's check `crawler/crawlers/cmsolar.py` to see what it expects or if there are hardcoded overrides.
|
||||||
|
({'id': 'cmsolar-10', 'name': '10호기', 'type': 'cmsolar',
|
||||||
|
'auth': {'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['cmsolar']}, cmsolar),
|
||||||
|
]
|
||||||
|
|
||||||
|
for plant_config, crawler_module in test_plants:
|
||||||
|
check_dates(plant_config, crawler_module, start_date, end_date)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
26
crawler/tests/check_today_10.py
Normal file
26
crawler/tests/check_today_10.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
from database import get_supabase_client
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
def check_today():
|
||||||
|
c = get_supabase_client()
|
||||||
|
# Today in KST
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
now = datetime.now(kst)
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
print(f"Checking data since {today_start.isoformat()} (KST)")
|
||||||
|
|
||||||
|
res = c.table('solar_logs').select('created_at, current_kw, today_kwh, status').eq('plant_id', 'cmsolar-10').gte('created_at', today_start.isoformat()).order('created_at', desc=True).execute()
|
||||||
|
|
||||||
|
print(f"Found {len(res.data)} records for today:")
|
||||||
|
for item in res.data:
|
||||||
|
print(f"{item['created_at']} | {item.get('current_kw')} kW")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_today()
|
||||||
51
crawler/tests/debug_cmsolar.py
Normal file
51
crawler/tests/debug_cmsolar.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawlers.cmsolar import fetch_data
|
||||||
|
from crawlers.base import create_session
|
||||||
|
|
||||||
|
def debug_cmsolar():
|
||||||
|
plants = get_all_plants()
|
||||||
|
target = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
print("Plant 10 not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Debug target: {target['name']}")
|
||||||
|
|
||||||
|
# Manually reproduce fetch_data logic to see raw response
|
||||||
|
auth = target.get('auth', {})
|
||||||
|
system = target.get('system', {})
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '') # config.py uses login_id? checking cmsolar.py it uses payload_id or auth get directly.
|
||||||
|
# config.py for cmsolar-10:
|
||||||
|
# 'auth': { 'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834' }
|
||||||
|
|
||||||
|
# cmsolar.py fetch_data:
|
||||||
|
# login_id = auth.get('payload_id', '') -> THIS MIGHT BE WRONG if config keys are login_id
|
||||||
|
|
||||||
|
# Check config.py again for cmsolar-10 auth keys.
|
||||||
|
# Lines 154-158 in config.py:
|
||||||
|
# 'auth': { 'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834' }
|
||||||
|
|
||||||
|
# cmsolar.py Lines 20-22:
|
||||||
|
# login_id = auth.get('payload_id', '')
|
||||||
|
# login_pw = auth.get('payload_pw', '')
|
||||||
|
# site_no = auth.get('site_no', '')
|
||||||
|
|
||||||
|
# WAIT! 'payload_id' vs 'login_id'.
|
||||||
|
# If the code expects 'payload_id' but config provides 'login_id', then login_id will be empty string.
|
||||||
|
# This might be the bug.
|
||||||
|
|
||||||
|
print(f"Auth keys in config: {list(auth.keys())}")
|
||||||
|
|
||||||
|
# Let's try to run fetch_data and catch exception
|
||||||
|
try:
|
||||||
|
result = fetch_data(target)
|
||||||
|
print(f"Result: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_cmsolar()
|
||||||
85
crawler/tests/debug_cmsolar_realtime.py
Normal file
85
crawler/tests/debug_cmsolar_realtime.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawlers.base import create_session
|
||||||
|
|
||||||
|
def debug_cmsolar_realtime():
|
||||||
|
plants = get_all_plants()
|
||||||
|
target = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
print("Plant 10 not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Debug target: {target['name']}")
|
||||||
|
|
||||||
|
# Extract info
|
||||||
|
auth = target.get('auth', {})
|
||||||
|
system = target.get('system', {})
|
||||||
|
|
||||||
|
login_id = auth.get('login_id', '')
|
||||||
|
login_pw = auth.get('login_pw', '')
|
||||||
|
site_no = auth.get('site_no', '')
|
||||||
|
login_url = system.get('login_url', '')
|
||||||
|
data_url = system.get('data_url', '')
|
||||||
|
|
||||||
|
print(f"Login ID: {login_id}")
|
||||||
|
print(f"Login URL: {login_url}")
|
||||||
|
print(f"Data URL: {data_url}")
|
||||||
|
|
||||||
|
session = create_session()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Login
|
||||||
|
login_data = {
|
||||||
|
'login_id': login_id,
|
||||||
|
'login_pw': login_pw,
|
||||||
|
'site_no': site_no
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Logging in...")
|
||||||
|
try:
|
||||||
|
res = session.post(login_url, data=login_data, headers=headers)
|
||||||
|
print(f"Login Status: {res.status_code}")
|
||||||
|
|
||||||
|
# Site selection
|
||||||
|
base_url = "http://www.cmsolar2.kr"
|
||||||
|
change_url = f"{base_url}/change.php?site={site_no}"
|
||||||
|
print(f"Selecting site via {change_url}...")
|
||||||
|
session.get(change_url, headers=headers)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Login/Select Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch Data
|
||||||
|
real_data_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
|
||||||
|
print(f"Fetching data from {real_data_url}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = session.get(real_data_url, headers=headers)
|
||||||
|
print(f"Data Status: {res.status_code}")
|
||||||
|
# print(f"Data Content-Type: {res.headers.get('Content-Type')}")
|
||||||
|
print(f"Data Response:\n{res.text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_data = res.json()
|
||||||
|
print(f"JSON parsed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"JSON Parse Error: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Data Fetch Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_cmsolar_realtime()
|
||||||
14
crawler/tests/debug_db_check.py
Normal file
14
crawler/tests/debug_db_check.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
from database import get_supabase_client
|
||||||
|
|
||||||
|
def check_db():
|
||||||
|
c = get_supabase_client()
|
||||||
|
res = c.table('solar_logs').select('created_at, current_kw, today_kwh').eq('plant_id', 'cmsolar-10').order('created_at', desc=True).limit(30).execute()
|
||||||
|
print("Recent logs for cmsolar-10:")
|
||||||
|
for item in res.data:
|
||||||
|
print(f"{item['created_at']} | {item.get('current_kw', 'N/A')} kW | {item.get('today_kwh', 'N/A')} kWh")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_db()
|
||||||
43
crawler/tests/debug_kremc.py
Normal file
43
crawler/tests/debug_kremc.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawlers.kremc import fetch_data
|
||||||
|
from crawlers.base import create_session
|
||||||
|
|
||||||
|
def debug_kremc():
|
||||||
|
plants = get_all_plants()
|
||||||
|
# 5호기 (kremc) 찾기 - id가 kremc-05인 것
|
||||||
|
target = next((p for p in plants if p['id'] == 'kremc-05'), None)
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
print("Plant kremc-05 not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Debug target: {target['name']}")
|
||||||
|
|
||||||
|
print(f"Debug target: {target['name']}")
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
print(f"Fetching hourly history for {today}...")
|
||||||
|
|
||||||
|
from crawlers.kremc import fetch_history_hourly
|
||||||
|
from database import save_history
|
||||||
|
try:
|
||||||
|
results = fetch_history_hourly(target, today, today)
|
||||||
|
print(f"Hourly Results ({len(results)}):")
|
||||||
|
for r in results:
|
||||||
|
print(f" {r['timestamp']}: {r['generation_kwh']} kWh")
|
||||||
|
|
||||||
|
if results:
|
||||||
|
print("Saving to DB...")
|
||||||
|
save_history(results, 'hourly')
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_kremc()
|
||||||
30
crawler/tests/debug_kremc_realtime.py
Normal file
30
crawler/tests/debug_kremc_realtime.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawlers.kremc import fetch_data
|
||||||
|
|
||||||
|
def debug_kremc_realtime():
|
||||||
|
plants = get_all_plants()
|
||||||
|
target = next((p for p in plants if p['id'] == 'kremc-05'), None)
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
print("Plant 5 not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Debug target: {target['name']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Fetching data...")
|
||||||
|
results = fetch_data(target)
|
||||||
|
print(f"Results: {results}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_kremc_realtime()
|
||||||
118
crawler/tests/fill_all_today.py
Normal file
118
crawler/tests/fill_all_today.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
from database import get_supabase_client, save_history
|
||||||
|
from config import get_all_plants
|
||||||
|
|
||||||
|
def cleanup_history_today(plant_id, today_str):
|
||||||
|
"""
|
||||||
|
Cleans up 'History' status records for the target date to avoid duplicates.
|
||||||
|
"""
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete records with status='History' created within the target date range
|
||||||
|
# Since created_at is timestampz, we need to be careful.
|
||||||
|
# But usually save_history sets created_at to the actual data timestamp for hourly history.
|
||||||
|
# Or does it?
|
||||||
|
# In 'save_history' (database.py): records.append({ ..., 'created_at': final_created_at, ... })
|
||||||
|
# where final_created_at comes from the data timestamp.
|
||||||
|
|
||||||
|
# So we should delete range [today 00:00:00, today 23:59:59]
|
||||||
|
start_ts = f"{today_str}T00:00:00"
|
||||||
|
end_ts = f"{today_str}T23:59:59"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# We also filter by status='History' to avoid deleting real-time crawled logs (if any exist)
|
||||||
|
# Real-time logs usually have status='Normal' or 'Abnormal' or empty.
|
||||||
|
# History fetch sets status='History'.
|
||||||
|
res = client.table('solar_logs').delete() \
|
||||||
|
.eq('plant_id', plant_id) \
|
||||||
|
.eq('status', 'History') \
|
||||||
|
.gte('created_at', start_ts) \
|
||||||
|
.lte('created_at', end_ts) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if res.data:
|
||||||
|
print(f" 🧹 Cleaned up {len(res.data)} old history records for {today_str}.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Cleanup failed: {e}")
|
||||||
|
|
||||||
|
def fill_all_today():
|
||||||
|
plants = get_all_plants()
|
||||||
|
now_kst = datetime.now(timezone(timedelta(hours=9)))
|
||||||
|
today_str = now_kst.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
print(f"🚀 Starting Manual Data Fetch for TODAY: {today_str}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for plant in plants:
|
||||||
|
plant_id = plant['id']
|
||||||
|
plant_name = plant['name']
|
||||||
|
plant_type = plant['type']
|
||||||
|
|
||||||
|
# Skip unknown or unsupported types
|
||||||
|
if plant_type == 'unknown':
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\nProcessing [{plant_type.upper()}] {plant_name} ({plant_id})...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Dynamic import
|
||||||
|
module = importlib.import_module(f"crawlers.{plant_type}")
|
||||||
|
|
||||||
|
# 1. Hourly Data
|
||||||
|
if hasattr(module, 'fetch_history_hourly'):
|
||||||
|
print(" ⏳ Fetching Hourly Data...")
|
||||||
|
# Cleanup previous 'History' data for today to prevent dups
|
||||||
|
cleanup_history_today(plant_id, today_str)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# fetch_history_hourly(config, start_date, end_date)
|
||||||
|
data = module.fetch_history_hourly(plant, today_str, today_str)
|
||||||
|
if data:
|
||||||
|
# save_history handles 'hourly' -> inserts into solar_logs
|
||||||
|
save_history(data, 'hourly')
|
||||||
|
else:
|
||||||
|
print(" ⚠️ No Hourly data found.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Hourly Fetch Error: {e}")
|
||||||
|
else:
|
||||||
|
print(" ℹ️ No fetch_history_hourly method.")
|
||||||
|
|
||||||
|
# 2. Daily Data (Optional, as it might not be ready yet)
|
||||||
|
if hasattr(module, 'fetch_history_daily'):
|
||||||
|
print(" ⏳ Fetching Daily Data...")
|
||||||
|
try:
|
||||||
|
# fetch_history_daily(config, start_date, end_date)
|
||||||
|
data = module.fetch_history_daily(plant, today_str, today_str)
|
||||||
|
if data:
|
||||||
|
# save_history handles 'daily' -> upserts daily_stats & updates monthly
|
||||||
|
save_history(data, 'daily')
|
||||||
|
else:
|
||||||
|
print(" ⚠️ No Daily data found (Site might not list today yet).")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Daily Fetch Error: {e}")
|
||||||
|
else:
|
||||||
|
print(" ℹ️ No fetch_history_daily method.")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print(f" ❌ Module 'crawlers.{plant_type}' not found.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error processing plant: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("All tasks completed.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fill_all_today()
|
||||||
53
crawler/tests/fill_today_data.py
Normal file
53
crawler/tests/fill_today_data.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from database import get_supabase_client, save_history
|
||||||
|
from config import get_all_plants
|
||||||
|
from crawlers.kremc import fetch_history_hourly as fetch_kremc
|
||||||
|
from crawlers.cmsolar import fetch_history_hourly as fetch_cmsolar
|
||||||
|
|
||||||
|
def cleanup_history(plant_id, today_str):
|
||||||
|
client = get_supabase_client()
|
||||||
|
# Delete 'History' status records for today to avoid duplicates/bad data
|
||||||
|
# Filter by created_at >= today's start and status='History'
|
||||||
|
|
||||||
|
# Simple approach: delete records with status='History' created today
|
||||||
|
# KST date string is tricky for created_at (UTC), but status='History' is unique to our manual script
|
||||||
|
try:
|
||||||
|
res = client.table('solar_logs').delete().eq('plant_id', plant_id).eq('status', 'History').execute()
|
||||||
|
print(f"[{plant_id}] Cleaned up {len(res.data)} old history records.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{plant_id}] Cleanup failed (might be empty): {e}")
|
||||||
|
|
||||||
|
def fill_today_data():
|
||||||
|
plants = get_all_plants()
|
||||||
|
kremc_plant = next((p for p in plants if p['id'] == 'kremc-05'), None)
|
||||||
|
cmsolar_plant = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
|
||||||
|
|
||||||
|
today = "2026-01-29"
|
||||||
|
print(f"Filling data for {today}...")
|
||||||
|
|
||||||
|
# 1. KREMC (5호기) - Skip as it's done
|
||||||
|
# if kremc_plant: ...
|
||||||
|
|
||||||
|
# 2. CMSolar (10호기)
|
||||||
|
if cmsolar_plant:
|
||||||
|
print("\n--- Processing CMSolar (10호기) ---")
|
||||||
|
cleanup_history('cmsolar-10', today)
|
||||||
|
try:
|
||||||
|
results = fetch_cmsolar(cmsolar_plant, today, today)
|
||||||
|
print(f"Fetched results: {results}")
|
||||||
|
if results:
|
||||||
|
save_history(results, 'hourly')
|
||||||
|
print("Saved CMSolar data.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CMSolar Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fill_today_data()
|
||||||
64
crawler/tools/_check_alert.py
Normal file
64
crawler/tools/_check_alert.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
os.environ['SUPABASE_URL'] = 'https://qgelkvfjnhqwuvltmqct.supabase.co'
|
||||||
|
os.environ['SUPABASE_KEY'] = 'sb_secret_BFkr9u1Npu3asKDWTn40tg_Qnn1uKLc'
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
print("Supabase 연결 실패")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 1. 오늘 KST 15:00~18:00 = UTC 06:00~09:00 의 nrems-03 solar_logs 조회
|
||||||
|
print("=" * 60)
|
||||||
|
print("[1] nrems-03 오늘 15:00~18:00 KST (solar_logs)")
|
||||||
|
print("=" * 60)
|
||||||
|
resp = client.table('solar_logs') \
|
||||||
|
.select('plant_id, current_kw, today_kwh, status, created_at') \
|
||||||
|
.eq('plant_id', 'nrems-03') \
|
||||||
|
.gte('created_at', '2026-05-14T06:00:00+00:00') \
|
||||||
|
.lte('created_at', '2026-05-14T09:30:00+00:00') \
|
||||||
|
.order('created_at') \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
for row in resp.data:
|
||||||
|
print(f" {row['created_at']} kw={row['current_kw']} today={row['today_kwh']} status={row['status']}")
|
||||||
|
print(f"총 {len(resp.data)}건\n")
|
||||||
|
|
||||||
|
# 2. daily_stats 오늘 날짜 확인
|
||||||
|
print("=" * 60)
|
||||||
|
print("[2] daily_stats 오늘 nrems-03")
|
||||||
|
print("=" * 60)
|
||||||
|
resp2 = client.table('daily_stats') \
|
||||||
|
.select('*') \
|
||||||
|
.eq('plant_id', 'nrems-03') \
|
||||||
|
.eq('date', '2026-05-14') \
|
||||||
|
.execute()
|
||||||
|
for row in resp2.data:
|
||||||
|
print(f" {row}")
|
||||||
|
|
||||||
|
# 3. alert_history 로컬 DB 확인
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("[3] 로컬 alert_history (crawler_manager.db)")
|
||||||
|
print("=" * 60)
|
||||||
|
db_path = Path(__file__).parent.parent / "crawler_manager.db"
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT site_id, alert_status, last_alert_time FROM alert_history WHERE site_id='nrems-03'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
print(f" site_id={row[0]} status={row[1]} last_alert={row[2]}")
|
||||||
|
else:
|
||||||
|
print(" nrems-03 alert_history 없음")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 로컬 DB 읽기 실패: {e} (서버 DB는 서버에 있음)")
|
||||||
66
crawler/tools/check_db.py
Normal file
66
crawler/tools/check_db.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to sys.path to allow importing from root
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 로드 환경 변수 (database 임포트 전에 실행)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from database import get_supabase_client
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def check_db_data():
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
print("❌ Supabase connection failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check data from yesterday 18:00 to 20:00
|
||||||
|
# Note: DB stores in KST or UTC?
|
||||||
|
# recover_data.py used KST time in 'created_at' string.
|
||||||
|
# Let's query based on string range.
|
||||||
|
|
||||||
|
# KST 18:00 - 20:00 is UTC 09:00 - 11:00
|
||||||
|
start_time = "2026-02-12 09:00:00"
|
||||||
|
end_time = "2026-02-12 11:15:00"
|
||||||
|
|
||||||
|
print(f"🔍 Checking DB data from {start_time} to {end_time} (UTC)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.table("solar_logs").select("*") \
|
||||||
|
.gte("created_at", start_time) \
|
||||||
|
.lte("created_at", end_time) \
|
||||||
|
.order("created_at") \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print("⚠️ No data found in this range.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ Found {len(data)} records.\n")
|
||||||
|
|
||||||
|
# Group by timestamp to see snapshot completeness
|
||||||
|
timestamps = {}
|
||||||
|
for item in data:
|
||||||
|
ts = item['created_at']
|
||||||
|
if ts not in timestamps:
|
||||||
|
timestamps[ts] = []
|
||||||
|
timestamps[ts].append(item)
|
||||||
|
|
||||||
|
for ts in sorted(timestamps.keys()):
|
||||||
|
items = timestamps[ts]
|
||||||
|
print(f"⏰ {ts} - {len(items)} plants")
|
||||||
|
for item in items:
|
||||||
|
print(f" - {item['plant_id']}: {item['current_kw']} kW / {item['today_kwh']} kWh")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error querying DB: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_db_data()
|
||||||
142
crawler/tools/recover_from_log.py
Normal file
142
crawler/tools/recover_from_log.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to sys.path to allow importing from root
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 로드 환경 변수
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from database import get_supabase_client, save_history
|
||||||
|
|
||||||
|
PLANT_MAP = {
|
||||||
|
"태양과바람 1호기": "nrems-01",
|
||||||
|
"태양과바람 2호기": "nrems-02",
|
||||||
|
"태양과바람 3호기": "nrems-03",
|
||||||
|
"태양과바람 4호기": "nrems-04",
|
||||||
|
"태양과바람 5호기": "kremc-05",
|
||||||
|
"태양과바람 6호기": "sunwms-06",
|
||||||
|
"태양과바람 8호기": "hyundai-08",
|
||||||
|
"태양과바람 9호기": "nrems-09",
|
||||||
|
"태양과바람 10호기": "cmsolar-10"
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_and_recover(log_path, start_time_str, end_time_str):
|
||||||
|
"""
|
||||||
|
1. Removes bad data (where current_kw == generation_kwh but current_kw should be 0)
|
||||||
|
Or simpler: remove ALL hourly data for the period and re-insert.
|
||||||
|
2. Parses log and re-inserts data.
|
||||||
|
"""
|
||||||
|
print(f"🧹 Cleaning DB data from {start_time_str} to {end_time_str}...")
|
||||||
|
|
||||||
|
# Convert local times to UTC range for deletion query
|
||||||
|
# But wait, save_history sends timezone-aware timestamp (+09:00).
|
||||||
|
# Supabase stores as UTC.
|
||||||
|
# To delete, we can use the same string range if we are careful, or convert.
|
||||||
|
# The safest way is to target the range.
|
||||||
|
|
||||||
|
# 1. Delete existing records in the range
|
||||||
|
client = get_supabase_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
|
||||||
|
# KST to UTC conversion for query
|
||||||
|
# 2026-02-12 17:00:00 KST -> 08:00 UTC
|
||||||
|
# 2026-02-13 10:00:00 KST -> 01:00 UTC (next day)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
end_dt = datetime.strptime(end_time_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# UTC subtract 9 hours
|
||||||
|
from datetime import timedelta
|
||||||
|
start_utc = (start_dt - timedelta(hours=9)).isoformat()
|
||||||
|
end_utc = (end_dt - timedelta(hours=9)).isoformat()
|
||||||
|
|
||||||
|
print(f" Deleting range (UTC): {start_utc} ~ {end_utc}")
|
||||||
|
|
||||||
|
# Delete solar_logs
|
||||||
|
res = client.table("solar_logs").delete() \
|
||||||
|
.gte("created_at", start_utc) \
|
||||||
|
.lte("created_at", end_utc) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
print(f"✅ Deleted {len(res.data) if res.data else '0'} records.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Deletion failed: {e}")
|
||||||
|
# Proceed to insert anyway? Duplicates might occur if delete failed.
|
||||||
|
|
||||||
|
print(f"📂 Parsing log: {log_path}")
|
||||||
|
|
||||||
|
start_pattern = re.compile(r"통합 관제 시스템.*\((\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\)")
|
||||||
|
table_pattern = re.compile(r"(태양과바람 \d+호기)\s+\|\s+([\d.]+)\s+\|\s+([\d.]+)\s+\|")
|
||||||
|
|
||||||
|
current_timestamp = None
|
||||||
|
recovered_data = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
start_match = start_pattern.search(line)
|
||||||
|
if start_match:
|
||||||
|
ts_str = start_match.group(1)
|
||||||
|
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
if start_dt <= ts_dt <= end_dt:
|
||||||
|
current_timestamp = ts_str
|
||||||
|
else:
|
||||||
|
current_timestamp = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_timestamp:
|
||||||
|
table_match = table_pattern.search(line)
|
||||||
|
if table_match:
|
||||||
|
plant_name = table_match.group(1).strip()
|
||||||
|
kw = float(table_match.group(2))
|
||||||
|
kwh = float(table_match.group(3))
|
||||||
|
|
||||||
|
plant_id = PLANT_MAP.get(plant_name)
|
||||||
|
if plant_id:
|
||||||
|
recovered_data.append({
|
||||||
|
'plant_id': plant_id,
|
||||||
|
'timestamp': current_timestamp,
|
||||||
|
'current_kw': kw, # Now database.py handles 0.0 correctly
|
||||||
|
'generation_kwh': kwh
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error parsing log: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ Found {len(recovered_data)} points to restore.")
|
||||||
|
|
||||||
|
if not recovered_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
chunk_size = 100
|
||||||
|
total_saved = 0
|
||||||
|
for i in range(0, len(recovered_data), chunk_size):
|
||||||
|
chunk = recovered_data[i:i + chunk_size]
|
||||||
|
if save_history(chunk, 'hourly'):
|
||||||
|
total_saved += len(chunk)
|
||||||
|
else:
|
||||||
|
print("❌ Insert failed")
|
||||||
|
|
||||||
|
print(f"🎉 Recovery finished. {total_saved} records inserted.")
|
||||||
|
|
||||||
|
# 2. Daily stats update (optional, but safe to do)
|
||||||
|
# ... (omitted for brevity, hourly is critical data)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
log_file = r"d:\dev\etc\SolorPower\crawler\log\cron.log"
|
||||||
|
# Target period: Yesterday 17:00 ~ Today 10:00
|
||||||
|
start = "2026-02-12 17:00:00"
|
||||||
|
end = "2026-02-13 10:00:00"
|
||||||
|
|
||||||
|
clean_and_recover(log_file, start, end)
|
||||||
502
crawler/venv_win/Scripts/Activate.ps1
Normal file
502
crawler/venv_win/Scripts/Activate.ps1
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
|
|
||||||
|
# SIG # Begin signature block
|
||||||
|
# MIIvJAYJKoZIhvcNAQcCoIIvFTCCLxECAQExDzANBglghkgBZQMEAgEFADB5Bgor
|
||||||
|
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
|
||||||
|
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk
|
||||||
|
# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCE8MwggWQMIIDeKADAgECAhAFmxtXno4h
|
||||||
|
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
|
||||||
|
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
|
||||||
|
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
|
||||||
|
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
|
||||||
|
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
|
||||||
|
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
|
||||||
|
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
|
||||||
|
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
|
||||||
|
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
|
||||||
|
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
|
||||||
|
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
|
||||||
|
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
|
||||||
|
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
|
||||||
|
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
|
||||||
|
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
|
||||||
|
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
|
||||||
|
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
|
||||||
|
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
|
||||||
|
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
|
||||||
|
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
|
||||||
|
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
|
||||||
|
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
|
||||||
|
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
|
||||||
|
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
|
||||||
|
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
|
||||||
|
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
|
||||||
|
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
|
||||||
|
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
|
||||||
|
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
|
||||||
|
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
|
||||||
|
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
|
||||||
|
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
|
||||||
|
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
|
||||||
|
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
|
||||||
|
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
|
||||||
|
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
|
||||||
|
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
|
||||||
|
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
|
||||||
|
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
|
||||||
|
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
|
||||||
|
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
|
||||||
|
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
|
||||||
|
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
|
||||||
|
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
|
||||||
|
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
|
||||||
|
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
|
||||||
|
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
|
||||||
|
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
|
||||||
|
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
|
||||||
|
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
|
||||||
|
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
|
||||||
|
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
|
||||||
|
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
|
||||||
|
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
|
||||||
|
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
|
||||||
|
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
|
||||||
|
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
|
||||||
|
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
|
||||||
|
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
|
||||||
|
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
|
||||||
|
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
|
||||||
|
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
|
||||||
|
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
|
||||||
|
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
|
||||||
|
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
|
||||||
|
# eE4wggd3MIIFX6ADAgECAhAHHxQbizANJfMU6yMM0NHdMA0GCSqGSIb3DQEBCwUA
|
||||||
|
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
|
||||||
|
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
|
||||||
|
# ODQgMjAyMSBDQTEwHhcNMjIwMTE3MDAwMDAwWhcNMjUwMTE1MjM1OTU5WjB8MQsw
|
||||||
|
# CQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQHEwlCZWF2ZXJ0b24x
|
||||||
|
# IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMSMwIQYDVQQDExpQ
|
||||||
|
# eXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
|
||||||
|
# ADCCAgoCggIBAKgc0BTT+iKbtK6f2mr9pNMUTcAJxKdsuOiSYgDFfwhjQy89koM7
|
||||||
|
# uP+QV/gwx8MzEt3c9tLJvDccVWQ8H7mVsk/K+X+IufBLCgUi0GGAZUegEAeRlSXx
|
||||||
|
# xhYScr818ma8EvGIZdiSOhqjYc4KnfgfIS4RLtZSrDFG2tN16yS8skFa3IHyvWdb
|
||||||
|
# D9PvZ4iYNAS4pjYDRjT/9uzPZ4Pan+53xZIcDgjiTwOh8VGuppxcia6a7xCyKoOA
|
||||||
|
# GjvCyQsj5223v1/Ig7Dp9mGI+nh1E3IwmyTIIuVHyK6Lqu352diDY+iCMpk9Zanm
|
||||||
|
# SjmB+GMVs+H/gOiofjjtf6oz0ki3rb7sQ8fTnonIL9dyGTJ0ZFYKeb6BLA66d2GA
|
||||||
|
# LwxZhLe5WH4Np9HcyXHACkppsE6ynYjTOd7+jN1PRJahN1oERzTzEiV6nCO1M3U1
|
||||||
|
# HbPTGyq52IMFSBM2/07WTJSbOeXjvYR7aUxK9/ZkJiacl2iZI7IWe7JKhHohqKuc
|
||||||
|
# eQNyOzxTakLcRkzynvIrk33R9YVqtB4L6wtFxhUjvDnQg16xot2KVPdfyPAWd81w
|
||||||
|
# tZADmrUtsZ9qG79x1hBdyOl4vUtVPECuyhCxaw+faVjumapPUnwo8ygflJJ74J+B
|
||||||
|
# Yxf6UuD7m8yzsfXWkdv52DjL74TxzuFTLHPyARWCSCAbzn3ZIly+qIqDAgMBAAGj
|
||||||
|
# ggIGMIICAjAfBgNVHSMEGDAWgBRoN+Drtjv4XxGG+/5hewiIZfROQjAdBgNVHQ4E
|
||||||
|
# FgQUt/1Teh2XDuUj2WW3siYWJgkZHA8wDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
|
||||||
|
# MAoGCCsGAQUFBwMDMIG1BgNVHR8Ega0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRp
|
||||||
|
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
|
||||||
|
# QTM4NDIwMjFDQTEuY3JsMFOgUaBPhk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20v
|
||||||
|
# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex
|
||||||
|
# LmNybDA+BgNVHSAENzA1MDMGBmeBDAEEATApMCcGCCsGAQUFBwIBFhtodHRwOi8v
|
||||||
|
# d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUF
|
||||||
|
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6
|
||||||
|
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWdu
|
||||||
|
# aW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZI
|
||||||
|
# hvcNAQELBQADggIBABxv4AeV/5ltkELHSC63fXAFYS5tadcWTiNc2rskrNLrfH1N
|
||||||
|
# s0vgSZFoQxYBFKI159E8oQQ1SKbTEubZ/B9kmHPhprHya08+VVzxC88pOEvz68nA
|
||||||
|
# 82oEM09584aILqYmj8Pj7h/kmZNzuEL7WiwFa/U1hX+XiWfLIJQsAHBla0i7QRF2
|
||||||
|
# de8/VSF0XXFa2kBQ6aiTsiLyKPNbaNtbcucaUdn6vVUS5izWOXM95BSkFSKdE45O
|
||||||
|
# q3FForNJXjBvSCpwcP36WklaHL+aHu1upIhCTUkzTHMh8b86WmjRUqbrnvdyR2yd
|
||||||
|
# I5l1OqcMBjkpPpIV6wcc+KY/RH2xvVuuoHjlUjwq2bHiNoX+W1scCpnA8YTs2d50
|
||||||
|
# jDHUgwUo+ciwpffH0Riq132NFmrH3r67VaN3TuBxjI8SIZM58WEDkbeoriDk3hxU
|
||||||
|
# 8ZWV7b8AW6oyVBGfM06UgkfMb58h+tJPrFx8VI/WLq1dTqMfZOm5cuclMnUHs2uq
|
||||||
|
# rRNtnV8UfidPBL4ZHkTcClQbCoz0UbLhkiDvIS00Dn+BBcxw/TKqVL4Oaz3bkMSs
|
||||||
|
# M46LciTeucHY9ExRVt3zy7i149sd+F4QozPqn7FrSVHXmem3r7bjyHTxOgqxRCVa
|
||||||
|
# 18Vtx7P/8bYSBeS+WHCKcliFCecspusCDSlnRUjZwyPdP0VHxaZg2unjHY3rMYIa
|
||||||
|
# tzCCGrMCAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
|
||||||
|
# Yy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJT
|
||||||
|
# QTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAHHxQbizANJfMU6yMM0NHdMA0GCWCGSAFl
|
||||||
|
# AwQCAQUAoIHIMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC
|
||||||
|
# AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBnAZ6P7YvTwq0fbF62
|
||||||
|
# o7E75R0LxsW5OtyYiFESQckLhjBcBgorBgEEAYI3AgEMMU4wTKBGgEQAQgB1AGkA
|
||||||
|
# bAB0ADoAIABSAGUAbABlAGEAcwBlAF8AdgAzAC4AMQAxAC4ANQBfADIAMAAyADMA
|
||||||
|
# MAA4ADIANAAuADAAMaECgAAwDQYJKoZIhvcNAQEBBQAEggIAhs4bX7EeJZ6oSTC7
|
||||||
|
# 5QH/9Qx1Cllidnzj94PqDIL0MiS5adMxYTBkEtP3XNQvYkCtBFc6+Rz7bdN+zWWo
|
||||||
|
# ZYr+sDmEQmRerr3RYyqt+EpgOXpN4BGsHyD7r1Dat1wblGSva8zlOHiIAfzRj2JB
|
||||||
|
# 0+fRJPSBRj9RYwZb5h+I2AFLmHf3yItUdgs8GV9NZsAs+p79dRmoqhgNC6qm8I0j
|
||||||
|
# PkwGr5ATZLyCk2U1+VGeK8iwAdTB4HAlVVM146D/34j/QPnoqe9ICE6Foo6IArVV
|
||||||
|
# CbqWRShWHffvpKaR7ACoTy9LoIQf93orWoc+amQsyaUmlV/zQaCnyjc2UoFCDHcH
|
||||||
|
# 87Yg+frSB8xe2azuKUTVlUDx9Y5wOtEgK+o8wg4ufwPZP0JnsVzN06aCNBz2Bnfb
|
||||||
|
# Mb96Mp0PoCnjp8eAKttmRTXWE0DYIv/XAr2xwwJLFEUdoG6bj0bpNF7Wz0/c3mi0
|
||||||
|
# NKZsd9xNLKBKjizQgCZ7SGCMuSjEnd6P0AI7M8jRx+NROKcJI6gjH0oKXm9JLvI8
|
||||||
|
# oKB2COIlxKEUI/R/kBOeKp53zUSsPFRiJrDEkiCFocAFdUTE326b9/acGbQPJJJ+
|
||||||
|
# nMXLrbTrMMohlj7qRshvO0ZVvpqBDoHlRQcJcfINEESgKNKx/bTpr5cuM3WIS5Ft
|
||||||
|
# 1GirQp9sABVeBom9Y0NDFXsCSkqhghdAMIIXPAYKKwYBBAGCNwMDATGCFywwghco
|
||||||
|
# BgkqhkiG9w0BBwKgghcZMIIXFQIBAzEPMA0GCWCGSAFlAwQCAQUAMHgGCyqGSIb3
|
||||||
|
# DQEJEAEEoGkEZzBlAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQgDHAE
|
||||||
|
# wrb/OjfkdGEAR/N6/5LxwnpqnhSdUI5gfWTSXKECEQDkKzKdiKykh3cqy0kBK32H
|
||||||
|
# GA8yMDIzMDgyNDE0NTcyOFqgghMJMIIGwjCCBKqgAwIBAgIQBUSv85SdCDmmv9s/
|
||||||
|
# X+VhFjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln
|
||||||
|
# aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5
|
||||||
|
# NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIzMDcxNDAwMDAwMFoXDTM0MTAx
|
||||||
|
# MzIzNTk1OVowSDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
|
||||||
|
# MSAwHgYDVQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMzCCAiIwDQYJKoZIhvcN
|
||||||
|
# AQEBBQADggIPADCCAgoCggIBAKNTRYcdg45brD5UsyPgz5/X5dLnXaEOCdwvSKOX
|
||||||
|
# ejsqnGfcYhVYwamTEafNqrJq3RApih5iY2nTWJw1cb86l+uUUI8cIOrHmjsvlmbj
|
||||||
|
# aedp/lvD1isgHMGXlLSlUIHyz8sHpjBoyoNC2vx/CSSUpIIa2mq62DvKXd4ZGIX7
|
||||||
|
# ReoNYWyd/nFexAaaPPDFLnkPG2ZS48jWPl/aQ9OE9dDH9kgtXkV1lnX+3RChG4PB
|
||||||
|
# uOZSlbVH13gpOWvgeFmX40QrStWVzu8IF+qCZE3/I+PKhu60pCFkcOvV5aDaY7Mu
|
||||||
|
# 6QXuqvYk9R28mxyyt1/f8O52fTGZZUdVnUokL6wrl76f5P17cz4y7lI0+9S769Sg
|
||||||
|
# LDSb495uZBkHNwGRDxy1Uc2qTGaDiGhiu7xBG3gZbeTZD+BYQfvYsSzhUa+0rRUG
|
||||||
|
# FOpiCBPTaR58ZE2dD9/O0V6MqqtQFcmzyrzXxDtoRKOlO0L9c33u3Qr/eTQQfqZc
|
||||||
|
# ClhMAD6FaXXHg2TWdc2PEnZWpST618RrIbroHzSYLzrqawGw9/sqhux7UjipmAmh
|
||||||
|
# cbJsca8+uG+W1eEQE/5hRwqM/vC2x9XH3mwk8L9CgsqgcT2ckpMEtGlwJw1Pt7U2
|
||||||
|
# 0clfCKRwo+wK8REuZODLIivK8SgTIUlRfgZm0zu++uuRONhRB8qUt+JQofM604qD
|
||||||
|
# y0B7AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAW
|
||||||
|
# BgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg
|
||||||
|
# hkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0O
|
||||||
|
# BBYEFKW27xPn783QZKHVVqllMaPe1eNJMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6
|
||||||
|
# Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEy
|
||||||
|
# NTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUF
|
||||||
|
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6
|
||||||
|
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZT
|
||||||
|
# SEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAIEa1t6g
|
||||||
|
# qbWYF7xwjU+KPGic2CX/yyzkzepdIpLsjCICqbjPgKjZ5+PF7SaCinEvGN1Ott5s
|
||||||
|
# 1+FgnCvt7T1IjrhrunxdvcJhN2hJd6PrkKoS1yeF844ektrCQDifXcigLiV4JZ0q
|
||||||
|
# BXqEKZi2V3mP2yZWK7Dzp703DNiYdk9WuVLCtp04qYHnbUFcjGnRuSvExnvPnPp4
|
||||||
|
# 4pMadqJpddNQ5EQSviANnqlE0PjlSXcIWiHFtM+YlRpUurm8wWkZus8W8oM3NG6w
|
||||||
|
# QSbd3lqXTzON1I13fXVFoaVYJmoDRd7ZULVQjK9WvUzF4UbFKNOt50MAcN7MmJ4Z
|
||||||
|
# iQPq1JE3701S88lgIcRWR+3aEUuMMsOI5ljitts++V+wQtaP4xeR0arAVeOGv6wn
|
||||||
|
# LEHQmjNKqDbUuXKWfpd5OEhfysLcPTLfddY2Z1qJ+Panx+VPNTwAvb6cKmx5Adza
|
||||||
|
# ROY63jg7B145WPR8czFVoIARyxQMfq68/qTreWWqaNYiyjvrmoI1VygWy2nyMpqy
|
||||||
|
# 0tg6uLFGhmu6F/3Ed2wVbK6rr3M66ElGt9V/zLY4wNjsHPW2obhDLN9OTH0eaHDA
|
||||||
|
# dwrUAuBcYLso/zjlUlrWrBciI0707NMX+1Br/wd3H3GXREHJuEbTbDJ8WC9nR2Xl
|
||||||
|
# G3O2mflrLAZG70Ee8PBf4NvZrZCARK+AEEGKMIIGrjCCBJagAwIBAgIQBzY3tyRU
|
||||||
|
# fNhHrP0oZipeWzANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
|
||||||
|
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
|
||||||
|
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjIwMzIzMDAwMDAwWhcN
|
||||||
|
# MzcwMzIyMjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs
|
||||||
|
# IEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEy
|
||||||
|
# NTYgVGltZVN0YW1waW5nIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC
|
||||||
|
# AgEAxoY1BkmzwT1ySVFVxyUDxPKRN6mXUaHW0oPRnkyibaCwzIP5WvYRoUQVQl+k
|
||||||
|
# iPNo+n3znIkLf50fng8zH1ATCyZzlm34V6gCff1DtITaEfFzsbPuK4CEiiIY3+va
|
||||||
|
# PcQXf6sZKz5C3GeO6lE98NZW1OcoLevTsbV15x8GZY2UKdPZ7Gnf2ZCHRgB720RB
|
||||||
|
# idx8ald68Dd5n12sy+iEZLRS8nZH92GDGd1ftFQLIWhuNyG7QKxfst5Kfc71ORJn
|
||||||
|
# 7w6lY2zkpsUdzTYNXNXmG6jBZHRAp8ByxbpOH7G1WE15/tePc5OsLDnipUjW8LAx
|
||||||
|
# E6lXKZYnLvWHpo9OdhVVJnCYJn+gGkcgQ+NDY4B7dW4nJZCYOjgRs/b2nuY7W+yB
|
||||||
|
# 3iIU2YIqx5K/oN7jPqJz+ucfWmyU8lKVEStYdEAoq3NDzt9KoRxrOMUp88qqlnNC
|
||||||
|
# aJ+2RrOdOqPVA+C/8KI8ykLcGEh/FDTP0kyr75s9/g64ZCr6dSgkQe1CvwWcZklS
|
||||||
|
# UPRR8zZJTYsg0ixXNXkrqPNFYLwjjVj33GHek/45wPmyMKVM1+mYSlg+0wOI/rOP
|
||||||
|
# 015LdhJRk8mMDDtbiiKowSYI+RQQEgN9XyO7ZONj4KbhPvbCdLI/Hgl27KtdRnXi
|
||||||
|
# YKNYCQEoAA6EVO7O6V3IXjASvUaetdN2udIOa5kM0jO0zbECAwEAAaOCAV0wggFZ
|
||||||
|
# MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLoW2W1NhS9zKXaaL3WMaiCP
|
||||||
|
# nshvMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQE
|
||||||
|
# AwIBhjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYB
|
||||||
|
# BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0
|
||||||
|
# cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j
|
||||||
|
# cnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp
|
||||||
|
# Z2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJ
|
||||||
|
# YIZIAYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQB9WY7Ak7ZvmKlEIgF+ZtbYIULh
|
||||||
|
# sBguEE0TzzBTzr8Y+8dQXeJLKftwig2qKWn8acHPHQfpPmDI2AvlXFvXbYf6hCAl
|
||||||
|
# NDFnzbYSlm/EUExiHQwIgqgWvalWzxVzjQEiJc6VaT9Hd/tydBTX/6tPiix6q4XN
|
||||||
|
# Q1/tYLaqT5Fmniye4Iqs5f2MvGQmh2ySvZ180HAKfO+ovHVPulr3qRCyXen/KFSJ
|
||||||
|
# 8NWKcXZl2szwcqMj+sAngkSumScbqyQeJsG33irr9p6xeZmBo1aGqwpFyd/EjaDn
|
||||||
|
# mPv7pp1yr8THwcFqcdnGE4AJxLafzYeHJLtPo0m5d2aR8XKc6UsCUqc3fpNTrDsd
|
||||||
|
# CEkPlM05et3/JWOZJyw9P2un8WbDQc1PtkCbISFA0LcTJM3cHXg65J6t5TRxktcm
|
||||||
|
# a+Q4c6umAU+9Pzt4rUyt+8SVe+0KXzM5h0F4ejjpnOHdI/0dKNPH+ejxmF/7K9h+
|
||||||
|
# 8kaddSweJywm228Vex4Ziza4k9Tm8heZWcpw8De/mADfIBZPJ/tgZxahZrrdVcA6
|
||||||
|
# KYawmKAr7ZVBtzrVFZgxtGIJDwq9gdkT/r+k0fNX2bwE+oLeMt8EifAAzV3C+dAj
|
||||||
|
# fwAL5HYCJtnwZXZCpimHCUcr5n8apIUP/JiW9lVUKx+A+sDyDivl1vupL0QVSucT
|
||||||
|
# Dh3bNzgaoSv27dZ8/DCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJ
|
||||||
|
# KoZIhvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IElu
|
||||||
|
# YzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQg
|
||||||
|
# QXNzdXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1
|
||||||
|
# OVowYjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UE
|
||||||
|
# CxMQd3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBS
|
||||||
|
# b290IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+Rd
|
||||||
|
# SjwwIjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20d
|
||||||
|
# q7J58soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7f
|
||||||
|
# gvMHhOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRA
|
||||||
|
# X7F6Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raR
|
||||||
|
# mECQecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzU
|
||||||
|
# vK4bA3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2
|
||||||
|
# mHY9WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkr
|
||||||
|
# fsCUtNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaA
|
||||||
|
# sPvoZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxf
|
||||||
|
# jT/JvNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEe
|
||||||
|
# xcCPorF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQF
|
||||||
|
# MAMBAf8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaA
|
||||||
|
# FEXroq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcB
|
||||||
|
# AQRtMGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggr
|
||||||
|
# BgEFBQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNz
|
||||||
|
# dXJlZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5k
|
||||||
|
# aWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQK
|
||||||
|
# MAgwBgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3
|
||||||
|
# v1cHvZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy
|
||||||
|
# 3iS8UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cn
|
||||||
|
# RNTnf+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3
|
||||||
|
# WlxUjG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2
|
||||||
|
# zm8jLfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGC
|
||||||
|
# A3YwggNyAgEBMHcwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ
|
||||||
|
# bmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hBMjU2
|
||||||
|
# IFRpbWVTdGFtcGluZyBDQQIQBUSv85SdCDmmv9s/X+VhFjANBglghkgBZQMEAgEF
|
||||||
|
# AKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8X
|
||||||
|
# DTIzMDgyNDE0NTcyOFowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUZvArMsLCyQ+C
|
||||||
|
# Xc6qisnGTxmcz0AwLwYJKoZIhvcNAQkEMSIEICXoYBY9n9CHBlb2ZPmyIOzhh93Z
|
||||||
|
# zUXRoskCDmMHyq3cMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEINL25G3tdCLM0dRA
|
||||||
|
# V2hBNm+CitpVmq4zFq9NGprUDHgoMA0GCSqGSIb3DQEBAQUABIICAFUzNs5f5wsA
|
||||||
|
# nHsLg2yauMwAyYAuQIL8+GKYnWW/AtSWnA/t+S4LbjIJaIpBzZaWTai8/I23tJJw
|
||||||
|
# W1CTYDV3hqPGG/8PEcs8RY12JQoYMRZHzHTkNvUJC9xMXfuZIxtCmoFP2xsQjLgP
|
||||||
|
# Pl45FYCo3NzWCwQ8A2SyR48lskuJ94Q7PADJHkTU7pEY0t/N6114Mo9aO+n6qSLJ
|
||||||
|
# huEu1DmWE7iarxtIKja66BQEHjdawlSbg82Fg8EfkfsAXDHLqH1pahvnWmOziFLp
|
||||||
|
# SOrFKfyUVdCoGR7k3bKkHO62AeWz/LbzN0HPkzV7xrh/PD+4rwzatBpSwzFUFgRN
|
||||||
|
# 8Zg+Kso4LgTktu3nW9rG3TkFUBM3WsP9atnUfvCGvAcDr4Qv5qSx1cyFhuTK4gRj
|
||||||
|
# FVEO93RAMu0S54vTXBQDjl/55MEgWkFinTMqbkM4DpqqMp2uBjp5sqHbns6cDOD+
|
||||||
|
# o+HyXhE9XKxu3myklhq338QMSKE8bcdQ0XogmOwqgrVkpiG3jH+2R9CJlPwsyJLM
|
||||||
|
# TYwlsno3hd4+w2SPtySgNXwZGSj2KRNJFMaePyru8QeVS33pcmXNh+CKrdW5fxk0
|
||||||
|
# C4wWetuCKJTCBDNCRzy5NjrGAkNH2F37JM1pi0n5x2esaTyLC4+gGzDfn5ki0BT4
|
||||||
|
# 8w/WVhUIJnuoyQ33wBhxukpdRjwb71K4
|
||||||
|
# SIG # End signature block
|
||||||
69
crawler/venv_win/Scripts/activate
Normal file
69
crawler/venv_win/Scripts/activate
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# you cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
unset VIRTUAL_ENV_PROMPT
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
VIRTUAL_ENV="D:\dev\etc\SolorPower\crawler\venv_win"
|
||||||
|
export VIRTUAL_ENV
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/Scripts:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1="(venv_win) ${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
VIRTUAL_ENV_PROMPT="(venv_win) "
|
||||||
|
export VIRTUAL_ENV_PROMPT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
34
crawler/venv_win/Scripts/activate.bat
Normal file
34
crawler/venv_win/Scripts/activate.bat
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
@echo off
|
||||||
|
|
||||||
|
rem This file is UTF-8 encoded, so we need to update the current code page while executing it
|
||||||
|
for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do (
|
||||||
|
set _OLD_CODEPAGE=%%a
|
||||||
|
)
|
||||||
|
if defined _OLD_CODEPAGE (
|
||||||
|
"%SystemRoot%\System32\chcp.com" 65001 > nul
|
||||||
|
)
|
||||||
|
|
||||||
|
set VIRTUAL_ENV=D:\dev\etc\SolorPower\crawler\venv_win
|
||||||
|
|
||||||
|
if not defined PROMPT set PROMPT=$P$G
|
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT%
|
||||||
|
if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT=%PROMPT%
|
||||||
|
set PROMPT=(venv_win) %PROMPT%
|
||||||
|
|
||||||
|
if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%
|
||||||
|
set PYTHONHOME=
|
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH%
|
||||||
|
if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH%
|
||||||
|
|
||||||
|
set PATH=%VIRTUAL_ENV%\Scripts;%PATH%
|
||||||
|
set VIRTUAL_ENV_PROMPT=(venv_win)
|
||||||
|
|
||||||
|
:END
|
||||||
|
if defined _OLD_CODEPAGE (
|
||||||
|
"%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul
|
||||||
|
set _OLD_CODEPAGE=
|
||||||
|
)
|
||||||
22
crawler/venv_win/Scripts/deactivate.bat
Normal file
22
crawler/venv_win/Scripts/deactivate.bat
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
@echo off
|
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PROMPT (
|
||||||
|
set "PROMPT=%_OLD_VIRTUAL_PROMPT%"
|
||||||
|
)
|
||||||
|
set _OLD_VIRTUAL_PROMPT=
|
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PYTHONHOME (
|
||||||
|
set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%"
|
||||||
|
set _OLD_VIRTUAL_PYTHONHOME=
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined _OLD_VIRTUAL_PATH (
|
||||||
|
set "PATH=%_OLD_VIRTUAL_PATH%"
|
||||||
|
)
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH=
|
||||||
|
|
||||||
|
set VIRTUAL_ENV=
|
||||||
|
set VIRTUAL_ENV_PROMPT=
|
||||||
|
|
||||||
|
:END
|
||||||
BIN
crawler/venv_win/Scripts/dotenv.exe
Normal file
BIN
crawler/venv_win/Scripts/dotenv.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/httpx.exe
Normal file
BIN
crawler/venv_win/Scripts/httpx.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/markdown-it.exe
Normal file
BIN
crawler/venv_win/Scripts/markdown-it.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/normalizer.exe
Normal file
BIN
crawler/venv_win/Scripts/normalizer.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pip.exe
Normal file
BIN
crawler/venv_win/Scripts/pip.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pip3.11.exe
Normal file
BIN
crawler/venv_win/Scripts/pip3.11.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pip3.exe
Normal file
BIN
crawler/venv_win/Scripts/pip3.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pygmentize.exe
Normal file
BIN
crawler/venv_win/Scripts/pygmentize.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pyiceberg.exe
Normal file
BIN
crawler/venv_win/Scripts/pyiceberg.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/python.exe
Normal file
BIN
crawler/venv_win/Scripts/python.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/pythonw.exe
Normal file
BIN
crawler/venv_win/Scripts/pythonw.exe
Normal file
Binary file not shown.
BIN
crawler/venv_win/Scripts/websockets.exe
Normal file
BIN
crawler/venv_win/Scripts/websockets.exe
Normal file
Binary file not shown.
5
crawler/venv_win/pyvenv.cfg
Normal file
5
crawler/venv_win/pyvenv.cfg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
home = C:\Users\haneu\AppData\Local\Programs\Python\Python311
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.11.5
|
||||||
|
executable = C:\Users\haneu\AppData\Local\Programs\Python\Python311\python.exe
|
||||||
|
command = C:\Users\haneu\AppData\Local\Programs\Python\Python311\python.exe -m venv D:\dev\etc\SolorPower\crawler\venv_win
|
||||||
249
crawler/verify_data.py
Normal file
249
crawler/verify_data.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
데이터 검증 스크립트
|
||||||
|
각 발전소별로 특정 날짜/월/연도의 실제 데이터를 조회하여 검증
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from crawlers import nrems, hyundai, kremc, sun_wms, cmsolar
|
||||||
|
from config import SYSTEM_CONSTANTS
|
||||||
|
|
||||||
|
|
||||||
|
def format_hourly_data(data, date_str, plant_name):
|
||||||
|
"""시간별 데이터 포맷팅"""
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"[{plant_name}] 시간별 데이터: {date_str}")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print(" ❌ 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 시간별로 그룹화
|
||||||
|
hourly_dict = {}
|
||||||
|
for record in data:
|
||||||
|
timestamp = record.get('timestamp', '')
|
||||||
|
if timestamp.startswith(date_str):
|
||||||
|
hour = timestamp.split(' ')[1][:2] if ' ' in timestamp else '00'
|
||||||
|
kwh = record.get('generation_kwh', 0)
|
||||||
|
if hour not in hourly_dict:
|
||||||
|
hourly_dict[hour] = 0
|
||||||
|
hourly_dict[hour] += kwh
|
||||||
|
|
||||||
|
if not hourly_dict:
|
||||||
|
print(" ❌ 해당 날짜 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" 총 {len(hourly_dict)}시간 데이터")
|
||||||
|
print(f"\n {'시간':<8} {'발전량(kWh)':<15}")
|
||||||
|
print(f" {'-'*25}")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for hour in sorted(hourly_dict.keys()):
|
||||||
|
kwh = hourly_dict[hour]
|
||||||
|
total += kwh
|
||||||
|
print(f" {hour}:00 {kwh:>10.2f}")
|
||||||
|
|
||||||
|
print(f" {'-'*25}")
|
||||||
|
print(f" {'합계':<8} {total:>10.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def format_daily_data(data, year_month, plant_name):
|
||||||
|
"""일별 데이터 포맷팅"""
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"[{plant_name}] 일별 데이터: {year_month}")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print(" ❌ 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 해당 월의 데이터만 필터링
|
||||||
|
monthly_data = [d for d in data if d.get('date', '').startswith(year_month)]
|
||||||
|
|
||||||
|
if not monthly_data:
|
||||||
|
print(" ❌ 해당 월 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" 총 {len(monthly_data)}일 데이터")
|
||||||
|
print(f"\n {'날짜':<15} {'발전량(kWh)':<15}")
|
||||||
|
print(f" {'-'*30}")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for record in sorted(monthly_data, key=lambda x: x.get('date', '')):
|
||||||
|
date = record.get('date', '')
|
||||||
|
kwh = record.get('generation_kwh', 0)
|
||||||
|
total += kwh
|
||||||
|
print(f" {date:<15} {kwh:>10.2f}")
|
||||||
|
|
||||||
|
print(f" {'-'*30}")
|
||||||
|
print(f" {'합계':<15} {total:>10.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def format_monthly_data(data, year, plant_name):
|
||||||
|
"""월별 데이터 포맷팅"""
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"[{plant_name}] 월별 데이터: {year}년")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print(" ❌ 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 해당 연도의 데이터만 필터링
|
||||||
|
yearly_data = [d for d in data if d.get('month', '').startswith(year)]
|
||||||
|
|
||||||
|
if not yearly_data:
|
||||||
|
print(" ❌ 해당 연도 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" 총 {len(yearly_data)}개월 데이터")
|
||||||
|
print(f"\n {'월':<10} {'발전량(kWh)':<15}")
|
||||||
|
print(f" {'-'*25}")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for record in sorted(yearly_data, key=lambda x: x.get('month', '')):
|
||||||
|
month = record.get('month', '')
|
||||||
|
kwh = record.get('generation_kwh', 0)
|
||||||
|
total += kwh
|
||||||
|
print(f" {month:<10} {kwh:>10.2f}")
|
||||||
|
|
||||||
|
print(f" {'-'*25}")
|
||||||
|
print(f" {'합계':<10} {total:>10.2f}")
|
||||||
|
if len(yearly_data) > 0:
|
||||||
|
print(f" {'평균':<10} {total/len(yearly_data):>10.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_plant(plant_config, crawler_module):
|
||||||
|
"""개별 발전소 데이터 검증"""
|
||||||
|
plant_name = plant_config['name']
|
||||||
|
|
||||||
|
print(f"\n{'#'*80}")
|
||||||
|
print(f"# {plant_name}")
|
||||||
|
print(f"{'#'*80}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 시간별 데이터: 2025-05-10, 2024-10-20
|
||||||
|
print(f"\n[1/6] 시간별 데이터 수집 중...")
|
||||||
|
|
||||||
|
hourly_2025 = crawler_module.fetch_history_hourly(plant_config, '2025-05-10', '2025-05-10')
|
||||||
|
format_hourly_data(hourly_2025, '2025-05-10', plant_name)
|
||||||
|
|
||||||
|
hourly_2024 = crawler_module.fetch_history_hourly(plant_config, '2024-10-20', '2024-10-20')
|
||||||
|
format_hourly_data(hourly_2024, '2024-10-20', plant_name)
|
||||||
|
|
||||||
|
# 2. 일별 데이터: 2025-05, 2024-07
|
||||||
|
print(f"\n[2/6] 일별 데이터 수집 중...")
|
||||||
|
|
||||||
|
daily_2025 = crawler_module.fetch_history_daily(plant_config, '2025-05-01', '2025-05-31')
|
||||||
|
format_daily_data(daily_2025, '2025-05', plant_name)
|
||||||
|
|
||||||
|
daily_2024 = crawler_module.fetch_history_daily(plant_config, '2024-07-01', '2024-07-31')
|
||||||
|
format_daily_data(daily_2024, '2024-07', plant_name)
|
||||||
|
|
||||||
|
# 3. 월별 데이터: 2024년, 2025년
|
||||||
|
print(f"\n[3/6] 월별 데이터 수집 중...")
|
||||||
|
|
||||||
|
monthly_2025 = crawler_module.fetch_history_monthly(plant_config, '2025-01', '2025-12')
|
||||||
|
format_monthly_data(monthly_2025, '2025', plant_name)
|
||||||
|
|
||||||
|
monthly_2024 = crawler_module.fetch_history_monthly(plant_config, '2024-01', '2024-12')
|
||||||
|
format_monthly_data(monthly_2024, '2024', plant_name)
|
||||||
|
|
||||||
|
print(f"\n>>> {plant_name} 검증 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n ❌ 오류 발생: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 함수"""
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print(">>> 발전소 데이터 검증 스크립트 <<<")
|
||||||
|
print("="*80)
|
||||||
|
print(f"검증 일시: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print("\n[검증 대상]")
|
||||||
|
print(" - 시간별: 2025-05-10, 2024-10-20")
|
||||||
|
print(" - 일별: 2025년 5월, 2024년 7월")
|
||||||
|
print(" - 월별: 2025년, 2024년")
|
||||||
|
|
||||||
|
# 테스트 대상 발전소 설정
|
||||||
|
test_plants = [
|
||||||
|
# NREMS 1,2호기 (분리)
|
||||||
|
({'id': 'nrems-01', 'name': '1호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072288'},
|
||||||
|
'options': {'is_split': True, 'unit_id': 1},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
({'id': 'nrems-02', 'name': '2호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072288'},
|
||||||
|
'options': {'is_split': True, 'unit_id': 2},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 3호기
|
||||||
|
({'id': 'nrems-03', 'name': '3호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'dc2023121086'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 4호기
|
||||||
|
({'id': 'nrems-04', 'name': '4호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'duce2023072269'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# NREMS 9호기
|
||||||
|
({'id': 'nrems-09', 'name': '9호기', 'type': 'nrems',
|
||||||
|
'auth': {'pscode': 'a2020061008'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
|
||||||
|
|
||||||
|
# KREMC 5호기
|
||||||
|
({'id': 'kremc-05', 'name': '5호기', 'type': 'kremc',
|
||||||
|
'auth': {'user_id': '서대문도서관', 'password': 'sunhope5!'},
|
||||||
|
'options': {'cid': '10013000376', 'cityProvCode': '11', 'rgnCode': '11410',
|
||||||
|
'dongCode': '1141011700', 'enso_type_code': '15001'},
|
||||||
|
'system': SYSTEM_CONSTANTS['kremc']}, kremc),
|
||||||
|
|
||||||
|
# Sun-WMS 6호기
|
||||||
|
({'id': 'sunwms-06', 'name': '6호기', 'type': 'sun_wms',
|
||||||
|
'auth': {'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==', 'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['sun_wms']}, sun_wms),
|
||||||
|
|
||||||
|
# Hyundai 8호기
|
||||||
|
({'id': 'hyundai-08', 'name': '8호기', 'type': 'hyundai',
|
||||||
|
'auth': {'user_id': 'epecoop', 'password': 'sunhope0419', 'site_id': 'M0494'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['hyundai']}, hyundai),
|
||||||
|
|
||||||
|
# CMSolar 10호기
|
||||||
|
({'id': 'cmsolar-10', 'name': '10호기', 'type': 'cmsolar',
|
||||||
|
'auth': {'login_id': 'smart3131', 'password': 'ehdrb!123', 'site_no': '834'},
|
||||||
|
'options': {},
|
||||||
|
'system': SYSTEM_CONSTANTS['cmsolar']}, cmsolar),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 각 발전소 검증
|
||||||
|
for plant_config, crawler_module in test_plants:
|
||||||
|
try:
|
||||||
|
verify_plant(plant_config, crawler_module)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ 사용자 중단")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ {plant_config['name']} 검증 실패: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
continue
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print(">>> 데이터 검증 완료 <<<")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
48
docs/check.md
Normal file
48
docs/check.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
[프로젝트 개요]
|
||||||
|
- 태양광 발전소 통합 모니터링 플랫폼 구축
|
||||||
|
|
||||||
|
[프로젝트 배경 및 목표]
|
||||||
|
- 당사는 태양광 발전소 시공 및 관리를 전문으로 하는 기업입니다. 현재 각 발전소에 설치된 장비 제조사별로 모니터링 웹사이트가 달라, 발전량 데이터를 확인하기 위해 여러 사이트를 개별적으로 접속해야 하는 번거로움이 있습니다.
|
||||||
|
이에, 분산된 여러 제조사의 모니터링 사이트 데이터를 하나의 플랫폼으로 통합하여 보여주는 시스템을 구축하고자 합니다.
|
||||||
|
1차적으로는 통합 모니터링 기능에 집중하고, 장기적으로는 공장 에너지 관리 시스템(EMS) 및 가상발전소(VPP) 기능까지 확장 가능한 기반을 마련하는 것을 목표로 합니다.
|
||||||
|
|
||||||
|
[과업 범위]
|
||||||
|
1. 수행 범위
|
||||||
|
- 상세 기획: 요구 사항 정의, 기능 정의, 화면 설계
|
||||||
|
- UI/UX 디자인
|
||||||
|
- 프런트엔드/Client 개발 (PC Web)
|
||||||
|
- 백엔드 개발
|
||||||
|
- 서버/DB/인프라 구성
|
||||||
|
|
||||||
|
2. 상세 기능 요구 사항
|
||||||
|
2-1. 통합 모니터링 대시보드: 사용자가 등록한 모든 발전소의 발전량, 상태 등 핵심 데이터를 한 화면에서 직관적으로 파악할 수 있는 대시보드 기능을 제공합니다.
|
||||||
|
2-2. 발전소 및 데이터 연동 관리: 사용자가 각 제조사별 모니터링 사이트의 계정 정보(ID/PW)를 입력하여 자신의 발전소를 플랫폼에 등록하고, 등록된 정보를 기반으로 시스템이 자동으로 데이터를 연동(수집)하는 기능을 구현합니다.
|
||||||
|
2-3. 사용자 관리 시스템: 플랫폼을 사용하는 고객사가 직접 회원가입, 로그인, 정보 수정을 할 수 있는 회원 관리 기능을 제공합니다.
|
||||||
|
2-4. 관리자 페이지 기능: 전체 사용자 계정 관리, 데이터 연동 상태 모니터링, 시스템 공지사항 등록 등 플랫폼 운영을 위한 포괄적인 관리자 기능을 포함합니다.
|
||||||
|
|
||||||
|
[지원 디바이스 및 디자인]
|
||||||
|
1. 지원 디바이스
|
||||||
|
- PC Web (반응형 지원)
|
||||||
|
2. 디자인 가이드
|
||||||
|
- 벤치마킹 대상: 발전왕
|
||||||
|
- 브랜드 가이드라인: 자사 브랜딩 적용(화이트 라벨링 방식)을 위한 협의 필요
|
||||||
|
|
||||||
|
[기술 스택]
|
||||||
|
프로젝트에 가장 적합하다고 판단되는 기술 스택을 자유롭게 제안해 주시기 바랍니다.
|
||||||
|
|
||||||
|
[지원 자격 및 우대 사항]
|
||||||
|
1. 지원 자격
|
||||||
|
- 유사 통합 모니터링 플랫폼 개발 경험
|
||||||
|
- 에너지 관리 시스템(EMS) 또는 관련 플랫폼 개발 경험 보유 업체
|
||||||
|
2. 우대 사항
|
||||||
|
- 외부 데이터 연동 및 파싱 관련 기술 경험
|
||||||
|
- 장기적인 관점에서 협력 및 시스템 고도화가 가능한 업체
|
||||||
|
|
||||||
|
[산출물]
|
||||||
|
- 상세 기획서 (IA, 기능정의서, 화면설계서 등)
|
||||||
|
- UI/UX 디자인 원본 파일 (Figma, XD 등)
|
||||||
|
- 개발 소스코드 일체
|
||||||
|
- 서버 구성 및 인프라 관련 명세서
|
||||||
|
|
||||||
|
[계약 관련 특이 사항]
|
||||||
|
- 본 프로젝트는 1단계 사업이며, 향후 에너지 관리 시스템(EMS), 전력 중개, 가상발전소(VPP) 등으로의 단계적 기능 고도화를 계획하고 있습니다. 장기적인 파트너십을 고려하여 제안해 주시기 바랍니다.
|
||||||
315
docs/platform_analysis_report.md
Normal file
315
docs/platform_analysis_report.md
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
# 태양광 발전소 통합 모니터링 플랫폼 — 종합 분석 보고서
|
||||||
|
|
||||||
|
> 작성일: 2026-04-22
|
||||||
|
> 참조 문서: `docs/check.md` (과업 요구사항), `README.md` (현 시스템 현황)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 전체상 (Vision)
|
||||||
|
|
||||||
|
### 1-1. 현재 시스템 vs. 목표 시스템
|
||||||
|
|
||||||
|
| 구분 | 현재 SolarPower | 목표 플랫폼 (check.md) |
|
||||||
|
|------|----------------|----------------------|
|
||||||
|
| **사용 주체** | 내부 관리자 1인 | 다수의 외부 고객사 (B2B) |
|
||||||
|
| **발전소 수** | 약 9개 (고정) | 무제한 (사용자가 직접 등록) |
|
||||||
|
| **크롤러 관리** | 개발자가 직접 코딩 | 사용자 계정 기반 자동 생성 |
|
||||||
|
| **UI** | React Native App (모바일 중심) | PC Web 반응형 (발전왕 수준) |
|
||||||
|
| **인프라 격리** | 단일 테넌트 | 멀티 테넌트 (고객사 간 데이터 완전 격리) |
|
||||||
|
| **확장 목표** | 현행 모니터링 유지 | EMS, VPP까지 단계적 확장 |
|
||||||
|
|
||||||
|
### 1-2. 제안 아키텍처 개요
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph CLIENT["클라이언트 (PC Web)"]
|
||||||
|
USER["일반 사용자\n(발전소 등록/모니터링)"]
|
||||||
|
ADMIN["관리자\n(계정/시스템 관리)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PLATFORM["플랫폼 코어 (Oracle Cloud)"]
|
||||||
|
NEXTJS["Next.js\nFrontend (SSR)"]
|
||||||
|
FASTAPI["FastAPI\nBackend (REST API)"]
|
||||||
|
SCHEDULER["Crawler Scheduler\n(사용자별 독립 스케줄)"]
|
||||||
|
SECRET_MGR["Secret Manager\n(AES-256 암호화)"]
|
||||||
|
QUEUE["Task Queue\n(Redis/Celery)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CRAWLERS["수집 레이어"]
|
||||||
|
PROXY["Residential Proxy Pool\n(IP 차단 우회)"]
|
||||||
|
HEADLESS["Headless Browser\nCluster (Playwright)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EXTERNAL["외부 제조사 시스템"]
|
||||||
|
NREMS["NREMS"]
|
||||||
|
KREMC["KREMC"]
|
||||||
|
SUNWMS["Sun-WMS"]
|
||||||
|
ETC["기타 제조사..."]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DATA["데이터 레이어"]
|
||||||
|
SUPABASE["Supabase\n(PostgreSQL + RLS)"]
|
||||||
|
TIMESERIES["시계열 DB\n(향후 EMS/VPP용)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
USER --> NEXTJS
|
||||||
|
ADMIN --> NEXTJS
|
||||||
|
NEXTJS --> FASTAPI
|
||||||
|
FASTAPI --> SECRET_MGR
|
||||||
|
FASTAPI --> SCHEDULER
|
||||||
|
SCHEDULER --> QUEUE
|
||||||
|
QUEUE --> HEADLESS
|
||||||
|
HEADLESS --> PROXY
|
||||||
|
PROXY --> NREMS & KREMC & SUNWMS & ETC
|
||||||
|
FASTAPI --> SUPABASE
|
||||||
|
HEADLESS --> SUPABASE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기능별 위험 요소 및 해결 방안
|
||||||
|
|
||||||
|
### 2-1. 핵심 기능: 발전소 등록 및 자동 데이터 연동
|
||||||
|
|
||||||
|
이 기능은 전체 프로젝트에서 **기술적 난이도와 리스크가 가장 높은** 부분입니다.
|
||||||
|
|
||||||
|
#### 🔴 위험 1: 사용자 계정 정보(ID/PW) 보안
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 타사 서비스의 평문 비밀번호를 DB에 저장하는 것은 법적·윤리적 리스크를 내포합니다.
|
||||||
|
- 서버 침해 시 사용자 계정 유출로 인한 2차 피해 및 배상 책임이 발생합니다.
|
||||||
|
- 개인정보보호법 및 정보통신망법 위반 소지가 있습니다.
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
1. 양방향 암호화 필수 적용
|
||||||
|
- 저장: AES-256-GCM 암호화 후 DB 저장
|
||||||
|
- 복호화 키: 애플리케이션 서버 환경변수 또는 전용 KMS(Key Management Service)에 분리 보관
|
||||||
|
- DB 유출 시에도 암호화 키 없이는 복호화 불가
|
||||||
|
|
||||||
|
2. 저장 범위 최소화
|
||||||
|
- 최초 로그인 성공 후 세션 쿠키/토큰만 저장하고 원본 PW는 메모리에서 즉시 파기
|
||||||
|
- 토큰 갱신 주기에만 PW 재사용 (접근 빈도 최소화)
|
||||||
|
|
||||||
|
3. 사용자 동의 및 약관
|
||||||
|
- 계정 정보 위탁 보관에 대한 명시적 동의 받기
|
||||||
|
- 개인정보 처리 방침에 해당 내용 명기
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔴 위험 2: 제조사 사이트의 자동화 탐지 및 차단
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 다수의 계정이 동일 IP에서 짧은 시간 내 로그인 → 보안 시스템에 의해 IP 차단
|
||||||
|
- 사용자 계정이 자동 잠금(Lock) 처리되는 최악의 UX 발생
|
||||||
|
- 2단계 인증(MFA), 캡차(CAPTCHA) 등의 방어 기제
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
1. 분산 프록시 전략
|
||||||
|
- Residential Proxy Pool 사용 (실제 가정용 IP 활용)
|
||||||
|
- 사용자별 고정 프록시 IP 할당 (한 IP = 한 계정 원칙)
|
||||||
|
- 예시 서비스: Bright Data, Oxylabs, IPRoyal
|
||||||
|
|
||||||
|
2. 인간 행동 시뮬레이션
|
||||||
|
- requests 방식 → Playwright/Puppeteer 헤드리스 브라우저로 전환
|
||||||
|
- 로그인 간격을 랜덤하게 설정 (1~5분 지연)
|
||||||
|
- User-Agent, 쿠키, 세션 헤더 완전 복제
|
||||||
|
|
||||||
|
3. 세션 캐싱 전략
|
||||||
|
- 로그인 성공 후 세션 쿠키를 Redis에 캐싱하여 재활용
|
||||||
|
- 세션 만료 직전에만 재로그인 수행 (로그인 빈도 최소화)
|
||||||
|
|
||||||
|
4. 실패 대응 UX
|
||||||
|
- 수집 실패 감지 즉시 → 사용자에게 이메일/앱 알림
|
||||||
|
- "계정 정보를 다시 확인해 주세요" UI 안내 흐름
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🟡 위험 3: 제조사별 크롤러 유지보수 부담
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 제조사 사이트 UI 변경 시 크롤러 즉시 중단
|
||||||
|
- 지원 제조사 수 증가에 따라 유지보수 공수가 선형 증가
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
1. 크롤러 플러그인 아키텍처 도입
|
||||||
|
- 제조사별 크롤러를 독립 모듈(어댑터 패턴)로 분리
|
||||||
|
- 공통 인터페이스(BaseCrawler) 상속으로 신규 제조사 추가 용이
|
||||||
|
|
||||||
|
2. 자동 헬스체크 시스템
|
||||||
|
- 매 수집 후 데이터 유효성(날짜, 수치 범위) 자동 검증
|
||||||
|
- 연속 3회 실패 시 관리자에게 자동 알람 발송
|
||||||
|
|
||||||
|
3. 현행 SolarPower 크롤러 재활용
|
||||||
|
- NREMS, KREMC, Sun-WMS, Hyundai, CMSolar 크롤러는 이미 검증됨
|
||||||
|
- 기존 코드를 멀티테넌트 구조로 래핑하는 방식으로 빠르게 확장 가능
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-2. 멀티테넌시 및 데이터 격리
|
||||||
|
|
||||||
|
#### 🔴 위험: 고객사 간 데이터 혼재
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 현재 구조는 단일 사용자 가정 하에 설계됨
|
||||||
|
- 다수 고객사가 사용할 경우 데이터가 섞일 위험
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
1. Supabase Row Level Security (RLS) 적용
|
||||||
|
- 모든 테이블에 tenant_id 컬럼 추가
|
||||||
|
- RLS 정책: "자신의 tenant_id에 해당하는 행만 SELECT/INSERT/UPDATE 가능"
|
||||||
|
- DB 레벨에서 격리 보장 → 애플리케이션 버그로도 타 사용자 데이터 접근 불가
|
||||||
|
|
||||||
|
2. 데이터 모델 재설계 필요
|
||||||
|
현재: plants (plant_id, name, ...)
|
||||||
|
변경: plants (plant_id, tenant_id, name, ...) ← tenant_id FK 추가
|
||||||
|
credentials (id, tenant_id, plant_id,
|
||||||
|
encrypted_id, encrypted_pw, ...) ← 신규 테이블 (암호화 계정 저장)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-3. 프론트엔드 아키텍처 (PC Web)
|
||||||
|
|
||||||
|
#### 🟡 위험: Expo Web의 B2B 대시보드 한계
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 현재 React Native(Expo) 기반은 모바일 UX 중심
|
||||||
|
- '발전왕' 수준의 복잡한 PC 데이터 대시보드 구현에 Expo는 적합하지 않음
|
||||||
|
- 복잡한 그리드, 데이터 테이블, 인터랙티브 차트를 구현하기 어려움
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
권장 프론트엔드 스택:
|
||||||
|
- Framework: Next.js 14 (App Router, SSR/ISR 지원)
|
||||||
|
- Styling: TailwindCSS + shadcn/ui (B2B 대시보드 컴포넌트)
|
||||||
|
- Charts: Recharts 또는 Apache ECharts (고성능 시계열 차트)
|
||||||
|
- State: Zustand + React Query (서버 상태 캐싱)
|
||||||
|
|
||||||
|
기존 Expo 앱은 모바일 알림 수신용으로 유지하고,
|
||||||
|
PC Web은 Next.js로 별도 구축하는 이원화 전략 추천
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-4. 인프라 확장성 (크롤링 서버)
|
||||||
|
|
||||||
|
#### 🟡 위험: 단일 Oracle Cloud 인스턴스의 한계
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
- 고객사 수 증가 → 동시 크롤링 작업 급증
|
||||||
|
- 나스의 단일 IP를 Exit Node로 사용 → IP 차단 시 전체 서비스 중단
|
||||||
|
|
||||||
|
**해결 방안:**
|
||||||
|
```
|
||||||
|
단계별 인프라 전략:
|
||||||
|
|
||||||
|
[1단계 - 소규모, ~50개 발전소]
|
||||||
|
현행 Oracle Cloud + Tailscale(나스) 구조 유지
|
||||||
|
Celery + Redis로 크롤링 태스크 큐 관리
|
||||||
|
|
||||||
|
[2단계 - 중규모, ~300개 발전소]
|
||||||
|
Residential Proxy Pool 도입 (IP 차단 우회)
|
||||||
|
크롤링 서버 인스턴스 수평 확장 (2~3대)
|
||||||
|
|
||||||
|
[3단계 - 대규모, 300개 이상]
|
||||||
|
Kubernetes 기반 크롤러 파드 자동 스케일링
|
||||||
|
제조사별 전용 크롤러 워커 분리
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 법적·사업적 리스크
|
||||||
|
|
||||||
|
### 3-1. 제조사 서비스 약관 위반
|
||||||
|
|
||||||
|
| 위험 수준 | 내용 | 대응 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 🔴 높음 | 제조사 ToS의 자동화 접근 금지 조항 위반 | 서비스 초기에 제조사에 협력 제안 검토 |
|
||||||
|
| 🟡 중간 | 스크래핑 행위 자체의 법적 회색 지대 | 법무 검토 후 이용약관에 면책 조항 추가 |
|
||||||
|
| 🟢 낮음 | 수집 데이터 저작권 분쟁 | 가공된 통계 데이터만 노출, 원문 미재현 |
|
||||||
|
|
||||||
|
### 3-2. 개인정보 처리
|
||||||
|
|
||||||
|
```
|
||||||
|
필수 조치:
|
||||||
|
- 개인정보 처리 방침 수립 (개인정보보호법 준수)
|
||||||
|
- 타사 계정 정보 위탁 처리에 대한 별도 동의 절차
|
||||||
|
- 개인정보 보호책임자(CPO) 지정
|
||||||
|
- 연 1회 이상 보안 취약점 점검
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 권장 기술 스택
|
||||||
|
|
||||||
|
| 영역 | 현재 | 권장 (플랫폼) | 이유 |
|
||||||
|
|------|------|-------------|------|
|
||||||
|
| **Frontend** | React Native (Expo) | **Next.js 14** | SSR, B2B 대시보드 최적화 |
|
||||||
|
| **Backend** | FastAPI | **FastAPI** 유지 | 검증된 구조 재활용 가능 |
|
||||||
|
| **Database** | Supabase (PostgreSQL) | **Supabase** 유지 + RLS 강화 | 멀티테넌트 RLS 기능 내장 |
|
||||||
|
| **크롤러** | Python requests | **Playwright** (헤드리스) | 자동화 탐지 우회 |
|
||||||
|
| **비동기 큐** | 없음 (Cron) | **Celery + Redis** | 다수 계정 동시 수집 관리 |
|
||||||
|
| **암호화** | 없음 | **AES-256-GCM + KMS** | 계정 정보 안전 보관 |
|
||||||
|
| **프록시** | Tailscale (나스) | **Residential Proxy Pool** | IP 차단 근본 해결 |
|
||||||
|
| **인프라** | Oracle Cloud 단일 | **Oracle Cloud + 수평 확장** | 트래픽 증가 대응 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 단계별 개발 로드맵
|
||||||
|
|
||||||
|
### Phase 1 — MVP (2~3개월)
|
||||||
|
> 목표: 소수 고객사를 대상으로 핵심 기능 검증
|
||||||
|
|
||||||
|
- [ ] 멀티테넌트 DB 스키마 설계 (RLS 포함)
|
||||||
|
- [ ] 사용자 회원가입/로그인 (Supabase Auth 활용)
|
||||||
|
- [ ] 발전소 등록 UI (제조사 선택 → ID/PW 입력)
|
||||||
|
- [ ] 기존 크롤러를 멀티테넌트 구조로 래핑
|
||||||
|
- [ ] 기본 모니터링 대시보드 (일/월별 발전량 차트)
|
||||||
|
- [ ] 수집 실패 알림 시스템
|
||||||
|
|
||||||
|
### Phase 2 — 안정화 (2~3개월)
|
||||||
|
> 목표: 보안 강화 및 운영 안정성 확보
|
||||||
|
|
||||||
|
- [ ] AES-256 계정 암호화 체계 적용
|
||||||
|
- [ ] Playwright 헤드리스 브라우저 전환 (탐지 우회)
|
||||||
|
- [ ] Celery + Redis 비동기 크롤링 큐 도입
|
||||||
|
- [ ] 관리자 페이지 구축 (사용자/연동 상태 관리)
|
||||||
|
- [ ] 크롤러 헬스체크 및 자동 알람 시스템
|
||||||
|
|
||||||
|
### Phase 3 — 확장 (3~4개월)
|
||||||
|
> 목표: 상용 서비스 품질 달성 및 EMS 기반 마련
|
||||||
|
|
||||||
|
- [ ] Residential Proxy Pool 연동 (IP 차단 근본 해결)
|
||||||
|
- [ ] 신규 제조사 크롤러 플러그인 추가 (수요 기반)
|
||||||
|
- [ ] 시계열 DB 도입 (EMS/VPP 대비)
|
||||||
|
- [ ] 화이트 라벨링 지원 (브랜드 커스터마이징)
|
||||||
|
- [ ] 정식 제조사 API 파트너십 추진
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 종합 평가
|
||||||
|
|
||||||
|
### ✅ 현재 프로젝트의 강점 (재활용 가능 자산)
|
||||||
|
|
||||||
|
1. **검증된 크롤러 코드**: NREMS, KREMC, Sun-WMS, Hyundai, CMSolar 등 핵심 제조사 크롤러가 이미 운영 검증됨. 플랫폼 전환 시 가장 큰 기술적 자산.
|
||||||
|
2. **스마트 스케줄러(crawler_manager)**: LEARNING/OPTIMIZED 모드 스케줄링 로직은 멀티테넌트 환경에서도 그대로 활용 가능.
|
||||||
|
3. **FastAPI + Supabase 백엔드**: 확장성 있는 구조로 재설계 부담 최소화.
|
||||||
|
|
||||||
|
### ⚠️ 핵심 해결 과제 (우선순위 순)
|
||||||
|
|
||||||
|
| 우선순위 | 과제 | 난이도 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| 1 | 사용자 계정(ID/PW) 암호화 보관 체계 | ⭐⭐⭐ |
|
||||||
|
| 2 | IP 차단 우회 인프라 (Residential Proxy) | ⭐⭐⭐⭐ |
|
||||||
|
| 3 | 멀티테넌트 DB 설계 및 RLS 적용 | ⭐⭐⭐ |
|
||||||
|
| 4 | 프론트엔드 Next.js 전환 | ⭐⭐ |
|
||||||
|
| 5 | 법적 리스크 검토 (약관 위반) | ⭐⭐⭐ |
|
||||||
|
|
||||||
|
### 💡 최종 제언
|
||||||
|
|
||||||
|
> 현재 SolarPower 프로젝트는 이 플랫폼을 구축하기 위한 **훌륭한 기술적 원형(Prototype)**입니다. 그러나 단순히 "기능 추가"가 아니라 **보안, 멀티테넌시, 인프라** 세 축에서 구조적인 재설계가 필요합니다.
|
||||||
|
>
|
||||||
|
> 특히 **계정 정보 보안**과 **IP 차단 우회**는 서비스 신뢰도의 기반이므로, 개발 착수 전 이 두 가지 아키텍처를 먼저 확정한 후 나머지 기능을 쌓는 순서로 진행하기를 강력히 권장합니다.
|
||||||
75
docs/system_infrastructure_guide.md
Normal file
75
docs/system_infrastructure_guide.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# SolarPower 시스템 인프라 및 구조 가이드
|
||||||
|
|
||||||
|
이 문서는 SolarPower 시스템의 실제 서버 인프라 정보와 로컬/원격 디렉토리 구조를 정리한 가이드입니다.
|
||||||
|
|
||||||
|
## 1. 서버 접속 정보 (Oracle Cloud)
|
||||||
|
|
||||||
|
현재 시스템의 메인 로직(Crawler 및 API)이 구동되고 있는 서버 정보입니다.
|
||||||
|
|
||||||
|
* **서버 이름:** `holdem-server`
|
||||||
|
* **공인 IP (Public):** `140.245.73.212`
|
||||||
|
* **테일스케일 IP:** `100.116.0.32`
|
||||||
|
* **접속 계정:** `ubuntu`
|
||||||
|
* **SSH 키 경로:** `C:\Users\haneu\.ssh\holdem_server.key`
|
||||||
|
|
||||||
|
### 💻 SSH 접속 명령어
|
||||||
|
```powershell
|
||||||
|
# 공인 IP로 접속
|
||||||
|
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@140.245.73.212
|
||||||
|
|
||||||
|
# Tailscale 연결 시 접속
|
||||||
|
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@100.116.0.32
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서버 디렉토리 구조 (Remote)
|
||||||
|
|
||||||
|
서버 내 주요 서비스 설치 경로입니다.
|
||||||
|
|
||||||
|
* **`~/solorpower_crawler/`**: 실시간 데이터 수집기(Crawler)가 실행되는 메인 경로
|
||||||
|
* `main.py`: 10분마다 실행되는 수집 엔트리
|
||||||
|
* `crawler.log`: 크롤링 실행 로그 (표준 출력/에러 통합)
|
||||||
|
* `database.py`: Supabase 저장 로직 (최근 0kW 보호 패치 적용됨)
|
||||||
|
* **`~/solorpower_server/`**: FastAPI 기반의 백엔드 API 서버 경로
|
||||||
|
* **`~/plant_sync/`**: 발전소 정보 동기화 및 기타 유틸리티
|
||||||
|
|
||||||
|
### ⏰ 자동화 스케줄 (Crontab)
|
||||||
|
서버에서 `crontab -l` 명령어로 확인된 자동화 설정입니다.
|
||||||
|
* `*/10 * * * *`: 10분마다 데이터 수집 실행 (`main.py`)
|
||||||
|
* `10 0 * * *`: 매일 0시 10분에 일일 통계 요약 실행 (`daily_summary.py`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 로컬 프로젝트 구조 (Local)
|
||||||
|
|
||||||
|
개발 PC(`d:\dev\etc\SolorPower`)의 구성입니다.
|
||||||
|
|
||||||
|
* **`crawler/`**: Python 기반 데이터 수집기 소스 코드
|
||||||
|
* **`api_server/`**: FastAPI 기반 백엔드 소스 코드
|
||||||
|
* **`app/`**: React Native (Expo) 모바일 애플리케이션 소스 코드
|
||||||
|
* **`sql/`**: 데이터베이스 스키마 및 히스토리 관리
|
||||||
|
* **`docs/`**: 시스템 설계 및 분석 리포트 (현재 이 문서가 포함된 위치)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 흐름 (Data Flow)
|
||||||
|
|
||||||
|
1. **수집:** Crawler(Oracle Cloud) → 각 발전소 사이트 (나스 프록시 경유)
|
||||||
|
2. **저장:** Crawler → Supabase (PostgreSQL)
|
||||||
|
3. **알림:** Crawler → Telegram Bot (이상 감지 시 즉시 발송)
|
||||||
|
4. **제공:** API Server(Oracle Cloud) → Supabase 데이터 가공 후 JSON 제공
|
||||||
|
5. **표시:** Mobile App (Expo) → API 호출 후 차트 및 현황 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 점검 및 대응 가이드
|
||||||
|
|
||||||
|
### 발전량이 0으로 표시될 때 (오늘 발생 사례)
|
||||||
|
1. **서버 접속:** `ssh oracle-fishingmap`이 아닌 `140.245.73.212`로 접속 확인
|
||||||
|
2. **로그 확인:** `tail -n 20 ~/solorpower_crawler/crawler.log`
|
||||||
|
3. **코드 패치:** `git pull origin main` (최신 보호 로직 반영 여부 확인)
|
||||||
|
4. **DB 확인:** Supabase `solar_logs` 테이블에서 해당 시간대 `status` 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
*마지막 업데이트: 2026-05-14 (0kW 오보 보호 패치 및 인프라 정리)*
|
||||||
80
sql/00_init_schema.sql
Normal file
80
sql/00_init_schema.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
-- ==============================================================================
|
||||||
|
-- Initial Schema Migration Dump
|
||||||
|
-- Generated: 2026-04-14
|
||||||
|
-- Description: 현재 Supabase DB에 존재하는 전체 테이블 구조 요약 (의존성 순서)
|
||||||
|
-- ==============================================================================
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 1. COMPANIES (업체 및 고객사 정보)
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.companies (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 2. PLANTS (발전소 기본 정보)
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
company_id BIGINT REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
capacity DOUBLE PRECISION DEFAULT 100.0,
|
||||||
|
initial_cumulative_kwh DOUBLE PRECISION DEFAULT 0.0,
|
||||||
|
constructed_at DATE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.plants.initial_cumulative_kwh IS '시스템 도입 이전의 누적 발전량 (기초 자산)';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 3. SOLAR_LOGS (실시간/시간 단위 발전 로그)
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.solar_logs (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
|
||||||
|
current_kw DOUBLE PRECISION,
|
||||||
|
today_kwh DOUBLE PRECISION,
|
||||||
|
status TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 4. DAILY_STATS (일일 발전 통계 요약)
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.daily_stats (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
total_generation DOUBLE PRECISION DEFAULT 0.0,
|
||||||
|
peak_kw DOUBLE PRECISION DEFAULT 0.0,
|
||||||
|
generation_hours DOUBLE PRECISION DEFAULT 0.0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(plant_id, date) -- 크롤러에서 UPSERT시 사용되는 제약조건
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.daily_stats IS '일일 발전 통계 요약 테이블';
|
||||||
|
COMMENT ON COLUMN public.daily_stats.total_generation IS '당일 총 발전량 (kWh)';
|
||||||
|
COMMENT ON COLUMN public.daily_stats.peak_kw IS '당일 최고 출력 (kW)';
|
||||||
|
COMMENT ON COLUMN public.daily_stats.generation_hours IS '발전 시간 = 발전량 / 설비용량';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_id ON public.daily_stats(plant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON public.daily_stats(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_date ON public.daily_stats(plant_id, date);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 5. MONTHLY_STATS (월간 발전 통계 요약)
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.monthly_stats (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
|
||||||
|
month TEXT NOT NULL,
|
||||||
|
total_generation DOUBLE PRECISION DEFAULT 0.0,
|
||||||
|
currnet_last_date TEXT,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()),
|
||||||
|
|
||||||
|
UNIQUE(plant_id, month) -- 크롤러에서 UPSERT시 사용되는 제약조건
|
||||||
|
);
|
||||||
42
sql/DATABASE_HISTORY_AND_SECURITY.md
Normal file
42
sql/DATABASE_HISTORY_AND_SECURITY.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# 🗄️ SolarPower Database History & Security Requirements
|
||||||
|
|
||||||
|
이 문서는 과거의 파편화된 데이터베이스 업데이트 내역과 보안(RLS) 점검 보고서를 하나로 통합한 가이드입니다. **Supabase 관리 시 이 문서를 기준으로 보안 정책과 과거 변경 이력을 참고하세요.**
|
||||||
|
|
||||||
|
## 1. 📋 테이블 구조 핵심 변경 이력
|
||||||
|
| 일자 | 내용 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| **2026-01-23** | `daily_stats` (일별 통계 요약 테이블) 신설 및 인덱스 추가 | `plant_id`, `date` 복합 인덱스 및 Unique 제약조건 적용 |
|
||||||
|
| **2026-01-23** | `plants` 테이블 `initial_cumulative_kwh` 컬럼 추가 | 시스템 도입 이전의 누적 발전량(자산) 추적 목적 |
|
||||||
|
| **2026-01-30** | RLS 보안 정책 대규모 적용 | `plants`, `solar_logs`, `daily_stats`, `monthly_stats` 테이블 전체 |
|
||||||
|
| **2026-04-14** | `plants` 테이블 `alerts_enabled` 컬럼 추가 (진행) | 대시보드에서 앱 알림을 토글링할 수 있도록 불리언 제어 컬럼 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 🛡️ 보안 통제 정책 (Row Level Security)
|
||||||
|
|
||||||
|
데이터베이스의 주요 4개 테이블(`plants`, `solar_logs`, `daily_stats`, `monthly_stats`)에는 치명적인 무단 조작을 방지하기 위해 엄격한 **RLS(Row Level Security)** 가 적용되어 있습니다.
|
||||||
|
|
||||||
|
### 사용자 등급별 권한
|
||||||
|
- **API 서버 및 크롤러 (service_role)**: 모든 데이터에 대한 자유로운 읽기/쓰기(INSERT, UPDATE) 권한 보유(`.env` 파일 내 `SUPABASE_KEY`가 `service_role` 토큰이어야 함).
|
||||||
|
- **일반 익명 사용자 (anon 권한 - App/Dashboard)**: 조회(SELECT)만 가능하며 데이터 수정이나 식별 불가능한 데이터 조작은 원천 차단됩니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- RLS 활성화 예시
|
||||||
|
ALTER TABLE plants ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 익명 사용자는 조회만 가능
|
||||||
|
CREATE POLICY "Allow anonymous read access" ON plants FOR SELECT TO anon USING (true);
|
||||||
|
|
||||||
|
-- 서비스 로직은 모든 권한 허용
|
||||||
|
CREATE POLICY "Allow service_role full access" ON plants FOR ALL TO service_role USING (true) WITH CHECK (true);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 🚨 데이터베이스 운영 환경 권장 사항
|
||||||
|
|
||||||
|
1. **API 키 관리 주의점**:
|
||||||
|
- 프론트엔드 모바일 앱/대시보드 소스코드에 삽입되는 Supabase Key는 반드시 `anon` (읽기 전용 퍼블릭) 키여야 합니다.
|
||||||
|
- 크롤러(`.env`)와 백엔드 API 서버(`.env`)는 `service_role` (마스터) 키를 사용 중입니다. 이 키가 외부 저장소나 클라이언트에 노출되지 않도록 각별히 유의해야 합니다.
|
||||||
|
2. **IP 화이트리스트 검토**:
|
||||||
|
- 오라클 클라우드(API 서버) 및 NAS 크롤러 서버에서만 Supabase DB에 직접 Write 할 수 있도록 DB Network Restrictions 설정에서 IP 기반 제한을 걸어두는 것을 강력히 권장합니다.
|
||||||
10
sql/migration_add_alerts_enabled.sql
Normal file
10
sql/migration_add_alerts_enabled.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration: Add alerts_enabled to plants table
|
||||||
|
-- Description: 발전소 대시보드(모바일/웹)에서 이상 알림을 끄거나 켤 수 있도록 상태를 저장하는 컬럼 추가
|
||||||
|
-- Created At: 2026-04-14
|
||||||
|
|
||||||
|
-- 1. plants 테이블에 alerts_enabled 컬럼 추가 (기본값: true)
|
||||||
|
ALTER TABLE public.plants
|
||||||
|
ADD COLUMN IF NOT EXISTS alerts_enabled BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- 2. 컬럼 역할에 대한 설명(주석) 추가
|
||||||
|
COMMENT ON COLUMN public.plants.alerts_enabled IS '발전소 이상 감지 알림 활성화 여부 (true: 알림 켬, false: 알림 끔)';
|
||||||
12
tools/mapping.json
Normal file
12
tools/mapping.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"1호기": "nrems-01",
|
||||||
|
"1호기.1": "nrems-01",
|
||||||
|
"2호기": "nrems-02",
|
||||||
|
"3호기": "nrems-03",
|
||||||
|
"4호기": "nrems-04",
|
||||||
|
"5호기": "nrems-05",
|
||||||
|
"6호기": "nrems-06",
|
||||||
|
"8호기": "nrems-08",
|
||||||
|
"9호기": "nrems-09",
|
||||||
|
"10호기": "nrems-10"
|
||||||
|
}
|
||||||
406
tools/solar_converter_gui.py
Normal file
406
tools/solar_converter_gui.py
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
"""
|
||||||
|
solar_converter_gui.py - 태양광 발전 엑셀 데이터 전처리 GUI
|
||||||
|
============================================================
|
||||||
|
엑셀 데이터를 읽어 DB 업로드용 표준 형식으로 변환합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, filedialog, messagebox
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
except ImportError:
|
||||||
|
print("pandas 설치 필요: pip install pandas openpyxl")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class SolarConverterApp:
|
||||||
|
"""태양광 발전 엑셀 변환 GUI 애플리케이션"""
|
||||||
|
|
||||||
|
CONFIG_FILE = "mapping.json"
|
||||||
|
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("☀️ 태양광 발전 데이터 변환기")
|
||||||
|
self.root.geometry("900x700")
|
||||||
|
self.root.minsize(800, 600)
|
||||||
|
|
||||||
|
# 상태 변수
|
||||||
|
self.file_path = tk.StringVar(value="파일을 선택하세요")
|
||||||
|
self.skip_rows = tk.IntVar(value=0)
|
||||||
|
self.df = None # 원본 데이터프레임
|
||||||
|
self.columns = [] # 컬럼 리스트
|
||||||
|
self.mapping_widgets = {} # 컬럼별 매핑 위젯
|
||||||
|
|
||||||
|
self._create_ui()
|
||||||
|
self._load_mapping()
|
||||||
|
|
||||||
|
def _create_ui(self):
|
||||||
|
"""UI 생성"""
|
||||||
|
# 메인 프레임
|
||||||
|
main_frame = ttk.Frame(self.root, padding=10)
|
||||||
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
# === 1. 파일 선택 영역 ===
|
||||||
|
file_frame = ttk.LabelFrame(main_frame, text="📁 파일 선택", padding=10)
|
||||||
|
file_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
ttk.Button(file_frame, text="원본 엑셀 열기", command=self._open_file).pack(side=tk.LEFT)
|
||||||
|
ttk.Label(file_frame, textvariable=self.file_path, foreground="blue").pack(side=tk.LEFT, padx=10)
|
||||||
|
|
||||||
|
# === 2. 구조 분석 영역 ===
|
||||||
|
analysis_frame = ttk.LabelFrame(main_frame, text="🔍 구조 분석", padding=10)
|
||||||
|
analysis_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
row1 = ttk.Frame(analysis_frame)
|
||||||
|
row1.pack(fill=tk.X)
|
||||||
|
|
||||||
|
ttk.Label(row1, text="건너뛸 행(Skip Rows):").pack(side=tk.LEFT)
|
||||||
|
ttk.Spinbox(row1, from_=0, to=100, width=5, textvariable=self.skip_rows).pack(side=tk.LEFT, padx=5)
|
||||||
|
ttk.Button(row1, text="📋 미리보기", command=self._preview_data).pack(side=tk.LEFT, padx=10)
|
||||||
|
|
||||||
|
# 미리보기 테이블
|
||||||
|
preview_container = ttk.Frame(analysis_frame)
|
||||||
|
preview_container.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
||||||
|
|
||||||
|
self.preview_tree = ttk.Treeview(preview_container, height=5, show="headings")
|
||||||
|
preview_scroll_x = ttk.Scrollbar(preview_container, orient=tk.HORIZONTAL, command=self.preview_tree.xview)
|
||||||
|
preview_scroll_y = ttk.Scrollbar(preview_container, orient=tk.VERTICAL, command=self.preview_tree.yview)
|
||||||
|
self.preview_tree.configure(xscrollcommand=preview_scroll_x.set, yscrollcommand=preview_scroll_y.set)
|
||||||
|
|
||||||
|
self.preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
|
preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
preview_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
|
|
||||||
|
# === 3. 발전소 매핑 영역 ===
|
||||||
|
mapping_outer = ttk.LabelFrame(main_frame, text="🔗 발전소 매핑", padding=10)
|
||||||
|
mapping_outer.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||||
|
|
||||||
|
# 매핑 버튼 행
|
||||||
|
mapping_buttons = ttk.Frame(mapping_outer)
|
||||||
|
mapping_buttons.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
ttk.Button(mapping_buttons, text="💾 매핑 저장", command=self._save_mapping).pack(side=tk.LEFT, padx=2)
|
||||||
|
ttk.Button(mapping_buttons, text="📂 매핑 불러오기", command=self._load_mapping).pack(side=tk.LEFT, padx=2)
|
||||||
|
ttk.Button(mapping_buttons, text="🗑️ 매핑 초기화", command=self._clear_mapping).pack(side=tk.LEFT, padx=2)
|
||||||
|
ttk.Label(mapping_buttons, text=" | 날짜 컬럼:").pack(side=tk.LEFT, padx=(20, 5))
|
||||||
|
self.date_column_var = tk.StringVar()
|
||||||
|
self.date_column_combo = ttk.Combobox(mapping_buttons, textvariable=self.date_column_var, width=15, state="readonly")
|
||||||
|
self.date_column_combo.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# 매핑 스크롤 영역
|
||||||
|
mapping_canvas = tk.Canvas(mapping_outer)
|
||||||
|
mapping_scrollbar = ttk.Scrollbar(mapping_outer, orient=tk.VERTICAL, command=mapping_canvas.yview)
|
||||||
|
self.mapping_frame = ttk.Frame(mapping_canvas)
|
||||||
|
|
||||||
|
mapping_canvas.configure(yscrollcommand=mapping_scrollbar.set)
|
||||||
|
mapping_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
mapping_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
canvas_window = mapping_canvas.create_window((0, 0), window=self.mapping_frame, anchor="nw")
|
||||||
|
|
||||||
|
def on_frame_configure(e):
|
||||||
|
mapping_canvas.configure(scrollregion=mapping_canvas.bbox("all"))
|
||||||
|
|
||||||
|
def on_canvas_configure(e):
|
||||||
|
mapping_canvas.itemconfig(canvas_window, width=e.width)
|
||||||
|
|
||||||
|
self.mapping_frame.bind("<Configure>", on_frame_configure)
|
||||||
|
mapping_canvas.bind("<Configure>", on_canvas_configure)
|
||||||
|
|
||||||
|
# 마우스 휠 스크롤
|
||||||
|
def on_mousewheel(e):
|
||||||
|
mapping_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
|
||||||
|
mapping_canvas.bind_all("<MouseWheel>", on_mousewheel)
|
||||||
|
|
||||||
|
# === 4. 변환 및 저장 영역 ===
|
||||||
|
action_frame = ttk.Frame(main_frame)
|
||||||
|
action_frame.pack(fill=tk.X)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
action_frame,
|
||||||
|
text="⬇️ 변환하여 저장하기",
|
||||||
|
command=self._convert_and_save,
|
||||||
|
style="Accent.TButton"
|
||||||
|
).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
self.status_label = ttk.Label(action_frame, text="준비됨", foreground="gray")
|
||||||
|
self.status_label.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# 스타일 설정
|
||||||
|
style = ttk.Style()
|
||||||
|
style.configure("Accent.TButton", font=("", 12, "bold"))
|
||||||
|
|
||||||
|
def _open_file(self):
|
||||||
|
"""파일 열기 대화상자"""
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="엑셀 파일 선택",
|
||||||
|
filetypes=[
|
||||||
|
("Excel Files", "*.xlsx *.xls"),
|
||||||
|
("All Files", "*.*")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.file_path.set(path)
|
||||||
|
self._status("파일 선택됨. '미리보기' 버튼을 클릭하세요.")
|
||||||
|
|
||||||
|
def _preview_data(self):
|
||||||
|
"""데이터 미리보기"""
|
||||||
|
path = self.file_path.get()
|
||||||
|
if not os.path.exists(path):
|
||||||
|
messagebox.showerror("오류", "먼저 엑셀 파일을 선택하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
skip = self.skip_rows.get()
|
||||||
|
self.df = pd.read_excel(path, skiprows=skip)
|
||||||
|
self.columns = list(self.df.columns)
|
||||||
|
|
||||||
|
# 미리보기 테이블 업데이트
|
||||||
|
self.preview_tree.delete(*self.preview_tree.get_children())
|
||||||
|
self.preview_tree["columns"] = self.columns
|
||||||
|
|
||||||
|
for col in self.columns:
|
||||||
|
self.preview_tree.heading(col, text=col)
|
||||||
|
self.preview_tree.column(col, width=100, minwidth=50)
|
||||||
|
|
||||||
|
# 상위 5행 표시
|
||||||
|
for idx, row in self.df.head(5).iterrows():
|
||||||
|
values = [str(v) if pd.notna(v) else "" for v in row]
|
||||||
|
self.preview_tree.insert("", tk.END, values=values)
|
||||||
|
|
||||||
|
# 매핑 UI 생성
|
||||||
|
self._create_mapping_ui()
|
||||||
|
self._status(f"미리보기 완료: {len(self.df)}행, {len(self.columns)}열")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("오류", f"파일 읽기 실패:\n{e}")
|
||||||
|
|
||||||
|
def _create_mapping_ui(self):
|
||||||
|
"""컬럼 매핑 UI 생성"""
|
||||||
|
# 기존 위젯 제거
|
||||||
|
for widget in self.mapping_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
self.mapping_widgets = {}
|
||||||
|
|
||||||
|
# 날짜 컬럼 콤보박스 업데이트
|
||||||
|
self.date_column_combo["values"] = self.columns
|
||||||
|
if self.columns:
|
||||||
|
# 날짜 관련 컬럼 자동 선택
|
||||||
|
for col in self.columns:
|
||||||
|
if any(kw in str(col).lower() for kw in ['date', '날짜', '일자', 'day']):
|
||||||
|
self.date_column_var.set(col)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.date_column_var.set(self.columns[0])
|
||||||
|
|
||||||
|
# 헤더
|
||||||
|
header = ttk.Frame(self.mapping_frame)
|
||||||
|
header.pack(fill=tk.X, pady=(0, 5))
|
||||||
|
ttk.Label(header, text="포함", width=5).pack(side=tk.LEFT)
|
||||||
|
ttk.Label(header, text="엑셀 컬럼명", width=25, anchor="w").pack(side=tk.LEFT, padx=5)
|
||||||
|
ttk.Label(header, text="→", width=3).pack(side=tk.LEFT)
|
||||||
|
ttk.Label(header, text="DB Plant ID", width=20, anchor="w").pack(side=tk.LEFT, padx=5)
|
||||||
|
|
||||||
|
ttk.Separator(self.mapping_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5)
|
||||||
|
|
||||||
|
# 각 컬럼에 대한 매핑 행
|
||||||
|
for col in self.columns:
|
||||||
|
row = ttk.Frame(self.mapping_frame)
|
||||||
|
row.pack(fill=tk.X, pady=2)
|
||||||
|
|
||||||
|
# 포함 체크박스
|
||||||
|
include_var = tk.BooleanVar(value=True)
|
||||||
|
chk = ttk.Checkbutton(row, variable=include_var, width=3)
|
||||||
|
chk.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# 컬럼명
|
||||||
|
ttk.Label(row, text=str(col), width=25, anchor="w").pack(side=tk.LEFT, padx=5)
|
||||||
|
|
||||||
|
# 화살표
|
||||||
|
ttk.Label(row, text="→", width=3).pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
# DB ID 입력
|
||||||
|
db_id_var = tk.StringVar()
|
||||||
|
entry = ttk.Entry(row, textvariable=db_id_var, width=20)
|
||||||
|
entry.pack(side=tk.LEFT, padx=5)
|
||||||
|
|
||||||
|
# 자동 매핑 추천
|
||||||
|
suggested_id = self._suggest_plant_id(col)
|
||||||
|
if suggested_id:
|
||||||
|
db_id_var.set(suggested_id)
|
||||||
|
|
||||||
|
# 위젯 저장
|
||||||
|
self.mapping_widgets[col] = {
|
||||||
|
'include': include_var,
|
||||||
|
'db_id': db_id_var
|
||||||
|
}
|
||||||
|
|
||||||
|
def _suggest_plant_id(self, column_name):
|
||||||
|
"""컬럼명에서 발전소 ID 추천"""
|
||||||
|
col_str = str(column_name).lower()
|
||||||
|
|
||||||
|
# 호기 번호 추출
|
||||||
|
import re
|
||||||
|
match = re.search(r'(\d+)\s*호기?', col_str)
|
||||||
|
if match:
|
||||||
|
num = match.group(1).zfill(2)
|
||||||
|
return f"nrems-{num}"
|
||||||
|
|
||||||
|
# 날짜 관련 컬럼은 비워둠
|
||||||
|
if any(kw in col_str for kw in ['date', '날짜', '일자', 'day', '월', 'month']):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _save_mapping(self):
|
||||||
|
"""매핑 설정 저장"""
|
||||||
|
mapping = {}
|
||||||
|
for col, widgets in self.mapping_widgets.items():
|
||||||
|
mapping[str(col)] = {
|
||||||
|
'include': widgets['include'].get(),
|
||||||
|
'db_id': widgets['db_id'].get()
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping['_date_column'] = self.date_column_var.get()
|
||||||
|
mapping['_skip_rows'] = self.skip_rows.get()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(mapping, f, ensure_ascii=False, indent=2)
|
||||||
|
self._status(f"매핑 저장됨: {self.CONFIG_FILE}")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("오류", f"저장 실패: {e}")
|
||||||
|
|
||||||
|
def _load_mapping(self):
|
||||||
|
"""저장된 매핑 불러오기"""
|
||||||
|
if not os.path.exists(self.CONFIG_FILE):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
mapping = json.load(f)
|
||||||
|
|
||||||
|
# skip_rows 복원
|
||||||
|
if '_skip_rows' in mapping:
|
||||||
|
self.skip_rows.set(mapping['_skip_rows'])
|
||||||
|
|
||||||
|
# 날짜 컬럼 복원
|
||||||
|
if '_date_column' in mapping:
|
||||||
|
self.date_column_var.set(mapping['_date_column'])
|
||||||
|
|
||||||
|
# 매핑 위젯 업데이트
|
||||||
|
for col, widgets in self.mapping_widgets.items():
|
||||||
|
col_str = str(col)
|
||||||
|
if col_str in mapping:
|
||||||
|
widgets['include'].set(mapping[col_str].get('include', True))
|
||||||
|
widgets['db_id'].set(mapping[col_str].get('db_id', ''))
|
||||||
|
|
||||||
|
self._status("매핑 불러옴")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"매핑 로드 오류: {e}")
|
||||||
|
|
||||||
|
def _clear_mapping(self):
|
||||||
|
"""매핑 초기화"""
|
||||||
|
for widgets in self.mapping_widgets.values():
|
||||||
|
widgets['include'].set(True)
|
||||||
|
widgets['db_id'].set("")
|
||||||
|
self._status("매핑 초기화됨")
|
||||||
|
|
||||||
|
def _convert_and_save(self):
|
||||||
|
"""변환 및 저장"""
|
||||||
|
if self.df is None or self.df.empty:
|
||||||
|
messagebox.showerror("오류", "먼저 데이터를 미리보기하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
date_col = self.date_column_var.get()
|
||||||
|
if not date_col:
|
||||||
|
messagebox.showerror("오류", "날짜 컬럼을 선택하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 매핑된 컬럼 추출
|
||||||
|
mapped_columns = {}
|
||||||
|
for col, widgets in self.mapping_widgets.items():
|
||||||
|
if widgets['include'].get() and widgets['db_id'].get():
|
||||||
|
mapped_columns[col] = widgets['db_id'].get()
|
||||||
|
|
||||||
|
if not mapped_columns:
|
||||||
|
messagebox.showerror("오류", "최소 하나의 컬럼을 매핑하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 필요한 컬럼만 추출
|
||||||
|
cols_to_use = [date_col] + list(mapped_columns.keys())
|
||||||
|
df_subset = self.df[cols_to_use].copy()
|
||||||
|
|
||||||
|
# melt로 변환 (wide → long)
|
||||||
|
df_melted = df_subset.melt(
|
||||||
|
id_vars=[date_col],
|
||||||
|
var_name='excel_column',
|
||||||
|
value_name='generation'
|
||||||
|
)
|
||||||
|
|
||||||
|
# plant_id 매핑
|
||||||
|
df_melted['plant_id'] = df_melted['excel_column'].map(mapped_columns)
|
||||||
|
|
||||||
|
# 날짜 표준화
|
||||||
|
df_melted['date'] = pd.to_datetime(df_melted[date_col]).dt.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 최종 결과
|
||||||
|
result = df_melted[['date', 'plant_id', 'generation']].copy()
|
||||||
|
result['generation'] = pd.to_numeric(result['generation'], errors='coerce').fillna(0)
|
||||||
|
|
||||||
|
# 저장 위치 선택
|
||||||
|
save_path = filedialog.asksaveasfilename(
|
||||||
|
title="변환 결과 저장",
|
||||||
|
defaultextension=".xlsx",
|
||||||
|
initialfile="processed_data.xlsx",
|
||||||
|
filetypes=[("Excel Files", "*.xlsx")]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not save_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 발전소별로 시트 분리하여 저장
|
||||||
|
with pd.ExcelWriter(save_path, engine='openpyxl') as writer:
|
||||||
|
# 전체 데이터 시트
|
||||||
|
result.to_excel(writer, sheet_name='전체', index=False)
|
||||||
|
|
||||||
|
# plant_id별 시트
|
||||||
|
for plant_id in result['plant_id'].unique():
|
||||||
|
plant_data = result[result['plant_id'] == plant_id]
|
||||||
|
# 시트명에 사용 불가한 문자 제거
|
||||||
|
sheet_name = plant_id.replace('/', '_').replace('\\', '_')[:31]
|
||||||
|
plant_data.to_excel(writer, sheet_name=sheet_name, index=False)
|
||||||
|
|
||||||
|
messagebox.showinfo(
|
||||||
|
"성공",
|
||||||
|
f"변환 완료!\n\n"
|
||||||
|
f"📊 총 {len(result)}행\n"
|
||||||
|
f"🏭 발전소 {len(result['plant_id'].unique())}개\n"
|
||||||
|
f"📁 저장 위치: {save_path}"
|
||||||
|
)
|
||||||
|
self._status(f"저장됨: {save_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("오류", f"변환 실패:\n{e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def _status(self, message):
|
||||||
|
"""상태 메시지 업데이트"""
|
||||||
|
self.status_label.config(text=message)
|
||||||
|
self.root.update_idletasks()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = tk.Tk()
|
||||||
|
app = SolarConverterApp(root)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
326
tools/solar_converter_v2.py
Normal file
326
tools/solar_converter_v2.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
import customtkinter as ctk
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, filedialog, messagebox
|
||||||
|
import pandas as pd
|
||||||
|
from pandas.tseries.offsets import MonthEnd
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 폰트 설정
|
||||||
|
if sys.platform.startswith('win'):
|
||||||
|
FONT_BOLD = ("Malgun Gothic", 14, "bold")
|
||||||
|
FONT_NORMAL = ("Malgun Gothic", 12)
|
||||||
|
FONT_SMALL = ("Malgun Gothic", 10)
|
||||||
|
else:
|
||||||
|
FONT_BOLD = ("AppleGothic", 14, "bold")
|
||||||
|
FONT_NORMAL = ("AppleGothic", 12)
|
||||||
|
FONT_SMALL = ("AppleGothic", 10)
|
||||||
|
|
||||||
|
ctk.set_appearance_mode("Dark")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
class SolarApp(ctk.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.title("태양광 데이터 전처리기 V7.0 (즉시 표시 버전)")
|
||||||
|
self.geometry("1200x900")
|
||||||
|
|
||||||
|
self.df_raw = None
|
||||||
|
self.header_row_idx = 0 # 기본값 0으로 설정
|
||||||
|
self.file_path = ""
|
||||||
|
self.mapping_file = "mapping.json"
|
||||||
|
self.mapping_entries = {}
|
||||||
|
self.saved_mapping = self.load_mapping()
|
||||||
|
|
||||||
|
self.create_ui()
|
||||||
|
|
||||||
|
def create_ui(self):
|
||||||
|
# 1. 상단: 파일 로드
|
||||||
|
self.frame_top = ctk.CTkFrame(self)
|
||||||
|
self.frame_top.pack(pady=10, padx=10, fill="x")
|
||||||
|
|
||||||
|
self.btn_load = ctk.CTkButton(self.frame_top, text="📂 1. 엑셀 파일 열기",
|
||||||
|
command=self.load_file, font=FONT_BOLD, height=40)
|
||||||
|
self.btn_load.pack(side="left", padx=20, pady=10)
|
||||||
|
|
||||||
|
self.lbl_file_info = ctk.CTkLabel(self.frame_top, text="파일을 선택해주세요",
|
||||||
|
text_color="gray", font=FONT_NORMAL)
|
||||||
|
self.lbl_file_info.pack(side="left", padx=10)
|
||||||
|
|
||||||
|
# 2. 메인 컨텐츠 (좌: 미리보기 / 우: 매핑)
|
||||||
|
self.frame_mid = ctk.CTkFrame(self)
|
||||||
|
self.frame_mid.pack(pady=5, padx=10, fill="both", expand=True)
|
||||||
|
|
||||||
|
# --- 좌측 패널 (미리보기) ---
|
||||||
|
self.frame_left = ctk.CTkFrame(self.frame_mid, width=700)
|
||||||
|
self.frame_left.pack(side="left", fill="both", expand=True, padx=(0, 5))
|
||||||
|
|
||||||
|
# 안내 문구 및 수동 버튼
|
||||||
|
self.frame_left_header = ctk.CTkFrame(self.frame_left, fg_color="transparent")
|
||||||
|
self.frame_left_header.pack(fill="x", pady=5)
|
||||||
|
|
||||||
|
self.lbl_preview = ctk.CTkLabel(self.frame_left_header,
|
||||||
|
text="📂 2. 데이터 미리보기",
|
||||||
|
font=FONT_BOLD, text_color="#FFA500")
|
||||||
|
self.lbl_preview.pack(side="left", padx=5)
|
||||||
|
|
||||||
|
self.btn_set_header = ctk.CTkButton(self.frame_left_header, text="👈 선택한 줄을 제목으로 설정",
|
||||||
|
command=self.set_header_manual, font=FONT_SMALL,
|
||||||
|
fg_color="#444444", hover_color="#666666")
|
||||||
|
self.btn_set_header.pack(side="right", padx=5)
|
||||||
|
|
||||||
|
# Treeview
|
||||||
|
style = ttk.Style()
|
||||||
|
style.theme_use("default")
|
||||||
|
style.configure("Treeview", background="#2b2b2b", fieldbackground="#2b2b2b",
|
||||||
|
foreground="white", rowheight=25, font=FONT_NORMAL)
|
||||||
|
style.configure("Treeview.Heading", background="#3a3a3a", foreground="white", font=FONT_BOLD)
|
||||||
|
style.map('Treeview', background=[('selected', '#1f538d')])
|
||||||
|
|
||||||
|
self.tree = ttk.Treeview(self.frame_left)
|
||||||
|
self.tree.pack(fill="both", expand=True, padx=5, pady=5)
|
||||||
|
|
||||||
|
vsb = ttk.Scrollbar(self.frame_left, orient="vertical", command=self.tree.yview)
|
||||||
|
vsb.pack(side='right', fill='y')
|
||||||
|
self.tree.configure(yscrollcommand=vsb.set)
|
||||||
|
|
||||||
|
# 이벤트 바인딩
|
||||||
|
self.tree.bind("<Double-1>", self.on_tree_double_click)
|
||||||
|
|
||||||
|
self.lbl_selected_header = ctk.CTkLabel(self.frame_left, text="현재 제목(Header): 1번째 줄 (자동설정)",
|
||||||
|
text_color="#55FF55", font=FONT_NORMAL)
|
||||||
|
self.lbl_selected_header.pack(pady=5)
|
||||||
|
|
||||||
|
# --- 우측 패널 (매핑) ---
|
||||||
|
self.frame_right_container = ctk.CTkFrame(self.frame_mid, width=400)
|
||||||
|
self.frame_right_container.pack(side="right", fill="both", padx=(5, 0))
|
||||||
|
|
||||||
|
# 매핑 스크롤 영역
|
||||||
|
self.frame_right = ctk.CTkScrollableFrame(self.frame_right_container, label_text="[단계 3] 매핑 설정",
|
||||||
|
label_font=FONT_BOLD)
|
||||||
|
self.frame_right.pack(fill="both", expand=True, padx=5, pady=5)
|
||||||
|
|
||||||
|
# 3. 하단: 변환 실행
|
||||||
|
self.frame_bottom = ctk.CTkFrame(self)
|
||||||
|
self.frame_bottom.pack(pady=10, padx=10, fill="x")
|
||||||
|
|
||||||
|
self.progress = ctk.CTkProgressBar(self.frame_bottom)
|
||||||
|
self.progress.set(0)
|
||||||
|
self.progress.pack(fill="x", padx=20, pady=(10, 5))
|
||||||
|
|
||||||
|
self.btn_convert = ctk.CTkButton(self.frame_bottom, text="🚀 변환 및 저장하기 (Start)",
|
||||||
|
command=self.start_conversion,
|
||||||
|
font=FONT_BOLD, fg_color="green", state="disabled", height=50)
|
||||||
|
self.btn_convert.pack(pady=10)
|
||||||
|
|
||||||
|
def load_file(self):
|
||||||
|
file_path = filedialog.askopenfilename(filetypes=[("Excel files", "*.xlsx *.xls *.csv")])
|
||||||
|
if not file_path: return
|
||||||
|
|
||||||
|
self.file_path = file_path
|
||||||
|
self.lbl_file_info.configure(text=f"...{os.path.basename(file_path)}", text_color="white")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 파일 읽기
|
||||||
|
if file_path.endswith('.csv'):
|
||||||
|
try: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='utf-8')
|
||||||
|
except: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='cp949')
|
||||||
|
else:
|
||||||
|
self.df_raw = pd.read_excel(file_path, header=None, nrows=50)
|
||||||
|
|
||||||
|
# 2. NaN 처리 (빈 문자열로)
|
||||||
|
self.df_raw = self.df_raw.fillna("")
|
||||||
|
self.df_raw = self.df_raw.astype(str)
|
||||||
|
|
||||||
|
# 3. 미리보기 갱신
|
||||||
|
self.update_treeview(self.df_raw)
|
||||||
|
|
||||||
|
# [핵심 변경] 파일 열자마자 0번 줄을 기준으로 매핑 UI 강제 생성
|
||||||
|
self.header_row_idx = 0
|
||||||
|
self.lbl_selected_header.configure(text="현재 제목(Header): 1번째 줄 (자동설정)", text_color="#55FF55")
|
||||||
|
self.generate_mapping_ui() # <--- 즉시 실행!
|
||||||
|
|
||||||
|
self.btn_convert.configure(state="normal")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("에러", f"파일 읽기 실패: {e}")
|
||||||
|
|
||||||
|
def update_treeview(self, df):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
self.tree["columns"] = list(df.columns)
|
||||||
|
self.tree["show"] = "headings"
|
||||||
|
for col in df.columns:
|
||||||
|
self.tree.heading(col, text=f"Col {col}")
|
||||||
|
self.tree.column(col, width=80)
|
||||||
|
for i, row in df.iterrows():
|
||||||
|
self.tree.insert("", "end", iid=str(i), values=list(row))
|
||||||
|
|
||||||
|
def on_tree_double_click(self, event):
|
||||||
|
item_id = self.tree.identify('item', event.x, event.y)
|
||||||
|
if not item_id: return
|
||||||
|
self.set_header_logic(int(item_id))
|
||||||
|
|
||||||
|
def set_header_manual(self):
|
||||||
|
# 현재 선택된 아이템 가져오기
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if not selected:
|
||||||
|
messagebox.showwarning("알림", "왼쪽 표에서 제목으로 쓸 줄을 먼저 클릭해서 선택해주세요.")
|
||||||
|
return
|
||||||
|
self.set_header_logic(int(selected[0]))
|
||||||
|
|
||||||
|
def set_header_logic(self, row_idx):
|
||||||
|
self.header_row_idx = row_idx
|
||||||
|
self.lbl_selected_header.configure(text=f"현재 제목(Header): {self.header_row_idx + 1}번째 줄", text_color="#55FF55")
|
||||||
|
self.generate_mapping_ui()
|
||||||
|
|
||||||
|
def generate_mapping_ui(self):
|
||||||
|
# 기존 위젯 삭제
|
||||||
|
for widget in self.frame_right.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
self.mapping_entries = {}
|
||||||
|
|
||||||
|
# 선택된 행의 데이터 가져오기
|
||||||
|
try:
|
||||||
|
headers = self.df_raw.iloc[self.header_row_idx].values
|
||||||
|
except:
|
||||||
|
return # 데이터가 아직 없을 때
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for i, col_name in enumerate(headers):
|
||||||
|
# 내용이 'nan' 이거나 공백이면 무시 (Unnmaed 등)
|
||||||
|
col_str = str(col_name).strip()
|
||||||
|
if not col_str or col_str.lower() == 'nan':
|
||||||
|
continue
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
row_frame = ctk.CTkFrame(self.frame_right, fg_color="transparent")
|
||||||
|
row_frame.pack(fill="x", pady=2)
|
||||||
|
|
||||||
|
# 라벨 (너무 길면 자르기)
|
||||||
|
display_name = (col_str[:15] + '..') if len(col_str) > 15 else col_str
|
||||||
|
ctk.CTkLabel(row_frame, text=f"{display_name}", width=120, anchor="w", font=FONT_NORMAL).pack(side="left", padx=5)
|
||||||
|
|
||||||
|
entry = ctk.CTkEntry(row_frame, placeholder_text="DB ID / year / month", font=FONT_NORMAL)
|
||||||
|
entry.pack(side="right", fill="x", expand=True, padx=5)
|
||||||
|
|
||||||
|
if col_str in self.saved_mapping:
|
||||||
|
entry.insert(0, self.saved_mapping[col_str])
|
||||||
|
self.mapping_entries[col_str] = entry
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
ctk.CTkLabel(self.frame_right, text="⚠️ 선택한 줄에 유효한 글자가 없습니다.\n다른 줄을 선택해주세요.",
|
||||||
|
text_color="#FF5555", font=FONT_NORMAL).pack(pady=20)
|
||||||
|
|
||||||
|
def start_conversion(self):
|
||||||
|
self.progress.set(0.1)
|
||||||
|
self.update_idletasks()
|
||||||
|
|
||||||
|
try:
|
||||||
|
mapping_dict = {}
|
||||||
|
special_cols = []
|
||||||
|
|
||||||
|
for original, entry in self.mapping_entries.items():
|
||||||
|
target = entry.get().strip()
|
||||||
|
if target:
|
||||||
|
mapping_dict[original] = target
|
||||||
|
self.saved_mapping[original] = target
|
||||||
|
if target.lower() in ['date', 'year', 'month', 'day']:
|
||||||
|
special_cols.append(target.lower())
|
||||||
|
|
||||||
|
self.save_mapping()
|
||||||
|
|
||||||
|
if not mapping_dict:
|
||||||
|
messagebox.showwarning("경고", "매핑된 컬럼이 하나도 없습니다.")
|
||||||
|
self.progress.set(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress.set(0.3)
|
||||||
|
|
||||||
|
# 실제 데이터 다시 읽기
|
||||||
|
if self.file_path.endswith('.csv'):
|
||||||
|
try: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='utf-8')
|
||||||
|
except: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='cp949')
|
||||||
|
else:
|
||||||
|
df_real = pd.read_excel(self.file_path, header=self.header_row_idx)
|
||||||
|
|
||||||
|
# 컬럼명 정리
|
||||||
|
df_real.columns = [str(c).strip() for c in df_real.columns]
|
||||||
|
|
||||||
|
# 매핑 적용
|
||||||
|
valid_cols = [c for c in mapping_dict.keys() if c in df_real.columns]
|
||||||
|
df_sel = df_real[valid_cols].copy()
|
||||||
|
df_sel.rename(columns=mapping_dict, inplace=True)
|
||||||
|
df_sel.columns = [c.lower() if c.lower() in ['date', 'year', 'month', 'day'] else c for c in df_sel.columns]
|
||||||
|
|
||||||
|
self.progress.set(0.5)
|
||||||
|
|
||||||
|
# 날짜 생성 로직
|
||||||
|
if 'date' in df_sel.columns:
|
||||||
|
df_sel['date'] = pd.to_datetime(df_sel['date'], errors='coerce')
|
||||||
|
elif 'year' in df_sel.columns and 'month' in df_sel.columns:
|
||||||
|
df_sel['year'] = pd.to_numeric(df_sel['year'], errors='coerce')
|
||||||
|
df_sel['month'] = pd.to_numeric(df_sel['month'], errors='coerce')
|
||||||
|
|
||||||
|
if 'day' not in df_sel.columns:
|
||||||
|
df_sel['day'] = 1
|
||||||
|
is_monthly_data = True
|
||||||
|
else:
|
||||||
|
df_sel['day'] = pd.to_numeric(df_sel['day'], errors='coerce')
|
||||||
|
is_monthly_data = False
|
||||||
|
|
||||||
|
df_sel.dropna(subset=['year', 'month'], inplace=True)
|
||||||
|
df_sel['date'] = pd.to_datetime(df_sel[['year', 'month', 'day']])
|
||||||
|
|
||||||
|
if is_monthly_data:
|
||||||
|
df_sel['date'] = df_sel['date'] + MonthEnd(0)
|
||||||
|
|
||||||
|
df_sel.dropna(subset=['date'], inplace=True)
|
||||||
|
df_sel['date'] = df_sel['date'].dt.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
cols_to_drop = [c for c in ['year', 'month', 'day'] if c in df_sel.columns]
|
||||||
|
df_sel.drop(columns=cols_to_drop, inplace=True)
|
||||||
|
|
||||||
|
self.progress.set(0.7)
|
||||||
|
|
||||||
|
# Melt
|
||||||
|
melted = pd.melt(df_sel, id_vars=['date'], var_name='plant_id', value_name='generation')
|
||||||
|
melted['generation'] = pd.to_numeric(melted['generation'], errors='coerce')
|
||||||
|
melted.dropna(subset=['generation'], inplace=True)
|
||||||
|
melted = melted[melted['generation'] > 0]
|
||||||
|
|
||||||
|
self.progress.set(0.9)
|
||||||
|
|
||||||
|
# 저장
|
||||||
|
save_path = os.path.splitext(self.file_path)[0] + "_processed.xlsx"
|
||||||
|
with pd.ExcelWriter(save_path, engine='openpyxl') as writer:
|
||||||
|
melted.to_excel(writer, sheet_name='ALL_DATA', index=False)
|
||||||
|
for plant in melted['plant_id'].unique():
|
||||||
|
safe_name = str(plant)[:30].replace('/', '_')
|
||||||
|
melted[melted['plant_id'] == plant].to_excel(writer, sheet_name=safe_name, index=False)
|
||||||
|
|
||||||
|
self.progress.set(1.0)
|
||||||
|
messagebox.showinfo("완료", f"변환 성공!\n{save_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.progress.set(0)
|
||||||
|
messagebox.showerror("오류", str(e))
|
||||||
|
|
||||||
|
def load_mapping(self):
|
||||||
|
if os.path.exists(self.mapping_file):
|
||||||
|
try:
|
||||||
|
with open(self.mapping_file, 'r', encoding='utf-8') as f: return json.load(f)
|
||||||
|
except: return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_mapping(self):
|
||||||
|
try:
|
||||||
|
with open(self.mapping_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.saved_mapping, f, ensure_ascii=False, indent=4)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = SolarApp()
|
||||||
|
app.mainloop()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user