Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| """ | |
| K1RL QUASAR — Visitor Tracking Module (HuggingFace Spaces) | |
| Tracks IPs, locations, devices with container-safe persistence | |
| """ | |
| import os | |
| import json | |
| import time | |
| import threading | |
| import requests | |
| from datetime import datetime | |
| from pathlib import Path | |
| try: | |
| from user_agents import parse as parse_ua | |
| except ImportError: | |
| # Fallback if user-agents not available | |
| def parse_ua(ua_string): | |
| return type('UA', (), { | |
| 'browser': type('Browser', (), {'family': 'Unknown', 'version_string': ''}), | |
| 'os': type('OS', (), {'family': 'Unknown', 'version_string': ''}), | |
| 'device': type('Device', (), {'family': 'Unknown'}), | |
| 'is_mobile': False, | |
| 'is_tablet': False, | |
| 'is_pc': True, | |
| 'is_bot': False | |
| }) | |
| # ============================================================================ | |
| # CONFIGURATION (HuggingFace Spaces paths) | |
| # ============================================================================ | |
| BASE_DIR = Path('/home/user/app') | |
| VISITOR_FILE = BASE_DIR / 'data' / 'visitors.json' | |
| VISITOR_TIMEOUT = 120 # seconds before considered inactive | |
| # Ensure data directory exists | |
| VISITOR_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| # ============================================================================ | |
| # STORAGE | |
| # ============================================================================ | |
| # Active visitors: {ip: {'last_seen': timestamp, 'user_agent': str}} | |
| active_visitors = {} | |
| # All-time visitors: {ip: {'first_seen': str, 'last_seen': str, 'visits': int, 'location': dict, 'device': dict}} | |
| all_time_visitors = {} | |
| visitor_lock = threading.Lock() | |
| # ============================================================================ | |
| # GEOLOCATION (Free API with fallback) | |
| # ============================================================================ | |
| def get_location(ip): | |
| """Get location from IP using free API with fallbacks""" | |
| try: | |
| # Skip local/private IPs | |
| if ip in ['127.0.0.1', 'localhost'] or ip.startswith(('192.168.', '10.', '172.')): | |
| return {'country': 'Local', 'city': 'Local', 'region': '', 'country_code': 'LO', 'isp': 'Local'} | |
| # Try ip-api.com (free, 1000 requests per month) | |
| response = requests.get( | |
| f'http://ip-api.com/json/{ip}?fields=status,country,countryCode,region,city,isp', | |
| timeout=3 | |
| ) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if data.get('status') == 'success': | |
| return { | |
| 'country': data.get('country', 'Unknown'), | |
| 'country_code': data.get('countryCode', '??'), | |
| 'city': data.get('city', 'Unknown'), | |
| 'region': data.get('region', ''), | |
| 'isp': data.get('isp', 'Unknown ISP') | |
| } | |
| except Exception as e: | |
| print(f"⚠️ Geolocation API failed for {ip}: {e}") | |
| # Fallback location data | |
| return { | |
| 'country': 'Unknown', | |
| 'city': 'Unknown', | |
| 'country_code': '??', | |
| 'region': '', | |
| 'isp': 'Unknown ISP' | |
| } | |
| def parse_device(user_agent_string): | |
| """Parse user agent to get device info with fallback""" | |
| try: | |
| ua = parse_ua(user_agent_string) | |
| return { | |
| 'browser': f"{ua.browser.family} {ua.browser.version_string}".strip(), | |
| 'os': f"{ua.os.family} {ua.os.version_string}".strip(), | |
| 'device': ua.device.family, | |
| 'is_mobile': ua.is_mobile, | |
| 'is_tablet': ua.is_tablet, | |
| 'is_pc': ua.is_pc, | |
| 'is_bot': ua.is_bot | |
| } | |
| except Exception as e: | |
| print(f"⚠️ User agent parsing failed: {e}") | |
| # Fallback device info | |
| return { | |
| 'browser': 'Unknown', | |
| 'os': 'Unknown', | |
| 'device': 'Unknown', | |
| 'is_mobile': False, | |
| 'is_tablet': False, | |
| 'is_pc': True, | |
| 'is_bot': False | |
| } | |
| # ============================================================================ | |
| # PERSISTENCE (Container-safe) | |
| # ============================================================================ | |
| def load_visitors(): | |
| """Load all-time visitors from file""" | |
| global all_time_visitors | |
| try: | |
| if VISITOR_FILE.exists(): | |
| with open(VISITOR_FILE, 'r') as f: | |
| data = json.load(f) | |
| all_time_visitors = data.get('visitors', {}) | |
| count = len(all_time_visitors) | |
| print(f"📊 Loaded {count} historical visitors from {VISITOR_FILE}") | |
| else: | |
| print(f"📊 No existing visitor data found at {VISITOR_FILE}") | |
| all_time_visitors = {} | |
| except Exception as e: | |
| print(f"⚠️ Could not load visitor data: {e}") | |
| all_time_visitors = {} | |
| def save_visitors(): | |
| """Save all-time visitors to file""" | |
| try: | |
| visitor_data = { | |
| 'visitors': all_time_visitors, | |
| 'total_count': len(all_time_visitors), | |
| 'last_updated': datetime.now().isoformat(), | |
| 'platform': 'huggingface-spaces' | |
| } | |
| # Atomic write (write to temp file then rename) | |
| temp_file = VISITOR_FILE.with_suffix('.tmp') | |
| with open(temp_file, 'w') as f: | |
| json.dump(visitor_data, f, indent=2) | |
| temp_file.rename(VISITOR_FILE) | |
| print(f"💾 Saved {len(all_time_visitors)} visitors to {VISITOR_FILE}") | |
| except Exception as e: | |
| print(f"⚠️ Could not save visitor data: {e}") | |
| # ============================================================================ | |
| # TRACKING FUNCTIONS | |
| # ============================================================================ | |
| def track_visitor(ip, user_agent=''): | |
| """Track a visitor by IP with location and device""" | |
| with visitor_lock: | |
| now = time.time() | |
| now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S') | |
| # Update active visitors | |
| active_visitors[ip] = { | |
| 'last_seen': now, | |
| 'user_agent': user_agent | |
| } | |
| # Check if new visitor | |
| is_new = ip not in all_time_visitors | |
| if is_new: | |
| print(f"👤 New visitor detected: {ip}") | |
| # Get location and device info (these can be slow, so do it in background) | |
| def get_visitor_info(): | |
| try: | |
| location = get_location(ip) | |
| device = parse_device(user_agent) | |
| with visitor_lock: | |
| all_time_visitors[ip] = { | |
| 'first_seen': now_str, | |
| 'last_seen': now_str, | |
| 'location': location, | |
| 'device': device, | |
| 'visits': 1, | |
| 'user_agent_history': [user_agent] if user_agent else [] | |
| } | |
| save_visitors() | |
| count = len(all_time_visitors) | |
| city = location.get('city', '?') | |
| country = location.get('country', '?') | |
| print(f"👤 Visitor #{count}: {ip} from {city}, {country}") | |
| except Exception as e: | |
| print(f"⚠️ Error processing new visitor {ip}: {e}") | |
| # Run in background thread to not slow down the request | |
| info_thread = threading.Thread(target=get_visitor_info, daemon=True) | |
| info_thread.start() | |
| else: | |
| # Update existing visitor | |
| visitor = all_time_visitors[ip] | |
| visitor['last_seen'] = now_str | |
| visitor['visits'] = visitor.get('visits', 0) + 1 | |
| # Update user agent history (keep last 5) | |
| if user_agent: | |
| ua_history = visitor.setdefault('user_agent_history', []) | |
| if user_agent not in ua_history: | |
| ua_history.append(user_agent) | |
| visitor['user_agent_history'] = ua_history[-5:] # Keep last 5 | |
| # Update device info if we have better data now | |
| if user_agent and visitor.get('device', {}).get('browser') == 'Unknown': | |
| visitor['device'] = parse_device(user_agent) | |
| # Save periodically (every 10 visits) | |
| if visitor['visits'] % 10 == 0: | |
| save_visitors() | |
| def cleanup_inactive(): | |
| """Remove inactive visitors from active list""" | |
| with visitor_lock: | |
| now = time.time() | |
| stale = [ip for ip, data in active_visitors.items() | |
| if now - data['last_seen'] > VISITOR_TIMEOUT] | |
| for ip in stale: | |
| del active_visitors[ip] | |
| def get_stats(): | |
| """Get basic visitor statistics (for API compatibility)""" | |
| cleanup_inactive() | |
| with visitor_lock: | |
| return { | |
| 'active_now': len(active_visitors), | |
| 'active_ips': list(active_visitors.keys()), | |
| 'all_time_unique': len(all_time_visitors), | |
| } | |
| def get_detailed_stats(): | |
| """Get detailed visitor statistics""" | |
| cleanup_inactive() | |
| with visitor_lock: | |
| active_ips = list(active_visitors.keys()) | |
| # Country stats | |
| countries = {} | |
| cities = {} | |
| devices = {'mobile': 0, 'tablet': 0, 'pc': 0, 'bot': 0} | |
| browsers = {} | |
| for ip, data in all_time_visitors.items(): | |
| # Country stats | |
| country = data.get('location', {}).get('country', 'Unknown') | |
| countries[country] = countries.get(country, 0) + 1 | |
| # City stats | |
| city = data.get('location', {}).get('city', 'Unknown') | |
| cities[city] = cities.get(city, 0) + 1 | |
| # Device stats | |
| device_info = data.get('device', {}) | |
| if device_info.get('is_mobile'): | |
| devices['mobile'] += 1 | |
| elif device_info.get('is_tablet'): | |
| devices['tablet'] += 1 | |
| elif device_info.get('is_bot'): | |
| devices['bot'] += 1 | |
| else: | |
| devices['pc'] += 1 | |
| # Browser stats | |
| browser = device_info.get('browser', 'Unknown').split()[0] # Get browser name only | |
| browsers[browser] = browsers.get(browser, 0) + 1 | |
| return { | |
| 'total_unique': len(all_time_visitors), | |
| 'active_now': len(active_ips), | |
| 'countries': dict(sorted(countries.items(), key=lambda x: x[1], reverse=True)[:10]), | |
| 'cities': dict(sorted(cities.items(), key=lambda x: x[1], reverse=True)[:10]), | |
| 'devices': devices, | |
| 'browsers': dict(sorted(browsers.items(), key=lambda x: x[1], reverse=True)[:10]), | |
| } | |
| def get_all_visitors(): | |
| """Get ALL visitor details with location and device""" | |
| cleanup_inactive() | |
| with visitor_lock: | |
| active_ips = list(active_visitors.keys()) | |
| visitors_list = [] | |
| for ip, data in all_time_visitors.items(): | |
| visitors_list.append({ | |
| 'ip': ip, | |
| 'is_active': ip in active_ips, | |
| 'first_seen': data.get('first_seen', ''), | |
| 'last_seen': data.get('last_seen', ''), | |
| 'visits': data.get('visits', 1), | |
| 'location': data.get('location', {}), | |
| 'device': data.get('device', {}), | |
| 'user_agent_count': len(data.get('user_agent_history', [])) | |
| }) | |
| # Sort: active first, then by visits, then by last_seen | |
| visitors_list.sort( | |
| key=lambda x: (not x['is_active'], -x['visits'], x['last_seen']), | |
| reverse=False | |
| ) | |
| return { | |
| 'total_unique': len(all_time_visitors), | |
| 'active_now': len(active_ips), | |
| 'visitors': visitors_list[:100], # Limit to first 100 for performance | |
| 'showing_count': min(100, len(visitors_list)) | |
| } | |
| # ============================================================================ | |
| # BACKGROUND CLEANUP TASK | |
| # ============================================================================ | |
| def background_cleanup(): | |
| """Background task to periodically clean up and save data""" | |
| while True: | |
| try: | |
| time.sleep(300) # Every 5 minutes | |
| cleanup_inactive() | |
| # Save data every hour | |
| current_time = int(time.time()) | |
| if current_time % 3600 < 300: # Within 5 minutes of the hour | |
| save_visitors() | |
| except Exception as e: | |
| print(f"⚠️ Background cleanup error: {e}") | |
| # ============================================================================ | |
| # INITIALIZE | |
| # ============================================================================ | |
| # Load existing visitor data | |
| load_visitors() | |
| # Start background cleanup task | |
| cleanup_thread = threading.Thread(target=background_cleanup, daemon=True) | |
| cleanup_thread.start() | |
| print(f"✅ K1RL QUASAR Visitor Tracker ready") | |
| print(f" Platform: HuggingFace Spaces") | |
| print(f" Historical visitors: {len(all_time_visitors)}") | |
| print(f" Data file: {VISITOR_FILE}") | |
| print("=" * 50) | |
| # Export main functions for use by other modules | |
| __all__ = ['track_visitor', 'get_stats', 'get_detailed_stats', 'get_all_visitors', 'cleanup_inactive'] | |