wiki-plugin-similarity 0.4.4 → 0.5.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/client/similarity.js +35 -35
- package/client/similarity.js.map +3 -3
- package/package.json +8 -3
- package/pages/about-similarity-plugin +90 -40
- package/server/embedder.js +71 -0
- package/server/farm-lib.js +102 -0
- package/server/farm-search.js +122 -0
- package/server/ort-hooks.mjs +6 -0
- package/server/ort-shim.mjs +2 -0
- package/server/search-report.js +0 -0
- package/server/server.js +153 -152
|
@@ -3,70 +3,66 @@
|
|
|
3
3
|
"story": [
|
|
4
4
|
{
|
|
5
5
|
"type": "markdown",
|
|
6
|
-
"id": "
|
|
7
|
-
"text": "The Similarity plugin adds
|
|
6
|
+
"id": "aec1dfcd5f2bc42b",
|
|
7
|
+
"text": "The Similarity plugin adds search to a Federated Wiki farm — semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type — **similarity** — with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
10
|
"type": "markdown",
|
|
11
|
-
"id": "
|
|
12
|
-
"text": "
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"type": "similarity",
|
|
16
|
-
"id": "ad3c41667c285efd",
|
|
17
|
-
"text": "SIMILAR: medium\nLIMIT: 5"
|
|
11
|
+
"id": "28edff984cc5334d",
|
|
12
|
+
"text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
|
|
18
13
|
},
|
|
19
14
|
{
|
|
20
15
|
"type": "markdown",
|
|
21
|
-
"id": "
|
|
22
|
-
"text": "##
|
|
16
|
+
"id": "251793ca0dae4810",
|
|
17
|
+
"text": "## Try It\n\nSemantic search across the farm:"
|
|
23
18
|
},
|
|
24
19
|
{
|
|
25
|
-
"type": "
|
|
26
|
-
"id": "
|
|
27
|
-
"text": "
|
|
20
|
+
"type": "similarity",
|
|
21
|
+
"id": "86aeab8be16dd8e1",
|
|
22
|
+
"text": "*"
|
|
28
23
|
},
|
|
29
24
|
{
|
|
30
25
|
"type": "markdown",
|
|
31
|
-
"id": "
|
|
32
|
-
"text": "
|
|
26
|
+
"id": "b6a9be0145bae9bd",
|
|
27
|
+
"text": "Pages similar to this one:"
|
|
33
28
|
},
|
|
34
29
|
{
|
|
35
|
-
"type": "
|
|
36
|
-
"id": "
|
|
37
|
-
"text": "
|
|
30
|
+
"type": "similarity",
|
|
31
|
+
"id": "cf558a16395d7d8c",
|
|
32
|
+
"text": "SIMILAR: medium\nLIMIT: 5"
|
|
38
33
|
},
|
|
39
34
|
{
|
|
40
35
|
"type": "markdown",
|
|
41
|
-
"id": "
|
|
42
|
-
"text": "
|
|
36
|
+
"id": "1aceb7035c7537b2",
|
|
37
|
+
"text": "A ranked report (opens as a ghost page):"
|
|
43
38
|
},
|
|
44
39
|
{
|
|
45
|
-
"type": "
|
|
46
|
-
"id": "
|
|
47
|
-
"text": "
|
|
40
|
+
"type": "similarity",
|
|
41
|
+
"id": "59bfe1e3968743a1",
|
|
42
|
+
"text": "REPORT\nLIMIT: 5"
|
|
48
43
|
},
|
|
49
44
|
{
|
|
50
45
|
"type": "markdown",
|
|
51
|
-
"id": "
|
|
52
|
-
"text": "
|
|
46
|
+
"id": "31b81a31f675c4e1",
|
|
47
|
+
"text": "Keyword search over the sites' own indexes:"
|
|
53
48
|
},
|
|
54
49
|
{
|
|
55
|
-
"type": "
|
|
56
|
-
"id": "
|
|
57
|
-
"text": "
|
|
50
|
+
"type": "similarity",
|
|
51
|
+
"id": "0c3fb9298ba8fd23",
|
|
52
|
+
"text": "KEYWORD\nLIMIT: 5"
|
|
58
53
|
},
|
|
59
54
|
{
|
|
60
55
|
"type": "markdown",
|
|
61
|
-
"id": "
|
|
62
|
-
"text": "##
|
|
56
|
+
"id": "d9a96dfebb9d1692",
|
|
57
|
+
"text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
|
|
63
58
|
},
|
|
64
59
|
{
|
|
65
60
|
"type": "markdown",
|
|
66
|
-
"id": "
|
|
67
|
-
"text": "
|
|
61
|
+
"id": "21eb5354d2adb29e",
|
|
62
|
+
"text": "**Version:** 0.5.1\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
|
|
68
63
|
}
|
|
69
64
|
],
|
|
65
|
+
"plugin": "similarity",
|
|
70
66
|
"journal": [
|
|
71
67
|
{
|
|
72
68
|
"type": "create",
|
|
@@ -75,14 +71,68 @@
|
|
|
75
71
|
"story": [
|
|
76
72
|
{
|
|
77
73
|
"type": "markdown",
|
|
78
|
-
"id": "
|
|
79
|
-
"text": "The Similarity plugin adds
|
|
74
|
+
"id": "aec1dfcd5f2bc42b",
|
|
75
|
+
"text": "The Similarity plugin adds search to a Federated Wiki farm — semantic similarity over prebuilt vector indexes, ranked search reports, and galactic keyword search over each site's own MiniSearch index. One item type — **similarity** — with a line-oriented DSL, plus a server component that serves every search same-origin. Works on any host: embedding runs in-process (native ONNX where available, WASM everywhere else)."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"type": "markdown",
|
|
79
|
+
"id": "28edff984cc5334d",
|
|
80
|
+
"text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "markdown",
|
|
84
|
+
"id": "251793ca0dae4810",
|
|
85
|
+
"text": "## Try It\n\nSemantic search across the farm:"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "similarity",
|
|
89
|
+
"id": "86aeab8be16dd8e1",
|
|
90
|
+
"text": "*"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"type": "markdown",
|
|
94
|
+
"id": "b6a9be0145bae9bd",
|
|
95
|
+
"text": "Pages similar to this one:"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"type": "similarity",
|
|
99
|
+
"id": "cf558a16395d7d8c",
|
|
100
|
+
"text": "SIMILAR: medium\nLIMIT: 5"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "markdown",
|
|
104
|
+
"id": "1aceb7035c7537b2",
|
|
105
|
+
"text": "A ranked report (opens as a ghost page):"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "similarity",
|
|
109
|
+
"id": "59bfe1e3968743a1",
|
|
110
|
+
"text": "REPORT\nLIMIT: 5"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"type": "markdown",
|
|
114
|
+
"id": "31b81a31f675c4e1",
|
|
115
|
+
"text": "Keyword search over the sites' own indexes:"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"type": "similarity",
|
|
119
|
+
"id": "0c3fb9298ba8fd23",
|
|
120
|
+
"text": "KEYWORD\nLIMIT: 5"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "markdown",
|
|
124
|
+
"id": "d9a96dfebb9d1692",
|
|
125
|
+
"text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"type": "markdown",
|
|
129
|
+
"id": "21eb5354d2adb29e",
|
|
130
|
+
"text": "**Version:** 0.5.1\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
|
|
80
131
|
}
|
|
81
132
|
]
|
|
82
133
|
},
|
|
83
|
-
"date":
|
|
84
|
-
"
|
|
134
|
+
"date": 1783782734556,
|
|
135
|
+
"provenance": "Authored by an AI agent — Claude Fable 5 via the [[fedwiki-page]] skill. [https://claude.com/claude-code Claude Code]"
|
|
85
136
|
}
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// wiki-plugin-similarity — in-process query embedder (CommonJS)
|
|
2
|
+
//
|
|
3
|
+
// Reproduces the farm indexer's embeddings (fastembed, BAAI/bge-small-en-v1.5,
|
|
4
|
+
// 384-dim, L2-normalised, NO query prefix) using transformers.js — pure
|
|
5
|
+
// JS/WASM ONNX, no native dependencies, so it runs inside any wiki-server
|
|
6
|
+
// install including the public farm's container.
|
|
7
|
+
//
|
|
8
|
+
// Parity notes (verified against the FastAPI /embed reference):
|
|
9
|
+
// - pooling and quantisation are configurable via env because they are the
|
|
10
|
+
// two knobs that can desync scores from the prebuilt semantic-vectors.json:
|
|
11
|
+
// WIKI_EMBED_POOLING cls | mean (default cls — BGE models use CLS)
|
|
12
|
+
// WIKI_EMBED_QUANTIZED 1 | 0 (default 0 — fp32 matches fastembed)
|
|
13
|
+
// - never add an instruction prefix: the index was built with plain .embed().
|
|
14
|
+
//
|
|
15
|
+
// Model files download on first use (~130MB fp32) into WIKI_MODEL_CACHE if set,
|
|
16
|
+
// else transformers.js's default cache. Subsequent runs are offline.
|
|
17
|
+
|
|
18
|
+
const MODEL = process.env.WIKI_EMBED_MODEL || 'Xenova/bge-small-en-v1.5'
|
|
19
|
+
const POOLING = process.env.WIKI_EMBED_POOLING || 'cls'
|
|
20
|
+
const QUANTIZED = process.env.WIKI_EMBED_QUANTIZED === '1'
|
|
21
|
+
|
|
22
|
+
// transformers.js statically imports onnxruntime-node, whose native binary
|
|
23
|
+
// needs glibc. On musl containers (node:alpine — the public farm) that import
|
|
24
|
+
// throws "Error loading shared library ld-linux-x86-64.so.2". Probe the
|
|
25
|
+
// native runtime with a CJS require first; if it can't load, register a
|
|
26
|
+
// module hook (Node >= 18.19) that redirects the onnxruntime-node import to
|
|
27
|
+
// onnxruntime-web — the WASM backend runs everywhere and benchmarks ~25ms a
|
|
28
|
+
// query. WIKI_EMBED_FORCE_WASM=1 forces the WASM path for testing.
|
|
29
|
+
|
|
30
|
+
const nativeOrtLoads = () => {
|
|
31
|
+
if (process.env.WIKI_EMBED_FORCE_WASM === '1') return false
|
|
32
|
+
try { require('onnxruntime-node'); return true } catch { return false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let extractorPromise = null
|
|
36
|
+
|
|
37
|
+
const getExtractor = () => {
|
|
38
|
+
if (!extractorPromise) {
|
|
39
|
+
extractorPromise = (async () => {
|
|
40
|
+
if (!nativeOrtLoads()) {
|
|
41
|
+
const { register } = require('node:module')
|
|
42
|
+
if (typeof register !== 'function') {
|
|
43
|
+
throw new Error('native onnxruntime unavailable and Node lacks ' +
|
|
44
|
+
'module.register (need >= 18.19) for the WASM fallback')
|
|
45
|
+
}
|
|
46
|
+
register('./ort-hooks.mjs', require('node:url').pathToFileURL(__filename))
|
|
47
|
+
console.log('[wiki-plugin-similarity] native onnxruntime unavailable — using WASM backend')
|
|
48
|
+
}
|
|
49
|
+
// Dynamic import: @xenova/transformers is ESM-only; this file must stay CJS.
|
|
50
|
+
const { pipeline, env } = await import('@xenova/transformers')
|
|
51
|
+
if (process.env.WIKI_MODEL_CACHE) env.cacheDir = process.env.WIKI_MODEL_CACHE
|
|
52
|
+
if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1
|
|
53
|
+
return pipeline('feature-extraction', MODEL, { quantized: QUANTIZED })
|
|
54
|
+
})()
|
|
55
|
+
extractorPromise.catch(() => { extractorPromise = null }) // allow retry
|
|
56
|
+
}
|
|
57
|
+
return extractorPromise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Embed one text → plain number[384], unit-normalised.
|
|
61
|
+
const embed = async text => {
|
|
62
|
+
const extractor = await getExtractor()
|
|
63
|
+
const out = await extractor(text, { pooling: POOLING, normalize: true })
|
|
64
|
+
return Array.from(out.data)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Warm the model in the background (call at startup; failures just defer to
|
|
68
|
+
// first request).
|
|
69
|
+
const warm = () => { getExtractor().catch(() => {}) }
|
|
70
|
+
|
|
71
|
+
module.exports = { embed, warm }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// wiki-plugin-similarity — shared farm helpers (CommonJS, see server/package.json)
|
|
2
|
+
//
|
|
3
|
+
// Used by server.js, search-report.js and farm-search.js. A "farms" list is
|
|
4
|
+
// [[rootPath, kind], ...] where kind is 'local' (the primary farm this wiki
|
|
5
|
+
// serves from) or 'public' (extra farms, e.g. the Nextcloud mirror).
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
|
|
10
|
+
// ── Glob matching — supports * and ?, no path separator semantics ─────────────
|
|
11
|
+
|
|
12
|
+
const globMatch = (pattern, str) => {
|
|
13
|
+
const p = pattern.length
|
|
14
|
+
const s = str.length
|
|
15
|
+
const dp = Array.from({ length: p + 1 }, () => new Array(s + 1).fill(false))
|
|
16
|
+
dp[0][0] = true
|
|
17
|
+
for (let i = 1; i <= p; i++) {
|
|
18
|
+
if (pattern[i - 1] === '*') dp[i][0] = dp[i - 1][0]
|
|
19
|
+
}
|
|
20
|
+
for (let i = 1; i <= p; i++) {
|
|
21
|
+
for (let j = 1; j <= s; j++) {
|
|
22
|
+
if (pattern[i - 1] === '*') {
|
|
23
|
+
dp[i][j] = dp[i - 1][j] || dp[i][j - 1]
|
|
24
|
+
} else if (pattern[i - 1] === '?' || pattern[i - 1] === str[j - 1]) {
|
|
25
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return dp[p][s]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Scope keywords (exact uppercase, per DSL convention) ──────────────────────
|
|
33
|
+
// * all farms
|
|
34
|
+
// PUBLIC domains in 'public' farms (Nextcloud mirror)
|
|
35
|
+
// LOCAL domains in the primary ('local') farm
|
|
36
|
+
// PRIVATE public domains marked "restricted": true in a farm config
|
|
37
|
+
|
|
38
|
+
const loadRestricted = publicFarms => {
|
|
39
|
+
const restricted = new Set()
|
|
40
|
+
for (const farm of publicFarms) {
|
|
41
|
+
let files
|
|
42
|
+
try { files = fs.readdirSync(farm) } catch { continue }
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
if (!/^config-.*\.json$/.test(f)) continue
|
|
45
|
+
try {
|
|
46
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(farm, f), 'utf8'))
|
|
47
|
+
for (const [domain, opts] of Object.entries(cfg.wikiDomains || {})) {
|
|
48
|
+
if (opts && opts.restricted) restricted.add(domain)
|
|
49
|
+
}
|
|
50
|
+
} catch { /* ignore malformed config */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return restricted
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const matchesAny = (domain, kind, patterns, restricted) =>
|
|
57
|
+
patterns.some(p => {
|
|
58
|
+
if (p === '*') return true
|
|
59
|
+
if (p === 'PUBLIC') return kind === 'public'
|
|
60
|
+
if (p === 'LOCAL') return kind === 'local'
|
|
61
|
+
if (p === 'PRIVATE') return kind === 'public' && restricted.has(domain)
|
|
62
|
+
return globMatch(p, domain)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ── Domain listing ────────────────────────────────────────────────────────────
|
|
66
|
+
// List domains across farms matching the patterns, optionally requiring a file
|
|
67
|
+
// (relative to the domain dir) to exist. First farm wins on duplicate names.
|
|
68
|
+
// Returns [{farm, kind, domain}].
|
|
69
|
+
|
|
70
|
+
const listDomains = (farms, patterns, restricted, requireFile = null) => {
|
|
71
|
+
const seen = new Set()
|
|
72
|
+
const out = []
|
|
73
|
+
for (const [farm, kind] of farms) {
|
|
74
|
+
let entries
|
|
75
|
+
try { entries = fs.readdirSync(farm, { withFileTypes: true }) } catch { continue }
|
|
76
|
+
for (const ent of entries) {
|
|
77
|
+
if (!ent.isDirectory()) continue
|
|
78
|
+
const domain = ent.name
|
|
79
|
+
if (seen.has(domain)) continue
|
|
80
|
+
if (!matchesAny(domain, kind, patterns, restricted)) continue
|
|
81
|
+
if (requireFile) {
|
|
82
|
+
try { fs.accessSync(path.join(farm, domain, requireFile), fs.constants.F_OK) }
|
|
83
|
+
catch { continue }
|
|
84
|
+
}
|
|
85
|
+
seen.add(domain)
|
|
86
|
+
out.push({ farm, kind, domain })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.sort((a, b) => a.domain.localeCompare(b.domain))
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// First existing path for domain + relative sub-path across farm roots.
|
|
94
|
+
const findInFarms = (farms, domain, relPath) => {
|
|
95
|
+
for (const [farm] of farms) {
|
|
96
|
+
const full = path.join(farm, domain, relPath)
|
|
97
|
+
try { fs.accessSync(full, fs.constants.F_OK); return full } catch { /* next */ }
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { globMatch, loadRestricted, matchesAny, listDomains, findInFarms }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// wiki-plugin-similarity — galactic keyword search (CommonJS)
|
|
2
|
+
//
|
|
3
|
+
// Fedwiki maintains a serialized MiniSearch index per site: the wiki-server
|
|
4
|
+
// updates {domain}/status/site-index.json on every edit. This module READS
|
|
5
|
+
// those indexes across every domain on the farm(s) — no index building of any
|
|
6
|
+
// kind. On the farm server the files are per-edit fresh because the server
|
|
7
|
+
// itself writes them.
|
|
8
|
+
//
|
|
9
|
+
// The server-built index stores no fields (storedFields is empty), so display
|
|
10
|
+
// titles come from each domain's status/sitemap.json.
|
|
11
|
+
//
|
|
12
|
+
// Loaded indexes are cached per domain and invalidated by file mtime.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
const crypto = require('node:crypto')
|
|
17
|
+
|
|
18
|
+
const MiniSearch = require('minisearch')
|
|
19
|
+
const { listDomains } = require('./farm-lib')
|
|
20
|
+
|
|
21
|
+
// site-index.json was serialized with these options; loadJSON needs them.
|
|
22
|
+
const MS_OPTIONS = { fields: ['title', 'content'] }
|
|
23
|
+
|
|
24
|
+
const makeId = () => crypto.randomBytes(8).toString('hex')
|
|
25
|
+
|
|
26
|
+
// ── Per-domain index cache, mtime-invalidated ─────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const cache = new Map() // domain → {mtime, ms, titles, synopses}
|
|
29
|
+
|
|
30
|
+
const loadDomainIndex = (farm, domain) => {
|
|
31
|
+
const indexPath = path.join(farm, domain, 'status', 'site-index.json')
|
|
32
|
+
let mtime
|
|
33
|
+
try { mtime = fs.statSync(indexPath).mtimeMs } catch { return null }
|
|
34
|
+
|
|
35
|
+
const hit = cache.get(domain)
|
|
36
|
+
if (hit && hit.mtime === mtime) return hit
|
|
37
|
+
|
|
38
|
+
let ms
|
|
39
|
+
try {
|
|
40
|
+
ms = MiniSearch.loadJSON(fs.readFileSync(indexPath, 'utf8'), MS_OPTIONS)
|
|
41
|
+
} catch { return null }
|
|
42
|
+
|
|
43
|
+
// Titles + synopses from the sitemap (the index stores no fields).
|
|
44
|
+
const titles = new Map()
|
|
45
|
+
const synopses = new Map()
|
|
46
|
+
try {
|
|
47
|
+
const sitemap = JSON.parse(fs.readFileSync(
|
|
48
|
+
path.join(farm, domain, 'status', 'sitemap.json'), 'utf8'))
|
|
49
|
+
for (const p of sitemap) {
|
|
50
|
+
titles.set(p.slug, p.title)
|
|
51
|
+
if (p.synopsis) synopses.set(p.slug, p.synopsis)
|
|
52
|
+
}
|
|
53
|
+
} catch { /* titles fall back to slugs */ }
|
|
54
|
+
|
|
55
|
+
const entry = { mtime, ms, titles, synopses }
|
|
56
|
+
cache.set(domain, entry)
|
|
57
|
+
return entry
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Search across domains ─────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const searchFarm = (farms, patterns, restricted, query, limit) => {
|
|
63
|
+
const domains = listDomains(farms, patterns, restricted, 'status/site-index.json')
|
|
64
|
+
const results = []
|
|
65
|
+
let searched = 0
|
|
66
|
+
for (const { farm, domain } of domains) {
|
|
67
|
+
const entry = loadDomainIndex(farm, domain)
|
|
68
|
+
if (!entry) continue
|
|
69
|
+
searched += 1
|
|
70
|
+
let hits
|
|
71
|
+
try { hits = entry.ms.search(query, { boost: { title: 2 } }) } catch { continue }
|
|
72
|
+
for (const h of hits) {
|
|
73
|
+
results.push({
|
|
74
|
+
domain,
|
|
75
|
+
slug: String(h.id),
|
|
76
|
+
title: entry.titles.get(String(h.id)) || String(h.id),
|
|
77
|
+
synopsis: entry.synopses.get(String(h.id)) || '',
|
|
78
|
+
score: h.score,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
results.sort((a, b) => b.score - a.score)
|
|
83
|
+
return { results: results.slice(0, limit), searched, matched: results.length }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Report page JSON (ghost-page ready, mirrors search-report style) ──────────
|
|
87
|
+
|
|
88
|
+
const keywordReportPage = (query, { results, searched, matched }, limit, specs) => {
|
|
89
|
+
const story = [{
|
|
90
|
+
type: 'markdown', id: makeId(),
|
|
91
|
+
text: `Keyword search for **${query}** — searched the live site indexes of ` +
|
|
92
|
+
`${searched} domains; ${matched} matches, top ${Math.min(limit, results.length)} shown.\n\n` +
|
|
93
|
+
`<small>Config — domains: ${(specs || ['*']).join(', ')}; limit: ${limit}. ` +
|
|
94
|
+
`Reads each site's own per-edit MiniSearch index (site-index.json) — ` +
|
|
95
|
+
`no separate index is built.</small>`,
|
|
96
|
+
}]
|
|
97
|
+
|
|
98
|
+
const seenTitles = new Set()
|
|
99
|
+
const uniqueTitles = results.map(r => r.title).filter(t => {
|
|
100
|
+
if (seenTitles.has(t)) return false
|
|
101
|
+
seenTitles.add(t)
|
|
102
|
+
return true
|
|
103
|
+
})
|
|
104
|
+
if (uniqueTitles.length) {
|
|
105
|
+
story.push({
|
|
106
|
+
type: 'markdown', id: makeId(),
|
|
107
|
+
text: uniqueTitles.map(t => `- [[${t}]]`).join('\n'),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
story.push({ type: 'markdown', id: makeId(), text: '# Results' })
|
|
112
|
+
for (const r of results) {
|
|
113
|
+
story.push({
|
|
114
|
+
type: 'reference', id: makeId(),
|
|
115
|
+
site: r.domain, slug: r.slug, title: r.title,
|
|
116
|
+
text: r.synopsis || `score ${r.score.toFixed(2)}`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return { title: `${query} Keyword Search`, story }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { searchFarm, keywordReportPage }
|
|
Binary file
|