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/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 };