Files
Northern-Thailand-Ping-Rive…/src/water_scraper_v3.py
T
grabowski 5e62ea529d
Documentation / Generate API Documentation (push) Successful in 11s
Documentation / Build Sphinx Documentation (push) Successful in 17s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 29s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 14s
Documentation / Validate Documentation (push) Failing after 9s
feat: --fill-gaps all repairs missing data across the whole DB range
Gap detection is now hour-granular: days with partial data (missing
hourly slots) are re-fetched, not just days with no rows at all. The
API's hour-24-is-next-midnight quirk is handled by re-fetching day D-1
when day D is missing its 00:00 slot. SQL adapters gain single-query
range and recorded-hours lookups; non-SQL backends fall back to the
old day-granular check.
2026-08-10 22:13:58 +07:00

872 lines
35 KiB
Python

#!/usr/bin/env python3
"""
Enhanced Water Monitor Scraper with multiple database backend support
"""
import datetime
import json
import logging
import os
import time
from typing import Dict, List, Optional
import requests
import schedule
try:
from .config import Config
from .database_adapters import create_database_adapter
from .logging_config import get_logger
from .metrics import Timer, increment_counter, record_histogram, set_gauge
from .rate_limiter import RateLimiter, RequestTracker
from .validators import DataValidator
except ImportError:
# Handle case when running as standalone script
from config import Config
from database_adapters import create_database_adapter
def get_logger(name):
return logging.getLogger(name)
def increment_counter(*args, **kwargs):
pass
def set_gauge(*args, **kwargs):
pass
def record_histogram(*args, **kwargs):
pass
class Timer:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
class RateLimiter:
def __init__(self, *args, **kwargs):
pass
def wait_if_needed(self):
pass
class RequestTracker:
def __init__(self):
pass
def record_request(self, *args, **kwargs):
pass
class DataValidator:
@staticmethod
def validate_measurements(measurements):
return measurements
# Get logger instance
logger = get_logger(__name__)
class EnhancedWaterMonitorScraper:
def __init__(self, db_config: Dict):
"""
Initialize scraper with database configuration
Args:
db_config: Database configuration dictionary
"""
self.api_url = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"
self.db_config = db_config.copy() # Make a copy to avoid modifying original
self.db_adapter = None
# Scheduler state tracking
self.last_successful_update = None
self.retry_mode = False
self.next_hourly_check = None
# Rate limiting and request tracking
self.rate_limiter = RateLimiter(max_requests=10, time_window_seconds=60)
self.request_tracker = RequestTracker()
# HTTP session for API requests
self.session = requests.Session()
self.session.headers.update(
{
"User-Agent": Config.USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
}
)
# Station mapping is persisted to a JSON file so that station CRUD via the
# API survives restarts; on first run it is seeded from the bundled
# defaults in data/stations.json.
self.station_config_path = Config.STATION_CONFIG_PATH
self.station_mapping = self._load_station_mapping()
self.init_database()
@staticmethod
def _default_station_mapping_path() -> str:
"""Path to the bundled default station mapping shipped with the package."""
return os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "stations.json"
)
def _load_station_mapping(self) -> Dict:
"""Load the station mapping, preferring the runtime-writable config file.
Order of precedence:
1. The runtime config file (STATION_CONFIG_PATH) if it exists — this holds
any changes made through the station CRUD API.
2. The bundled defaults in data/stations.json.
"""
for source in (self.station_config_path, self._default_station_mapping_path()):
if source and os.path.exists(source):
try:
with open(source, encoding="utf-8") as f:
mapping = json.load(f)
logger.info(f"Loaded {len(mapping)} stations from {source}")
return mapping
except Exception as e:
logger.error(f"Failed to load station mapping from {source}: {e}")
logger.error(
"No station mapping could be loaded; starting with an empty mapping"
)
return {}
def save_stations(self) -> bool:
"""Persist the current station mapping to the runtime config file.
Written atomically (temp file + replace) so a crash mid-write cannot
corrupt the existing configuration.
"""
path = self.station_config_path
if not path:
logger.warning(
"STATION_CONFIG_PATH not set; station changes will not persist"
)
return False
try:
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(self.station_mapping, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp_path, path)
logger.info(f"Persisted {len(self.station_mapping)} stations to {path}")
return True
except Exception as e:
logger.error(f"Failed to persist station mapping to {path}: {e}")
return False
def init_database(self):
"""Initialize database connection"""
try:
# Extract db_type and pass remaining config as kwargs
db_config_copy = self.db_config.copy()
db_type = db_config_copy.pop("type")
self.db_adapter = create_database_adapter(db_type, **db_config_copy)
success = self.db_adapter.connect()
if success:
logger.info(f"Successfully connected to {db_type.upper()} database")
set_gauge("database_connected", 1)
increment_counter("database_connections_successful")
else:
logger.error(f"Failed to connect to {db_type.upper()} database")
set_gauge("database_connected", 0)
increment_counter("database_connections_failed")
except Exception as e:
logger.error(f"Error initializing database: {e}")
set_gauge("database_connected", 0)
increment_counter("database_connections_failed")
self.db_adapter = None
def fetch_water_data_for_date(
self, target_date: datetime.datetime
) -> Optional[List[Dict]]:
"""Fetch water levels and discharge data from API for a specific date"""
with Timer("api_request_duration"):
try:
logger.info(
f"Starting data fetch from API for date: {target_date.strftime('%Y-%m-%d')}"
)
# Rate limiting
self.rate_limiter.wait_if_needed()
# Create Thai format date (Buddhist calendar)
thai_year = target_date.year + 543
thai_date = f"{target_date.day:02d}/{target_date.month:02d}/{thai_year}"
# API parameters
payload = {
"DW[UtokID]": "1",
"DW[BasinID]": "6",
"DW[TimeCurrent]": thai_date,
"_search": "false",
"nd": str(int(time.time() * 1000)),
"rows": "100",
"page": "1",
"sidx": "indexhourly",
"sord": "asc",
}
logger.debug(f"API parameters: {payload}")
# POST request to API
start_time = time.time()
response = self.session.post(self.api_url, data=payload, timeout=30)
response_time = time.time() - start_time
response.raise_for_status()
# Record successful request
self.request_tracker.record_request(True, response_time)
increment_counter("api_requests_successful")
record_histogram("api_response_time", response_time)
# Parse JSON response
try:
json_data = response.json()
logger.debug(
f"API response received: {len(str(json_data))} characters"
)
except ValueError as e:
logger.error(f"Error parsing JSON response: {e}")
self.request_tracker.record_request(
False, response_time, "json_parse_error"
)
increment_counter("api_requests_failed")
return None
water_data = []
# Parse JSON data
if json_data and isinstance(json_data, dict) and "rows" in json_data:
for row in json_data["rows"]:
try:
# Parse timestamp
time_str = row.get("hourlytime", "")
if not time_str:
continue
try:
# Format: "1.00", "2.00", ..., "24.00"
api_hour = int(float(time_str))
if api_hour < 1 or api_hour > 24:
continue
if api_hour == 24:
# Hour 24 = midnight (00:00) of the next day
data_time = target_date.replace(
hour=0, minute=0, second=0, microsecond=0
)
data_time = data_time + datetime.timedelta(days=1)
else:
# Hours 1-23 = 01:00-23:00 of the same day
data_time = target_date.replace(
hour=api_hour, minute=0, second=0, microsecond=0
)
except (ValueError, IndexError):
logger.warning(f"Could not parse timestamp: {time_str}")
continue
# Parse all water levels and discharge values
station_count = 0
for station_num in range(1, 17): # Stations 1-16
wl_key = f"wlvalues{station_num}"
q_key = f"qvalues{station_num}"
qp_key = f"QPercent{station_num}"
# Check if water level data exists (required)
if wl_key in row:
try:
water_level = row[wl_key]
# Skip if water level is None or invalid
if water_level is None:
continue
# Convert water level to float (required)
water_level = float(water_level)
# Try to parse discharge data (optional)
discharge = None
discharge_percent = None
if q_key in row:
try:
discharge_raw = row[q_key]
if (
discharge_raw is not None
and discharge_raw != "***"
):
discharge = float(discharge_raw)
# Only parse discharge percent if discharge is valid
discharge_percent_raw = row.get(
qp_key
)
if (
discharge_percent_raw
is not None
):
try:
discharge_percent = float(
discharge_percent_raw
)
except (ValueError, TypeError):
discharge_percent = None
else:
logger.debug(
"Skipping malformed discharge data for "
f"station {station_num}: {discharge_raw}"
)
except (ValueError, TypeError) as e:
logger.debug(
f"Could not parse discharge for station {station_num}: {e}"
)
station_info = self.station_mapping.get(
str(station_num),
{
"code": f"P.{19+station_num}",
"thai_name": f"Station {station_num}",
"english_name": f"Station {station_num}",
},
)
water_data.append(
{
"timestamp": data_time,
"station_id": station_num,
"station_code": station_info["code"],
"station_name_en": station_info[
"english_name"
],
"station_name_th": station_info[
"thai_name"
],
"latitude": station_info.get(
"latitude"
),
"longitude": station_info.get(
"longitude"
),
"geohash": station_info.get("geohash"),
"water_level": water_level,
"water_level_unit": "m",
"discharge": discharge,
"discharge_unit": "cms",
"discharge_percent": discharge_percent,
"status": "active",
}
)
station_count += 1
except (ValueError, TypeError) as e:
logger.warning(
f"Could not parse water level for station {station_num}: {e}"
)
continue
logger.debug(
f"Processed {station_count} stations for time {time_str}"
)
except Exception as e:
logger.warning(f"Error processing data row: {e}")
continue
# Validate data
water_data = DataValidator.validate_measurements(water_data)
logger.info(
f"Successfully fetched {len(water_data)} data points from API "
f"for {target_date.strftime('%Y-%m-%d')}"
)
return water_data
except requests.RequestException as e:
logger.error(f"Network error fetching API data: {e}")
self.request_tracker.record_request(False, 0, "network_error")
increment_counter("api_requests_failed")
return None
except Exception as e:
logger.error(f"Unexpected error fetching API data: {e}")
self.request_tracker.record_request(False, 0, "unexpected_error")
increment_counter("api_requests_failed")
return None
def fetch_water_data(self) -> Optional[List[Dict]]:
"""Fetch water levels and discharge data from API with smart date selection"""
current_time = datetime.datetime.now()
# If it's past 01:00, try today's data first, then yesterday as fallback
if current_time.hour >= 1:
logger.info(
"After 01:00 - trying today's data first, will fallback to yesterday if needed"
)
# Try today's data first
today_data = self.fetch_water_data_for_date(current_time)
if today_data and len(today_data) > 0:
logger.info(
f"Successfully fetched {len(today_data)} data points for today"
)
return today_data
# Fallback to yesterday's data
logger.info("No data available for today, trying yesterday's data")
yesterday = current_time - datetime.timedelta(days=1)
yesterday_data = self.fetch_water_data_for_date(yesterday)
if yesterday_data and len(yesterday_data) > 0:
logger.info(
f"Successfully fetched {len(yesterday_data)} data points for yesterday"
)
return yesterday_data
logger.warning("No data available for today or yesterday")
return None
else:
# Before 01:00 - only try yesterday's data (API likely hasn't updated yet)
logger.info("Before 01:00 - fetching yesterday's data only")
yesterday = current_time - datetime.timedelta(days=1)
return self.fetch_water_data_for_date(yesterday)
def save_to_database(self, water_data: List[Dict], max_retries: int = 3) -> bool:
"""Save water measurements to database with retry logic"""
if not self.db_adapter:
logger.error("Database adapter not initialized")
return False
if not water_data:
logger.warning("No data to save")
return False
for attempt in range(max_retries):
try:
success = self.db_adapter.save_measurements(water_data)
if success:
logger.info(
f"Successfully saved {len(water_data)} measurements to database"
)
increment_counter("database_saves_successful")
set_gauge("last_save_timestamp", time.time())
return True
else:
logger.warning(f"Save attempt {attempt + 1} failed, retrying...")
except Exception as e:
if "database is locked" in str(e).lower() and attempt < max_retries - 1:
logger.warning(
f"Database locked on attempt {attempt + 1}, retrying in {2 ** attempt} seconds..."
)
time.sleep(2**attempt) # Exponential backoff
continue
else:
logger.error(
f"Error saving to database (attempt {attempt + 1}): {e}"
)
if attempt == max_retries - 1:
increment_counter("database_saves_failed")
return False
return False
def get_latest_data(self, limit: int = 100) -> List[Dict]:
"""Get latest data from database"""
if not self.db_adapter:
return []
try:
return self.db_adapter.get_latest_measurements(limit=limit)
except Exception as e:
logger.error(f"Error getting latest data: {e}")
return []
def _check_data_freshness(self, water_data: List[Dict]) -> bool:
"""Check if the fetched data contains new data for the current hour"""
if not water_data:
return False
current_time = datetime.datetime.now()
current_hour = current_time.hour
# Find the most recent timestamp in the data
latest_timestamp = None
for data_point in water_data:
timestamp = data_point.get("timestamp")
if timestamp and (latest_timestamp is None or timestamp > latest_timestamp):
latest_timestamp = timestamp
if latest_timestamp is None:
logger.warning("No valid timestamps found in data")
return False
latest_hour = latest_timestamp.hour
time_diff = current_time - latest_timestamp
minutes_old = time_diff.total_seconds() / 60
logger.info(
f"Current time: {current_time.strftime('%H:%M')}, Latest data: {latest_timestamp.strftime('%H:%M')}"
)
logger.info(
f"Current hour: {current_hour}, Latest data hour: {latest_hour}, Age: {minutes_old:.1f} minutes"
)
# Strict check: we need data from the current hour
# If it's 20:xx and we only have data up to 19:xx, that's stale - go to retry mode
has_current_hour_data = latest_hour >= current_hour
if not has_current_hour_data:
logger.warning(
f"No new data available - expected hour {current_hour}, got {latest_hour}"
)
logger.warning("Switching to retry mode until new data becomes available")
return False
else:
logger.info(f"Fresh data available for current hour {current_hour}")
return True
def run_scraping_cycle(self) -> bool:
"""Run a complete scraping cycle with freshness check"""
logger.info("Starting scraping cycle...")
try:
# Fetch current data
water_data = self.fetch_water_data()
if water_data:
# Check if data is fresh/recent
is_fresh = self._check_data_freshness(water_data)
if is_fresh:
success = self.save_to_database(water_data)
if success:
logger.info(
"Scraping cycle completed successfully with fresh data"
)
increment_counter("scraping_cycles_successful")
return True
else:
logger.error("Failed to save data")
increment_counter("scraping_cycles_failed")
return False
else:
# Data exists but is stale
logger.warning(
"Data fetched but is stale - treating as no fresh data available"
)
increment_counter("scraping_cycles_failed")
return False
else:
logger.warning("No data fetched")
increment_counter("scraping_cycles_failed")
return False
except Exception as e:
logger.error(f"Scraping cycle failed: {e}")
increment_counter("scraping_cycles_failed")
return False
def fill_data_gaps(self, days_back: Optional[int] = None) -> int:
"""Fill gaps in stored data by re-fetching incomplete days.
Args:
days_back: How many days back to scan. None scans the whole data
range, from the earliest measurement in the database to now.
Detection is hour-granular where the backend supports it: any calendar
day missing one or more hourly slots gets its source date(s) re-fetched.
Because the API reports hour 24 as midnight of the next day, a missing
00:00 slot on day D is repaired by re-fetching day D-1.
"""
logger = get_logger(__name__)
filled_count = 0
try:
now = datetime.datetime.now()
end_date = now.date()
if days_back is not None:
start_date = end_date - datetime.timedelta(days=days_back)
else:
if not self.db_adapter:
logger.error("Database adapter not initialized")
return 0
date_range = self.db_adapter.get_measurement_date_range()
if not date_range:
logger.error(
"Database is empty or does not support range queries; "
"use --import-historical to seed data first"
)
return 0
start_date = date_range[0].date()
logger.info(f"Checking for gaps from {start_date} to {end_date}")
fetch_dates = self._find_gap_fetch_dates(start_date, end_date, now)
if not fetch_dates:
logger.info("No gaps found")
return 0
logger.info(f"Found {len(fetch_dates)} day(s) needing a re-fetch")
for fetch_date in sorted(fetch_dates):
fetch_dt = datetime.datetime.combine(fetch_date, datetime.time.min)
logger.info(f"Filling gap for date: {fetch_date}")
data = self.fetch_water_data_for_date(fetch_dt)
if data:
if self.save_to_database(data):
filled_count += len(data)
logger.info(
f"Filled {len(data)} measurements for {fetch_date}"
)
else:
logger.warning(f"Failed to save data for {fetch_date}")
else:
logger.warning(f"No data available for {fetch_date}")
# Be respectful to the API
time.sleep(1)
except Exception as e:
logger.error(f"Gap filling error: {e}")
return filled_count
def _find_gap_fetch_dates(
self,
start_date: datetime.date,
end_date: datetime.date,
now: datetime.datetime,
) -> List[datetime.date]:
"""Determine which source dates need re-fetching to fill gaps.
Uses hour-granular detection when the adapter supports it, otherwise
falls back to re-fetching only days with no data at all.
"""
hours_by_day = None
if self.db_adapter:
hours_by_day = self.db_adapter.get_recorded_hours_by_day(
start_date, end_date
)
fetch_dates = set()
if hours_by_day is None:
logger.info(
"Backend does not support hour-granular gap detection; "
"checking for fully-missing days only"
)
current = start_date
while current <= end_date:
current_dt = datetime.datetime.combine(current, datetime.time.min)
if not self._check_data_exists_for_date(current_dt):
fetch_dates.add(current)
current += datetime.timedelta(days=1)
return sorted(fetch_dates)
current = start_date
while current <= end_date:
if current == now.date():
# Today: only expect hours that have already passed
expected_hours = set(range(0, now.hour))
else:
expected_hours = set(range(24))
missing = expected_hours - hours_by_day.get(current, set())
if missing:
# Hours 1-23 of day D come from fetching D; hour 0 comes from
# the previous day's fetch (the API's "hour 24")
if any(h >= 1 for h in missing):
fetch_dates.add(current)
if 0 in missing and current > start_date:
fetch_dates.add(current - datetime.timedelta(days=1))
current += datetime.timedelta(days=1)
return sorted(fetch_dates)
def update_existing_data(self, days_back: int) -> int:
"""Update existing data with latest values for the specified number of days back"""
logger = get_logger(__name__)
updated_count = 0
try:
# Calculate date range
end_date = datetime.datetime.now()
start_date = end_date - datetime.timedelta(days=days_back)
logger.info(f"Updating data from {start_date.date()} to {end_date.date()}")
# Iterate through each date in the range
current_date = start_date
while current_date <= end_date:
logger.info(f"Updating data for date: {current_date.date()}")
# Fetch fresh data for this date
data = self.fetch_water_data_for_date(current_date)
if data:
# Save the data (this will update existing records)
if self.save_to_database(data):
updated_count += len(data)
logger.info(
f"Updated {len(data)} measurements for {current_date.date()}"
)
else:
logger.warning(
f"Failed to update data for {current_date.date()}"
)
else:
logger.warning(f"No data available for {current_date.date()}")
current_date += datetime.timedelta(days=1)
except Exception as e:
logger.error(f"Data update error: {e}")
return updated_count
def _check_data_exists_for_date(self, target_date: datetime.datetime) -> bool:
"""Check if data exists for a specific date"""
try:
if not self.db_adapter:
return False
# Get data for the specific date
measurements = self.db_adapter.get_measurements_for_date(target_date)
return len(measurements) > 0
except Exception as e:
logger = get_logger(__name__)
logger.debug(f"Error checking data existence: {e}")
return False
def import_historical_data(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
skip_existing: bool = True,
) -> int:
"""
Import historical data for a date range
Args:
start_date: Start date for historical import
end_date: End date for historical import
skip_existing: Skip dates that already have data (default: True)
Returns:
Number of data points imported
"""
logger.info(
f"Starting historical data import from {start_date.date()} to {end_date.date()}"
)
total_imported = 0
current_date = start_date
while current_date <= end_date:
try:
# Check if data already exists for this date
if skip_existing and self._check_data_exists_for_date(current_date):
logger.info(
f"Data already exists for {current_date.date()}, skipping..."
)
current_date += datetime.timedelta(days=1)
continue
logger.info(f"Importing data for {current_date.date()}...")
# Fetch data for this date
data = self.fetch_water_data_for_date(current_date)
if data:
# Save to database
if self.save_to_database(data):
total_imported += len(data)
logger.info(
f"Successfully imported {len(data)} data points for {current_date.date()}"
)
else:
logger.warning(f"Failed to save data for {current_date.date()}")
else:
logger.warning(f"No data available for {current_date.date()}")
# Add small delay to be respectful to the API
time.sleep(1)
except Exception as e:
logger.error(f"Error importing data for {current_date.date()}: {e}")
current_date += datetime.timedelta(days=1)
logger.info(
f"Historical import completed. Total data points imported: {total_imported}"
)
return total_imported
# Main execution for standalone usage
if __name__ == "__main__":
import argparse
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("water_monitor.log"), logging.StreamHandler()],
)
parser = argparse.ArgumentParser(description="Thailand Water Monitor")
parser.add_argument("--test", action="store_true", help="Run single test cycle")
args = parser.parse_args()
# Default SQLite configuration
db_config = {"type": "sqlite", "connection_string": "sqlite:///water_levels.db"}
try:
scraper = EnhancedWaterMonitorScraper(db_config)
if args.test:
logger.info("Running test cycle...")
result = scraper.run_scraping_cycle()
if result:
logger.info("✅ Test completed successfully")
sys.exit(0)
else:
logger.error("❌ Test failed")
sys.exit(1)
else:
logger.info("Starting continuous monitoring...")
schedule.every(1).hours.do(scraper.run_scraping_cycle)
# Run initial cycle
scraper.run_scraping_cycle()
while True:
schedule.run_pending()
time.sleep(60)
except KeyboardInterrupt:
logger.info("Monitoring stopped by user")
except Exception as e:
logger.error(f"Error: {e}")
sys.exit(1)