← back to Watches

services/chrono24-scraper.js

300 lines

/**
 * Chrono24 Public Listing Scraper
 * Scrapes publicly visible listings from Chrono24 search results
 * Respects robots.txt and rate limits
 */

import { setTimeout } from 'timers/promises';

const CHRONO24_BASE = 'https://www.chrono24.com';

// Strict rate limiting: 1 request every 5 seconds
const RATE_LIMIT_MS = 5000;
let lastRequestTime = 0;

// Cache configuration
const cache = new Map();
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours

// Robots.txt compliance - these paths are typically allowed
const ALLOWED_PATHS = [
  '/omega/',
  '/rolex/',
  '/search/'
];

/**
 * Check if path is allowed by robots.txt
 */
function isPathAllowed(path) {
  return ALLOWED_PATHS.some(allowed => path.startsWith(allowed));
}

/**
 * Rate-limited fetch with user agent
 */
async function rateLimitedFetch(url) {
  const now = Date.now();
  const timeSinceLastRequest = now - lastRequestTime;

  if (timeSinceLastRequest < RATE_LIMIT_MS) {
    await setTimeout(RATE_LIMIT_MS - timeSinceLastRequest);
  }

  lastRequestTime = Date.now();

  return fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (compatible; OmegaWatchBot/1.0; +http://45.61.58.125:7600)',
      'Accept': 'text/html,application/xhtml+xml',
      'Accept-Language': 'en-US,en;q=0.9'
    }
  });
}

/**
 * Get cached result or null
 */
function getCached(key) {
  const cached = cache.get(key);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }
  cache.delete(key);
  return null;
}

/**
 * Set cache
 */
function setCache(key, data) {
  cache.set(key, { data, timestamp: Date.now() });
}

/**
 * Extract listing data from HTML
 * Uses regex patterns to find structured data
 * @param {string} html - Page HTML content
 * @returns {array} Array of listing objects
 */
function extractListings(html) {
  const listings = [];

  // Look for JSON-LD structured data first (most reliable)
  const jsonLdMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g);
  if (jsonLdMatch) {
    for (const match of jsonLdMatch) {
      try {
        const jsonStr = match.replace(/<script[^>]*>/, '').replace(/<\/script>/, '');
        const data = JSON.parse(jsonStr);

        if (data['@type'] === 'Product' || data['@type'] === 'ItemList') {
          const items = data.itemListElement || [data];
          for (const item of items) {
            if (item.offers?.price) {
              listings.push({
                name: item.name || '',
                price: parseFloat(item.offers.price),
                currency: item.offers.priceCurrency || 'USD',
                url: item.url || '',
                condition: item.itemCondition?.replace('https://schema.org/', '') || 'unknown',
                seller: item.offers?.seller?.name || '',
                location: item.offers?.availableAtOrFrom?.address?.addressLocality || ''
              });
            }
          }
        }
      } catch (e) {
        // JSON parse failed, continue
      }
    }
  }

  // Fallback: Extract from HTML patterns
  if (listings.length === 0) {
    // Price patterns
    const pricePattern = /data-price="(\d+)"/g;
    const namePattern = /class="[^"]*article-title[^"]*"[^>]*>([^<]+)</g;

    let priceMatch;
    const prices = [];
    while ((priceMatch = pricePattern.exec(html)) !== null) {
      prices.push(parseFloat(priceMatch[1]));
    }

    let nameMatch;
    const names = [];
    while ((nameMatch = namePattern.exec(html)) !== null) {
      names.push(nameMatch[1].trim());
    }

    // Combine
    for (let i = 0; i < Math.min(prices.length, names.length); i++) {
      listings.push({
        name: names[i],
        price: prices[i],
        currency: 'USD',
        condition: 'unknown',
        source: 'html_extraction'
      });
    }
  }

  return listings;
}

/**
 * Scrape Omega listings from Chrono24
 * @param {string} reference - Watch reference number (optional)
 * @param {object} options - Scraping options
 * @returns {array} Array of listing objects
 */
export async function scrapeOmegaListings(reference = '', options = {}) {
  const { maxPages = 1, minPrice = 0, maxPrice = 100000 } = options;

  const path = reference
    ? `/omega/ref-${reference}.htm`
    : '/omega/index.htm';

  if (!isPathAllowed(path)) {
    console.warn(`Path not allowed by robots.txt: ${path}`);
    return [];
  }

  const cacheKey = `chrono24:${path}:${minPrice}:${maxPrice}`;
  const cached = getCached(cacheKey);
  if (cached) return cached;

  const allListings = [];

  for (let page = 1; page <= maxPages; page++) {
    try {
      const url = `${CHRONO24_BASE}${path}?priceFrom=${minPrice}&priceTo=${maxPrice}&pageSize=60&showpage=${page}`;

      console.log(`Scraping Chrono24 page ${page}: ${url}`);
      const response = await rateLimitedFetch(url);

      if (!response.ok) {
        console.warn(`Chrono24 returned ${response.status}`);
        break;
      }

      const html = await response.text();
      const listings = extractListings(html);

      if (listings.length === 0) {
        break; // No more results
      }

      allListings.push(...listings.map(listing => ({
        ...listing,
        reference: reference || 'unknown',
        source: 'chrono24',
        sourceUrl: url,
        scrapedAt: new Date().toISOString(),
        confidence: 4 // High confidence for direct scrape
      })));

    } catch (error) {
      console.error(`Chrono24 scrape error: ${error.message}`);
      break;
    }
  }

  setCache(cacheKey, allListings);
  return allListings;
}

/**
 * Get price statistics for a reference
 * @param {string} reference - Watch reference number
 * @returns {object} Price statistics
 */
export async function getPriceStats(reference) {
  const listings = await scrapeOmegaListings(reference, { maxPages: 1 });

  if (listings.length === 0) {
    return null;
  }

  const prices = listings.map(l => l.price).filter(p => p > 0).sort((a, b) => a - b);

  return {
    reference,
    count: prices.length,
    min: prices[0],
    max: prices[prices.length - 1],
    avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
    median: prices[Math.floor(prices.length / 2)],
    lastUpdated: new Date().toISOString()
  };
}

/**
 * Search for watches by query
 * @param {string} query - Search query
 * @returns {array} Array of listing objects
 */
export async function searchWatches(query) {
  const path = `/search/index.htm`;
  const cacheKey = `chrono24:search:${query}`;
  const cached = getCached(cacheKey);
  if (cached) return cached;

  try {
    const url = `${CHRONO24_BASE}${path}?query=${encodeURIComponent(query)}&dosearch=true&searchexplain=1&pageSize=30`;

    const response = await rateLimitedFetch(url);
    if (!response.ok) {
      return [];
    }

    const html = await response.text();
    const listings = extractListings(html).map(listing => ({
      ...listing,
      query,
      source: 'chrono24_search',
      scrapedAt: new Date().toISOString()
    }));

    setCache(cacheKey, listings);
    return listings;

  } catch (error) {
    console.error(`Chrono24 search error: ${error.message}`);
    return [];
  }
}

/**
 * Clear expired cache entries
 */
export function clearExpiredCache() {
  const now = Date.now();
  for (const [key, value] of cache) {
    if (now - value.timestamp > CACHE_TTL) {
      cache.delete(key);
    }
  }
}

/**
 * Get scraper status
 */
export function getStatus() {
  return {
    cacheEntries: cache.size,
    lastRequest: new Date(lastRequestTime).toISOString(),
    rateLimitMs: RATE_LIMIT_MS,
    cacheTtlHours: CACHE_TTL / (60 * 60 * 1000)
  };
}

export default {
  scrapeOmegaListings,
  getPriceStats,
  searchWatches,
  clearExpiredCache,
  getStatus
};