refactor: complete repository structure cleanup
Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run

This commit is contained in:
haneulai 2026-08-07 15:30:44 +09:00
parent 76172bb22a
commit f2bb131884
42 changed files with 487 additions and 2432 deletions

4
.gitignore vendored
View File

@ -20,8 +20,12 @@ env/
*.db-journal *.db-journal
.env .env
.env.* .env.*
!.env.example
secrets.json secrets.json
# Runtime output
*.log
# IDEs and Editors # IDEs and Editors
.vscode/ .vscode/
.idea/ .idea/

View File

@ -43,19 +43,17 @@ SolarPower is an integrated solar power monitoring system consisting of:
- **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api` - **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api`
- **Docs**: Access Swagger UI at `http://localhost:8000/docs` - **Docs**: Access Swagger UI at `http://localhost:8000/docs`
- **Test**: - **Test**:
- No automated test suite (pytest) currently. - Run `python -m unittest discover -s tests -p "test_*.py" -v`.
- Verify endpoints manually using `curl` or Swagger UI. - Verify deployed endpoints using `curl` or Swagger UI.
### 🐍 Crawler (`crawler/`) ### 🐍 Crawler (`crawler/`)
- **Environment**: Python 3.10+ - **Environment**: Python 3.10+
- **Install**: Manual dependency installation (requests, python-dotenv, supabase). - **Install**: `pip install -r requirements.txt`
- **Run (Manual)**: `python main.py` - **Run (Manual)**: `python main.py`
- Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`. - Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`.
- **Run (Force)**: `python main.py --force` (Ignores scheduler constraints) - **Run (Force)**: `python main.py --force` (Ignores scheduler constraints)
- **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning) - **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning)
- **Testing**: - **Testing**: `python -m unittest discover -s tests -p "test_*.py" -v`
- 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 ## 4. Code Style & Standards
@ -99,7 +97,7 @@ SolarPower is an integrated solar power monitoring system consisting of:
### ☁️ Server Deployment ### ☁️ Server Deployment
1. **Push**: Commit and push changes to the remote Git repository. 1. **Push**: Commit and push changes to the remote Git repository.
2. **Pull**: SSH into the server and pull changes. 2. **Pull**: SSH into the server and pull changes.
- Command: `ssh user@server "cd ~/solorpower_server && git pull"` - Command: `ssh user@server "cd ~/solorpower && git pull"`
3. **Restart**: Restart the API service. 3. **Restart**: Restart the API service.
- Command: `sudo systemctl restart solar-api` - Command: `sudo systemctl restart solar-api`

View File

@ -2,6 +2,8 @@
태양광 발전소의 실시간 발전 데이터를 수집하고 모니터링하는 통합 관제 시스템입니다. 태양광 발전소의 실시간 발전 데이터를 수집하고 모니터링하는 통합 관제 시스템입니다.
개발 환경은 [개발 및 테스트 가이드](docs/development_and_testing.md), 디렉터리 책임과 생성 파일 정책은 [저장소 구조 문서](docs/repository_structure.md)를 기준으로 합니다.
--- ---
## 📋 목차 ## 📋 목차
@ -114,7 +116,7 @@ flowchart TD
### 위치 ### 위치
``` ```
d:\dev\etc\SolorPower\crawler\ (Local PC) d:\dev\etc\SolorPower\crawler\ (Local PC)
/home/ubuntu/SolorPower/crawler/ (Oracle Cloud) /home/ubuntu/solorpower/crawler/ (Oracle Cloud)
``` ```
### 네트워크 구성 (Tailscale) ### 네트워크 구성 (Tailscale)
@ -157,7 +159,7 @@ crawler/
### 자동 수집 (Cron) ### 자동 수집 (Cron)
```bash ```bash
# 30분마다 실행 (Oracle Cloud 환경) # 30분마다 실행 (Oracle Cloud 환경)
*/30 * * * * cd /home/ubuntu/SolorPower/crawler && /home/ubuntu/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1 */10 * * * * cd /home/ubuntu/solorpower/crawler && /usr/bin/python3 main.py >> cron.log 2>&1
``` ```
### 크롤러별 개선 사항 (2026.01) ### 크롤러별 개선 사항 (2026.01)
@ -173,7 +175,7 @@ crawler/
### 위치 ### 위치
``` ```
/home/ubuntu/SolorPower/api_server/ /home/ubuntu/solorpower/api_server/
``` ```
### 환경 설정 (.env) ### 환경 설정 (.env)
@ -219,6 +221,8 @@ d:\dev\etc\SolorPower\app\
- 별칭(`발전량`, `kwh` 등) 및 셀 병합 처리 지원. - 별칭(`발전량`, `kwh` 등) 및 셀 병합 처리 지원.
- **반응형 웹 지원**: PC/모바일 브라우저 최적화. - **반응형 웹 지원**: PC/모바일 브라우저 최적화.
API 주소와 조회 업체는 `EXPO_PUBLIC_API_BASE_URL`, `EXPO_PUBLIC_COMPANY_ID`로 설정합니다. 예시는 `app/.env.example`을 참고하며 공개 설정 파일에 인증정보를 넣지 않습니다.
--- ---
## 웹 앱 배포 가이드 (Web Deployment) ## 웹 앱 배포 가이드 (Web Deployment)
@ -228,7 +232,8 @@ React Native(Expo) 앱을 웹으로 빌드하고 Nginx 서버에 배포하는
### 1. 웹 빌드 (Local PC) ### 1. 웹 빌드 (Local PC)
```bash ```bash
cd d:\dev\etc\SolorPower\app cd d:\dev\etc\SolorPower\app
npm run export:web npm ci
npm run build:web
``` ```
- `dist` 폴더에 정적 웹 파일들이 생성됩니다. - `dist` 폴더에 정적 웹 파일들이 생성됩니다.

View File

@ -5,7 +5,7 @@
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from supabase import Client from supabase import Client
from typing import Literal, Optional from typing import Annotated, Literal, Optional
from datetime import date as date_type from datetime import date as date_type
import calendar import calendar
import re import re
@ -63,7 +63,10 @@ def get_all_plants_comparison(
date: Optional[str] = Query(None, description="날짜 (YYYY-MM-DD)"), date: Optional[str] = Query(None, description="날짜 (YYYY-MM-DD)"),
year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="연도"), year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, description="연도"),
month: Optional[int] = Query(None, ge=1, le=12, description=""), month: Optional[int] = Query(None, ge=1, le=12, description=""),
db: Client = Depends(get_db) db: Client = Depends(get_db),
company_id: Annotated[
Optional[int], Query(ge=1, description="업체 ID")
] = None,
) -> ComparisonStatsResponse: ) -> ComparisonStatsResponse:
""" """
전체 발전소 발전량 비교 통계 조회 전체 발전소 발전량 비교 통계 조회
@ -75,8 +78,12 @@ def get_all_plants_comparison(
target_year = year if year is not None else target_date.year target_year = year if year is not None else target_date.year
target_month = month if month is not None else target_date.month target_month = month if month is not None else target_date.month
# 1. 모든 발전소 기본 정보 조회 (이름, 용량) # 업체가 지정되면 해당 업체 발전소만 비교한다. 미지정 호출은 기존
plants_res = db.table("plants").select("id, name, capacity").execute() # 전체 비교 계약을 유지하여 하위 호환성을 보장한다.
plants_query = db.table("plants").select("id, name, capacity")
if company_id is not None:
plants_query = plants_query.eq("company_id", company_id)
plants_res = plants_query.execute()
plants = {p['id']: p for p in plants_res.data} plants = {p['id']: p for p in plants_res.data}
# 결과 초기화 # 결과 초기화

View File

@ -53,6 +53,13 @@ class ApiContractTest(unittest.TestCase):
self.assertEqual(400, response.status_code) self.assertEqual(400, response.status_code)
def test_invalid_comparison_company_returns_422(self):
response = self.client.get(
"/plants/stats/comparison?period=day&company_id=0"
)
self.assertEqual(422, response.status_code)
def test_invalid_month_returns_422(self): def test_invalid_month_returns_422(self):
response = self.client.get( response = self.client.get(
"/plants/nrems-03/stats?period=day&year=2026&month=13" "/plants/nrems-03/stats?period=day&year=2026&month=13"

View File

@ -84,6 +84,17 @@ class StatsTimezoneTest(unittest.TestCase):
self.assertIn(("lt", "created_at", "2026-01-01T15:00:00+00:00"), query.filters) self.assertIn(("lt", "created_at", "2026-01-01T15:00:00+00:00"), query.filters)
self.assertEqual(123.4, result.data[0].generation) self.assertEqual(123.4, result.data[0].generation)
def test_comparison_filters_plants_by_company_when_requested(self):
db = FakeDb()
stats.get_all_plants_comparison(
period="day", date="2026-01-01", year=None, month=None,
db=db, company_id=7,
)
query = db.latest_query("plants")
self.assertIn(("eq", "company_id", 7), query.filters)
def test_plant_today_query_has_upper_bound(self): def test_plant_today_query_has_upper_bound(self):
db = FakeDb([{"today_kwh": 77.0}]) db = FakeDb([{"today_kwh": 77.0}])

3
app/.env.example Normal file
View File

@ -0,0 +1,3 @@
# Expo exposes EXPO_PUBLIC_* values to the app bundle. Do not put secrets here.
EXPO_PUBLIC_API_BASE_URL=https://solorpower.dadot.net
EXPO_PUBLIC_COMPANY_ID=1

View File

@ -16,8 +16,9 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import PlantDetailScreen from './screens/PlantDetailScreen'; import PlantDetailScreen from './screens/PlantDetailScreen';
import ComparisonScreen from './screens/ComparisonScreen'; import ComparisonScreen from './screens/ComparisonScreen';
import { appConfig } from './config/appConfig';
import { getPlants, updatePlantAlerts } from './services/solarApi';
const API_URL = 'https://solorpower.dadot.net/plants/1';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
// 메인 대시보드 화면 // 메인 대시보드 화면
@ -33,11 +34,7 @@ function DashboardScreen({ navigation }) {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
setError(null); setError(null);
const response = await fetch(API_URL); const result = await getPlants();
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
setData(result); setData(result);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@ -62,17 +59,7 @@ function DashboardScreen({ navigation }) {
return newData; return newData;
}); });
const response = await fetch(`https://solorpower.dadot.net/plants/${plant.company_id || 1}/${plant.id}/alerts`, { await updatePlantAlerts(plant.company_id, plant.id, newValue);
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) { } catch (err) {
console.error(err); console.error(err);
setData((prev) => { setData((prev) => {
@ -196,7 +183,7 @@ function DashboardScreen({ navigation }) {
<Text style={styles.headerTitle}> 태양광 발전 현황</Text> <Text style={styles.headerTitle}> 태양광 발전 현황</Text>
<TouchableOpacity <TouchableOpacity
style={styles.compareButton} style={styles.compareButton}
onPress={() => navigation.navigate('Comparison')} onPress={() => navigation.navigate('Comparison', { companyId: appConfig.companyId })}
> >
<Text style={styles.compareButtonText}>📊 전체 비교</Text> <Text style={styles.compareButtonText}>📊 전체 비교</Text>
</TouchableOpacity> </TouchableOpacity>

View File

@ -0,0 +1,76 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
const TABS = [
{ key: 'today', label: '오늘' },
{ key: 'day', label: '일간' },
{ key: 'month', label: '월간' },
{ key: 'year', label: '연간' },
];
export default function StatsPeriodControls({
dateLabel,
onMoveDate,
onPeriodChange,
period,
}) {
return (
<>
<View style={styles.dateControl}>
<TouchableOpacity onPress={() => onMoveDate(-1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
<Text style={styles.dateText}>{dateLabel}</Text>
<TouchableOpacity onPress={() => onMoveDate(1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
</View>
<View style={styles.tabContainer}>
{TABS.map((tab) => (
<TouchableOpacity
key={tab.key}
style={[styles.tab, period === tab.key && styles.tabActive]}
onPress={() => onPeriodChange(tab.key)}
>
<Text style={[styles.tabText, period === tab.key && styles.tabTextActive]}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
</>
);
}
const styles = StyleSheet.create({
dateControl: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: 12,
gap: 16,
},
arrowButton: { padding: 8 },
arrowText: { fontSize: 20, color: '#4B5563' },
dateText: { fontSize: 16, fontWeight: 'bold', color: '#1F2937' },
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' },
});

View File

@ -14,8 +14,7 @@ import {
Alert, Alert,
} from 'react-native'; } from 'react-native';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import { uploadGenerationData } from '../services/solarApi';
const API_URL = 'https://solorpower.dadot.net';
export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) { export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -70,29 +69,11 @@ export default function UploadModal({ visible, onClose, plantId, onUploadSuccess
}); });
} }
// uploadType에 따라 엔드포인트 분기 const result = await uploadGenerationData(plantId, uploadType, formData);
const endpoint = uploadType === 'daily' Alert.alert('성공', result.message || '업로드가 완료되었습니다.');
? `/plants/${plantId}/upload` setSelectedFile(null);
: `/plants/${plantId}/upload/monthly`; onUploadSuccess?.();
onClose();
console.log(`🚀 Uploading ${uploadType} data`);
console.log(" URL:", `${API_URL}${endpoint}`);
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok) {
Alert.alert('성공', result.message || '업로드가 완료되었습니다.');
setSelectedFile(null);
onUploadSuccess?.();
onClose();
} else {
Alert.alert('오류', result.detail || '업로드에 실패했습니다.');
}
} catch (error) { } catch (error) {
Alert.alert('오류', `업로드 중 오류가 발생했습니다: ${error.message}`); Alert.alert('오류', `업로드 중 오류가 발생했습니다: ${error.message}`);
} finally { } finally {

20
app/config/appConfig.js Normal file
View File

@ -0,0 +1,20 @@
const DEFAULT_API_BASE_URL = 'https://solorpower.dadot.net';
const DEFAULT_COMPANY_ID = 1;
const normalizeBaseUrl = (value) => value.replace(/\/+$/, '');
const parseCompanyId = (value) => {
const companyId = Number.parseInt(value, 10);
return Number.isInteger(companyId) && companyId > 0
? companyId
: DEFAULT_COMPANY_ID;
};
// This is the single tenant-selection boundary until the UI supports switching
// companies. Deployments can select another company without changing source.
export const appConfig = Object.freeze({
apiBaseUrl: normalizeBaseUrl(
process.env.EXPO_PUBLIC_API_BASE_URL || DEFAULT_API_BASE_URL,
),
companyId: parseCompanyId(process.env.EXPO_PUBLIC_COMPANY_ID),
});

View File

@ -0,0 +1,73 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getHourlyPlantStats, getPlantStats } from '../services/solarApi';
import {
formatHourlyChartData,
formatPeriodChartData,
toLocalIsoDate,
} from '../utils/statsChart';
const buildPeriodParams = (period, selectedDate) => {
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
if (period === 'day') return { period, year, month };
return { period, year };
};
const formatUpdatedAt = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
};
export default function usePlantStats(plant, period, selectedDate) {
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const reload = useCallback(async () => {
try {
setLoading(true);
setError(null);
if (period === 'today') {
const result = await getHourlyPlantStats(plant.id, toLocalIsoDate(selectedDate));
setChartData(formatHourlyChartData(result?.data || [], selectedDate));
} else {
const result = await getPlantStats(
plant.id,
buildPeriodParams(period, selectedDate),
);
setChartData(formatPeriodChartData(period, result?.data || [], selectedDate));
}
} catch (requestError) {
setError(requestError.message);
setChartData([]);
} finally {
setLoading(false);
}
}, [period, plant.id, selectedDate]);
useEffect(() => {
reload();
}, [reload]);
const todayData = useMemo(() => {
if (period !== 'today' || toLocalIsoDate(selectedDate) !== toLocalIsoDate(new Date())) {
return null;
}
const latestLog = plant.latest_log;
if (!latestLog) return null;
return {
currentKw: Number(latestLog.current_kw) || 0,
todayKwh: Number(latestLog.today_kwh) || 0,
updatedAt: formatUpdatedAt(latestLog.created_at),
};
}, [period, plant.latest_log, selectedDate]);
return { chartData, loading, error, reload, todayData };
}

View File

@ -10,10 +10,10 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts'; import { BarChart } from 'react-native-gifted-charts';
import { appConfig } from '../config/appConfig';
import { getComparisonStats } from '../services/solarApi';
const API_BASE_URL = 'https://solorpower.dadot.net'; export default function ComparisonScreen({ navigation, route }) {
export default function ComparisonScreen({ navigation }) {
const [period, setPeriod] = useState('day'); const [period, setPeriod] = useState('day');
const [statsData, setStatsData] = useState([]); const [statsData, setStatsData] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -58,19 +58,17 @@ export default function ComparisonScreen({ navigation }) {
const d = currentDate.getDate(); const d = currentDate.getDate();
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
let query = `period=${period}`; const query = { company_id: route.params?.companyId || appConfig.companyId, period };
if (period === 'day') { if (period === 'day') {
query += `&date=${dateStr}`; query.date = dateStr;
} else if (period === 'month') { } else if (period === 'month') {
query += `&year=${y}&month=${m}`; query.year = y;
query.month = m;
} else if (period === 'year') { } else if (period === 'year') {
query += `&year=${y}`; query.year = y;
} }
const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`); const result = await getComparisonStats(query);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const result = await response.json();
setStatsData(result.data || []); setStatsData(result.data || []);
} catch (err) { } catch (err) {
@ -79,7 +77,7 @@ export default function ComparisonScreen({ navigation }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [period, currentDate]); }, [period, currentDate, route.params?.companyId]);
useEffect(() => { useEffect(() => {
fetchStats(); fetchStats();

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
StyleSheet, StyleSheet,
View, View,
@ -6,27 +6,26 @@ import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
Alert,
useWindowDimensions, useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts'; import { BarChart } from 'react-native-gifted-charts';
import StatsPeriodControls from '../components/StatsPeriodControls';
import UploadModal from '../components/UploadModal'; import UploadModal from '../components/UploadModal';
import usePlantStats from '../hooks/usePlantStats';
const API_BASE_URL = 'https://solorpower.dadot.net';
export default function PlantDetailScreen({ route, navigation }) { export default function PlantDetailScreen({ route, navigation }) {
const { plant } = route.params; const { plant } = route.params;
const [period, setPeriod] = useState('today'); const [period, setPeriod] = useState('today');
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(true);
const [uploadVisible, setUploadVisible] = useState(false); const [uploadVisible, setUploadVisible] = 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 { width } = useWindowDimensions();
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const { chartData, loading, error, reload: fetchStats, todayData } = usePlantStats(
plant,
period,
currentDate,
);
// 탭 변경 시 날짜 초기화 // 탭 변경 시 날짜 초기화
useEffect(() => { useEffect(() => {
@ -51,144 +50,6 @@ export default function PlantDetailScreen({ route, navigation }) {
setCurrentDate(newDate); 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일 등) // 날짜 포맷팅 (YYYY년 MM월 DD일 등)
const formatDateDisplay = () => { const formatDateDisplay = () => {
const y = currentDate.getFullYear(); const y = currentDate.getFullYear();
@ -202,36 +63,8 @@ export default function PlantDetailScreen({ route, navigation }) {
return `${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) => { const renderTooltip = (item) => {
return ( return (
<View style={{ <View style={{
marginBottom: 20, marginBottom: 20,
@ -248,52 +81,11 @@ export default function PlantDetailScreen({ route, navigation }) {
); );
}; };
// 라벨 포맷팅
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 = () => { const getTotalGeneration = () => {
return chartData.reduce((sum, item) => sum + (item.value || 0), 0); return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
}; };
// 탭 렌더링
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); const chartWidth = Math.min(width - 60, 600);
// 막대 너비 및 간격 계산 // 막대 너비 및 간격 계산
@ -329,11 +121,12 @@ export default function PlantDetailScreen({ route, navigation }) {
</View> </View>
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}> <ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
{/* 날짜 컨트롤 */} <StatsPeriodControls
{renderDateControl()} dateLabel={formatDateDisplay()}
onMoveDate={moveDate}
{/* 탭 */} onPeriodChange={setPeriod}
{renderTabs()} period={period}
/>
{/* 오늘 실시간 현황 카드 (today 탭일 때만) */} {/* 오늘 실시간 현황 카드 (today 탭일 때만) */}
{period === 'today' && todayData && ( {period === 'today' && todayData && (
@ -360,7 +153,7 @@ export default function PlantDetailScreen({ route, navigation }) {
<View style={styles.chartSection}> <View style={styles.chartSection}>
<View style={styles.chartHeader}> <View style={styles.chartHeader}>
<Text style={styles.sectionTitle}> <Text style={styles.sectionTitle}>
📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()}) 📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({formatDateDisplay()})
</Text> </Text>
<Text style={styles.chartHint}>막대를 터치하면 상세 정보</Text> <Text style={styles.chartHint}>막대를 터치하면 상세 정보</Text>
</View> </View>
@ -503,36 +296,6 @@ const styles = StyleSheet.create({
gap: 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 // Today Card
todayCard: { todayCard: {
backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',

View File

@ -1,33 +0,0 @@
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"

63
app/services/solarApi.js Normal file
View File

@ -0,0 +1,63 @@
import { appConfig } from '../config/appConfig';
const buildQuery = (params) => {
const entries = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
return entries.length > 0 ? `?${entries.join('&')}` : '';
};
const requestJson = async (path, options = {}) => {
const response = await fetch(`${appConfig.apiBaseUrl}${path}`, options);
let payload = null;
try {
payload = await response.json();
} catch {
// Preserve the HTTP status when an upstream proxy returns a non-JSON body.
}
if (!response.ok) {
const message = payload?.detail || payload?.message || `HTTP Error: ${response.status}`;
throw new Error(message);
}
return payload;
};
export const getPlants = (companyId = appConfig.companyId) => (
requestJson(`/plants/${companyId}`)
);
export const updatePlantAlerts = (companyId, plantId, alertsEnabled) => {
if (!companyId) {
throw new Error('발전소의 업체 ID가 없습니다.');
}
return requestJson(`/plants/${companyId}/${plantId}/alerts`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alerts_enabled: alertsEnabled }),
});
};
export const getHourlyPlantStats = (plantId, date) => (
requestJson(`/plants/${plantId}/stats/today${buildQuery({ date })}`)
);
export const getPlantStats = (plantId, params) => (
requestJson(`/plants/${plantId}/stats${buildQuery(params)}`)
);
export const getComparisonStats = (params) => (
requestJson('/plants/stats/comparison' + buildQuery(params))
);
export const uploadGenerationData = (plantId, uploadType, formData) => {
const suffix = uploadType === 'monthly' ? '/monthly' : '';
return requestJson(`/plants/${plantId}/upload${suffix}`, {
method: 'POST',
body: formData,
});
};

87
app/utils/statsChart.js Normal file
View File

@ -0,0 +1,87 @@
const EMPTY_BAR_COLOR = '#E5E7EB';
const DATA_BAR_COLOR = '#3B82F6';
const CURRENT_BAR_COLOR = '#10B981';
export const toLocalIsoDate = (value) => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const labelTextStyle = (shiftX) => ({
fontSize: 10,
color: '#6B7280',
width: 40,
textAlign: 'center',
shiftX,
});
const formatYearLabel = (label) => (
label?.length === 4 ? `${label.slice(2)}` : (label || '')
);
export const formatHourlyChartData = (hourlyData, selectedDate, now = new Date()) => {
const byHour = new Map(hourlyData.map((item) => [item.hour, item]));
const isToday = toLocalIsoDate(now) === toLocalIsoDate(selectedDate);
return Array.from({ length: 25 }, (_, hour) => {
if (hour === 24) {
return { value: 0, label: '24시', frontColor: 'transparent' };
}
const item = byHour.get(hour) || {};
const hasData = item.has_data || item.current_kw > 0;
const isCurrentHour = isToday && hour === now.getHours();
return {
value: item.current_kw || 0,
label: hour % 2 === 0 ? `${hour}` : '',
frontColor: isCurrentHour
? CURRENT_BAR_COLOR
: (hasData ? DATA_BAR_COLOR : EMPTY_BAR_COLOR),
};
});
};
export const formatPeriodChartData = (period, rawData, selectedDate) => {
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
const dataMap = new Map(rawData.map((item) => [item.label, item.value || 0]));
if (period === 'day') {
const lastDay = new Date(year, month, 0).getDate();
return Array.from({ length: lastDay }, (_, index) => {
const day = index + 1;
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const value = dataMap.get(key) || 0;
return {
value,
label: day === 1 || day % 5 === 0 ? `${day}` : '',
labelTextStyle: labelTextStyle(-10),
frontColor: value > 0 ? DATA_BAR_COLOR : EMPTY_BAR_COLOR,
};
});
}
if (period === 'month') {
return Array.from({ length: 12 }, (_, index) => {
const itemMonth = index + 1;
const key = `${year}-${String(itemMonth).padStart(2, '0')}`;
const value = dataMap.get(key) || 0;
return {
value,
label: `${itemMonth}`,
labelTextStyle: labelTextStyle(-5),
frontColor: value > 0 ? DATA_BAR_COLOR : EMPTY_BAR_COLOR,
};
});
}
return rawData.map((item) => ({
value: item.value || 0,
label: formatYearLabel(item.label),
labelTextStyle: labelTextStyle(-10),
frontColor: item.value > 0 ? DATA_BAR_COLOR : EMPTY_BAR_COLOR,
}));
};

View File

@ -6,14 +6,12 @@
NAS 배포 전 Windows 환경에서 테스트 및 데이터 복구를 수행할 수 있습니다. NAS 배포 전 Windows 환경에서 테스트 및 데이터 복구를 수행할 수 있습니다.
## 2. 가상환경 (Windows) ## 2. 가상환경 (Windows)
윈도우용 가상환경은 `venv_win` 폴더에 구성되어 있습니다. 가상환경은 저장소에 포함하지 않고 개발 PC마다 `.venv`로 생성합니다. 고정 의존성은 `requirements.txt`를 사용합니다.
```powershell ```powershell
# 가상환경 활성화 python -m venv .venv
.\venv_win\Scripts\activate .\.venv\Scripts\python -m pip install -r requirements.txt
.\.venv\Scripts\python -m unittest discover -s tests -p "test_*.py" -v
# 의존성 설치
pip install -r requirements.txt
``` ```
## 3. 개발 도구 (tools 폴더) ## 3. 개발 도구 (tools 폴더)
@ -35,16 +33,16 @@ pip install -r requirements.txt
python tools/recover_from_log.py python tools/recover_from_log.py
``` ```
## 4. 실행 및 테스트 ## 4. 실행
메인 크롤러 실행: 메인 크롤러 실행:
```powershell ```powershell
python main.py .\.venv\Scripts\python main.py
``` ```
GUI 대시보드 실행 (테스트용): GUI 대시보드 실행 (테스트용):
```powershell ```powershell
python crawler_gui.py .\.venv\Scripts\python crawler_gui.py
``` ```
## 5. 배포 시 주의사항 ## 5. 배포 시 주의사항
- `crawler_manager.py``site_data.db`는 로컬에 생성되므로 배포 시 제외하거나 초기화 상태로 배포하세요. - `crawler_manager.py``crawler_manager.db`는 런타임에 생성되며 Git에서 제외합니다. 운영 상태 DB를 배포 파일로 덮어쓰지 마세요.
- `.env` 파일의 API 키가 만료되지 않았는지 확인하세요. - `.env` 파일의 API 키가 만료되지 않았는지 확인하세요.

View File

@ -1,319 +0,0 @@
# ==========================================
# 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

View File

@ -1,427 +0,0 @@
# ==========================================
# 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

View File

@ -1,343 +0,0 @@
# ==========================================
# 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

View File

@ -1,343 +0,0 @@
# ==========================================
# 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

View File

@ -112,11 +112,13 @@
## 🔧 사용 방법 ## 🔧 사용 방법
모든 스크립트는 crawler 가상환경에서 실행: 모든 스크립트는 `docs/development_and_testing.md`에 따라 새로 만든 crawler 가상환경에서 실행:
```powershell ```powershell
cd d:\dev\etc\SolorPower\crawler cd d:\dev\etc\SolorPower\crawler
.\venv_win\Scripts\Activate.ps1 python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
# 스크립트 실행 예시 # 스크립트 실행 예시
python scripts_archive/fetch_february.py python scripts_archive/fetch_february.py

View File

@ -1,502 +0,0 @@
<#
.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

View File

@ -1,69 +0,0 @@
# 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

View File

@ -1,34 +0,0 @@
@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=
)

View File

@ -1,22 +0,0 @@
@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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +0,0 @@
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

View File

@ -44,6 +44,13 @@ $env:DEBUG = "false"
`package-lock.json`을 저장소에 포함하고 CI와 새 환경에서는 `npm ci`를 사용한다. `package-lock.json`을 저장소에 포함하고 CI와 새 환경에서는 `npm ci`를 사용한다.
API 주소와 현재 조회 업체는 Expo 공개 환경변수로 선택한다. 기본값은 운영 환경을 유지하며, 다른 환경에서는 `app/.env.example``app/.env.local`로 복사해 값을 바꾼다. 이 값은 앱 번들에 포함되는 공개 설정이므로 인증 토큰이나 비밀번호를 넣지 않는다.
| 환경변수 | 용도 | 기본값 |
|---|---|---|
| `EXPO_PUBLIC_API_BASE_URL` | FastAPI/Nginx 기본 URL | `https://solorpower.dadot.net` |
| `EXPO_PUBLIC_COMPANY_ID` | 대시보드와 비교 화면의 업체 ID | `1` |
```powershell ```powershell
cd app cd app
npm ci npm ci
@ -52,6 +59,8 @@ npm run test:ci
현재 `test:ci`는 Expo 웹 export를 수행하여 의존성, 번들링, 정적 산출물 생성을 함께 검사한다. 출력 디렉터리는 `app/dist/`이며 Git에서 제외된다. 현재 `test:ci`는 Expo 웹 export를 수행하여 의존성, 번들링, 정적 산출물 생성을 함께 검사한다. 출력 디렉터리는 `app/dist/`이며 Git에서 제외된다.
앱의 네트워크 호출은 `services/solarApi.js`, 환경 선택은 `config/appConfig.js`를 통해 수행한다. 화면에 API URL이나 업체 ID를 직접 작성하지 않는다.
## DB migration ## DB migration
통계 쓰기 일관성 migration은 독립 PostgreSQL 15 DB에서 다음 계약 테스트로 확인한다. 통계 쓰기 일관성 migration은 독립 PostgreSQL 15 DB에서 다음 계약 테스트로 확인한다.

View File

@ -0,0 +1,60 @@
# 저장소 및 앱 코드 구조
작성일: 2026-08-07
이 문서는 8단계 저장소 정리 이후 유지해야 할 디렉터리 책임과 산출물 관리 기준을 정의한다.
## 최상위 디렉터리
| 경로 | 책임 |
|---|---|
| `crawler/` | 외부 발전 사이트 수집, 알림, 일·월 통계 저장과 백필 |
| `api_server/` | 앱용 FastAPI 조회·업로드 API |
| `app/` | Expo 기반 모바일·웹 대시보드 |
| `supabase/` | PostgreSQL migration과 DB 계약 테스트 |
| `deploy/` | Nginx 등 버전 관리 대상 배포 설정 |
| `docs/` | 운영, 개발, 개선 단계와 구조 문서 |
| `sql/` | 초기 스키마 및 과거 SQL 참고 자료 |
| `tools/` | 프로젝트 외부 입력을 변환하는 독립 도구 |
## 앱 내부 경계
| 경로 | 책임 |
|---|---|
| `config/appConfig.js` | 환경별 API 기본 URL과 현재 업체 선택 |
| `services/solarApi.js` | HTTP 경로, 쿼리 구성, 오류 응답 처리 |
| `hooks/usePlantStats.js` | 상세 통계 조회 상태와 새로고침 수명주기 |
| `utils/statsChart.js` | API 통계를 차트 데이터로 변환하는 순수 로직 |
| `components/` | 업로드 모달과 기간 선택 등 재사용 UI |
| `screens/` | 화면 배치와 사용자 상호작용 조합 |
현재 앱은 한 업체를 선택해 보여 주지만 업체 ID를 화면이나 URL에 직접 하드코딩하지 않는다. 배포 환경은 `EXPO_PUBLIC_COMPANY_ID`로 업체를 선택하고, 대시보드가 받은 각 발전소의 `company_id`를 변경 API에 다시 사용한다. 비교 API는 선택적인 `company_id` 쿼리를 받아 같은 업체의 발전소만 반환한다. 향후 업체 선택 UI를 추가할 때는 이 설정값을 앱 상태로 승격하고 서비스 함수에 전달하는 경계를 유지한다.
## 생성 파일 관리
다음 파일은 Git에 추가하지 않는다.
- Python 가상환경: `.venv/`, `venv/`, `venv_win/`
- Node/Expo 산출물: `node_modules/`, `.expo/`, `dist/`, `build/`
- 런타임 상태와 출력: `*.db`, `*.db-journal`, `*.log`
- 로컬 환경과 인증정보: `.env`, `.env.*` (`.env.example` 제외), `secrets.json`
- 서버 배포 백업과 staging: `deploy_backups/`, `deploy_staging/`
- 로컬 Supabase CLI 바이너리: `supabase.exe`, `supabase-go.exe`
가상환경은 각 하위 프로젝트의 고정 `requirements.txt`로, 앱 의존성은 `package-lock.json``npm ci`로 재생성한다.
## 보관 정책
- 실행 코드와 같은 디렉터리에 `*_old.py`, `*.backup` 사본을 두지 않는다. 이전 버전은 Git 이력에서 복구한다.
- 완료된 일회성 데이터 보정 스크립트는 삭제 목적, 영향 데이터, 실행 주의사항이 있는 경우에만 `crawler/scripts_archive/`에 둔다.
- `scripts_archive/`의 코드는 정기 실행 진입점이 아니며, 다시 실행하기 전에 현재 DB 스키마와 KST 처리 정책을 재검토한다.
- 운영 배포 전 백업은 서버의 `deploy_backups/`에 보존하되 저장소에는 포함하지 않는다.
## 8단계에서 제거한 추적 파일
- `crawler/venv_win/`: 개발 PC에서 생성된 Windows 가상환경과 실행 파일
- `app/server.log`: Expo 개발 서버 런타임 로그
- `crawler/crawlers/cmsolar_old.py`, `cmsolar_old2.py`: 현재 라우팅에서 참조되지 않는 구형 구현
- `crawler/crawlers/sun_wms_old.py`, `sun_wms.py.backup`: 현재 구현과 중복된 사본
모든 소스 사본은 삭제 전 Git 이력에 존재하므로 필요하면 해당 커밋에서 복구할 수 있다.