243 lines
8.1 KiB
JavaScript
243 lines
8.1 KiB
JavaScript
|
|
import { createServer } from "node:http";
|
||
|
|
import { loadConfig } from "./config.js";
|
||
|
|
import { HttpClient } from "./http/client.js";
|
||
|
|
import { ProxyPool } from "./http/proxy.js";
|
||
|
|
import { SearchRunner } from "./run.js";
|
||
|
|
import { setUserAgents } from "./utils/ua.js";
|
||
|
|
import { loadApiKey } from "./api-key.js";
|
||
|
|
import { logger, childLogger } from "./utils/logger.js";
|
||
|
|
import { formatSearchResponse, formatErrorResponse } from "./output/agent.js";
|
||
|
|
|
||
|
|
const log = childLogger({ component: "api-server" });
|
||
|
|
|
||
|
|
function unauthorized(res, msg = "Unauthorized") {
|
||
|
|
res.writeHead(401, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: false, error: { code: "UNAUTHORIZED", message: msg } }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function badRequest(res, msg) {
|
||
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: false, error: { code: "BAD_REQUEST", message: msg } }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function serverError(res, msg) {
|
||
|
|
if (res.headersSent) return;
|
||
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: false, error: { code: "INTERNAL_ERROR", message: msg } }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseBody(req) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
let data = "";
|
||
|
|
req.on("data", chunk => data += chunk);
|
||
|
|
req.on("end", () => {
|
||
|
|
try { resolve(JSON.parse(data)); }
|
||
|
|
catch { reject(new Error("Invalid JSON")); }
|
||
|
|
});
|
||
|
|
req.on("error", reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function authenticate(req) {
|
||
|
|
const apiKey = loadApiKey();
|
||
|
|
if (!apiKey) return true;
|
||
|
|
|
||
|
|
const auth = req.headers["authorization"] || "";
|
||
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||
|
|
return token === apiKey;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function startServer(port = 9876) {
|
||
|
|
const config = await loadConfig();
|
||
|
|
if (config.http.user_agents) setUserAgents(config.http.user_agents);
|
||
|
|
|
||
|
|
const httpClient = new HttpClient(config.http);
|
||
|
|
const proxyPool = new ProxyPool(config.proxies, config.proxy);
|
||
|
|
|
||
|
|
if (config.proxy.enabled) {
|
||
|
|
httpClient.setProxyPool(proxyPool);
|
||
|
|
log.info({ proxyCount: config.proxies.length }, "proxy pool attached to API server");
|
||
|
|
}
|
||
|
|
|
||
|
|
const runner = new SearchRunner({ httpClient, config });
|
||
|
|
|
||
|
|
const server = createServer(async (req, res) => {
|
||
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||
|
|
|
||
|
|
if (req.method === "OPTIONS") {
|
||
|
|
res.writeHead(204);
|
||
|
|
res.end();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||
|
|
const path = url.pathname;
|
||
|
|
|
||
|
|
// Health check — no auth required
|
||
|
|
if (path === "/health" && req.method === "GET") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
const m = proxyPool.getMetrics();
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
success: true,
|
||
|
|
status: "ok",
|
||
|
|
uptime: process.uptime(),
|
||
|
|
proxyCount: config.proxies.length,
|
||
|
|
proxyPool: {
|
||
|
|
total: m.totalProxies,
|
||
|
|
alive: m.alive,
|
||
|
|
dead: m.dead,
|
||
|
|
circuitOpen: m.circuitOpen,
|
||
|
|
requestsTotal: m.requestsTotal,
|
||
|
|
successRate: m.successRate,
|
||
|
|
hourlyUsage: m.hourlyUsageCurrent
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Metrics — requires auth
|
||
|
|
if (path === "/metrics" && req.method === "GET") {
|
||
|
|
if (!authenticate(req)) return unauthorized(res);
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
success: true,
|
||
|
|
metrics: proxyPool.getMetrics(),
|
||
|
|
proxies: proxyPool.getProxyDetail()
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auth required for search endpoints
|
||
|
|
if (!authenticate(req)) return unauthorized(res);
|
||
|
|
|
||
|
|
if (path === "/search" && req.method === "POST") {
|
||
|
|
const body = await parseBody(req);
|
||
|
|
const { query, type = "image", limit = 10 } = body;
|
||
|
|
if (!query) return badRequest(res, "Missing 'query' field");
|
||
|
|
|
||
|
|
const start = Date.now();
|
||
|
|
const data = await runner.run({ query, type, limit });
|
||
|
|
const response = JSON.parse(formatSearchResponse(data));
|
||
|
|
response.execution_time_ms = Date.now() - start;
|
||
|
|
|
||
|
|
res.writeHead(data.image?.results?.length > 0 || data.web?.results?.length > 0 ? 200 : 404, {
|
||
|
|
"Content-Type": "application/json"
|
||
|
|
});
|
||
|
|
res.end(JSON.stringify(response));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (path === "/batch" && req.method === "POST") {
|
||
|
|
const body = await parseBody(req);
|
||
|
|
const { queries = [] } = body;
|
||
|
|
|
||
|
|
if (!Array.isArray(queries) || queries.length === 0) {
|
||
|
|
return badRequest(res, "Missing or empty 'queries' array");
|
||
|
|
}
|
||
|
|
if (queries.length > 50) {
|
||
|
|
return badRequest(res, "Maximum 50 queries per batch");
|
||
|
|
}
|
||
|
|
|
||
|
|
const logBatch = childLogger({ component: "batch", batchSize: queries.length });
|
||
|
|
|
||
|
|
const batchStart = Date.now();
|
||
|
|
logBatch.info("batch started");
|
||
|
|
|
||
|
|
const batchResults = await Promise.allSettled(
|
||
|
|
queries.map(async (q, i) => {
|
||
|
|
const qStart = Date.now();
|
||
|
|
try {
|
||
|
|
const data = await runner.run({
|
||
|
|
query: q.query || "",
|
||
|
|
type: q.type || "image",
|
||
|
|
limit: q.limit || 10
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
index: i,
|
||
|
|
query: q.query,
|
||
|
|
type: q.type || "image",
|
||
|
|
success: true,
|
||
|
|
execution_time_ms: Date.now() - qStart,
|
||
|
|
results: data,
|
||
|
|
errors: data.errors || []
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
return {
|
||
|
|
index: i,
|
||
|
|
query: q.query,
|
||
|
|
type: q.type || "image",
|
||
|
|
success: false,
|
||
|
|
execution_time_ms: Date.now() - qStart,
|
||
|
|
error: err.message
|
||
|
|
};
|
||
|
|
}
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
const totalMs = Date.now() - batchStart;
|
||
|
|
const ok = batchResults.filter(r => r.status === "fulfilled" && r.value.success).length;
|
||
|
|
const fail = batchResults.filter(r => r.status === "fulfilled" && !r.value.success).length;
|
||
|
|
|
||
|
|
logBatch.info({ total: queries.length, ok, fail, totalMs }, "batch completed");
|
||
|
|
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({
|
||
|
|
success: true,
|
||
|
|
batch_size: queries.length,
|
||
|
|
execution_time_ms: totalMs,
|
||
|
|
ok,
|
||
|
|
fail,
|
||
|
|
results: batchResults.map(r => r.status === "fulfilled" ? r.value : {
|
||
|
|
index: -1,
|
||
|
|
query: "unknown",
|
||
|
|
success: false,
|
||
|
|
error: r.reason?.message || "Promise rejected"
|
||
|
|
}),
|
||
|
|
metrics: {
|
||
|
|
pool: proxyPool.getMetrics(),
|
||
|
|
hourly_usage: proxyPool.getMetrics().hourlyUsageCurrent
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 404
|
||
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `No endpoint: ${req.method} ${path}` } }));
|
||
|
|
|
||
|
|
} catch (err) {
|
||
|
|
log.error({ error: err.message }, "API error");
|
||
|
|
serverError(res, err.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
server.listen(port, () => {
|
||
|
|
log.info({ port }, "API server started");
|
||
|
|
console.log(`\n PolySearch API running on http://localhost:${port}`);
|
||
|
|
console.log(` Health: http://localhost:${port}/health`);
|
||
|
|
console.log(` Search: POST http://localhost:${port}/search`);
|
||
|
|
console.log(` Batch: POST http://localhost:${port}/batch`);
|
||
|
|
console.log(` Metrics: GET http://localhost:${port}/metrics`);
|
||
|
|
const key = loadApiKey();
|
||
|
|
if (key) {
|
||
|
|
console.log(` Auth: Authorization: Bearer <key>`);
|
||
|
|
console.log(` Key: ${key.substring(0, 8)}...${key.slice(-4)}\n`);
|
||
|
|
} else {
|
||
|
|
console.log(` Auth: none (no API key configured)\n`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const shutdown = () => {
|
||
|
|
log.info("shutting down API server");
|
||
|
|
proxyPool.destroy();
|
||
|
|
server.close(() => process.exit(0));
|
||
|
|
};
|
||
|
|
process.on("SIGINT", shutdown);
|
||
|
|
process.on("SIGTERM", shutdown);
|
||
|
|
|
||
|
|
return server;
|
||
|
|
}
|