election-vibe 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cli.js +92 -16
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Unofficial results for the US general election of November 3, 2026, in your term
|
|
|
9
9
|
You need Node 22 or later.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm i -g election-vibe # installs the `elections` command
|
|
12
|
+
npm i -g election-vibe # installs the `elections` command (also as `election-vibe`)
|
|
13
13
|
elections collect # the collector: leave it running in its own terminal
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -302,6 +302,8 @@ To remove it all, in this order (the plugin lives inside the package's folder, s
|
|
|
302
302
|
|
|
303
303
|
**Does collection stop on November 4?** No: the collector runs until you stop it. November 4 is how long the tool is supported; see [Operating window](#operating-window).
|
|
304
304
|
|
|
305
|
+
**Why does `collect --once` take so long?** It is not stuck. `--once` reads everything that is due, once, then exits. On a fresh install that is every contest the collector reads from civicAPI (471, plus one for each followed California contest that civicAPI also carries), one request at a time at about one per second, so the single pass takes about 10 minutes and prints its one log line at the end. Plain `elections collect` paces itself (at most 9 civicAPI requests per pass, then a 10-second wait before the next pass) and is the normal way to run it. For a quick connectivity check, `elections collect --once --civic-limit 2` bounds the pass to two civicAPI requests and exits in seconds.
|
|
306
|
+
|
|
305
307
|
**Does this run on Windows?** Not tested. This release was built and tested on macOS. The CLI uses only Node, but the plugin assumes a macOS or Linux shell path, and nothing here has been tried on Windows or Linux.
|
|
306
308
|
|
|
307
309
|
**Does `race` treat the comma as part of the query?** Yes. `race` takes its words as one phrase, punctuation included, and looks for it in titles and ids: `"palo alto, transactions"` matches Palo Alto's Measure J because its title contains that phrase. `find` is the one that matches word by word.
|
|
@@ -310,6 +312,8 @@ To remove it all, in this order (the plugin lives inside the package's folder, s
|
|
|
310
312
|
|
|
311
313
|
**Which Claude Code version?** The plugin was tested with Claude Code 2.1.316. To update: update the package, restart the collector, then `/reload-plugins`. To remove: the plugin before the package (see above).
|
|
312
314
|
|
|
315
|
+
**Does it work behind a corporate proxy?** It connects to the sources directly and does not use a proxy (`HTTPS_PROXY` is not read). On a network that inspects HTTPS or blocks direct connections, the reads fail, and `elections sources` says why for each source (for example "the connection's certificate is not trusted").
|
|
316
|
+
|
|
313
317
|
**How do I pick the display time zone?** `--tz <IANA zone>` on any command, or `ELECTIONS_TZ`. The default is your computer's zone.
|
|
314
318
|
|
|
315
319
|
**How much disk does it use?** A few megabytes for the snapshots, plus one log line per collector cycle.
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { appendFileSync } from "fs";
|
|
4
|
+
import { appendFileSync, realpathSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
5
6
|
|
|
6
7
|
// src/store.ts
|
|
7
8
|
import { randomBytes } from "crypto";
|
|
@@ -215,7 +216,7 @@ var REHEARSAL_SENTENCE = {
|
|
|
215
216
|
|
|
216
217
|
// src/http.ts
|
|
217
218
|
import { gunzipSync } from "zlib";
|
|
218
|
-
var USER_AGENT = `ElectionsCompanion/${"0.1.
|
|
219
|
+
var USER_AGENT = `ElectionsCompanion/${"0.1.2"} (unofficial results reader; local, no telemetry)`;
|
|
219
220
|
function retryAfterMs(header, nowMs) {
|
|
220
221
|
if (!header) return void 0;
|
|
221
222
|
const t = header.trim();
|
|
@@ -295,6 +296,9 @@ async function fetchDocument(url, opts) {
|
|
|
295
296
|
}
|
|
296
297
|
}
|
|
297
298
|
if (res.status === 401 || res.status === 403) return { kind: "denied", status: res.status, fetchedAt: stamp() };
|
|
299
|
+
if (res.status === 407) {
|
|
300
|
+
return { kind: "client-error", status: 407, fetchedAt: stamp(), detail: "HTTP 407: a proxy asks for credentials; this tool connects directly and does not use a proxy" };
|
|
301
|
+
}
|
|
298
302
|
if (res.status >= 500) return { kind: "server-error", status: res.status, fetchedAt: stamp() };
|
|
299
303
|
if (res.status !== 200) return { kind: "client-error", status: res.status, fetchedAt: stamp() };
|
|
300
304
|
let body = opts.method === "HEAD" ? Buffer.alloc(0) : await readBounded2(res, maxBytes);
|
|
@@ -319,11 +323,23 @@ async function fetchDocument(url, opts) {
|
|
|
319
323
|
} catch (err) {
|
|
320
324
|
if (err instanceof BodyTooLargeError) return { kind: "too-large", status: err.status, fetchedAt: stamp(), detail: "captured body over bound" };
|
|
321
325
|
const aborted = err.name === "AbortError";
|
|
322
|
-
return { kind: aborted ? "timeout" : "network", fetchedAt: stamp(), detail: aborted ? void 0 :
|
|
326
|
+
return { kind: aborted ? "timeout" : "network", fetchedAt: stamp(), detail: aborted ? void 0 : networkWords(err) };
|
|
323
327
|
} finally {
|
|
324
328
|
clearTimeout(timer);
|
|
325
329
|
}
|
|
326
330
|
}
|
|
331
|
+
function networkWords(err) {
|
|
332
|
+
const raw = err?.cause?.code;
|
|
333
|
+
const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(raw) ? raw : "";
|
|
334
|
+
if (/^(SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_(GET_ISSUER_CERT(_LOCALLY)?|VERIFY_LEAF_SIGNATURE)|CERT_[A-Z_]+|ERR_TLS_[A-Z_]+)$/.test(code)) {
|
|
335
|
+
return `the connection's certificate is not trusted (${code}): a network that inspects HTTPS, such as a corporate proxy, causes this; this tool connects directly`;
|
|
336
|
+
}
|
|
337
|
+
if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/.test(code)) {
|
|
338
|
+
return `the connection was refused or cut off (${code}): no network, or a firewall or proxy that blocks direct connections`;
|
|
339
|
+
}
|
|
340
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return `the source's name could not be resolved (${code}): no network, or a network that blocks the name`;
|
|
341
|
+
return code ? `the connection failed (${code})` : "the connection failed";
|
|
342
|
+
}
|
|
327
343
|
function jsonOf(body) {
|
|
328
344
|
try {
|
|
329
345
|
return JSON.parse(body.toString("utf8"));
|
|
@@ -1927,6 +1943,8 @@ var CIVIC_BLANKET_FLOOR_SEC = 900;
|
|
|
1927
1943
|
var THROTTLE_FLOOR_MS = 5 * 6e4;
|
|
1928
1944
|
var BACKOFF_MAX_MS = 30 * 6e4;
|
|
1929
1945
|
var MAX_EVENTS = 2e3;
|
|
1946
|
+
var CLOSE_SPREAD_MS = 6e4;
|
|
1947
|
+
var READ_JITTER_SHARE = 0.1;
|
|
1930
1948
|
var CLARITY_RECHECK_MS = 30 * 6e4;
|
|
1931
1949
|
var LockLost = class extends Error {
|
|
1932
1950
|
constructor(report) {
|
|
@@ -1985,10 +2003,40 @@ function newerRecord(a, b) {
|
|
|
1985
2003
|
function blocked(s, nowMs) {
|
|
1986
2004
|
return !!s.deniedAt || !!s.cooldownUntil && Date.parse(s.cooldownUntil) > nowMs;
|
|
1987
2005
|
}
|
|
1988
|
-
|
|
2006
|
+
var closeOffsets = /* @__PURE__ */ new WeakMap();
|
|
2007
|
+
function closeOffsetMs(deps, id) {
|
|
2008
|
+
let run = closeOffsets.get(deps);
|
|
2009
|
+
if (!run) {
|
|
2010
|
+
run = /* @__PURE__ */ new Map();
|
|
2011
|
+
closeOffsets.set(deps, run);
|
|
2012
|
+
}
|
|
2013
|
+
let o = run.get(id);
|
|
2014
|
+
if (o === void 0) {
|
|
2015
|
+
const r = deps.random();
|
|
2016
|
+
o = r >= 0 && r < 1 ? Math.floor(r * CLOSE_SPREAD_MS) : 0;
|
|
2017
|
+
run.set(id, o);
|
|
2018
|
+
}
|
|
2019
|
+
return o;
|
|
2020
|
+
}
|
|
2021
|
+
var readJitters = /* @__PURE__ */ new WeakMap();
|
|
2022
|
+
function readJitterMs(deps, id, after, cadenceMs) {
|
|
2023
|
+
let run = readJitters.get(deps);
|
|
2024
|
+
if (!run) {
|
|
2025
|
+
run = /* @__PURE__ */ new Map();
|
|
2026
|
+
readJitters.set(deps, run);
|
|
2027
|
+
}
|
|
2028
|
+
let held = run.get(id);
|
|
2029
|
+
if (!held || held.after !== after) {
|
|
2030
|
+
const r = deps.random();
|
|
2031
|
+
held = { after, share: r >= 0 && r < 1 ? r : 0 };
|
|
2032
|
+
run.set(id, held);
|
|
2033
|
+
}
|
|
2034
|
+
return Math.floor(held.share * READ_JITTER_SHARE * cadenceMs);
|
|
2035
|
+
}
|
|
2036
|
+
function sourceCadenceMs(src, contests, nowMs, rehearsal, offsetMs, jitterMs) {
|
|
1989
2037
|
if (rehearsal) return src.cadenceSec * 1e3;
|
|
1990
|
-
const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) <= nowMs);
|
|
1991
|
-
return
|
|
2038
|
+
const anyClosed = contests.some((c) => c.sources.some((m2) => m2.source === src.id) && Date.parse(c.pollsClose) + offsetMs <= nowMs);
|
|
2039
|
+
return anyClosed ? src.cadenceSec * 1e3 + jitterMs() : src.preCloseCadenceSec * 1e3;
|
|
1992
2040
|
}
|
|
1993
2041
|
function due(s, cadenceMs, nowMs) {
|
|
1994
2042
|
if (blocked(s, nowMs)) return false;
|
|
@@ -2123,7 +2171,7 @@ function sealSources(raw) {
|
|
|
2123
2171
|
}
|
|
2124
2172
|
return out;
|
|
2125
2173
|
}
|
|
2126
|
-
function civicQueue(registry, src, config, last, nowMs) {
|
|
2174
|
+
function civicQueue(registry, src, config, last, nowMs, closeOffsetMs2 = 0, jitterMs = () => 0) {
|
|
2127
2175
|
const blanketSec = Math.max(CIVIC_BLANKET_FLOOR_SEC, config.blanketSec ?? Number(src.config.blanketSec ?? 1800));
|
|
2128
2176
|
const out = [];
|
|
2129
2177
|
for (const def of registry) {
|
|
@@ -2131,10 +2179,10 @@ function civicQueue(registry, src, config, last, nowMs) {
|
|
|
2131
2179
|
if (!m2) continue;
|
|
2132
2180
|
const watch = config.follow.includes(def.id) || (def.presets ?? []).includes("hot");
|
|
2133
2181
|
if (m2.role !== "primary" && !watch) continue;
|
|
2134
|
-
const preClose = nowMs < Date.parse(def.pollsClose);
|
|
2182
|
+
const preClose = nowMs < Date.parse(def.pollsClose) + closeOffsetMs2;
|
|
2135
2183
|
const cadence = (preClose ? src.preCloseCadenceSec : watch ? src.cadenceSec : blanketSec) * 1e3;
|
|
2136
2184
|
const prev = last[m2.key];
|
|
2137
|
-
if (prev !== void 0 && nowMs - prev < cadence) continue;
|
|
2185
|
+
if (prev !== void 0 && nowMs - prev < cadence + (preClose ? 0 : jitterMs(m2.key, prev, cadence))) continue;
|
|
2138
2186
|
out.push({ def, key: m2.key, tier: watch ? "watch" : "blanket", age: prev === void 0 ? Number.POSITIVE_INFINITY : nowMs - prev });
|
|
2139
2187
|
}
|
|
2140
2188
|
out.sort((a, b) => a.tier === b.tier ? b.age - a.age : a.tier === "watch" ? -1 : 1);
|
|
@@ -2223,7 +2271,15 @@ async function cycle(deps, opts) {
|
|
|
2223
2271
|
if (src.kind === "civicapi") continue;
|
|
2224
2272
|
const approval = approvals[src.id];
|
|
2225
2273
|
const approvedSinceAttempt = !!approval && !!s.lastAttemptAt && Date.parse(approval.approvedAt) > Date.parse(s.lastAttemptAt);
|
|
2226
|
-
|
|
2274
|
+
const cadenceMs = sourceCadenceMs(
|
|
2275
|
+
src,
|
|
2276
|
+
deps.registry,
|
|
2277
|
+
t0,
|
|
2278
|
+
opts.admissionDisabled === true,
|
|
2279
|
+
closeOffsetMs(deps, src.id),
|
|
2280
|
+
() => readJitterMs(deps, src.id, s.lastAttemptAt, src.cadenceSec * 1e3)
|
|
2281
|
+
);
|
|
2282
|
+
if (!(approvedSinceAttempt && !blocked(s, t0)) && !due(s, cadenceMs, t0)) {
|
|
2227
2283
|
report.outcomes[src.id] = "skipped";
|
|
2228
2284
|
continue;
|
|
2229
2285
|
}
|
|
@@ -2481,7 +2537,15 @@ async function cycle(deps, opts) {
|
|
|
2481
2537
|
const civic = deps.sources.find((s) => s.kind === "civicapi");
|
|
2482
2538
|
if (civic && !config.disabledSources.includes(civic.id)) {
|
|
2483
2539
|
const minInterval = Number(civic.config.minIntervalMs ?? 1100);
|
|
2484
|
-
const queue = civicQueue(
|
|
2540
|
+
const queue = civicQueue(
|
|
2541
|
+
deps.registry,
|
|
2542
|
+
civic,
|
|
2543
|
+
config,
|
|
2544
|
+
cstate.civicLast,
|
|
2545
|
+
deps.now(),
|
|
2546
|
+
opts.admissionDisabled === true ? 0 : closeOffsetMs(deps, civic.id),
|
|
2547
|
+
opts.admissionDisabled === true ? void 0 : (key, prev, cadenceMs) => readJitterMs(deps, `civic:${key}`, prev, cadenceMs)
|
|
2548
|
+
);
|
|
2485
2549
|
const limit = opts.civicLimit ?? queue.length;
|
|
2486
2550
|
let n = 0;
|
|
2487
2551
|
for (const item of queue) {
|
|
@@ -2492,7 +2556,9 @@ async function cycle(deps, opts) {
|
|
|
2492
2556
|
log("civicapi: hourly ceiling reached; the rest waits");
|
|
2493
2557
|
break;
|
|
2494
2558
|
}
|
|
2495
|
-
|
|
2559
|
+
const draw = deps.random();
|
|
2560
|
+
const spread = draw >= 0 && draw < 1 ? Math.round(draw * 250) : 0;
|
|
2561
|
+
await deps.sleep(n > 0 ? minInterval + spread : spread);
|
|
2496
2562
|
n++;
|
|
2497
2563
|
cstate.civicLast[item.key] = deps.now();
|
|
2498
2564
|
const r = await get(civic, `${civic.config.base}/race/${item.key}`);
|
|
@@ -46269,7 +46335,7 @@ function storeOf(p) {
|
|
|
46269
46335
|
}
|
|
46270
46336
|
async function main(argv, io) {
|
|
46271
46337
|
if (argv[0] === "--version" || argv[0] === "version") {
|
|
46272
|
-
io.out("0.1.
|
|
46338
|
+
io.out("0.1.2");
|
|
46273
46339
|
return 0;
|
|
46274
46340
|
}
|
|
46275
46341
|
const p = parseArgs(argv);
|
|
@@ -46476,6 +46542,8 @@ ${renderRace(v, { nowMs, zone, width, color, partisan, pollsClose })}`);
|
|
|
46476
46542
|
disabled: cfg.disabledSources.includes(s.id),
|
|
46477
46543
|
approved: approvals[s.id] ?? null,
|
|
46478
46544
|
lastOutcome: st?.lastOutcome ?? null,
|
|
46545
|
+
// Why the last read failed, in words (a blocked network names what blocks it); null after a success.
|
|
46546
|
+
lastOutcomeDetail: st?.lastOutcome && st.lastOutcome !== "ok" && st.lastOutcome !== "not-modified" && st.lastOutcomeDetail ? humanLabel(st.lastOutcomeDetail, 300) : null,
|
|
46479
46547
|
lastSuccessAt: st?.lastSuccessAt ?? null,
|
|
46480
46548
|
cooldownUntil: st?.cooldownUntil ?? null,
|
|
46481
46549
|
deniedAt: st?.deniedAt ?? null,
|
|
@@ -46492,7 +46560,7 @@ ${renderRace(v, { nowMs, zone, width, color, partisan, pollsClose })}`);
|
|
|
46492
46560
|
const lines = rows.map(
|
|
46493
46561
|
(r) => [
|
|
46494
46562
|
`${r.name} (${r.id}) \xB7 ledger ${r.ledger} \xB7 admission ${r.admission}${r.disabled ? " \xB7 DISABLED locally" : ""}`,
|
|
46495
|
-
` last: ${r.lastOutcome ?? "never read"}${r.lastSuccessAt ? ` \xB7 last success ${formatAge(r.lastSuccessAt, nowMs)} ago` : ""}${r.cooldownUntil && Date.parse(r.cooldownUntil) > nowMs ? ` \xB7 cooling down until ${formatInstant(r.cooldownUntil, zone)}` : ""}${r.deniedAt ? " \xB7 ACCESS DENIED: stopped pending diagnosis" : ""}`,
|
|
46563
|
+
` last: ${r.lastOutcome ?? "never read"}${r.lastOutcomeDetail ? `: ${r.lastOutcomeDetail}` : ""}${r.lastSuccessAt ? ` \xB7 last success ${formatAge(r.lastSuccessAt, nowMs)} ago` : ""}${r.cooldownUntil && Date.parse(r.cooldownUntil) > nowMs ? ` \xB7 cooling down until ${formatInstant(r.cooldownUntil, zone)}` : ""}${r.deniedAt ? " \xB7 ACCESS DENIED: stopped pending diagnosis" : ""}`,
|
|
46496
46564
|
` revision: ${r.lastRevision ? `${localizeTimes(r.lastRevision.label, zone)} (hash ${r.lastRevision.hash})` : "none"} \xB7 production: ${r.approved ? `approved from ${approvalPoint(r.id, r.approved.revisionOrder, zone)}, at ${formatInstant(r.approved.approvedAt, zone)}` : r.admission === "operator" ? "awaiting production approval" : "admitted by source tag after poll close"}`,
|
|
46497
46565
|
` requests this hour: ${r.requestsThisHour} \xB7 ${r.officialLink ? `official results: ${r.officialLink}` : `not an official source (aggregator): ${r.site}`}`
|
|
46498
46566
|
].join("\n")
|
|
@@ -46720,8 +46788,15 @@ function approvalPoint(sourceId, order, zone) {
|
|
|
46720
46788
|
const src = sourceById(sourceId);
|
|
46721
46789
|
return src && orderIsTime(src) ? `the report of ${formatInstant(new Date(order).toISOString(), zone)}` : `revision ${order}`;
|
|
46722
46790
|
}
|
|
46723
|
-
|
|
46724
|
-
if (
|
|
46791
|
+
function isEntryPath(launched, moduleUrl) {
|
|
46792
|
+
if (!launched) return false;
|
|
46793
|
+
try {
|
|
46794
|
+
return realpathSync(launched) === realpathSync(fileURLToPath(moduleUrl));
|
|
46795
|
+
} catch {
|
|
46796
|
+
return false;
|
|
46797
|
+
}
|
|
46798
|
+
}
|
|
46799
|
+
if (isEntryPath(process.argv[1], import.meta.url)) {
|
|
46725
46800
|
main(process.argv.slice(2), {
|
|
46726
46801
|
out: (s) => process.stdout.write(`${s}
|
|
46727
46802
|
`),
|
|
@@ -46751,6 +46826,7 @@ function exitWhenFlushed(code) {
|
|
|
46751
46826
|
process.stderr.write("", done);
|
|
46752
46827
|
}
|
|
46753
46828
|
export {
|
|
46829
|
+
isEntryPath,
|
|
46754
46830
|
main,
|
|
46755
46831
|
parseArgs
|
|
46756
46832
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "election-vibe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "US election night, Nov 3, 2026, in your terminal and in Claude Code: unofficial results from official free sources and civicAPI, kept on your machine. Never calls a race. Unofficial results. Not an election authority. Not affiliated with any government, campaign, or party.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"election",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"node": ">=22"
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
|
-
"elections": "./dist/cli.js"
|
|
22
|
+
"elections": "./dist/cli.js",
|
|
23
|
+
"election-vibe": "./dist/cli.js"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"dist",
|