hexo-semantic-search-ai 1.0.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/README.md +201 -0
- package/README.zh-CN.md +201 -0
- package/assets/semantic-search.js +175 -0
- package/index.js +201 -0
- package/lib/client.js +142 -0
- package/lib/helpers.js +96 -0
- package/lib/related.js +283 -0
- package/lib/state.js +91 -0
- package/lib/sync.js +159 -0
- package/package.json +37 -0
package/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* global hexo */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const SemanticSearchClient = require('./lib/client');
|
|
7
|
+
const SyncManager = require('./lib/sync');
|
|
8
|
+
const RelatedPostsManager = require('./lib/related');
|
|
9
|
+
const { registerHelpers } = require('./lib/helpers');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* hexo-semantic-search-ai
|
|
13
|
+
*
|
|
14
|
+
* A Hexo plugin that integrates SemanticSearch for:
|
|
15
|
+
* - Automatic post indexing with incremental sync
|
|
16
|
+
* - Related posts generation at build time
|
|
17
|
+
* - Frontend search component
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PLUGIN_NAME = 'semantic_search';
|
|
21
|
+
|
|
22
|
+
// Get config
|
|
23
|
+
const config = hexo.config[PLUGIN_NAME] || {};
|
|
24
|
+
|
|
25
|
+
// Skip if not enabled
|
|
26
|
+
if (config.enable === false) {
|
|
27
|
+
hexo.log.debug('[SemanticSearch] Plugin disabled by config');
|
|
28
|
+
} else {
|
|
29
|
+
// Validate required config
|
|
30
|
+
const endpoint = config.endpoint;
|
|
31
|
+
const writerKey = resolveEnvVar(config.writer_key || config.writerKey);
|
|
32
|
+
const readerKey = resolveEnvVar(config.reader_key || config.readerKey);
|
|
33
|
+
|
|
34
|
+
if (!endpoint) {
|
|
35
|
+
hexo.log.warn('[SemanticSearch] No endpoint configured, plugin disabled');
|
|
36
|
+
} else {
|
|
37
|
+
hexo.log.debug('[SemanticSearch] Plugin initializing...');
|
|
38
|
+
|
|
39
|
+
// Create client
|
|
40
|
+
const client = new SemanticSearchClient({
|
|
41
|
+
endpoint,
|
|
42
|
+
writerKey,
|
|
43
|
+
readerKey,
|
|
44
|
+
timeout: config.timeout || 30000
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Create managers
|
|
48
|
+
const syncManager = new SyncManager(hexo, client, {
|
|
49
|
+
fields: config.sync?.fields || ['title', 'content', 'excerpt', 'tags', 'categories']
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const relatedConfig = config.related_posts || config.relatedPosts || {};
|
|
53
|
+
const relatedManager = new RelatedPostsManager(hexo, client, {
|
|
54
|
+
limit: relatedConfig.limit || 5,
|
|
55
|
+
minScore: relatedConfig.min_score || relatedConfig.minScore || 0.3,
|
|
56
|
+
queryFields: relatedConfig.query_fields || relatedConfig.queryFields || ['title', 'excerpt']
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Register helpers
|
|
60
|
+
registerHelpers(hexo, config);
|
|
61
|
+
|
|
62
|
+
// Copy assets to public folder
|
|
63
|
+
hexo.extend.generator.register('semantic_search_assets', function() {
|
|
64
|
+
const assetPath = path.join(__dirname, 'assets', 'semantic-search.js');
|
|
65
|
+
const content = fs.readFileSync(assetPath, 'utf-8');
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
path: 'js/semantic-search.js',
|
|
69
|
+
data: content
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const autoSync = config.sync?.auto !== false;
|
|
74
|
+
const relatedEnabled = (relatedConfig.enable !== false) && readerKey;
|
|
75
|
+
|
|
76
|
+
// Hook: after_generate - sync posts after all content is generated
|
|
77
|
+
if (autoSync && writerKey) {
|
|
78
|
+
hexo.extend.filter.register('after_generate', async function() {
|
|
79
|
+
try {
|
|
80
|
+
hexo.log.info('[SemanticSearch] Syncing posts to index...');
|
|
81
|
+
await syncManager.sync();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
hexo.log.error(`[SemanticSearch] Sync failed: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Hook: after_post_render - fetch related posts with caching and rate limiting
|
|
89
|
+
// Note: Related posts require posts to already be indexed (from previous builds)
|
|
90
|
+
// Uses persistent cache to skip API calls for unchanged posts
|
|
91
|
+
if (relatedEnabled) {
|
|
92
|
+
// Track pending requests for rate limiting
|
|
93
|
+
let pendingRequests = 0;
|
|
94
|
+
const maxConcurrent = 3;
|
|
95
|
+
const requestQueue = [];
|
|
96
|
+
|
|
97
|
+
const processQueue = async () => {
|
|
98
|
+
while (requestQueue.length > 0 && pendingRequests < maxConcurrent) {
|
|
99
|
+
const { post, resolve } = requestQueue.shift();
|
|
100
|
+
pendingRequests++;
|
|
101
|
+
try {
|
|
102
|
+
const related = await relatedManager.getRelatedPostsWithCache(post);
|
|
103
|
+
post.semantic_related = related;
|
|
104
|
+
resolve(related);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
resolve([]);
|
|
107
|
+
} finally {
|
|
108
|
+
pendingRequests--;
|
|
109
|
+
processQueue();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
hexo.extend.filter.register('after_post_render', function(data) {
|
|
115
|
+
if (data.layout === 'post' || data.layout === 'page') {
|
|
116
|
+
return new Promise(resolve => {
|
|
117
|
+
requestQueue.push({
|
|
118
|
+
post: data,
|
|
119
|
+
resolve: () => resolve(data)
|
|
120
|
+
});
|
|
121
|
+
processQueue();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return data;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Save cache after generation
|
|
128
|
+
hexo.extend.filter.register('after_generate', function() {
|
|
129
|
+
relatedManager.saveCache();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Command: hexo semantic-search sync
|
|
134
|
+
hexo.extend.console.register('semantic-search', 'SemanticSearch commands', {
|
|
135
|
+
usage: '<command>',
|
|
136
|
+
desc: 'SemanticSearch plugin commands',
|
|
137
|
+
arguments: [
|
|
138
|
+
{ name: 'command', desc: 'sync | full-sync | status' }
|
|
139
|
+
],
|
|
140
|
+
options: [
|
|
141
|
+
{ name: '--force', desc: 'Force full sync (ignore state)' }
|
|
142
|
+
]
|
|
143
|
+
}, async function(args) {
|
|
144
|
+
const command = args._[0] || 'sync';
|
|
145
|
+
|
|
146
|
+
// Load source files first
|
|
147
|
+
await hexo.load();
|
|
148
|
+
|
|
149
|
+
switch (command) {
|
|
150
|
+
case 'sync':
|
|
151
|
+
if (args.force) {
|
|
152
|
+
hexo.log.info('[SemanticSearch] Running full sync (--force)...');
|
|
153
|
+
await syncManager.fullSync();
|
|
154
|
+
} else {
|
|
155
|
+
hexo.log.info('[SemanticSearch] Running incremental sync...');
|
|
156
|
+
await syncManager.sync();
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case 'full-sync':
|
|
161
|
+
hexo.log.info('[SemanticSearch] Running full sync...');
|
|
162
|
+
await syncManager.fullSync();
|
|
163
|
+
break;
|
|
164
|
+
|
|
165
|
+
case 'status':
|
|
166
|
+
const state = syncManager.state;
|
|
167
|
+
const tracked = state.getTrackedSlugs();
|
|
168
|
+
hexo.log.info(`[SemanticSearch] Tracked posts: ${tracked.length}`);
|
|
169
|
+
tracked.forEach(slug => {
|
|
170
|
+
const data = state.state.posts[slug];
|
|
171
|
+
hexo.log.info(` - ${slug} (synced: ${data.syncedAt})`);
|
|
172
|
+
});
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
default:
|
|
176
|
+
hexo.log.error(`[SemanticSearch] Unknown command: ${command}`);
|
|
177
|
+
hexo.log.info('Available commands: sync, full-sync, status');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
hexo.log.debug('[SemanticSearch] Plugin initialized');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve environment variable reference
|
|
187
|
+
* Supports: ${VAR_NAME} or $VAR_NAME
|
|
188
|
+
*/
|
|
189
|
+
function resolveEnvVar(value) {
|
|
190
|
+
if (!value || typeof value !== 'string') {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Match ${VAR_NAME} or $VAR_NAME
|
|
195
|
+
const envMatch = value.match(/^\$\{?([A-Z_][A-Z0-9_]*)\}?$/);
|
|
196
|
+
if (envMatch) {
|
|
197
|
+
return process.env[envMatch[1]] || value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return value;
|
|
201
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SemanticSearch API Client
|
|
5
|
+
*/
|
|
6
|
+
class SemanticSearchClient {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.endpoint = options.endpoint?.replace(/\/$/, '');
|
|
9
|
+
this.writerKey = options.writerKey;
|
|
10
|
+
this.readerKey = options.readerKey;
|
|
11
|
+
this.timeout = options.timeout || 30000;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async _request(method, path, body, useWriterKey = true) {
|
|
15
|
+
const url = `${this.endpoint}${path}`;
|
|
16
|
+
const key = useWriterKey ? this.writerKey : this.readerKey;
|
|
17
|
+
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
method,
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Authorization': `Bearer ${key}`
|
|
27
|
+
},
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
29
|
+
signal: controller.signal
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
clearTimeout(timeoutId);
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const error = await response.text();
|
|
36
|
+
throw new Error(`SemanticSearch API error: ${response.status} - ${error}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const contentType = response.headers.get('content-type');
|
|
40
|
+
if (contentType?.includes('application/json')) {
|
|
41
|
+
return await response.json();
|
|
42
|
+
}
|
|
43
|
+
return await response.text();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
clearTimeout(timeoutId);
|
|
46
|
+
if (error.name === 'AbortError') {
|
|
47
|
+
throw new Error(`SemanticSearch API timeout after ${this.timeout}ms`);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create or update a document
|
|
55
|
+
* SemanticSearch API format: POST /v1/documents with { id, text, metadata }
|
|
56
|
+
*/
|
|
57
|
+
async upsertDocument(id, document) {
|
|
58
|
+
// Get text content, use title as fallback if no content
|
|
59
|
+
const text = document.content || document.text || document.title || '';
|
|
60
|
+
|
|
61
|
+
// Skip if no meaningful text
|
|
62
|
+
if (!text || text.trim().length < 10) {
|
|
63
|
+
throw new Error('Document text is too short or empty');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Transform to SemanticSearch format
|
|
67
|
+
const payload = {
|
|
68
|
+
id,
|
|
69
|
+
text,
|
|
70
|
+
metadata: {
|
|
71
|
+
title: document.title,
|
|
72
|
+
url: document.url,
|
|
73
|
+
date: document.date,
|
|
74
|
+
updated: document.updated,
|
|
75
|
+
excerpt: document.excerpt,
|
|
76
|
+
tags: document.tags,
|
|
77
|
+
categories: document.categories
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return this._request('POST', '/v1/documents', payload);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Delete a document
|
|
85
|
+
*/
|
|
86
|
+
async deleteDocument(id) {
|
|
87
|
+
return this._request('DELETE', `/v1/documents/${encodeURIComponent(id)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get a document by ID
|
|
92
|
+
*/
|
|
93
|
+
async getDocument(id) {
|
|
94
|
+
return this._request('GET', `/v1/documents/${encodeURIComponent(id)}`, null, false);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Search documents
|
|
99
|
+
*/
|
|
100
|
+
async search(query, options = {}) {
|
|
101
|
+
return this._request('POST', '/v1/search', {
|
|
102
|
+
query,
|
|
103
|
+
limit: options.limit || 10,
|
|
104
|
+
...options
|
|
105
|
+
}, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Batch upsert documents
|
|
110
|
+
*/
|
|
111
|
+
async batchUpsert(documents) {
|
|
112
|
+
// If the API supports batch, use it; otherwise do sequentially
|
|
113
|
+
const results = [];
|
|
114
|
+
for (const doc of documents) {
|
|
115
|
+
try {
|
|
116
|
+
const result = await this.upsertDocument(doc.id, doc.data);
|
|
117
|
+
results.push({ id: doc.id, success: true, result });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
results.push({ id: doc.id, success: false, error: error.message });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Batch delete documents
|
|
127
|
+
*/
|
|
128
|
+
async batchDelete(ids) {
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const id of ids) {
|
|
131
|
+
try {
|
|
132
|
+
await this.deleteDocument(id);
|
|
133
|
+
results.push({ id, success: true });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
results.push({ id, success: false, error: error.message });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = SemanticSearchClient;
|
package/lib/helpers.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register Hexo helpers for semantic search
|
|
5
|
+
*/
|
|
6
|
+
function registerHelpers(hexo, config) {
|
|
7
|
+
const readerKey = config.reader_key || config.readerKey;
|
|
8
|
+
const endpoint = config.endpoint?.replace(/\/$/, '');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Output search config as JSON for frontend
|
|
12
|
+
*/
|
|
13
|
+
hexo.extend.helper.register('semantic_search_config', function() {
|
|
14
|
+
return JSON.stringify({
|
|
15
|
+
endpoint,
|
|
16
|
+
readerKey,
|
|
17
|
+
placeholder: config.search?.placeholder || 'Search...'
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render search box HTML
|
|
23
|
+
*/
|
|
24
|
+
hexo.extend.helper.register('semantic_search_box', function(options = {}) {
|
|
25
|
+
const id = options.id || 'semantic-search';
|
|
26
|
+
const placeholder = options.placeholder || config.search?.placeholder || 'Search...';
|
|
27
|
+
const className = options.class || 'semantic-search-box';
|
|
28
|
+
const resultClass = options.resultClass || 'semantic-search-results';
|
|
29
|
+
|
|
30
|
+
return `
|
|
31
|
+
<div class="${className}" id="${id}">
|
|
32
|
+
<input type="text" class="semantic-search-input" placeholder="${placeholder}" />
|
|
33
|
+
<div class="${resultClass}"></div>
|
|
34
|
+
</div>
|
|
35
|
+
<script>
|
|
36
|
+
window.SEMANTIC_SEARCH_CONFIG = ${JSON.stringify({ endpoint, readerKey })};
|
|
37
|
+
</script>
|
|
38
|
+
<script src="/js/semantic-search.js"></script>
|
|
39
|
+
`.trim();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render related posts for current post
|
|
44
|
+
*/
|
|
45
|
+
hexo.extend.helper.register('semantic_related_posts', function(options = {}) {
|
|
46
|
+
const post = this.post || this.page;
|
|
47
|
+
if (!post || !post.semantic_related || post.semantic_related.length === 0) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const limit = options.limit || post.semantic_related.length;
|
|
52
|
+
const className = options.class || 'semantic-related-posts';
|
|
53
|
+
const title = options.title !== undefined ? options.title : 'Related Posts';
|
|
54
|
+
const showExcerpt = options.excerpt !== false;
|
|
55
|
+
|
|
56
|
+
const items = post.semantic_related.slice(0, limit);
|
|
57
|
+
|
|
58
|
+
let html = `<div class="${className}">`;
|
|
59
|
+
|
|
60
|
+
if (title) {
|
|
61
|
+
html += `<h3 class="semantic-related-title">${title}</h3>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
html += '<ul class="semantic-related-list">';
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
html += `<li class="semantic-related-item">`;
|
|
67
|
+
html += `<a href="${item.url}" class="semantic-related-link">${item.title}</a>`;
|
|
68
|
+
if (showExcerpt && item.excerpt) {
|
|
69
|
+
const excerpt = item.excerpt.substring(0, 100) + (item.excerpt.length > 100 ? '...' : '');
|
|
70
|
+
html += `<p class="semantic-related-excerpt">${excerpt}</p>`;
|
|
71
|
+
}
|
|
72
|
+
html += `</li>`;
|
|
73
|
+
}
|
|
74
|
+
html += '</ul></div>';
|
|
75
|
+
|
|
76
|
+
return html;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if related posts exist for current post
|
|
81
|
+
*/
|
|
82
|
+
hexo.extend.helper.register('has_semantic_related', function() {
|
|
83
|
+
const post = this.post || this.page;
|
|
84
|
+
return post?.semantic_related?.length > 0;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get raw related posts data
|
|
89
|
+
*/
|
|
90
|
+
hexo.extend.helper.register('get_semantic_related', function() {
|
|
91
|
+
const post = this.post || this.page;
|
|
92
|
+
return post?.semantic_related || [];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { registerHelpers };
|