Code cleanup: standardized imports, refined formatting for consistency, and resolved minor redundancies in services, models, monitoring, and tools modules.

This commit is contained in:
Yûki VACHOT 2025-11-29 00:49:36 +01:00
parent e486d78e31
commit ec0fe38861
39 changed files with 222 additions and 239 deletions

View file

@ -8,6 +8,6 @@ with automatic API monitoring and dynamic schema adaptation.
__version__ = "0.2.0" __version__ = "0.2.0"
__author__ = "Yûki VACHOT" __author__ = "Yûki VACHOT"
from .main import mcp, main from .main import main, mcp
__all__ = ["mcp", "main", "__version__"] __all__ = ["mcp", "main", "__version__"]

View file

@ -1,7 +1,7 @@
"""API client module for GeoGuessr communication.""" """API client module for GeoGuessr communication."""
from .client import GeoGuessrClient, DynamicResponse from .client import DynamicResponse, GeoGuessrClient
from .endpoints import Endpoints, EndpointInfo, EndpointBuilder from .endpoints import EndpointBuilder, EndpointInfo, Endpoints
__all__ = [ __all__ = [
"GeoGuessrClient", "GeoGuessrClient",
@ -9,4 +9,4 @@ __all__ = [
"Endpoints", "Endpoints",
"EndpointInfo", "EndpointInfo",
"EndpointBuilder", "EndpointBuilder",
] ]

View file

@ -96,6 +96,7 @@ class DynamicResponse:
This reduces token usage while providing essential information. This reduces token usage while providing essential information.
""" """
def summarize_value(value: Any, depth: int) -> Any: def summarize_value(value: Any, depth: int) -> Any:
if depth <= 0: if depth <= 0:
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
@ -103,10 +104,7 @@ class DynamicResponse:
return value return value
if isinstance(value, dict): if isinstance(value, dict):
return { return {k: summarize_value(v, depth - 1) for k, v in list(value.items())[:10]}
k: summarize_value(v, depth - 1)
for k, v in list(value.items())[:10]
}
if isinstance(value, list): if isinstance(value, list):
if len(value) == 0: if len(value) == 0:
return [] return []
@ -163,11 +161,7 @@ class GeoGuessrClient:
@staticmethod @staticmethod
def _get_base_url(endpoint: EndpointInfo) -> str: def _get_base_url(endpoint: EndpointInfo) -> str:
"""Get the appropriate base URL for an endpoint.""" """Get the appropriate base URL for an endpoint."""
return ( return settings.GAME_SERVER_URL if endpoint.use_game_server else settings.GEOGUESSR_API_URL
settings.GAME_SERVER_URL
if endpoint.use_game_server
else settings.GEOGUESSR_API_URL
)
async def request( async def request(
self, self,
@ -199,6 +193,7 @@ class GeoGuessrClient:
logger.debug(f"{endpoint.method} {url}") logger.debug(f"{endpoint.method} {url}")
import time import time
start_time = time.time() start_time = time.time()
async with await self._get_authenticated_client(session_token) as client: async with await self._get_authenticated_client(session_token) as client:

View file

@ -13,6 +13,7 @@ from ..config import settings
@dataclass @dataclass
class EndpointInfo: class EndpointInfo:
"""Metadata about an API endpoint.""" """Metadata about an API endpoint."""
path: str path: str
method: str = "GET" method: str = "GET"
description: str = "" description: str = ""
@ -31,6 +32,7 @@ class Endpoints:
class AUTH: class AUTH:
"""Authentication endpoints.""" """Authentication endpoints."""
SIGNIN = EndpointInfo( SIGNIN = EndpointInfo(
path="/v3/accounts/signin", path="/v3/accounts/signin",
method="POST", method="POST",
@ -40,6 +42,7 @@ class Endpoints:
class PROFILES: class PROFILES:
"""User profile and stats endpoints.""" """User profile and stats endpoints."""
GET_PROFILE = EndpointInfo( GET_PROFILE = EndpointInfo(
path="/v3/profiles", path="/v3/profiles",
description="Get current user profile", description="Get current user profile",
@ -79,6 +82,7 @@ class Endpoints:
class GAMES: class GAMES:
"""Game-related endpoints.""" """Game-related endpoints."""
GET_UNFINISHED_GAMES = EndpointInfo( GET_UNFINISHED_GAMES = EndpointInfo(
path="/v3/social/events/unfinishedgames", path="/v3/social/events/unfinishedgames",
description="Get unfinished games", description="Get unfinished games",
@ -102,6 +106,7 @@ class Endpoints:
class GAME_SERVER: class GAME_SERVER:
"""Game server endpoints (different base URL).""" """Game server endpoints (different base URL)."""
GET_TOURNAMENTS = EndpointInfo( GET_TOURNAMENTS = EndpointInfo(
path="/tournaments", path="/tournaments",
use_game_server=True, use_game_server=True,
@ -137,6 +142,7 @@ class Endpoints:
class COMPETITIVE: class COMPETITIVE:
"""Competitive and season-related endpoints.""" """Competitive and season-related endpoints."""
GET_ACTIVE_SEASON_STATS = EndpointInfo( GET_ACTIVE_SEASON_STATS = EndpointInfo(
path="/v4/seasons/active/stats", path="/v4/seasons/active/stats",
description="Get active season statistics", description="Get active season statistics",
@ -171,6 +177,7 @@ class Endpoints:
class SOCIAL: class SOCIAL:
"""Social and friends endpoints.""" """Social and friends endpoints."""
GET_FRIENDS_SUMMARY = EndpointInfo( GET_FRIENDS_SUMMARY = EndpointInfo(
path="/v3/social/friends/summary", path="/v3/social/friends/summary",
description="Get friends summary", description="Get friends summary",
@ -194,9 +201,7 @@ class Endpoints:
) )
@staticmethod @staticmethod
def get_friends_activities( def get_friends_activities(time_frame: str = "week", limit: int = 20) -> EndpointInfo:
time_frame: str = "week", limit: int = 20
) -> EndpointInfo:
"""Get friends' activities.""" """Get friends' activities."""
return EndpointInfo( return EndpointInfo(
path="/v3/social/friends/activities", path="/v3/social/friends/activities",
@ -206,6 +211,7 @@ class Endpoints:
class MAPS: class MAPS:
"""Map-related endpoints.""" """Map-related endpoints."""
GET_PERSONALIZED_MAPS = EndpointInfo( GET_PERSONALIZED_MAPS = EndpointInfo(
path="/v3/social/maps/browse/personalized", path="/v3/social/maps/browse/personalized",
description="Get personalized maps", description="Get personalized maps",
@ -243,6 +249,7 @@ class Endpoints:
class EXPLORER: class EXPLORER:
"""Explorer mode endpoints.""" """Explorer mode endpoints."""
GET_PROGRESS = EndpointInfo( GET_PROGRESS = EndpointInfo(
path="/v3/explorer", path="/v3/explorer",
description="Get explorer mode progress", description="Get explorer mode progress",
@ -250,6 +257,7 @@ class Endpoints:
class OBJECTIVES: class OBJECTIVES:
"""Objectives and rewards endpoints.""" """Objectives and rewards endpoints."""
GET_OBJECTIVES = EndpointInfo( GET_OBJECTIVES = EndpointInfo(
path="/v4/objectives", path="/v4/objectives",
description="Get current objectives", description="Get current objectives",
@ -261,6 +269,7 @@ class Endpoints:
class SUBSCRIPTION: class SUBSCRIPTION:
"""Subscription-related endpoints.""" """Subscription-related endpoints."""
GET_INFO = EndpointInfo( GET_INFO = EndpointInfo(
path="/v3/subscriptions", path="/v3/subscriptions",
description="Get subscription details", description="Get subscription details",
@ -281,11 +290,7 @@ class EndpointBuilder:
Returns: Returns:
Complete URL string Complete URL string
""" """
base = ( base = settings.GAME_SERVER_URL if endpoint.use_game_server else settings.GEOGUESSR_API_URL
settings.GAME_SERVER_URL
if endpoint.use_game_server
else settings.GEOGUESSR_API_URL
)
return f"{base}{endpoint.path}" return f"{base}{endpoint.path}"
@staticmethod @staticmethod

View file

@ -1,7 +1,8 @@
"""Auth module for GeoGuessr session.""" """Auth module for GeoGuessr session."""
from .session import UserSession, SessionManager from .session import SessionManager, UserSession
__all__ = [ __all__ = [
"UserSession", "UserSession",
"SessionManager", "SessionManager",
] ]

View file

@ -213,9 +213,7 @@ class SessionManager:
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
client.cookies.set("_ncfa", cookie, domain=settings.GEOGUESSR_DOMAIN_NAME) client.cookies.set("_ncfa", cookie, domain=settings.GEOGUESSR_DOMAIN_NAME)
response = await client.get( response = await client.get(f"{settings.GEOGUESSR_API_URL}/v3/profiles")
f"{settings.GEOGUESSR_API_URL}/v3/profiles"
)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
except Exception as e: except Exception as e:

View file

@ -84,8 +84,7 @@ def main():
logger.info("Default authentication cookie configured from environment") logger.info("Default authentication cookie configured from environment")
else: else:
logger.warning( logger.warning(
"No default authentication cookie set. " "No default authentication cookie set. " "Users will need to login or provide a cookie."
"Users will need to login or provide a cookie."
) )
# Run the server # Run the server

View file

@ -7,6 +7,7 @@ from typing import Optional
@dataclass @dataclass
class Achievement: class Achievement:
"""Represents a user achievement.""" """Represents a user achievement."""
id: str id: str
name: str name: str
description: str = "" description: str = ""

View file

@ -7,6 +7,7 @@ from typing import Optional
@dataclass @dataclass
class DailyChallenge: class DailyChallenge:
"""Daily challenge information.""" """Daily challenge information."""
token: str token: str
map_name: str = "" map_name: str = ""
date: str = "" date: str = ""
@ -20,7 +21,9 @@ class DailyChallenge:
"""Create DailyChallenge from API response.""" """Create DailyChallenge from API response."""
return cls( return cls(
token=data.get("token", data.get("challengeToken", "")), token=data.get("token", data.get("challengeToken", "")),
map_name=data.get("map", {}).get("name", "") if isinstance(data.get("map"), dict) else "", map_name=(
data.get("map", {}).get("name", "") if isinstance(data.get("map"), dict) else ""
),
date=data.get("date", data.get("day", "")), date=data.get("date", data.get("day", "")),
time_limit=data.get("timeLimit", 0), time_limit=data.get("timeLimit", 0),
completed=data.get("completed", data.get("played", False)), completed=data.get("completed", data.get("played", False)),

View file

@ -9,6 +9,7 @@ from .RoundGuess import RoundGuess
@dataclass @dataclass
class Game: class Game:
"""Represents a complete game.""" """Represents a complete game."""
token: str token: str
map_name: str map_name: str
mode: str mode: str

View file

@ -7,6 +7,7 @@ from typing import Optional
@dataclass @dataclass
class RoundGuess: class RoundGuess:
"""Represents a single round guess in a game.""" """Represents a single round guess in a game."""
round_number: int round_number: int
score: int score: int
distance_meters: float distance_meters: float

View file

@ -6,6 +6,7 @@ from dataclasses import dataclass, field
@dataclass @dataclass
class SeasonStats: class SeasonStats:
"""Competitive season statistics.""" """Competitive season statistics."""
season_id: str season_id: str
season_name: str = "" season_name: str = ""
rank: int = 0 rank: int = 0

View file

@ -7,6 +7,7 @@ from typing import Optional
@dataclass @dataclass
class UserProfile: class UserProfile:
"""User profile information.""" """User profile information."""
id: str id: str
nick: str nick: str
email: str = "" email: str = ""
@ -30,7 +31,9 @@ class UserProfile:
created=data.get("created", data.get("createdAt", "")), created=data.get("created", data.get("createdAt", "")),
is_verified=data.get("isVerified", data.get("verified", False)), is_verified=data.get("isVerified", data.get("verified", False)),
is_pro=data.get("isPro", data.get("isProUser", False)), is_pro=data.get("isPro", data.get("isProUser", False)),
avatar_url=data.get("pin", {}).get("url") if isinstance(data.get("pin"), dict) else None, avatar_url=(
data.get("pin", {}).get("url") if isinstance(data.get("pin"), dict) else None
),
raw_data=data, raw_data=data,
) )

View file

@ -6,6 +6,7 @@ from dataclasses import dataclass, field
@dataclass @dataclass
class UserStats: class UserStats:
"""User statistics from various endpoints.""" """User statistics from various endpoints."""
games_played: int = 0 games_played: int = 0
rounds_played: int = 0 rounds_played: int = 0
total_score: int = 0 total_score: int = 0

View file

@ -1,12 +1,13 @@
"""Data models for GeoGuessr.""" """Data models for GeoGuessr."""
from .UserProfile import UserProfile
from .UserStats import UserStats
from .RoundGuess import RoundGuess
from Game import Game
from Achievement import Achievement from Achievement import Achievement
from SeasonStats import SeasonStats
from DailyChallenge import DailyChallenge from DailyChallenge import DailyChallenge
from Game import Game
from SeasonStats import SeasonStats
from .RoundGuess import RoundGuess
from .UserProfile import UserProfile
from .UserStats import UserStats
__all__ = [ __all__ = [
"UserProfile", "UserProfile",
@ -16,4 +17,4 @@ __all__ = [
"Achievement", "Achievement",
"SeasonStats", "SeasonStats",
"DailyChallenge", "DailyChallenge",
] ]

View file

@ -1,9 +1,11 @@
"""Monitoring module for API endpoint tracking and schema detection.""" """Monitoring module for API endpoint tracking and schema detection."""
from .endpoint.EndpointMonitor import EndpointMonitor, endpoint_monitor, MONITORED_ENDPOINTS
from schema.EndpointSchema import EndpointSchema from schema.EndpointSchema import EndpointSchema
from schema.SchemaRegistry import SchemaRegistry, schema_registry
from schema.SchemaDetector import SchemaDetector, SchemaField from schema.SchemaDetector import SchemaDetector, SchemaField
from schema.SchemaRegistry import SchemaRegistry, schema_registry
from .endpoint.EndpointMonitor import (MONITORED_ENDPOINTS, EndpointMonitor,
endpoint_monitor)
__all__ = [ __all__ = [
"EndpointMonitor", "EndpointMonitor",
@ -14,4 +16,4 @@ __all__ = [
"EndpointSchema", "EndpointSchema",
"SchemaField", "SchemaField",
"schema_registry", "schema_registry",
] ]

View file

@ -8,9 +8,11 @@ requirement, and additional parameters.
from dataclasses import dataclass, field from dataclasses import dataclass, field
@dataclass @dataclass
class EndpointDefinition: class EndpointDefinition:
"""Definition of an API endpoint to monitor.""" """Definition of an API endpoint to monitor."""
path: str path: str
method: str = "GET" method: str = "GET"
requires_auth: bool = True requires_auth: bool = True

View file

@ -13,15 +13,15 @@ Classes:
import asyncio import asyncio
import logging import logging
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Optional from typing import Optional
import httpx import httpx
from ...config import settings from ...config import settings
from ..schema.SchemaRegistry import SchemaRegistry, schema_registry
from .EndpointDefinition import EndpointDefinition from .EndpointDefinition import EndpointDefinition
from .EndpointMonitoringResult import MonitoringResult from .EndpointMonitoringResult import MonitoringResult
from ..schema.SchemaRegistry import SchemaRegistry, schema_registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -48,13 +48,11 @@ MONITORED_ENDPOINTS = [
path="/v3/profiles/maps", path="/v3/profiles/maps",
description="User's custom maps", description="User's custom maps",
), ),
# Game endpoints # Game endpoints
EndpointDefinition( EndpointDefinition(
path="/v3/social/events/unfinishedgames", path="/v3/social/events/unfinishedgames",
description="Unfinished games", description="Unfinished games",
), ),
# Social endpoints # Social endpoints
EndpointDefinition( EndpointDefinition(
path="/v4/feed/private", path="/v4/feed/private",
@ -73,19 +71,16 @@ MONITORED_ENDPOINTS = [
path="/v3/social/maps/browse/personalized", path="/v3/social/maps/browse/personalized",
description="Personalized map recommendations", description="Personalized map recommendations",
), ),
# Competitive endpoints # Competitive endpoints
EndpointDefinition( EndpointDefinition(
path="/v4/seasons/active/stats", path="/v4/seasons/active/stats",
description="Active season statistics", description="Active season statistics",
), ),
# Explorer endpoints # Explorer endpoints
EndpointDefinition( EndpointDefinition(
path="/v3/explorer", path="/v3/explorer",
description="Explorer mode progress", description="Explorer mode progress",
), ),
# Objectives endpoints # Objectives endpoints
EndpointDefinition( EndpointDefinition(
path="/v4/objectives", path="/v4/objectives",
@ -95,19 +90,16 @@ MONITORED_ENDPOINTS = [
path="/v4/objectives/unclaimed", path="/v4/objectives/unclaimed",
description="Unclaimed objective rewards", description="Unclaimed objective rewards",
), ),
# Subscription endpoints # Subscription endpoints
EndpointDefinition( EndpointDefinition(
path="/v3/subscriptions", path="/v3/subscriptions",
description="Subscription information", description="Subscription information",
), ),
# Challenge endpoints # Challenge endpoints
EndpointDefinition( EndpointDefinition(
path="/v3/challenges/daily-challenges/today", path="/v3/challenges/daily-challenges/today",
description="Today's daily challenge", description="Today's daily challenge",
), ),
# Game server endpoints # Game server endpoints
EndpointDefinition( EndpointDefinition(
path="/tournaments", path="/tournaments",
@ -116,6 +108,7 @@ MONITORED_ENDPOINTS = [
), ),
] ]
class EndpointMonitor: class EndpointMonitor:
""" """
Monitors API endpoints for availability and schema changes. Monitors API endpoints for availability and schema changes.
@ -125,9 +118,9 @@ class EndpointMonitor:
""" """
def __init__( def __init__(
self, self,
registry: Optional[SchemaRegistry] = None, registry: Optional[SchemaRegistry] = None,
ncfa_cookie: Optional[str] = None, ncfa_cookie: Optional[str] = None,
): ):
self.registry = registry or schema_registry self.registry = registry or schema_registry
self.ncfa_cookie = ncfa_cookie or settings.DEFAULT_NCFA_COOKIE self.ncfa_cookie = ncfa_cookie or settings.DEFAULT_NCFA_COOKIE
@ -136,9 +129,9 @@ class EndpointMonitor:
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
async def check_endpoint( async def check_endpoint(
self, self,
endpoint: EndpointDefinition, endpoint: EndpointDefinition,
client: httpx.AsyncClient, client: httpx.AsyncClient,
) -> MonitoringResult: ) -> MonitoringResult:
""" """
Check a single endpoint and update its schema. Check a single endpoint and update its schema.
@ -151,9 +144,7 @@ class EndpointMonitor:
MonitoringResult with check details MonitoringResult with check details
""" """
base_url = ( base_url = (
settings.GAME_SERVER_URL settings.GAME_SERVER_URL if endpoint.use_game_server else settings.GEOGUESSR_API_URL
if endpoint.use_game_server
else settings.GEOGUESSR_API_URL
) )
url = f"{base_url}{endpoint.path}" url = f"{base_url}{endpoint.path}"
@ -264,14 +255,16 @@ class EndpointMonitor:
except Exception as e: except Exception as e:
logger.error(f"Error checking {endpoint.path}: {e}") logger.error(f"Error checking {endpoint.path}: {e}")
results.append(MonitoringResult( results.append(
endpoint=endpoint.path, MonitoringResult(
is_available=False, endpoint=endpoint.path,
response_code=0, is_available=False,
response_time_ms=0, response_code=0,
schema_changed=False, response_time_ms=0,
error_message=str(e), schema_changed=False,
)) error_message=str(e),
)
)
self.results = results self.results = results
return results return results
@ -334,8 +327,7 @@ class EndpointMonitor:
changed = [r for r in self.results if r.schema_changed] changed = [r for r in self.results if r.schema_changed]
avg_response_time = ( avg_response_time = (
sum(r.response_time_ms for r in available) / len(available) sum(r.response_time_ms for r in available) / len(available) if available else 0
if available else 0
) )
return { return {

View file

@ -7,13 +7,14 @@ an endpoint, including its availability, response time, and any errors encounter
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Optional from typing import Optional
@dataclass @dataclass
class MonitoringResult: class MonitoringResult:
"""Result of monitoring an endpoint.""" """Result of monitoring an endpoint."""
endpoint: str endpoint: str
is_available: bool is_available: bool
response_code: int response_code: int

View file

@ -10,7 +10,7 @@ schema information.
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Any, Optional from typing import Any, Optional
from .SchemaField import SchemaField from .SchemaField import SchemaField
@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class EndpointSchema: class EndpointSchema:
"""Schema definition for an API endpoint.""" """Schema definition for an API endpoint."""
endpoint: str endpoint: str
method: str method: str
fields: dict[str, SchemaField] = field(default_factory=dict) fields: dict[str, SchemaField] = field(default_factory=dict)

View file

@ -60,7 +60,8 @@ class SchemaDetector:
def _is_uuid(value: str) -> bool: def _is_uuid(value: str) -> bool:
"""Check if string is UUID format.""" """Check if string is UUID format."""
import re import re
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
return bool(re.match(uuid_pattern, value.lower())) return bool(re.match(uuid_pattern, value.lower()))
@staticmethod @staticmethod
@ -86,13 +87,7 @@ class SchemaDetector:
self._analyze_object(data, fields, "", max_depth) self._analyze_object(data, fields, "", max_depth)
return fields return fields
def _analyze_object( def _analyze_object(self, obj: dict, fields: dict, prefix: str, remaining_depth: int) -> None:
self,
obj: dict,
fields: dict,
prefix: str,
remaining_depth: int
) -> None:
"""Recursively analyze an object and extract field information.""" """Recursively analyze an object and extract field information."""
if remaining_depth <= 0: if remaining_depth <= 0:
return return
@ -121,7 +116,6 @@ class SchemaDetector:
def compute_schema_hash(fields: dict[str, SchemaField]) -> str: def compute_schema_hash(fields: dict[str, SchemaField]) -> str:
"""Compute a hash of the schema for change detection.""" """Compute a hash of the schema for change detection."""
schema_repr = json.dumps( schema_repr = json.dumps(
{name: (f.field_type, f.nullable) for name, f in sorted(fields.items())}, {name: (f.field_type, f.nullable) for name, f in sorted(fields.items())}, sort_keys=True
sort_keys=True
) )
return hashlib.sha256(schema_repr.encode()).hexdigest()[:16] return hashlib.sha256(schema_repr.encode()).hexdigest()[:16]

View file

@ -12,6 +12,7 @@ from typing import Any, Optional
@dataclass @dataclass
class SchemaField: class SchemaField:
"""Represents a single field in a schema.""" """Represents a single field in a schema."""
name: str name: str
field_type: str field_type: str
nullable: bool = False nullable: bool = False

View file

@ -12,11 +12,10 @@ Classes:
import json import json
import logging import logging
from datetime import datetime, UTC from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from ...config import settings from ...config import settings
from .EndpointSchema import EndpointSchema from .EndpointSchema import EndpointSchema
from .SchemaDetector import SchemaDetector from .SchemaDetector import SchemaDetector
@ -78,9 +77,7 @@ class SchemaRegistry:
try: try:
with open(self._get_schema_file(), "w") as f: with open(self._get_schema_file(), "w") as f:
json.dump( json.dump(
{ep: schema.to_dict() for ep, schema in self.schemas.items()}, {ep: schema.to_dict() for ep, schema in self.schemas.items()}, f, indent=2
f,
indent=2
) )
with open(self._get_history_file(), "w") as f: with open(self._get_history_file(), "w") as f:
@ -90,17 +87,13 @@ class SchemaRegistry:
for ep, history in self.schema_history.items() for ep, history in self.schema_history.items()
}, },
f, f,
indent=2 indent=2,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to save schemas: {e}") logger.error(f"Failed to save schemas: {e}")
def update_schema( def update_schema(
self, self, endpoint: str, response_data: Any, response_code: int = 200, method: str = "GET"
endpoint: str,
response_data: Any,
response_code: int = 200,
method: str = "GET"
) -> tuple[EndpointSchema, bool]: ) -> tuple[EndpointSchema, bool]:
""" """
Update schema for an endpoint based on response data. Update schema for an endpoint based on response data.
@ -143,12 +136,7 @@ class SchemaRegistry:
return new_schema, schema_changed return new_schema, schema_changed
def mark_unavailable( def mark_unavailable(self, endpoint: str, error_message: str, response_code: int = 0) -> None:
self,
endpoint: str,
error_message: str,
response_code: int = 0
) -> None:
"""Mark an endpoint as unavailable.""" """Mark an endpoint as unavailable."""
if endpoint in self.schemas: if endpoint in self.schemas:
self.schemas[endpoint].is_available = False self.schemas[endpoint].is_available = False
@ -191,7 +179,7 @@ class SchemaRegistry:
"response_code": schema.response_code, "response_code": schema.response_code,
} }
for endpoint, schema in self.schemas.items() for endpoint, schema in self.schemas.items()
} },
} }
def generate_dynamic_description(self, endpoint: str) -> str: def generate_dynamic_description(self, endpoint: str) -> str:
@ -228,14 +216,10 @@ class SchemaRegistry:
"""Truncate sample response for storage.""" """Truncate sample response for storage."""
if isinstance(data, dict): if isinstance(data, dict):
return { return {
k: SchemaRegistry._truncate_sample(v, max_items) k: SchemaRegistry._truncate_sample(v, max_items) for k, v in list(data.items())[:20]
for k, v in list(data.items())[:20]
} }
if isinstance(data, list): if isinstance(data, list):
return [ return [SchemaRegistry._truncate_sample(item, max_items) for item in data[:max_items]]
SchemaRegistry._truncate_sample(item, max_items)
for item in data[:max_items]
]
if isinstance(data, str) and len(data) > 200: if isinstance(data, str) and len(data) > 200:
return data[:200] + "..." return data[:200] + "..."
return data return data

View file

@ -1,12 +1,12 @@
"""Services module for business logic.""" """Services module for business logic."""
from .profile_service import ProfileService
from .game_service import GameService
from .analysis_service import AnalysisService, GameAnalysis from .analysis_service import AnalysisService, GameAnalysis
from .game_service import GameService
from .profile_service import ProfileService
__all__ = [ __all__ = [
"ProfileService", "ProfileService",
"GameService", "GameService",
"AnalysisService", "AnalysisService",
"GameAnalysis", "GameAnalysis",
] ]

View file

@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class GameAnalysis: class GameAnalysis:
"""Analysis results for a set of games.""" """Analysis results for a set of games."""
games_analyzed: int = 0 games_analyzed: int = 0
total_score: int = 0 total_score: int = 0
average_score: float = 0.0 average_score: float = 0.0
@ -88,15 +89,9 @@ class AnalysisService:
# Calculate averages # Calculate averages
avg_distance = ( avg_distance = (
sum(r.distance_meters for r in all_rounds) / total_rounds sum(r.distance_meters for r in all_rounds) / total_rounds if total_rounds > 0 else 0
if total_rounds > 0
else 0
)
avg_time = (
sum(r.time_seconds for r in all_rounds) / total_rounds
if total_rounds > 0
else 0
) )
avg_time = sum(r.time_seconds for r in all_rounds) / total_rounds if total_rounds > 0 else 0
# Find best and worst # Find best and worst
scores = [g.total_score for g in games] scores = [g.total_score for g in games]
@ -106,9 +101,7 @@ class AnalysisService:
# Determine trend (simple moving average comparison) # Determine trend (simple moving average comparison)
trend = "stable" trend = "stable"
if len(games) >= 4: if len(games) >= 4:
first_half = sum(g.total_score for g in games[: len(games) // 2]) / ( first_half = sum(g.total_score for g in games[: len(games) // 2]) / (len(games) // 2)
len(games) // 2
)
second_half = sum(g.total_score for g in games[len(games) // 2 :]) / ( second_half = sum(g.total_score for g in games[len(games) // 2 :]) / (
len(games) - len(games) // 2 len(games) - len(games) // 2
) )
@ -124,18 +117,22 @@ class AnalysisService:
for game in games: for game in games:
for round_guess in game.rounds: for round_guess in game.rounds:
if round_guess.score < 2000: if round_guess.score < 2000:
weak_areas.append({ weak_areas.append(
"game": game.token, {
"round": round_guess.round_number, "game": game.token,
"score": round_guess.score, "round": round_guess.round_number,
"distance": round_guess.distance_meters, "score": round_guess.score,
}) "distance": round_guess.distance_meters,
}
)
elif round_guess.score >= 4500: elif round_guess.score >= 4500:
strong_areas.append({ strong_areas.append(
"game": game.token, {
"round": round_guess.round_number, "game": game.token,
"score": round_guess.score, "round": round_guess.round_number,
}) "score": round_guess.score,
}
)
return GameAnalysis( return GameAnalysis(
games_analyzed=len(games), games_analyzed=len(games),
@ -204,9 +201,7 @@ class AnalysisService:
# Get comprehensive profile # Get comprehensive profile
try: try:
results["profile"] = await self.profile_service.get_comprehensive_profile( results["profile"] = await self.profile_service.get_comprehensive_profile(session_token)
session_token
)
except Exception as e: except Exception as e:
results["errors"].append(f"Profile: {str(e)}") results["errors"].append(f"Profile: {str(e)}")
@ -227,17 +222,13 @@ class AnalysisService:
# Analyze recent games # Analyze recent games
try: try:
results["recent_games_analysis"] = await self.analyze_recent_games( results["recent_games_analysis"] = await self.analyze_recent_games(5, session_token)
5, session_token
)
except Exception as e: except Exception as e:
results["errors"].append(f"Recent games: {str(e)}") results["errors"].append(f"Recent games: {str(e)}")
# Get explorer progress # Get explorer progress
try: try:
response = await self.client.get( response = await self.client.get(self._create_endpoint("/v3/explorer"), session_token)
self._create_endpoint("/v3/explorer"), session_token
)
if response.is_success: if response.is_success:
results["explorer"] = response.summarize() results["explorer"] = response.summarize()
except Exception as e: except Exception as e:
@ -245,9 +236,7 @@ class AnalysisService:
# Get objectives # Get objectives
try: try:
response = await self.client.get( response = await self.client.get(self._create_endpoint("/v4/objectives"), session_token)
self._create_endpoint("/v4/objectives"), session_token
)
if response.is_success: if response.is_success:
results["objectives"] = response.summarize() results["objectives"] = response.summarize()
except Exception as e: except Exception as e:
@ -273,42 +262,50 @@ class AnalysisService:
# Analyze perfect round rate # Analyze perfect round rate
if analysis.perfect_round_percentage < 20: if analysis.perfect_round_percentage < 20:
recommendations.append({ recommendations.append(
"category": "accuracy", {
"priority": "high", "category": "accuracy",
"recommendation": "Focus on improving pinpoint accuracy", "priority": "high",
"detail": f"Your perfect round rate is {analysis.perfect_round_percentage:.1f}%. " "recommendation": "Focus on improving pinpoint accuracy",
"Practice with familiar maps to build confidence.", "detail": f"Your perfect round rate is {analysis.perfect_round_percentage:.1f}%. "
}) "Practice with familiar maps to build confidence.",
}
)
# Analyze time usage # Analyze time usage
if analysis.average_time_seconds < 30: if analysis.average_time_seconds < 30:
recommendations.append({ recommendations.append(
"category": "time_management", {
"priority": "medium", "category": "time_management",
"recommendation": "Consider taking more time per round", "priority": "medium",
"detail": f"Average time: {analysis.average_time_seconds:.0f}s. " "recommendation": "Consider taking more time per round",
"Taking a bit more time can improve accuracy.", "detail": f"Average time: {analysis.average_time_seconds:.0f}s. "
}) "Taking a bit more time can improve accuracy.",
}
)
# Analyze score trend # Analyze score trend
if analysis.score_trend == "declining": if analysis.score_trend == "declining":
recommendations.append({ recommendations.append(
"category": "consistency", {
"priority": "high", "category": "consistency",
"recommendation": "Your scores are trending downward", "priority": "high",
"detail": "Consider taking breaks and reviewing your weak areas.", "recommendation": "Your scores are trending downward",
}) "detail": "Consider taking breaks and reviewing your weak areas.",
}
)
# Check for weak areas pattern # Check for weak areas pattern
if len(analysis.weak_areas) > 5: if len(analysis.weak_areas) > 5:
recommendations.append({ recommendations.append(
"category": "practice", {
"priority": "medium", "category": "practice",
"recommendation": "Practice specific regions", "priority": "medium",
"detail": f"You had {len(analysis.weak_areas)} rounds under 2000 points. " "recommendation": "Practice specific regions",
"Consider using region-specific practice maps.", "detail": f"You had {len(analysis.weak_areas)} rounds under 2000 points. "
}) "Consider using region-specific practice maps.",
}
)
return { return {
"analysis_summary": { "analysis_summary": {
@ -331,4 +328,5 @@ class AnalysisService:
def _create_endpoint(path: str): def _create_endpoint(path: str):
"""Create simple endpoint info for raw requests.""" """Create simple endpoint info for raw requests."""
from ..api.endpoints import EndpointInfo from ..api.endpoints import EndpointInfo
return EndpointInfo(path=path, description=f"Request to {path}") return EndpointInfo(path=path, description=f"Request to {path}")

View file

@ -7,11 +7,11 @@ Handles game history, details, and competitive data with dynamic schema support.
import logging import logging
from typing import Optional from typing import Optional
from ..api.client import GeoGuessrClient, DynamicResponse from ..api.client import DynamicResponse, GeoGuessrClient
from ..api.endpoints import Endpoints from ..api.endpoints import Endpoints
from ..models.DailyChallenge import DailyChallenge
from ..models.Game import Game from ..models.Game import Game
from ..models.SeasonStats import SeasonStats from ..models.SeasonStats import SeasonStats
from ..models.DailyChallenge import DailyChallenge
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,9 +23,9 @@ class GameService:
self.client = client self.client = client
async def get_game_details( async def get_game_details(
self, self,
game_token: str, game_token: str,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> tuple[Game, DynamicResponse]: ) -> tuple[Game, DynamicResponse]:
""" """
Get details for a specific game. Get details for a specific game.
@ -47,26 +47,26 @@ class GameService:
raise ValueError(f"Failed to get game details: {response.data}") raise ValueError(f"Failed to get game details: {response.data}")
async def get_unfinished_games( async def get_unfinished_games(
self, self,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
"""Get list of unfinished games.""" """Get list of unfinished games."""
return await self.client.get(Endpoints.GAMES.GET_UNFINISHED_GAMES, session_token) return await self.client.get(Endpoints.GAMES.GET_UNFINISHED_GAMES, session_token)
async def get_streak_game( async def get_streak_game(
self, self,
game_token: str, game_token: str,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
"""Get streak game details.""" """Get streak game details."""
endpoint = Endpoints.GAMES.get_streak_game(game_token) endpoint = Endpoints.GAMES.get_streak_game(game_token)
return await self.client.get(endpoint, session_token) return await self.client.get(endpoint, session_token)
async def get_activity_feed( async def get_activity_feed(
self, self,
count: int = 10, count: int = 10,
page: int = 0, page: int = 0,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
""" """
Get the activity feed. Get the activity feed.
@ -83,9 +83,9 @@ class GameService:
return await self.client.get(endpoint, session_token) return await self.client.get(endpoint, session_token)
async def get_recent_games( async def get_recent_games(
self, self,
count: int = 10, count: int = 10,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> list[Game]: ) -> list[Game]:
""" """
Get recent games from the activity feed. Get recent games from the activity feed.
@ -124,8 +124,8 @@ class GameService:
return games return games
async def get_season_stats( async def get_season_stats(
self, self,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> tuple[SeasonStats, DynamicResponse]: ) -> tuple[SeasonStats, DynamicResponse]:
"""Get active season statistics.""" """Get active season statistics."""
response = await self.client.get( response = await self.client.get(
@ -139,9 +139,9 @@ class GameService:
raise ValueError(f"Failed to get season stats: {response.data}") raise ValueError(f"Failed to get season stats: {response.data}")
async def get_daily_challenge( async def get_daily_challenge(
self, self,
day: str = "today", day: str = "today",
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> tuple[DailyChallenge, DynamicResponse]: ) -> tuple[DailyChallenge, DynamicResponse]:
""" """
Get daily challenge. Get daily challenge.
@ -163,26 +163,26 @@ class GameService:
raise ValueError(f"Failed to get daily challenge: {response.data}") raise ValueError(f"Failed to get daily challenge: {response.data}")
async def get_battle_royale( async def get_battle_royale(
self, self,
game_id: str, game_id: str,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
"""Get battle royale game details.""" """Get battle royale game details."""
endpoint = Endpoints.GAME_SERVER.get_battle_royale(game_id) endpoint = Endpoints.GAME_SERVER.get_battle_royale(game_id)
return await self.client.get(endpoint, session_token) return await self.client.get(endpoint, session_token)
async def get_duel( async def get_duel(
self, self,
duel_id: str, duel_id: str,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
"""Get duel game details.""" """Get duel game details."""
endpoint = Endpoints.GAME_SERVER.get_duel(duel_id) endpoint = Endpoints.GAME_SERVER.get_duel(duel_id)
return await self.client.get(endpoint, session_token) return await self.client.get(endpoint, session_token)
async def get_tournaments( async def get_tournaments(
self, self,
session_token: Optional[str] = None, session_token: Optional[str] = None,
) -> DynamicResponse: ) -> DynamicResponse:
"""Get tournament information.""" """Get tournament information."""
return await self.client.get(Endpoints.GAME_SERVER.GET_TOURNAMENTS, session_token) return await self.client.get(Endpoints.GAME_SERVER.GET_TOURNAMENTS, session_token)

View file

@ -8,7 +8,7 @@ dynamic schema adaptation.
import logging import logging
from typing import Optional from typing import Optional
from ..api.client import GeoGuessrClient, DynamicResponse from ..api.client import DynamicResponse, GeoGuessrClient
from ..api.endpoints import Endpoints from ..api.endpoints import Endpoints
from ..models.Achievement import Achievement from ..models.Achievement import Achievement
from ..models.UserProfile import UserProfile from ..models.UserProfile import UserProfile
@ -171,11 +171,7 @@ class ProfileService:
"unlocked": len(unlocked), "unlocked": len(unlocked),
"recent": [ "recent": [
{"name": a.name, "unlocked_at": a.unlocked_at} {"name": a.name, "unlocked_at": a.unlocked_at}
for a in sorted( for a in sorted(unlocked, key=lambda x: x.unlocked_at or "", reverse=True)[:5]
unlocked,
key=lambda x: x.unlocked_at or "",
reverse=True
)[:5]
], ],
} }
except Exception as e: except Exception as e:

View file

@ -1,4 +1,3 @@
@mcp.tool() @mcp.tool()
async def analyze_recent_games(count: int = 10) -> dict: async def analyze_recent_games(count: int = 10) -> dict:
""" """
@ -11,8 +10,7 @@ async def analyze_recent_games(count: int = 10) -> dict:
async with await get_async_session() as client: async with await get_async_session() as client:
# Get activity feed # Get activity feed
feed_response = await client.get( feed_response = await client.get(
f"{GEOGUESSR_BASE_URL}/v4/feed/private", f"{GEOGUESSR_BASE_URL}/v4/feed/private", params={"count": count * 2, "page": 0}
params={"count": count * 2, "page": 0}
) )
feed_response.raise_for_status() feed_response.raise_for_status()
feed = feed_response.json() feed = feed_response.json()
@ -27,7 +25,9 @@ async def analyze_recent_games(count: int = 10) -> dict:
game_token = entry.get("payload", {}).get("gameToken") game_token = entry.get("payload", {}).get("gameToken")
if game_token: if game_token:
try: try:
game_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/games/{game_token}") game_response = await client.get(
f"{GEOGUESSR_BASE_URL}/v3/games/{game_token}"
)
if game_response.status_code == 200: if game_response.status_code == 200:
game = game_response.json() game = game_response.json()
@ -36,17 +36,19 @@ async def analyze_recent_games(count: int = 10) -> dict:
"map": game.get("map", {}).get("name", "Unknown"), "map": game.get("map", {}).get("name", "Unknown"),
"mode": game.get("type", "Unknown"), "mode": game.get("type", "Unknown"),
"total_score": 0, "total_score": 0,
"rounds": [] "rounds": [],
} }
for round_data in game.get("player", {}).get("guesses", []): for round_data in game.get("player", {}).get("guesses", []):
round_score = round_data.get("roundScoreInPoints", 0) round_score = round_data.get("roundScoreInPoints", 0)
game_info["total_score"] += round_score game_info["total_score"] += round_score
game_info["rounds"].append({ game_info["rounds"].append(
"score": round_score, {
"distance": round_data.get("distanceInMeters", 0), "score": round_score,
"time": round_data.get("time", 0) "distance": round_data.get("distanceInMeters", 0),
}) "time": round_data.get("time", 0),
}
)
total_rounds += 1 total_rounds += 1
if round_score == 5000: if round_score == 5000:
@ -63,8 +65,10 @@ async def analyze_recent_games(count: int = 10) -> dict:
"average_score": total_score / len(games_analyzed) if games_analyzed else 0, "average_score": total_score / len(games_analyzed) if games_analyzed else 0,
"total_rounds": total_rounds, "total_rounds": total_rounds,
"perfect_rounds": perfect_rounds, "perfect_rounds": perfect_rounds,
"perfect_round_percentage": (perfect_rounds / total_rounds * 100) if total_rounds > 0 else 0, "perfect_round_percentage": (
"games": games_analyzed (perfect_rounds / total_rounds * 100) if total_rounds > 0 else 0
),
"games": games_analyzed,
} }
@ -111,14 +115,16 @@ async def get_performance_summary() -> dict:
# Get achievements # Get achievements
try: try:
achievements_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/achievements") achievements_response = await client.get(
f"{GEOGUESSR_BASE_URL}/v3/profiles/achievements"
)
achievements_response.raise_for_status() achievements_response.raise_for_status()
achievements = achievements_response.json() achievements = achievements_response.json()
results["achievements_summary"] = { results["achievements_summary"] = {
"total": len(achievements) if isinstance(achievements, list) else 0, "total": len(achievements) if isinstance(achievements, list) else 0,
"achievements": achievements "achievements": achievements,
} }
except Exception as e: except Exception as e:
results["achievements_error"] = str(e) results["achievements_error"] = str(e)
return results return results

View file

@ -1,5 +1,7 @@
"""MCP tools for auth operations.""" """MCP tools for auth operations."""
import logging import logging
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from ..auth.session import SessionManager from ..auth.session import SessionManager
@ -44,7 +46,6 @@ def register_auth_tools(mcp: FastMCP, session_manager: SessionManager):
logger.error(f"Login error: {e}") logger.error(f"Login error: {e}")
return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} return {"success": False, "error": f"An unexpected error occurred: {str(e)}"}
@mcp.tool() @mcp.tool()
async def logout() -> dict: async def logout() -> dict:
""" """
@ -63,7 +64,6 @@ def register_auth_tools(mcp: FastMCP, session_manager: SessionManager):
return {"success": False, "message": "No active session"} return {"success": False, "message": "No active session"}
@mcp.tool() @mcp.tool()
async def set_session_token(token: str) -> dict: async def set_session_token(token: str) -> dict:
""" """
@ -86,7 +86,6 @@ def register_auth_tools(mcp: FastMCP, session_manager: SessionManager):
return {"success": False, "error": "Invalid or expired session token"} return {"success": False, "error": "Invalid or expired session token"}
@mcp.tool() @mcp.tool()
async def set_ncfa_cookie(cookie: str) -> dict: async def set_ncfa_cookie(cookie: str) -> dict:
""" """
@ -135,7 +134,6 @@ def register_auth_tools(mcp: FastMCP, session_manager: SessionManager):
"session_token": session_token, "session_token": session_token,
} }
@mcp.tool() @mcp.tool()
async def get_auth_status() -> dict: async def get_auth_status() -> dict:
""" """

View file

@ -31,10 +31,7 @@ def mock_profile_data():
"created": "2025-01-01T00:00:00.000Z", "created": "2025-01-01T00:00:00.000Z",
"isVerified": True, "isVerified": True,
"level": 50, "level": 50,
"rating": { "rating": {"rating": 1500, "deviation": 100},
"rating": 1500,
"deviation": 100
}
} }

View file

@ -1 +1 @@
"""Integration tests for GeoGuessr MCP Server.""" """Integration tests for GeoGuessr MCP Server."""

View file

@ -1 +1 @@
# TODO # TODO

View file

@ -1 +1 @@
# TODO # TODO

View file

@ -1 +1 @@
"""Unit tests for GeoGuessr MCP Server.""" """Unit tests for GeoGuessr MCP Server."""

View file

@ -19,10 +19,11 @@ TestSessionManager
login, logout, and session management operations in an async context. login, logout, and session management operations in an async context.
""" """
import pytest from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, UTC
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from geoguessr_mcp.auth.session import SessionManager, UserSession from geoguessr_mcp.auth.session import SessionManager, UserSession

View file

@ -8,8 +8,8 @@ objects, computation of schema hashes, and parsing of specific data formats
such as datetime strings, URLs, and UUIDs. such as datetime strings, URLs, and UUIDs.
""" """
from geoguessr_mcp.monitoring.schema.SchemaDetector import SchemaDetector
from geoguessr_mcp.monitoring.schema.EndpointSchema import SchemaField from geoguessr_mcp.monitoring.schema.EndpointSchema import SchemaField
from geoguessr_mcp.monitoring.schema.SchemaDetector import SchemaDetector
class TestSchemaDetector: class TestSchemaDetector:
@ -94,7 +94,7 @@ class TestSchemaDetector:
"id": "123", "id": "123",
"profile": { "profile": {
"name": "Test", "name": "Test",
} },
} }
} }

View file

@ -1 +1 @@
# TODO # TODO

View file

@ -1 +1 @@
# TODO # TODO

View file

@ -1 +1 @@
# TODO # TODO