saq1b commited on
Commit
40e38fe
·
verified ·
1 Parent(s): 2765d51

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +400 -267
main.py CHANGED
@@ -1,18 +1,21 @@
 
 
1
  # main.py
2
  from fastapi import FastAPI, HTTPException
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
5
- from typing import Optional, Any
6
  import aiohttp
7
  import os
8
- from datetime import datetime, timezone # <-- Add timezone
9
  import json
10
  import re
11
  from google.oauth2.service_account import Credentials as ServiceAccountCredentials
12
  from googleapiclient.discovery import build
 
13
  from dotenv import load_dotenv
14
  import asyncio
15
- import logging # <-- Add logging
16
 
17
  # --- Logging Setup ---
18
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -33,39 +36,63 @@ app.add_middleware(
33
  )
34
 
35
  # Google Sheets Config
36
- SPREADSHEET_ID = '1sgkhBNGw_r6tBIxvdeXaI0bVmWBeACN4jiw_oDEeXLw'
37
- VALUES_SPREADSHEET_ID = '1Toe07o3P517q8sm9Qb1e5xyFWCuwgskj71IKJwJNfNU'
 
 
 
38
  SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
 
 
39
  USER_SCAMMER_SHEET = "User Scammer Files"
 
40
  SERVER_SCAMMER_SHEET = "Server Scammer Files"
 
41
  DWC_SHEET = "DWC Servers / Users"
 
 
 
42
  DUPE_LIST_SHEET = "Dupe List"
 
 
43
  CATEGORIES = [
44
  "Vehicles", "Textures", "Colours", "Spoilers",
45
  "Rims", "Furnitures", "Gun Skins", "Hyperchromes"
46
  ]
 
47
 
48
  # Cache Update Interval
49
  CACHE_UPDATE_INTERVAL_SECONDS = 60 # 1 minute
50
 
51
  # --- Global Cache ---
52
  cache = {
53
- "values": {},
54
- "value_changes": {},
55
  "user_scammers": [],
56
  "server_scammers": [],
57
  "dwc": [],
58
- "dupes": [],
59
- "last_updated": None,
60
- "is_ready": False,
61
- "service_available": True
62
  }
63
  # --- Google Sheets Initialization ---
64
  sheets_service = None # Initialize as None
65
 
 
 
 
 
 
 
 
 
 
 
 
66
  def init_google_sheets(scopes=SCOPES):
67
  """Initialize Google Sheets credentials from environment variable"""
68
- global sheets_service, cache # Allow modifying global vars
69
  try:
70
  creds_json_str = os.getenv('CREDENTIALS_JSON')
71
  if not creds_json_str:
@@ -76,20 +103,20 @@ def init_google_sheets(scopes=SCOPES):
76
  creds_json,
77
  scopes=scopes
78
  )
79
- sheets_service = build('sheets', 'v4', credentials=creds)
80
  logger.info("Google Sheets service initialized successfully from ENV VAR.")
81
  cache["service_available"] = True
82
  return sheets_service
83
  except Exception as e:
84
  logger.error(f"Error initializing Google Sheets from ENV VAR: {e}")
85
- # Fallback attempt (optional)
86
  try:
87
  logger.info("Falling back to loading credentials from file 'credentials.json'")
88
  creds = ServiceAccountCredentials.from_service_account_file(
89
  'credentials.json',
90
  scopes=scopes
91
  )
92
- sheets_service = build('sheets', 'v4', credentials=creds)
93
  logger.info("Google Sheets service initialized successfully from file.")
94
  cache["service_available"] = True
95
  return sheets_service
@@ -97,14 +124,14 @@ def init_google_sheets(scopes=SCOPES):
97
  logger.error(f"Error loading credentials from file: {file_e}")
98
  logger.critical("Google Sheets service could not be initialized. API will be limited.")
99
  cache["service_available"] = False
100
- sheets_service = None # Ensure it's None if failed
101
  return None
102
 
103
  # Initialize on module load
104
  init_google_sheets()
105
 
106
 
107
- # --- Helper Functions (Mostly unchanged) ---
108
 
109
  def extract_drive_id(url):
110
  if not url or not isinstance(url, str): return None
@@ -116,13 +143,20 @@ def convert_to_thumbnail_url(drive_url):
116
  return f"https://drive.google.com/thumbnail?id={drive_id}&sz=w1000" if drive_id else drive_url
117
 
118
  def extract_image_url(formula, drive_url=None):
 
119
  if drive_url and isinstance(drive_url, str) and 'drive.google.com' in drive_url:
120
  return convert_to_thumbnail_url(drive_url)
121
  if not formula or not isinstance(formula, str): return ''
 
 
 
 
122
  if formula.startswith('=IMAGE('):
123
  match = re.search(r'=IMAGE\("([^"]+)"', formula)
124
  if match: return match.group(1)
125
- return formula
 
 
126
 
127
  def format_currency(value: Any) -> Optional[str]:
128
  if value is None or str(value).strip() == '': return 'N/A'
@@ -132,19 +166,18 @@ def format_currency(value: Any) -> Optional[str]:
132
  num = float(num_str)
133
  return f"${num:,.0f}"
134
  except (ValueError, TypeError):
135
- # Check if it's non-numeric text before returning N/A
136
- if isinstance(value, str) and not re.match(r'^-?[\d,.]+\$?$', value.strip()):
137
- return value.strip() # Return original text if it doesn't look like a number/currency
138
- return 'N/A' # Default to N/A if conversion fails
139
-
140
  def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
141
- if value_str is None or value_str.lower() == 'n/a':
142
  return None
143
  try:
144
- num_str = value_str.replace('$', '').replace(',', '').strip()
145
  return float(num_str)
146
  except (ValueError, TypeError):
147
- return None # Cannot parse
148
 
149
  def clean_string(value, default='N/A'):
150
  if value is None: return default
@@ -174,7 +207,6 @@ async def get_roblox_user_id(session: aiohttp.ClientSession, username: str):
174
  data = await response.json()
175
  if data and data.get("data") and len(data["data"]) > 0:
176
  return data["data"][0].get("id")
177
- # else: logger.warning(f"Roblox User ID API non-200 status for {username}: {response.status}") # Maybe too noisy
178
  return None
179
  except asyncio.TimeoutError:
180
  logger.warning(f"Timeout fetching Roblox User ID for {username}")
@@ -195,7 +227,6 @@ async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
195
  data = await response.json()
196
  if data and data.get("data") and len(data["data"]) > 0:
197
  return data["data"][0].get("imageUrl")
198
- # else: logger.warning(f"Roblox Avatar API non-200 status for User ID {user_id}: {response.status}") # Maybe too noisy
199
  return None
200
  except asyncio.TimeoutError:
201
  logger.warning(f"Timeout fetching Roblox avatar for User ID {user_id}")
@@ -208,344 +239,449 @@ async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
208
  return None
209
 
210
 
211
- # --- Data Processing Functions (Unchanged) ---
212
- def process_sheet_data(values):
213
- """Process raw sheet data into structured format for values"""
 
 
214
  if not values: return []
215
  processed_data = []
216
- for row in values:
217
- if not row or not any(cell.strip() for cell in row if cell): continue
218
- drive_url = row[14] if len(row) > 14 else None
219
- filtered_row = [cell for i, cell in enumerate(row) if i % 2 == 0]
220
- if len(filtered_row) >= 4 and isinstance(filtered_row[3], str) and re.search(r'LEVEL \d+ \| HYPERCHROMES', filtered_row[3]): continue
221
- if len(filtered_row) >= 6:
222
- processed_item = {
223
- 'icon': extract_image_url(filtered_row[0], drive_url),
224
- 'name': clean_string(filtered_row[1], 'N/A') if len(filtered_row) > 1 else 'N/A',
225
- 'value': format_currency(filtered_row[2]) if len(filtered_row) > 2 else 'N/A',
226
- 'dupedValue': format_currency(filtered_row[3]) if len(filtered_row) > 3 else 'N/A',
227
- 'marketValue': format_currency(filtered_row[4]) if len(filtered_row) > 4 else 'N/A',
228
- 'demand': clean_string(filtered_row[5], 'N/A') if len(filtered_row) > 5 else 'N/A',
229
- 'notes': clean_string(filtered_row[6], '') if len(filtered_row) > 6 else ''
230
- }
231
- processed_data.append(processed_item)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  return processed_data
233
 
234
- def process_user_scammer_data(values):
235
- """Process raw user scammer data"""
236
  if not values: return []
237
  processed_data = []
238
- for row in values:
239
- if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
240
- discord_id = clean_string_optional(row[0]) if len(row) > 0 else None
241
- roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None
 
 
242
  if not discord_id and not roblox_username: continue
243
  processed_item = {
244
  'discord_id': discord_id,
245
  'roblox_username': roblox_username,
246
- 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
247
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
248
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
249
- 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
250
- 'roblox_avatar_url': None # Placeholder
251
  }
252
  processed_data.append(processed_item)
253
  return processed_data
254
 
255
- def process_server_scammer_data(values):
256
- """Process raw server scammer data"""
257
  if not values: return []
258
  processed_data = []
259
- for row in values:
260
- if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
261
- server_id = clean_string_optional(row[0]) if len(row) > 0 else None
262
- server_name = clean_string_optional(row[1]) if len(row) > 1 else None
 
 
263
  if not server_id and not server_name: continue
264
  processed_item = {
265
  'server_id': server_id,
266
  'server_name': server_name,
267
- 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
268
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
269
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None
270
  }
271
  processed_data.append(processed_item)
272
  return processed_data
273
 
274
- def process_dwc_data(values):
275
- """Process raw DWC data"""
276
  if not values: return []
277
  processed_data = []
278
- for row in values:
279
- if not row or len(row) < 3 or not any(clean_string_optional(cell) for cell in row[:3]): continue
280
- user_id = clean_string_optional(row[0]) if len(row) > 0 else None
281
- server_id = clean_string_optional(row[1]) if len(row) > 1 else None
282
- roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None
 
 
283
  if not user_id and not server_id and not roblox_user: continue
284
  processed_item = {
285
  'status': 'DWC',
286
  'discord_user_id': user_id,
287
  'discord_server_id': server_id,
288
  'roblox_username': roblox_user,
289
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
290
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
291
- 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
292
- 'roblox_avatar_url': None # Placeholder
293
  }
294
  processed_data.append(processed_item)
295
  return processed_data
296
 
297
- def process_dupe_list_data(values):
298
- """Process raw dupe list data"""
299
  if not values: return []
300
- return [row[0].strip().lower() for row in values if row and row[0] and isinstance(row[0], str) and row[0].strip()]
 
 
301
 
 
302
 
303
- # --- Async Fetching Functions (Used by background task) ---
304
- async def fetch_sheet_data_async(sheet_name, range_name, processor, value_render_option='FORMATTED_VALUE', spreadsheet_id=SPREADSHEET_ID):
305
- """Async wrapper to fetch and process sheet data"""
306
- global sheets_service # Access the initialized service
307
  if not sheets_service:
308
- logger.warning(f"Attempted to fetch {sheet_name} but Sheets service is unavailable.")
309
- raise Exception("Google Sheets service not initialized") # Raise to signal failure in update task
 
 
 
310
 
311
  try:
312
- quoted_sheet_name = f"'{sheet_name}'" if not sheet_name.isalnum() else sheet_name
313
- full_range = f"{quoted_sheet_name}!{range_name}"
314
-
315
  loop = asyncio.get_event_loop()
316
  result = await loop.run_in_executor(
317
- None, # Default executor
318
- lambda: sheets_service.spreadsheets().values().get(
319
  spreadsheetId=spreadsheet_id,
320
- range=full_range,
321
- valueRenderOption=value_render_option
 
322
  ).execute()
323
  )
324
- values = result.get('values', [])
325
- return processor(values)
 
 
 
 
 
 
 
 
326
  except Exception as e:
327
- logger.error(f"Error fetching/processing {sheet_name} from {spreadsheet_id}: {e}")
328
- # Re-raise the exception so the update loop knows this part failed
329
  raise e
330
 
331
-
332
- # --- Background Cache Update Task ---
333
 
334
  async def update_cache_periodically():
335
- """Fetches data from sheets, detects value changes, and updates the cache periodically."""
336
  global cache
337
- async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
338
  while True:
339
  if not cache["service_available"]:
340
- logger.warning("Google Sheets service unavailable, skipping cache update cycle.")
341
- await asyncio.sleep(CACHE_UPDATE_INTERVAL_SECONDS)
342
- continue
 
 
 
 
 
343
 
344
  logger.info("Starting cache update cycle...")
345
  start_time = datetime.now(timezone.utc)
346
- success = True
347
 
 
 
348
  new_cache_data = {
349
- "values": {},
350
  "user_scammers": [],
351
  "server_scammers": [],
352
  "dwc": [],
353
  "dupes": [],
354
  }
355
- detected_value_changes = {} # Store changes detected *in this cycle*
356
 
357
  try:
358
- # --- Fetch all data concurrently ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  fetch_tasks = {
360
- "user_scammers": fetch_sheet_data_async(USER_SCAMMER_SHEET, 'B6:G', process_user_scammer_data, spreadsheet_id=SPREADSHEET_ID),
361
- "server_scammers": fetch_sheet_data_async(SERVER_SCAMMER_SHEET, 'B6:F', process_server_scammer_data, spreadsheet_id=SPREADSHEET_ID),
362
- "dwc": fetch_sheet_data_async(DWC_SHEET, 'B6:G', process_dwc_data, spreadsheet_id=SPREADSHEET_ID),
363
- "dupes": fetch_sheet_data_async(DUPE_LIST_SHEET, 'B2:B', process_dupe_list_data, spreadsheet_id=VALUES_SPREADSHEET_ID),
364
- # Add tasks for each value category
365
- **{f"values_{cat}": fetch_sheet_data_async(
366
- cat, 'B6:P', process_sheet_data,
367
- value_render_option='FORMULA',
368
- spreadsheet_id=VALUES_SPREADSHEET_ID
369
- ) for cat in CATEGORIES}
370
  }
371
 
 
372
  results = await asyncio.gather(*fetch_tasks.values(), return_exceptions=True)
373
  task_keys = list(fetch_tasks.keys())
374
 
375
- # --- Process results and update temporary cache ---
376
- fetched_values = {}
377
- current_errors = {} # Track errors for specific keys
 
378
  for i, result in enumerate(results):
379
  key = task_keys[i]
380
  if isinstance(result, Exception):
381
- logger.error(f"Failed to fetch data for {key}: {result}")
382
- success = False
383
- current_errors[key] = str(result) # Log the error
384
- # Decide: keep old data or clear? We'll keep old cache data by not updating below
385
  else:
386
- if key.startswith("values_"):
387
- category_name = key.split("_", 1)[1]
388
- fetched_values[category_name] = result
389
- elif key in new_cache_data: # Only update if key exists
390
- new_cache_data[key] = result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  else:
392
- logger.warning(f"Fetched data for unknown key: {key}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
  # --- Detect Value Changes ---
395
- logger.info("Comparing fetched values with cached values to detect changes...")
396
  current_time = datetime.now(timezone.utc)
 
397
  fields_to_compare = ['value', 'dupedValue', 'marketValue']
398
 
399
- for category, new_items in fetched_values.items():
400
- if category not in cache["values"]: # Category is new or wasn't cached before
401
- cache["values"][category] = [] # Ensure old category exists for comparison logic below
402
 
403
- # Create a lookup for old items by name for efficient comparison
404
  old_items_dict = {item['name']: item for item in cache["values"].get(category, [])}
405
  category_changes = []
406
 
407
  for new_item in new_items:
408
- item_name = new_item['name']
409
- if item_name in old_items_dict:
410
- old_item = old_items_dict[item_name]
411
- for field in fields_to_compare:
412
- old_val_str = old_item.get(field)
413
- new_val_str = new_item.get(field)
414
-
415
- # Normalize "N/A" and potential whitespace before comparison
416
- old_norm = old_val_str.strip().lower() if isinstance(old_val_str, str) else old_val_str
417
- new_norm = new_val_str.strip().lower() if isinstance(new_val_str, str) else new_val_str
418
 
419
- if old_norm == 'n/a': old_norm = None
420
- if new_norm == 'n/a': new_norm = None
 
 
 
 
 
421
 
422
- # Only record change if values are meaningfully different
423
  if old_norm != new_norm:
424
- # Try parsing for better comparison (optional but recommended)
425
- old_numeric = parse_cached_currency(old_val_str)
426
- new_numeric = parse_cached_currency(new_val_str)
427
-
428
- # Compare numeric if possible, otherwise string representations
429
- # (Handles cases like $10 vs $10.00 becoming same numeric 10.0)
430
- values_differ = False
431
- if old_numeric is not None and new_numeric is not None:
432
- if old_numeric != new_numeric:
433
- values_differ = True
434
- elif old_val_str != new_val_str: # Fallback to string comparison if parsing fails or types differ
435
- values_differ = True
436
-
437
- if values_differ:
438
- logger.info(f"Change detected in {category}: {item_name} - {field}: '{old_val_str}' -> '{new_val_str}'")
439
- category_changes.append({
440
- "item_name": item_name,
441
- "field": field,
442
- "old_value": old_val_str if old_val_str is not None else "N/A",
443
- "new_value": new_val_str if new_val_str is not None else "N/A",
444
- "timestamp": current_time.isoformat()
445
- })
446
  if category_changes:
447
  detected_value_changes[category] = category_changes
448
 
449
- # --- Fetch Roblox Avatars (no changes needed here) ---
450
- # ... (avatar fetching logic remains the same)
 
 
 
 
 
 
 
 
 
 
 
451
 
452
  # --- Final Cache Update ---
453
- # Only update the main cache if the fetch cycle didn't have critical errors
454
- # We allow partial updates if only some fetches failed.
455
- if not current_errors: # If no errors at all
456
- logger.info("Updating full cache.")
457
- cache["values"] = fetched_values
458
  cache["user_scammers"] = new_cache_data["user_scammers"]
459
  cache["server_scammers"] = new_cache_data["server_scammers"]
460
  cache["dwc"] = new_cache_data["dwc"]
461
  cache["dupes"] = new_cache_data["dupes"]
462
- cache["value_changes"] = detected_value_changes # Store the detected changes
463
  cache["last_updated"] = current_time
464
  cache["is_ready"] = True
 
465
  logger.info(f"Cache update cycle completed successfully.")
466
- else:
467
- # Update parts that *did* succeed, if any
468
- partial_update_occurred = False
469
- if fetched_values: # Only update values if *all* value fetches succeeded
470
- all_values_fetched = True
471
- for cat in CATEGORIES:
472
- if f"values_{cat}" in current_errors:
473
- all_values_fetched = False
474
- break
475
- if all_values_fetched:
476
- cache["values"] = fetched_values
477
- cache["value_changes"] = detected_value_changes # Update changes if values updated
478
- partial_update_occurred = True
479
- logger.info("Partially updated cache: Values updated.")
480
- else:
481
- logger.warning("Values cache not updated due to fetch errors in some categories.")
482
-
483
-
484
- # Update other sections if they succeeded
485
- for key in ["user_scammers", "server_scammers", "dwc", "dupes"]:
486
- if key not in current_errors and new_cache_data.get(key) is not None:
487
- cache[key] = new_cache_data[key]
488
- partial_update_occurred = True
489
- logger.info(f"Partially updated cache: {key} updated.")
490
-
491
- if partial_update_occurred:
 
 
 
 
492
  cache["last_updated"] = current_time # Mark partial update time
493
  cache["is_ready"] = True # Allow access even if partial
494
- logger.warning(f"Cache update cycle completed with errors: {current_errors}. Some data might be stale.")
495
  else:
496
- logger.error(f"Cache update cycle failed completely. No parts updated. Errors: {current_errors}")
497
  # Keep cache["is_ready"] as it was.
498
 
499
-
500
  except Exception as e:
501
  logger.exception(f"Critical error during cache update cycle: {e}")
502
- success = False # Should already be false if exception bubbled up
 
503
 
504
  # --- Wait for the next cycle ---
505
  end_time = datetime.now(timezone.utc)
506
  duration = (end_time - start_time).total_seconds()
507
- wait_time = max(0, CACHE_UPDATE_INTERVAL_SECONDS - duration)
508
- logger.info(f"Cache update duration: {duration:.2f}s. Waiting {wait_time:.2f}s for next cycle.")
509
  await asyncio.sleep(wait_time)
510
 
511
- # Helper specifically for the background task to update the dict in place
512
  async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: dict):
513
- """Fetches avatar and updates the provided entry dictionary."""
514
  roblox_username = entry.get('roblox_username')
515
  if not roblox_username: return
516
 
 
 
 
517
  try:
518
  user_id = await get_roblox_user_id(session, roblox_username)
519
  if user_id:
520
- avatar_url = await get_roblox_avatar_url(session, user_id)
521
- entry['roblox_avatar_url'] = avatar_url # Update the dict directly
522
- # logger.debug(f"Avatar found for {roblox_username}") # Debug level
523
- # else: logger.debug(f"No Roblox user ID found for {roblox_username}") # Debug level
524
  except Exception as e:
525
  # Log errors but don't stop the main update loop
526
  logger.warning(f"Failed to fetch avatar for {roblox_username}: {e}")
527
- entry['roblox_avatar_url'] = None # Ensure it's None on error
 
 
 
 
 
528
 
529
 
530
  # --- FastAPI Startup Event ---
531
  @app.on_event("startup")
532
  async def startup_event():
533
  """Starts the background cache update task."""
534
- if cache["service_available"]:
535
- logger.info("Starting background cache update task...")
536
- asyncio.create_task(update_cache_periodically())
537
- else:
538
- logger.warning("Google Sheets service not available. Cache update task will not start.")
539
 
540
 
541
- # --- API Endpoints (Modified to use Cache) ---
542
 
543
- def check_service_availability():
544
- """Reusable check for API endpoints"""
545
- if not cache["service_available"]:
546
- raise HTTPException(status_code=503, detail="Google Sheets service unavailable. Cannot fetch data.")
547
  if not cache["is_ready"]:
548
- raise HTTPException(status_code=503, detail="Cache is not ready yet. Please try again shortly.")
549
 
550
  @app.get("/")
551
  async def root():
@@ -553,7 +689,7 @@ async def root():
553
 
554
  @app.get("/api/status")
555
  async def get_status():
556
- """Returns the current status of the cache"""
557
  return {
558
  "cache_ready": cache["is_ready"],
559
  "sheets_service_available": cache["service_available"],
@@ -564,49 +700,44 @@ async def get_status():
564
  "server_scammers": len(cache["server_scammers"]),
565
  "dwc_entries": len(cache["dwc"]),
566
  "duped_usernames": len(cache["dupes"]),
567
- }
 
568
  }
569
 
570
-
571
  @app.get("/api/values")
572
  async def get_values():
573
  """Get all values data from cache"""
574
- check_service_availability()
575
  return cache["values"]
576
 
577
  @app.get("/api/values/{category}")
578
  async def get_category_values(category: str):
579
  """Get values data for a specific category from cache"""
580
- check_service_availability()
581
- category = category.capitalize()
582
-
583
- if category not in CATEGORIES:
584
- raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
585
-
586
- return {category: cache["values"].get(category, [])}
587
 
588
  @app.get("/api/value-changes/{category}")
589
  async def get_category_value_changes(category: str):
590
- """Get detected value changes for a specific category from the last cache update cycle."""
591
- check_service_availability()
592
- category = category.capitalize()
593
-
594
- if category not in CATEGORIES:
595
- raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
596
-
597
- return {category: cache.get("value_changes", {}).get(category, [])}
598
 
599
  @app.get("/api/value-changes")
600
  async def get_all_value_changes():
601
- """Get all detected value changes from the last cache update cycle."""
602
- check_service_availability()
603
  return cache.get("value_changes", {})
604
 
605
  @app.get("/api/scammers")
606
  async def get_scammers():
607
  """Get all scammer and DWC data (users, servers, dwc) from cache"""
608
- check_service_availability()
609
- # Data is already fetched and processed (including avatars) by the background task
610
  return {
611
  "users": cache["user_scammers"],
612
  "servers": cache["server_scammers"],
@@ -616,8 +747,9 @@ async def get_scammers():
616
  @app.get("/api/dupes")
617
  async def get_dupes():
618
  """Get all duped usernames from cache"""
619
- check_service_availability()
620
- return {"usernames": cache["dupes"]}
 
621
 
622
 
623
  class UsernameCheck(BaseModel):
@@ -626,16 +758,16 @@ class UsernameCheck(BaseModel):
626
  @app.post("/api/check")
627
  async def check_username(data: UsernameCheck):
628
  """Check if a username is duped using cached data and send webhook"""
629
- check_service_availability() # Ensure cache is ready before checking
630
 
631
  username_to_check = data.username.strip().lower()
632
- is_duped = username_to_check in cache["dupes"]
633
 
634
- # Webhook notification logic (remains the same, consider making it non-blocking)
635
  if not is_duped:
636
  webhook_url = os.getenv("WEBHOOK_URL")
637
  if webhook_url:
638
- async def send_webhook_notification(): # Wrap in async func
639
  try:
640
  async with aiohttp.ClientSession() as session:
641
  webhook_data = {
@@ -644,7 +776,7 @@ async def check_username(data: UsernameCheck):
644
  "title": "New Dupe Check - Not Found",
645
  "description": f"Username `{data.username}` was checked but not found in the dupe database.",
646
  "color": 16776960, # Yellow
647
- "timestamp": datetime.now(timezone.utc).isoformat() # Use timezone aware
648
  }]
649
  }
650
  async with session.post(webhook_url, json=webhook_data) as response:
@@ -652,8 +784,6 @@ async def check_username(data: UsernameCheck):
652
  logger.warning(f"Failed to send webhook (Status: {response.status}): {await response.text()}")
653
  except Exception as e:
654
  logger.error(f"Error sending webhook: {e}")
655
-
656
- # Run the webhook sending in the background so it doesn't delay the API response
657
  asyncio.create_task(send_webhook_notification())
658
  else:
659
  logger.info("Webhook URL not configured. Skipping notification.")
@@ -661,12 +791,15 @@ async def check_username(data: UsernameCheck):
661
  return {"username": data.username, "is_duped": is_duped}
662
 
663
 
664
- # Optional: Add a health check endpoint (simple version)
665
  @app.get("/health")
666
  def health_check():
667
- # Basic check: is the app running?
668
- # More advanced: could check cache['is_ready'] or cache['last_updated']
 
 
 
 
 
669
  return {"status": "ok"}
670
 
671
- # Run with: uvicorn main:app --reload (for development)
672
- # For production: uvicorn main:app --host 0.0.0.0 --port 8000 (or your preferred port)
 
1
+ # --- START OF FILE main.py ---
2
+
3
  # main.py
4
  from fastapi import FastAPI, HTTPException
5
  from fastapi.middleware.cors import CORSMiddleware
6
  from pydantic import BaseModel
7
+ from typing import Optional, Any, Dict, List
8
  import aiohttp
9
  import os
10
+ from datetime import datetime, timezone
11
  import json
12
  import re
13
  from google.oauth2.service_account import Credentials as ServiceAccountCredentials
14
  from googleapiclient.discovery import build
15
+ from googleapiclient.errors import HttpError
16
  from dotenv import load_dotenv
17
  import asyncio
18
+ import logging
19
 
20
  # --- Logging Setup ---
21
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
36
  )
37
 
38
  # Google Sheets Config
39
+ # Spreadsheet containing Scammer and DWC info
40
+ SCAMMER_DWC_SPREADSHEET_ID = '1sgkhBNGw_r6tBIxvdeXaI0bVmWBeACN4jiw_oDEeXLw'
41
+ # Spreadsheet containing Value lists and Dupe list
42
+ VALUES_DUPE_SPREADSHEET_ID = '1Toe07o3P517q8sm9Qb1e5xyFWCuwgskj71IKJwJNfNU'
43
+
44
  SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
45
+
46
+ # Sheet Names and Ranges within SCAMMER_DWC_SPREADSHEET_ID
47
  USER_SCAMMER_SHEET = "User Scammer Files"
48
+ USER_SCAMMER_RANGE = "B6:G"
49
  SERVER_SCAMMER_SHEET = "Server Scammer Files"
50
+ SERVER_SCAMMER_RANGE = "B6:F"
51
  DWC_SHEET = "DWC Servers / Users"
52
+ DWC_RANGE = "B6:G"
53
+
54
+ # Sheet Names and Ranges within VALUES_DUPE_SPREADSHEET_ID
55
  DUPE_LIST_SHEET = "Dupe List"
56
+ DUPE_LIST_RANGE = "B2:B"
57
+ # Value Categories (Sheet Names)
58
  CATEGORIES = [
59
  "Vehicles", "Textures", "Colours", "Spoilers",
60
  "Rims", "Furnitures", "Gun Skins", "Hyperchromes"
61
  ]
62
+ VALUES_RANGE = 'B6:P' # Range within each category sheet
63
 
64
  # Cache Update Interval
65
  CACHE_UPDATE_INTERVAL_SECONDS = 60 # 1 minute
66
 
67
  # --- Global Cache ---
68
  cache = {
69
+ "values": {}, # Dict mapping category name to list of items
70
+ "value_changes": {}, # Dict mapping category name to list of changes
71
  "user_scammers": [],
72
  "server_scammers": [],
73
  "dwc": [],
74
+ "dupes": [], # List of duped usernames
75
+ "last_updated": None, # Timestamp of the last successful/partial update
76
+ "is_ready": False, # Is the cache populated at least once?
77
+ "service_available": True # Is the Google Sheets service reachable?
78
  }
79
  # --- Google Sheets Initialization ---
80
  sheets_service = None # Initialize as None
81
 
82
+ def quote_sheet_name(name: str) -> str:
83
+ """Adds single quotes around a sheet name if it needs them."""
84
+ if not name:
85
+ return "''"
86
+ # Simple check: if it contains spaces or non-alphanumeric chars (excluding _)
87
+ if not re.match(r"^[a-zA-Z0-9_]+$", name):
88
+ # Escape existing single quotes within the name
89
+ escaped_name = name.replace("'", "''")
90
+ return f"'{escaped_name}'"
91
+ return name
92
+
93
  def init_google_sheets(scopes=SCOPES):
94
  """Initialize Google Sheets credentials from environment variable"""
95
+ global sheets_service, cache
96
  try:
97
  creds_json_str = os.getenv('CREDENTIALS_JSON')
98
  if not creds_json_str:
 
103
  creds_json,
104
  scopes=scopes
105
  )
106
+ sheets_service = build('sheets', 'v4', credentials=creds, cache_discovery=False) # Disable discovery cache
107
  logger.info("Google Sheets service initialized successfully from ENV VAR.")
108
  cache["service_available"] = True
109
  return sheets_service
110
  except Exception as e:
111
  logger.error(f"Error initializing Google Sheets from ENV VAR: {e}")
112
+ # Fallback attempt
113
  try:
114
  logger.info("Falling back to loading credentials from file 'credentials.json'")
115
  creds = ServiceAccountCredentials.from_service_account_file(
116
  'credentials.json',
117
  scopes=scopes
118
  )
119
+ sheets_service = build('sheets', 'v4', credentials=creds, cache_discovery=False)
120
  logger.info("Google Sheets service initialized successfully from file.")
121
  cache["service_available"] = True
122
  return sheets_service
 
124
  logger.error(f"Error loading credentials from file: {file_e}")
125
  logger.critical("Google Sheets service could not be initialized. API will be limited.")
126
  cache["service_available"] = False
127
+ sheets_service = None
128
  return None
129
 
130
  # Initialize on module load
131
  init_google_sheets()
132
 
133
 
134
+ # --- Helper Functions (Data Extraction & Formatting) ---
135
 
136
  def extract_drive_id(url):
137
  if not url or not isinstance(url, str): return None
 
143
  return f"https://drive.google.com/thumbnail?id={drive_id}&sz=w1000" if drive_id else drive_url
144
 
145
  def extract_image_url(formula, drive_url=None):
146
+ # Priority to explicit drive_url if provided
147
  if drive_url and isinstance(drive_url, str) and 'drive.google.com' in drive_url:
148
  return convert_to_thumbnail_url(drive_url)
149
  if not formula or not isinstance(formula, str): return ''
150
+ # Handle direct URLs
151
+ if formula.startswith('http://') or formula.startswith('https://'):
152
+ return formula
153
+ # Handle =IMAGE("...") formula
154
  if formula.startswith('=IMAGE('):
155
  match = re.search(r'=IMAGE\("([^"]+)"', formula)
156
  if match: return match.group(1)
157
+ # If it wasn't a formula or direct URL, and no drive_url, return empty or original?
158
+ # Let's assume if it's not a recognizable URL/formula, it's not an image source.
159
+ return '' # Return empty string if no valid URL found
160
 
161
  def format_currency(value: Any) -> Optional[str]:
162
  if value is None or str(value).strip() == '': return 'N/A'
 
166
  num = float(num_str)
167
  return f"${num:,.0f}"
168
  except (ValueError, TypeError):
169
+ if isinstance(value, str) and not re.match(r'^-?[\d,.$]+\$?$', value.strip()):
170
+ return value.strip() # Return original text if non-numeric-like
171
+ return 'N/A'
172
+
 
173
  def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
174
+ if value_str is None or value_str is None or str(value_str).strip().lower() == 'n/a':
175
  return None
176
  try:
177
+ num_str = str(value_str).replace('$', '').replace(',', '').strip()
178
  return float(num_str)
179
  except (ValueError, TypeError):
180
+ return None
181
 
182
  def clean_string(value, default='N/A'):
183
  if value is None: return default
 
207
  data = await response.json()
208
  if data and data.get("data") and len(data["data"]) > 0:
209
  return data["data"][0].get("id")
 
210
  return None
211
  except asyncio.TimeoutError:
212
  logger.warning(f"Timeout fetching Roblox User ID for {username}")
 
227
  data = await response.json()
228
  if data and data.get("data") and len(data["data"]) > 0:
229
  return data["data"][0].get("imageUrl")
 
230
  return None
231
  except asyncio.TimeoutError:
232
  logger.warning(f"Timeout fetching Roblox avatar for User ID {user_id}")
 
239
  return None
240
 
241
 
242
+ # --- Data Processing Functions ---
243
+ # These functions take raw rows from the sheet and process them.
244
+ # They are now independent of *which* sheet they came from, as long as the structure matches.
245
+
246
+ def process_sheet_data(values): # For Value Categories
247
  if not values: return []
248
  processed_data = []
249
+ for row in values: # Expected range like B6:P
250
+ if not row or not any(str(cell).strip() for cell in row if cell is not None): continue
251
+
252
+ # Indices based on B6:P (0-indexed from B)
253
+ # B=0, C=1, D=2, E=3, F=4, G=5, H=6, I=7, J=8, K=9, L=10, M=11, N=12, O=13, P=14
254
+ icon_formula = row[0] if len(row) > 0 else ''
255
+ name = row[2] if len(row) > 2 else 'N/A'
256
+ value_raw = row[4] if len(row) > 4 else 'N/A'
257
+ duped_value_raw = row[6] if len(row) > 6 else 'N/A'
258
+ market_value_raw = row[8] if len(row) > 8 else 'N/A'
259
+ demand = row[10] if len(row) > 10 else 'N/A'
260
+ notes = row[12] if len(row) > 12 else ''
261
+ drive_url = row[14] if len(row) > 14 else None # Column P
262
+
263
+ # Skip header-like rows (e.g., "LEVEL 1 | HYPERCHROMES" in column F/index 4)
264
+ if len(row) > 4 and isinstance(row[4], str) and re.search(r'LEVEL \d+ \|', row[4]):
265
+ continue
266
+ if clean_string(name) == 'N/A':
267
+ continue
268
+
269
+ processed_item = {
270
+ 'icon': extract_image_url(icon_formula, drive_url),
271
+ 'name': clean_string(name, 'N/A'),
272
+ 'value': format_currency(value_raw),
273
+ 'dupedValue': format_currency(duped_value_raw),
274
+ 'marketValue': format_currency(market_value_raw),
275
+ 'demand': clean_string(demand, 'N/A'),
276
+ 'notes': clean_string(notes, '')
277
+ }
278
+ processed_data.append(processed_item)
279
  return processed_data
280
 
281
+ def process_user_scammer_data(values): # For User Scammer Sheet
 
282
  if not values: return []
283
  processed_data = []
284
+ for row in values: # Expected range like B6:G
285
+ if not row or len(row) < 2: continue
286
+ # Indices based on B6:G (0-indexed from B)
287
+ # B=0, C=1, D=2, E=3, F=4, G=5
288
+ discord_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
289
+ roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
290
  if not discord_id and not roblox_username: continue
291
  processed_item = {
292
  'discord_id': discord_id,
293
  'roblox_username': roblox_username,
294
+ 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A', # Col D
295
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A', # Col E
296
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None, # Col F
297
+ 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [], # Col G
298
+ 'roblox_avatar_url': None
299
  }
300
  processed_data.append(processed_item)
301
  return processed_data
302
 
303
+ def process_server_scammer_data(values): # For Server Scammer Sheet
 
304
  if not values: return []
305
  processed_data = []
306
+ for row in values: # Expected range like B6:F
307
+ if not row or len(row) < 2: continue
308
+ # Indices based on B6:F (0-indexed from B)
309
+ # B=0, C=1, D=2, E=3, F=4
310
+ server_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
311
+ server_name = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
312
  if not server_id and not server_name: continue
313
  processed_item = {
314
  'server_id': server_id,
315
  'server_name': server_name,
316
+ 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A', # Col D
317
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A', # Col E
318
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None # Col F
319
  }
320
  processed_data.append(processed_item)
321
  return processed_data
322
 
323
+ def process_dwc_data(values): # For DWC Sheet
 
324
  if not values: return []
325
  processed_data = []
326
+ for row in values: # Expected range like B6:G
327
+ if not row or len(row) < 3: continue
328
+ # Indices based on B6:G (0-indexed from B)
329
+ # B=0, C=1, D=2, E=3, F=4, G=5
330
+ user_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
331
+ server_id = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
332
+ roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None # Col D
333
  if not user_id and not server_id and not roblox_user: continue
334
  processed_item = {
335
  'status': 'DWC',
336
  'discord_user_id': user_id,
337
  'discord_server_id': server_id,
338
  'roblox_username': roblox_user,
339
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A', # Col E
340
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None, # Col F
341
+ 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [], # Col G
342
+ 'roblox_avatar_url': None
343
  }
344
  processed_data.append(processed_item)
345
  return processed_data
346
 
347
+ def process_dupe_list_data(values): # For Dupe List Sheet
 
348
  if not values: return []
349
+ # Expected range like B2:B
350
+ return [row[0].strip().lower() for row in values if row and len(row)>0 and row[0] and isinstance(row[0], str) and row[0].strip()]
351
+
352
 
353
+ # --- Async Fetching Functions ---
354
 
355
+ async def fetch_batch_ranges_async(spreadsheet_id: str, ranges: List[str], value_render_option: str = 'FORMATTED_VALUE') -> List[Dict]:
356
+ """Async wrapper to fetch multiple ranges using batchGet and return raw valueRanges."""
357
+ global sheets_service
 
358
  if not sheets_service:
359
+ logger.warning(f"Attempted batch fetch from {spreadsheet_id} but Sheets service is unavailable.")
360
+ raise Exception("Google Sheets service not initialized")
361
+ if not ranges:
362
+ logger.warning(f"Batch fetch called with empty ranges for {spreadsheet_id}.")
363
+ return []
364
 
365
  try:
366
+ logger.info(f"Fetching batch ranges from {spreadsheet_id}: {ranges}")
 
 
367
  loop = asyncio.get_event_loop()
368
  result = await loop.run_in_executor(
369
+ None,
370
+ lambda: sheets_service.spreadsheets().values().batchGet(
371
  spreadsheetId=spreadsheet_id,
372
+ ranges=ranges,
373
+ valueRenderOption=value_render_option,
374
+ majorDimension='ROWS'
375
  ).execute()
376
  )
377
+ value_ranges = result.get('valueRanges', [])
378
+ logger.info(f"Successfully fetched batch data for {len(value_ranges)} ranges from {spreadsheet_id}.")
379
+ return value_ranges # Return the raw list of valueRange objects
380
+
381
+ except HttpError as e:
382
+ error_details = json.loads(e.content).get('error', {})
383
+ status = error_details.get('status')
384
+ message = error_details.get('message')
385
+ logger.error(f"Google API HTTP Error during batch fetch for {spreadsheet_id}: Status={status}, Message={message}")
386
+ raise e
387
  except Exception as e:
388
+ logger.error(f"Error during batch fetching from {spreadsheet_id} for ranges {ranges}: {e}")
 
389
  raise e
390
 
391
+ # --- Background Cache Update Task (Refactored for Batching per Spreadsheet) ---
 
392
 
393
  async def update_cache_periodically():
394
+ """Fetches data using batchGet per spreadsheet, processes, detects changes, and updates cache."""
395
  global cache
396
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=25)) as session: # Slightly longer timeout
397
  while True:
398
  if not cache["service_available"]:
399
+ logger.info("Attempting to re-initialize Google Sheets service...")
400
+ init_google_sheets()
401
+ if not cache["service_available"]:
402
+ logger.warning("Google Sheets service still unavailable, skipping cache update cycle.")
403
+ await asyncio.sleep(CACHE_UPDATE_INTERVAL_SECONDS * 2)
404
+ continue
405
+ else:
406
+ logger.info("Google Sheets service re-initialized. Proceeding with cache update.")
407
 
408
  logger.info("Starting cache update cycle...")
409
  start_time = datetime.now(timezone.utc)
 
410
 
411
+ # Prepare temporary storage for fetched data
412
+ fetched_values_categories = {} # { "CategoryName": [items...] }
413
  new_cache_data = {
 
414
  "user_scammers": [],
415
  "server_scammers": [],
416
  "dwc": [],
417
  "dupes": [],
418
  }
419
+ current_errors = {} # Track errors for specific fetches/sheets
420
 
421
  try:
422
+ # --- Define Ranges and Processors ---
423
+ # Scammer/DWC Spreadsheet
424
+ scammer_dwc_ranges = [
425
+ f"{quote_sheet_name(USER_SCAMMER_SHEET)}!{USER_SCAMMER_RANGE}",
426
+ f"{quote_sheet_name(SERVER_SCAMMER_SHEET)}!{SERVER_SCAMMER_RANGE}",
427
+ f"{quote_sheet_name(DWC_SHEET)}!{DWC_RANGE}",
428
+ ]
429
+ scammer_dwc_processor_map = {
430
+ USER_SCAMMER_SHEET: process_user_scammer_data,
431
+ SERVER_SCAMMER_SHEET: process_server_scammer_data,
432
+ DWC_SHEET: process_dwc_data,
433
+ }
434
+ scammer_dwc_target_key_map = { # Map sheet name to cache key
435
+ USER_SCAMMER_SHEET: "user_scammers",
436
+ SERVER_SCAMMER_SHEET: "server_scammers",
437
+ DWC_SHEET: "dwc",
438
+ }
439
+
440
+ # Values/Dupes Spreadsheet
441
+ values_dupes_ranges = [f"{quote_sheet_name(DUPE_LIST_SHEET)}!{DUPE_LIST_RANGE}"]
442
+ values_dupes_ranges.extend([f"{quote_sheet_name(cat)}!{VALUES_RANGE}" for cat in CATEGORIES])
443
+
444
+ # --- Define Fetch Tasks ---
445
  fetch_tasks = {
446
+ "scammer_dwc_batch": fetch_batch_ranges_async(
447
+ SCAMMER_DWC_SPREADSHEET_ID,
448
+ scammer_dwc_ranges,
449
+ value_render_option='FORMATTED_VALUE' # These don't need formulas
450
+ ),
451
+ "values_dupes_batch": fetch_batch_ranges_async(
452
+ VALUES_DUPE_SPREADSHEET_ID,
453
+ values_dupes_ranges,
454
+ value_render_option='FORMULA' # Need formula for IMAGE() in values
455
+ )
456
  }
457
 
458
+ # --- Execute Tasks Concurrently ---
459
  results = await asyncio.gather(*fetch_tasks.values(), return_exceptions=True)
460
  task_keys = list(fetch_tasks.keys())
461
 
462
+ # --- Process Results ---
463
+ raw_scammer_dwc_results = None
464
+ raw_values_dupes_results = None
465
+
466
  for i, result in enumerate(results):
467
  key = task_keys[i]
468
  if isinstance(result, Exception):
469
+ logger.error(f"Failed to fetch batch data for {key}: {result}")
470
+ current_errors[key] = str(result)
 
 
471
  else:
472
+ # Store the raw valueRanges list
473
+ if key == "scammer_dwc_batch":
474
+ raw_scammer_dwc_results = result
475
+ elif key == "values_dupes_batch":
476
+ raw_values_dupes_results = result
477
+
478
+ # --- Process Scammer/DWC Results ---
479
+ if raw_scammer_dwc_results is not None:
480
+ logger.info(f"Processing {len(raw_scammer_dwc_results)} valueRanges from Scammer/DWC sheet...")
481
+ for vr in raw_scammer_dwc_results:
482
+ range_str = vr.get('range', '')
483
+ # Extract sheet name (handle quotes)
484
+ match = re.match(r"^'?([^'!]+)'?!", range_str)
485
+ if not match:
486
+ logger.warning(f"Could not extract sheet name from range '{range_str}' in Scammer/DWC response.")
487
+ continue
488
+ sheet_name = match.group(1).replace("''", "'") # Unescape quotes
489
+
490
+ if sheet_name in scammer_dwc_processor_map:
491
+ processor = scammer_dwc_processor_map[sheet_name]
492
+ target_key = scammer_dwc_target_key_map[sheet_name]
493
+ values = vr.get('values', [])
494
+ try:
495
+ processed_data = processor(values)
496
+ new_cache_data[target_key] = processed_data
497
+ logger.info(f"Processed {len(processed_data)} items for {sheet_name} -> {target_key}")
498
+ except Exception as e:
499
+ logger.error(f"Error processing data for {sheet_name} using {processor.__name__}: {e}", exc_info=True)
500
+ current_errors[f"process_{target_key}"] = str(e)
501
  else:
502
+ logger.warning(f"No processor found for sheet name '{sheet_name}' derived from range '{range_str}' in Scammer/DWC sheet.")
503
+
504
+ # --- Process Values/Dupes Results ---
505
+ if raw_values_dupes_results is not None:
506
+ logger.info(f"Processing {len(raw_values_dupes_results)} valueRanges from Values/Dupes sheet...")
507
+ for vr in raw_values_dupes_results:
508
+ range_str = vr.get('range', '')
509
+ match = re.match(r"^'?([^'!]+)'?!", range_str)
510
+ if not match:
511
+ logger.warning(f"Could not extract sheet name from range '{range_str}' in Values/Dupes response.")
512
+ continue
513
+ sheet_name = match.group(1).replace("''", "'")
514
+
515
+ values = vr.get('values', [])
516
+ try:
517
+ if sheet_name == DUPE_LIST_SHEET:
518
+ processed_data = process_dupe_list_data(values)
519
+ new_cache_data["dupes"] = processed_data
520
+ logger.info(f"Processed {len(processed_data)} items for {DUPE_LIST_SHEET} -> dupes")
521
+ elif sheet_name in CATEGORIES:
522
+ processed_data = process_sheet_data(values)
523
+ fetched_values_categories[sheet_name] = processed_data
524
+ logger.info(f"Processed {len(processed_data)} items for Category: {sheet_name}")
525
+ else:
526
+ logger.warning(f"Unrecognized sheet name '{sheet_name}' derived from range '{range_str}' in Values/Dupes sheet.")
527
+ except Exception as e:
528
+ target_key = "dupes" if sheet_name == DUPE_LIST_SHEET else f"values_{sheet_name}"
529
+ logger.error(f"Error processing data for {sheet_name}: {e}", exc_info=True)
530
+ current_errors[f"process_{target_key}"] = str(e)
531
 
532
  # --- Detect Value Changes ---
533
+ logger.info("Comparing fetched values with cached values...")
534
  current_time = datetime.now(timezone.utc)
535
+ detected_value_changes = {}
536
  fields_to_compare = ['value', 'dupedValue', 'marketValue']
537
 
538
+ if "values" not in cache: cache["values"] = {} # Ensure exists
 
 
539
 
540
+ for category, new_items in fetched_values_categories.items():
541
  old_items_dict = {item['name']: item for item in cache["values"].get(category, [])}
542
  category_changes = []
543
 
544
  for new_item in new_items:
545
+ item_name = new_item.get('name')
546
+ if not item_name or item_name == 'N/A': continue
 
 
 
 
 
 
 
 
547
 
548
+ old_item = old_items_dict.get(item_name)
549
+ if old_item: # Check existing item for changes
550
+ for field in fields_to_compare:
551
+ old_val_str = old_item.get(field, 'N/A')
552
+ new_val_str = new_item.get(field, 'N/A')
553
+ old_norm = parse_cached_currency(old_val_str) if parse_cached_currency(old_val_str) is not None else old_val_str
554
+ new_norm = parse_cached_currency(new_val_str) if parse_cached_currency(new_val_str) is not None else new_val_str
555
 
 
556
  if old_norm != new_norm:
557
+ logger.info(f"Change detected in {category}: {item_name} - {field}: '{old_val_str}' -> '{new_val_str}'")
558
+ category_changes.append({
559
+ "item_name": item_name, "field": field,
560
+ "old_value": old_val_str if old_val_str is not None else "N/A",
561
+ "new_value": new_val_str if new_val_str is not None else "N/A",
562
+ "timestamp": current_time.isoformat()
563
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  if category_changes:
565
  detected_value_changes[category] = category_changes
566
 
567
+ # --- Fetch Roblox Avatars ---
568
+ logger.info("Fetching Roblox avatars...")
569
+ avatar_tasks = []
570
+ # Combine lists needing avatars (only user scammers and DWC have roblox usernames)
571
+ entries_needing_avatars = new_cache_data.get("user_scammers", []) + new_cache_data.get("dwc", [])
572
+ for entry in entries_needing_avatars:
573
+ if entry.get('roblox_username'):
574
+ # Pass the specific entry dict to the update function
575
+ avatar_tasks.append(fetch_avatar_for_entry_update(session, entry))
576
+ if avatar_tasks:
577
+ await asyncio.gather(*avatar_tasks) # Exceptions logged within helper
578
+ logger.info(f"Finished fetching avatars for {len(avatar_tasks)} potential entries.")
579
+
580
 
581
  # --- Final Cache Update ---
582
+ update_occurred = False
583
+ if not current_errors: # Perfect cycle
584
+ logger.info("Updating full cache (no errors during fetch or processing).")
585
+ cache["values"] = fetched_values_categories
 
586
  cache["user_scammers"] = new_cache_data["user_scammers"]
587
  cache["server_scammers"] = new_cache_data["server_scammers"]
588
  cache["dwc"] = new_cache_data["dwc"]
589
  cache["dupes"] = new_cache_data["dupes"]
590
+ cache["value_changes"] = detected_value_changes
591
  cache["last_updated"] = current_time
592
  cache["is_ready"] = True
593
+ update_occurred = True
594
  logger.info(f"Cache update cycle completed successfully.")
595
+ else: # Errors occurred, attempt partial update
596
+ logger.warning(f"Cache update cycle completed with errors: {current_errors}. Attempting partial update.")
597
+ partial_update_details = []
598
+
599
+ # Update values only if the values/dupes batch succeeded AND processing succeeded
600
+ if "values_dupes_batch" not in current_errors and not any(k.startswith("process_values_") for k in current_errors):
601
+ if cache["values"] != fetched_values_categories:
602
+ cache["values"] = fetched_values_categories
603
+ cache["value_changes"] = detected_value_changes # Update changes along with values
604
+ partial_update_details.append("values")
605
+ update_occurred = True
606
+
607
+ # Update dupes only if the values/dupes batch succeeded AND processing succeeded
608
+ if "values_dupes_batch" not in current_errors and "process_dupes" not in current_errors:
609
+ if cache["dupes"] != new_cache_data["dupes"]:
610
+ cache["dupes"] = new_cache_data["dupes"]
611
+ partial_update_details.append("dupes")
612
+ update_occurred = True
613
+
614
+ # Update scammer/DWC sections if their batch succeeded AND processing succeeded
615
+ if "scammer_dwc_batch" not in current_errors:
616
+ for key in ["user_scammers", "server_scammers", "dwc"]:
617
+ process_error_key = f"process_{key}"
618
+ if process_error_key not in current_errors:
619
+ if cache[key] != new_cache_data[key]:
620
+ cache[key] = new_cache_data[key]
621
+ partial_update_details.append(key)
622
+ update_occurred = True
623
+
624
+ if update_occurred:
625
  cache["last_updated"] = current_time # Mark partial update time
626
  cache["is_ready"] = True # Allow access even if partial
627
+ logger.info(f"Partially updated cache sections: {', '.join(partial_update_details)}")
628
  else:
629
+ logger.error(f"Cache update cycle failed, and no parts could be updated based on errors. Errors: {current_errors}")
630
  # Keep cache["is_ready"] as it was.
631
 
 
632
  except Exception as e:
633
  logger.exception(f"Critical error during cache update cycle: {e}")
634
+ if isinstance(e, (aiohttp.ClientError, HttpError, asyncio.TimeoutError)):
635
+ logger.warning("Communication error detected, will re-check service availability next cycle.")
636
 
637
  # --- Wait for the next cycle ---
638
  end_time = datetime.now(timezone.utc)
639
  duration = (end_time - start_time).total_seconds()
640
+ wait_time = max(10, CACHE_UPDATE_INTERVAL_SECONDS - duration)
641
+ logger.info(f"Cache update cycle duration: {duration:.2f}s. Waiting {wait_time:.2f}s for next cycle.")
642
  await asyncio.sleep(wait_time)
643
 
644
+
645
  async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: dict):
646
+ """Fetches avatar and updates the provided entry dictionary IN PLACE."""
647
  roblox_username = entry.get('roblox_username')
648
  if not roblox_username: return
649
 
650
+ current_avatar = entry.get('roblox_avatar_url')
651
+ new_avatar = None # Default to None
652
+
653
  try:
654
  user_id = await get_roblox_user_id(session, roblox_username)
655
  if user_id:
656
+ new_avatar = await get_roblox_avatar_url(session, user_id)
657
+
 
 
658
  except Exception as e:
659
  # Log errors but don't stop the main update loop
660
  logger.warning(f"Failed to fetch avatar for {roblox_username}: {e}")
661
+ # Keep new_avatar as None on error
662
+
663
+ finally:
664
+ # Update the dict only if the value has actually changed
665
+ if current_avatar != new_avatar:
666
+ entry['roblox_avatar_url'] = new_avatar
667
 
668
 
669
  # --- FastAPI Startup Event ---
670
  @app.on_event("startup")
671
  async def startup_event():
672
  """Starts the background cache update task."""
673
+ if not cache["service_available"]:
674
+ logger.warning("Google Sheets service not available at startup. Will attempt re-init in background task.")
675
+ logger.info("Starting background cache update task...")
676
+ asyncio.create_task(update_cache_periodically())
 
677
 
678
 
679
+ # --- API Endpoints (Largely unchanged, rely on cache state) ---
680
 
681
+ def check_cache_readiness():
682
+ """Reusable check for API endpoints - Checks cache readiness"""
 
 
683
  if not cache["is_ready"]:
684
+ raise HTTPException(status_code=503, detail="Cache is initializing or data is currently unavailable. Please try again shortly.")
685
 
686
  @app.get("/")
687
  async def root():
 
689
 
690
  @app.get("/api/status")
691
  async def get_status():
692
+ """Returns the current status of the cache and service availability"""
693
  return {
694
  "cache_ready": cache["is_ready"],
695
  "sheets_service_available": cache["service_available"],
 
700
  "server_scammers": len(cache["server_scammers"]),
701
  "dwc_entries": len(cache["dwc"]),
702
  "duped_usernames": len(cache["dupes"]),
703
+ },
704
+ "value_change_categories": len(cache.get("value_changes", {}))
705
  }
706
 
 
707
  @app.get("/api/values")
708
  async def get_values():
709
  """Get all values data from cache"""
710
+ check_cache_readiness()
711
  return cache["values"]
712
 
713
  @app.get("/api/values/{category}")
714
  async def get_category_values(category: str):
715
  """Get values data for a specific category from cache"""
716
+ check_cache_readiness()
717
+ matched_category = next((c for c in CATEGORIES if c.lower() == category.lower()), None)
718
+ if not matched_category:
719
+ raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
720
+ return {matched_category: cache["values"].get(matched_category, [])}
 
 
721
 
722
  @app.get("/api/value-changes/{category}")
723
  async def get_category_value_changes(category: str):
724
+ """Get detected value changes for a specific category."""
725
+ check_cache_readiness()
726
+ matched_category = next((c for c in CATEGORIES if c.lower() == category.lower()), None)
727
+ if not matched_category:
728
+ raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
729
+ return {matched_category: cache.get("value_changes", {}).get(matched_category, [])}
 
 
730
 
731
  @app.get("/api/value-changes")
732
  async def get_all_value_changes():
733
+ """Get all detected value changes from the last cycle."""
734
+ check_cache_readiness()
735
  return cache.get("value_changes", {})
736
 
737
  @app.get("/api/scammers")
738
  async def get_scammers():
739
  """Get all scammer and DWC data (users, servers, dwc) from cache"""
740
+ check_cache_readiness()
 
741
  return {
742
  "users": cache["user_scammers"],
743
  "servers": cache["server_scammers"],
 
747
  @app.get("/api/dupes")
748
  async def get_dupes():
749
  """Get all duped usernames from cache"""
750
+ check_cache_readiness()
751
+ # Handle case where dupes might be None temporarily during init failure
752
+ return {"usernames": cache.get("dupes") or []}
753
 
754
 
755
  class UsernameCheck(BaseModel):
 
758
  @app.post("/api/check")
759
  async def check_username(data: UsernameCheck):
760
  """Check if a username is duped using cached data and send webhook"""
761
+ check_cache_readiness() # Use the standard readiness check
762
 
763
  username_to_check = data.username.strip().lower()
764
+ is_duped = username_to_check in (cache.get("dupes") or [])
765
 
766
+ # Webhook notification (runs in background)
767
  if not is_duped:
768
  webhook_url = os.getenv("WEBHOOK_URL")
769
  if webhook_url:
770
+ async def send_webhook_notification():
771
  try:
772
  async with aiohttp.ClientSession() as session:
773
  webhook_data = {
 
776
  "title": "New Dupe Check - Not Found",
777
  "description": f"Username `{data.username}` was checked but not found in the dupe database.",
778
  "color": 16776960, # Yellow
779
+ "timestamp": datetime.now(timezone.utc).isoformat()
780
  }]
781
  }
782
  async with session.post(webhook_url, json=webhook_data) as response:
 
784
  logger.warning(f"Failed to send webhook (Status: {response.status}): {await response.text()}")
785
  except Exception as e:
786
  logger.error(f"Error sending webhook: {e}")
 
 
787
  asyncio.create_task(send_webhook_notification())
788
  else:
789
  logger.info("Webhook URL not configured. Skipping notification.")
 
791
  return {"username": data.username, "is_duped": is_duped}
792
 
793
 
 
794
  @app.get("/health")
795
  def health_check():
796
+ """Provides a health status of the API and its cache."""
797
+ if not cache["is_ready"]:
798
+ return {"status": "initializing"}
799
+ if not cache["service_available"]:
800
+ return {"status": "degraded", "reason": "Sheets service connection issue"}
801
+ if cache["last_updated"] and (datetime.now(timezone.utc) - cache["last_updated"]).total_seconds() > CACHE_UPDATE_INTERVAL_SECONDS * 3:
802
+ return {"status": "degraded", "reason": "Cache potentially stale (last update > 3 intervals ago)"}
803
  return {"status": "ok"}
804
 
805
+ # --- END OF FILE main.py ---