sxng-cli 1.0.5 → 1.0.7
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 +88 -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 +155 -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 +153 -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 +69 -0
- package/dist/deep/session.d.ts.map +1 -0
- package/dist/deep/session.js +206 -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 +394 -100
- package/dist/runCli.js.map +1 -1
- package/package.json +6 -2
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
|
|
@@ -163,10 +213,8 @@ function formatAsMarkdown(data) {
|
|
|
163
213
|
lines.push('');
|
|
164
214
|
for (let i = 0; i < results.length; i++) {
|
|
165
215
|
const r = results[i];
|
|
166
|
-
// Title with score indicator
|
|
167
216
|
let scoreIndicator = '';
|
|
168
217
|
if (r.score !== undefined && r.score !== null) {
|
|
169
|
-
// Score might be already > 1 or in 0-1 range
|
|
170
218
|
const normalizedScore = r.score > 1 ? r.score : r.score * 100;
|
|
171
219
|
scoreIndicator = ` [${normalizedScore.toFixed(1)}]`;
|
|
172
220
|
}
|
|
@@ -176,7 +224,6 @@ function formatAsMarkdown(data) {
|
|
|
176
224
|
lines.push(r.content);
|
|
177
225
|
lines.push('');
|
|
178
226
|
}
|
|
179
|
-
// Metadata: engine, category, publishedDate (if available)
|
|
180
227
|
const meta = [];
|
|
181
228
|
meta.push(`Engine: ${r.engine || 'unknown'}`);
|
|
182
229
|
if (r.category)
|
|
@@ -191,13 +238,13 @@ function formatAsMarkdown(data) {
|
|
|
191
238
|
lines.push('No results found.');
|
|
192
239
|
lines.push('');
|
|
193
240
|
}
|
|
194
|
-
// Unresponsive engines
|
|
241
|
+
// Unresponsive engines
|
|
195
242
|
if (data.unresponsiveEngines && data.unresponsiveEngines.length > 0) {
|
|
196
243
|
const unresponsive = data.unresponsiveEngines.map((item) => Array.isArray(item) ? item[0] : item).join(', ');
|
|
197
244
|
lines.push(`*Unresponsive: ${unresponsive}*`);
|
|
198
245
|
lines.push('');
|
|
199
246
|
}
|
|
200
|
-
// Suggestions
|
|
247
|
+
// Suggestions
|
|
201
248
|
if (data.suggestions && data.suggestions.length > 0) {
|
|
202
249
|
lines.push('**Suggestions:** ' + data.suggestions.join(' · '));
|
|
203
250
|
lines.push('');
|
|
@@ -205,17 +252,52 @@ function formatAsMarkdown(data) {
|
|
|
205
252
|
return lines.join('\n');
|
|
206
253
|
}
|
|
207
254
|
function formatOutput(data, format) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
graph = new DirectedGraph();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
graph = new DirectedGraph();
|
|
278
|
+
}
|
|
218
279
|
}
|
|
280
|
+
else {
|
|
281
|
+
graph = new DirectedGraph();
|
|
282
|
+
}
|
|
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
|
+
}
|
|
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');
|
|
219
301
|
}
|
|
220
302
|
export async function runCli(args, service) {
|
|
221
303
|
const options = parseArgs(args);
|
|
@@ -226,6 +308,15 @@ export async function runCli(args, service) {
|
|
|
226
308
|
if (options.init) {
|
|
227
309
|
return await initConfig();
|
|
228
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
|
+
}
|
|
229
320
|
if (options.health) {
|
|
230
321
|
const health = await service.healthCheck();
|
|
231
322
|
const envelope = health.status === 'healthy'
|
|
@@ -276,8 +367,32 @@ export async function runCli(args, service) {
|
|
|
276
367
|
return 1;
|
|
277
368
|
}
|
|
278
369
|
}
|
|
279
|
-
if (
|
|
280
|
-
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"' });
|
|
281
396
|
console.log(JSON.stringify(envelope, null, 2));
|
|
282
397
|
return 1;
|
|
283
398
|
}
|
|
@@ -289,45 +404,224 @@ export async function runCli(args, service) {
|
|
|
289
404
|
return 1;
|
|
290
405
|
}
|
|
291
406
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
format: options.format || config.defaultFormat
|
|
301
|
-
};
|
|
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
|
+
}
|
|
302
415
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
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 = {
|
|
317
464
|
query: results.query,
|
|
318
465
|
totalResults: results.numberOfResults,
|
|
319
|
-
results:
|
|
466
|
+
results: displayResults,
|
|
320
467
|
answers: results.answers,
|
|
321
468
|
suggestions: results.suggestions,
|
|
322
|
-
unresponsiveEngines: results.unresponsiveEngines
|
|
323
|
-
|
|
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'));
|
|
324
612
|
}
|
|
325
613
|
else if (outputFormat === 'json') {
|
|
326
|
-
console.log(JSON.stringify(
|
|
614
|
+
console.log(JSON.stringify(createSuccessEnvelope({
|
|
615
|
+
...displayData,
|
|
616
|
+
returnedResults: fusedResults.length,
|
|
617
|
+
}), null, 2));
|
|
327
618
|
}
|
|
328
619
|
else {
|
|
329
|
-
|
|
330
|
-
|
|
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);
|
|
331
625
|
}
|
|
332
626
|
return 0;
|
|
333
627
|
}
|