Intial Version

This commit is contained in:
2025-12-03 18:00:10 -05:00
parent 43c4227da7
commit 0b86c88eb4
55 changed files with 8938 additions and 0 deletions

14
.env Normal file
View File

@@ -0,0 +1,14 @@
# Database
DATABASE_URL="postgresql://rmtpw:rmtpw_password@localhost:5432/rmtpocketwatcher?schema=public"
# Scraper Configuration
SCRAPE_INTERVAL_MINUTES=5
SCRAPER_HEADLESS=true
SCRAPER_TIMEOUT=30000
SCRAPER_MAX_RETRIES=3
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=info

22
.kiro/steering/product.md Normal file
View File

@@ -0,0 +1,22 @@
# Product Overview
rmtPocketWatcher is a cross-platform desktop application that tracks real-money trading (RMT) prices for Star Citizen AUEC (in-game currency). The app provides a Bloomberg-style terminal interface displaying a real-time AUEC Price Index derived from multiple vendor sources. It is developed and offered by the Lambda Banking Conglomerate a Star Citizen Organization
## Core Functionality
- Scrapes AUEC prices from Eldorado and PlayerAuctions every 5 minutes
- Tracks all individual seller listings with platform, seller name, and USD per 1M AUEC
- Computes lowest price across all vendors and sellers
- Displays real-time price data with seller-level granularity
- Historical charts showing price trends by seller, platform, or lowest overall
- Vendor comparison table with sortable/filterable seller listings
- Client-side alerts for price thresholds with native notifications
- Long-term historical data storage for all listings (not just lowest)
## Key Constraints
- Desktop-only (no mobile or web versions)
- No buying/selling functionality within the app
- Alerts are evaluated client-side, not on the backend
- Focus on data visualization and tracking, not RMT facilitation
- Store all seller listings over time for historical comparison and trend analysis

View File

@@ -0,0 +1,66 @@
# Project Structure
This is a monorepo-style project with separate backend and frontend applications.
## Expected Organization
```
/
├── backend/ # TypeScript backend server
│ ├── src/
│ │ ├── scrapers/ # Playwright scraping modules
│ │ ├── api/ # REST endpoints
│ │ ├── websocket/ # WebSocket handlers
│ │ ├── database/ # Prisma/Drizzle schema & migrations
│ │ ├── services/ # Business logic (price index calculation)
│ │ └── utils/ # Shared utilities
│ ├── tests/
│ ├── Dockerfile
│ └── package.json
├── electron-app/ # Electron + React frontend
│ ├── src/
│ │ ├── main/ # Electron main process
│ │ ├── renderer/ # React UI components
│ │ │ ├── components/
│ │ │ ├── pages/
│ │ │ ├── hooks/
│ │ │ └── store/ # Zustand/Recoil state
│ │ └── shared/ # IPC types & shared code
│ ├── tests/
│ └── package.json
├── shared/ # Shared TypeScript types/interfaces
│ └── types/
├── .kiro/ # Kiro AI assistant configuration
│ └── steering/ # Project guidance documents
├── PRD.md # Product Requirements Document
└── README.md
```
## Architecture Patterns
- **Backend**: Modular service-oriented architecture
- Scrapers are isolated, containerized services
- API layer is stateless for horizontal scaling
- TimescaleDB handles time-series data efficiently
- **Frontend**: Component-based React architecture
- Sandboxed renderer process for security
- Secure IPC messaging between main and renderer
- Client-side alert evaluation logic
- **Database Schema**: Three main tables
- `raw_vendor_prices`: Individual vendor listings
- `price_index`: Computed median index values
- `scrape_log`: Monitoring and debugging
## Key Conventions
- All code in TypeScript with strict type checking
- Scrapers include retry logic (3 attempts) and error handling
- WebSocket auto-reconnect logic in Electron app
- Signed binaries for all platform distributions
- No remote code evaluation in Electron (security)

55
.kiro/steering/tech.md Normal file
View File

@@ -0,0 +1,55 @@
# Technology Stack
## Backend
- **Language**: TypeScript (Node.js)
- **Framework**: Fastify
- **Scraping**: Playwright (headless Chromium)
- **Database**: PostgreSQL + TimescaleDB extension
- **ORM**: Prisma or Drizzle
- **Job Scheduling**: Node Scheduler or BullMQ
- **WebSockets**: Native `ws` or Fastify WS plugin
- **Deployment**: Docker containers
## Frontend (Electron Desktop App)
- **Framework**: Electron 30+
- **UI Library**: NextJS? + TypeScript/TSX
- **Build Tool**: Vite
- **Styling**: TailwindCSS
- **Charts**: Recharts, ECharts, or TradingView Charting Library
- **State Management**: Zustand or Recoil
- **Auto-updates**: electron-updater
## Testing
- **Unit Tests**: Jest
- **E2E Tests**: Playwright
- **API Integration**: Supertest or Pact
## Common Commands
```bash
# Backend
npm run dev # Start development server
npm run build # Build TypeScript
npm run test # Run Jest tests
npm run scrape # Manual scrape trigger
# Frontend (Electron)
npm run dev # Start Electron in dev mode
npm run build # Build production app
npm run package # Package for distribution
npm run test # Run tests
# Database
npm run migrate # Run database migrations
npm run seed # Seed initial data
```
## Performance Requirements
- API latency < 50ms for standard queries
- Support 5000+ concurrent WebSocket connections
- Desktop app must handle 50k+ chart datapoints smoothly
- Scraping frequency: every 5 minutes (configurable)

2
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

381
PRD.md Normal file
View File

@@ -0,0 +1,381 @@
Product Requirements Document (PRD)
Star Citizen AUEC Price Index Tracker
Unified Modern TypeScript Stack + Electron Desktop Application
1. Product Overview
This product is a cross-platform desktop application (Electron + React/TSX) that displays real-money trading (RMT) prices for Star Citizen AUEC. A modern TypeScript-based backend performs scraping, normalization, time-series storage, and API hosting.
The goal is to generate a reliable AUEC Price Index, similar in spirit to a financial market index, and visualize it in a Bloomberg-style terminal UI that includes tables, charts, and alerts.
Alerts are evaluated client-side, based on data received from the backend.
2. Goals & Non-Goals
Goals
Scrape AUEC prices from:
Eldorado
PlayerAuctions
Normalize them into a USD ↔ AUEC Price Index.
Provide REST + WebSocket API to clients.
Display Bloomberg-like UI using Electron + React.
Allow users to set local alerts (no backend evaluation).
Store long-term historical price data.
Non-Goals
Buying/selling AUEC inside the app.
Mobile or web app (desktop only).
Legal/regulatory RMT analysis.
Server-side alert evaluation.
3. System Architecture
[Scraper Services - TypeScript + Playwright]
|
v
[Backend API - Fastify/NestJS - TypeScript]
- Normalize prices
- Store historical data (PostgreSQL + TimescaleDB)
- Expose REST + WebSocket
|
v
[Electron Desktop App - React/TSX UI]
- Bloomberg-style terminal interface
- Client-side alerts
- Charting & vendor tables
4. Detailed Requirements
4.1 Scraping Module (Backend)
Technology
TypeScript
Playwright for dynamic scraping
Node Scheduler or BullMQ for job scheduling
Deployed as containers (Docker)
Scraping Frequency
Every 5 minutes (configurable per environment)
Scraping Targets
Eldorado AUEC Market
https://www.eldorado.gg/star-citizen-auec/g/141-0-0
PlayerAuctions AUEC Market
https://www.playerauctions.com/star-citizen-auec/
Scraped Data Fields
Vendor name
Listing price (USD)
AUEC amount per listing
Derived:
USD per 1 million AUEC
AUEC per USD
Timestamp
Listing min/max spread (if available)
Scraper Requirements
Headless mode with Playwright Chromium
Randomized UA + viewport
Retry logic (3 attempts)
Automatically detect layout changes (optional heuristics)
Error Handling
Failures logged to DB
Continue schedule regardless of scrape results
4.2 Backend Server (TypeScript)
Technology Stack
Node.js + TypeScript
Fastify or NestJS (devs choice)
WebSockets via native ws or Fastify WS plugin
PostgreSQL + TimescaleDB for time-series storage
Prisma or Drizzle ORM
Backend Responsibilities
Accept scraped data
Compute Price Index
Store time-series data
Serve historical & latest prices
Push WS updates to connected Electron clients
Price Index Formula
Default:
lowest_price = min(USD_per_million_AUEC_from_all_vendors)
All individual listings are stored with seller and platform information for historical tracking and comparison.
REST API Endpoints
GET /prices/latest - Get all current listings with seller info
GET /prices/lowest - Get current lowest price across all vendors
GET /prices/by-seller?seller=&platform= - Get specific seller's price history
GET /prices/by-platform?platform= - Get all listings from a platform
GET /prices/history?from=&to=&seller=&platform= - Historical data with filters
GET /index/history?range=7d|30d|90d|all - Lowest price over time
WebSocket Channels
/ws/index → broadcast each new scrape
/ws/vendors → vendor-level updates
Performance Requirements
Handle 5000+ concurrent WS connections
API latency < 50ms for standard queries
4.3 Database Layer
Database
PostgreSQL (core DB)
TimescaleDB extension for time-series compression & fast queries
Schema
raw_vendor_prices
field type
id uuid
timestamp timestamptz
vendor text (eldorado/playerauctions)
seller_name text
usd_price numeric
auec_amount numeric
usd_per_million numeric
delivery_time text
url text
price_index
field type
timestamp timestamptz
lowest_price numeric
vendor text
seller_name text
scrape_log
field type
timestamp timestamptz
status text (success/failure)
message text
runtime_ms int
4.4 Electron Desktop App (React/TSX)
Technology Stack
Electron 30+
React + TypeScript/TSX
Vite build tooling
TailwindCSS for styling
Recharts, ECharts, or TradingView Charting Library for visuals
Zustand/Recoil for state management
Main Features
Dashboard (Bloomberg-style)
Large real-time Price Index number
Color-coded trend (% change)
Mini sparkline
Vendor Comparison Table
Columns:
Platform (Eldorado/PlayerAuctions)
Seller Name
USD per 1M AUEC
Delivery Time
Last updated
Sortable by price, seller, platform
Filter by platform
Auto-refresh from WebSocket events
Historical Chart
Select by:
Lowest price across all vendors (default)
Specific platform (Eldorado/PlayerAuctions)
Specific seller
Chart types:
Line (price over time)
Area
Multi-line comparison (compare multiple sellers)
Time ranges:
1h, 6h, 24h, 7d, 30d, All
Show all seller prices as separate lines for comparison
Client-Side Alerts
User selects:
Condition:
Price rises above X
Price falls below X
Threshold value
Stored locally (JSON or SQLite)
Trigger rules:
On each WS update, re-evaluate alerts
Trigger:
Windows/macOS/Linux native notification
Sound (toggle in settings)
UI popup/log entry
Settings Panel
Dark/light mode
Alert sounds on/off
Launch on startup
Chart theme
Electron-Specific Requirements
Auto-update (e.g., using electron-updater)
Secure IPC messaging (no remote code eval)
Sandboxed renderer process
Signed binaries (Windows .exe, macOS .dmg/.pkg)
5. Non-Functional Requirements
Security
HTTPS for all API calls
No sensitive user data stored
Sanitized DB inputs
CSRF not needed (desktop app only)
Signed Electron builds
Scalability
Scrapers run in Docker for horizontal scaling
Backend stateless easy to scale behind a load balancer
TimescaleDB handles time-series at scale
Reliability
WebSocket auto-reconnect
Graceful handling if server is offline
Retry logic in scraping pipeline
Performance
Desktop app must remain responsive when charts have 50k+ datapoints
(handled with TimescaleDB downsampling + chart library performance)
Testing
Unit tests: Jest (backend & Electron renderer)
E2E tests: Playwright for UI
Integration tests for API via Supertest or Pact
6. Risks & Mitigation
Risk Mitigation
Vendor site layout changes break scrapers Monitoring + abstraction layers + fallback parsing
Browser automation instability Playwright retries + container isolation
WS disconnects Auto-reconnect logic in Electron
Heavy Electron UI Use Vite + lazy-loaded components + chart virtualization
TimescaleDB complexity Can be optional if scale is small
7. Future Enhancements
Add more RMT vendors
Discord alerts via webhook
Predictive modeling (ML)
Add terminal-like command bar (e.g., /chart index 30d)
Desktop widgets and system tray price ticker

159
README.md
View File

@@ -1,2 +1,161 @@
# rmtPocketWatcher # rmtPocketWatcher
A cross-platform desktop application that tracks real-money trading (RMT) prices for Star Citizen AUEC. Provides a Bloomberg-style terminal interface displaying real-time AUEC price data from multiple vendors.
Developed by Lambda Banking Conglomerate - A Star Citizen Organization
## Features
- 🔄 Automated scraping from Eldorado and PlayerAuctions every 5 minutes
- 📊 Track all seller listings with historical data
- 💰 Real-time lowest price tracking across all vendors
- 📈 Historical price charts by seller, platform, or overall lowest
- 🔔 Client-side price alerts with native notifications
- 🌐 REST API + WebSocket for real-time updates
- 🗄️ PostgreSQL + Prisma for reliable data storage
## Quick Start
### Using Docker Compose (Recommended)
```bash
# Clone the repository
git clone <repository-url>
cd rmtPocketWatcher
# Start everything
docker-compose up -d
# View logs
docker-compose logs -f backend
# Access the API
curl http://localhost:3000/health
```
The backend will be available at `http://localhost:3000`
### Local Development
```bash
# Start database only
docker-compose up -d postgres
# Setup backend
cd backend
npm install
npm run db:generate
npm run db:push
npx playwright install chromium
# Run development server
npm run dev
# Or test scraper manually
npm run scrape
```
## Project Structure
```
rmtPocketWatcher/
├── backend/ # TypeScript backend server
│ ├── src/
│ │ ├── scrapers/ # Playwright scraping modules
│ │ ├── api/ # Fastify REST + WebSocket API
│ │ ├── database/ # Prisma client and repository
│ │ └── index.ts # Main server entry point
│ ├── prisma/ # Database schema and migrations
│ └── Dockerfile
├── electron-app/ # Electron desktop app (coming soon)
├── docker-compose.yml # Docker orchestration
└── README.md
```
## API Endpoints
### Prices
- `GET /api/prices/latest` - Get all current listings
- `GET /api/prices/lowest` - Get current lowest price
- `GET /api/prices/by-seller?seller=&platform=` - Filter by seller
- `GET /api/prices/by-platform?platform=` - Filter by platform
- `GET /api/prices/history?from=&to=&seller=&platform=` - Historical data
### Index
- `GET /api/index/history?range=7d|30d|90d|all` - Lowest price over time
- `GET /api/index/ws` - WebSocket for real-time updates
### Health
- `GET /health` - Server health check
## Configuration
Edit `.env` or set environment variables:
```env
DATABASE_URL=postgresql://rmtpw:rmtpw_password@localhost:5432/rmtpocketwatcher
PORT=3000
SCRAPE_INTERVAL_MINUTES=5
SCRAPER_HEADLESS=true
NODE_ENV=production
```
## Technology Stack
**Backend:**
- TypeScript + Node.js
- Fastify (REST API + WebSocket)
- Playwright (web scraping)
- PostgreSQL + Prisma ORM
- Node Scheduler (cron jobs)
**Frontend (Coming Soon):**
- Electron 30+
- React + TypeScript
- TailwindCSS
- Recharts/ECharts
## Development
```bash
# Run tests
npm test
# Lint and type check
npx tsc --noEmit
# Database management
npm run db:studio # Open Prisma Studio
npm run db:push # Push schema changes
npm run db:migrate # Create migration
# Manual scrape test
npm run scrape
```
## Docker Commands
```bash
# Build and start
docker-compose up --build
# Stop services
docker-compose down
# View logs
docker-compose logs -f backend
# Restart backend only
docker-compose restart backend
# Remove everything including volumes
docker-compose down -v
```
## License
MIT
## Disclaimer
This application is for informational and tracking purposes only. It does not facilitate any real-money trading transactions. Users are responsible for complying with all applicable laws and game terms of service.

171
SETUP.md Normal file
View File

@@ -0,0 +1,171 @@
# rmtPocketWatcher Setup Guide
## What's Been Created
**Backend Scraper Service**
- Playwright-based scrapers for Eldorado and PlayerAuctions
- Automatic retry logic and error handling
- Scheduled scraping every 5 minutes
- Tracks all seller listings with platform, price, and delivery time
**Database Layer**
- PostgreSQL with Prisma ORM
- Three tables: VendorPrice, PriceIndex, ScrapeLog
- Stores all historical listings for trend analysis
- Indexed for fast queries
**API Layer**
- Fastify REST API with 6 endpoints
- WebSocket for real-time updates
- Filter by seller, platform, date range
- Historical price data
**Docker Setup**
- Docker Compose orchestration
- PostgreSQL container with health checks
- Backend container with auto-migration
- Volume persistence for database
## Current Status
The Docker Compose stack is building. This will:
1. Pull PostgreSQL 16 Alpine image
2. Build the backend Node.js container
3. Install Playwright and dependencies
4. Generate Prisma client
5. Start both services
## Once Build Completes
### Check Status
```bash
# View logs
docker-compose logs -f backend
# Check if services are running
docker ps
```
### Test the API
```bash
# Health check
curl http://localhost:3000/health
# Get latest prices (after first scrape)
curl http://localhost:3000/api/prices/latest
# Get lowest price
curl http://localhost:3000/api/prices/lowest
# Get price history
curl "http://localhost:3000/api/index/history?range=7d"
```
### Monitor Scraping
The backend will automatically:
- Scrape Eldorado and PlayerAuctions every 5 minutes
- Save all listings to the database
- Calculate and store the lowest price
- Log all scrape attempts
Check logs to see scraping activity:
```bash
docker-compose logs -f backend | grep "scraping"
```
## Database Access
### Using Prisma Studio
```bash
cd backend
npm run db:studio
```
### Using psql
```bash
docker exec -it rmtpw-postgres psql -U rmtpw -d rmtpocketwatcher
```
## Stopping and Restarting
```bash
# Stop services
docker-compose down
# Start services
docker-compose up -d
# Rebuild after code changes
docker-compose up --build -d
# View logs
docker-compose logs -f
# Remove everything including data
docker-compose down -v
```
## Environment Variables
Edit `.env` or `docker-compose.yml` to configure:
- `SCRAPE_INTERVAL_MINUTES` - How often to scrape (default: 5)
- `SCRAPER_HEADLESS` - Run browser in headless mode (default: true)
- `PORT` - API server port (default: 3000)
- `DATABASE_URL` - PostgreSQL connection string
## Next Steps
1. **Wait for build to complete** (~2-5 minutes)
2. **Verify services are running**: `docker ps`
3. **Check first scrape**: `docker-compose logs -f backend`
4. **Test API endpoints**: See examples above
5. **Build Electron frontend** (coming next)
## Troubleshooting
### Backend won't start
```bash
# Check logs
docker-compose logs backend
# Restart backend
docker-compose restart backend
```
### Database connection issues
```bash
# Check postgres is healthy
docker-compose ps
# Restart postgres
docker-compose restart postgres
```
### Scraper errors
```bash
# View detailed logs
docker-compose logs -f backend | grep -A 5 "error"
# Check scrape log in database
docker exec -it rmtpw-postgres psql -U rmtpw -d rmtpocketwatcher -c "SELECT * FROM scrape_log ORDER BY timestamp DESC LIMIT 10;"
```
## File Structure
```
rmtPocketWatcher/
├── docker-compose.yml # Docker orchestration
├── .env # Environment variables
├── backend/
│ ├── Dockerfile # Backend container definition
│ ├── prisma/
│ │ └── schema.prisma # Database schema
│ ├── src/
│ │ ├── scrapers/ # Scraping logic
│ │ ├── api/ # REST + WebSocket API
│ │ ├── database/ # Prisma client & repository
│ │ └── index.ts # Main server
│ └── package.json
└── README.md
```

12
backend/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.env
.env.local
*.log
.DS_Store
coverage
.vscode
.idea
npm-debug.log*
yarn-debug.log*
yarn-error.log*

14
backend/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/rmtpocketwatcher?schema=public"
# Scraper Configuration
SCRAPE_INTERVAL_MINUTES=5
SCRAPER_HEADLESS=true
SCRAPER_TIMEOUT=30000
SCRAPER_MAX_RETRIES=3
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=info

9
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
coverage/
.vscode/
.idea/

49
backend/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM node:20-slim
# Install Playwright dependencies and PostgreSQL client
RUN apt-get update && apt-get install -y \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies (including dev dependencies for Prisma)
RUN npm install
# Install Playwright browsers
RUN npx playwright install chromium --with-deps
# Generate Prisma client
RUN npx prisma generate
# Copy application code
COPY . .
# Build TypeScript
RUN npm run build
# Expose port
EXPOSE 3000
# Start application (migrations will be run via docker-compose command)
CMD ["npm", "start"]

120
backend/README.md Normal file
View File

@@ -0,0 +1,120 @@
# rmtPocketWatcher Backend
Backend scraping and API service for rmtPocketWatcher.
## Features
- Playwright-based web scraping for Eldorado and PlayerAuctions
- Automatic retry logic (3 attempts with exponential backoff)
- Scheduled scraping every 5 minutes (configurable)
- PostgreSQL + Prisma for data storage
- REST API + WebSocket for real-time updates
- Tracks all seller listings over time
## Setup
```bash
# Install dependencies
npm install
# Install Playwright browsers
npx playwright install chromium
# Setup environment
cp .env.example .env
# Edit .env with your PostgreSQL connection string
# Generate Prisma client
npm run db:generate
# Run database migrations
npm run db:migrate
```
## Quick Start with Docker Compose
The easiest way to run the entire stack:
```bash
# From the project root
docker-compose up -d
# View logs
docker-compose logs -f backend
# Stop everything
docker-compose down
```
This will:
- Start PostgreSQL database
- Run database migrations
- Start the backend API server
- Begin scraping every 5 minutes
## Local Development Setup
If you want to run locally without Docker:
```bash
# Start PostgreSQL with Docker
docker-compose up -d postgres
# Install dependencies
cd backend
npm install
# Setup environment
cp .env.example .env
# Edit .env if needed
# Generate Prisma client and push schema
npm run db:generate
npm run db:push
# Install Playwright browsers
npx playwright install chromium
```
## Usage
```bash
# Development mode with hot reload
npm run dev
# Manual scrape test (no database)
npm run scrape
# Build for production
npm run build
# Run production build
npm start
# Database management
npm run db:studio # Open Prisma Studio
npm run db:push # Push schema changes without migration
```
## API Endpoints
### Prices
- `GET /api/prices/latest` - Get all current listings
- `GET /api/prices/lowest` - Get current lowest price
- `GET /api/prices/by-seller?seller=&platform=` - Get seller's price history
- `GET /api/prices/by-platform?platform=` - Get all listings from a platform
- `GET /api/prices/history?from=&to=&seller=&platform=` - Historical data
### Index
- `GET /api/index/history?range=7d|30d|90d|all` - Lowest price over time
- `GET /api/index/ws` - WebSocket for real-time updates
### Health
- `GET /health` - Server health check
## Architecture
- `src/scrapers/` - Playwright scraping modules
- `src/api/` - Fastify REST + WebSocket API
- `src/database/` - Prisma client and repository
- `prisma/` - Database schema and migrations

15
backend/jest.config.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
moduleFileExtensions: ['ts', 'js', 'json'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
};

5200
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "rmtpocketwatcher-backend",
"version": "1.0.0",
"description": "Backend scraping and API service for rmtPocketWatcher",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest",
"scrape": "tsx src/scrapers/manual-scrape.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"fastify": "^4.25.0",
"@fastify/websocket": "^10.0.0",
"@fastify/cors": "^9.0.0",
"playwright": "^1.40.0",
"prisma": "^5.7.0",
"@prisma/client": "^5.7.0",
"node-schedule": "^2.1.1",
"ws": "^8.16.0",
"zod": "^3.22.4",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/node-schedule": "^2.1.5",
"@types/ws": "^8.5.10",
"typescript": "^5.3.0",
"tsx": "^4.7.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.0",
"ts-jest": "^29.1.0"
}
}

View File

@@ -0,0 +1 @@
# Migrations directory

View File

@@ -0,0 +1,51 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model VendorPrice {
id String @id @default(uuid())
timestamp DateTime @default(now()) @db.Timestamptz(3)
vendor String // 'eldorado' or 'playerauctions'
sellerName String? @map("seller_name")
usdPrice Decimal @map("usd_price") @db.Decimal(12, 2)
auecAmount BigInt @map("auec_amount")
usdPerMillion Decimal @map("usd_per_million") @db.Decimal(12, 8)
deliveryTime String? @map("delivery_time")
url String
@@index([timestamp])
@@index([vendor])
@@index([sellerName])
@@index([usdPerMillion])
@@map("raw_vendor_prices")
}
model PriceIndex {
id String @id @default(uuid())
timestamp DateTime @default(now()) @db.Timestamptz(3)
lowestPrice Decimal @map("lowest_price") @db.Decimal(12, 8)
vendor String
sellerName String? @map("seller_name")
@@index([timestamp])
@@map("price_index")
}
model ScrapeLog {
id String @id @default(uuid())
timestamp DateTime @default(now()) @db.Timestamptz(3)
status String // 'success' or 'failure'
message String?
runtimeMs Int? @map("runtime_ms")
@@index([timestamp])
@@map("scrape_log")
}

View File

@@ -0,0 +1,55 @@
import { FastifyPluginAsync } from 'fastify';
import { PriceRepository } from '../../database/repository';
const repository = new PriceRepository();
export const indexRoutes: FastifyPluginAsync = async (server) => {
// GET /api/index/history?range=7d|30d|90d|all
server.get<{
Querystring: { range?: string };
}>('/history', async (request, reply) => {
try {
const { range = '7d' } = request.query;
const history = await repository.getIndexHistory(range);
return {
success: true,
data: history.map((h: any) => ({
timestamp: h.timestamp,
price: h.lowestPrice.toString(),
vendor: h.vendor,
seller: h.sellerName,
})),
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
// WebSocket endpoint for real-time updates
server.get('/ws', { websocket: true }, (connection: any, request: any) => {
connection.socket.on('message', (message: any) => {
// Echo back for now - will be replaced with real-time updates
connection.socket.send(message.toString());
});
// Send initial data
repository.getLowestPrice().then(lowest => {
if (lowest) {
connection.socket.send(JSON.stringify({
type: 'index',
data: {
timestamp: lowest.timestamp,
price: lowest.lowestPrice.toString(),
vendor: lowest.vendor,
seller: lowest.sellerName,
},
}));
}
});
});
};

View File

@@ -0,0 +1,158 @@
import { FastifyPluginAsync } from 'fastify';
import { PriceRepository } from '../../database/repository';
const repository = new PriceRepository();
export const priceRoutes: FastifyPluginAsync = async (server) => {
// GET /api/prices/latest - Get all current listings
server.get('/latest', async (request, reply) => {
try {
const prices = await repository.getLatestPrices();
return {
success: true,
data: prices.map((p: any) => ({
id: p.id,
timestamp: p.timestamp,
vendor: p.vendor,
seller: p.sellerName,
usdPrice: p.usdPrice.toString(),
auecAmount: p.auecAmount.toString(),
usdPerMillion: p.usdPerMillion.toString(),
deliveryTime: p.deliveryTime,
url: p.url,
})),
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
// GET /api/prices/lowest - Get current lowest price
server.get('/lowest', async (request, reply) => {
try {
const lowest = await repository.getLowestPrice();
if (!lowest) {
return { success: true, data: null };
}
return {
success: true,
data: {
timestamp: lowest.timestamp,
price: lowest.lowestPrice.toString(),
vendor: lowest.vendor,
seller: lowest.sellerName,
},
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
// GET /api/prices/by-seller?seller=&platform=
server.get<{
Querystring: { seller: string; platform?: string };
}>('/by-seller', async (request, reply) => {
try {
const { seller, platform } = request.query;
if (!seller) {
reply.code(400);
return { success: false, error: 'seller parameter is required' };
}
const prices = await repository.getPricesBySeller(seller, platform);
return {
success: true,
data: prices.map((p: any) => ({
timestamp: p.timestamp,
vendor: p.vendor,
seller: p.sellerName,
usdPerMillion: p.usdPerMillion.toString(),
deliveryTime: p.deliveryTime,
})),
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
// GET /api/prices/by-platform?platform=
server.get<{
Querystring: { platform: string };
}>('/by-platform', async (request, reply) => {
try {
const { platform } = request.query;
if (!platform) {
reply.code(400);
return { success: false, error: 'platform parameter is required' };
}
const prices = await repository.getPricesByPlatform(platform);
return {
success: true,
data: prices.map((p: any) => ({
timestamp: p.timestamp,
seller: p.sellerName,
usdPerMillion: p.usdPerMillion.toString(),
deliveryTime: p.deliveryTime,
})),
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
// GET /api/prices/history?from=&to=&seller=&platform=
server.get<{
Querystring: { from: string; to: string; seller?: string; platform?: string };
}>('/history', async (request, reply) => {
try {
const { from, to, seller, platform } = request.query;
if (!from || !to) {
reply.code(400);
return { success: false, error: 'from and to parameters are required' };
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
reply.code(400);
return { success: false, error: 'Invalid date format' };
}
const prices = await repository.getPriceHistory(fromDate, toDate, seller, platform);
return {
success: true,
data: prices.map((p: any) => ({
timestamp: p.timestamp,
vendor: p.vendor,
seller: p.sellerName,
usdPerMillion: p.usdPerMillion.toString(),
deliveryTime: p.deliveryTime,
})),
};
} catch (error) {
reply.code(500);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
};

View File

@@ -0,0 +1,153 @@
import { FastifyPluginAsync } from 'fastify';
import { WebSocket } from 'ws';
import { PriceRepository } from '../../database/repository';
interface WebSocketMessage {
type: string;
data?: any;
}
interface WebSocketConnection {
socket: WebSocket;
}
const clients = new Set<WebSocketConnection>();
const repository = new PriceRepository();
export const websocketRoutes: FastifyPluginAsync = async (server) => {
server.log.info('WebSocket route registered at /ws');
server.get('/ws', { websocket: true }, (socket: any, request: any) => {
server.log.info('WebSocket client connected');
console.log('✓ WebSocket client connected');
const connection = { socket };
clients.add(connection);
// Send connection confirmation
socket.send(JSON.stringify({
type: 'connection_status',
data: { connected: true, message: 'Connected to rmtPocketWatcher' }
}));
// Handle incoming messages
socket.on('message', async (message: Buffer) => {
try {
const parsed: WebSocketMessage = JSON.parse(message.toString());
if (parsed.type === 'subscribe' && parsed.data?.channel === 'price_updates') {
// Send latest price data on subscription
const latestPrices = await repository.getLatestPrices();
if (latestPrices.length > 0) {
const lowestPrice = latestPrices.reduce((min: any, p: any) =>
Number(p.usdPerMillion) < Number(min.usdPerMillion) ? p : min
);
socket.send(JSON.stringify({
type: 'price_update',
data: {
timestamp: new Date(),
lowestPrice: Number(lowestPrice.usdPerMillion),
platform: lowestPrice.vendor,
sellerName: lowestPrice.sellerName,
allPrices: latestPrices.map((p: any) => ({
id: p.id,
platform: p.vendor,
sellerName: p.sellerName,
pricePerMillion: Number(p.usdPerMillion),
timestamp: p.timestamp,
url: p.url
}))
}
}));
}
} else if (parsed.type === 'get_history') {
// Handle historical data request
const { range } = parsed.data || {};
const now = new Date();
let from = new Date();
switch (range) {
case '6h':
from = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case '24h':
from = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case '3d':
from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
break;
case '7d':
from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case '1mo':
from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case 'ytd':
from = new Date(now.getFullYear(), 0, 1);
break;
default:
from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
const history = await repository.getPriceHistory(from, now);
const payload = {
type: 'history_data',
data: {
range,
from,
to: now,
prices: history.map((p: any) => ({
id: p.id,
platform: p.vendor,
sellerName: p.sellerName,
pricePerMillion: Number(p.usdPerMillion),
timestamp: p.timestamp,
url: p.url
}))
}
};
server.log.info(`Sending history data: ${payload.data.prices.length} prices for range ${range}`);
socket.send(JSON.stringify(payload));
}
} catch (error: any) {
server.log.error({ error }, 'Error handling WebSocket message');
socket.send(JSON.stringify({
type: 'error',
data: 'Invalid message format'
}));
}
});
socket.on('close', () => {
server.log.info('WebSocket client disconnected');
clients.delete(connection);
});
socket.on('error', (error: any) => {
server.log.error({ error }, 'WebSocket error');
clients.delete(connection);
});
});
};
// Broadcast price updates to all connected clients
export function broadcastPriceUpdate(data: any): void {
const message = JSON.stringify({
type: 'price_update',
data
});
clients.forEach((connection) => {
try {
if (connection.socket.readyState === 1) { // OPEN
connection.socket.send(message);
}
} catch (error) {
console.error('Error broadcasting to client:', error);
}
});
}

36
backend/src/api/server.ts Normal file
View File

@@ -0,0 +1,36 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import websocket from '@fastify/websocket';
import { priceRoutes } from './routes/prices';
import { indexRoutes } from './routes/index';
import { websocketRoutes } from './routes/websocket';
const server = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
});
// Register plugins
server.register(cors, {
origin: true,
});
server.register(websocket);
// Register routes
server.register(priceRoutes, { prefix: '/api/prices' });
server.register(indexRoutes, { prefix: '/api/index' });
server.register(websocketRoutes);
// Health check
server.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Test endpoint to trigger scrape
server.get('/api/test/scrape', async () => {
return { message: 'Scrape will be triggered by scheduler' };
});
export default server;

View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
export default prisma;

View File

@@ -0,0 +1,195 @@
import prisma from './prisma';
import { VendorListing } from '../scrapers/types';
export class PriceRepository {
async saveListings(listings: VendorListing[]): Promise<void> {
// Use a single timestamp for all listings in this batch
const batchTimestamp = new Date();
await prisma.vendorPrice.createMany({
data: listings.map(listing => ({
vendor: listing.vendor,
sellerName: listing.seller,
usdPrice: listing.priceUSD,
auecAmount: listing.amountAUEC,
usdPerMillion: listing.pricePerMillion,
deliveryTime: listing.deliveryTime,
url: listing.url,
timestamp: batchTimestamp,
})),
});
}
async savePriceIndex(lowestPrice: number, vendor: string, sellerName?: string): Promise<void> {
await prisma.priceIndex.create({
data: {
lowestPrice,
vendor,
sellerName,
},
});
}
async logScrape(status: string, message?: string, runtimeMs?: number): Promise<void> {
await prisma.scrapeLog.create({
data: {
status,
message,
runtimeMs,
},
});
}
async getLatestPrices() {
// Get the most recent timestamp across all vendors
const latest = await prisma.vendorPrice.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
});
if (!latest) return [];
// Get all prices from that timestamp (all vendors)
return prisma.vendorPrice.findMany({
where: { timestamp: latest.timestamp },
orderBy: { usdPerMillion: 'asc' },
});
}
async getLatestPricesOld() {
// Get the most recent timestamp for each vendor
const latestEldorado = await prisma.vendorPrice.findFirst({
where: { vendor: 'eldorado' },
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
});
const latestPlayerAuctions = await prisma.vendorPrice.findFirst({
where: { vendor: 'playerauctions' },
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
});
// Get all prices from the latest scrape of each vendor
const prices = [];
if (latestEldorado) {
const eldoradoPrices = await prisma.vendorPrice.findMany({
where: {
vendor: 'eldorado',
timestamp: latestEldorado.timestamp,
},
});
prices.push(...eldoradoPrices);
}
if (latestPlayerAuctions) {
// Get all PlayerAuctions listings within 30 seconds of the latest timestamp
const timeWindow = new Date(latestPlayerAuctions.timestamp.getTime() - 30000);
const paPrices = await prisma.vendorPrice.findMany({
where: {
vendor: 'playerauctions',
timestamp: {
gte: timeWindow,
lte: latestPlayerAuctions.timestamp,
},
},
});
prices.push(...paPrices);
}
// Sort by price
return prices.sort((a, b) =>
Number(a.usdPerMillion) - Number(b.usdPerMillion)
);
}
async getLowestPrice() {
const latest = await prisma.priceIndex.findFirst({
orderBy: { timestamp: 'desc' },
});
return latest;
}
async getPricesBySeller(seller: string, platform?: string) {
return prisma.vendorPrice.findMany({
where: {
sellerName: seller,
...(platform && { vendor: platform }),
},
orderBy: { timestamp: 'desc' },
take: 100,
});
}
async getPricesByPlatform(platform: string) {
const latest = await prisma.vendorPrice.findFirst({
where: { vendor: platform },
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
});
if (!latest) return [];
return prisma.vendorPrice.findMany({
where: {
vendor: platform,
timestamp: latest.timestamp,
},
orderBy: { usdPerMillion: 'asc' },
});
}
async getPriceHistory(from: Date, to: Date, seller?: string, platform?: string) {
return prisma.vendorPrice.findMany({
where: {
timestamp: {
gte: from,
lte: to,
},
...(seller && { sellerName: seller }),
...(platform && { vendor: platform }),
},
orderBy: { timestamp: 'asc' },
});
}
async getIndexHistory(range: string) {
const now = new Date();
let from = new Date();
switch (range) {
case '1h':
from = new Date(now.getTime() - 60 * 60 * 1000);
break;
case '6h':
from = new Date(now.getTime() - 6 * 60 * 60 * 1000);
break;
case '24h':
from = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case '7d':
from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case '30d':
from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case '90d':
from = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
case 'all':
from = new Date(0);
break;
default:
from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
return prisma.priceIndex.findMany({
where: {
timestamp: { gte: from },
},
orderBy: { timestamp: 'asc' },
});
}
}

91
backend/src/index.ts Normal file
View File

@@ -0,0 +1,91 @@
import 'dotenv/config';
import server from './api/server';
import { ScraperScheduler } from './scrapers/scheduler';
import { PriceRepository } from './database/repository';
import { broadcastPriceUpdate } from './api/routes/websocket';
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
const SCRAPE_INTERVAL = parseInt(process.env.SCRAPE_INTERVAL_MINUTES || '5', 10);
const repository = new PriceRepository();
const scheduler = new ScraperScheduler(SCRAPE_INTERVAL);
// Handle scrape results
scheduler.onScrapeComplete(async (results) => {
const startTime = Date.now();
try {
// Save all listings
const allListings = results.flatMap(r => r.listings);
if (allListings.length > 0) {
await repository.saveListings(allListings);
// Find and save lowest price
const lowestListing = allListings.reduce((min, listing) =>
listing.pricePerMillion < min.pricePerMillion ? listing : min
);
await repository.savePriceIndex(
lowestListing.pricePerMillion,
lowestListing.vendor,
lowestListing.seller
);
// Broadcast update to WebSocket clients
const latestPrices = await repository.getLatestPrices();
broadcastPriceUpdate({
timestamp: new Date(),
lowestPrice: lowestListing.pricePerMillion,
platform: lowestListing.vendor,
sellerName: lowestListing.seller,
allPrices: latestPrices.map((p: any) => ({
id: p.id,
platform: p.vendor,
sellerName: p.sellerName,
pricePerMillion: Number(p.usdPerMillion),
timestamp: p.timestamp,
url: p.url
}))
});
console.log(`✓ Broadcasted price update to ${latestPrices.length} listings`);
}
// Log successful scrape
const runtimeMs = Date.now() - startTime;
await repository.logScrape('success', `Saved ${allListings.length} listings`, runtimeMs);
console.log(`✓ Saved ${allListings.length} listings to database`);
} catch (error) {
const runtimeMs = Date.now() - startTime;
const message = error instanceof Error ? error.message : 'Unknown error';
await repository.logScrape('failure', message, runtimeMs);
console.error('✗ Failed to save listings:', message);
}
});
// Start server
const start = async () => {
try {
await server.listen({ port: PORT, host: HOST });
console.log(`Server listening on ${HOST}:${PORT}`);
// Start scraper
scheduler.start();
console.log(`Scraper started (interval: ${SCRAPE_INTERVAL} minutes)`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');
await scheduler.close();
await server.close();
process.exit(0);
});
start();

View File

@@ -0,0 +1,92 @@
import { Browser, Page, chromium } from 'playwright';
import { VendorListing, ScrapeResult, ScraperConfig } from './types';
export abstract class BaseScraper {
protected config: ScraperConfig;
protected browser: Browser | null = null;
constructor(config: Partial<ScraperConfig> = {}) {
this.config = {
maxRetries: 3,
retryDelay: 2000,
timeout: 30000,
headless: true,
...config,
};
}
abstract getVendorName(): 'eldorado' | 'playerauctions';
abstract getTargetUrl(): string;
abstract extractListings(page: Page): Promise<VendorListing[]>;
async scrape(): Promise<ScrapeResult> {
const vendor = this.getVendorName();
let lastError: string | undefined;
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
try {
const listings = await this.performScrape();
return {
success: true,
vendor,
listings,
scrapedAt: new Date(),
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
if (attempt < this.config.maxRetries) {
await this.delay(this.config.retryDelay);
}
}
}
return {
success: false,
vendor,
listings: [],
error: lastError,
scrapedAt: new Date(),
};
}
private async performScrape(): Promise<VendorListing[]> {
this.browser = await chromium.launch({ headless: this.config.headless });
try {
const context = await this.browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
});
const page = await context.newPage();
page.setDefaultTimeout(this.config.timeout);
await page.goto(this.getTargetUrl(), { waitUntil: 'networkidle' });
const listings = await this.extractListings(page);
await context.close();
return listings;
} finally {
await this.browser?.close();
this.browser = null;
}
}
protected calculatePricePerMillion(amountAUEC: number, priceUSD: number): number {
return (priceUSD / amountAUEC) * 1_000_000;
}
protected delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async close(): Promise<void> {
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
}

View File

@@ -0,0 +1,151 @@
import { Page } from 'playwright';
import { BaseScraper } from './base-scraper';
import { VendorListing } from './types';
export class EldoradoScraper extends BaseScraper {
getVendorName(): 'eldorado' {
return 'eldorado';
}
getTargetUrl(): string {
return 'https://www.eldorado.gg/star-citizen-auec/g/141-0-0';
}
async extractListings(page: Page): Promise<VendorListing[]> {
// Wait for page readiness
await page.waitForSelector('text=Star Citizen aUEC', { timeout: 15000 }).catch(() => {});
// Wait for price elements to appear
await page.waitForTimeout(3000);
const listings = await page.evaluate(() => {
const results: Array<{
amountAUEC: number;
priceUSD: number;
pricePerMillion: number;
seller?: string;
deliveryTime?: string;
}> = [];
const bodyText = document.body.innerText;
const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
// Track seen combinations to avoid duplicates
const seenListings = new Set<string>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Look for "$/M" pattern - this is the direct price per million
// Examples: "$0.00007 / M", "$0.00018 / M", "$0.00007/M"
const pricePerMMatch = line.match(/\$\s*([\d.]+)\s*\/?\s*\/\s*M/i) || line.match(/\$\s*([\d.]+)\s*\/\s*M/i);
if (pricePerMMatch) {
const pricePerMillion = parseFloat(pricePerMMatch[1]);
// Look for "Min. qty" or "Min qty" nearby to get the quantity
let minQtyM = 10000; // Default to 10000M
for (let j = Math.max(0, i - 5); j < Math.min(lines.length, i + 5); j++) {
const qtyLine = lines[j];
// Match patterns like "Min. qty. 6000 M" or "Min qty: 16,000 M"
const qtyMatch = qtyLine.match(/Min\.?\s*qty\.?\s*:?\s*([\d,]+)\s*M/i);
if (qtyMatch) {
minQtyM = parseFloat(qtyMatch[1].replace(/,/g, ''));
break;
}
}
const amountAUEC = minQtyM * 1_000_000;
const priceUSD = pricePerMillion * minQtyM;
// Find seller name - look both backwards and forwards
// For featured seller, name appears BEFORE the price
// For other sellers, name appears in a structured list
let seller: string | undefined;
// Search backwards first (for featured seller and some list items)
for (let j = Math.max(0, i - 20); j < i; j++) {
const sellerLine = lines[j];
// Skip common non-seller text
if (
sellerLine.includes('$') ||
sellerLine.includes('Price') ||
sellerLine.includes('qty') ||
sellerLine.includes('stock') ||
sellerLine.includes('Delivery') ||
sellerLine.toLowerCase().includes('review') ||
sellerLine.includes('Rating') ||
sellerLine.includes('Offer') ||
sellerLine.includes('Details') ||
sellerLine.includes('FEATURED') ||
sellerLine.includes('Other') ||
sellerLine.includes('sellers') ||
sellerLine.includes('aUEC') ||
sellerLine === 'Star Citizen' || // Exclude the game title but not seller names
sellerLine.includes('IMF') ||
sellerLine.includes('in-game') ||
sellerLine.includes('currency') ||
sellerLine.length < 3 ||
sellerLine.length > 30
) {
continue;
}
// Match seller name patterns - alphanumeric with underscores/hyphens
// Allow some special cases like "StarCitizen"
if (/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(sellerLine)) {
seller = sellerLine;
// Don't break - keep looking for a closer match
}
}
// Find delivery time
let deliveryTime: string | undefined;
for (let j = Math.max(0, i - 5); j < Math.min(lines.length, i + 5); j++) {
const deliveryLine = lines[j];
if (
deliveryLine.match(/\d+\s*min/i) ||
deliveryLine.match(/\d+\s*hour/i) ||
deliveryLine.toLowerCase().includes('instant')
) {
deliveryTime = deliveryLine;
break;
}
}
// Create unique key to avoid duplicates
const key = `${pricePerMillion}-${minQtyM}`;
if (seenListings.has(key)) continue;
seenListings.add(key);
results.push({
amountAUEC,
priceUSD,
pricePerMillion,
seller,
deliveryTime,
});
}
}
return results;
});
const scrapedAt = new Date();
const url = this.getTargetUrl();
return listings.map(listing => ({
vendor: 'eldorado' as const,
amountAUEC: listing.amountAUEC,
priceUSD: listing.priceUSD,
pricePerMillion: listing.pricePerMillion,
seller: listing.seller,
deliveryTime: listing.deliveryTime,
scrapedAt,
url,
}));
}
}

View File

@@ -0,0 +1,6 @@
export { BaseScraper } from './base-scraper';
export { EldoradoScraper } from './eldorado-scraper';
export { PlayerAuctionsScraper } from './playerauctions-scraper';
export { ScraperService } from './scraper-service';
export { ScraperScheduler } from './scheduler';
export type { VendorListing, ScrapeResult, ScraperConfig } from './types';

View File

@@ -0,0 +1,49 @@
import { ScraperService } from './scraper-service';
async function main() {
const scraperService = new ScraperService();
try {
console.log('Scraping in progress...\n');
const results = await scraperService.scrapeAll();
// Show completion status for each vendor
results.forEach(result => {
if (result.success) {
console.log(`${result.vendor.charAt(0).toUpperCase() + result.vendor.slice(1)} scraping done`);
} else {
console.log(`${result.vendor.charAt(0).toUpperCase() + result.vendor.slice(1)} scraping failed`);
}
});
// Show listings from each vendor
console.log('');
results.forEach(result => {
console.log(`[${result.vendor.toUpperCase()}] Found ${result.listings.length} listings`);
if (result.listings.length > 0) {
result.listings.forEach((listing, i) => {
console.log(` ${i + 1}. $${listing.pricePerMillion}/M (${listing.seller || 'Unknown'})`);
});
}
console.log('');
});
const allListings = results.flatMap(r => r.listings);
const lowestPrice = scraperService.calculatePriceIndex(allListings);
console.log('=== LOWEST PRICE ===');
if (lowestPrice) {
console.log(`$${lowestPrice}/M`);
} else {
console.log('No listings found');
}
} catch (error) {
console.error('Error:', error);
process.exit(1);
} finally {
await scraperService.close();
}
}
main();

View File

@@ -0,0 +1,202 @@
import { Page } from 'playwright';
import { BaseScraper } from './base-scraper';
import { VendorListing } from './types';
export class PlayerAuctionsScraper extends BaseScraper {
getVendorName(): 'playerauctions' {
return 'playerauctions';
}
getTargetUrl(): string {
return 'https://www.playerauctions.com/star-citizen-auec/';
}
async extractListings(page: Page): Promise<VendorListing[]> {
// Wait for page readiness
await page.waitForTimeout(3000);
// Close cookie popup if it exists
try {
const cookieClose = page.locator('[id*="cookie"] button, [class*="cookie"] button, button:has-text("Accept"), button:has-text("Close")').first();
if (await cookieClose.isVisible({ timeout: 2000 }).catch(() => false)) {
await cookieClose.click();
await page.waitForTimeout(500);
}
} catch (e) {
// No cookie popup or already closed
}
// Find offer cards - they have class "offer-item"
const offerCards = await page.locator('.offer-item, [class*="offer-item"]').all();
if (offerCards.length === 0) {
return this.extractListingsAlternative(page);
}
const listings: VendorListing[] = [];
const targetQuantityM = 100000; // 10000 M = 10 billion AUEC (field is already in millions)
// Step 2-5: Process each offer card
for (let i = 0; i < Math.min(offerCards.length, 20); i++) {
try {
const card = offerCards[i];
// Find the quantity input (shows number with "M" suffix, has +/- buttons)
const qtyInput = card.locator('input[type="number"]').first();
if (!(await qtyInput.isVisible({ timeout: 1000 }).catch(() => false))) {
continue;
}
// Set quantity to 10000 (which means 10000 M = 10 billion AUEC)
await qtyInput.scrollIntoViewIfNeeded();
await qtyInput.click({ force: true });
await qtyInput.fill('');
await qtyInput.pressSequentially(targetQuantityM.toString(), { delay: 10 });
await qtyInput.press('Enter'); // Trigger update
// Wait for price to update (0.5-2 seconds as per instructions)
await page.waitForTimeout(2500);
// Step 3: Extract the total price from the BUY NOW button area
// Look for the price near the BUY NOW button - it's typically in a large font
let totalPriceUSD = 0;
// Try to find the price element near BUY NOW button
const buyNowButton = card.locator('button:has-text("BUY NOW"), [class*="buy"]').first();
if (await buyNowButton.isVisible().catch(() => false)) {
// Get the parent container and look for price nearby
const priceContainer = buyNowButton.locator('xpath=..').first();
const priceText = await priceContainer.textContent().catch(() => '');
// Extract price - should be like "$5.00" in large text
if (priceText) {
const priceMatch = priceText.match(/\$\s*([\d,]+\.\d{2})/);
if (priceMatch) {
totalPriceUSD = parseFloat(priceMatch[1].replace(/,/g, ''));
}
}
}
// Fallback: look for price in the card, but exclude "Minutes" context
if (totalPriceUSD === 0) {
const cardText = await card.textContent().catch(() => '');
if (cardText) {
const lines = cardText.split('\n').map(l => l.trim());
for (const line of lines) {
// Skip lines that contain time indicators
if (line.includes('Minutes') || line.includes('Hours') || line.includes('Days')) {
continue;
}
// Look for price pattern with decimal
const priceMatch = line.match(/\$\s*([\d,]+\.\d{2})/);
if (priceMatch) {
const price = parseFloat(priceMatch[1].replace(/,/g, ''));
if (price > 0 && price < 100000) {
totalPriceUSD = price;
break;
}
}
}
}
}
if (totalPriceUSD === 0) {
continue;
}
// Step 4: Compute USD per 1M
const pricePerMillion = totalPriceUSD / targetQuantityM;
// Extract seller name and delivery time from card text
const fullCardText = await card.textContent().catch(() => '');
const sellerMatch = fullCardText ? fullCardText.match(/([a-zA-Z0-9_-]{3,20})/) : null;
const seller = sellerMatch ? sellerMatch[1] : 'Unknown';
const deliveryMatch = fullCardText ? fullCardText.match(/(\d+\s*(?:Minutes?|Hours?|Days?))/i) : null;
const deliveryTime = deliveryMatch ? deliveryMatch[1] : undefined;
listings.push({
vendor: 'playerauctions',
amountAUEC: targetQuantityM * 1_000_000,
priceUSD: totalPriceUSD,
pricePerMillion,
seller: seller.trim(),
deliveryTime,
scrapedAt: new Date(),
url: this.getTargetUrl(),
});
} catch (error) {
// Skip this card
}
}
if (listings.length === 0) {
return this.extractListingsAlternative(page);
}
return listings;
}
private async extractListingsAlternative(page: Page): Promise<VendorListing[]> {
const listings = await page.evaluate(() => {
const results: Array<{
amountAUEC: number;
priceUSD: number;
pricePerMillion: number;
seller?: string;
}> = [];
const bodyText = document.body.innerText;
const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const seenPrices = new Set<number>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Look for "$1 = X M aUEC" pattern and convert
const exchangeMatch = line.match(/\$1\s*=\s*([\d,]+(?:\.\d+)?)\s*M\s*(?:aUEC)?/i);
if (exchangeMatch) {
const millionsPerDollar = parseFloat(exchangeMatch[1].replace(/,/g, ''));
const pricePerMillion = 1 / millionsPerDollar;
if (seenPrices.has(pricePerMillion)) continue;
seenPrices.add(pricePerMillion);
const targetQuantityM = 100000;
const amountAUEC = targetQuantityM * 1_000_000;
const priceUSD = pricePerMillion * targetQuantityM;
results.push({
amountAUEC,
priceUSD,
pricePerMillion,
seller: 'Unknown',
});
}
}
return results;
});
const scrapedAt = new Date();
const url = this.getTargetUrl();
return listings.map(listing => ({
vendor: 'playerauctions' as const,
amountAUEC: listing.amountAUEC,
priceUSD: listing.priceUSD,
pricePerMillion: listing.pricePerMillion,
seller: listing.seller,
deliveryTime: undefined,
scrapedAt,
url,
}));
}
}

View File

@@ -0,0 +1,80 @@
import * as schedule from 'node-schedule';
import { ScraperService } from './scraper-service';
import { ScrapeResult } from './types';
export type ScrapeCallback = (results: ScrapeResult[]) => Promise<void>;
export class ScraperScheduler {
private scraperService: ScraperService;
private job: schedule.Job | null = null;
private callback: ScrapeCallback | null = null;
private intervalMinutes: number;
constructor(intervalMinutes: number = 5) {
this.scraperService = new ScraperService();
this.intervalMinutes = intervalMinutes;
}
onScrapeComplete(callback: ScrapeCallback): void {
this.callback = callback;
}
start(): void {
if (this.job) {
console.log('Scheduler already running');
return;
}
// Run immediately on start
this.runScrape();
// Schedule recurring scrapes
const rule = `*/${this.intervalMinutes} * * * *`;
this.job = schedule.scheduleJob(rule, () => {
this.runScrape();
});
console.log(`Scraper scheduled to run every ${this.intervalMinutes} minutes`);
}
stop(): void {
if (this.job) {
this.job.cancel();
this.job = null;
console.log('Scraper scheduler stopped');
}
}
async runScrape(): Promise<void> {
try {
console.log(`[${new Date().toISOString()}] Running scheduled scrape...`);
const results = await this.scraperService.scrapeAll();
const successCount = results.filter(r => r.success).length;
const totalListings = results.reduce((sum, r) => sum + r.listings.length, 0);
console.log(`Scrape complete: ${successCount}/${results.length} vendors successful, ${totalListings} listings`);
if (this.callback) {
await this.callback(results);
}
} catch (error) {
console.error('Error during scheduled scrape:', error);
}
}
setInterval(minutes: number): void {
this.intervalMinutes = minutes;
if (this.job) {
this.stop();
this.start();
}
}
async close(): Promise<void> {
this.stop();
await this.scraperService.close();
}
}

View File

@@ -0,0 +1,59 @@
import { EldoradoScraper } from './eldorado-scraper';
import { PlayerAuctionsScraper } from './playerauctions-scraper';
import { ScrapeResult, VendorListing } from './types';
export class ScraperService {
private eldoradoScraper: EldoradoScraper;
private playerAuctionsScraper: PlayerAuctionsScraper;
constructor() {
this.eldoradoScraper = new EldoradoScraper();
this.playerAuctionsScraper = new PlayerAuctionsScraper();
}
async scrapeAll(): Promise<ScrapeResult[]> {
const results = await Promise.allSettled([
this.eldoradoScraper.scrape(),
this.playerAuctionsScraper.scrape(),
]);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
const vendor = index === 0 ? 'eldorado' : 'playerauctions';
return {
success: false,
vendor,
listings: [],
error: result.reason?.message || 'Unknown error',
scrapedAt: new Date(),
};
}
});
}
async scrapeEldorado(): Promise<ScrapeResult> {
return this.eldoradoScraper.scrape();
}
async scrapePlayerAuctions(): Promise<ScrapeResult> {
return this.playerAuctionsScraper.scrape();
}
calculatePriceIndex(listings: VendorListing[]): number | null {
if (listings.length === 0) return null;
const prices = listings.map(l => l.pricePerMillion);
// Return the lowest price
return Math.min(...prices);
}
async close(): Promise<void> {
await Promise.all([
this.eldoradoScraper.close(),
this.playerAuctionsScraper.close(),
]);
}
}

View File

@@ -0,0 +1,25 @@
export interface VendorListing {
vendor: 'eldorado' | 'playerauctions';
amountAUEC: number;
priceUSD: number;
pricePerMillion: number;
seller?: string;
deliveryTime?: string;
scrapedAt: Date;
url: string;
}
export interface ScrapeResult {
success: boolean;
vendor: string;
listings: VendorListing[];
error?: string;
scrapedAt: Date;
}
export interface ScraperConfig {
maxRetries: number;
retryDelay: number;
timeout: number;
headless: boolean;
}

26
backend/test-ws.js Normal file
View File

@@ -0,0 +1,26 @@
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:3000/ws');
ws.on('open', () => {
console.log('✓ Connected to WebSocket');
ws.send(JSON.stringify({ type: 'subscribe', data: { channel: 'price_updates' } }));
});
ws.on('message', (data) => {
console.log('✓ Received:', data.toString());
});
ws.on('close', (code, reason) => {
console.log(`✗ Connection closed: ${code} - ${reason}`);
});
ws.on('error', (error) => {
console.error('✗ WebSocket error:', error.message);
});
setTimeout(() => {
console.log('Closing connection...');
ws.close();
process.exit(0);
}, 5000);

View File

@@ -0,0 +1,55 @@
import { ScraperService } from '../../src/scrapers/scraper-service';
import { VendorListing } from '../../src/scrapers/types';
describe('ScraperService', () => {
let service: ScraperService;
beforeEach(() => {
service = new ScraperService();
});
afterEach(async () => {
await service.close();
});
describe('calculatePriceIndex', () => {
it('should return null for empty listings', () => {
const result = service.calculatePriceIndex([]);
expect(result).toBeNull();
});
it('should calculate median for odd number of listings', () => {
const listings: VendorListing[] = [
{ pricePerMillion: 10 } as VendorListing,
{ pricePerMillion: 20 } as VendorListing,
{ pricePerMillion: 30 } as VendorListing,
];
const result = service.calculatePriceIndex(listings);
expect(result).toBe(20);
});
it('should calculate median for even number of listings', () => {
const listings: VendorListing[] = [
{ pricePerMillion: 10 } as VendorListing,
{ pricePerMillion: 20 } as VendorListing,
{ pricePerMillion: 30 } as VendorListing,
{ pricePerMillion: 40 } as VendorListing,
];
const result = service.calculatePriceIndex(listings);
expect(result).toBe(25);
});
it('should handle unsorted listings', () => {
const listings: VendorListing[] = [
{ pricePerMillion: 30 } as VendorListing,
{ pricePerMillion: 10 } as VendorListing,
{ pricePerMillion: 20 } as VendorListing,
];
const result = service.calculatePriceIndex(listings);
expect(result).toBe(20);
});
});
});

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: rmtpw-postgres
restart: unless-stopped
environment:
POSTGRES_USER: rmtpw
POSTGRES_PASSWORD: rmtpw_password
POSTGRES_DB: rmtpocketwatcher
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rmtpw -d rmtpocketwatcher"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: rmtpw-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://rmtpw:rmtpw_password@postgres:5432/rmtpocketwatcher?schema=public
PORT: 3000
HOST: 0.0.0.0
NODE_ENV: production
SCRAPE_INTERVAL_MINUTES: 5
SCRAPER_HEADLESS: "true"
SCRAPER_TIMEOUT: 30000
SCRAPER_MAX_RETRIES: 3
ports:
- "3000:3000"
volumes:
- ./backend:/app
- /app/node_modules
- /app/dist
command: sh -c "npm run db:push && npm start"
volumes:
postgres_data:
driver: local

View File

@@ -0,0 +1,5 @@
# WebSocket connection URL
WS_URL=ws://localhost:3000/ws
# Development mode
NODE_ENV=development

62
electron-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,62 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
dist-electron/
out/
build/
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store
# OS files
Thumbs.db
Desktop.ini
# Electron specific
*.asar
*.dmg
*.exe
*.AppImage
*.deb
*.rpm
*.snap
# Release builds
release/
releases/
# TypeScript
*.tsbuildinfo
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
.cache/

61
electron-app/README.md Normal file
View File

@@ -0,0 +1,61 @@
# rmtPocketWatcher - Electron Desktop App
Cross-platform desktop application for tracking AUEC RMT prices in real-time.
## WebSocket Implementation
The app uses a robust WebSocket client with the following features:
### Main Process (src/main/)
- **websocket-client.ts**: Core WebSocket client with auto-reconnect logic
- Exponential backoff reconnection (max 10 attempts)
- Event-based architecture for easy integration
- Connection state management
- **ipc-handlers.ts**: IPC bridge between main and renderer processes
- Forwards WebSocket events to renderer
- Handles renderer commands (connect/disconnect/send)
- Auto-connects on app startup
### Renderer Process (src/renderer/)
- **hooks/useWebSocket.ts**: React hook for WebSocket integration
- Manages connection status
- Provides latest price updates
- Exposes connect/disconnect/send methods
### Shared Types (src/shared/)
- **types.ts**: TypeScript interfaces shared between processes
- PriceIndex, VendorPrice, WebSocketMessage
- Ensures type safety across IPC boundary
## Setup
```bash
npm install
```
## Development
```bash
# Start Vite dev server and Electron
npm run electron:dev
```
## Build
```bash
# Build for production
npm run electron:build
```
## Environment Variables
Copy `.env.example` to `.env` and configure:
- `WS_URL`: WebSocket server URL (default: ws://localhost:3000/ws)
## Security
- Context isolation enabled
- Sandbox mode enabled
- No remote code evaluation
- Secure IPC communication via preload script

51
electron-app/index.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>rmtPocketWatcher</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
}
/* Custom scrollbar */
#root::-webkit-scrollbar {
width: 12px;
}
#root::-webkit-scrollbar-track {
background: #1a1f3a;
}
#root::-webkit-scrollbar-thumb {
background: #50e3c2;
border-radius: 6px;
}
#root::-webkit-scrollbar-thumb:hover {
background: #3cc9a8;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/index.tsx"></script>
</body>
</html>

61
electron-app/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "rmtpocketwatcher",
"version": "1.0.0",
"description": "Real-time AUEC price tracking desktop application",
"main": "dist/main/index.js",
"scripts": {
"dev": "vite",
"build:main": "tsc --project tsconfig.main.json && tsc --project tsconfig.preload.json",
"build:renderer": "vite build",
"build": "npm run build:main && npm run build:renderer",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"",
"electron:build": "npm run build && electron-builder",
"test": "jest"
},
"dependencies": {
"electron": "^30.0.0",
"electron-updater": "^6.1.0",
"recharts": "^3.5.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.3.27",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^8.2.0",
"cross-env": "^10.1.0",
"electron-builder": "^24.9.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.lambdabanking.rmtpocketwatcher",
"productName": "rmtPocketWatcher",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"win": {
"target": [
"nsis"
]
},
"mac": {
"target": [
"dmg"
]
},
"linux": {
"target": [
"AppImage"
]
}
}
}

View File

@@ -0,0 +1,53 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
title: 'rmtPocketWatcher',
});
// Setup IPC handlers for WebSocket communication
setupIpcHandlers(mainWindow);
// Load the app
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
}
mainWindow.on('closed', () => {
cleanupIpcHandlers();
mainWindow = null;
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.on('before-quit', () => {
cleanupIpcHandlers();
});

View File

@@ -0,0 +1,73 @@
import { ipcMain, BrowserWindow } from 'electron';
import { WebSocketClient } from './websocket-client';
import { PriceIndex } from '../shared/types';
let wsClient: WebSocketClient | null = null;
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
// Initialize WebSocket client
wsClient = new WebSocketClient(wsUrl);
// Forward WebSocket events to renderer
wsClient.on('connected', () => {
mainWindow.webContents.send('ws:connected');
});
wsClient.on('disconnected', () => {
mainWindow.webContents.send('ws:disconnected');
});
wsClient.on('priceUpdate', (data: PriceIndex) => {
mainWindow.webContents.send('ws:priceUpdate', data);
});
wsClient.on('historyData', (data: any) => {
mainWindow.webContents.send('ws:historyData', data);
});
wsClient.on('error', (error: Error) => {
mainWindow.webContents.send('ws:error', error.message);
});
wsClient.on('maxReconnectAttemptsReached', () => {
mainWindow.webContents.send('ws:maxReconnectAttemptsReached');
});
// IPC handlers for renderer requests
ipcMain.handle('ws:connect', async () => {
wsClient?.connect();
return { success: true };
});
ipcMain.handle('ws:disconnect', async () => {
wsClient?.disconnect();
return { success: true };
});
ipcMain.handle('ws:getStatus', async () => {
return {
connected: wsClient?.isConnected() || false,
state: wsClient?.getConnectionState() || 'DISCONNECTED',
};
});
ipcMain.handle('ws:send', async (_event, message: any) => {
wsClient?.sendMessage(message);
return { success: true };
});
// Auto-connect on startup
wsClient.connect();
}
export function cleanupIpcHandlers(): void {
wsClient?.disconnect();
wsClient = null;
ipcMain.removeHandler('ws:connect');
ipcMain.removeHandler('ws:disconnect');
ipcMain.removeHandler('ws:getStatus');
ipcMain.removeHandler('ws:send');
}

View File

@@ -0,0 +1,145 @@
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import { PriceIndex, WebSocketMessage, HistoricalData } from '../shared/types';
export class WebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private url: string;
private reconnectInterval: number = 5000;
private reconnectTimer: NodeJS.Timeout | null = null;
private isIntentionallyClosed: boolean = false;
private maxReconnectAttempts: number = 10;
private reconnectAttempts: number = 0;
constructor(url: string) {
super();
this.url = url;
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
console.log('WebSocket already connected');
return;
}
this.isIntentionallyClosed = false;
console.log(`Connecting to WebSocket: ${this.url}`);
try {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.emit('connected');
this.sendMessage({ type: 'subscribe', data: { channel: 'price_updates' } });
});
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message: WebSocketMessage = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
});
this.ws.on('close', (code: number, reason: Buffer) => {
console.log(`WebSocket closed: ${code} - ${reason.toString()}`);
this.emit('disconnected');
if (!this.isIntentionallyClosed) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error: Error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
private handleMessage(message: WebSocketMessage): void {
switch (message.type) {
case 'price_update':
this.emit('priceUpdate', message.data as PriceIndex);
break;
case 'history_data':
this.emit('historyData', message.data as HistoricalData);
break;
case 'connection_status':
this.emit('status', message.data);
break;
case 'error':
this.emit('error', new Error(message.data));
break;
default:
console.warn('Unknown message type:', message.type);
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.isIntentionallyClosed) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
this.emit('maxReconnectAttemptsReached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.reconnectInterval * this.reconnectAttempts, 30000);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
sendMessage(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected, cannot send message');
}
}
disconnect(): void {
this.isIntentionallyClosed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
getConnectionState(): string {
if (!this.ws) return 'DISCONNECTED';
switch (this.ws.readyState) {
case WebSocket.CONNECTING: return 'CONNECTING';
case WebSocket.OPEN: return 'CONNECTED';
case WebSocket.CLOSING: return 'CLOSING';
case WebSocket.CLOSED: return 'DISCONNECTED';
default: return 'UNKNOWN';
}
}
}

View File

@@ -0,0 +1,34 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
ipcRenderer: {
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
on: (channel: string, func: (...args: any[]) => void) => {
ipcRenderer.on(channel, (_event: any, ...args: any[]) => func(...args));
},
once: (channel: string, func: (...args: any[]) => void) => {
ipcRenderer.once(channel, (_event: any, ...args: any[]) => func(...args));
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
},
},
});
// Type definitions for TypeScript
export interface IElectronAPI {
ipcRenderer: {
invoke: (channel: string, ...args: any[]) => Promise<any>;
on: (channel: string, func: (...args: any[]) => void) => void;
once: (channel: string, func: (...args: any[]) => void) => void;
removeAllListeners: (channel: string) => void;
};
}
declare global {
interface Window {
electron: IElectronAPI;
}
}

View File

@@ -0,0 +1,303 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const TIME_RANGES = [
{ label: '6 Hours', value: '6h' },
{ label: '24 Hours', value: '24h' },
{ label: '3 Days', value: '3d' },
{ label: '7 Days', value: '7d' },
{ label: '1 Month', value: '1mo' },
{ label: 'YTD', value: 'ytd' },
];
const COLORS = [
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#a28fd0', '#f5a623',
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c'
];
export function App() {
const { status, latestPrice, historyData, requestHistory } = useWebSocket();
const [selectedRange, setSelectedRange] = useState('7d');
const [zoomLevel, setZoomLevel] = useState(1);
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (status.connected) {
requestHistory(selectedRange);
}
}, [status.connected, selectedRange, requestHistory]);
const chartData = useMemo(() => {
if (!historyData?.prices) return [];
// Group by timestamp and seller
const grouped = new Map<string, any>();
historyData.prices.forEach(price => {
const time = new Date(price.timestamp).getTime();
const key = time.toString();
if (!grouped.has(key)) {
grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() });
}
const entry = grouped.get(key);
const sellerKey = `${price.sellerName}`;
entry[sellerKey] = price.pricePerMillion;
});
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData]);
const sellers = useMemo(() => {
if (!historyData?.prices) return [];
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
return Array.from(uniqueSellers);
}, [historyData]);
const { minY, maxY } = useMemo(() => {
if (!historyData?.prices || historyData.prices.length === 0) {
return { minY: 0, maxY: 1 };
}
// Get all prices and sort them
const allPrices = historyData.prices.map(p => p.pricePerMillion).sort((a, b) => a - b);
// Find the 90th percentile to focus on competitive prices
const percentile90Index = Math.floor(allPrices.length * 0.90);
const percentile90 = allPrices[percentile90Index];
// Get the minimum price
const minPrice = allPrices[0];
// Base max Y on 90th percentile with padding
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
// Apply zoom level (higher zoom = smaller range)
const zoomedMaxY = baseMaxY / zoomLevel;
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
return { minY: 0, maxY: zoomedMaxY };
}, [historyData, zoomLevel]);
useEffect(() => {
const chartContainer = chartContainerRef.current;
if (!chartContainer) {
console.log('Chart container ref not found');
return;
}
console.log('Setting up wheel event listener on chart container');
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
console.log('Wheel event:', e.deltaY);
// Zoom in when scrolling up (negative deltaY), zoom out when scrolling down
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
setZoomLevel(prev => {
const newZoom = prev * zoomDelta;
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
console.log('Zoom level:', prev, '->', clampedZoom);
return clampedZoom;
});
};
chartContainer.addEventListener('wheel', handleWheel, { passive: false });
return () => {
console.log('Removing wheel event listener');
chartContainer.removeEventListener('wheel', handleWheel);
};
}, []);
const handleRangeChange = (range: string) => {
setSelectedRange(range);
};
const resetZoom = () => {
setZoomLevel(1);
};
return (
<div style={{
padding: '20px',
fontFamily: 'system-ui',
backgroundColor: '#0a0e27',
color: '#fff',
minHeight: '100%'
}}>
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>CONNECTION</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: status.connected ? '#50e3c2' : '#ff6b9d' }}>
{status.state}
</div>
</div>
{latestPrice && (
<>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>LOWEST PRICE</div>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#50e3c2' }}>
${latestPrice.lowestPrice.toFixed(4)}
</div>
<div style={{ fontSize: '12px', color: '#888' }}>per 1M AUEC</div>
</div>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>SELLER</div>
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{latestPrice.sellerName}</div>
<div style={{ fontSize: '12px', color: '#888' }}>{latestPrice.platform}</div>
</div>
</>
)}
</div>
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 style={{ margin: 0 }}>Price History</h2>
<div style={{ display: 'flex', gap: '10px' }}>
{TIME_RANGES.map(range => (
<button
key={range.value}
onClick={() => handleRangeChange(range.value)}
style={{
padding: '8px 16px',
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
color: selectedRange === range.value ? '#0a0e27' : '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: selectedRange === range.value ? 'bold' : 'normal',
}}
>
{range.label}
</button>
))}
</div>
</div>
{historyData && historyData.prices && historyData.prices.length > 0 ? (
chartData.length > 0 ? (
<div>
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span>
<button
onClick={resetZoom}
style={{
padding: '4px 12px',
backgroundColor: '#2a2f4a',
color: '#fff',
border: '1px solid #50e3c2',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px'
}}
>
Reset Zoom (×{zoomLevel.toFixed(1)})
</button>
</div>
<div
ref={chartContainerRef}
style={{
width: '100%',
height: '500px',
minHeight: '400px',
cursor: 'ns-resize',
border: '1px solid #2a2f4a'
}}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} key={`chart-${zoomLevel}`}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
<XAxis
dataKey="time"
stroke="#888"
tick={{ fill: '#888', fontSize: 10 }}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
stroke="#888"
tick={{ fill: '#888', fontSize: 12 }}
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
domain={[0, yAxisDomain[1]]}
allowDataOverflow={true}
/>
<Tooltip
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
labelStyle={{ color: '#fff' }}
/>
<Legend
wrapperStyle={{ color: '#fff', maxHeight: '100px', overflowY: 'auto' }}
iconType="line"
/>
{sellers.map((seller, index) => (
<Line
key={seller}
type="monotone"
dataKey={seller}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#ff6b9d' }}>
Data received but chart data is empty. Check console for details.
</div>
)
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
{status.connected ? 'Loading historical data...' : 'Connect to view historical data'}
</div>
)}
</div>
{latestPrice && (
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
<h2>Current Listings ({latestPrice.allPrices.length})</h2>
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
<tr style={{ borderBottom: '2px solid #2a2f4a' }}>
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Platform</th>
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Seller</th>
<th style={{ textAlign: 'right', padding: '12px', color: '#888', fontWeight: 'normal' }}>Price/1M AUEC</th>
</tr>
</thead>
<tbody>
{latestPrice.allPrices.map((price, index) => (
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
<td style={{ padding: '12px' }}>{price.platform}</td>
<td style={{ padding: '12px' }}>{price.sellerName}</td>
<td style={{ textAlign: 'right', padding: '12px', color: '#50e3c2', fontWeight: 'bold' }}>
${price.pricePerMillion.toFixed(4)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState, useCallback } from 'react';
import { PriceIndex, HistoricalData } from '../../shared/types';
interface WebSocketStatus {
connected: boolean;
state: string;
error?: string;
}
export function useWebSocket() {
const [status, setStatus] = useState<WebSocketStatus>({
connected: false,
state: 'DISCONNECTED',
});
const [latestPrice, setLatestPrice] = useState<PriceIndex | null>(null);
const [historyData, setHistoryData] = useState<HistoricalData | null>(null);
useEffect(() => {
// Listen for WebSocket events from main process
const handleConnected = () => {
setStatus({ connected: true, state: 'CONNECTED' });
};
const handleDisconnected = () => {
setStatus({ connected: false, state: 'DISCONNECTED' });
};
const handlePriceUpdate = (data: PriceIndex) => {
setLatestPrice(data);
};
const handleHistoryData = (data: HistoricalData) => {
setHistoryData(data);
};
const handleError = (error: string) => {
setStatus((prev: WebSocketStatus) => ({ ...prev, error }));
};
const handleMaxReconnect = () => {
setStatus((prev: WebSocketStatus) => ({
...prev,
error: 'Maximum reconnection attempts reached. Please restart the application.'
}));
};
// Register IPC listeners
window.electron.ipcRenderer.on('ws:connected', handleConnected);
window.electron.ipcRenderer.on('ws:disconnected', handleDisconnected);
window.electron.ipcRenderer.on('ws:priceUpdate', handlePriceUpdate);
window.electron.ipcRenderer.on('ws:historyData', handleHistoryData);
window.electron.ipcRenderer.on('ws:error', handleError);
window.electron.ipcRenderer.on('ws:maxReconnectAttemptsReached', handleMaxReconnect);
// Get initial status
window.electron.ipcRenderer.invoke('ws:getStatus').then((initialStatus) => {
setStatus(initialStatus);
});
// Cleanup
return () => {
window.electron.ipcRenderer.removeAllListeners('ws:connected');
window.electron.ipcRenderer.removeAllListeners('ws:disconnected');
window.electron.ipcRenderer.removeAllListeners('ws:priceUpdate');
window.electron.ipcRenderer.removeAllListeners('ws:historyData');
window.electron.ipcRenderer.removeAllListeners('ws:error');
window.electron.ipcRenderer.removeAllListeners('ws:maxReconnectAttemptsReached');
};
}, []);
const connect = useCallback(async () => {
await window.electron.ipcRenderer.invoke('ws:connect');
}, []);
const disconnect = useCallback(async () => {
await window.electron.ipcRenderer.invoke('ws:disconnect');
}, []);
const sendMessage = useCallback(async (message: any) => {
await window.electron.ipcRenderer.invoke('ws:send', message);
}, []);
const requestHistory = useCallback(async (range: string) => {
await sendMessage({ type: 'get_history', data: { range } });
}, [sendMessage]);
return {
status,
latestPrice,
historyData,
connect,
disconnect,
sendMessage,
requestHistory,
};
}

View File

@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
const container = document.getElementById('root');
if (!container) throw new Error('Root element not found');
const root = createRoot(container);
root.render(<App />);

View File

@@ -0,0 +1,3 @@
"use strict";
// Shared types between main and renderer processes
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,43 @@
// Shared types between main and renderer processes
export interface VendorPrice {
id: string;
platform: 'eldorado' | 'playerauctions';
sellerName: string;
pricePerMillion: number;
timestamp: Date;
url?: string;
}
export interface PriceIndex {
timestamp: Date;
lowestPrice: number;
platform: string;
sellerName: string;
allPrices: VendorPrice[];
}
export interface PriceAlert {
id: string;
type: 'below' | 'above';
threshold: number;
enabled: boolean;
}
export interface WebSocketMessage {
type: 'price_update' | 'connection_status' | 'error' | 'history_data' | 'get_history' | 'subscribe';
data: any;
}
export interface HistoricalData {
range: string;
from: Date;
to: Date;
prices: VendorPrice[];
}
export interface ConnectionStatus {
connected: boolean;
lastUpdate?: Date;
error?: string;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"jsx": "react-jsx",
"types": ["node", "electron"],
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/main",
"rootDir": "./src"
},
"include": ["src/main/**/*", "src/shared/**/*"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/main",
"rootDir": "./src"
},
"include": ["src/preload.ts", "src/shared/**/*"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
root: '.',
build: {
outDir: 'dist/renderer',
emptyOutDir: true,
},
server: {
port: 5173,
},
});