Create Stonks market data daemon
This commit is contained in:
commit
77d56e1dcb
7 changed files with 583 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
scratch/
|
||||
node_modules/
|
||||
.env
|
||||
|
||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Stonks Agent Notes
|
||||
|
||||
Stonks is a market data adapter daemon for the CultMesh Verse. It is not a
|
||||
trading bot, portfolio manager, investment adviser, or renderer decoration.
|
||||
|
||||
## Authority Map
|
||||
|
||||
- Owner: Stonks owns public market data polling, normalization, provenance,
|
||||
freshness, its provider-owned Eve/CultUI surface, and local snapshot logs.
|
||||
- Inputs: public market data endpoints, configured symbols/assets, local
|
||||
runtime configuration, and future provider-owned market data feeds.
|
||||
- Outputs: `stonks.market_snapshot.v1`, `gamecult.eve.surface_state.v1`,
|
||||
`/eve/deck`, `/eve/deck/providers`, `/health`, and ignored scratch snapshots
|
||||
under `scratch/stonks/`.
|
||||
- Derived state: Odin, Nightwing, Eve clients, dashboards, and agents consume
|
||||
Stonks projections; they do not own market truth or scrape markets directly.
|
||||
- Forbidden writers: renderers, Odin, VoidBot rumination, and repo Faces must
|
||||
not invent market state. They consume the Stonks provider surface.
|
||||
- Shared paths: HTTP status, Eve surface, and future CultMesh documents are all
|
||||
derived from the same normalized market snapshot.
|
||||
|
||||
Markets own prices. Stonks owns the adapter. Odin sees the Verse.
|
||||
|
||||
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Stonks
|
||||
|
||||
Stonks publishes market-data pulse into the GameCult CultMesh Verse.
|
||||
|
||||
It polls public equity/ETF and crypto endpoints, normalizes the result, writes a
|
||||
local scratch snapshot, and exposes a provider-owned Eve/CultUI surface for
|
||||
Odin and renderers such as Nightwing.
|
||||
|
||||
## Current Body
|
||||
|
||||
- Provider id: `stonks.market`
|
||||
- Eve deck: `ws://127.0.0.1:8802/eve/deck`
|
||||
- Provider manifest: `http://127.0.0.1:8802/eve/deck/providers`
|
||||
- Health: `http://127.0.0.1:8802/health`
|
||||
- Snapshot: `http://127.0.0.1:8802/market/state`
|
||||
- Scratch state: `scratch/stonks/`
|
||||
- Default focus: gaming and tech public names such as Ubisoft, EA, Take-Two,
|
||||
Roblox, Nintendo, Sony, Microsoft, NVIDIA, AMD, Google, Meta, Apple, TSMC,
|
||||
Tesla, ASML, Corsair, Logitech, and Sea.
|
||||
- Private watch names: `Musk Complex` is represented explicitly as mixed
|
||||
public/private exposure: TSLA is tracked through the public `TSLA.US` quote,
|
||||
while SpaceX, xAI/X, Neuralink, and Boring need provider-owned news,
|
||||
disclosure, or secondary-market feeds before Stonks treats their movement as
|
||||
data.
|
||||
|
||||
## Start
|
||||
|
||||
```powershell
|
||||
.\scripts\start-stonks.ps1
|
||||
```
|
||||
|
||||
## Data Sources
|
||||
|
||||
- Equities/ETFs: Stooq CSV quote endpoint.
|
||||
- Crypto: CoinGecko simple price endpoint.
|
||||
|
||||
The daemon is for operator visibility and ambient market context. It is not
|
||||
financial advice and does not place trades.
|
||||
|
||||
Configure defaults with:
|
||||
|
||||
```powershell
|
||||
$env:STONKS_EQUITIES="ubi.fr,ea.us,ttwo.us,rblx.us,ntdoy.us,nvda.us,amd.us,tsla.us"
|
||||
$env:STONKS_CRYPTO="bitcoin,ethereum,solana,dogecoin"
|
||||
$env:STONKS_PRIVATE_WATCH="Musk Complex"
|
||||
```
|
||||
32
docs/architecture.md
Normal file
32
docs/architecture.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Stonks Architecture
|
||||
|
||||
## Objective
|
||||
|
||||
Expose market price and volume pulse as a provider-owned CultMesh/Eve surface so
|
||||
Odin can aggregate it and renderers can lower it without owning market state.
|
||||
|
||||
## Current Mechanism
|
||||
|
||||
```text
|
||||
Public market endpoints
|
||||
-> Stonks poller
|
||||
-> normalized market snapshot with provenance and freshness
|
||||
-> provider-owned Eve/CultUI surface
|
||||
-> /eve/deck WebSocket + /eve/deck/providers manifest
|
||||
-> Odin interface ingestion
|
||||
-> Eve / Nightwing / agent projection
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- Market sources own prices and volumes.
|
||||
- Stonks owns polling, normalization, provenance, and surface publication.
|
||||
- Odin owns discovery and aggregation, not market truth.
|
||||
- Renderers lower the Stonks surface only.
|
||||
- Stonks does not trade, advise, custody funds, or infer portfolio intent.
|
||||
|
||||
## Future Cut
|
||||
|
||||
When a typed CultMesh market feed exists, replace public polling with provider
|
||||
documents while preserving the same normalized snapshot and Eve surface.
|
||||
|
||||
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@gamecult/stonks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Market data daemon that publishes a provider-owned Eve/CultMesh surface for Odin.",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"check": "node --check src/stonks-daemon.cjs",
|
||||
"start": "node src/stonks-daemon.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
52
scripts/start-stonks.ps1
Normal file
52
scripts/start-stonks.ps1
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
param(
|
||||
[int] $Port = 8802,
|
||||
[string] $StateDir = "E:\Projects\Stonks\scratch\stonks",
|
||||
[switch] $Foreground
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$scriptPath = Join-Path $repoRoot "src\stonks-daemon.cjs"
|
||||
$pidPath = Join-Path $StateDir "stonks.pid"
|
||||
$outLog = Join-Path $StateDir "stonks.out.log"
|
||||
$errLog = Join-Path $StateDir "stonks.err.log"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
||||
|
||||
if (Test-Path $pidPath) {
|
||||
$oldPid = (Get-Content -Raw $pidPath).Trim()
|
||||
if ($oldPid -match "^\d+$") {
|
||||
$old = Get-Process -Id ([int] $oldPid) -ErrorAction SilentlyContinue
|
||||
if ($old) {
|
||||
throw "Stonks already appears to be running as PID $oldPid."
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $pidPath -Force
|
||||
}
|
||||
|
||||
$env:NODE_PATH = "E:\Projects\CultLib\packages"
|
||||
$args = @(
|
||||
$scriptPath,
|
||||
"--port", "$Port",
|
||||
"--stateDir", $StateDir
|
||||
)
|
||||
|
||||
if ($Foreground) {
|
||||
& node @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$proc = Start-Process -FilePath "node" -ArgumentList $args -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput $outLog -RedirectStandardError $errLog
|
||||
$proc.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath
|
||||
Start-Sleep -Seconds 1
|
||||
if ($proc.HasExited) {
|
||||
$detail = ""
|
||||
if (Test-Path $outLog) { $detail += Get-Content -Raw $outLog }
|
||||
if (Test-Path $errLog) { $detail += Get-Content -Raw $errLog }
|
||||
throw "Stonks exited immediately with code $($proc.ExitCode).`n$detail"
|
||||
}
|
||||
|
||||
Write-Host "Stonks started as PID $($proc.Id)."
|
||||
Write-Host "Health: http://127.0.0.1:$Port/health"
|
||||
Write-Host "Eve deck: ws://127.0.0.1:$Port/eve/deck"
|
||||
410
src/stonks-daemon.cjs
Normal file
410
src/stonks-daemon.cjs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const port = Number(args.port || process.env.STONKS_PORT || 8802);
|
||||
const host = args.host || process.env.STONKS_HOST || "0.0.0.0";
|
||||
const intervalMs = Number(args.intervalMs || process.env.STONKS_INTERVAL_MS || 15000);
|
||||
const stateDir = args.stateDir || path.join(repoRoot, "scratch", "stonks");
|
||||
const providerId = "stonks.market";
|
||||
const clients = new Set();
|
||||
|
||||
const equitySymbols = String(args.equities || process.env.STONKS_EQUITIES || "ubi.fr,ea.us,ttwo.us,rblx.us,ntdoy.us,7974.jp,sony.us,msft.us,nvda.us,amd.us,googl.us,meta.us,aapl.us,tsla.us,tsm.us,asml.us,crsr.us,logi.us,se.us")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const cryptoIds = String(args.crypto || process.env.STONKS_CRYPTO || "bitcoin,ethereum,solana,dogecoin")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const privateWatch = String(args.privateWatch || process.env.STONKS_PRIVATE_WATCH || "Musk Complex")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
|
||||
let version = 0;
|
||||
let latestSnapshot = pendingSnapshot("Stonks starting");
|
||||
let currentState = buildState(latestSnapshot);
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const server = http.createServer(handleHttp);
|
||||
server.on("upgrade", handleUpgrade);
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Stonks listening on ws://${host}:${port}/eve/deck`);
|
||||
});
|
||||
|
||||
await refresh();
|
||||
setInterval(() => {
|
||||
refresh().catch((error) => console.error("refresh failed:", error));
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
latestSnapshot = await marketSnapshot();
|
||||
currentState = buildState(latestSnapshot);
|
||||
persistSnapshot(latestSnapshot, currentState);
|
||||
broadcast(currentState);
|
||||
}
|
||||
|
||||
async function marketSnapshot() {
|
||||
const startedAt = new Date();
|
||||
const [equities, crypto] = await Promise.all([
|
||||
fetchEquities().catch((error) => ({ ok: false, error: error.message, items: [] })),
|
||||
fetchCrypto().catch((error) => ({ ok: false, error: error.message, items: [] })),
|
||||
]);
|
||||
return {
|
||||
schema: "stonks.market_snapshot.v1",
|
||||
providerId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt.getTime(),
|
||||
sources: {
|
||||
equities: {
|
||||
name: "Stooq",
|
||||
url: "https://stooq.com/q/l/",
|
||||
ok: equities.ok,
|
||||
error: equities.error || null,
|
||||
},
|
||||
crypto: {
|
||||
name: "CoinGecko",
|
||||
url: "https://api.coingecko.com/api/v3/simple/price",
|
||||
ok: crypto.ok,
|
||||
error: crypto.error || null,
|
||||
},
|
||||
},
|
||||
equities: equities.items,
|
||||
crypto: crypto.items,
|
||||
privateWatch: privateWatch.map(privateWatchItem),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchEquities() {
|
||||
if (equitySymbols.length === 0) return { ok: true, items: [] };
|
||||
const rows = await Promise.all(equitySymbols.map(async (symbol) => {
|
||||
const params = new URLSearchParams({ s: symbol, f: "sd2t2ohlcv", h: "", e: "csv" });
|
||||
const text = await getText(`https://stooq.com/q/l/?${params}`);
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
const header = lines.shift()?.split(",") || [];
|
||||
return lines.map((line) => csvLine(line, header));
|
||||
}));
|
||||
const items = rows
|
||||
.flat()
|
||||
.filter((row) => row.Symbol && row.Close && row.Close !== "N/D")
|
||||
.map((row) => ({
|
||||
kind: "equity",
|
||||
symbol: row.Symbol.toUpperCase(),
|
||||
price: numberOrNull(row.Close),
|
||||
open: numberOrNull(row.Open),
|
||||
high: numberOrNull(row.High),
|
||||
low: numberOrNull(row.Low),
|
||||
volume: numberOrNull(row.Volume),
|
||||
date: row.Date,
|
||||
time: row.Time,
|
||||
source: "stooq",
|
||||
}));
|
||||
return { ok: true, items };
|
||||
}
|
||||
|
||||
async function fetchCrypto() {
|
||||
if (cryptoIds.length === 0) return { ok: true, items: [] };
|
||||
const params = new URLSearchParams({
|
||||
ids: cryptoIds.join(","),
|
||||
vs_currencies: "usd",
|
||||
include_24hr_vol: "true",
|
||||
include_24hr_change: "true",
|
||||
include_market_cap: "true",
|
||||
});
|
||||
const json = JSON.parse(await getText(`https://api.coingecko.com/api/v3/simple/price?${params}`));
|
||||
const items = Object.entries(json).map(([id, value]) => ({
|
||||
kind: "crypto",
|
||||
symbol: id.toUpperCase(),
|
||||
price: numberOrNull(value.usd),
|
||||
change24h: numberOrNull(value.usd_24h_change),
|
||||
volume24h: numberOrNull(value.usd_24h_vol),
|
||||
marketCap: numberOrNull(value.usd_market_cap),
|
||||
source: "coingecko",
|
||||
}));
|
||||
return { ok: true, items };
|
||||
}
|
||||
|
||||
function buildState(snapshot) {
|
||||
version++;
|
||||
const rows = [
|
||||
textNode("updated", `updated ${snapshot.updatedAt}`),
|
||||
textNode("source-equities", `equities ${snapshot.sources.equities.ok ? "ok" : "error"} via ${snapshot.sources.equities.name}`),
|
||||
textNode("source-crypto", `crypto ${snapshot.sources.crypto.ok ? "ok" : "error"} via ${snapshot.sources.crypto.name}`),
|
||||
...snapshot.equities.map((item) => quoteNode(item)),
|
||||
...snapshot.crypto.map((item) => quoteNode(item)),
|
||||
...snapshot.privateWatch.map((item) => privateWatchNode(item)),
|
||||
];
|
||||
if (snapshot.equities.length === 0 && snapshot.crypto.length === 0) {
|
||||
rows.push(textNode("empty", snapshot.error || "no market rows available"));
|
||||
}
|
||||
|
||||
return {
|
||||
schema: "gamecult.eve.surface_state.v1",
|
||||
providerId,
|
||||
title: "Stonks",
|
||||
version,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
snapshot,
|
||||
surface: {
|
||||
root: {
|
||||
id: "stonks-root",
|
||||
kind: "interface",
|
||||
props: {
|
||||
title: "Stonks Market Pulse",
|
||||
providerId,
|
||||
text: `Market pulse: ${snapshot.equities.length} equities, ${snapshot.crypto.length} crypto assets, ${snapshot.privateWatch.length} private watch names.`,
|
||||
},
|
||||
children: rows,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function quoteNode(item) {
|
||||
const price = item.price == null ? "N/D" : `$${formatNumber(item.price)}`;
|
||||
const change = item.change24h == null ? "" : ` ${item.change24h >= 0 ? "+" : ""}${item.change24h.toFixed(2)}%`;
|
||||
const volume = item.volume24h ?? item.volume;
|
||||
const volumeText = volume == null ? "" : ` vol ${formatCompact(volume)}`;
|
||||
return textNode(`quote-${item.kind}-${item.symbol}`, `${item.symbol} ${price}${change}${volumeText}`);
|
||||
}
|
||||
|
||||
function privateWatchNode(item) {
|
||||
return textNode(`watch-${item.symbol}`, `${item.name} ${item.status}; ${item.movement}`);
|
||||
}
|
||||
|
||||
function textNode(id, text) {
|
||||
return { id, kind: "text", props: { title: id, text } };
|
||||
}
|
||||
|
||||
function pendingSnapshot(error) {
|
||||
return {
|
||||
schema: "stonks.market_snapshot.v1",
|
||||
providerId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
error,
|
||||
sources: {
|
||||
equities: { name: "Stooq", ok: false, error },
|
||||
crypto: { name: "CoinGecko", ok: false, error },
|
||||
},
|
||||
equities: [],
|
||||
crypto: [],
|
||||
privateWatch: privateWatch.map(privateWatchItem),
|
||||
};
|
||||
}
|
||||
|
||||
function privateWatchItem(name) {
|
||||
if (name.toLowerCase() === "musk complex") {
|
||||
return {
|
||||
kind: "private-watch",
|
||||
symbol: "MUSK-COMPLEX",
|
||||
name,
|
||||
status: "mixed-public-private",
|
||||
movement: "TSLA.US live; SpaceX/xAI-X/Neuralink/Boring need provider-owned news or secondary-market feed",
|
||||
source: "stonks-watchlist",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "private-watch",
|
||||
symbol: name.toUpperCase().replace(/\s+/g, "-"),
|
||||
name,
|
||||
status: "private-no-public-ticker",
|
||||
movement: "awaiting provider-owned news or secondary-market feed",
|
||||
source: "stonks-watchlist",
|
||||
};
|
||||
}
|
||||
|
||||
function persistSnapshot(snapshot, state) {
|
||||
fs.writeFileSync(path.join(stateDir, "latest-market-state.json"), JSON.stringify(snapshot, null, 2), "utf8");
|
||||
fs.writeFileSync(path.join(stateDir, "latest-surface.json"), JSON.stringify(state, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function handleHttp(req, res) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
|
||||
if (url.pathname === "/health") {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
providerId,
|
||||
version: currentState.version,
|
||||
clients: clients.size,
|
||||
intervalMs,
|
||||
stateDir,
|
||||
equities: equitySymbols,
|
||||
crypto: cryptoIds,
|
||||
privateWatch,
|
||||
sources: latestSnapshot.sources,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/eve/deck/providers") {
|
||||
sendJson(res, 200, {
|
||||
providers: [{
|
||||
id: providerId,
|
||||
title: "Stonks",
|
||||
description: "Market data pulse provider for the GameCult CultMesh Verse.",
|
||||
version: String(currentState.version),
|
||||
endpoint: "/eve/deck",
|
||||
capabilities: ["market-data", "equities", "crypto", "cultui-surface"],
|
||||
usesCultMesh: false,
|
||||
transport: "Eve WebSocket + scratch snapshot",
|
||||
}],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/market/state") {
|
||||
sendJson(res, 200, latestSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
sendText(res, 404, "not found");
|
||||
}
|
||||
|
||||
function handleUpgrade(req, socket) {
|
||||
if (!req.url.startsWith("/eve/deck")) {
|
||||
socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = req.headers["sec-websocket-key"];
|
||||
if (!key) {
|
||||
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
||||
);
|
||||
clients.add(socket);
|
||||
sendFrame(socket, 0x1, Buffer.from(JSON.stringify(currentState), "utf8"));
|
||||
socket.on("data", (chunk) => {
|
||||
if ((chunk[0] & 0x0f) === 0x8) {
|
||||
clients.delete(socket);
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
socket.on("close", () => clients.delete(socket));
|
||||
socket.on("error", () => clients.delete(socket));
|
||||
}
|
||||
|
||||
function broadcast(state) {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8");
|
||||
for (const client of [...clients]) {
|
||||
try {
|
||||
sendFrame(client, 0x1, payload);
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame(socket, opcode, payload) {
|
||||
const header = [0x80 | opcode];
|
||||
if (payload.length < 126) {
|
||||
header.push(payload.length);
|
||||
} else if (payload.length <= 0xffff) {
|
||||
header.push(126, payload.length >> 8, payload.length & 0xff);
|
||||
} else {
|
||||
const length = Buffer.alloc(8);
|
||||
length.writeBigUInt64BE(BigInt(payload.length));
|
||||
header.push(127, ...length);
|
||||
}
|
||||
socket.write(Buffer.concat([Buffer.from(header), payload]));
|
||||
}
|
||||
|
||||
function getText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 8000, headers: { "user-agent": "GameCult-Stonks/0.1" } }, (res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => { data += chunk; });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`${url} returned ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`${url} timed out`));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function csvLine(line, header) {
|
||||
const values = line.split(",");
|
||||
const row = {};
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
row[header[i]] = values[i] || "";
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Math.abs(value) >= 100 ? value.toFixed(2) : value.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function formatCompact(value) {
|
||||
if (value == null) return "";
|
||||
if (Math.abs(value) >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
parsed[key] = true;
|
||||
continue;
|
||||
}
|
||||
parsed[key] = next;
|
||||
index++;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sendText(res, status, value) {
|
||||
res.writeHead(status, { "content-type": "text/plain" });
|
||||
res.end(value);
|
||||
}
|
||||
Loading…
Reference in a new issue