Intial Version
This commit is contained in:
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user