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 { // 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 = 1000000; // 1000000 M = 1 trillion 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 1000000 (which means 1000000 M = 1 trillion 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 { 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: string) => l.trim()).filter((l: string) => l.length > 0); const seenPrices = new Set(); 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 = 1000000; 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: any) => ({ vendor: 'playerauctions' as const, amountAUEC: listing.amountAUEC, priceUSD: listing.priceUSD, pricePerMillion: listing.pricePerMillion, seller: listing.seller, deliveryTime: undefined, scrapedAt, url, })); } }