idle-node 0.1.0 → 0.2.1

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/bin/idle-node.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { startNode } from '../src/node.js';
3
+ import { startNode, ALL_TYPES } from '../src/node.js';
4
4
  import chalk from 'chalk';
5
5
 
6
6
  const program = new Command();
@@ -8,24 +8,18 @@ const program = new Command();
8
8
  program
9
9
  .name('idle-node')
10
10
  .description('IDLE Protocol node — earn from idle compute')
11
- .version('0.1.0');
11
+ .version('0.2.0');
12
12
 
13
13
  program
14
14
  .command('start')
15
15
  .description('Start the IDLE node and begin processing jobs')
16
16
  .requiredOption('--wallet <address>', 'Solana wallet address for payouts')
17
- .option('--types <types>', 'Comma-separated job types to accept (default: fetch,validate)', 'fetch,validate')
17
+ .option('--types <types>', 'Comma-separated job types to accept (default: all)', ALL_TYPES.join(','))
18
18
  .option('--poll-interval <ms>', 'Job poll interval in milliseconds', '5000')
19
19
  .option('--max-concurrent <n>', 'Max concurrent jobs', '3')
20
20
  .option('--supabase-url <url>', 'Supabase project URL', process.env.IDLE_SUPABASE_URL || 'https://vnhzyynewdtfpiynaaqd.supabase.co')
21
21
  .option('--supabase-key <key>', 'Supabase anon key', process.env.IDLE_SUPABASE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZuaHp5eW5ld2R0ZnBpeW5hYXFkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY5NzgyMDksImV4cCI6MjA5MjU1NDIwOX0.6Sw8p9wnfx67NjhzJGmPWialMG4517Eubj9YyfHPaJc')
22
22
  .action(async (opts) => {
23
- console.log(chalk.green('IDLE Node v0.1.0'));
24
- console.log(chalk.gray(`Wallet: ${opts.wallet}`));
25
- console.log(chalk.gray(`Types: ${opts.types}`));
26
- console.log(chalk.gray(`Poll interval: ${opts.pollInterval}ms`));
27
- console.log('');
28
-
29
23
  try {
30
24
  await startNode({
31
25
  wallet: opts.wallet,
@@ -52,4 +46,22 @@ program
52
46
  await checkStatus(opts);
53
47
  });
54
48
 
49
+ program
50
+ .command('types')
51
+ .description('List all supported task types')
52
+ .action(() => {
53
+ console.log(chalk.green.bold('\n IDLE Protocol — Supported Task Types\n'));
54
+ const tiers = {
55
+ 'Tier 1 — Basic Monitoring': ['http_fetch', 'api_health', 'dns_lookup', 'ssl_check', 'response_time', 'json_validate'],
56
+ 'Tier 2 — Web Intelligence': ['html_scrape', 'element_extract', 'link_extract', 'price_extract', 'availability_check', 'content_verify', 'change_detect'],
57
+ };
58
+ for (const [tier, types] of Object.entries(tiers)) {
59
+ console.log(chalk.cyan(` ${tier}`));
60
+ for (const t of types) {
61
+ console.log(chalk.white(` - ${t}`));
62
+ }
63
+ console.log('');
64
+ }
65
+ });
66
+
55
67
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idle-node",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "IDLE Protocol node — turn idle compute into revenue",
5
5
  "main": "./src/node.js",
6
6
  "bin": {
@@ -29,7 +29,12 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@supabase/supabase-js": "^2.45.0",
32
+ "axios": "^1.16.0",
33
+ "chalk": "^5.3.0",
34
+ "cheerio": "^1.2.0",
32
35
  "commander": "^12.1.0",
33
- "chalk": "^5.3.0"
36
+ "puppeteer": "^24.43.0",
37
+ "puppeteer-extra": "^3.3.6",
38
+ "puppeteer-extra-plugin-stealth": "^2.11.2"
34
39
  }
35
40
  }
package/src/node.js CHANGED
@@ -1,39 +1,42 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import chalk from 'chalk';
3
+ import {
4
+ executeHttpFetch,
5
+ executeHtmlScrape,
6
+ executeElementExtract,
7
+ executeLinkExtract,
8
+ executePriceExtract,
9
+ executeAvailabilityCheck,
10
+ executeContentVerify,
11
+ executeDnsLookup,
12
+ executeSslCheck,
13
+ executeResponseTime,
14
+ executeApiHealth,
15
+ executeJsonValidate,
16
+ executeChangeDetect,
17
+ } from './tasks/fast.js';
3
18
 
4
19
  const EXECUTORS = {
5
- fetch: async (payload) => {
6
- const { url, method = 'GET' } = payload;
7
- const start = Date.now();
8
- const res = await fetch(url, { method });
9
- const body = await res.text();
10
- return {
11
- status: res.status,
12
- headers: Object.fromEntries(res.headers.entries()),
13
- body: body.slice(0, 10000),
14
- latency_ms: Date.now() - start,
15
- };
16
- },
17
-
18
- validate: async (payload) => {
19
- const { data, schema } = payload;
20
- // Basic JSON schema validation
21
- const errors = [];
22
- if (schema?.required) {
23
- for (const field of schema.required) {
24
- if (!(field in (data || {}))) errors.push(`missing required field: ${field}`);
25
- }
26
- }
27
- return { valid: errors.length === 0, errors };
28
- },
29
-
30
- query: async (payload) => {
31
- // Natural language query placeholder — real implementation
32
- // would connect to user's configured data source
33
- return { error: 'query execution requires data source configuration' };
34
- },
20
+ http_fetch: executeHttpFetch,
21
+ fetch: executeHttpFetch,
22
+ html_scrape: executeHtmlScrape,
23
+ element_extract: executeElementExtract,
24
+ link_extract: executeLinkExtract,
25
+ price_extract: executePriceExtract,
26
+ availability_check: executeAvailabilityCheck,
27
+ content_verify: executeContentVerify,
28
+ change_detect: executeChangeDetect,
29
+ dns_lookup: executeDnsLookup,
30
+ ssl_check: executeSslCheck,
31
+ response_time: executeResponseTime,
32
+ api_health: executeApiHealth,
33
+ json_validate: executeJsonValidate,
34
+ validate: executeJsonValidate,
35
+ query: async (payload) => ({ error: 'query execution requires data source configuration' }),
35
36
  };
36
37
 
38
+ const ALL_TYPES = Object.keys(EXECUTORS).filter(k => k !== 'fetch' && k !== 'validate');
39
+
37
40
  export async function startNode(config) {
38
41
  if (!config.supabaseUrl || !config.supabaseKey) {
39
42
  throw new Error('Supabase URL and key required. Set IDLE_SUPABASE_URL and IDLE_SUPABASE_KEY env vars, or pass --supabase-url and --supabase-key.');
@@ -44,13 +47,22 @@ export async function startNode(config) {
44
47
  let totalProcessed = 0;
45
48
  let totalEarned = 0;
46
49
 
47
- console.log(chalk.green('Node started. Polling for jobs...'));
50
+ console.log('');
51
+ console.log(chalk.green.bold(' IDLE Protocol Node v0.2.0'));
52
+ console.log(chalk.gray(' The earnings layer for idle compute'));
53
+ console.log('');
54
+ console.log(chalk.white(` Wallet: ${config.wallet}`));
55
+ console.log(chalk.white(` Types: ${config.types.length} task types`));
56
+ console.log(chalk.white(` Poll: ${config.pollInterval}ms`));
57
+ console.log(chalk.white(` Workers: ${config.maxConcurrent} concurrent`));
58
+ console.log('');
59
+ console.log(chalk.cyan(' Waiting for tasks...'));
60
+ console.log('');
48
61
 
49
62
  const poll = async () => {
50
63
  if (running >= config.maxConcurrent) return;
51
64
 
52
65
  try {
53
- // Claim a pending job atomically
54
66
  const { data: jobs, error } = await supabase
55
67
  .rpc('idle_claim_node_job', {
56
68
  p_node_wallet: config.wallet,
@@ -59,7 +71,9 @@ export async function startNode(config) {
59
71
  });
60
72
 
61
73
  if (error) {
62
- console.error(chalk.red('Poll error:'), error.message);
74
+ if (!error.message.includes('does not exist')) {
75
+ console.error(chalk.red(' Poll error:'), error.message);
76
+ }
63
77
  return;
64
78
  }
65
79
 
@@ -71,34 +85,29 @@ export async function startNode(config) {
71
85
  running--;
72
86
  totalProcessed++;
73
87
  totalEarned += earned;
74
- if (totalProcessed % 10 === 0) {
75
- console.log(chalk.cyan(`[stats] ${totalProcessed} jobs | $${totalEarned.toFixed(4)} earned`));
76
- }
88
+ console.log(chalk.green(` Done | Earned: $${earned.toFixed(4)} | Total: $${totalEarned.toFixed(4)} | Jobs: ${totalProcessed}`));
77
89
  }).catch((err) => {
78
90
  running--;
79
- console.error(chalk.red(`Job ${job.id} failed:`), err.message);
91
+ console.error(chalk.red(` Job ${job.id.slice(0, 8)} failed:`), err.message);
80
92
  });
81
93
  }
82
94
  } catch (err) {
83
- console.error(chalk.red('Poll error:'), err.message);
95
+ console.error(chalk.red(' Poll error:'), err.message);
84
96
  }
85
97
  };
86
98
 
87
- // Poll loop
88
99
  const interval = setInterval(poll, config.pollInterval);
89
100
 
90
- // Graceful shutdown
91
101
  const shutdown = () => {
92
- console.log(chalk.yellow('\nShutting down...'));
102
+ console.log(chalk.yellow('\n Shutting down...'));
93
103
  clearInterval(interval);
94
- console.log(chalk.green(`Processed ${totalProcessed} jobs, earned $${totalEarned.toFixed(4)}`));
104
+ console.log(chalk.green(` Processed ${totalProcessed} jobs, earned $${totalEarned.toFixed(4)}`));
95
105
  process.exit(0);
96
106
  };
97
107
 
98
108
  process.on('SIGINT', shutdown);
99
109
  process.on('SIGTERM', shutdown);
100
110
 
101
- // Initial poll
102
111
  await poll();
103
112
  }
104
113
 
@@ -113,7 +122,7 @@ async function processJob(supabase, job, wallet) {
113
122
  }
114
123
 
115
124
  const start = Date.now();
116
- console.log(chalk.gray(`[${job.type}] Processing ${job.id.slice(0, 8)}...`));
125
+ console.log(chalk.gray(` [${job.type}] Processing ${job.id.slice(0, 8)}...`));
117
126
 
118
127
  try {
119
128
  const result = await executor(job.payload);
@@ -131,7 +140,7 @@ async function processJob(supabase, job, wallet) {
131
140
  .eq('id', job.id);
132
141
 
133
142
  const earned = job.node_payout_usd || 0;
134
- console.log(chalk.green(`[${job.type}] ${job.id.slice(0, 8)} done (${duration}ms) +$${earned.toFixed(4)}`));
143
+ console.log(chalk.green(` [${job.type}] ${job.id.slice(0, 8)} done (${duration}ms) +$${earned.toFixed(4)}`));
135
144
  return earned;
136
145
  } catch (err) {
137
146
  await supabase
@@ -146,3 +155,5 @@ async function processJob(supabase, job, wallet) {
146
155
  throw err;
147
156
  }
148
157
  }
158
+
159
+ export { ALL_TYPES };
@@ -0,0 +1,396 @@
1
+ import axios from 'axios';
2
+ import * as cheerio from 'cheerio';
3
+ import dns from 'dns/promises';
4
+ import crypto from 'crypto';
5
+ import https from 'https';
6
+
7
+ const DEFAULT_HEADERS = {
8
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
9
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
10
+ 'Accept-Language': 'en-US,en;q=0.5',
11
+ 'Accept-Encoding': 'gzip, deflate, br',
12
+ 'DNT': '1',
13
+ 'Connection': 'keep-alive',
14
+ };
15
+
16
+ export async function executeHttpFetch(payload) {
17
+ const start = Date.now();
18
+ try {
19
+ const res = await axios({
20
+ url: payload.url,
21
+ method: (payload.method || 'GET'),
22
+ headers: { ...DEFAULT_HEADERS, ...(payload.headers || {}) },
23
+ timeout: 10000,
24
+ maxRedirects: 5,
25
+ validateStatus: () => true,
26
+ });
27
+ return {
28
+ status: res.status,
29
+ body: typeof res.data === 'string' ? res.data.slice(0, 10000) : JSON.stringify(res.data).slice(0, 10000),
30
+ headers: res.headers,
31
+ latency_ms: Date.now() - start,
32
+ success: true,
33
+ };
34
+ } catch (err) {
35
+ return { status: 0, body: err.message, latency_ms: Date.now() - start, success: false };
36
+ }
37
+ }
38
+
39
+ export async function executeHtmlScrape(payload) {
40
+ const start = Date.now();
41
+ try {
42
+ const res = await axios.get(payload.url, {
43
+ headers: DEFAULT_HEADERS,
44
+ timeout: 15000,
45
+ maxRedirects: 5,
46
+ });
47
+ const $ = cheerio.load(res.data);
48
+ $('script, style, noscript, iframe').remove();
49
+
50
+ let result;
51
+ if (payload.selector) {
52
+ result = $(payload.selector).html() || '';
53
+ } else {
54
+ result = $.html();
55
+ }
56
+
57
+ return {
58
+ status: res.status,
59
+ html: result.slice(0, 50000),
60
+ text: $('body').text().replace(/\s+/g, ' ').trim().slice(0, 20000),
61
+ title: $('title').text(),
62
+ latency_ms: Date.now() - start,
63
+ success: true,
64
+ };
65
+ } catch (err) {
66
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
67
+ }
68
+ }
69
+
70
+ export async function executeElementExtract(payload) {
71
+ const start = Date.now();
72
+ try {
73
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
74
+ const $ = cheerio.load(res.data);
75
+ const extracted = {};
76
+
77
+ for (const [key, selector] of Object.entries(payload.selectors)) {
78
+ const elements = $(selector);
79
+ const attr = payload.attribute || 'text';
80
+
81
+ if (attr === 'text') {
82
+ extracted[key] = elements.map((_, el) => $(el).text().trim()).get();
83
+ } else if (attr === 'html') {
84
+ extracted[key] = elements.map((_, el) => $(el).html() || '').get();
85
+ } else {
86
+ extracted[key] = elements.map((_, el) => $(el).attr(attr) || '').get();
87
+ }
88
+
89
+ if (Array.isArray(extracted[key]) && extracted[key].length === 1) {
90
+ extracted[key] = extracted[key][0];
91
+ }
92
+ }
93
+
94
+ return { success: true, extracted, latency_ms: Date.now() - start };
95
+ } catch (err) {
96
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
97
+ }
98
+ }
99
+
100
+ export async function executeLinkExtract(payload) {
101
+ const start = Date.now();
102
+ try {
103
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
104
+ const $ = cheerio.load(res.data);
105
+ const baseUrl = new URL(payload.url);
106
+ const links = [];
107
+
108
+ $('a[href]').each((_, el) => {
109
+ const href = $(el).attr('href') || '';
110
+ const text = $(el).text().trim();
111
+ try {
112
+ const url = new URL(href, payload.url);
113
+ const type = url.hostname === baseUrl.hostname ? 'internal' : 'external';
114
+ if (!payload.filter || payload.filter === 'all' || payload.filter === type) {
115
+ links.push({ href: url.href, text, type });
116
+ }
117
+ } catch {}
118
+ });
119
+
120
+ return {
121
+ success: true,
122
+ links: links.slice(0, 500),
123
+ total: links.length,
124
+ latency_ms: Date.now() - start,
125
+ };
126
+ } catch (err) {
127
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
128
+ }
129
+ }
130
+
131
+ export async function executePriceExtract(payload) {
132
+ const start = Date.now();
133
+ try {
134
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
135
+ const $ = cheerio.load(res.data);
136
+
137
+ const priceSelectors = [
138
+ '[itemprop="price"]',
139
+ '.price', '.product-price', '#price', '.offer-price',
140
+ '[class*="price"]', '[id*="price"]',
141
+ 'span[data-price]', 'meta[property="product:price:amount"]',
142
+ ];
143
+
144
+ let price = null;
145
+ let currency = null;
146
+
147
+ for (const sel of priceSelectors) {
148
+ const el = $(sel).first();
149
+ if (el.length) {
150
+ price = el.attr('content') || el.attr('data-price') || el.text().trim();
151
+ if (price && /[\d]/.test(price)) break;
152
+ }
153
+ }
154
+
155
+ currency = $('meta[property="product:price:currency"]').attr('content') ||
156
+ $('[itemprop="priceCurrency"]').attr('content') || null;
157
+
158
+ $('script[type="application/ld+json"]').each((_, el) => {
159
+ try {
160
+ const data = JSON.parse($(el).html() || '{}');
161
+ if (data.offers?.price && !price) price = String(data.offers.price);
162
+ if (data.offers?.priceCurrency && !currency) currency = data.offers.priceCurrency;
163
+ } catch {}
164
+ });
165
+
166
+ return { success: true, price, currency, raw: price, latency_ms: Date.now() - start };
167
+ } catch (err) {
168
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
169
+ }
170
+ }
171
+
172
+ export async function executeAvailabilityCheck(payload) {
173
+ const start = Date.now();
174
+ try {
175
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
176
+ const $ = cheerio.load(res.data);
177
+ const html = res.data.toLowerCase();
178
+
179
+ let availability = null;
180
+ $('script[type="application/ld+json"]').each((_, el) => {
181
+ try {
182
+ const data = JSON.parse($(el).html() || '{}');
183
+ if (data.offers?.availability) availability = data.offers.availability;
184
+ } catch {}
185
+ });
186
+
187
+ const inStockSignals = ['in stock', 'add to cart', 'buy now', 'add to bag', 'in-stock'];
188
+ const outOfStockSignals = ['out of stock', 'sold out', 'unavailable', 'not available', 'backorder'];
189
+
190
+ let inStock = null;
191
+ if (availability) {
192
+ inStock = availability.toLowerCase().includes('instock');
193
+ } else {
194
+ const isInStock = inStockSignals.some(s => html.includes(s));
195
+ const isOutOfStock = outOfStockSignals.some(s => html.includes(s));
196
+ if (isInStock && !isOutOfStock) inStock = true;
197
+ else if (isOutOfStock) inStock = false;
198
+ }
199
+
200
+ return { success: true, in_stock: inStock, availability_raw: availability, latency_ms: Date.now() - start };
201
+ } catch (err) {
202
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
203
+ }
204
+ }
205
+
206
+ export async function executeContentVerify(payload) {
207
+ const start = Date.now();
208
+ try {
209
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
210
+ const $ = cheerio.load(res.data);
211
+ const bodyText = $('body').text();
212
+
213
+ let found = false;
214
+ let matchedText = null;
215
+
216
+ if (payload.text) {
217
+ found = bodyText.toLowerCase().includes(payload.text.toLowerCase());
218
+ if (found) matchedText = payload.text;
219
+ }
220
+
221
+ if (payload.pattern) {
222
+ const regex = new RegExp(payload.pattern, 'i');
223
+ const match = bodyText.match(regex);
224
+ if (match) {
225
+ found = true;
226
+ matchedText = match[0];
227
+ }
228
+ }
229
+
230
+ if (payload.selector) {
231
+ const el = $(payload.selector);
232
+ found = el.length > 0;
233
+ matchedText = el.first().text().trim();
234
+ }
235
+
236
+ return { success: true, found, matched_text: matchedText, status: res.status, latency_ms: Date.now() - start };
237
+ } catch (err) {
238
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
239
+ }
240
+ }
241
+
242
+ export async function executeDnsLookup(payload) {
243
+ const start = Date.now();
244
+ try {
245
+ const type = payload.type || 'A';
246
+ let records;
247
+
248
+ if (type === 'A') records = await dns.resolve4(payload.hostname);
249
+ else if (type === 'AAAA') records = await dns.resolve6(payload.hostname);
250
+ else if (type === 'MX') records = await dns.resolveMx(payload.hostname);
251
+ else if (type === 'TXT') records = await dns.resolveTxt(payload.hostname);
252
+ else if (type === 'NS') records = await dns.resolveNs(payload.hostname);
253
+ else records = await dns.resolve(payload.hostname);
254
+
255
+ return { success: true, hostname: payload.hostname, type, records, latency_ms: Date.now() - start };
256
+ } catch (err) {
257
+ return { success: false, hostname: payload.hostname, error: err.message, latency_ms: Date.now() - start };
258
+ }
259
+ }
260
+
261
+ export async function executeSslCheck(payload) {
262
+ const start = Date.now();
263
+ return new Promise(resolve => {
264
+ const req = https.request({ hostname: payload.hostname, port: 443, method: 'HEAD', rejectUnauthorized: false }, (res) => {
265
+ const cert = res.socket.getPeerCertificate();
266
+ const now = new Date();
267
+ const expiresAt = new Date(cert.valid_to);
268
+ const daysUntilExpiry = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
269
+
270
+ resolve({
271
+ success: true,
272
+ hostname: payload.hostname,
273
+ valid: res.socket.authorized,
274
+ issuer: cert.issuer?.CN || cert.issuer?.O,
275
+ subject: cert.subject?.CN,
276
+ expires_at: cert.valid_to,
277
+ days_until_expiry: daysUntilExpiry,
278
+ expired: daysUntilExpiry < 0,
279
+ latency_ms: Date.now() - start,
280
+ });
281
+ req.destroy();
282
+ });
283
+ req.on('error', (err) => {
284
+ resolve({ success: false, hostname: payload.hostname, error: err.message, latency_ms: Date.now() - start });
285
+ });
286
+ req.setTimeout(10000, () => {
287
+ req.destroy();
288
+ resolve({ success: false, hostname: payload.hostname, error: 'timeout', latency_ms: Date.now() - start });
289
+ });
290
+ req.end();
291
+ });
292
+ }
293
+
294
+ export async function executeResponseTime(payload) {
295
+ const iterations = Math.min(payload.iterations || 3, 5);
296
+ const times = [];
297
+ let lastStatus = 0;
298
+
299
+ for (let i = 0; i < iterations; i++) {
300
+ const start = Date.now();
301
+ try {
302
+ const res = await axios.get(payload.url, {
303
+ headers: DEFAULT_HEADERS,
304
+ timeout: 10000,
305
+ validateStatus: () => true,
306
+ });
307
+ lastStatus = res.status;
308
+ times.push(Date.now() - start);
309
+ } catch {
310
+ times.push(10000);
311
+ }
312
+ if (i < iterations - 1) await new Promise(r => setTimeout(r, 200));
313
+ }
314
+
315
+ const sorted = [...times].sort((a, b) => a - b);
316
+ return {
317
+ success: true,
318
+ url: payload.url,
319
+ status: lastStatus,
320
+ p50_ms: sorted[Math.floor(sorted.length / 2)],
321
+ p95_ms: sorted[Math.floor(sorted.length * 0.95)],
322
+ min_ms: Math.min(...times),
323
+ max_ms: Math.max(...times),
324
+ avg_ms: Math.round(times.reduce((a, b) => a + b, 0) / times.length),
325
+ iterations,
326
+ };
327
+ }
328
+
329
+ export async function executeApiHealth(payload) {
330
+ const start = Date.now();
331
+ try {
332
+ const res = await axios({
333
+ url: payload.url,
334
+ method: (payload.method || 'GET'),
335
+ headers: DEFAULT_HEADERS,
336
+ timeout: 10000,
337
+ validateStatus: () => true,
338
+ });
339
+
340
+ const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
341
+ const statusOk = !payload.expected_status || res.status === payload.expected_status;
342
+ const bodyOk = !payload.expected_body || body.includes(payload.expected_body);
343
+
344
+ return {
345
+ success: true,
346
+ healthy: statusOk && bodyOk,
347
+ status: res.status,
348
+ status_ok: statusOk,
349
+ body_ok: bodyOk,
350
+ latency_ms: Date.now() - start,
351
+ body_preview: body.slice(0, 500),
352
+ };
353
+ } catch (err) {
354
+ return { success: false, healthy: false, error: err.message, latency_ms: Date.now() - start };
355
+ }
356
+ }
357
+
358
+ export async function executeJsonValidate(payload) {
359
+ const errors = [];
360
+ for (const [key, expectedType] of Object.entries(payload.schema)) {
361
+ const val = payload.data?.[key];
362
+ if (val === undefined || val === null) {
363
+ errors.push(`Missing field: ${key}`);
364
+ } else if (typeof val !== expectedType) {
365
+ errors.push(`${key}: expected ${expectedType}, got ${typeof val}`);
366
+ }
367
+ }
368
+ return { valid: errors.length === 0, errors, field_count: Object.keys(payload.schema).length };
369
+ }
370
+
371
+ export async function executeChangeDetect(payload) {
372
+ const start = Date.now();
373
+ try {
374
+ const res = await axios.get(payload.url, { headers: DEFAULT_HEADERS, timeout: 15000 });
375
+ const $ = cheerio.load(res.data);
376
+ $('script, style, noscript').remove();
377
+
378
+ const content = payload.selector
379
+ ? $(payload.selector).text().replace(/\s+/g, ' ').trim()
380
+ : $('body').text().replace(/\s+/g, ' ').trim();
381
+
382
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
383
+ const changed = payload.previous_hash ? hash !== payload.previous_hash : null;
384
+
385
+ return {
386
+ success: true,
387
+ hash,
388
+ changed,
389
+ content_length: content.length,
390
+ content_preview: content.slice(0, 200),
391
+ latency_ms: Date.now() - start,
392
+ };
393
+ } catch (err) {
394
+ return { success: false, error: err.message, latency_ms: Date.now() - start };
395
+ }
396
+ }