import { OfferSchema, runAdapterSearch } from "./types.js"; import type { NormalizedQuery, Offer, StoreAdapter } from "./types.js"; import type { PoliteHttp } from "./politeHttp.js"; import { HttpStatusError, StorePausedError } from "./politeHttp.js"; import { ChallengeRequiredError } from "./browserErrors.js"; import type { ResponseCache } from "./cache.js"; export interface SearchOutcome { ok: boolean; offers: Offer[]; cached: boolean; durationMs: number; /** 200 for a successful live fetch, the store's error status when it sent one, * null when no HTTP happened (cache hit, timeout, network error). */ httpStatus: number | null; /** Set when ok=false; safe to show the user. */ error?: string; /** Walled (onDemand) store hit a Cloudflare challenge; the UI opens `solveUrl` * (noVNC) for the user to clear it, then retries. See WALLED-STORES.md. */ needsSolve?: boolean; solveUrl?: string; } /** * Per-store anomaly counters (TECHNICAL-SPEC §4): a store whose zeroParse rate * spikes while others return results almost certainly changed its markup. */ export interface StoreCounters { searches: number; httpFailures: number; zeroParse: number; // HTTP OK but nothing parsed zodRejections: number; // offers parsed but invalid } export class SearchService { readonly counters = new Map(); constructor( private http: PoliteHttp, private cache: ResponseCache, ) {} private countersFor(storeId: string): StoreCounters { let c = this.counters.get(storeId); if (!c) { c = { searches: 0, httpFailures: 0, zeroParse: 0, zodRejections: 0 }; this.counters.set(storeId, c); } return c; } async search(adapter: StoreAdapter, q: NormalizedQuery): Promise { const started = Date.now(); const c = this.countersFor(adapter.id); c.searches++; const cachedOffers = this.cache.get(adapter.id, q.canonical); if (cachedOffers) { return { ok: true, offers: cachedOffers, cached: true, durationMs: Date.now() - started, httpStatus: null, }; } let raw: Offer[]; try { raw = await withTimeout(runAdapterSearch(adapter, q, this.http), adapter.timeoutMs, adapter.id); } catch (err) { // A walled store needing a human to clear a challenge is not a store failure — // don't count it against the store; hand the UI the solve URL instead. if (err instanceof ChallengeRequiredError) { return { ok: false, offers: [], cached: false, durationMs: Date.now() - started, httpStatus: null, needsSolve: true, solveUrl: err.solveUrl, error: "nepieciešams atrisināt Cloudflare pārbaudi", }; } c.httpFailures++; const paused = err instanceof StorePausedError; return { ok: false, offers: [], cached: false, durationMs: Date.now() - started, httpStatus: err instanceof HttpStatusError ? err.status : null, error: paused ? "veikals īslaicīgi atslēgts pēc kļūdām" : "neizdevās sazināties ar veikalu", }; } const offers: Offer[] = []; for (const o of raw) { const parsed = OfferSchema.safeParse(o); if (parsed.success) offers.push(parsed.data); else c.zodRejections++; } if (raw.length === 0) c.zeroParse++; this.cache.set(adapter.id, q.canonical, offers); return { ok: true, offers, cached: false, durationMs: Date.now() - started, httpStatus: 200 }; } } function withTimeout(p: Promise, ms: number, storeId: string): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(`${storeId}: adapter timed out after ${ms}ms`)), ms); p.then( (v) => (clearTimeout(t), resolve(v)), (e) => (clearTimeout(t), reject(e)), ); }); }