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

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;
}