import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeQuery } from "./core/normalize.js"; import { PoliteHttp } from "./core/politeHttp.js"; import { ResponseCache } from "./core/cache.js"; import { SearchService } from "./core/search.js"; import { Measurement } from "./core/measurement.js"; import { renderStatsPage } from "./core/statsPage.js"; import { validateOutUrl } from "./core/redirect.js"; import { enabledAdapters, getAdapter } from "./core/registry.js"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const app = Fastify({ logger: { level: "info" } }); const http = new PoliteHttp(); const cache = new ResponseCache(path.join(root, "data", "poc.sqlite")); const service = new SearchService(http, cache); const measurement = new Measurement(path.join(root, "data", "poc.sqlite")); app.register(fastifyStatic, { root: path.join(root, "public") }); app.get("/api/stores", async () => ({ stores: enabledAdapters().map((a) => ({ id: a.id, displayName: a.displayName, homepage: a.storeHomepage, fetchMode: a.fetchMode ?? "auto", })), })); /** Registers a user search for measurement; the returned sid ties the per-store * results and clickouts of one search together. */ app.post<{ Body: { q?: string } }>("/api/searches", async (req, reply) => { const raw = typeof req.body?.q === "string" ? req.body.q : ""; const q = normalizeQuery(raw); if (!q) { return reply.code(400).send({ error: "query too short" }); } return { sid: measurement.logSearch(raw, q.canonical) }; }); app.get<{ Params: { store: string }; Querystring: { q?: string; sid?: string } }>( "/api/search/:store", async (req, reply) => { const adapter = getAdapter(req.params.store); if (!adapter || !adapter.enabled) { return reply.code(404).send({ error: "unknown store" }); } const q = normalizeQuery(req.query.q ?? ""); if (!q) { return reply.code(400).send({ error: "query too short" }); } const outcome = await service.search(adapter, q); const sid = Number.parseInt(req.query.sid ?? "", 10); measurement.logResult(Number.isNaN(sid) ? null : sid, { store: adapter.id, offersFound: outcome.offers.length, httpStatus: outcome.httpStatus, parseOk: outcome.ok, cached: outcome.cached, durationMs: outcome.durationMs, }); return { store: adapter.id, displayName: adapter.displayName, query: q.canonical, ...outcome }; }, ); /** Click-through logger: records the clickout, then 302s to the store's product page. */ app.get<{ Querystring: { store?: string; url?: string; sid?: string } }>( "/out", async (req, reply) => { const adapter = getAdapter(req.query.store ?? ""); const target = adapter ? validateOutUrl(adapter, req.query.url ?? "") : null; if (!adapter || !target) { return reply.code(400).send({ error: "invalid outbound link" }); } const sid = Number.parseInt(req.query.sid ?? "", 10); measurement.logClickout(Number.isNaN(sid) ? null : sid, adapter.id, target); return reply.redirect(target, 302); }, ); app.get("/stats", async (_req, reply) => { return reply.type("text/html; charset=utf-8").send(renderStatsPage(measurement.stats())); }); app.get("/health", async () => ({ stores: Object.fromEntries(service.counters), })); const port = Number(process.env.PORT ?? 3000); const host = process.env.HOST ?? "0.0.0.0"; // LAN-accessible; set HOST=127.0.0.1 for local-only app.listen({ port, host }).then(() => { console.log(`→ http://${host}:${port}`); });