carom-link 1.0.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.
@@ -0,0 +1,86 @@
1
+ import chalk from 'chalk';
2
+ import { getDb, findLinkByIdOrSlug, getClickStats } from '../../db.js';
3
+ import { formatTable, formatClassification, formatDate, printError, printInfo, truncate } from '../formatters.js';
4
+
5
+ /**
6
+ * Register the `stats` command.
7
+ */
8
+ export function registerStatsCommand(program) {
9
+ program
10
+ .command('stats <idOrSlug>')
11
+ .description('Show detailed click analytics for a link')
12
+ .option('--json', 'Output as JSON')
13
+ .action(async (idOrSlug, options) => {
14
+ try {
15
+ const dataDir = program.opts().dataDir;
16
+ const db = getDb(dataDir);
17
+
18
+ const link = findLinkByIdOrSlug(db, idOrSlug);
19
+ if (!link) {
20
+ printError(`Link "${idOrSlug}" not found.`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const stats = getClickStats(db, link.id);
25
+
26
+ if (options.json) {
27
+ console.log(JSON.stringify({ link, stats }, null, 2));
28
+ process.exit(0);
29
+ }
30
+
31
+ // Header
32
+ console.log('');
33
+ console.log(chalk.bold.cyan(` Link: /${link.slug}`));
34
+ console.log(chalk.dim(` Target: ${link.destination_url}`));
35
+ console.log(chalk.dim(` Created: ${formatDate(link.created_at)}`));
36
+ console.log('');
37
+
38
+ // Click summary
39
+ console.log(chalk.bold(' Click Summary'));
40
+ console.log(chalk.dim(' ─────────────'));
41
+ console.log(` Total: ${chalk.bold(stats.totals.total)}`);
42
+ console.log(` Human: ${chalk.green(stats.totals.human || 0)}`);
43
+ console.log(` Bot: ${chalk.red(stats.totals.bot || 0)}`);
44
+ console.log(` Suspicious: ${chalk.yellow(stats.totals.suspicious || 0)}`);
45
+ console.log('');
46
+
47
+ // Timeline
48
+ console.log(chalk.bold(' Timeline'));
49
+ console.log(chalk.dim(' ────────'));
50
+ console.log(` Last 24h: ${stats.timeline.last24h}`);
51
+ console.log(` Last 7d: ${stats.timeline.last7d}`);
52
+ console.log(` Last 30d: ${stats.timeline.last30d}`);
53
+ console.log('');
54
+
55
+ // Top user agents
56
+ if (stats.topUserAgents.length > 0) {
57
+ console.log(chalk.bold(' Top User Agents'));
58
+ const uaHeaders = ['USER AGENT', 'TYPE', 'COUNT'];
59
+ const uaRows = stats.topUserAgents.map(r => [
60
+ truncate(r.user_agent || '(empty)', 50),
61
+ formatClassification(r.classification),
62
+ r.count,
63
+ ]);
64
+ console.log(formatTable(uaHeaders, uaRows));
65
+ console.log('');
66
+ }
67
+
68
+ // Top bot signals
69
+ if (stats.topSignals.length > 0) {
70
+ console.log(chalk.bold(' Top Bot Signals'));
71
+ const sigHeaders = ['SIGNALS', 'COUNT'];
72
+ const sigRows = stats.topSignals.map(r => [
73
+ r.bot_signals || '—',
74
+ r.count,
75
+ ]);
76
+ console.log(formatTable(sigHeaders, sigRows));
77
+ console.log('');
78
+ }
79
+
80
+ process.exit(0);
81
+ } catch (err) {
82
+ printError(err.message);
83
+ process.exit(1);
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { loadConfig } from '../../config.js';
5
+ import { getDb, getLinkCount } from '../../db.js';
6
+ import { getServiceStatus, isServiceInstalled, getServiceType } from '../../service/manager.js';
7
+ import { DEFAULT_DATA_DIR, PID_FILENAME } from '../../constants.js';
8
+ import { printInfo, printError } from '../formatters.js';
9
+
10
+ /**
11
+ * Register the `status` command.
12
+ */
13
+ export function registerStatusCommand(program) {
14
+ program
15
+ .command('status')
16
+ .description('Show carom server status')
17
+ .action(async () => {
18
+ try {
19
+ const dataDir = program.opts().dataDir || DEFAULT_DATA_DIR;
20
+ const config = loadConfig(dataDir);
21
+
22
+ console.log('');
23
+ console.log(chalk.bold.cyan(' carom Status'));
24
+ console.log('');
25
+
26
+ // Check PID file
27
+ const pidPath = join(dataDir, PID_FILENAME);
28
+ let pidRunning = false;
29
+ let pid = null;
30
+
31
+ if (existsSync(pidPath)) {
32
+ pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
33
+ try {
34
+ process.kill(pid, 0); // Test if process exists
35
+ pidRunning = true;
36
+ } catch {
37
+ pidRunning = false;
38
+ }
39
+ }
40
+
41
+ // Check service status
42
+ const serviceStatus = await getServiceStatus();
43
+
44
+ const running = pidRunning || serviceStatus.running;
45
+ const displayPid = serviceStatus.pid || pid;
46
+
47
+ if (running) {
48
+ console.log(` ${chalk.green('●')} ${chalk.bold('carom is running')} ${displayPid ? chalk.dim(`(PID ${displayPid})`) : ''}`);
49
+ } else {
50
+ console.log(` ${chalk.red('●')} ${chalk.bold('carom is not running')}`);
51
+ }
52
+
53
+ console.log(` ${chalk.dim('Port:')} ${config.port}`);
54
+ console.log(` ${chalk.dim('Host:')} ${config.host}`);
55
+ console.log(` ${chalk.dim('Protection:')} ${config.shield?.enabled ? chalk.green('ON') : chalk.red('OFF')}`);
56
+ console.log(` ${chalk.dim('API Key:')} ${config.apiKey ? chalk.green('SET') : chalk.yellow('NOT SET')}`);
57
+
58
+ // Link count
59
+ try {
60
+ const db = getDb(dataDir);
61
+ const count = getLinkCount(db);
62
+ console.log(` ${chalk.dim('Links:')} ${count}`);
63
+ } catch {
64
+ console.log(` ${chalk.dim('Links:')} ${chalk.dim('(DB not accessible)')}`);
65
+ }
66
+
67
+ // Service info
68
+ console.log('');
69
+ const serviceType = getServiceType();
70
+ if (serviceStatus.installed) {
71
+ console.log(` ${chalk.dim('Service:')} ${chalk.green('Installed')} (${serviceType})`);
72
+ console.log(` ${chalk.dim('Auto-start:')} ${chalk.green('Yes (survives reboots)')}`);
73
+ if (serviceStatus.path) {
74
+ console.log(` ${chalk.dim('Config:')} ${serviceStatus.path}`);
75
+ }
76
+ } else {
77
+ console.log(` ${chalk.dim('Service:')} ${chalk.dim('Not installed')}`);
78
+ console.log(` ${chalk.dim('Auto-start:')} ${chalk.dim('No')}`);
79
+ printInfo(`Run ${chalk.bold('carom install')} to auto-start on boot.`);
80
+ }
81
+
82
+ console.log('');
83
+ process.exit(0);
84
+ } catch (err) {
85
+ printError(err.message);
86
+ process.exit(1);
87
+ }
88
+ });
89
+ }
@@ -0,0 +1,28 @@
1
+ import { uninstallService, isServiceInstalled } from '../../service/manager.js';
2
+ import { printSuccess, printError, printInfo } from '../formatters.js';
3
+
4
+ /**
5
+ * Register the `uninstall` command.
6
+ */
7
+ export function registerUninstallCommand(program) {
8
+ program
9
+ .command('uninstall')
10
+ .description('Remove the carom system service')
11
+ .action(async () => {
12
+ try {
13
+ if (!isServiceInstalled()) {
14
+ printError('carom service is not installed.');
15
+ process.exit(1);
16
+ }
17
+
18
+ const result = await uninstallService();
19
+ printSuccess(result.message);
20
+ printInfo('Your data (database, config, logs) has been preserved in ~/.carom/');
21
+
22
+ process.exit(0);
23
+ } catch (err) {
24
+ printError(err.message);
25
+ process.exit(1);
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,132 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Format a table for terminal output.
5
+ */
6
+ export function formatTable(headers, rows) {
7
+ if (rows.length === 0) {
8
+ return chalk.dim(' No results.');
9
+ }
10
+
11
+ // Calculate column widths
12
+ const widths = headers.map((h, i) => {
13
+ const maxData = rows.reduce((max, row) => Math.max(max, String(row[i] ?? '').length), 0);
14
+ return Math.max(h.length, maxData);
15
+ });
16
+
17
+ // Header row
18
+ const headerLine = headers.map((h, i) => chalk.bold.cyan(h.padEnd(widths[i]))).join(' ');
19
+ const separator = widths.map(w => chalk.dim('─'.repeat(w))).join('──');
20
+
21
+ // Data rows
22
+ const dataLines = rows.map(row =>
23
+ row.map((cell, i) => {
24
+ const str = String(cell ?? '');
25
+ return str.padEnd(widths[i]);
26
+ }).join(' ')
27
+ );
28
+
29
+ return [headerLine, separator, ...dataLines].join('\n');
30
+ }
31
+
32
+ /**
33
+ * Truncate a string to a max length.
34
+ */
35
+ export function truncate(str, maxLen = 40) {
36
+ if (!str) return '';
37
+ if (str.length <= maxLen) return str;
38
+ return str.substring(0, maxLen - 1) + '…';
39
+ }
40
+
41
+ /**
42
+ * Format a click count with human/bot breakdown.
43
+ */
44
+ export function formatClicks(human, bot) {
45
+ const h = chalk.green(String(human || 0));
46
+ const b = chalk.red(String(bot || 0));
47
+ return `${h}/${b}`;
48
+ }
49
+
50
+ /**
51
+ * Format an active/inactive status badge.
52
+ */
53
+ export function formatStatus(active) {
54
+ return active ? chalk.green('✓ active') : chalk.dim('✗ inactive');
55
+ }
56
+
57
+ /**
58
+ * Format a classification badge.
59
+ */
60
+ export function formatClassification(classification) {
61
+ switch (classification) {
62
+ case 'human': return chalk.green('👤 human');
63
+ case 'bot': return chalk.red('🛡 bot');
64
+ case 'suspicious': return chalk.yellow('⚠ suspicious');
65
+ default: return chalk.dim(classification || 'unknown');
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Print a banner for server startup.
71
+ */
72
+ export function printBanner(config, linkCount) {
73
+ const adminPort = config.adminPort || 3001;
74
+ const lines = [
75
+ '',
76
+ chalk.bold.cyan(' ╔═══════════════════════════════════╗'),
77
+ chalk.bold.cyan(' ║') + chalk.bold.white(' carom v1.0.0 ') + chalk.bold.cyan('║'),
78
+ chalk.bold.cyan(' ╚═══════════════════════════════════╝'),
79
+ '',
80
+ ` ${chalk.dim('Redirects:')} ${chalk.white(`http://${config.host}:${config.port}`)}`,
81
+ ` ${chalk.dim('Admin:')} ${chalk.white(`http://${config.host}:${adminPort}`)}`,
82
+ ` ${chalk.dim('API:')} ${chalk.white(`http://${config.host}:${adminPort}/api`)}`,
83
+ ` ${chalk.dim('Links:')} ${chalk.white(linkCount)}`,
84
+ ` ${chalk.dim('Protection:')} ${config.shield?.enabled ? chalk.green('ON') + chalk.dim(` (threshold: ${config.shield.threshold}, mode: ${config.shield.mode})`) : chalk.red('OFF')}`,
85
+ ` ${chalk.dim('API Key:')} ${config.apiKey ? chalk.green('SET') : chalk.yellow('NOT SET (open access)')}`,
86
+ '',
87
+ chalk.dim(' Press Ctrl+C to stop.'),
88
+ '',
89
+ ];
90
+ console.log(lines.join('\n'));
91
+ }
92
+
93
+ /**
94
+ * Print a success message.
95
+ */
96
+ export function printSuccess(message) {
97
+ console.log(chalk.green('✓') + ' ' + message);
98
+ }
99
+
100
+ /**
101
+ * Print an error message.
102
+ */
103
+ export function printError(message) {
104
+ console.error(chalk.red('✗') + ' ' + message);
105
+ }
106
+
107
+ /**
108
+ * Print a warning message.
109
+ */
110
+ export function printWarning(message) {
111
+ console.log(chalk.yellow('⚠') + ' ' + message);
112
+ }
113
+
114
+ /**
115
+ * Print an info message.
116
+ */
117
+ export function printInfo(message) {
118
+ console.log(chalk.blue('ℹ') + ' ' + message);
119
+ }
120
+
121
+ /**
122
+ * Format a date string for display.
123
+ */
124
+ export function formatDate(dateStr) {
125
+ if (!dateStr) return chalk.dim('—');
126
+ try {
127
+ const d = new Date(dateStr);
128
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
129
+ } catch {
130
+ return dateStr;
131
+ }
132
+ }
@@ -0,0 +1,45 @@
1
+ import { Command } from 'commander';
2
+ import { VERSION, PACKAGE_NAME, DEFAULT_DATA_DIR } from '../constants.js';
3
+ import { setConfigDir } from '../config.js';
4
+ import { registerStartCommand } from './commands/start.js';
5
+ import { registerAddCommand } from './commands/add.js';
6
+ import { registerListCommand } from './commands/list.js';
7
+ import { registerRemoveCommand } from './commands/remove.js';
8
+ import { registerStatsCommand } from './commands/stats.js';
9
+ import { registerRulesCommand } from './commands/rules.js';
10
+ import { registerConfigCommand } from './commands/config.js';
11
+ import { registerInstallCommand } from './commands/install.js';
12
+ import { registerUninstallCommand } from './commands/uninstall.js';
13
+ import { registerStatusCommand } from './commands/status.js';
14
+ import { registerLogsCommand } from './commands/logs.js';
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name(PACKAGE_NAME)
20
+ .description('Link redirect server with bot and automated traffic protection')
21
+ .version(VERSION)
22
+ .option('--data-dir <path>', `Data directory (default: ${DEFAULT_DATA_DIR})`, DEFAULT_DATA_DIR)
23
+ .option('-v, --verbose', 'Verbose logging')
24
+ .hook('preAction', (thisCommand) => {
25
+ const opts = thisCommand.opts();
26
+ if (opts.dataDir) {
27
+ setConfigDir(opts.dataDir);
28
+ }
29
+ });
30
+
31
+ // Register all commands
32
+ registerStartCommand(program);
33
+ registerAddCommand(program);
34
+ registerListCommand(program);
35
+ registerRemoveCommand(program);
36
+ registerStatsCommand(program);
37
+ registerRulesCommand(program);
38
+ registerConfigCommand(program);
39
+ registerInstallCommand(program);
40
+ registerUninstallCommand(program);
41
+ registerStatusCommand(program);
42
+ registerLogsCommand(program);
43
+
44
+ // Parse CLI arguments
45
+ program.parse();
@@ -0,0 +1,243 @@
1
+ import { BOT_UA_PATTERNS } from './patterns.js';
2
+ import { lookupIp, getClientIp } from './ipLookup.js';
3
+ import { validateToken } from './tokens.js';
4
+ import { trackVelocity, getRules } from '../db.js';
5
+ import { VELOCITY_THRESHOLD, TIMING_FAST_THRESHOLD_MS } from '../constants.js';
6
+
7
+ /**
8
+ * Score a request for bot likelihood.
9
+ *
10
+ * @param {object} req - Express request object
11
+ * @param {object} options
12
+ * @param {Date|string} options.linkCreatedAt - When the link was created
13
+ * @param {string} options.slug - The link slug
14
+ * @param {object} options.config - Shield config
15
+ * @param {object} options.db - Database connection (for custom rules)
16
+ * @returns {Promise<{ score, signals, isBot, classification }>}
17
+ */
18
+ export async function scoreRequest(req, { linkCreatedAt, slug, config, db }) {
19
+ const signals = [];
20
+ let score = 0;
21
+ const threshold = config?.shield?.threshold || 40;
22
+
23
+ const ua = (req.headers['user-agent'] || '').toLowerCase();
24
+ const ip = getClientIp(req);
25
+
26
+ // ── 1. Check for valid JS challenge token (bypass all checks) ──
27
+ const token = req.query?._t;
28
+ if (token && validateToken(slug, token)) {
29
+ return {
30
+ score: 0,
31
+ signals: [{ name: 'valid_token', weight: 0, detail: 'JS challenge passed' }],
32
+ isBot: false,
33
+ classification: 'human',
34
+ };
35
+ }
36
+
37
+ // ── 2. User-Agent pattern matching ──
38
+ if (!ua || ua.length === 0) {
39
+ score += 15;
40
+ signals.push({ name: 'ua_missing', weight: 15, detail: 'No User-Agent header' });
41
+ } else {
42
+ // Check built-in patterns
43
+ for (const pattern of BOT_UA_PATTERNS) {
44
+ if (ua.includes(pattern)) {
45
+ score += 30;
46
+ signals.push({ name: 'ua_pattern', weight: 30, detail: `Matched: "${pattern}"` });
47
+ break; // Only count once for built-in patterns
48
+ }
49
+ }
50
+
51
+ // Check custom rules from DB
52
+ if (db) {
53
+ try {
54
+ const customRules = getRules(db);
55
+ for (const rule of customRules) {
56
+ if (rule.type === 'ua_pattern' && ua.includes(rule.pattern.toLowerCase())) {
57
+ const w = rule.weight || 30;
58
+ score += w;
59
+ signals.push({ name: 'custom_ua_pattern', weight: w, detail: `Custom rule: "${rule.pattern}"` });
60
+ break;
61
+ }
62
+ }
63
+ } catch {
64
+ // DB not available, skip custom rules
65
+ }
66
+ }
67
+ }
68
+
69
+ // ── 3. Header analysis ──
70
+ const suspiciousHeaders = analyzeHeaders(req);
71
+ if (suspiciousHeaders.length > 0) {
72
+ score += 20;
73
+ signals.push({
74
+ name: 'headers_suspicious',
75
+ weight: 20,
76
+ detail: suspiciousHeaders.join('; '),
77
+ });
78
+ }
79
+
80
+ // ── 4. IP / Datacenter check (async) ──
81
+ try {
82
+ const ipInfo = await lookupIp(ip);
83
+ if (ipInfo?.isDatacenter) {
84
+ score += 25;
85
+ signals.push({
86
+ name: 'datacenter_ip',
87
+ weight: 25,
88
+ detail: `ASN: ${ipInfo.asn || 'unknown'}, Org: ${ipInfo.org || 'unknown'}`,
89
+ });
90
+ }
91
+
92
+ // Check custom ASN rules
93
+ if (db && ipInfo?.asn) {
94
+ try {
95
+ const customRules = getRules(db);
96
+ for (const rule of customRules) {
97
+ if (rule.type === 'asn' && parseInt(rule.pattern, 10) === ipInfo.asn) {
98
+ const w = rule.weight || 25;
99
+ score += w;
100
+ signals.push({ name: 'custom_asn', weight: w, detail: `Custom ASN rule: ${rule.pattern}` });
101
+ break;
102
+ }
103
+ }
104
+ } catch {
105
+ // Skip
106
+ }
107
+ }
108
+ } catch {
109
+ // IP lookup failed — fail open, don't penalize
110
+ }
111
+
112
+ // ── 5. Timing analysis ──
113
+ if (linkCreatedAt) {
114
+ const createdMs = new Date(linkCreatedAt).getTime();
115
+ const elapsed = Date.now() - createdMs;
116
+ if (elapsed < TIMING_FAST_THRESHOLD_MS) {
117
+ score += 15;
118
+ signals.push({
119
+ name: 'timing_fast',
120
+ weight: 15,
121
+ detail: `Click ${elapsed}ms after link creation (threshold: ${TIMING_FAST_THRESHOLD_MS}ms)`,
122
+ });
123
+ }
124
+ }
125
+
126
+ // ── 6. Velocity check ──
127
+ if (slug) {
128
+ const hitCount = trackVelocity(slug);
129
+ if (hitCount >= VELOCITY_THRESHOLD) {
130
+ score += 15;
131
+ signals.push({
132
+ name: 'velocity_high',
133
+ weight: 15,
134
+ detail: `${hitCount} hits in 10s window (threshold: ${VELOCITY_THRESHOLD})`,
135
+ });
136
+ }
137
+ }
138
+
139
+ // ── Classification ──
140
+ let classification;
141
+ if (score >= threshold) {
142
+ classification = 'bot';
143
+ } else if (score >= 20) {
144
+ classification = 'suspicious';
145
+ } else {
146
+ classification = 'human';
147
+ }
148
+
149
+ return {
150
+ score,
151
+ signals,
152
+ isBot: classification === 'bot',
153
+ classification,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Analyze request headers for bot-like characteristics.
159
+ */
160
+ function analyzeHeaders(req) {
161
+ const issues = [];
162
+ const headers = req.headers;
163
+
164
+ // Most real browsers send Accept-Language
165
+ if (!headers['accept-language']) {
166
+ issues.push('Missing Accept-Language');
167
+ }
168
+
169
+ // Real browsers typically send a complex Accept header
170
+ const accept = headers['accept'] || '';
171
+ if (accept && !accept.includes('text/html') && !accept.includes('*/*')) {
172
+ issues.push('Unusual Accept header');
173
+ }
174
+
175
+ // Real browsers send Accept-Encoding
176
+ if (!headers['accept-encoding']) {
177
+ issues.push('Missing Accept-Encoding');
178
+ }
179
+
180
+ // Check for very short or very generic UA
181
+ const ua = headers['user-agent'] || '';
182
+ if (ua && ua.length < 20) {
183
+ issues.push(`Very short User-Agent (${ua.length} chars)`);
184
+ }
185
+
186
+ // Check for connection: close (many bots do this)
187
+ if (headers['connection'] === 'close' && !headers['accept-language']) {
188
+ issues.push('Connection: close without Accept-Language');
189
+ }
190
+
191
+ return issues;
192
+ }
193
+
194
+ /**
195
+ * Test a user-agent string against the detection engine.
196
+ * Used by the CLI `rules test` command.
197
+ * Returns a simulated score result.
198
+ */
199
+ export function testUserAgent(userAgent, db) {
200
+ const signals = [];
201
+ let score = 0;
202
+ const ua = (userAgent || '').toLowerCase();
203
+
204
+ if (!ua || ua.length === 0) {
205
+ score += 15;
206
+ signals.push({ name: 'ua_missing', weight: 15, detail: 'No User-Agent provided' });
207
+ } else {
208
+ for (const pattern of BOT_UA_PATTERNS) {
209
+ if (ua.includes(pattern)) {
210
+ score += 30;
211
+ signals.push({ name: 'ua_pattern', weight: 30, detail: `Matched built-in: "${pattern}"` });
212
+ break;
213
+ }
214
+ }
215
+
216
+ // Custom rules
217
+ if (db) {
218
+ try {
219
+ const customRules = getRules(db);
220
+ for (const rule of customRules) {
221
+ if (rule.type === 'ua_pattern' && ua.includes(rule.pattern.toLowerCase())) {
222
+ const w = rule.weight || 30;
223
+ score += w;
224
+ signals.push({ name: 'custom_ua_pattern', weight: w, detail: `Custom rule: "${rule.pattern}"` });
225
+ break;
226
+ }
227
+ }
228
+ } catch {
229
+ // Skip
230
+ }
231
+ }
232
+ }
233
+
234
+ // Note: can't test headers, IP, timing, or velocity in offline mode
235
+ signals.push({ name: 'note', weight: 0, detail: 'Header, IP, timing, and velocity checks only run on live requests' });
236
+
237
+ return {
238
+ score,
239
+ signals: signals.filter(s => s.weight > 0 || s.name === 'note'),
240
+ isBot: score >= 40,
241
+ classification: score >= 40 ? 'bot' : score >= 20 ? 'suspicious' : 'human',
242
+ };
243
+ }