添加typesense搜索

文章导读 摘要由 Qwen 生成

这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。

ARTICLE SERIES

博客定制手记

第 13 / 14 篇
  1. 01 配置说说页面
  2. 02 在侧边栏中添加欢迎信息
  3. 03 自定义右键菜单
  4. 04 利用插件自定义页脚菜单
  5. 05 博客添加AI总结
  6. 06 利用 SiteMap 随机访问站内页面
  7. 07 自定义导航栏
  8. 08 自定义分类条
  9. 09 自定义页脚(新)
  10. 10 使用GitHub推送Hexo到服务器
  11. 11 在侧边栏添加日历和倒计时
  12. 12 自定义字体
  13. 13 添加typesense搜索
  14. 14 添加网站左上角菜单

注意:本文为早期 Hexo / Butterfly 魔改记录,本站现已迁移到 Astro,相关代码、路径或插件可能已经失效,仅作历史参考。

最近在构建班级博客,用ghost cms,在构建搜索时发现了typesense,所以把他移植到这个博客上。

安装typesense

直接用docker-compose:


services:
  typesense:
    image: typesense/typesense:30.1
    restart: always
    ports:
      - "8108:8108"
    volumes:
      - ./typesense-data:/data
    command: '--data-dir /data --api-key=填写key --enable-cors'

然后就是反向代理之类的,不过多写了。

添加数据集

# 先安装库
npm install hexo-generator-search
npm install typesense xml2js

然后在config.yml配置(就是把文章生成json):

search:
  path: search.json
  field: post
  content: true

创建一个数据同步脚本sync_typesense.js:

const Typesense = require('typesense');
const fs = require('fs');
const xml2js = require('xml2js');

// --- 配置区域 ---
const CONFIG = {
  apiKey: '你的Admin-API-Key', // 必须是 Admin Key
  host: '你的Typesense主机地址', 
  port: 443,
  protocol: 'https',
  collectionName: 'blogs'
};

const client = new Typesense.Client({
  'nodes': [{ 'host': CONFIG.host, 'port': CONFIG.port, 'protocol': CONFIG.protocol }],
  'apiKey': CONFIG.apiKey,
  'connectionTimeoutSeconds': 5
});

async function sync() {
  try {
    // 1. 读取并解析 XML
    const xml = fs.readFileSync('./public/search.xml', 'utf8');
    const parser = new xml2js.Parser({ explicitArray: false });
    const result = await parser.parseStringPromise(xml);
    
    // 提取文章列表 (处理单篇文章和多篇文章的情况)
    let entries = result.search.entry;
    if (!Array.isArray(entries)) entries = [entries];

    // 格式化数据以适配 Typesense
    const documents = entries.map(post => ({
      title: post.title,
      url: post.url,
      content: post.content,
      categories: post.categories ? (Array.isArray(post.categories.category) ? post.categories.category : [post.categories.category]) : [],
      tags: post.tags ? (Array.isArray(post.tags.tag) ? post.tags.tag : [post.tags.tag]) : [],
    }));

    // 2. 检查或创建 Collection (Schema)
    try {
      await client.collections(CONFIG.collectionName).retrieve();
    } catch (err) {
      const schema = {
        name: CONFIG.collectionName,
        fields: [
          { name: 'title', type: 'string', locale: 'zh'},
          { name: 'content', type: 'string', locale: 'zh'},
          { name: 'url', type: 'string' },
          { name: 'categories', type: 'string[]', facet: true },
          { name: 'tags', type: 'string[]', facet: true }
        ]
      };
      await client.collections().create(schema);
      console.log('Collection created!');
    }

    // 3. 导入数据 (使用 upsert 模式:存在则更新,不存在则创建)
    console.log(`Syncing ${documents.length} posts to Typesense...`);
    await client.collections(CONFIG.collectionName).documents().import(documents, { action: 'upsert' });
    console.log('Sync complete!');

  } catch (error) {
    console.error('Sync failed:', error);
  }
}

sync();

然后运行node sync_typesense.js

创建只读key

把下面代码存储成js,node运行就行。

const http = require('https');

const data = JSON.stringify({
  "description": "Public search only key",
  "actions": ["documents:search"],
  "collections": ["blogs"]
});

const options = {
  hostname: '', // 不要带 https://
  port: 443,
  path: '/keys',
  method: 'POST',
  headers: {
    'X-TYPESENSE-API-KEY': '你的admin key',
    'Content-Type': 'application/json',
    'Content-Length': data.length
  }
};

const req = http.request(options, res => {
  res.on('data', d => { process.stdout.write(d); });
});

req.on('error', error => { console.error(error); });
req.write(data);
req.end();

博客添加搜索

config.yamlinject bottom添加:

    - <script src="https://cdn.jsdmirror.com/npm/instantsearch.js@4.56.0"></script>
    - <script src="https://cdn.jsdmirror.com/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script>

为了方便,我直接修改了\themes\butterfly\source\js\search\local_search.js

(function () {
  'use strict';

  // ============================================================================
  // 配置区域 - 请根据实际情况修改
  // ============================================================================
  const CONFIG = {
    apiKey: "", // ⚠️ 建议使用 Search-Only API Key
    server: {
      host: "host",
      port: "443",
      protocol: "https"
    },
    indexName: "blogs",
    searchParams: {
      query_by: "title,content",
      highlight_full_fields: "title,content",
      per_page: 8,
      num_typos: 1,
      typo_tokens_threshold: 1,
      prefix: true
    },
    ui: {
      maxRetries: 10,
      retryDelay: 100,
      animationDuration: 300
    }
  };

  // ============================================================================
  // 状态管理
  // ============================================================================
  let searchInstance = null;
  let isInitialized = false;
  let isSearchOpen = false;
  let initRetryCount = 0;
  const MAX_INIT_RETRIES = 30; // 最多重试30次 (3秒)

  // ============================================================================
  // 错误提示函数
  // ============================================================================
  function showErrorMessage() {
    const hitsContainer = document.getElementById('hits');
    if (!hitsContainer) return;
    
    hitsContainer.innerHTML = 
      '<div class="ts-empty">' +
        '<div style="color: #f44336;"><i class="fas fa-exclamation-triangle" style="font-size: 3rem;"></i></div>' +
        '<div style="font-size: 1.1rem; font-weight: bold; margin: 15px 0;">搜索服务加载失败</div>' +
        '<div style="font-size: 0.9rem; color: #666; line-height: 1.8;">' +
          '<p>依赖库未能正确加载,请检查以下配置:</p>' +
          '<ol style="text-align: left; max-width: 500px; margin: 15px auto;">' +
            '<li>确认已在 <code>_config.butterfly.yml</code> 中正确引入依赖</li>' +
            '<li>检查 JS 文件加载顺序(先 instantsearch.js,再 adapter)</li>' +
            '<li>尝试更换 CDN 或使用本地文件</li>' +
            '<li>打开浏览器控制台查看详细错误信息</li>' +
          '</ol>' +
        '</div>' +
        '<div style="margin-top: 20px;">' +
          '<button onclick="location.reload()" style="padding: 10px 20px; background: #49b1f5; color: white; border: none; border-radius: 5px; cursor: pointer;">重新加载页面</button>' +
        '</div>' +
      '</div>';
  }

  // ============================================================================
  // 1. 动态插入 HTML 结构
  // ============================================================================
  const searchHTML = `
    <div id="typesense-search-mask" class="ts-mask" style="display:none;">
      <div id="typesense-search-container" class="ts-container">
        <div class="ts-header">
          <span class="ts-title">
            <i class="fas fa-search"></i> 本站搜索
          </span>
          <span id="close-typesense" class="ts-close" aria-label="关闭搜索">&times;</span>
        </div>
        <div id="searchbox"></div>
        <div id="stats" class="ts-stats"></div>
        <div id="hits" class="ts-hits"></div>
        <div id="pagination" class="ts-pagination"></div>
        <div class="ts-footer">
          <small>Search powered by <strong>Typesense</strong></small>
        </div>
      </div>
    </div>

    <style>
      .ts-mask {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.7);
        z-index: 10000;
        backdrop-filter: blur(5px);
        -webkit-backdrop-filter: blur(5px);
        opacity: 0;
        transition: opacity 300ms ease;
      }
      .ts-mask.active { opacity: 1; }
      .ts-container {
        margin: 5% auto;
        width: 90%;
        max-width: 650px;
        background: var(--search-bg, var(--card-bg, #fff));
        padding: 25px;
        border-radius: 12px;
        box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
        position: relative;
        z-index: 10001;
        transform: translateY(-50px);
        opacity: 0;
        transition: all 300ms ease;
      }
      .ts-mask.active .ts-container {
        transform: translateY(0);
        opacity: 1;
      }
      .ts-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20px;
        border-bottom: 2px solid var(--text-highlight-color, #49b1f5);
        padding-bottom: 10px;
      }
      .ts-title {
        font-size: 1.2rem;
        font-weight: bold;
        color: var(--text-highlight-color, #49b1f5);
      }
      .ts-close {
        cursor: pointer;
        font-size: 28px;
        color: var(--font-color, #333);
        line-height: 1;
        transition: color 0.2s, transform 0.2s;
      }
      .ts-close:hover {
        color: var(--text-highlight-color, #49b1f5);
        transform: scale(1.1);
      }
      .ais-SearchBox-input {
        position: relative;
        z-index: 10002;
        cursor: text;
        padding: 12px 40px 12px 15px !important;
        border-radius: 8px !important;
        border: 2px solid #eee !important;
        width: 100%;
        outline: none;
        transition: border-color 0.3s, box-shadow 0.3s;
        background: var(--card-bg, #fff);
        color: var(--font-color, #333);
        font-size: 1rem;
      }
      .ais-SearchBox-input:focus {
        border-color: var(--text-highlight-color, #49b1f5) !important;
        box-shadow: 0 0 0 3px rgba(73, 177, 245, 0.1);
      }
      .ts-stats {
        margin: 10px 0;
        font-size: 0.85rem;
        color: var(--font-color, #666);
        opacity: 0.8;
      }
      .ts-hits {
        max-height: 55vh;
        overflow-y: auto;
        margin-top: 15px;
        padding-right: 5px;
      }
      .ts-hits::-webkit-scrollbar { width: 6px; }
      .ts-hits::-webkit-scrollbar-track {
        background: var(--card-bg, #f1f1f1);
        border-radius: 10px;
      }
      .ts-hits::-webkit-scrollbar-thumb {
        background: var(--text-highlight-color, #49b1f5);
        border-radius: 10px;
      }
      .ts-empty {
        text-align: center;
        padding: 40px 20px;
        color: var(--font-color, #999);
      }
      .ts-empty i {
        font-size: 3rem;
        margin-bottom: 15px;
        opacity: 0.3;
      }
      .ts-empty code {
        background: #f5f5f5;
        padding: 2px 6px;
        border-radius: 3px;
        font-family: monospace;
        color: #e91e63;
      }
      .ts-empty ol {
        padding-left: 20px;
      }
      .ts-empty li {
        margin: 8px 0;
      }
      .ts-result-item {
        border-radius: 8px;
        transition: all 0.2s ease;
        margin-bottom: 10px;
        padding: 15px;
        border: 1px solid transparent;
        text-decoration: none;
        display: block;
        background: var(--card-bg, #fff);
      }
      .ts-result-item:hover {
        background: var(--text-bg-hover, rgba(73, 177, 245, 0.05));
        border-color: var(--text-highlight-color, #49b1f5);
        transform: translateX(5px);
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      }
      .ts-result-title {
        font-weight: bold;
        color: var(--text-highlight-color, #49b1f5);
        font-size: 1.1rem;
        margin-bottom: 8px;
        display: block;
        line-height: 1.4;
      }
      .ts-result-content {
        font-size: 0.9rem;
        color: var(--font-color, #666);
        line-height: 1.6;
        opacity: 0.85;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
      }
      .ts-result-item mark {
        background: #ffeb3b;
        color: #000;
        padding: 2px 4px;
        border-radius: 3px;
        font-weight: 500;
      }
      .ts-pagination {
        margin-top: 20px;
        display: flex;
        justify-content: center;
        gap: 5px;
      }
      .ais-Pagination-list {
        display: flex;
        list-style: none;
        padding: 0;
        margin: 0;
        gap: 5px;
      }
      .ais-Pagination-link {
        display: block;
        padding: 8px 12px;
        border: 1px solid #ddd;
        border-radius: 5px;
        color: var(--font-color, #333);
        text-decoration: none;
        transition: all 0.2s;
        background: var(--card-bg, #fff);
      }
      .ais-Pagination-link:hover {
        background: var(--text-highlight-color, #49b1f5);
        color: #fff;
      }
      .ais-Pagination-item--selected .ais-Pagination-link {
        background: var(--text-highlight-color, #49b1f5);
        color: #fff;
      }
      .ts-footer {
        text-align: right;
        margin-top: 15px;
        border-top: 1px solid var(--border-color, #eee);
        padding-top: 10px;
      }
      @media (max-width: 768px) {
        .ts-container {
          margin: 10px;
          width: calc(100% - 20px);
          padding: 20px 15px;
        }
        .ts-hits { max-height: 50vh; }
      }
      [data-theme="dark"] .ts-mask,
      .dark-mode .ts-mask {
        background: rgba(0, 0, 0, 0.85);
      }
    </style>
  `;

  document.body.insertAdjacentHTML('beforeend', searchHTML);

  const mask = document.getElementById('typesense-search-mask');
  const closeBtn = document.getElementById('close-typesense');
  const container = document.getElementById('typesense-search-container');

  // ============================================================================
  // 搜索控制
  // ============================================================================
  function openSearch() {
    if (isSearchOpen) return;
    isSearchOpen = true;
    mask.style.display = 'block';
    void mask.offsetWidth;
    mask.classList.add('active');
    document.body.style.overflow = 'hidden';

    if (!isInitialized) {
      initTypesense();
    }
    focusSearchInput();
  }

  function closeSearch() {
    if (!isSearchOpen) return;
    isSearchOpen = false;
    mask.classList.remove('active');
    setTimeout(function() {
      mask.style.display = 'none';
      document.body.style.overflow = '';
    }, CONFIG.ui.animationDuration);
  }

  function focusSearchInput(retryCount) {
    retryCount = retryCount || 0;
    const input = document.querySelector('.ais-SearchBox-input');
    if (input) {
      input.focus();
      input.select();
    } else if (retryCount < CONFIG.ui.maxRetries) {
      setTimeout(function() {
        focusSearchInput(retryCount + 1);
      }, CONFIG.ui.retryDelay);
    }
  }

  // ============================================================================
  // 事件监听
  // ============================================================================
  document.addEventListener('click', function(e) {
    if (e.target.closest('.search-typesense-trigger')) {
      e.preventDefault();
      openSearch();
    }
  });

  closeBtn.addEventListener('click', closeSearch);
  mask.addEventListener('click', function(e) {
    if (e.target === mask) closeSearch();
  });
  container.addEventListener('click', function(e) {
    e.stopPropagation();
  });
  window.addEventListener('keydown', function(e) {
    if (e.key === 'Escape' && isSearchOpen) {
      closeSearch();
    }
    if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
      e.preventDefault();
      openSearch();
    }
  });

  // ============================================================================
  // Typesense 初始化(带重试限制)
  // ============================================================================
  function initTypesense() {
    if (isInitialized || searchInstance) {
      console.warn('Typesense 搜索已初始化');
      return;
    }

    var instantsearchLoaded = typeof instantsearch !== 'undefined';
    var adapterLoaded = typeof TypesenseInstantSearchAdapter !== 'undefined' || 
                        typeof window.TypesenseInstantSearchAdapter !== 'undefined';
    
    console.log('📦 依赖库检查 (' + (initRetryCount + 1) + '/' + MAX_INIT_RETRIES + '):');
    console.log('  instantsearch.js:', instantsearchLoaded ? '✅ 已加载' : '❌ 未加载');
    console.log('  TypesenseAdapter:', adapterLoaded ? '✅ 已加载' : '❌ 未加载');
    
    if (!instantsearchLoaded || !adapterLoaded) {
      initRetryCount++;
      
      if (initRetryCount >= MAX_INIT_RETRIES) {
        console.error('');
        console.error('❌ Typesense 依赖库加载失败!');
        console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
        console.error('');
        console.error('🔧 请检查 _config.butterfly.yml 配置:');
        console.error('');
        console.error('inject:');
        console.error('  bottom:  # ⚠️ 使用 bottom 而不是 head');
        console.error('    - <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.56.0"></script>');
        console.error('    - <script src="https://cdn.jsdelivr.net/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script>');
        console.error('    - <script src="/js/typesense-search-fixed.js"></script>');
        console.error('');
        console.error('💡 或在控制台手动检查:');
        console.error('   typeof instantsearch');
        console.error('   typeof TypesenseInstantSearchAdapter');
        console.error('');
        console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
        
        showErrorMessage();
        return;
      }
      
      console.warn('⏳ 100ms 后重试...');
      setTimeout(initTypesense, 100);
      return;
    }
    
    initRetryCount = 0;
    console.log('🚀 开始初始化 Typesense...');

    try {
      const typesenseAdapter = new TypesenseInstantSearchAdapter({
        server: {
          apiKey: CONFIG.apiKey,
          nodes: [{
            host: CONFIG.server.host,
            port: CONFIG.server.port,
            protocol: CONFIG.server.protocol
          }],
          cacheSearchResultsForSeconds: 120
        },
        additionalSearchParameters: CONFIG.searchParams
      });

      searchInstance = instantsearch({
        searchClient: typesenseAdapter.searchClient,
        indexName: CONFIG.indexName,
        routing: false
      });

      searchInstance.addWidgets([
        instantsearch.widgets.searchBox({
          container: '#searchbox',
          placeholder: '输入关键词寻找故事...',
          autofocus: true,
          showReset: true,
          showSubmit: false,
          showLoadingIndicator: true
        }),
        instantsearch.widgets.stats({
          container: '#stats',
          templates: {
            text: function(data) {
              if (!data.query) return '';
              return '找到 <strong>' + data.nbHits + '</strong> 条结果 (' + data.processingTimeMS + 'ms)';
            }
          }
        }),
        instantsearch.widgets.hits({
          container: '#hits',
          templates: {
            empty: function(results) {
              return '<div class="ts-empty">' +
                '<div><i class="fas fa-search"></i></div>' +
                '<div>找不到与 "<strong>' + results.query + '</strong>" 相关的内容</div>' +
                '<div style="margin-top: 10px;">试试其他关键词吧 (´·ω·`)</div>' +
                '</div>';
            },
            item: function(hit) {
              // 使用 _highlightResult 获取高亮文本
              var titleHighlight = hit._highlightResult && hit._highlightResult.title 
                ? hit._highlightResult.title.value 
                : (hit.title || '');
              
              var contentHighlight = hit._highlightResult && hit._highlightResult.content 
                ? hit._highlightResult.content.value 
                : (hit.content || '');
              
              // 截取内容长度
              if (contentHighlight.length > 200) {
                contentHighlight = contentHighlight.substring(0, 200) + '...';
              }
              
              return '<a href="' + hit.url + '" class="ts-result-item">' +
                '<div class="ts-result-title">' + titleHighlight + '</div>' +
                '<div class="ts-result-content">' + contentHighlight + '</div>' +
                '</a>';
            }
          }
        }),
        instantsearch.widgets.pagination({
          container: '#pagination',
          padding: 2,
          showFirst: false,
          showLast: false
        })
      ]);

      searchInstance.start();
      isInitialized = true;
      
      console.log('✅ Typesense 初始化成功!');

      searchInstance.on('render', function() {
        if (isSearchOpen) {
          const input = document.querySelector('.ais-SearchBox-input');
          if (input && document.activeElement !== input) {
            input.focus();
          }
        }
      });

    } catch (error) {
      console.error('❌ 初始化失败:', error);
      showErrorMessage();
    }
  }

  // ============================================================================
  // 初始化
  // ============================================================================
  function init() {
    console.log('🔍 Typesense 搜索已准备就绪');
    console.log('💡 快捷键: Ctrl/Cmd + K');
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  // ============================================================================
  // 全局接口
  // ============================================================================
  window.TypesenseSearch = {
    open: openSearch,
    close: closeSearch,
    isOpen: function() { return isSearchOpen; },
    getInstance: function() { return searchInstance; }
  };

})();

注意

indexName: “blogs” 和 collectionName: ‘posts’ 要一致!!!

添加typesense搜索

/posts/f287c563/
作者
biss
发布于
许可协议
CC BY-NC-SA 4.0
订阅 打赏

Comments

添加typesense搜索

Ctrl + 右键:浏览器原生菜单