← back to Watches
services/wayback-scraper.js
311 lines
/**
* Wayback Machine Price Scraper Service
* Fetches historical watch prices from archived dealer pages
*/
import { setTimeout } from 'timers/promises';
const WAYBACK_CDX_API = 'https://web.archive.org/cdx/search/cdx';
const WAYBACK_WEB_API = 'https://web.archive.org/web';
// Rate limiting: 1 request per second
const RATE_LIMIT_MS = 1000;
let lastRequestTime = 0;
// In-memory cache
const cache = new Map();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
/**
* Rate-limited fetch
*/
async function rateLimitedFetch(url, options = {}) {
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, options);
}
/**
* 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() });
}
/**
* Query Wayback Machine CDX API for snapshots of a URL
* @param {string} url - The URL to search for
* @param {object} options - Query options
* @returns {array} Array of snapshot records
*/
export async function getSnapshots(url, options = {}) {
const {
from = '20100101',
to = new Date().toISOString().slice(0, 10).replace(/-/g, ''),
limit = 50,
filter = 'statuscode:200'
} = options;
const cacheKey = `snapshots:${url}:${from}:${to}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const params = new URLSearchParams({
url,
output: 'json',
from,
to,
limit,
filter,
fl: 'timestamp,original,statuscode,digest'
});
try {
const response = await rateLimitedFetch(`${WAYBACK_CDX_API}?${params}`);
if (!response.ok) {
throw new Error(`CDX API error: ${response.status}`);
}
const data = await response.json();
// First row is headers
const headers = data[0];
const records = data.slice(1).map(row => {
const record = {};
headers.forEach((header, i) => {
record[header] = row[i];
});
return record;
});
setCache(cacheKey, records);
return records;
} catch (error) {
console.error('Wayback CDX error:', error.message);
return [];
}
}
/**
* Fetch archived page content
* @param {string} url - Original URL
* @param {string} timestamp - Wayback timestamp (YYYYMMDDhhmmss)
* @returns {string} Page HTML content
*/
export async function fetchArchivedPage(url, timestamp) {
const cacheKey = `page:${url}:${timestamp}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const waybackUrl = `${WAYBACK_WEB_API}/${timestamp}/${url}`;
try {
const response = await rateLimitedFetch(waybackUrl);
if (!response.ok) {
throw new Error(`Wayback fetch error: ${response.status}`);
}
const html = await response.text();
setCache(cacheKey, html);
return html;
} catch (error) {
console.error('Wayback fetch error:', error.message);
return null;
}
}
/**
* Extract price from HTML content
* Supports common dealer page formats
* @param {string} html - Page HTML
* @param {object} patterns - Custom extraction patterns
* @returns {number|null} Extracted price or null
*/
export function extractPrice(html, patterns = {}) {
if (!html) return null;
// Common price patterns
const pricePatterns = [
// USD formats
/\$\s*([\d,]+(?:\.\d{2})?)/g,
// "Price: $X,XXX"
/price[:\s]*\$\s*([\d,]+(?:\.\d{2})?)/gi,
// "USD X,XXX"
/USD\s*([\d,]+(?:\.\d{2})?)/gi,
// Schema.org price
/"price":\s*"?([\d,]+(?:\.\d{2})?)"?/g,
// data-price attribute
/data-price="([\d,]+(?:\.\d{2})?)"/gi,
// Custom patterns
...(patterns.custom || [])
];
const prices = [];
for (const pattern of pricePatterns) {
const matches = html.matchAll(pattern);
for (const match of matches) {
const price = parseFloat(match[1].replace(/,/g, ''));
if (price > 0 && price < 10000000) { // Sanity check
prices.push(price);
}
}
}
if (prices.length === 0) return null;
// Return median price if multiple found
prices.sort((a, b) => a - b);
return prices[Math.floor(prices.length / 2)];
}
/**
* Scrape historical prices for a watch from Wayback Machine
* @param {string} watchRef - Watch reference number
* @param {array} dealerUrls - Array of dealer URLs to search
* @returns {array} Array of { date, price, source, confidence }
*/
export async function scrapeHistoricalPrices(watchRef, dealerUrls = []) {
const results = [];
// Default dealer URLs if none provided
const urls = dealerUrls.length > 0 ? dealerUrls : [
`https://www.chrono24.com/omega/ref-${watchRef}.htm`,
`https://www.bobswatches.com/omega/${watchRef}`,
`https://www.omegawatches.com/${watchRef}`
];
for (const url of urls) {
try {
// Get snapshots
const snapshots = await getSnapshots(url, { limit: 20 });
for (const snapshot of snapshots) {
// Fetch archived page
const html = await fetchArchivedPage(url, snapshot.timestamp);
if (!html) continue;
// Extract price
const price = extractPrice(html);
if (!price) continue;
// Parse date from timestamp
const timestamp = snapshot.timestamp;
const date = new Date(
timestamp.slice(0, 4),
parseInt(timestamp.slice(4, 6)) - 1,
timestamp.slice(6, 8)
);
results.push({
date: date.toISOString().slice(0, 10),
year: date.getFullYear(),
price,
source: 'wayback',
sourceUrl: url,
confidence: 3, // Medium confidence for scraped data
timestamp: snapshot.timestamp
});
}
} catch (error) {
console.error(`Error scraping ${url}:`, error.message);
}
}
// Deduplicate by date (keep highest confidence)
const byDate = new Map();
for (const result of results) {
const existing = byDate.get(result.date);
if (!existing || result.confidence > existing.confidence) {
byDate.set(result.date, result);
}
}
return Array.from(byDate.values()).sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
}
/**
* Get price trends from historical data
* @param {array} priceData - Array of price records
* @returns {object} Trend analysis
*/
export function analyzePriceTrends(priceData) {
if (!priceData || priceData.length < 2) {
return { trend: 'insufficient_data', change: 0 };
}
const sorted = [...priceData].sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
const oldest = sorted[0];
const newest = sorted[sorted.length - 1];
const priceChange = newest.price - oldest.price;
const percentChange = (priceChange / oldest.price) * 100;
const years = (new Date(newest.date) - new Date(oldest.date)) /
(365.25 * 24 * 60 * 60 * 1000);
const annualizedReturn = years > 0 ?
(Math.pow(newest.price / oldest.price, 1 / years) - 1) * 100 : 0;
return {
trend: priceChange > 0 ? 'up' : priceChange < 0 ? 'down' : 'stable',
oldestPrice: oldest.price,
oldestDate: oldest.date,
newestPrice: newest.price,
newestDate: newest.date,
absoluteChange: priceChange,
percentChange: percentChange.toFixed(2),
annualizedReturn: annualizedReturn.toFixed(2),
dataPoints: priceData.length
};
}
/**
* 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);
}
}
}
export default {
getSnapshots,
fetchArchivedPage,
extractPrice,
scrapeHistoricalPrices,
analyzePriceTrends,
clearExpiredCache
};