Intial Version
This commit is contained in:
12
backend/.dockerignore
Normal file
12
backend/.dockerignore
Normal 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
14
backend/.env.example
Normal 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
9
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vscode/
|
||||
.idea/
|
||||
49
backend/Dockerfile
Normal file
49
backend/Dockerfile
Normal 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
120
backend/README.md
Normal 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
15
backend/jest.config.js
Normal 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
5200
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
backend/prisma/migrations/.gitkeep
Normal file
1
backend/prisma/migrations/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Migrations directory
|
||||
51
backend/prisma/schema.prisma
Normal file
51
backend/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
55
backend/src/api/routes/index.ts
Normal file
55
backend/src/api/routes/index.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
158
backend/src/api/routes/prices.ts
Normal file
158
backend/src/api/routes/prices.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
153
backend/src/api/routes/websocket.ts
Normal file
153
backend/src/api/routes/websocket.ts
Normal 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
36
backend/src/api/server.ts
Normal 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;
|
||||
7
backend/src/database/prisma.ts
Normal file
7
backend/src/database/prisma.ts
Normal 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;
|
||||
195
backend/src/database/repository.ts
Normal file
195
backend/src/database/repository.ts
Normal 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
91
backend/src/index.ts
Normal 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();
|
||||
92
backend/src/scrapers/base-scraper.ts
Normal file
92
backend/src/scrapers/base-scraper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
backend/src/scrapers/eldorado-scraper.ts
Normal file
151
backend/src/scrapers/eldorado-scraper.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
6
backend/src/scrapers/index.ts
Normal file
6
backend/src/scrapers/index.ts
Normal 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';
|
||||
49
backend/src/scrapers/manual-scrape.ts
Normal file
49
backend/src/scrapers/manual-scrape.ts
Normal 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();
|
||||
202
backend/src/scrapers/playerauctions-scraper.ts
Normal file
202
backend/src/scrapers/playerauctions-scraper.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
80
backend/src/scrapers/scheduler.ts
Normal file
80
backend/src/scrapers/scheduler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
59
backend/src/scrapers/scraper-service.ts
Normal file
59
backend/src/scrapers/scraper-service.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
backend/src/scrapers/types.ts
Normal file
25
backend/src/scrapers/types.ts
Normal 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
26
backend/test-ws.js
Normal 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);
|
||||
55
backend/tests/scrapers/scraper-service.test.ts
Normal file
55
backend/tests/scrapers/scraper-service.test.ts
Normal 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
20
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user