diff --git a/.gitignore b/.gitignore
index b8e96bd..9aa7806 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,8 +20,12 @@ env/
*.db-journal
.env
.env.*
+!.env.example
secrets.json
+# Runtime output
+*.log
+
# IDEs and Editors
.vscode/
.idea/
diff --git a/AGENTS.md b/AGENTS.md
index bca2907..922c6db 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,19 +43,17 @@ SolarPower is an integrated solar power monitoring system consisting of:
- **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api`
- **Docs**: Access Swagger UI at `http://localhost:8000/docs`
- **Test**:
- - No automated test suite (pytest) currently.
- - Verify endpoints manually using `curl` or Swagger UI.
+ - Run `python -m unittest discover -s tests -p "test_*.py" -v`.
+ - Verify deployed endpoints using `curl` or Swagger UI.
### ๐ Crawler (`crawler/`)
- **Environment**: Python 3.10+
-- **Install**: Manual dependency installation (requests, python-dotenv, supabase).
+- **Install**: `pip install -r requirements.txt`
- **Run (Manual)**: `python main.py`
- Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`.
- **Run (Force)**: `python main.py --force` (Ignores scheduler constraints)
- **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning)
-- **Testing**:
- - No standard test runner. Use standalone scripts in `tests/`.
- - **Run Single Test**: `python tests/debug_kremc.py` (or similar script for specific site).
+- **Testing**: `python -m unittest discover -s tests -p "test_*.py" -v`
## 4. Code Style & Standards
@@ -99,7 +97,7 @@ SolarPower is an integrated solar power monitoring system consisting of:
### โ๏ธ Server Deployment
1. **Push**: Commit and push changes to the remote Git repository.
2. **Pull**: SSH into the server and pull changes.
- - Command: `ssh user@server "cd ~/solorpower_server && git pull"`
+ - Command: `ssh user@server "cd ~/solorpower && git pull"`
3. **Restart**: Restart the API service.
- Command: `sudo systemctl restart solar-api`
diff --git a/README.md b/README.md
index 781b9c7..f4771b8 100644
--- a/README.md
+++ b/README.md
@@ -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)
-/home/ubuntu/SolorPower/crawler/ (Oracle Cloud)
+/home/ubuntu/solorpower/crawler/ (Oracle Cloud)
```
### ๋คํธ์ํฌ ๊ตฌ์ฑ (Tailscale)
@@ -157,7 +159,7 @@ crawler/
### ์๋ ์์ง (Cron)
```bash
# 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)
@@ -173,7 +175,7 @@ crawler/
### ์์น
```
-/home/ubuntu/SolorPower/api_server/
+/home/ubuntu/solorpower/api_server/
```
### ํ๊ฒฝ ์ค์ (.env)
@@ -219,6 +221,8 @@ d:\dev\etc\SolorPower\app\
- ๋ณ์นญ(`๋ฐ์ ๋`, `kwh` ๋ฑ) ๋ฐ ์
๋ณํฉ ์ฒ๋ฆฌ ์ง์.
- **๋ฐ์ํ ์น ์ง์**: PC/๋ชจ๋ฐ์ผ ๋ธ๋ผ์ฐ์ ์ต์ ํ.
+API ์ฃผ์์ ์กฐํ ์
์ฒด๋ `EXPO_PUBLIC_API_BASE_URL`, `EXPO_PUBLIC_COMPANY_ID`๋ก ์ค์ ํฉ๋๋ค. ์์๋ `app/.env.example`์ ์ฐธ๊ณ ํ๋ฉฐ ๊ณต๊ฐ ์ค์ ํ์ผ์ ์ธ์ฆ์ ๋ณด๋ฅผ ๋ฃ์ง ์์ต๋๋ค.
+
---
## ์น ์ฑ ๋ฐฐํฌ ๊ฐ์ด๋ (Web Deployment)
@@ -228,7 +232,8 @@ React Native(Expo) ์ฑ์ ์น์ผ๋ก ๋น๋ํ๊ณ Nginx ์๋ฒ์ ๋ฐฐํฌํ๋
### 1. ์น ๋น๋ (Local PC)
```bash
cd d:\dev\etc\SolorPower\app
-npm run export:web
+npm ci
+npm run build:web
```
- `dist` ํด๋์ ์ ์ ์น ํ์ผ๋ค์ด ์์ฑ๋ฉ๋๋ค.
diff --git a/api_server/app/routers/stats.py b/api_server/app/routers/stats.py
index ead1cae..f979daa 100644
--- a/api_server/app/routers/stats.py
+++ b/api_server/app/routers/stats.py
@@ -5,7 +5,7 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from supabase import Client
-from typing import Literal, Optional
+from typing import Annotated, Literal, Optional
from datetime import date as date_type
import calendar
import re
@@ -63,7 +63,10 @@ def get_all_plants_comparison(
date: Optional[str] = Query(None, description="๋ ์ง (YYYY-MM-DD)"),
year: Optional[int] = Query(None, ge=MIN_STATS_YEAR, le=MAX_STATS_YEAR, 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:
"""
์ ์ฒด ๋ฐ์ ์ ๋ฐ์ ๋ ๋น๊ต ํต๊ณ ์กฐํ
@@ -75,8 +78,12 @@ def get_all_plants_comparison(
target_year = year if year is not None else target_date.year
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}
# ๊ฒฐ๊ณผ ์ด๊ธฐํ
diff --git a/api_server/tests/test_api_contract.py b/api_server/tests/test_api_contract.py
index 90b2aaa..5c911b5 100644
--- a/api_server/tests/test_api_contract.py
+++ b/api_server/tests/test_api_contract.py
@@ -53,6 +53,13 @@ class ApiContractTest(unittest.TestCase):
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):
response = self.client.get(
"/plants/nrems-03/stats?period=day&year=2026&month=13"
diff --git a/api_server/tests/test_stats_timezone.py b/api_server/tests/test_stats_timezone.py
index d0c1fd6..74f961c 100644
--- a/api_server/tests/test_stats_timezone.py
+++ b/api_server/tests/test_stats_timezone.py
@@ -84,6 +84,17 @@ class StatsTimezoneTest(unittest.TestCase):
self.assertIn(("lt", "created_at", "2026-01-01T15:00:00+00:00"), query.filters)
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):
db = FakeDb([{"today_kwh": 77.0}])
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 0000000..016bc63
--- /dev/null
+++ b/app/.env.example
@@ -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
diff --git a/app/App.js b/app/App.js
index 34e3c6b..c3e650d 100644
--- a/app/App.js
+++ b/app/App.js
@@ -16,8 +16,9 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import PlantDetailScreen from './screens/PlantDetailScreen';
import ComparisonScreen from './screens/ComparisonScreen';
+import { appConfig } from './config/appConfig';
+import { getPlants, updatePlantAlerts } from './services/solarApi';
-const API_URL = 'https://solorpower.dadot.net/plants/1';
const Stack = createNativeStackNavigator();
// ๋ฉ์ธ ๋์๋ณด๋ ํ๋ฉด
@@ -33,11 +34,7 @@ function DashboardScreen({ navigation }) {
const fetchData = useCallback(async () => {
try {
setError(null);
- const response = await fetch(API_URL);
- if (!response.ok) {
- throw new Error(`HTTP Error: ${response.status}`);
- }
- const result = await response.json();
+ const result = await getPlants();
setData(result);
} catch (err) {
setError(err.message);
@@ -62,17 +59,7 @@ function DashboardScreen({ navigation }) {
return newData;
});
- const response = await fetch(`https://solorpower.dadot.net/plants/${plant.company_id || 1}/${plant.id}/alerts`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ alerts_enabled: newValue }),
- });
-
- if (!response.ok) {
- throw new Error('Failed to update alert settings');
- }
+ await updatePlantAlerts(plant.company_id, plant.id, newValue);
} catch (err) {
console.error(err);
setData((prev) => {
@@ -196,7 +183,7 @@ function DashboardScreen({ navigation }) {
โ๏ธ ํ์๊ด ๋ฐ์ ํํฉ
navigation.navigate('Comparison')}
+ onPress={() => navigation.navigate('Comparison', { companyId: appConfig.companyId })}
>
๐ ์ ์ฒด ๋น๊ต
diff --git a/app/components/StatsPeriodControls.js b/app/components/StatsPeriodControls.js
new file mode 100644
index 0000000..69bf5a2
--- /dev/null
+++ b/app/components/StatsPeriodControls.js
@@ -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 (
+ <>
+
+ onMoveDate(-1)} style={styles.arrowButton}>
+ โ
+
+ {dateLabel}
+ onMoveDate(1)} style={styles.arrowButton}>
+ โถ
+
+
+
+ {TABS.map((tab) => (
+ onPeriodChange(tab.key)}
+ >
+
+ {tab.label}
+
+
+ ))}
+
+ >
+ );
+}
+
+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' },
+});
diff --git a/app/components/UploadModal.js b/app/components/UploadModal.js
index 28fe989..a596629 100644
--- a/app/components/UploadModal.js
+++ b/app/components/UploadModal.js
@@ -14,8 +14,7 @@ import {
Alert,
} from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
-
-const API_URL = 'https://solorpower.dadot.net';
+import { uploadGenerationData } from '../services/solarApi';
export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) {
const [uploading, setUploading] = useState(false);
@@ -70,29 +69,11 @@ export default function UploadModal({ visible, onClose, plantId, onUploadSuccess
});
}
- // uploadType์ ๋ฐ๋ผ ์๋ํฌ์ธํธ ๋ถ๊ธฐ
- const endpoint = uploadType === 'daily'
- ? `/plants/${plantId}/upload`
- : `/plants/${plantId}/upload/monthly`;
-
- console.log(`๐ Uploading ${uploadType} data`);
- console.log(" URL:", `${API_URL}${endpoint}`);
-
- const response = await fetch(`${API_URL}${endpoint}`, {
- method: 'POST',
- body: formData,
- });
-
- const result = await response.json();
-
- if (response.ok) {
- Alert.alert('์ฑ๊ณต', result.message || '์
๋ก๋๊ฐ ์๋ฃ๋์์ต๋๋ค.');
- setSelectedFile(null);
- onUploadSuccess?.();
- onClose();
- } else {
- Alert.alert('์ค๋ฅ', result.detail || '์
๋ก๋์ ์คํจํ์ต๋๋ค.');
- }
+ const result = await uploadGenerationData(plantId, uploadType, formData);
+ Alert.alert('์ฑ๊ณต', result.message || '์
๋ก๋๊ฐ ์๋ฃ๋์์ต๋๋ค.');
+ setSelectedFile(null);
+ onUploadSuccess?.();
+ onClose();
} catch (error) {
Alert.alert('์ค๋ฅ', `์
๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: ${error.message}`);
} finally {
diff --git a/app/config/appConfig.js b/app/config/appConfig.js
new file mode 100644
index 0000000..89452a6
--- /dev/null
+++ b/app/config/appConfig.js
@@ -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),
+});
diff --git a/app/hooks/usePlantStats.js b/app/hooks/usePlantStats.js
new file mode 100644
index 0000000..ee39bd9
--- /dev/null
+++ b/app/hooks/usePlantStats.js
@@ -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 };
+}
diff --git a/app/screens/ComparisonScreen.js b/app/screens/ComparisonScreen.js
index d6b09bc..c8efd3a 100644
--- a/app/screens/ComparisonScreen.js
+++ b/app/screens/ComparisonScreen.js
@@ -10,10 +10,10 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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 }) {
+export default function ComparisonScreen({ navigation, route }) {
const [period, setPeriod] = useState('day');
const [statsData, setStatsData] = useState([]);
const [loading, setLoading] = useState(true);
@@ -58,19 +58,17 @@ export default function ComparisonScreen({ navigation }) {
const d = currentDate.getDate();
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') {
- query += `&date=${dateStr}`;
+ query.date = dateStr;
} else if (period === 'month') {
- query += `&year=${y}&month=${m}`;
+ query.year = y;
+ query.month = m;
} else if (period === 'year') {
- query += `&year=${y}`;
+ query.year = y;
}
- const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`);
- if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
-
- const result = await response.json();
+ const result = await getComparisonStats(query);
setStatsData(result.data || []);
} catch (err) {
@@ -79,7 +77,7 @@ export default function ComparisonScreen({ navigation }) {
} finally {
setLoading(false);
}
- }, [period, currentDate]);
+ }, [period, currentDate, route.params?.companyId]);
useEffect(() => {
fetchStats();
diff --git a/app/screens/PlantDetailScreen.js b/app/screens/PlantDetailScreen.js
index 324b444..fbce335 100644
--- a/app/screens/PlantDetailScreen.js
+++ b/app/screens/PlantDetailScreen.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect } from 'react';
import {
StyleSheet,
View,
@@ -6,27 +6,26 @@ import {
TouchableOpacity,
ActivityIndicator,
ScrollView,
- Alert,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts';
+import StatsPeriodControls from '../components/StatsPeriodControls';
import UploadModal from '../components/UploadModal';
-
-const API_BASE_URL = 'https://solorpower.dadot.net';
+import usePlantStats from '../hooks/usePlantStats';
export default function PlantDetailScreen({ route, navigation }) {
const { plant } = route.params;
const [period, setPeriod] = useState('today');
- const [chartData, setChartData] = useState([]);
- const [loading, setLoading] = useState(true);
const [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 [currentDate, setCurrentDate] = useState(new Date());
+ const { chartData, loading, error, reload: fetchStats, todayData } = usePlantStats(
+ plant,
+ period,
+ currentDate,
+ );
// ํญ ๋ณ๊ฒฝ ์ ๋ ์ง ์ด๊ธฐํ
useEffect(() => {
@@ -51,144 +50,6 @@ export default function PlantDetailScreen({ route, navigation }) {
setCurrentDate(newDate);
};
- // ํต๊ณ ๋ฐ์ดํฐ ์กฐํ
- const fetchStats = useCallback(async () => {
- try {
- setLoading(true);
- setError(null);
-
- const y = currentDate.getFullYear();
- const m = currentDate.getMonth() + 1;
- const d = currentDate.getDate();
- const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
-
- if (period === 'today') {
- // ์ค๋ ์ค์๊ฐ ๋ฐ์ดํฐ๋ ์ด๋ฏธ ์ ๋ฌ๋ฐ์ plant.latest_log ์ฌ์ฉ (์๋จ ์นด๋์ฉ)
- // ๋ง์ฝ ๊ณผ๊ฑฐ ๋ ์ง๋ฅผ ์ ํํ๋ค๋ฉด ์๋จ ์นด๋๋ ์จ๊ธฐ๊ฑฐ๋ ํด๋น ๋ ์ง์ ์์ฝ์ผ๋ก ๋์ฒดํด์ผ ํจ.
- // ์ฌ๊ธฐ์ '์ค๋'์ธ ๊ฒฝ์ฐ์๋ง ์๋จ ์นด๋๋ฅผ ๋ณด์ฌ์ฃผ๋ ์์ผ๋ก ์ฒ๋ฆฌ ๊ฐ๋ฅ.
-
- // ์๋ฒ์์ ์๊ฐ๋ณ ๋ฐ์ดํฐ ์กฐํ
- const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats/today?date=${dateStr}`);
- if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
- const result = await response.json();
- const hourlyData = result.data || [];
-
- // 24์๊ฐ ๋ฐ์ดํฐ ํฌ๋งทํ
- const formattedData = [];
- for (let i = 0; i <= 24; i++) {
- if (i === 24) {
- formattedData.push({ value: 0, label: '24์', frontColor: 'transparent' });
- continue;
- }
-
- const item = hourlyData.find(d => d.hour === i) || {};
- // ํ์ฌ ์๊ฐ์ธ์ง ํ์ธ (์ค๋ ๋ ์ง์ด๊ณ ํ์ฌ ์๊ฐ์ธ ๊ฒฝ์ฐ)
- const isToday = new Date().toDateString() === currentDate.toDateString();
- const isCurrentHour = isToday && (i === new Date().getHours());
- const hasData = item.has_data || item.current_kw > 0;
-
- let color = '#E5E7EB';
- if (isCurrentHour) {
- color = '#10B981';
- } else if (hasData) {
- color = '#3B82F6';
- }
-
- formattedData.push({
- value: item.current_kw || 0,
- label: i % 2 === 0 ? `${i}์` : '',
- frontColor: color,
- onPress: () => i < 24 && showTooltip(item.current_kw || 0, `${i}์`),
- });
- }
- setChartData(formattedData);
-
- // Today ํญ์ด์ง๋ง ๊ณผ๊ฑฐ ๋ ์ง์ธ ๊ฒฝ์ฐ ์๋จ ์์ฝ ์นด๋ ๋ฐ์ดํฐ ๊ฐฑ์ ํ์
- // API ๊ฒฐ๊ณผ์ daily total์ด ํฌํจ๋์ด ์๋ค๊ณ ๊ฐ์ ํ๊ฑฐ๋, hourly sum์ ํด์ผ ํจ.
- // ํ์ฌ API(stats/today)๋ today_kwh๋ฅผ ์ฃผ์ง๋ง, ์ด๋ ๊ทธ ์๊ฐ๋๊น์ง์ ๋์ ์.
- // ๋ง์ง๋ง ์๊ฐ๋์ today_kwh๊ฐ ๊ทธ๋ ์ ์ด ๋ฐ์ ๋.
- // ํธ์์ ์ฐจํธ ๋ฐ์ดํฐ๋ง ๊ฐฑ์ .
-
- } else {
- // ๊ธฐ์กด ์ผ๊ฐ/์๊ฐ/์ฐ๊ฐ ์กฐํ
- // ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ์ถ๊ฐ
- let query = `period=${period}`;
- if (period === 'day') {
- // period=day -> year, month ํ์
- query += `&year=${y}&month=${m}`;
- } else if (period === 'month') {
- // period=month -> year ํ์
- query += `&year=${y}`;
- } else if (period === 'year') {
- // period=year -> year ํ์ (๊ธฐ์ค ์ฐ๋)
- query += `&year=${y}`;
- }
-
- const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats?${query}`);
- if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
- const result = await response.json();
-
- const rawData = result.data || [];
- const formattedData = [];
-
- if (period === 'day') {
- // ์ ํ๋ ๋ฌ์ ๋ง์ง๋ง ๋
- const lastDay = new Date(y, m, 0).getDate();
- const dataMap = {};
- rawData.forEach(item => { dataMap[item.label] = item.value; });
-
- for (let day = 1; day <= lastDay; day++) {
- const ds = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- const val = dataMap[ds] || 0;
- const showLabel = (day === 1 || day % 5 === 0);
-
- formattedData.push({
- value: val,
- label: showLabel ? `${day}` : '',
- labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
- frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
- });
- }
- } else if (period === 'month') {
- // 1์ ~ 12์
- const dataMap = {};
- rawData.forEach(item => { dataMap[item.label] = item.value; });
-
- for (let month = 1; month <= 12; month++) {
- const ms = `${y}-${String(month).padStart(2, '0')}`;
- const val = dataMap[ms] || 0;
-
- formattedData.push({
- value: val,
- label: `${month}์`,
- labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -5 },
- frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
- renderTooltip: renderTooltip,
- });
- }
- } else {
- // year (๊ธฐ์กด ์ ์ง)
- rawData.forEach(item => {
- formattedData.push({
- value: item.value || 0,
- label: formatLabel(item.label, period),
- labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
- frontColor: item.value > 0 ? '#3B82F6' : '#E5E7EB',
- renderTooltip: renderTooltip,
- });
- });
- }
-
- setChartData(formattedData);
- }
- } catch (err) {
- setError(err.message);
- setChartData([]);
- } finally {
- setLoading(false);
- }
- }, [plant.id, period, currentDate]);
-
// ๋ ์ง ํฌ๋งทํ
(YYYY๋
MM์ DD์ผ ๋ฑ)
const formatDateDisplay = () => {
const y = currentDate.getFullYear();
@@ -202,36 +63,8 @@ export default function PlantDetailScreen({ route, navigation }) {
return `${y}๋
๊ธฐ์ค`;
};
- // ๊ธฐ๊ฐ ์ค๋ช
(์์ )
- const getPeriodDescription = () => {
- return formatDateDisplay();
- };
-
- // ํญ ๋ ๋๋ง (์๋ ๋ ์ง ์ปจํธ๋กค ์ถ๊ฐ)
- // ... renderTabs๋ ๊ทธ๋๋ก ๋๊ณ , ํธ์ถํ๋ ๊ณณ์์ ์ปจํธ๋กค ์ถ๊ฐ
-
- // ๋ ์ง ์ปจํธ๋กค ๋ ๋๋ง
- const renderDateControl = () => (
-
- moveDate(-1)} style={{ padding: 8 }}>
- โ
-
-
- {formatDateDisplay()}
-
- moveDate(1)} style={{ padding: 8 }}>
- โถ
-
-
- );
-
- useEffect(() => {
- fetchStats();
- }, [fetchStats]);
-
- // ํดํ ํ์
// ํดํ ๋ ๋๋ง (์ปค์คํ
)
- const renderTooltip = (item, index) => {
+ const renderTooltip = (item) => {
return (
{
- if (!label) return '';
-
- if (period === 'day') {
- const parts = label.split('-');
- return parts.length >= 3 ? `${parseInt(parts[2], 10)}` : label;
- } else if (period === 'month') {
- const parts = label.split('-');
- // ์์ 0 ์ ๊ฑฐํ๊ณ '์' ์ถ๊ฐ (์: 01 -> 1์)
- return parts.length >= 2 ? `${parseInt(parts[1], 10)}์` : label;
- } else {
- return label.length === 4 ? `${label.slice(2)}๋
` : label;
- }
- };
-
// ์ด ๋ฐ์ ๋ ๊ณ์ฐ
const getTotalGeneration = () => {
return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
};
- // ํญ ๋ ๋๋ง
- const renderTabs = () => (
-
- {[
- { key: 'today', label: '์ค๋' },
- { key: 'day', label: '์ผ๊ฐ' },
- { key: 'month', label: '์๊ฐ' },
- { key: 'year', label: '์ฐ๊ฐ' },
- ].map((tab) => (
- setPeriod(tab.key)}
- >
-
- {tab.label}
-
-
- ))}
-
- );
-
- // ๊ธฐ๊ฐ ์ค๋ช
(์์ - ์์์ ์ด๋ฏธ ์ ์๋จ, ์ค๋ณต ์ ๊ฑฐ)
- // const getPeriodDescription = ... (removed)
-
const chartWidth = Math.min(width - 60, 600);
// ๋ง๋ ๋๋น ๋ฐ ๊ฐ๊ฒฉ ๊ณ์ฐ
@@ -329,11 +121,12 @@ export default function PlantDetailScreen({ route, navigation }) {
- {/* ๋ ์ง ์ปจํธ๋กค */}
- {renderDateControl()}
-
- {/* ํญ */}
- {renderTabs()}
+
{/* ์ค๋ ์ค์๊ฐ ํํฉ ์นด๋ (today ํญ์ผ ๋๋ง) */}
{period === 'today' && todayData && (
@@ -360,7 +153,7 @@ export default function PlantDetailScreen({ route, navigation }) {
- ๐ {period === 'today' ? '์๊ฐ๋๋ณ ์ถ๋ ฅ' : '๋ฐ์ ๋ ์ถ์ด'} ({getPeriodDescription()})
+ ๐ {period === 'today' ? '์๊ฐ๋๋ณ ์ถ๋ ฅ' : '๋ฐ์ ๋ ์ถ์ด'} ({formatDateDisplay()})
๋ง๋๋ฅผ ํฐ์นํ๋ฉด ์์ธ ์ ๋ณด
@@ -503,36 +296,6 @@ const styles = StyleSheet.create({
gap: 16,
},
- // Tabs
- tabContainer: {
- flexDirection: 'row',
- backgroundColor: '#FFFFFF',
- borderRadius: 12,
- padding: 4,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 3,
- },
- tab: {
- flex: 1,
- paddingVertical: 12,
- alignItems: 'center',
- borderRadius: 8,
- },
- tabActive: {
- backgroundColor: '#3B82F6',
- },
- tabText: {
- fontSize: 13,
- fontWeight: '600',
- color: '#6B7280',
- },
- tabTextActive: {
- color: '#FFFFFF',
- },
-
// Today Card
todayCard: {
backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
diff --git a/app/server.log b/app/server.log
deleted file mode 100644
index c4a5fe1..0000000
--- a/app/server.log
+++ /dev/null
@@ -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"
diff --git a/app/services/solarApi.js b/app/services/solarApi.js
new file mode 100644
index 0000000..1fe2102
--- /dev/null
+++ b/app/services/solarApi.js
@@ -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,
+ });
+};
diff --git a/app/utils/statsChart.js b/app/utils/statsChart.js
new file mode 100644
index 0000000..8c68c94
--- /dev/null
+++ b/app/utils/statsChart.js
@@ -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,
+ }));
+};
diff --git a/crawler/DEVELOPMENT.md b/crawler/DEVELOPMENT.md
index 79d2730..0122cc2 100644
--- a/crawler/DEVELOPMENT.md
+++ b/crawler/DEVELOPMENT.md
@@ -6,14 +6,12 @@
NAS ๋ฐฐํฌ ์ Windows ํ๊ฒฝ์์ ํ
์คํธ ๋ฐ ๋ฐ์ดํฐ ๋ณต๊ตฌ๋ฅผ ์ํํ ์ ์์ต๋๋ค.
## 2. ๊ฐ์ํ๊ฒฝ (Windows)
-์๋์ฐ์ฉ ๊ฐ์ํ๊ฒฝ์ `venv_win` ํด๋์ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
+๊ฐ์ํ๊ฒฝ์ ์ ์ฅ์์ ํฌํจํ์ง ์๊ณ ๊ฐ๋ฐ PC๋ง๋ค `.venv`๋ก ์์ฑํฉ๋๋ค. ๊ณ ์ ์์กด์ฑ์ `requirements.txt`๋ฅผ ์ฌ์ฉํฉ๋๋ค.
```powershell
-# ๊ฐ์ํ๊ฒฝ ํ์ฑํ
-.\venv_win\Scripts\activate
-
-# ์์กด์ฑ ์ค์น
-pip install -r requirements.txt
+python -m venv .venv
+.\.venv\Scripts\python -m pip install -r requirements.txt
+.\.venv\Scripts\python -m unittest discover -s tests -p "test_*.py" -v
```
## 3. ๊ฐ๋ฐ ๋๊ตฌ (tools ํด๋)
@@ -35,16 +33,16 @@ pip install -r requirements.txt
python tools/recover_from_log.py
```
-## 4. ์คํ ๋ฐ ํ
์คํธ
+## 4. ์คํ
๋ฉ์ธ ํฌ๋กค๋ฌ ์คํ:
```powershell
-python main.py
+.\.venv\Scripts\python main.py
```
GUI ๋์๋ณด๋ ์คํ (ํ
์คํธ์ฉ):
```powershell
-python crawler_gui.py
+.\.venv\Scripts\python crawler_gui.py
```
## 5. ๋ฐฐํฌ ์ ์ฃผ์์ฌํญ
-- `crawler_manager.py`์ `site_data.db`๋ ๋ก์ปฌ์ ์์ฑ๋๋ฏ๋ก ๋ฐฐํฌ ์ ์ ์ธํ๊ฑฐ๋ ์ด๊ธฐํ ์ํ๋ก ๋ฐฐํฌํ์ธ์.
+- `crawler_manager.py`์ `crawler_manager.db`๋ ๋ฐํ์์ ์์ฑ๋๋ฉฐ Git์์ ์ ์ธํฉ๋๋ค. ์ด์ ์ํ DB๋ฅผ ๋ฐฐํฌ ํ์ผ๋ก ๋ฎ์ด์ฐ์ง ๋ง์ธ์.
- `.env` ํ์ผ์ API ํค๊ฐ ๋ง๋ฃ๋์ง ์์๋์ง ํ์ธํ์ธ์.
diff --git a/crawler/crawlers/cmsolar_old.py b/crawler/crawlers/cmsolar_old.py
deleted file mode 100644
index d10d002..0000000
--- a/crawler/crawlers/cmsolar_old.py
+++ /dev/null
@@ -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
diff --git a/crawler/crawlers/cmsolar_old2.py b/crawler/crawlers/cmsolar_old2.py
deleted file mode 100644
index 83dc02c..0000000
--- a/crawler/crawlers/cmsolar_old2.py
+++ /dev/null
@@ -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
diff --git a/crawler/crawlers/sun_wms.py.backup b/crawler/crawlers/sun_wms.py.backup
deleted file mode 100644
index ad76f41..0000000
--- a/crawler/crawlers/sun_wms.py.backup
+++ /dev/null
@@ -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
diff --git a/crawler/crawlers/sun_wms_old.py b/crawler/crawlers/sun_wms_old.py
deleted file mode 100644
index ad76f41..0000000
--- a/crawler/crawlers/sun_wms_old.py
+++ /dev/null
@@ -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
diff --git a/crawler/scripts_archive/README.md b/crawler/scripts_archive/README.md
index fdd53cb..4516d3c 100644
--- a/crawler/scripts_archive/README.md
+++ b/crawler/scripts_archive/README.md
@@ -112,11 +112,13 @@
## ๐ง ์ฌ์ฉ ๋ฐฉ๋ฒ
-๋ชจ๋ ์คํฌ๋ฆฝํธ๋ crawler ๊ฐ์ํ๊ฒฝ์์ ์คํ:
+๋ชจ๋ ์คํฌ๋ฆฝํธ๋ `docs/development_and_testing.md`์ ๋ฐ๋ผ ์๋ก ๋ง๋ crawler ๊ฐ์ํ๊ฒฝ์์ ์คํ:
```powershell
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
diff --git a/crawler/venv_win/Scripts/Activate.ps1 b/crawler/venv_win/Scripts/Activate.ps1
deleted file mode 100644
index 7175cf1..0000000
--- a/crawler/venv_win/Scripts/Activate.ps1
+++ /dev/null
@@ -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
diff --git a/crawler/venv_win/Scripts/activate b/crawler/venv_win/Scripts/activate
deleted file mode 100644
index de2aa06..0000000
--- a/crawler/venv_win/Scripts/activate
+++ /dev/null
@@ -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
diff --git a/crawler/venv_win/Scripts/activate.bat b/crawler/venv_win/Scripts/activate.bat
deleted file mode 100644
index 2ed0bc1..0000000
--- a/crawler/venv_win/Scripts/activate.bat
+++ /dev/null
@@ -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=
-)
diff --git a/crawler/venv_win/Scripts/deactivate.bat b/crawler/venv_win/Scripts/deactivate.bat
deleted file mode 100644
index 62a39a7..0000000
--- a/crawler/venv_win/Scripts/deactivate.bat
+++ /dev/null
@@ -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
diff --git a/crawler/venv_win/Scripts/dotenv.exe b/crawler/venv_win/Scripts/dotenv.exe
deleted file mode 100644
index 5a3e015..0000000
Binary files a/crawler/venv_win/Scripts/dotenv.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/httpx.exe b/crawler/venv_win/Scripts/httpx.exe
deleted file mode 100644
index 6735d52..0000000
Binary files a/crawler/venv_win/Scripts/httpx.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/markdown-it.exe b/crawler/venv_win/Scripts/markdown-it.exe
deleted file mode 100644
index 38ee808..0000000
Binary files a/crawler/venv_win/Scripts/markdown-it.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/normalizer.exe b/crawler/venv_win/Scripts/normalizer.exe
deleted file mode 100644
index c690909..0000000
Binary files a/crawler/venv_win/Scripts/normalizer.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pip.exe b/crawler/venv_win/Scripts/pip.exe
deleted file mode 100644
index 428ed33..0000000
Binary files a/crawler/venv_win/Scripts/pip.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pip3.11.exe b/crawler/venv_win/Scripts/pip3.11.exe
deleted file mode 100644
index 428ed33..0000000
Binary files a/crawler/venv_win/Scripts/pip3.11.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pip3.exe b/crawler/venv_win/Scripts/pip3.exe
deleted file mode 100644
index 428ed33..0000000
Binary files a/crawler/venv_win/Scripts/pip3.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pygmentize.exe b/crawler/venv_win/Scripts/pygmentize.exe
deleted file mode 100644
index 6935096..0000000
Binary files a/crawler/venv_win/Scripts/pygmentize.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pyiceberg.exe b/crawler/venv_win/Scripts/pyiceberg.exe
deleted file mode 100644
index 9ace56c..0000000
Binary files a/crawler/venv_win/Scripts/pyiceberg.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/python.exe b/crawler/venv_win/Scripts/python.exe
deleted file mode 100644
index d30bc8e..0000000
Binary files a/crawler/venv_win/Scripts/python.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/pythonw.exe b/crawler/venv_win/Scripts/pythonw.exe
deleted file mode 100644
index 2aae56f..0000000
Binary files a/crawler/venv_win/Scripts/pythonw.exe and /dev/null differ
diff --git a/crawler/venv_win/Scripts/websockets.exe b/crawler/venv_win/Scripts/websockets.exe
deleted file mode 100644
index 1385c8d..0000000
Binary files a/crawler/venv_win/Scripts/websockets.exe and /dev/null differ
diff --git a/crawler/venv_win/pyvenv.cfg b/crawler/venv_win/pyvenv.cfg
deleted file mode 100644
index 115c854..0000000
--- a/crawler/venv_win/pyvenv.cfg
+++ /dev/null
@@ -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
diff --git a/docs/development_and_testing.md b/docs/development_and_testing.md
index 56a15ba..fbdeca0 100644
--- a/docs/development_and_testing.md
+++ b/docs/development_and_testing.md
@@ -44,6 +44,13 @@ $env:DEBUG = "false"
`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
cd app
npm ci
@@ -52,6 +59,8 @@ npm run test:ci
ํ์ฌ `test:ci`๋ Expo ์น export๋ฅผ ์ํํ์ฌ ์์กด์ฑ, ๋ฒ๋ค๋ง, ์ ์ ์ฐ์ถ๋ฌผ ์์ฑ์ ํจ๊ป ๊ฒ์ฌํ๋ค. ์ถ๋ ฅ ๋๋ ํฐ๋ฆฌ๋ `app/dist/`์ด๋ฉฐ Git์์ ์ ์ธ๋๋ค.
+์ฑ์ ๋คํธ์ํฌ ํธ์ถ์ `services/solarApi.js`, ํ๊ฒฝ ์ ํ์ `config/appConfig.js`๋ฅผ ํตํด ์ํํ๋ค. ํ๋ฉด์ API URL์ด๋ ์
์ฒด ID๋ฅผ ์ง์ ์์ฑํ์ง ์๋๋ค.
+
## DB migration
ํต๊ณ ์ฐ๊ธฐ ์ผ๊ด์ฑ migration์ ๋
๋ฆฝ PostgreSQL 15 DB์์ ๋ค์ ๊ณ์ฝ ํ
์คํธ๋ก ํ์ธํ๋ค.
diff --git a/docs/repository_structure.md b/docs/repository_structure.md
new file mode 100644
index 0000000..d17f10d
--- /dev/null
+++ b/docs/repository_structure.md
@@ -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 ์ด๋ ฅ์ ์กด์ฌํ๋ฏ๋ก ํ์ํ๋ฉด ํด๋น ์ปค๋ฐ์์ ๋ณต๊ตฌํ ์ ์๋ค.