/** Client for the Albion Online Data Project (AODP) REST API. */ import { fetch as undiciFetch, ProxyAgent } from 'undici'; import { getMeta } from '../db/index.js'; /** * AODP is blocked in some regions. If the user's VPN is browser-only, they can * instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app * routes its market requests through it. */ function getProxyUrl(): string | null { return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null; } async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise }> { const proxyUrl = getProxyUrl(); const signal = AbortSignal.timeout(timeoutMs); if (proxyUrl) { return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) }); } return fetch(url, { signal }); } export const SERVER_HOSTS: Record = { europe: 'europe.albion-online-data.com', americas: 'west.albion-online-data.com', asia: 'east.albion-online-data.com', }; /** Locations with player markets we track by default. */ export const CITIES = [ 'Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martlock', 'Thetford', 'Brecilien', 'Black Market', ] as const; export interface AodpPriceRow { item_id: string; city: string; quality: number; sell_price_min: number; sell_price_min_date: string; sell_price_max: number; sell_price_max_date: string; buy_price_max: number; buy_price_max_date: string; buy_price_min: number; buy_price_min_date: string; } const CHUNK_SIZE = 100; // item ids per request (keeps URLs short) const REQUEST_DELAY_MS = 500; // stay well under AODP's ~180 req/min limit const REQUEST_TIMEOUT_MS = 20000; /** AODP uses 0001-01-01T00:00:00 as "never observed". */ export function normalizeAodpDate(d: string | null | undefined): string | null { if (!d || d.startsWith('0001-')) return null; return d.endsWith('Z') ? d : `${d}Z`; // AODP timestamps are UTC without the Z } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } export class AodpUnreachableError extends Error { constructor(cause: unknown) { super( 'Market data API (albion-online-data.com) is unreachable. ' + 'If you are in a region where it is blocked, check that your VPN is on. ' + `(${cause instanceof Error ? cause.message : String(cause)})`, ); this.name = 'AodpUnreachableError'; } } export interface FetchPricesOptions { server: string; // key of SERVER_HOSTS items: string[]; cities?: string[]; qualities?: number[]; onProgress?: (done: number, total: number) => void; } export async function fetchPrices(opts: FetchPricesOptions): Promise { const host = SERVER_HOSTS[opts.server] ?? SERVER_HOSTS.europe; const cities = opts.cities ?? [...CITIES]; const qualities = opts.qualities ?? [1, 2, 3, 4, 5]; const chunks: string[][] = []; for (let i = 0; i < opts.items.length; i += CHUNK_SIZE) { chunks.push(opts.items.slice(i, i + CHUNK_SIZE)); } const all: AodpPriceRow[] = []; for (let i = 0; i < chunks.length; i++) { const url = `https://${host}/api/v2/stats/prices/${encodeURIComponent(chunks[i].join(','))}` + `?locations=${encodeURIComponent(cities.join(','))}` + `&qualities=${qualities.join(',')}`; try { const res = await proxyAwareFetch(url, REQUEST_TIMEOUT_MS); if (!res.ok) throw new Error(`HTTP ${res.status}`); const rows = (await res.json()) as AodpPriceRow[]; all.push(...rows); } catch (e) { throw new AodpUnreachableError(e); } opts.onProgress?.(i + 1, chunks.length); if (i < chunks.length - 1) await sleep(REQUEST_DELAY_MS); } return all; }