update 20260207
This commit is contained in:
@@ -591,7 +591,7 @@ math:
|
|||||||
search:
|
search:
|
||||||
use: local_search
|
use: local_search
|
||||||
placeholder:
|
placeholder:
|
||||||
path: search.json
|
path: search.xml
|
||||||
field: posts
|
field: posts
|
||||||
content: true
|
content: true
|
||||||
|
|
||||||
|
|||||||
720
source/_posts/2026/2026.2/add-typesense-search.md
Normal file
720
source/_posts/2026/2026.2/add-typesense-search.md
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
---
|
||||||
|
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:
|
||||||
|
tags:
|
||||||
|
---
|
||||||
|
|
||||||
|
最近在构建班级博客,用```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="关闭搜索">×</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 %}
|
||||||
110
source/_posts/2026/2026.2/install-gitea.md
Normal file
110
source/_posts/2026/2026.2/install-gitea.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
title: 安装gitea
|
||||||
|
cover: https://pic.biss.click/image/961bc881-cb0a-4ab7-ace5-9990e71c30a0.webp
|
||||||
|
categories: 技术
|
||||||
|
tags: gitea
|
||||||
|
abbrlink: 34725d47
|
||||||
|
summary: >-
|
||||||
|
这篇文章详细介绍了如何在2C2G的服务器上安装Gitea,包括获取二进制文件、创建用户和工作目录、创建系统服务、创建数据库以及安装Runner等步骤。Gitea作为一个轻量级的自助Git服务,适合用于托管网站源码。文章还提供了关于如何配置反向代理和数据库的信息,以及如何使用Docker运行Gitea
|
||||||
|
Runner以实现GitHub Actions功能。通过这些步骤,用户可以在自己的服务器上成功部署Gitea,并利用其进行版本控制和自动化工作流程。
|
||||||
|
date: 2026-02-07 06:32:04
|
||||||
|
series:
|
||||||
|
---
|
||||||
|
|
||||||
|
今天想把网站的源码转移到自建git仓,所以先来安装gitea吧(gitlab过于庞大,服务器配置不够)
|
||||||
|
PS:我的服务器为2C2G
|
||||||
|
|
||||||
|
# 安装gitea
|
||||||
|
这里用二进制文件安装
|
||||||
|
## 获取二进制文件:
|
||||||
|
```bash
|
||||||
|
wget -O gitea https://dl.gitea.com/gitea/1.25.4/gitea-1.25.4-linux-amd64
|
||||||
|
chmod +x gitea
|
||||||
|
cp gitea /usr/local/bin/gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建用户
|
||||||
|
这一步不是必须的,但是推荐这样,用root用户很容易出问题。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Ubuntu/Debian:
|
||||||
|
adduser \
|
||||||
|
--system \
|
||||||
|
--shell /bin/bash \
|
||||||
|
--gecos 'Git Version Control' \
|
||||||
|
--group \
|
||||||
|
--disabled-password \
|
||||||
|
--home /home/git \
|
||||||
|
git
|
||||||
|
|
||||||
|
# On Fedora/RHEL/CentOS:
|
||||||
|
groupadd --system git
|
||||||
|
adduser \
|
||||||
|
--system \
|
||||||
|
--shell /bin/bash \
|
||||||
|
--comment 'Git Version Control' \
|
||||||
|
--gid git \
|
||||||
|
--home-dir /home/git \
|
||||||
|
--create-home \
|
||||||
|
git
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建工作目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /var/lib/gitea/{custom,data,log}
|
||||||
|
chown -R git:git /var/lib/gitea/
|
||||||
|
chmod -R 750 /var/lib/gitea/
|
||||||
|
mkdir /etc/gitea
|
||||||
|
chown root:git /etc/gitea
|
||||||
|
chmod 770 /etc/gitea
|
||||||
|
chmod 750 /etc/gitea
|
||||||
|
chmod 640 /etc/gitea/app.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建系统服务
|
||||||
|
|
||||||
|
直接把github上面的挪过来就可以
|
||||||
|
|
||||||
|
{% link service文件,github,https://github.com/go-gitea/gitea/blob/release/v1.25/contrib/systemd/gitea.service %}
|
||||||
|
|
||||||
|
然后注册服务并启动
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable gitea
|
||||||
|
sudo systemctl start gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建数据库
|
||||||
|
可以用MySQL数据库或者PostgreSQL,创建一个数据库在web页面填写进去就行。
|
||||||
|
|
||||||
|
反向代理略过,和普通网站的反向代理配置没有什么区别。
|
||||||
|
|
||||||
|
# 安装runner
|
||||||
|
这个runner也不是必须的,是为了实现github的action功能;在2C2G服务器上我看运行的还可以,当然,只是这个hexo博客的自动构建,占用资源也少;
|
||||||
|
使用doker,这也是官方建议。以下是compose文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
ports:
|
||||||
|
- 8088:8088
|
||||||
|
environment:
|
||||||
|
- CONFIG_FILE=/config.yaml
|
||||||
|
- GITEA_INSTANCE_URL=https://git.biss.click
|
||||||
|
- GITEA_RUNNER_REGISTRATION_TOKEN= #替换成自己的token
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml
|
||||||
|
- ./data:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # 允许 Runner 调用宿主机 Docker
|
||||||
|
|
||||||
|
```
|
||||||
|
token在管理后台 工作流-运行器-新建运行器获取
|
||||||
|
config文件需要这样生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --entrypoint="" --rm -it docker.io/gitea/act_runner:latest act_runner generate-config > config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
在后台工作流运行器可以看见就没问题了。
|
||||||
76
source/_posts/2026/2026.2/remove-to-gitea.md
Normal file
76
source/_posts/2026/2026.2/remove-to-gitea.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: 将博客仓库转移到gitea
|
||||||
|
cover: https://pic.biss.click/image/f9767ecf-b8de-461b-8e62-8f7444297ea6.webp
|
||||||
|
categories: 技术
|
||||||
|
tags: gitea
|
||||||
|
abbrlink: d2c8521
|
||||||
|
summary: >-
|
||||||
|
这篇文章详细介绍了如何将博客仓库从现有的平台转移到Gitea。首先,确保已经成功安装了Gitea。接着,文章重点讲解了action文件的修改,包括检查分支、缓存项目、安装Node和Hexo、安装依赖、清理文件树、生成静态文件并压缩、部署到本地仓库以及Webhook的配置。这些步骤确保了博客能够顺利迁移到Gitea,并且保持了原有的功能和结构。需要注意的是,文章中的action文件包含了一些具体的指令和变量,如ref:
|
||||||
|
master、cache-node-modules等,这些在实际操作时需要替换为具体的值。此外,文章还提到了Webhook的配置,这是一个可选但推荐的自定义事件触发器,用于在特定事件发生时自动触发部署流程。
|
||||||
|
date: 2026-02-07 12:30:39
|
||||||
|
series:
|
||||||
|
---
|
||||||
|
|
||||||
|
在上一篇文章中已经完成了gitea的安装
|
||||||
|
那么博客源码迁移倒是没问题,直接```git remote add origin```就行,但是action文件就有些变更。
|
||||||
|
这是我修改的action文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: 自动部署
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
workflow_dispatch:
|
||||||
|
env:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 检查分支
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: master
|
||||||
|
- name: 缓存项目 npm 包
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-nodeModules-${{ hashFiles('package-lock.json') }}-${{ hashFiles('package.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-nodeModules-
|
||||||
|
- name: 安装 Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.x"
|
||||||
|
- name: 安装 Hexo
|
||||||
|
run: |
|
||||||
|
npm install hexo-cli --global
|
||||||
|
- name: 安装依赖
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
- name: 清理文件树
|
||||||
|
run: |
|
||||||
|
npm run clean
|
||||||
|
- name: 生成静态文件并压缩
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
- name: 部署
|
||||||
|
run: |
|
||||||
|
cd ./public
|
||||||
|
git init
|
||||||
|
git config user.name "${{ gitea.actor }}"
|
||||||
|
git config user.email "${{ gitea.actor }}@noreply.gitea.io"
|
||||||
|
git add .
|
||||||
|
git commit -m "${{ gitea.event.head_commit.message }}··[$(date +"%Z %Y-%m-%d %A %H:%M:%S")]"
|
||||||
|
git push --force --quiet "https://${{ gitea.actor }}:${{ secrets.DEPLOY_TOKEN }}@git.biss.click/biss/blog.git" master:page
|
||||||
|
- name: Deploy to Server
|
||||||
|
run: |
|
||||||
|
curl -k -X POST
|
||||||
|
```
|
||||||
|
仅供参考吧,最后面是webhook,可以自己改改。
|
||||||
Reference in New Issue
Block a user