albion/server/src/market/client.ts
Заид Омар Медхат 7ff3e6ec95 Phase 2: market data layer
AODP price client (chunked, throttled, proxy-aware), snapshot storage
with freshness ages, manual price overrides that outrank API data,
async refresh job with progress, Market Data + Settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:59:06 +05:00

116 lines
3.7 KiB
TypeScript

/** 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<unknown> }> {
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<string, string> = {
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<void> {
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<AodpPriceRow[]> {
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;
}