cyborg-hunter 0.3.0
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 +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/cli/renderers/typing-profile.js
|
|
2
|
+
// Renders per-participant typing speed bar chart using node-canvas.
|
|
3
|
+
// Each bar = one trial's chars/sec. A horizontal threshold line shows
|
|
4
|
+
// the configured CPS threshold. Bars above threshold are colored red.
|
|
5
|
+
|
|
6
|
+
import { writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
const BAR_WIDTH = 30;
|
|
10
|
+
const BAR_GAP = 8;
|
|
11
|
+
const CHART_HEIGHT = 200;
|
|
12
|
+
const TOP_PAD = 50;
|
|
13
|
+
const BOTTOM_PAD = 50;
|
|
14
|
+
const LEFT_PAD = 60;
|
|
15
|
+
|
|
16
|
+
export async function renderTypingProfiles(participants, config) {
|
|
17
|
+
const { createCanvas } = await import('canvas');
|
|
18
|
+
const threshold = config.typingSpeedThreshold_cps || 10;
|
|
19
|
+
|
|
20
|
+
for (const p of participants) {
|
|
21
|
+
const trials = p.trials;
|
|
22
|
+
if (trials.length === 0) continue;
|
|
23
|
+
|
|
24
|
+
// Per-trial state. We distinguish three cases:
|
|
25
|
+
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
26
|
+
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
|
27
|
+
// grey hatched marker so the absence is legible. Single
|
|
28
|
+
// paste fires one input event → editTimestamps.length < 2
|
|
29
|
+
// → computeTypingSpeed returns null. Without this, a
|
|
30
|
+
// pasted response looks identical to a very slow one.
|
|
31
|
+
// "no-data" — neither, leave blank
|
|
32
|
+
const kinds = trials.map(t => {
|
|
33
|
+
if (typeof t.charsPerSec === 'number') return 'typed';
|
|
34
|
+
if ((t.pasteEvents || []).length > 0) return 'paste-only';
|
|
35
|
+
return 'no-data';
|
|
36
|
+
});
|
|
37
|
+
const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
|
|
38
|
+
if (kinds.every(k => k === 'no-data')) continue;
|
|
39
|
+
|
|
40
|
+
const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
|
|
41
|
+
const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
|
|
42
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
43
|
+
const ctx = canvas.getContext('2d');
|
|
44
|
+
|
|
45
|
+
// Background
|
|
46
|
+
ctx.fillStyle = '#ffffff';
|
|
47
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
48
|
+
|
|
49
|
+
// Title
|
|
50
|
+
ctx.fillStyle = '#333';
|
|
51
|
+
ctx.font = 'bold 14px sans-serif';
|
|
52
|
+
ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
|
|
53
|
+
ctx.font = '10px sans-serif';
|
|
54
|
+
ctx.fillStyle = '#666';
|
|
55
|
+
ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
|
|
56
|
+
|
|
57
|
+
// Legend — three states documented in the `kinds` comment above.
|
|
58
|
+
const legendY = 35;
|
|
59
|
+
const legendX0 = LEFT_PAD + 180;
|
|
60
|
+
ctx.fillStyle = '#2196F3';
|
|
61
|
+
ctx.fillRect(legendX0, legendY - 8, 12, 10);
|
|
62
|
+
ctx.fillStyle = '#666';
|
|
63
|
+
ctx.fillText('typed', legendX0 + 16, legendY);
|
|
64
|
+
ctx.fillStyle = '#f44336';
|
|
65
|
+
ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
|
|
66
|
+
ctx.fillStyle = '#666';
|
|
67
|
+
ctx.fillText('> threshold', legendX0 + 76, legendY);
|
|
68
|
+
// Hatched swatch for paste-only
|
|
69
|
+
drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
|
|
70
|
+
ctx.fillStyle = '#666';
|
|
71
|
+
ctx.fillText('paste (P)', legendX0 + 166, legendY);
|
|
72
|
+
|
|
73
|
+
// Y-axis scaling
|
|
74
|
+
const maxSpeed = Math.max(...speeds, threshold * 1.5);
|
|
75
|
+
const yScale = CHART_HEIGHT / maxSpeed;
|
|
76
|
+
|
|
77
|
+
// Y-axis labels
|
|
78
|
+
ctx.fillStyle = '#666';
|
|
79
|
+
ctx.font = '9px sans-serif';
|
|
80
|
+
const yTicks = 5;
|
|
81
|
+
for (let t = 0; t <= yTicks; t++) {
|
|
82
|
+
const val = (maxSpeed / yTicks) * t;
|
|
83
|
+
const y = TOP_PAD + CHART_HEIGHT - val * yScale;
|
|
84
|
+
ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
|
|
85
|
+
// Grid line
|
|
86
|
+
ctx.strokeStyle = '#eee';
|
|
87
|
+
ctx.lineWidth = 0.5;
|
|
88
|
+
ctx.beginPath();
|
|
89
|
+
ctx.moveTo(LEFT_PAD, y);
|
|
90
|
+
ctx.lineTo(canvasW - 10, y);
|
|
91
|
+
ctx.stroke();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Threshold line
|
|
95
|
+
const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
|
|
96
|
+
ctx.strokeStyle = '#ff0000';
|
|
97
|
+
ctx.lineWidth = 1.5;
|
|
98
|
+
ctx.setLineDash([5, 3]);
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
ctx.moveTo(LEFT_PAD, threshY);
|
|
101
|
+
ctx.lineTo(canvasW - 10, threshY);
|
|
102
|
+
ctx.stroke();
|
|
103
|
+
ctx.setLineDash([]);
|
|
104
|
+
|
|
105
|
+
// Bars
|
|
106
|
+
const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
|
|
107
|
+
for (let i = 0; i < trials.length; i++) {
|
|
108
|
+
const kind = kinds[i];
|
|
109
|
+
const speed = speeds[i];
|
|
110
|
+
const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
|
|
111
|
+
|
|
112
|
+
if (kind === 'paste-only') {
|
|
113
|
+
// Fixed-height hatched grey bar + "P" marker. The height is arbitrary
|
|
114
|
+
// — the point is visible presence, not magnitude (we have no CPS to
|
|
115
|
+
// show). Sits at the bottom like a normal bar.
|
|
116
|
+
const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
|
|
117
|
+
drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
118
|
+
ctx.strokeStyle = '#888';
|
|
119
|
+
ctx.lineWidth = 0.5;
|
|
120
|
+
ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
121
|
+
// "P" marker above the bar
|
|
122
|
+
ctx.fillStyle = '#555';
|
|
123
|
+
ctx.font = 'bold 11px sans-serif';
|
|
124
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
125
|
+
} else if (kind === 'typed') {
|
|
126
|
+
const barH = speed * yScale;
|
|
127
|
+
const y = TOP_PAD + CHART_HEIGHT - barH;
|
|
128
|
+
ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
|
|
129
|
+
ctx.fillRect(x, y, BAR_WIDTH, barH);
|
|
130
|
+
ctx.strokeStyle = '#333';
|
|
131
|
+
ctx.lineWidth = 0.5;
|
|
132
|
+
ctx.strokeRect(x, y, BAR_WIDTH, barH);
|
|
133
|
+
if (speed > 0) {
|
|
134
|
+
ctx.fillStyle = '#fff';
|
|
135
|
+
ctx.font = 'bold 9px sans-serif';
|
|
136
|
+
ctx.fillText(speed.toFixed(1), x + 3, y + 12);
|
|
137
|
+
}
|
|
138
|
+
// Mixed: typed AND pasted in the same trial. The bar shows the typing
|
|
139
|
+
// rate (legitimate signal); the "P" glyph above it flags that *some*
|
|
140
|
+
// of the response came in via paste. Distinct from pure paste-only
|
|
141
|
+
// (which is a hatched bar, no colored fill).
|
|
142
|
+
if ((trials[i].pasteEvents || []).length > 0) {
|
|
143
|
+
ctx.fillStyle = '#555';
|
|
144
|
+
ctx.font = 'bold 11px sans-serif';
|
|
145
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 'no-data' → render nothing (blank column)
|
|
149
|
+
|
|
150
|
+
// Trial label (always drawn, even for blank columns)
|
|
151
|
+
const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
|
|
152
|
+
ctx.fillStyle = '#333';
|
|
153
|
+
ctx.font = '8px sans-serif';
|
|
154
|
+
ctx.save();
|
|
155
|
+
ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
|
|
156
|
+
ctx.rotate(Math.PI / 4);
|
|
157
|
+
ctx.fillText(trialId.substring(0, 12), 0, 0);
|
|
158
|
+
ctx.restore();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Baseline
|
|
162
|
+
ctx.strokeStyle = '#333';
|
|
163
|
+
ctx.lineWidth = 1;
|
|
164
|
+
ctx.beginPath();
|
|
165
|
+
ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
|
|
166
|
+
ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
|
|
167
|
+
ctx.stroke();
|
|
168
|
+
|
|
169
|
+
const buf = canvas.toBuffer('image/png');
|
|
170
|
+
const filename = `typing_profile_${sanitize(p.participantId)}.png`;
|
|
171
|
+
writeFileSync(join(config.outputDir, 'images', filename), buf);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(` typing profiles — rendered`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sanitize(name) {
|
|
178
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Cross-hatched fill for paste-only markers. Uses a light grey background
|
|
182
|
+
// with diagonal strokes so the bar reads as "distinct category, not zero".
|
|
183
|
+
function drawHatchedRect(ctx, x, y, w, h) {
|
|
184
|
+
ctx.save();
|
|
185
|
+
ctx.fillStyle = '#e8e8e8';
|
|
186
|
+
ctx.fillRect(x, y, w, h);
|
|
187
|
+
ctx.beginPath();
|
|
188
|
+
ctx.rect(x, y, w, h);
|
|
189
|
+
ctx.clip();
|
|
190
|
+
ctx.strokeStyle = '#888';
|
|
191
|
+
ctx.lineWidth = 0.8;
|
|
192
|
+
const step = 5;
|
|
193
|
+
for (let d = -h; d < w + h; d += step) {
|
|
194
|
+
ctx.beginPath();
|
|
195
|
+
ctx.moveTo(x + d, y);
|
|
196
|
+
ctx.lineTo(x + d + h, y + h);
|
|
197
|
+
ctx.stroke();
|
|
198
|
+
}
|
|
199
|
+
ctx.restore();
|
|
200
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/cli/report.js
|
|
2
|
+
// CLI report pipeline orchestrator.
|
|
3
|
+
// Runs: config → ingest → analyze → render → output
|
|
4
|
+
//
|
|
5
|
+
// This is the main pipeline that ties all CLI modules together.
|
|
6
|
+
// Each stage is imported lazily where possible so partial runs
|
|
7
|
+
// (e.g., missing canvas) degrade gracefully.
|
|
8
|
+
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
11
|
+
import { ingest } from './ingest.js';
|
|
12
|
+
import { computeSummary } from './analyzers/summary.js';
|
|
13
|
+
import { detectEdgeExits } from './analyzers/edge-exit.js';
|
|
14
|
+
import { rankTriage } from './analyzers/triage.js';
|
|
15
|
+
import { VERSION } from '../shared/constants.js';
|
|
16
|
+
|
|
17
|
+
export async function run(args) {
|
|
18
|
+
console.log(`cyborg-hunter report v${VERSION}\n`);
|
|
19
|
+
|
|
20
|
+
// 1. Load and merge config (defaults ← file ← CLI flags)
|
|
21
|
+
const config = loadConfig(args);
|
|
22
|
+
|
|
23
|
+
// 2. Ingest participant data files
|
|
24
|
+
const { participants, warnings } = await ingest(config);
|
|
25
|
+
console.log(`Found ${participants.length} participants` +
|
|
26
|
+
(warnings.length ? ` (${warnings.length} files had warnings)` : ''));
|
|
27
|
+
|
|
28
|
+
if (participants.length === 0) {
|
|
29
|
+
console.error('No valid participant data found. Check dataDir and filePattern.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority
|
|
34
|
+
const summaries = computeSummary(participants, config);
|
|
35
|
+
const edgeExits = detectEdgeExits(participants, config);
|
|
36
|
+
const triage = rankTriage(summaries, edgeExits, config);
|
|
37
|
+
|
|
38
|
+
const flaggedHard = triage.filter(t => t.hardTriggered).length;
|
|
39
|
+
const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
|
|
40
|
+
const clean = triage.length - flaggedHard - flaggedSoft;
|
|
41
|
+
|
|
42
|
+
console.log(`\nAnalyzing...`);
|
|
43
|
+
console.log(` Participants flagged (hard signals): ${flaggedHard}`);
|
|
44
|
+
console.log(` Participants flagged (soft score): ${flaggedSoft}`);
|
|
45
|
+
console.log(` Participants clean: ${clean}`);
|
|
46
|
+
|
|
47
|
+
// 4. Render outputs
|
|
48
|
+
console.log(`\nRendering...`);
|
|
49
|
+
const { mkdirSync } = await import('fs');
|
|
50
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
// Text-based renderers (always available, no native dependencies)
|
|
53
|
+
const { renderSummaryCSV } = await import('./renderers/summary-csv.js');
|
|
54
|
+
const { renderTriage } = await import('./renderers/triage-md.js');
|
|
55
|
+
const { renderEventLog } = await import('./renderers/event-log.js');
|
|
56
|
+
const { renderExtensions } = await import('./renderers/extensions.js');
|
|
57
|
+
|
|
58
|
+
await renderSummaryCSV(summaries, triage, config);
|
|
59
|
+
await renderTriage(triage, config);
|
|
60
|
+
await renderEventLog(participants, config);
|
|
61
|
+
await renderExtensions(participants, config);
|
|
62
|
+
|
|
63
|
+
// Visual renderers require node-canvas (optional dependency).
|
|
64
|
+
// If canvas is unavailable, skip with a helpful install message.
|
|
65
|
+
let visualsRendered = false;
|
|
66
|
+
if (!config.noVisuals) {
|
|
67
|
+
let canvas;
|
|
68
|
+
try {
|
|
69
|
+
canvas = await import('canvas');
|
|
70
|
+
} catch {
|
|
71
|
+
console.log(`\n Trajectory images require the 'canvas' package, which needs Cairo.\n`);
|
|
72
|
+
console.log(` macOS: brew install pkg-config cairo pango libpng jpeg giflib librsvg`);
|
|
73
|
+
console.log(` Ubuntu: sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev`);
|
|
74
|
+
console.log(` Windows: https://github.com/nicktacik/node-canvas#compiling\n`);
|
|
75
|
+
console.log(` Then run: npm install canvas`);
|
|
76
|
+
console.log(` Continuing without visuals.\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (canvas) {
|
|
80
|
+
const { renderTrajectories } = await import('./renderers/trajectories.js');
|
|
81
|
+
const { renderTabTimelines } = await import('./renderers/tab-timeline.js');
|
|
82
|
+
const { renderTypingProfiles } = await import('./renderers/typing-profile.js');
|
|
83
|
+
|
|
84
|
+
// Create images/ subdirectory for visual outputs
|
|
85
|
+
mkdirSync(join(config.outputDir, 'images'), { recursive: true });
|
|
86
|
+
|
|
87
|
+
await renderTrajectories(participants, triage, config);
|
|
88
|
+
await renderTabTimelines(participants, config);
|
|
89
|
+
await renderTypingProfiles(participants, config);
|
|
90
|
+
visualsRendered = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// HTML index page — references images/ folder (not base64-embedded)
|
|
95
|
+
const { renderHtmlIndex } = await import('./renderers/html-index.js');
|
|
96
|
+
await renderHtmlIndex(summaries, triage, participants, config, visualsRendered);
|
|
97
|
+
|
|
98
|
+
console.log(`\nReport written to ${config.outputDir}/`);
|
|
99
|
+
console.log(` Open ${config.outputDir}/index.html to review`);
|
|
100
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/core/index.js
|
|
2
|
+
// Package entry point. Exports init() and static utilities.
|
|
3
|
+
//
|
|
4
|
+
// IMPORTANT: Do NOT manually assign window.CyborgHunter here.
|
|
5
|
+
// esbuild's globalName: 'CyborgHunter' handles the IIFE global assignment,
|
|
6
|
+
// and the build footer adds window.IntegrityMonitor = CyborgHunter.
|
|
7
|
+
// Manual assignment would create a second object, causing state divergence.
|
|
8
|
+
|
|
9
|
+
import { init } from './monitor.js';
|
|
10
|
+
import { VERSION } from '../shared/constants.js';
|
|
11
|
+
import { preventTextSelection, addHoneypot, setAltText } from './signals/dom-protection.js';
|
|
12
|
+
|
|
13
|
+
export { init, VERSION, preventTextSelection, addHoneypot, setAltText };
|