sxng-cli 1.0.4 → 1.0.6
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 +120 -4
- package/dist/commands/build-graph.d.ts +8 -0
- package/dist/commands/build-graph.d.ts.map +1 -0
- package/dist/commands/build-graph.js +70 -0
- package/dist/commands/build-graph.js.map +1 -0
- package/dist/commands/extract.d.ts +11 -0
- package/dist/commands/extract.d.ts.map +1 -0
- package/dist/commands/extract.js +84 -0
- package/dist/commands/extract.js.map +1 -0
- package/dist/commands/graph-add.d.ts +13 -0
- package/dist/commands/graph-add.d.ts.map +1 -0
- package/dist/commands/graph-add.js +148 -0
- package/dist/commands/graph-add.js.map +1 -0
- package/dist/commands/query-graph.d.ts +11 -0
- package/dist/commands/query-graph.d.ts.map +1 -0
- package/dist/commands/query-graph.js +146 -0
- package/dist/commands/query-graph.js.map +1 -0
- package/dist/commands/raw.d.ts +14 -0
- package/dist/commands/raw.d.ts.map +1 -0
- package/dist/commands/raw.js +125 -0
- package/dist/commands/raw.js.map +1 -0
- package/dist/commands/session.d.ts +28 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +144 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/deep/dedupe.d.ts +14 -0
- package/dist/deep/dedupe.d.ts.map +1 -0
- package/dist/deep/dedupe.js +53 -0
- package/dist/deep/dedupe.js.map +1 -0
- package/dist/deep/extractor.d.ts +29 -0
- package/dist/deep/extractor.d.ts.map +1 -0
- package/dist/deep/extractor.js +125 -0
- package/dist/deep/extractor.js.map +1 -0
- package/dist/deep/graph.d.ts +55 -0
- package/dist/deep/graph.d.ts.map +1 -0
- package/dist/deep/graph.js +153 -0
- package/dist/deep/graph.js.map +1 -0
- package/dist/deep/index.d.ts +10 -0
- package/dist/deep/index.d.ts.map +1 -0
- package/dist/deep/index.js +10 -0
- package/dist/deep/index.js.map +1 -0
- package/dist/deep/keywords.d.ts +27 -0
- package/dist/deep/keywords.d.ts.map +1 -0
- package/dist/deep/keywords.js +293 -0
- package/dist/deep/keywords.js.map +1 -0
- package/dist/deep/rrf.d.ts +13 -0
- package/dist/deep/rrf.d.ts.map +1 -0
- package/dist/deep/rrf.js +23 -0
- package/dist/deep/rrf.js.map +1 -0
- package/dist/deep/session.d.ts +65 -0
- package/dist/deep/session.d.ts.map +1 -0
- package/dist/deep/session.js +197 -0
- package/dist/deep/session.js.map +1 -0
- package/dist/deep/simhash.d.ts +12 -0
- package/dist/deep/simhash.d.ts.map +1 -0
- package/dist/deep/simhash.js +56 -0
- package/dist/deep/simhash.js.map +1 -0
- package/dist/runCli.d.ts.map +1 -1
- package/dist/runCli.js +403 -135
- package/dist/runCli.js.map +1 -1
- package/package.json +6 -2
- package/sxng.config.example.json +1 -1
package/dist/runCli.js
CHANGED
|
@@ -4,38 +4,65 @@
|
|
|
4
4
|
import { createSuccessEnvelope, createErrorEnvelope } from './protocol.js';
|
|
5
5
|
import { config } from './config.js';
|
|
6
6
|
import { initConfig } from './init.js';
|
|
7
|
+
import { runExtract } from './commands/extract.js';
|
|
8
|
+
import { ContentExtractor } from './deep/extractor.js';
|
|
9
|
+
import { rrf } from './deep/rrf.js';
|
|
10
|
+
import { normalizeUrl } from './deep/dedupe.js';
|
|
11
|
+
import { deserializeGraph, serializeGraph, graphStats, resultId } from './deep/graph.js';
|
|
12
|
+
import { initSessionDir, resolveSessionPath, appendSessionResults, updateSessionGraph, loadSessionResults } from './deep/session.js';
|
|
13
|
+
import { runSessionList, runSessionDelete } from './commands/session.js';
|
|
14
|
+
import { DirectedGraph } from 'graphology';
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
7
16
|
const HELP_TEXT = `SearXNG CLI - Web Search Tool
|
|
8
17
|
|
|
9
18
|
Usage:
|
|
10
|
-
sxng <query> [options]
|
|
11
|
-
sxng
|
|
12
|
-
sxng --
|
|
13
|
-
sxng --
|
|
14
|
-
sxng --
|
|
19
|
+
sxng <query> [options] Single search
|
|
20
|
+
sxng --queries "q1,q2,q3" Multi-query search (RRF fusion)
|
|
21
|
+
sxng --session new <query> Create new session and search (auto-named)
|
|
22
|
+
sxng --session <dir> <query> Multi-round session with result accumulation
|
|
23
|
+
sxng extract --urls/--from-json Extract article content
|
|
24
|
+
sxng extract --session <dir> Extract content from session and merge back
|
|
25
|
+
sxng query-graph <path> --seeds Query subgraph via BFS
|
|
26
|
+
sxng graph-add <path> --data Add entities/edges to knowledge graph
|
|
27
|
+
sxng session-list List all sessions
|
|
28
|
+
sxng session-delete <names> Delete sessions by name
|
|
29
|
+
sxng session-delete --older <h> Delete sessions older than N hours
|
|
30
|
+
sxng init Interactive configuration setup
|
|
31
|
+
sxng --health Check SearXNG server health
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
init Interactive configuration setup
|
|
18
|
-
<query> Perform a web search with the given query
|
|
19
|
-
|
|
20
|
-
Options:
|
|
33
|
+
Search Options:
|
|
21
34
|
-e, --engines <engines> Comma-separated list of search engines
|
|
22
35
|
-c, --categories <cats> Comma-separated list of categories
|
|
23
36
|
-l, --limit <n> Maximum number of results (default: ${config.defaultLimit})
|
|
24
37
|
-p, --page <n> Page number for pagination
|
|
25
38
|
--lang <code> Language code (e.g., en, zh, ja)
|
|
26
39
|
--time <range> Time range: day, week, month, year, all
|
|
27
|
-
-f, --format <fmt> Output format: md
|
|
28
|
-
--
|
|
29
|
-
--
|
|
30
|
-
--
|
|
31
|
-
|
|
40
|
+
-f, --format <fmt> Output format: md (default), json
|
|
41
|
+
--queries <q1,q2,q3> Multi-query with RRF fusion
|
|
42
|
+
--merge <file> Merge new results with previous search JSON
|
|
43
|
+
--session <dir|new> Session dir, or "new" to auto-create
|
|
44
|
+
--owner <name> Session owner (stored in meta.json)
|
|
45
|
+
--desc <text> Session description (stored in meta.json)
|
|
46
|
+
--graph <file> Save search result metadata to knowledge graph file
|
|
47
|
+
|
|
48
|
+
Extract Options:
|
|
49
|
+
--urls <url1,url2> URLs to extract content from
|
|
50
|
+
--from-json <file> Extract from search results JSON file
|
|
51
|
+
--session <dir> Extract from session results and merge content back
|
|
52
|
+
|
|
53
|
+
Graph Options:
|
|
54
|
+
--seeds <s1,s2> Seed nodes for query-graph BFS
|
|
55
|
+
--depth <n> BFS depth for query-graph (default: 2)
|
|
56
|
+
--data <json> JSON with entities/edges for graph-add
|
|
32
57
|
|
|
33
58
|
Examples:
|
|
34
|
-
sxng init
|
|
35
59
|
sxng "TypeScript tutorial"
|
|
36
|
-
sxng --
|
|
37
|
-
sxng --
|
|
38
|
-
sxng
|
|
60
|
+
sxng --session new "rust async" --owner "agent-1" --desc "async ecosystem research"
|
|
61
|
+
sxng --session /tmp/s "rust async"
|
|
62
|
+
sxng extract --session /tmp/s
|
|
63
|
+
sxng session-list
|
|
64
|
+
sxng session-delete ds_1745000000_abcd123
|
|
65
|
+
sxng session-delete --older 24
|
|
39
66
|
|
|
40
67
|
Environment Variables:
|
|
41
68
|
SEARXNG_BASE_URL SearXNG server URL
|
|
@@ -49,6 +76,11 @@ Environment Variables:
|
|
|
49
76
|
function parseArgs(args) {
|
|
50
77
|
const options = {
|
|
51
78
|
init: false,
|
|
79
|
+
extract: false,
|
|
80
|
+
queryGraph: false,
|
|
81
|
+
graphAdd: false,
|
|
82
|
+
sessionList: false,
|
|
83
|
+
sessionDelete: false,
|
|
52
84
|
enginesList: false,
|
|
53
85
|
categoriesList: false,
|
|
54
86
|
health: false,
|
|
@@ -65,6 +97,21 @@ function parseArgs(args) {
|
|
|
65
97
|
case 'init':
|
|
66
98
|
options.init = true;
|
|
67
99
|
break;
|
|
100
|
+
case 'extract':
|
|
101
|
+
options.extract = true;
|
|
102
|
+
break;
|
|
103
|
+
case 'query-graph':
|
|
104
|
+
options.queryGraph = true;
|
|
105
|
+
break;
|
|
106
|
+
case 'graph-add':
|
|
107
|
+
options.graphAdd = true;
|
|
108
|
+
break;
|
|
109
|
+
case 'session-list':
|
|
110
|
+
options.sessionList = true;
|
|
111
|
+
break;
|
|
112
|
+
case 'session-delete':
|
|
113
|
+
options.sessionDelete = true;
|
|
114
|
+
break;
|
|
68
115
|
case '-e':
|
|
69
116
|
case '--engines':
|
|
70
117
|
options.engines = args[++i]?.split(',').map(e => e.trim()).filter(Boolean);
|
|
@@ -93,10 +140,46 @@ function parseArgs(args) {
|
|
|
93
140
|
case '-f':
|
|
94
141
|
case '--format':
|
|
95
142
|
const fmt = args[++i];
|
|
96
|
-
if (
|
|
143
|
+
if (fmt === 'json' || fmt === 'md' || fmt === 'markdown') {
|
|
97
144
|
options.format = fmt === 'markdown' ? 'md' : fmt;
|
|
98
145
|
}
|
|
99
146
|
break;
|
|
147
|
+
case '--queries':
|
|
148
|
+
options.queries = args[++i]?.split(',').map(q => q.trim()).filter(Boolean);
|
|
149
|
+
break;
|
|
150
|
+
case '--urls':
|
|
151
|
+
options.urls = args[++i]?.split(',').map(u => u.trim()).filter(Boolean);
|
|
152
|
+
break;
|
|
153
|
+
case '--from-json':
|
|
154
|
+
options.fromJson = args[++i];
|
|
155
|
+
break;
|
|
156
|
+
case '--merge':
|
|
157
|
+
options.merge = args[++i];
|
|
158
|
+
break;
|
|
159
|
+
case '--graph':
|
|
160
|
+
options.graph = args[++i];
|
|
161
|
+
break;
|
|
162
|
+
case '--session':
|
|
163
|
+
options.session = args[++i];
|
|
164
|
+
break;
|
|
165
|
+
case '--seeds':
|
|
166
|
+
options.seeds = args[++i]?.split(',').map(s => s.trim()).filter(Boolean);
|
|
167
|
+
break;
|
|
168
|
+
case '--depth':
|
|
169
|
+
options.depth = parseInt(args[++i], 10);
|
|
170
|
+
break;
|
|
171
|
+
case '--data':
|
|
172
|
+
options.data = args[++i];
|
|
173
|
+
break;
|
|
174
|
+
case '--owner':
|
|
175
|
+
options.owner = args[++i];
|
|
176
|
+
break;
|
|
177
|
+
case '--desc':
|
|
178
|
+
options.desc = args[++i];
|
|
179
|
+
break;
|
|
180
|
+
case '--older':
|
|
181
|
+
options.older = parseFloat(args[++i]);
|
|
182
|
+
break;
|
|
100
183
|
case '--engines-list':
|
|
101
184
|
options.enginesList = true;
|
|
102
185
|
break;
|
|
@@ -118,39 +201,6 @@ function parseArgs(args) {
|
|
|
118
201
|
}
|
|
119
202
|
return options;
|
|
120
203
|
}
|
|
121
|
-
function formatAsCsv(results) {
|
|
122
|
-
if (results.length === 0)
|
|
123
|
-
return '';
|
|
124
|
-
const headers = ['title', 'url', 'content', 'engine', 'category'];
|
|
125
|
-
const lines = [headers.join(',')];
|
|
126
|
-
for (const r of results) {
|
|
127
|
-
const row = headers.map(h => {
|
|
128
|
-
const val = String(r[h] || '').replace(/"/g, '""');
|
|
129
|
-
return `"${val}"`;
|
|
130
|
-
});
|
|
131
|
-
lines.push(row.join(','));
|
|
132
|
-
}
|
|
133
|
-
return lines.join('\n');
|
|
134
|
-
}
|
|
135
|
-
function formatAsHtml(results) {
|
|
136
|
-
const rows = results.map(r => `
|
|
137
|
-
<tr>
|
|
138
|
-
<td><a href="${r.url}">${r.title}</a></td>
|
|
139
|
-
<td>${r.content}</td>
|
|
140
|
-
<td>${r.engine}</td>
|
|
141
|
-
</tr>
|
|
142
|
-
`).join('');
|
|
143
|
-
return `<!DOCTYPE html>
|
|
144
|
-
<html>
|
|
145
|
-
<head><title>Search Results</title></head>
|
|
146
|
-
<body>
|
|
147
|
-
<table border="1">
|
|
148
|
-
<tr><th>Title</th><th>Content</th><th>Engine</th></tr>
|
|
149
|
-
${rows}
|
|
150
|
-
</table>
|
|
151
|
-
</body>
|
|
152
|
-
</html>`;
|
|
153
|
-
}
|
|
154
204
|
function formatAsMarkdown(data) {
|
|
155
205
|
const lines = [];
|
|
156
206
|
// Query info
|
|
@@ -160,31 +210,27 @@ function formatAsMarkdown(data) {
|
|
|
160
210
|
const results = data.results || [];
|
|
161
211
|
if (results.length > 0) {
|
|
162
212
|
lines.push(`**${results.length}** results`);
|
|
163
|
-
if (data.totalResults) {
|
|
164
|
-
lines.push(`Total: ${data.totalResults}`);
|
|
165
|
-
}
|
|
166
213
|
lines.push('');
|
|
167
214
|
for (let i = 0; i < results.length; i++) {
|
|
168
215
|
const r = results[i];
|
|
169
|
-
|
|
216
|
+
let scoreIndicator = '';
|
|
217
|
+
if (r.score !== undefined && r.score !== null) {
|
|
218
|
+
const normalizedScore = r.score > 1 ? r.score : r.score * 100;
|
|
219
|
+
scoreIndicator = ` [${normalizedScore.toFixed(1)}]`;
|
|
220
|
+
}
|
|
221
|
+
lines.push(`### ${i + 1}. [${r.title || 'No Title'}](${r.url || '#'})${scoreIndicator}`);
|
|
170
222
|
lines.push('');
|
|
171
223
|
if (r.content) {
|
|
172
224
|
lines.push(r.content);
|
|
173
225
|
lines.push('');
|
|
174
226
|
}
|
|
175
|
-
// Metadata line: engine, category, score, publishedDate
|
|
176
227
|
const meta = [];
|
|
177
228
|
meta.push(`Engine: ${r.engine || 'unknown'}`);
|
|
178
229
|
if (r.category)
|
|
179
230
|
meta.push(`Category: ${r.category}`);
|
|
180
|
-
if (r.score !== undefined && r.score !== null)
|
|
181
|
-
meta.push(`Score: ${r.score}`);
|
|
182
231
|
if (r.publishedDate)
|
|
183
|
-
meta.push(`
|
|
184
|
-
lines.push(meta.join('
|
|
185
|
-
if (r.thumbnail) {
|
|
186
|
-
lines.push(`Thumbnail: ${r.thumbnail}`);
|
|
187
|
-
}
|
|
232
|
+
meta.push(`Published: ${r.publishedDate}`);
|
|
233
|
+
lines.push(meta.join(' · '));
|
|
188
234
|
lines.push('');
|
|
189
235
|
}
|
|
190
236
|
}
|
|
@@ -194,54 +240,64 @@ function formatAsMarkdown(data) {
|
|
|
194
240
|
}
|
|
195
241
|
// Unresponsive engines
|
|
196
242
|
if (data.unresponsiveEngines && data.unresponsiveEngines.length > 0) {
|
|
197
|
-
|
|
243
|
+
const unresponsive = data.unresponsiveEngines.map((item) => Array.isArray(item) ? item[0] : item).join(', ');
|
|
244
|
+
lines.push(`*Unresponsive: ${unresponsive}*`);
|
|
198
245
|
lines.push('');
|
|
199
|
-
|
|
246
|
+
}
|
|
247
|
+
// Suggestions
|
|
248
|
+
if (data.suggestions && data.suggestions.length > 0) {
|
|
249
|
+
lines.push('**Suggestions:** ' + data.suggestions.join(' · '));
|
|
200
250
|
lines.push('');
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
251
|
+
}
|
|
252
|
+
return lines.join('\n');
|
|
253
|
+
}
|
|
254
|
+
function formatOutput(data, format) {
|
|
255
|
+
if (format === 'md')
|
|
256
|
+
return formatAsMarkdown(data);
|
|
257
|
+
return JSON.stringify(data, null, 2);
|
|
258
|
+
}
|
|
259
|
+
function addToGraph(graphFile, results) {
|
|
260
|
+
// Load existing graph or create new one
|
|
261
|
+
let graph;
|
|
262
|
+
if (existsSync(graphFile)) {
|
|
263
|
+
try {
|
|
264
|
+
const raw = readFileSync(graphFile, 'utf-8');
|
|
265
|
+
const parsed = JSON.parse(raw);
|
|
266
|
+
const graphData = parsed.status === 'ok' && parsed.data?.graph
|
|
267
|
+
? parsed.data.graph
|
|
268
|
+
: (parsed.nodes && parsed.edges ? parsed : null);
|
|
269
|
+
if (graphData) {
|
|
270
|
+
graph = deserializeGraph(graphData);
|
|
204
271
|
}
|
|
205
272
|
else {
|
|
206
|
-
|
|
273
|
+
graph = new DirectedGraph();
|
|
207
274
|
}
|
|
208
275
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// Answers (if any)
|
|
212
|
-
if (data.answers && data.answers.length > 0) {
|
|
213
|
-
lines.push('---');
|
|
214
|
-
lines.push('');
|
|
215
|
-
lines.push('### Answers');
|
|
216
|
-
lines.push('');
|
|
217
|
-
for (const answer of data.answers) {
|
|
218
|
-
lines.push(answer);
|
|
219
|
-
lines.push('');
|
|
276
|
+
catch {
|
|
277
|
+
graph = new DirectedGraph();
|
|
220
278
|
}
|
|
221
279
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
lines.push('---');
|
|
225
|
-
lines.push('');
|
|
226
|
-
lines.push('### Suggestions');
|
|
227
|
-
lines.push('');
|
|
228
|
-
lines.push(data.suggestions.map((s) => `- ${s}`).join('\n'));
|
|
229
|
-
lines.push('');
|
|
280
|
+
else {
|
|
281
|
+
graph = new DirectedGraph();
|
|
230
282
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
283
|
+
// Save result metadata nodes (title, url, rank) into the graph
|
|
284
|
+
// Entity nodes and relationships are added by Agent via graph-add
|
|
285
|
+
for (let i = 0; i < results.length; i++) {
|
|
286
|
+
const r = results[i];
|
|
287
|
+
const id = resultId(r.url);
|
|
288
|
+
if (!graph.hasNode(id)) {
|
|
289
|
+
graph.mergeNode(id, {
|
|
290
|
+
type: 'result',
|
|
291
|
+
label: r.title,
|
|
292
|
+
url: r.url,
|
|
293
|
+
title: r.title,
|
|
294
|
+
rank: i + 1,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
244
297
|
}
|
|
298
|
+
const serialized = serializeGraph(graph);
|
|
299
|
+
const stats = graphStats(graph);
|
|
300
|
+
writeFileSync(graphFile, JSON.stringify({ status: 'ok', data: { graph: serialized, stats } }, null, 2), 'utf-8');
|
|
245
301
|
}
|
|
246
302
|
export async function runCli(args, service) {
|
|
247
303
|
const options = parseArgs(args);
|
|
@@ -252,6 +308,15 @@ export async function runCli(args, service) {
|
|
|
252
308
|
if (options.init) {
|
|
253
309
|
return await initConfig();
|
|
254
310
|
}
|
|
311
|
+
if (options.extract) {
|
|
312
|
+
const extractor = new ContentExtractor();
|
|
313
|
+
const extractOptions = {
|
|
314
|
+
urls: options.urls,
|
|
315
|
+
fromJson: options.fromJson,
|
|
316
|
+
session: options.session,
|
|
317
|
+
};
|
|
318
|
+
return await runExtract(extractor, extractOptions);
|
|
319
|
+
}
|
|
255
320
|
if (options.health) {
|
|
256
321
|
const health = await service.healthCheck();
|
|
257
322
|
const envelope = health.status === 'healthy'
|
|
@@ -302,8 +367,32 @@ export async function runCli(args, service) {
|
|
|
302
367
|
return 1;
|
|
303
368
|
}
|
|
304
369
|
}
|
|
305
|
-
if (
|
|
306
|
-
const
|
|
370
|
+
if (options.queryGraph) {
|
|
371
|
+
const { runQueryGraph } = await import('./commands/query-graph.js');
|
|
372
|
+
const graphFormat = options.format === 'json' ? 'json' : 'md';
|
|
373
|
+
return await runQueryGraph({
|
|
374
|
+
graphFile: options.query || options.fromJson || '',
|
|
375
|
+
seeds: options.seeds || [],
|
|
376
|
+
depth: options.depth ?? 2,
|
|
377
|
+
format: graphFormat,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (options.graphAdd) {
|
|
381
|
+
const { runGraphAdd } = await import('./commands/graph-add.js');
|
|
382
|
+
return await runGraphAdd({
|
|
383
|
+
graphFile: options.query || options.fromJson || '',
|
|
384
|
+
data: options.data || '',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (options.sessionList) {
|
|
388
|
+
return await runSessionList();
|
|
389
|
+
}
|
|
390
|
+
if (options.sessionDelete) {
|
|
391
|
+
const names = (options.query || '').split(',').map(n => n.trim()).filter(Boolean);
|
|
392
|
+
return await runSessionDelete(names, options.older);
|
|
393
|
+
}
|
|
394
|
+
if (!options.query && !options.queries) {
|
|
395
|
+
const envelope = createErrorEnvelope('MISSING_QUERY', 'No search query provided', { hint: 'Use: sxng "your search query" or sxng --queries "q1,q2,q3"' });
|
|
307
396
|
console.log(JSON.stringify(envelope, null, 2));
|
|
308
397
|
return 1;
|
|
309
398
|
}
|
|
@@ -315,45 +404,224 @@ export async function runCli(args, service) {
|
|
|
315
404
|
return 1;
|
|
316
405
|
}
|
|
317
406
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
format: options.format || config.defaultFormat
|
|
327
|
-
};
|
|
407
|
+
const queries = options.queries || (options.query ? [options.query] : []);
|
|
408
|
+
const limit = options.limit ?? config.defaultLimit;
|
|
409
|
+
// Initialize session directory if --session specified
|
|
410
|
+
if (options.session) {
|
|
411
|
+
const resolved = resolveSessionPath(options.session);
|
|
412
|
+
options.session = resolved;
|
|
413
|
+
initSessionDir(resolved, options.owner, options.desc, options.query);
|
|
414
|
+
}
|
|
328
415
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
416
|
+
// Single query: use SearXNG directly (it already aggregates/sorts)
|
|
417
|
+
if (queries.length === 1 && !options.merge) {
|
|
418
|
+
const searchOptions = {
|
|
419
|
+
query: queries[0],
|
|
420
|
+
engines: options.engines,
|
|
421
|
+
categories: options.categories,
|
|
422
|
+
limit,
|
|
423
|
+
page: options.page,
|
|
424
|
+
language: options.language,
|
|
425
|
+
timeRange: options.timeRange,
|
|
426
|
+
};
|
|
427
|
+
const results = await service.search(searchOptions);
|
|
428
|
+
// Session: accumulate results and update graph
|
|
429
|
+
let sessionInfo = null;
|
|
430
|
+
if (options.session) {
|
|
431
|
+
sessionInfo = appendSessionResults(options.session, results.results);
|
|
432
|
+
for (const query of queries) {
|
|
433
|
+
updateSessionGraph(options.session, query, results.results.map(r => ({
|
|
434
|
+
url: r.url,
|
|
435
|
+
title: r.title,
|
|
436
|
+
})));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const outputFormat = options.format || config.defaultFormat;
|
|
440
|
+
// When session active, RRF-merge with all accumulated results for display
|
|
441
|
+
let displayResults = results.results;
|
|
442
|
+
if (options.session) {
|
|
443
|
+
const allSessionResults = loadSessionResults(options.session);
|
|
444
|
+
const rankings = [
|
|
445
|
+
results.results.map(r => ({ id: normalizeUrl(r.url), ...r })),
|
|
446
|
+
allSessionResults.map(r => ({ id: normalizeUrl(r.url), ...r })),
|
|
447
|
+
];
|
|
448
|
+
const fused = rrf(rankings);
|
|
449
|
+
const urlMap = new Map();
|
|
450
|
+
for (const r of results.results)
|
|
451
|
+
urlMap.set(normalizeUrl(r.url), r);
|
|
452
|
+
for (const r of allSessionResults)
|
|
453
|
+
urlMap.set(normalizeUrl(r.url), r);
|
|
454
|
+
displayResults = fused
|
|
455
|
+
.map(item => {
|
|
456
|
+
const original = urlMap.get(item.id);
|
|
457
|
+
return original ? { ...original, score: item.score } : null;
|
|
458
|
+
})
|
|
459
|
+
.filter(Boolean);
|
|
460
|
+
if (limit > 0)
|
|
461
|
+
displayResults = displayResults.slice(0, limit);
|
|
462
|
+
}
|
|
463
|
+
const displayData = {
|
|
343
464
|
query: results.query,
|
|
344
465
|
totalResults: results.numberOfResults,
|
|
345
|
-
results:
|
|
466
|
+
results: displayResults,
|
|
346
467
|
answers: results.answers,
|
|
347
468
|
suggestions: results.suggestions,
|
|
348
|
-
unresponsiveEngines: results.unresponsiveEngines
|
|
349
|
-
|
|
469
|
+
unresponsiveEngines: results.unresponsiveEngines,
|
|
470
|
+
...(sessionInfo ? { session: { dir: options.session, added: sessionInfo.added, total: sessionInfo.total } } : {}),
|
|
471
|
+
};
|
|
472
|
+
if (outputFormat === 'md') {
|
|
473
|
+
console.log(formatOutput(displayData, 'md'));
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
console.log(JSON.stringify(createSuccessEnvelope({
|
|
477
|
+
...displayData,
|
|
478
|
+
returnedResults: displayResults.length,
|
|
479
|
+
}), null, 2));
|
|
480
|
+
}
|
|
481
|
+
// Auto-insert result metadata into knowledge graph if --graph specified
|
|
482
|
+
if (options.graph) {
|
|
483
|
+
addToGraph(options.graph, results.results);
|
|
484
|
+
}
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
// Multi-query or --merge: RRF fusion across ranked lists
|
|
488
|
+
const allResponses = [];
|
|
489
|
+
for (const query of queries) {
|
|
490
|
+
const searchOptions = {
|
|
491
|
+
query,
|
|
492
|
+
engines: options.engines,
|
|
493
|
+
categories: options.categories,
|
|
494
|
+
limit,
|
|
495
|
+
page: options.page,
|
|
496
|
+
language: options.language,
|
|
497
|
+
timeRange: options.timeRange,
|
|
498
|
+
};
|
|
499
|
+
const response = await service.search(searchOptions);
|
|
500
|
+
allResponses.push(response);
|
|
501
|
+
}
|
|
502
|
+
// Build rankings for RRF: each query's results as a separate ranked list
|
|
503
|
+
let rankings = allResponses.map(resp => resp.results.map((r) => ({ id: normalizeUrl(r.url), ...r })));
|
|
504
|
+
// If --merge, load historical results and add as another ranking
|
|
505
|
+
let mergeData = null;
|
|
506
|
+
if (options.merge) {
|
|
507
|
+
try {
|
|
508
|
+
const raw = readFileSync(options.merge, 'utf-8');
|
|
509
|
+
mergeData = JSON.parse(raw);
|
|
510
|
+
let mergeResults = mergeData;
|
|
511
|
+
if (mergeData.status === 'ok' && mergeData.data) {
|
|
512
|
+
mergeResults = mergeData.data;
|
|
513
|
+
}
|
|
514
|
+
const historicalResults = (mergeResults.results || []);
|
|
515
|
+
if (historicalResults.length > 0) {
|
|
516
|
+
rankings.push(historicalResults.map((r) => ({ id: normalizeUrl(r.url), ...r })));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
const envelope = createErrorEnvelope('MERGE_FILE_FAILED', `Failed to read merge file: ${options.merge}`, { hint: 'Ensure the file exists and contains valid search results JSON' });
|
|
521
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
522
|
+
return 1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// RRF fusion
|
|
526
|
+
const rrfFused = rrf(rankings);
|
|
527
|
+
// Map RRF scores back to original results
|
|
528
|
+
const allResults = [];
|
|
529
|
+
const allSuggestions = [];
|
|
530
|
+
const allAnswers = [];
|
|
531
|
+
const allUnresponsive = [];
|
|
532
|
+
for (const resp of allResponses) {
|
|
533
|
+
allResults.push(...resp.results);
|
|
534
|
+
for (const s of resp.suggestions) {
|
|
535
|
+
if (!allSuggestions.includes(s))
|
|
536
|
+
allSuggestions.push(s);
|
|
537
|
+
}
|
|
538
|
+
for (const a of resp.answers) {
|
|
539
|
+
if (!allAnswers.includes(a))
|
|
540
|
+
allAnswers.push(a);
|
|
541
|
+
}
|
|
542
|
+
for (const u of resp.unresponsiveEngines) {
|
|
543
|
+
const name = Array.isArray(u) ? u[0] : u;
|
|
544
|
+
if (!allUnresponsive.includes(name))
|
|
545
|
+
allUnresponsive.push(name);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Include historical results in the URL map if merging
|
|
549
|
+
if (mergeData) {
|
|
550
|
+
let mergeResults = mergeData;
|
|
551
|
+
if (mergeData.status === 'ok' && mergeData.data) {
|
|
552
|
+
mergeResults = mergeData.data;
|
|
553
|
+
}
|
|
554
|
+
const historical = mergeResults.results || [];
|
|
555
|
+
allResults.push(...historical);
|
|
556
|
+
if (mergeResults.suggestions) {
|
|
557
|
+
for (const s of mergeResults.suggestions) {
|
|
558
|
+
if (!allSuggestions.includes(s))
|
|
559
|
+
allSuggestions.push(s);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (mergeResults.answers) {
|
|
563
|
+
for (const a of mergeResults.answers) {
|
|
564
|
+
if (!allAnswers.includes(a))
|
|
565
|
+
allAnswers.push(a);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Dedup by URL to get unique results, then apply RRF order
|
|
570
|
+
const urlMap = new Map();
|
|
571
|
+
for (const r of allResults) {
|
|
572
|
+
const norm = normalizeUrl(r.url);
|
|
573
|
+
if (!urlMap.has(norm))
|
|
574
|
+
urlMap.set(norm, r);
|
|
575
|
+
}
|
|
576
|
+
let fusedResults = rrfFused
|
|
577
|
+
.map((item) => {
|
|
578
|
+
const original = urlMap.get(item.id);
|
|
579
|
+
if (!original)
|
|
580
|
+
return null;
|
|
581
|
+
return { ...original, score: item.score };
|
|
582
|
+
})
|
|
583
|
+
.filter(Boolean);
|
|
584
|
+
if (limit > 0) {
|
|
585
|
+
fusedResults = fusedResults.slice(0, limit);
|
|
586
|
+
}
|
|
587
|
+
// Session: accumulate results and update graph
|
|
588
|
+
let sessionInfo = null;
|
|
589
|
+
if (options.session) {
|
|
590
|
+
sessionInfo = appendSessionResults(options.session, fusedResults);
|
|
591
|
+
for (const query of queries) {
|
|
592
|
+
updateSessionGraph(options.session, query, fusedResults.map(r => ({
|
|
593
|
+
url: r.url,
|
|
594
|
+
title: r.title,
|
|
595
|
+
})));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const displayQuery = queries.length > 1 ? queries.join(' · ') : queries[0];
|
|
599
|
+
const outputFormat = options.format || config.defaultFormat;
|
|
600
|
+
const displayData = {
|
|
601
|
+
query: displayQuery,
|
|
602
|
+
queries,
|
|
603
|
+
totalResults: allResults.length,
|
|
604
|
+
results: fusedResults,
|
|
605
|
+
answers: allAnswers,
|
|
606
|
+
suggestions: allSuggestions,
|
|
607
|
+
unresponsiveEngines: allUnresponsive,
|
|
608
|
+
...(sessionInfo ? { session: { dir: options.session, added: sessionInfo.added, total: sessionInfo.total } } : {}),
|
|
609
|
+
};
|
|
610
|
+
if (outputFormat === 'md') {
|
|
611
|
+
console.log(formatOutput(displayData, 'md'));
|
|
350
612
|
}
|
|
351
613
|
else if (outputFormat === 'json') {
|
|
352
|
-
console.log(JSON.stringify(
|
|
614
|
+
console.log(JSON.stringify(createSuccessEnvelope({
|
|
615
|
+
...displayData,
|
|
616
|
+
returnedResults: fusedResults.length,
|
|
617
|
+
}), null, 2));
|
|
353
618
|
}
|
|
354
619
|
else {
|
|
355
|
-
|
|
356
|
-
|
|
620
|
+
console.log(formatOutput(displayData, outputFormat));
|
|
621
|
+
}
|
|
622
|
+
// Auto-insert result metadata into knowledge graph if --graph specified
|
|
623
|
+
if (options.graph) {
|
|
624
|
+
addToGraph(options.graph, fusedResults);
|
|
357
625
|
}
|
|
358
626
|
return 0;
|
|
359
627
|
}
|