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

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

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

# 安装typesense

直接用```docker-compose```:

```yaml

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'

```

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

# 添加数据集

```bash
# 先安装库
npm install hexo-generator-search
npm install typesense xml2js
```
然后在```config.yml```配置（就是把文章生成json）：

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

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

```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运行就行。
```js
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.yaml```inject bottom添加：

```yaml
    - <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```

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

})();
```

# 注意

{% note red %}
indexName: "blogs" 和 collectionName: 'posts' 要一致！！！
{% endnote %}