idle-node 0.1.0 → 0.2.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/bin/idle-node.js +21 -9
- package/package.json +7 -2
- package/src/node.js +56 -45
- package/src/tasks/fast.js +396 -0
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.
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
-
"
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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('\
|
|
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
|
+
}
|