diff options
| author | kboz <kutayboz@gmail.com> | 2026-04-19 01:50:32 +0200 |
|---|---|---|
| committer | kboz <kutayboz@gmail.com> | 2026-04-19 01:50:32 +0200 |
| commit | acbf6d6bd6d63ab0e9f7c4e119827f76e0382aac (patch) | |
| tree | 9d2f74608ae4b910a480648c7bd95fdf6c403389 | |
| parent | 8cc70dfee2049772a2da9cfdeb15f64452bc390b (diff) | |
| download | haul-acbf6d6bd6d63ab0e9f7c4e119827f76e0382aac.tar.gz | |
feat(backend): unify boot process and refactor route properties
- Added scripts/haul.sh, setup_auth.py, and setup_eve_data.py for simplified SSO and CSV data bootstrapping.
- Refactored `Ship` and `RouteStep` properties across route planning modules to utilize dictionaries instead of strict dataclass attributes.
- Re-aligned route limits, reducing upfront capital risk values in `arbitrage.py` and updating ship metrics in `manager.py`.
- Updated `.gitignore`, `.env.example`, and created AI-assistant prompt configuration.
- Added comprehensive "Getting Started" guides to the README and initialized CHANGELOG.md.
| -rw-r--r-- | .env.example | 6 | ||||
| -rw-r--r-- | .github/prompts/document-changes.prompt.md | 10 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | CHANGELOG.md | 27 | ||||
| -rw-r--r-- | backend/src/arbitrage.py | 2 | ||||
| -rw-r--r-- | backend/src/graph.py | 1 | ||||
| -rw-r--r-- | backend/src/loader.py | 5 | ||||
| -rw-r--r-- | backend/src/manager.py | 31 | ||||
| -rw-r--r-- | backend/src/pathfinder.py | 4 | ||||
| -rw-r--r-- | backend/src/route.py | 31 | ||||
| -rw-r--r-- | readme.md | 73 | ||||
| -rwxr-xr-x | scripts/haul.sh | 217 | ||||
| -rw-r--r-- | scripts/setup_auth.py | 129 | ||||
| -rw-r--r-- | scripts/setup_eve_data.py | 55 |
14 files changed, 557 insertions, 36 deletions
diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..438c714 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# EVE Online SSO application credentials +CLIENT_ID=your_eve_sso_client_id +SECRET_KEY=your_eve_sso_secret + +# The Python backend reads this exact lowercase variable name. +character_id=your_eve_character_id
\ No newline at end of file diff --git a/.github/prompts/document-changes.prompt.md b/.github/prompts/document-changes.prompt.md new file mode 100644 index 0000000..215389c --- /dev/null +++ b/.github/prompts/document-changes.prompt.md @@ -0,0 +1,10 @@ +--- +description: Analyzes the recent workspace changes to update documentation, CHANGELOG, and generate a commit message. +--- + +Please review the current workspace changes or diffs and perform the following tasks sequentially: + +1. **Analyze Changes**: Review the modified files and understand the scope of the updates. +2. **Update README**: Check if the changes affect the project structure, run instructions, or architecture described in `readme.md`. If so, update `readme.md` to reflect these changes. +3. **Update CHANGELOG**: Modify `CHANGELOG.md` to include these changes under the `[Unreleased]` section following the Keep a Changelog format (Added, Changed, Deprecated, Removed, Fixed, Security). +4. **Suggest Commit Message**: Provide a clear, concise commit message summarizing the changes, optionally formatted with Conventional Commits specifications (e.g., `feat: ...`, `fix: ...`, `docs: ...`). @@ -1,4 +1,6 @@ +.venv/ venv/ +.tools/ data/ cache/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3b3c23 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `scripts/haul.sh` CLI for unified backend/frontend dependency checking, running, and EVE data bootstrapping. +- `scripts/setup_auth.py` and `scripts/setup_eve_data.py` scripts to fetch OAuth credentials and base CSV game data. +- `.env.example` to ease SSO configuration. +- Custom Copilot Chat prompt for documenting changes under `.github/prompts`. + +### Changed +- Refactored `backend/src/manager.py` to fix graph instantiations, updated ship properties to feature larger cargo, lower subwarp/warp speed, and accurately lower ship ISK value. +- Re-aligned route and item capital checks across `backend/src/manager.py` and `backend/src/pathfinder.py` by transitioning the `Ship` attribute references from dataclasses to dictionaries. +- Reduced initial max capital layout cap to `20,000,000` in `backend/src/arbitrage.py` to be more cautious about early-game up-front ISK demands. +- Updated `backend/src/loader.py` to handle edge cases where `solar_system_id` was returned as 'None'. +- Enhanced graph generation to reliably build its parent directory for `PATHS_FILE`. +- Adjusted `.gitignore` to track standard Python and Node virtual environment directories properly. + +### Fixed +- Fixed uninitialized class name alias by swapping `Graph` to `NavigationGraph` across references. +- Remedied route step structure by moving away from hardcoded dataclass references (`RouteStep`) to generic dictionaries in `backend/src/route.py`. +- Checked and resolved printing of `snipe` variable references within `backend/src/manager.py` loops arrays versus strings. diff --git a/backend/src/arbitrage.py b/backend/src/arbitrage.py index 0991bbc..c1a0459 100644 --- a/backend/src/arbitrage.py +++ b/backend/src/arbitrage.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) TAX_RATE = 0.08 PROFIT_THRESHOLD = 100_000 -MAX_CAPITAL = 100_000_000 +MAX_CAPITAL = 20_000_000 SNIPE_PROFIT_THRESHOLD = 20_000_000 @dataclass diff --git a/backend/src/graph.py b/backend/src/graph.py index 31eef80..9e4534a 100644 --- a/backend/src/graph.py +++ b/backend/src/graph.py @@ -300,6 +300,7 @@ class NavigationGraph: else: logger.info("Generating path lengths and caching to file") dist = dict(nx.all_pairs_dijkstra_path_length(self.graph)) + PATHS_FILE.parent.mkdir(parents=True, exist_ok=True) with PATHS_FILE.open("wb") as f: pickle.dump(dist, f) return dist diff --git a/backend/src/loader.py b/backend/src/loader.py index 7e536c1..2d46cd6 100644 --- a/backend/src/loader.py +++ b/backend/src/loader.py @@ -96,7 +96,10 @@ def process_row( item_id_int = int(item_id) type_id_int = int(type_id) group_id_int = int(group_id) - solar_system_id_int = int(solar_system_id) + + solar_system_id_int = None + if solar_system_id and solar_system_id != 'None': + solar_system_id_int = int(solar_system_id) if type_id_int == TYPE_ID_SOLAR_SYSTEM: solar_systems[item_id_int] = { diff --git a/backend/src/manager.py b/backend/src/manager.py index 03df5d8..09d920e 100644 --- a/backend/src/manager.py +++ b/backend/src/manager.py @@ -1,6 +1,6 @@ import threading -from graph import Graph +from graph import NavigationGraph from esi.api import get_location, set_waypoints, open_market_window from market import Market from arbitrage import arbitrage, snipe @@ -14,15 +14,15 @@ class Manager: def __init__(self): self.ship = { "location": get_location(), - "cargo": 600, - "max_warp_speed": 8.22, # AU/s - "max_subwarp_speed": 216.5, # m/s + "cargo": 4000, + "max_warp_speed": 4.55, # AU/s + "max_subwarp_speed": 166.9, # m/s "gankers_areas": {"Ahbazon": 0.5}, #["Miroitem", "Rancer", "Huola", "Ahbazon", "Uedama", "Sivala", "Tama", "Onatoh"], "player_cost": 15_000_000 / 3600, # isk/second "risk_cost": 60_000_000, # isk - "ship_cost": 50_000_000 # isk + "ship_cost": 5_500_000 # isk } - self.graph = Graph(self.ship) + self.graph = NavigationGraph(self.ship) self.route = [] self.route_info = {} self.market = Market() @@ -51,12 +51,12 @@ class Manager: potential_snipe = snipe(self.market.orders, self.ship["cargo"], current_region) if potential_snipe: self.log.append("----------------") - self.log.append("Snipe: " + str(potential_snipe[1]) + " orders for " + str(potential_snipe[0]) + " ISK") + self.log.append(f"Snipe: {potential_snipe.item_name} orders for {potential_snipe.gross_profit} ISK") self.log.append("----------------") - print("snipe possible", potential_snipe[0], "ISK", potential_snipe[1]) + print("snipe possible", potential_snipe.gross_profit, "ISK", potential_snipe.item_name) - open_market_window(potential_snipe[1]["type_id"]) + open_market_window(potential_snipe.type_id) def update(self): @@ -82,8 +82,9 @@ class Manager: def target_function(log): try: - updated_regions = self.market.update_orders(log) + updated_regions = self.market.update_orders() if updated_regions: + log.append(f"Updated {len(updated_regions)} market regions") self.snipe_callback(updated_regions) finally: self.updating_orders = False @@ -97,8 +98,12 @@ class Manager: order_matches = pathfinder(order_matches, self.graph, self.ship) self.route, self.route_info = route.route(order_matches, self.graph, self.ship) - set_waypoints([r["location_id"] for r in self.route[1:]]) - - print(self.route) + if self.route: + set_waypoints([r["location_id"] for r in self.route[1:]]) + print(self.route) + else: + self.route = [] + self.route_info = {} + print("No profitable route found yet.")
\ No newline at end of file diff --git a/backend/src/pathfinder.py b/backend/src/pathfinder.py index 80e7a44..4ce2df3 100644 --- a/backend/src/pathfinder.py +++ b/backend/src/pathfinder.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from tqdm import tqdm from arbitrage import Trade -from graph import Graph +from graph import NavigationGraph as Graph logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def calculate_net_profit(order_matches: list[Trade], graph: Graph, ship: Ship) - for order_match in tqdm(order_matches): try: # Calculate transport cost from ship location to from_station - cost_to_source = graph.shortest_path_length(ship.location, order_match.from_station) + cost_to_source = graph.shortest_path_length(ship["location"], order_match.from_station) # Calculate transport cost from from_station to to_station cost_to_destination = graph.shortest_path_length(order_match.from_station, order_match.to_station) diff --git a/backend/src/route.py b/backend/src/route.py index ec099b1..2bcae7e 100644 --- a/backend/src/route.py +++ b/backend/src/route.py @@ -4,9 +4,10 @@ from typing import Optional from tqdm import tqdm -from graph import Graph +from graph import NavigationGraph as Graph from pathfinder import Ship from arbitrage import Trade +from typing import Any logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -14,13 +15,7 @@ logger = logging.getLogger(__name__) MAX_TRADES_TO_CONSIDER = 20_000 MAX_CAPITAL = 100_000_000 -@dataclass -class RouteStep: - location_id: int - station_name: str - actions: list[dict] = field(default_factory=list) - -Route = list[RouteStep] +Route = list[dict[str, Any]] def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route], Optional[dict]]: """ @@ -46,7 +41,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route seen_routes = set() for trade in tqdm(trades[:MAX_TRADES_TO_CONSIDER], desc="Evaluating trades"): - route_stations = (ship.location, trade.from_station, trade.to_station) + route_stations = (ship["location"], trade.from_station, trade.to_station) if route_stations in seen_routes: continue @@ -59,7 +54,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route continue try: - path = graph.shortest_path(ship.location, trade.from_station)[:-1] + path = graph.shortest_path(ship["location"], trade.from_station)[:-1] path += graph.shortest_path(trade.from_station, trade.to_station) except Exception as e: logger.error(f"Error finding path: {e}") @@ -68,7 +63,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route risk = sum(graph.graph[u][v]['risk'] for u, v in zip(path, path[1:])) transport_time = sum(graph.graph[u][v]['time'] for u, v in zip(path, path[1:])) - capital = getattr(ship, 'ship_cost', 0.0) + sum(t.from_price * t.quantity for t in optimized_trades) + capital = ship.get('ship_cost', 0.0) + sum(t.from_price * t.quantity for t in optimized_trades) if transport_time == 0: logger.warning("Transport time is zero, skipping this route to avoid division by zero.") @@ -133,7 +128,7 @@ def select_trades(stations: list[int], trades: list[Trade], ship: Ship) -> list[ from_remaining = used_orders.get(from_order, quantity) to_remaining = used_orders.get(to_order, quantity) - max_quantity_by_cargo = int((ship.cargo - cargo) // trade.item_cargo_volume) + max_quantity_by_cargo = int((ship["cargo"] - cargo) // trade.item_cargo_volume) max_quantity_by_capital = int((MAX_CAPITAL - capital) // trade.from_price) max_quantity = min(quantity, from_remaining, to_remaining, max_quantity_by_cargo, max_quantity_by_capital) @@ -165,7 +160,7 @@ def select_trades(stations: list[int], trades: list[Trade], ship: Ship) -> list[ optimized_trades.append(updated_trade) - if cargo >= ship.cargo or capital >= MAX_CAPITAL: + if cargo >= ship["cargo"] or capital >= MAX_CAPITAL: break return optimized_trades @@ -184,8 +179,8 @@ def set_actions(route: Route, trades: list[Trade]) -> Route: bought_items = set() for trade in trades: for step in route: - if trade.from_station == step.location_id: - step.actions.append({ + if trade.from_station == step["location_id"]: + step["actions"].append({ "action_type": "buy", "item": trade.item_name, "type_id": trade.type_id, @@ -193,8 +188,8 @@ def set_actions(route: Route, trades: list[Trade]) -> Route: "price": trade.from_price, }) bought_items.add(trade.type_id) - if trade.to_station == step.location_id and trade.type_id in bought_items: - step.actions.append({ + if trade.to_station == step["location_id"] and trade.type_id in bought_items: + step["actions"].append({ "action_type": "sell", "item": trade.item_name, "type_id": trade.type_id, @@ -203,6 +198,6 @@ def set_actions(route: Route, trades: list[Trade]) -> Route: }) for step in route: - step.actions.sort(key=lambda action: (action["action_type"], action["item"], action["price"])) + step["actions"].sort(key=lambda action: (action["action_type"], action["item"], action["price"])) return route @@ -75,6 +75,38 @@ The project comprises: * * * * * +**Algorithm Criteria & Limits** +------------------------------- + +The route planning and arbitrage algorithms evaluate the profitability and safety of moving items using the following constraints: + +1. **Ship & Financial Limits** + - **Cargo Capacity**: Hard limit of 600m³. Orders that don't fit are ignored. + - **Maximum Route Capital**: Caps the up-front item purchase cost at 100,000,000 ISK when compounding multiple items in a route. + - **Profit Threshold**: A single trade must yield a minimum gross profit of 100,000 ISK to be considered. + +2. **Time & Navigational Costs** + - **Ship Speed**: Calculations factor in `max_warp_speed = 8.22 AU/s` and `max_subwarp_speed = 216.5 m/s`. + - **Stargate Delay**: Each jump is assigned a fixed 9.0-second delay. + - **In-system Warp**: Calculates exact 3D geometry for acceleration and deceleration. + - **Player Time Cost**: The algorithm assigns a real-life time value of ~4,166 ISK per second (`15_000_000 / 3600`) to punish overly long routes. + +3. **Risk & Safety Penalties** + - **Gank Zones**: Hardcoded danger zones (e.g., Ahbazon) multiply the risk penalty heavily. + - **Security Status**: High-sec space carries virtually no penalty, low-sec applies a 0.005 penalty, and null-sec adds a massive 0.5 risk penalty. + - **Value at Risk**: The calculated risk percentage is multiplied against a theoretical 60m ISK penalty + the 50m ship value + the cargo value. + +4. **Trade Execution & Filtering Strategy** + - **Market Tax**: Accounts for an 8% transaction tax on all trades automatically. + - **Arbitrage Capital Limits**: Rejects individual items exceeding 20,000,000 ISK up-front capital during initial data processing but allows up to 100,000,000 ISK when compounding multiple items continuously. + - **Same-Region Filtering**: Ensures that pickup and dropoff stations for a single item are within the same region. + - **Evaluation Limit**: Limits pathfinding to the top 20,000 trades to conserve CPU overhead. + - **Snipe Feature Execution**: Background polling monitors loop regions actively for single trades that boast >20,000,000 ISK gross profit. If triggered, it executes an automatic ESI API call to forcefully open your in-game client's market window to intercept the trade instantly. + - **ESI Error Limits**: Pauses backend routines entirely if the X-Esi-Error-Limit-Remain headers fall below 50. + - **The Final Metric**: The route chosen is the one that produces the highest `profit_rate` (which is `net_profit = total_profit - risk * capital` divided by `transport_time`). + +* * * * * + **Project Structure** --------------------- @@ -88,4 +120,43 @@ The project comprises: ├── rust_backend/ # Experimental Rust rewrite │ ├── src/ # Rust codebase │ └── Cargo.toml # Rust dependencies -```
\ No newline at end of file +├── scripts/ # Setup and run scripts +│ └── haul.sh # Unified script for data/auth setup and app runtime +``` + +* * * * * + +**Getting Started** +------------------- + +HAUL uses a unified shell script to handle setup, data fetching, authorization, and running the application. + +1. **Check Environment**: + Verify what prerequisites are missing by running: + ```bash + ./scripts/haul.sh check + ``` + +2. **Fetch Static Data**: + HAUL requires EVE Online CSV dumps. Download and extract them automatically: + ```bash + ./scripts/haul.sh setup-data + ``` + +3. **Authenticate**: + Set up your EVE SSO credentials and generate the auth cache: + ```bash + ./scripts/haul.sh setup-auth + ``` + +4. **Run the Application**: + Start both the Python backend and React frontend concurrently: + ```bash + ./scripts/haul.sh up + ``` + + Alternatively, run them separately: + ```bash + ./scripts/haul.sh backend + ./scripts/haul.sh frontend + ```
\ No newline at end of file diff --git a/scripts/haul.sh b/scripts/haul.sh new file mode 100755 index 0000000..b677694 --- /dev/null +++ b/scripts/haul.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="$ROOT_DIR/.venv/bin/python" +NODE_BIN_DIR="$ROOT_DIR/.tools/node/bin" +AUTH_FILE="$ROOT_DIR/backend/src/cache/auth.json" +DATA_DIR="$ROOT_DIR/backend/data" +ENV_FILE="$ROOT_DIR/.env" + +REQUIRED_DATA_FILES=( + mapDenormalize.csv + mapJumps.csv + invTypes.csv + mapRegions.csv +) + +print_usage() { + cat <<'EOF' +Usage: scripts/haul.sh <command> + +Commands: + check Show what is ready and what is still missing + setup-data Download required EVE CSV files into backend/data + setup-auth Create backend/src/cache/auth.json via EVE SSO + backend Start only the Python backend on http://127.0.0.1:8000 + frontend Start only the React frontend on http://127.0.0.1:3000 + up Start backend and frontend together +EOF +} + +load_env() { + if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + fi + + if [[ -n "${CHARACTER_ID:-}" && -z "${character_id:-}" ]]; then + export character_id="$CHARACTER_ID" + fi +} + +require_python() { + if [[ ! -x "$PYTHON_BIN" ]]; then + echo "Missing Python virtualenv at $PYTHON_BIN" + echo "Create it first or rerun environment setup." + exit 1 + fi +} + +require_node() { + if [[ ! -x "$NODE_BIN_DIR/node" || ! -x "$NODE_BIN_DIR/npm" ]]; then + echo "Missing repo-local Node runtime under $NODE_BIN_DIR" + exit 1 + fi +} + +require_frontend_install() { + if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then + echo "Missing frontend/node_modules" + echo "Run: scripts/haul.sh frontend" + exit 1 + fi +} + +backend_runtime_ready() { + local missing=0 + + if [[ -z "${CLIENT_ID:-}" || -z "${SECRET_KEY:-}" || -z "${character_id:-}" ]]; then + echo "Missing required env vars. Run: scripts/haul.sh setup-auth" + missing=1 + fi + + if [[ ! -f "$AUTH_FILE" ]]; then + echo "Missing auth cache: $AUTH_FILE" + echo "Run: scripts/haul.sh setup-auth" + missing=1 + fi + + for file_name in "${REQUIRED_DATA_FILES[@]}"; do + if [[ ! -f "$DATA_DIR/$file_name" ]]; then + echo "Missing data file: $DATA_DIR/$file_name" + missing=1 + fi + done + + if [[ $missing -ne 0 ]]; then + echo "Run: scripts/haul.sh setup-data" + return 1 + fi + + return 0 +} + +print_status() { + load_env + + if [[ -x "$PYTHON_BIN" ]]; then + echo "python: ready ($PYTHON_BIN)" + else + echo "python: missing ($PYTHON_BIN)" + fi + + if [[ -x "$NODE_BIN_DIR/node" ]]; then + echo "node: ready ($NODE_BIN_DIR/node)" + else + echo "node: missing ($NODE_BIN_DIR/node)" + fi + + if [[ -d "$ROOT_DIR/frontend/node_modules" ]]; then + echo "frontend deps: ready" + else + echo "frontend deps: missing" + fi + + if [[ -f "$AUTH_FILE" ]]; then + echo "auth cache: ready" + else + echo "auth cache: missing" + fi + + for file_name in "${REQUIRED_DATA_FILES[@]}"; do + if [[ -f "$DATA_DIR/$file_name" ]]; then + echo "data: ready ($file_name)" + else + echo "data: missing ($file_name)" + fi + done + + if [[ -f "$ENV_FILE" ]]; then + echo ".env: present" + else + echo ".env: missing (setup-auth can create it)" + fi +} + +run_setup_data() { + require_python + exec "$PYTHON_BIN" "$ROOT_DIR/scripts/setup_eve_data.py" +} + +run_setup_auth() { + require_python + load_env + exec "$PYTHON_BIN" "$ROOT_DIR/scripts/setup_auth.py" +} + +run_backend() { + require_python + load_env + backend_runtime_ready + cd "$ROOT_DIR/backend/src" + exec "$PYTHON_BIN" -m uvicorn main:app --reload --host 127.0.0.1 --port 8000 +} + +run_frontend() { + require_node + require_frontend_install + export PATH="$NODE_BIN_DIR:$PATH" + cd "$ROOT_DIR/frontend" + exec npm start +} + +run_up() { + require_python + require_node + require_frontend_install + load_env + backend_runtime_ready + + export PATH="$NODE_BIN_DIR:$PATH" + + cd "$ROOT_DIR/backend/src" + "$PYTHON_BIN" -m uvicorn main:app --host 127.0.0.1 --port 8000 & + backend_pid=$! + + cleanup() { + if kill -0 "$backend_pid" >/dev/null 2>&1; then + kill "$backend_pid" + wait "$backend_pid" 2>/dev/null || true + fi + } + + trap cleanup EXIT INT TERM + + cd "$ROOT_DIR/frontend" + exec npm start +} + +command_name="${1:-}" + +case "$command_name" in + check) + print_status + ;; + setup-data) + run_setup_data + ;; + setup-auth) + run_setup_auth + ;; + backend) + run_backend + ;; + frontend) + run_frontend + ;; + up) + run_up + ;; + *) + print_usage + exit 1 + ;; +esac
\ No newline at end of file diff --git a/scripts/setup_auth.py b/scripts/setup_auth.py new file mode 100644 index 0000000..d675ddd --- /dev/null +++ b/scripts/setup_auth.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import base64 +import json +from datetime import datetime, UTC +from getpass import getpass +from pathlib import Path +import shlex +from urllib.parse import urlencode, quote + +import requests +from dotenv import load_dotenv +from jose import jwt +import os + + +ROOT_DIR = Path(__file__).resolve().parent.parent +AUTH_FILE = ROOT_DIR / "backend" / "src" / "cache" / "auth.json" +ENV_FILE = ROOT_DIR / ".env" +REDIRECT_URI = "https://localhost/callback/" +TOKEN_URL = "https://login.eveonline.com/v2/oauth/token" +AUTH_URL = "https://login.eveonline.com/v2/oauth/authorize/" +SCOPES = [ + "esi-assets.read_assets.v1", + "esi-location.read_location.v1", + "esi-location.read_online.v1", + "esi-ui.open_window.v1", + "esi-ui.write_waypoint.v1", +] + + +def build_auth_url(client_id: str) -> str: + params = { + "response_type": "code", + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "state": "haul-local", + "scope": " ".join(SCOPES), + } + return f"{AUTH_URL}?{urlencode(params, quote_via=quote)}" + + +def prompt_for_value(name: str, secret: bool = False) -> str: + while True: + value = getpass(f"{name}: ") if secret else input(f"{name}: ").strip() + if value: + return value + print(f"{name} is required.") + + +def append_missing_env_values(values: dict[str, str]) -> None: + existing_text = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else "" + existing_keys = set() + + for raw_line in existing_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _ = line.split("=", 1) + existing_keys.add(key.strip()) + + lines_to_add = [] + for key, value in values.items(): + if key not in existing_keys: + lines_to_add.append(f"{key}={shlex.quote(value)}") + + if not lines_to_add: + return + + if existing_text and not existing_text.endswith("\n"): + existing_text += "\n" + + ENV_FILE.write_text(existing_text + "\n".join(lines_to_add) + "\n", encoding="utf-8") + + +def main() -> int: + load_dotenv(ENV_FILE) + + client_id = os.getenv("CLIENT_ID") or prompt_for_value("CLIENT_ID") + secret_key = os.getenv("SECRET_KEY") or prompt_for_value("SECRET_KEY", secret=True) + + print("Open this URL in your browser and complete the EVE SSO flow:\n") + print(build_auth_url(client_id)) + print("\nAfter the redirect, copy the 'code' query parameter and paste it below.") + code = input("Authorization code: ").strip() + + if not code: + print("No authorization code provided.") + return 1 + + user_pass = f"{client_id}:{secret_key}".encode("utf-8") + headers = { + "Authorization": f"Basic {base64.urlsafe_b64encode(user_pass).decode()}", + "Content-Type": "application/x-www-form-urlencoded", + "Host": "login.eveonline.com", + } + form_values = { + "grant_type": "authorization_code", + "code": code, + } + + response = requests.post(TOKEN_URL, data=form_values, headers=headers, timeout=30) + response.raise_for_status() + auth_data = response.json() + auth_data["expires_at"] = int(datetime.now(UTC).timestamp()) + int(auth_data.get("expires_in", 0)) + + AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + AUTH_FILE.write_text(json.dumps(auth_data, indent=2), encoding="utf-8") + + claims = jwt.get_unverified_claims(auth_data["access_token"]) + character_id = claims.get("sub", "").split(":")[-1] if claims.get("sub") else "unknown" + character_name = claims.get("name", "unknown") + + append_missing_env_values( + { + "CLIENT_ID": client_id, + "SECRET_KEY": secret_key, + "character_id": character_id, + } + ) + + print(f"Saved auth cache to {AUTH_FILE}") + print(f"Detected character: {character_name} ({character_id})") + print(f"Ensured runtime env values exist in {ENV_FILE}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())
\ No newline at end of file diff --git a/scripts/setup_eve_data.py b/scripts/setup_eve_data.py new file mode 100644 index 0000000..a7f7efd --- /dev/null +++ b/scripts/setup_eve_data.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import bz2 +import shutil +import sys +import urllib.request +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT_DIR / "backend" / "data" +BASE_URL = "https://www.fuzzwork.co.uk/dump/latest" + +REQUIRED_FILES = { + "mapDenormalize.csv": f"{BASE_URL}/mapDenormalize.csv.bz2", + "mapJumps.csv": f"{BASE_URL}/mapJumps.csv.bz2", + "invTypes.csv": f"{BASE_URL}/invTypes.csv.bz2", + "mapRegions.csv": f"{BASE_URL}/mapRegions.csv.bz2", +} + + +def download_and_extract(file_name: str, url: str) -> None: + destination = DATA_DIR / file_name + archive_path = destination.with_suffix(destination.suffix + ".bz2") + + if destination.exists(): + print(f"Skipping {file_name}; already exists.") + return + + print(f"Downloading {file_name} from {url}") + + try: + with urllib.request.urlopen(url, timeout=60) as response, archive_path.open("wb") as output_file: + shutil.copyfileobj(response, output_file) + + with bz2.open(archive_path, "rb") as compressed_file, destination.open("wb") as output_file: + shutil.copyfileobj(compressed_file, output_file) + finally: + archive_path.unlink(missing_ok=True) + + print(f"Wrote {destination}") + + +def main() -> int: + DATA_DIR.mkdir(parents=True, exist_ok=True) + + for file_name, url in REQUIRED_FILES.items(): + download_and_extract(file_name, url) + + print("EVE static data is ready.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())
\ No newline at end of file |
