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>
116 lines
3.7 KiB
TypeScript
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;
|
|
}
|