Files
NetMesh/scripts/ai-brave-search.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

277 lines
7.9 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const http = require('node:http');
const https = require('node:https');
const { promises: dnsPromises } = require('node:dns');
const { URL } = require('node:url');
const { isIP } = require('node:net');
const BRAVE_SEARCH_URL = 'https://api.search.brave.com/res/v1/web/search';
const USER_AGENT = 'netcatty-ai-automation/1.0';
const MAX_FETCH_BYTES = 200_000;
const FETCH_TIMEOUT_MS = 10_000;
const SEARCH_TIMEOUT_MS = 12_000;
function readApiKey() {
const file = String(process.env.BRAVE_API_KEY_FILE || '').trim();
if (file) {
const value = fs.readFileSync(file, 'utf8').replace(/[\r\n]+$/g, '');
if (!value) throw new Error('BRAVE_API_KEY_FILE is empty.');
return value;
}
const env = String(process.env.BRAVE_API_KEY || '').trim();
if (!env) throw new Error('BRAVE_API_KEY is not configured.');
return env;
}
function appendToolLog(event) {
const logPath = String(process.env.BRAVE_TOOL_LOG || '').trim();
if (!logPath) return;
fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`);
}
function requestJson(url, { headers = {}, timeoutMs = SEARCH_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const req = lib.request(parsed, {
method: 'GET',
headers,
timeout: timeoutMs,
}, (res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_FETCH_BYTES) {
req.destroy(new Error('Response exceeded byte limit.'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 300)}`));
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('Brave Search returned non-JSON.'));
}
});
});
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
req.on('error', reject);
req.end();
});
}
async function requestText(url, { timeoutMs = FETCH_TIMEOUT_MS, redirects = 0 } = {}) {
if (redirects > 2) {
throw new Error('Too many redirects.');
}
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL.');
}
if (parsed.protocol !== 'https:') {
throw new Error('Only https URLs can be fetched.');
}
if (isPrivateHostname(parsed.hostname)) {
throw new Error('Refusing to fetch a private or local address.');
}
let addresses;
try {
addresses = await dnsPromises.lookup(parsed.hostname, { all: true, verbatim: true });
} catch {
throw new Error('Could not resolve fetch host.');
}
if (
!addresses.length
|| addresses.some((entry) => isPrivateHostname(entry.address))
) {
throw new Error('Refusing to fetch a private or local address.');
}
return new Promise((resolve, reject) => {
const req = https.request(parsed, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9',
},
timeout: timeoutMs,
}, (res) => {
const location = res.headers.location;
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
const next = new URL(location, parsed).toString();
resolve(requestText(next, { timeoutMs, redirects: redirects + 1 }));
res.resume();
return;
}
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_FETCH_BYTES) {
req.destroy(new Error('Response exceeded byte limit.'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
resolve(body);
});
});
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
req.on('error', reject);
req.end();
});
}
function isPrivateHostname(hostname) {
let host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
if (host.startsWith('::ffff:')) host = host.slice(7);
if (
host === 'localhost'
|| host.endsWith('.localhost')
|| host.endsWith('.local')
|| host === '0.0.0.0'
|| host === '::1'
) {
return true;
}
const ipVersion = isIP(host);
if (!ipVersion) return false;
if (ipVersion === 4) {
const [a, b] = host.split('.').map((part) => Number(part));
return (
a === 10
|| a === 127
|| a === 0
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
);
}
return (
host === '::1'
|| host.startsWith('fc')
|| host.startsWith('fd')
|| host.startsWith('fe80')
);
}
function extractUrlsFromSearch(data) {
const urls = [];
const results = [];
const web = Array.isArray(data?.web?.results) ? data.web.results : [];
for (const item of web.slice(0, 8)) {
const url = String(item?.url || '').trim();
if (!url.startsWith('https://')) continue;
urls.push(url);
results.push({
title: String(item.title || '').slice(0, 200),
url,
description: String(item.description || '').slice(0, 500),
});
}
return { urls, results };
}
function stripHtml(value) {
return String(value || '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8_000);
}
async function runSearch(query) {
const q = String(query || '').trim();
if (!q) throw new Error('Search query is required.');
if (q.length > 400) throw new Error('Search query is too long.');
const url = new URL(BRAVE_SEARCH_URL);
url.searchParams.set('q', q);
url.searchParams.set('count', '8');
url.searchParams.set('text_decorations', 'false');
url.searchParams.set('search_lang', 'en');
const data = await requestJson(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'identity',
'X-Subscription-Token': readApiKey(),
'User-Agent': USER_AGENT,
},
});
const extracted = extractUrlsFromSearch(data);
const event = {
ok: true,
action: 'search',
query: q,
urls: extracted.urls,
results: extracted.results,
};
appendToolLog(event);
return event;
}
async function runFetch(target) {
const url = String(target || '').trim();
if (!url.startsWith('https://')) throw new Error('Fetch URL must be https.');
const body = stripHtml(await requestText(url));
const event = {
ok: true,
action: 'fetch',
query: url,
urls: [url],
results: [{ title: url, url, description: body }],
};
appendToolLog(event);
return event;
}
async function main(argv) {
const action = String(argv[2] || '').toLowerCase();
const input = argv.slice(3).join(' ').trim();
if (action !== 'search' && action !== 'fetch') {
throw new Error('Usage: ai-brave-search.cjs <search|fetch> <query-or-url>');
}
const event = action === 'search' ? await runSearch(input) : await runFetch(input);
process.stdout.write(`${JSON.stringify(event, null, 2)}\n`);
}
module.exports = {
extractUrlsFromSearch,
isPrivateHostname,
stripHtml,
runSearch,
runFetch,
};
if (require.main === module) {
main(process.argv).catch((error) => {
const failed = {
ok: false,
action: String(process.argv[2] || ''),
query: process.argv.slice(3).join(' '),
error: error.message,
};
try { appendToolLog(failed); } catch { /* ignore log failure */ }
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}