← back to Watches
services/github-dataset.js
342 lines
/**
* GitHub-hosted Price Dataset Integration
* Fetches luxury watch price data from public GitHub repositories
*/
import { setTimeout } from 'timers/promises';
// GitHub API configuration
const GITHUB_API = 'https://api.github.com';
const GITHUB_RAW = 'https://raw.githubusercontent.com';
// Rate limiting: GitHub allows 60 requests/hour unauthenticated
const RATE_LIMIT_MS = 60000; // 1 request per minute to be safe
let lastRequestTime = 0;
let remainingRequests = 60;
// Cache configuration
const cache = new Map();
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
// Known public datasets
const KNOWN_DATASETS = [
{
owner: 'philmorefkoung',
repo: 'Webscrapped-Watch-Dataset',
path: 'Omega.csv',
format: 'csv',
brand: 'omega'
},
{
owner: 'bobbypine',
repo: 'Rolex',
path: 'Prices/Weekly_Median_Prices.csv',
format: 'csv',
brand: 'rolex'
}
];
/**
* Rate-limited fetch for GitHub API
*/
async function rateLimitedFetch(url, options = {}) {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
// Check remaining requests
if (remainingRequests <= 5 && timeSinceLastRequest < RATE_LIMIT_MS) {
const waitTime = RATE_LIMIT_MS - timeSinceLastRequest;
console.log(`GitHub rate limit approaching, waiting ${waitTime}ms`);
await setTimeout(waitTime);
}
lastRequestTime = Date.now();
const response = await fetch(url, {
...options,
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'OmegaWatchPlatform',
...options.headers
}
});
// Update rate limit info from headers
const remaining = response.headers.get('X-RateLimit-Remaining');
if (remaining) {
remainingRequests = parseInt(remaining);
}
return response;
}
/**
* 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() });
}
/**
* Parse CSV data
* @param {string} csvText - Raw CSV content
* @returns {array} Array of objects
*/
function parseCSV(csvText) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim().toLowerCase().replace(/['"]/g, ''));
return lines.slice(1).map(line => {
const values = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
const obj = {};
headers.forEach((header, i) => {
obj[header] = values[i]?.replace(/['"]/g, '') || '';
});
return obj;
});
}
/**
* Fetch raw file content from GitHub
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} path - File path
* @returns {string} File content
*/
export async function fetchRawFile(owner, repo, path) {
const cacheKey = `raw:${owner}/${repo}/${path}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const url = `${GITHUB_RAW}/${owner}/${repo}/main/${path}`;
try {
const response = await fetch(url);
if (!response.ok) {
// Try master branch
const masterUrl = `${GITHUB_RAW}/${owner}/${repo}/master/${path}`;
const masterResponse = await fetch(masterUrl);
if (!masterResponse.ok) {
throw new Error(`Failed to fetch ${path}: ${response.status}`);
}
const content = await masterResponse.text();
setCache(cacheKey, content);
return content;
}
const content = await response.text();
setCache(cacheKey, content);
return content;
} catch (error) {
console.error(`GitHub fetch error: ${error.message}`);
return null;
}
}
/**
* Fetch and parse dataset from GitHub
* @param {object} dataset - Dataset configuration
* @returns {array} Parsed data records
*/
export async function fetchDataset(dataset) {
const content = await fetchRawFile(dataset.owner, dataset.repo, dataset.path);
if (!content) return [];
if (dataset.format === 'csv') {
return parseCSV(content);
} else if (dataset.format === 'json') {
try {
return JSON.parse(content);
} catch {
return [];
}
}
return [];
}
/**
* Fetch Omega watch prices from known datasets
* @returns {array} Array of price records
*/
export async function fetchOmegaPrices() {
const cacheKey = 'omega-prices';
const cached = getCached(cacheKey);
if (cached) return cached;
const results = [];
for (const dataset of KNOWN_DATASETS) {
if (dataset.brand !== 'omega') continue;
try {
const data = await fetchDataset(dataset);
for (const record of data) {
// Extract relevant fields (adapt based on actual CSV structure)
const price = parseFloat(
(record.price || record.listing_price || record.value || '0')
.replace(/[^0-9.]/g, '')
);
if (price > 0) {
results.push({
reference: record.reference || record.ref || record.model_number || '',
model: record.model || record.name || record.title || '',
price,
condition: record.condition || 'unknown',
source: `github:${dataset.owner}/${dataset.repo}`,
sourceUrl: `https://github.com/${dataset.owner}/${dataset.repo}`,
date: record.date || record.scraped_date || new Date().toISOString().slice(0, 10),
confidence: 4 // High confidence for structured datasets
});
}
}
} catch (error) {
console.error(`Error fetching ${dataset.repo}:`, error.message);
}
}
setCache(cacheKey, results);
return results;
}
/**
* Search for watch data repositories on GitHub
* @param {string} query - Search query
* @returns {array} Array of repository info
*/
export async function searchRepositories(query = 'watch prices dataset') {
const cacheKey = `search:${query}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const url = `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&per_page=10`;
try {
const response = await rateLimitedFetch(url);
if (!response.ok) {
throw new Error(`GitHub search error: ${response.status}`);
}
const data = await response.json();
const repos = data.items.map(repo => ({
owner: repo.owner.login,
name: repo.name,
description: repo.description,
stars: repo.stargazers_count,
url: repo.html_url,
lastUpdated: repo.updated_at
}));
setCache(cacheKey, repos);
return repos;
} catch (error) {
console.error('GitHub search error:', error.message);
return [];
}
}
/**
* Get price statistics from dataset
* @param {array} priceData - Array of price records
* @param {string} reference - Watch reference to filter by
* @returns {object} Statistics
*/
export function getPriceStatistics(priceData, reference = null) {
let data = priceData;
if (reference) {
data = priceData.filter(p =>
p.reference?.toLowerCase().includes(reference.toLowerCase())
);
}
if (data.length === 0) {
return { count: 0, min: 0, max: 0, avg: 0, median: 0 };
}
const prices = data.map(p => p.price).sort((a, b) => a - b);
return {
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)],
stdDev: Math.round(
Math.sqrt(
prices.reduce((sum, p) => {
const avg = prices.reduce((a, b) => a + b, 0) / prices.length;
return sum + Math.pow(p - avg, 2);
}, 0) / prices.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);
}
}
}
/**
* Get cache statistics
*/
export function getCacheStats() {
return {
entries: cache.size,
remainingRequests,
lastRequest: new Date(lastRequestTime).toISOString()
};
}
export default {
fetchRawFile,
fetchDataset,
fetchOmegaPrices,
searchRepositories,
getPriceStatistics,
clearExpiredCache,
getCacheStats,
KNOWN_DATASETS
};