添加搜索
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { articles } from "../src/data/articles";
|
||||
import { people } from "../src/data/people";
|
||||
import { photoTopics } from "../src/data/photos";
|
||||
import { timeline } from "../src/data/timeline";
|
||||
|
||||
interface SearchDocument {
|
||||
id: string;
|
||||
type: "时间线" | "照片" | "文章" | "同学";
|
||||
title: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
year?: number;
|
||||
timestamp?: number;
|
||||
url: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const collectionName = process.env.PUBLIC_TYPESENSE_COLLECTION || "class_memories";
|
||||
const host = process.env.PUBLIC_TYPESENSE_HOST?.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const port = process.env.PUBLIC_TYPESENSE_PORT || "443";
|
||||
const protocol = process.env.PUBLIC_TYPESENSE_PROTOCOL || "https";
|
||||
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
|
||||
|
||||
if (!host || !adminKey) {
|
||||
throw new Error(
|
||||
"缺少 Typesense 配置。请设置 PUBLIC_TYPESENSE_HOST 和 TYPESENSE_ADMIN_API_KEY。"
|
||||
);
|
||||
}
|
||||
|
||||
const origin = `${protocol}://${host}${
|
||||
(protocol === "https" && port === "443") || (protocol === "http" && port === "80")
|
||||
? ""
|
||||
: `:${port}`
|
||||
}`;
|
||||
|
||||
const schema = {
|
||||
name: collectionName,
|
||||
fields: [
|
||||
{ name: "title", type: "string", locale: "zh" },
|
||||
{ name: "content", type: "string", locale: "zh" },
|
||||
{ name: "type", type: "string", facet: true },
|
||||
{ name: "category", type: "string", facet: true, optional: true },
|
||||
{ name: "tags", type: "string[]", facet: true, optional: true },
|
||||
{ name: "year", type: "int32", facet: true, optional: true },
|
||||
{ name: "timestamp", type: "int64", optional: true },
|
||||
{ name: "url", type: "string", index: false },
|
||||
{ name: "image", type: "string", index: false, optional: true }
|
||||
]
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
const getYear = (value: string) => Number(value.match(/\d{4}/)?.[0]) || undefined;
|
||||
|
||||
const toTimestamp = (value: string) => {
|
||||
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
|
||||
return Number.isNaN(timestamp) ? undefined : Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const documents: SearchDocument[] = [
|
||||
...timeline.map((moment, index) => ({
|
||||
id: `timeline-${index + 1}-${slugify(moment.title)}`,
|
||||
type: "时间线" as const,
|
||||
title: moment.title,
|
||||
content: `${moment.date} ${moment.text}`,
|
||||
category: "高中三年",
|
||||
tags: ["时间线", moment.title],
|
||||
year: getYear(moment.date),
|
||||
url: "/timeline/"
|
||||
})),
|
||||
...photoTopics.flatMap((topic) =>
|
||||
topic.photos.map((photo, index) => ({
|
||||
id: `photo-${topic.slug}-${index + 1}`,
|
||||
type: "照片" as const,
|
||||
title: photo.title,
|
||||
content: `${topic.title} ${photo.caption}`,
|
||||
category: topic.title,
|
||||
tags: ["照片", topic.title],
|
||||
url: `/gallery/${topic.slug}/`,
|
||||
image: photo.image
|
||||
}))
|
||||
),
|
||||
...articles.map((article, index) => ({
|
||||
id: `article-${index + 1}-${slugify(article.fileName)}`,
|
||||
type: "文章" as const,
|
||||
title: article.title,
|
||||
content: article.excerpt,
|
||||
category: article.category,
|
||||
tags: ["公众号", article.category],
|
||||
year: getYear(article.date),
|
||||
timestamp: toTimestamp(article.date),
|
||||
url: article.sourceUrl,
|
||||
image: article.cover
|
||||
})),
|
||||
...people.map((person) => ({
|
||||
id: `person-${person.slug}`,
|
||||
type: "同学" as const,
|
||||
title: person.name,
|
||||
content: [person.location, person.school, person.direction, person.text, person.currentStatus]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
category: person.direction,
|
||||
tags: person.keywords,
|
||||
url: `/people/${person.slug}/`,
|
||||
image: person.photo || undefined
|
||||
}))
|
||||
];
|
||||
|
||||
const request = (path: string, init: RequestInit = {}) =>
|
||||
fetch(`${origin}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"X-TYPESENSE-API-KEY": adminKey,
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
|
||||
const ensureCollection = async () => {
|
||||
const response = await request(`/collections/${encodeURIComponent(collectionName)}`);
|
||||
if (response.ok) return;
|
||||
if (response.status !== 404) {
|
||||
throw new Error(`读取集合失败:${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const createResponse = await request("/collections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(schema)
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`创建集合失败:${createResponse.status} ${await createResponse.text()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
await ensureCollection();
|
||||
|
||||
const payload = documents.map((document) => JSON.stringify(document)).join("\n");
|
||||
const response = await request(
|
||||
`/collections/${encodeURIComponent(collectionName)}/documents/import?action=upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: payload
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`导入失败:${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const results = (await response.text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { success: boolean; error?: string; document?: string });
|
||||
const failures = results.filter((result) => !result.success);
|
||||
|
||||
if (failures.length > 0) {
|
||||
failures.slice(0, 10).forEach((failure) => console.error(failure.error, failure.document));
|
||||
throw new Error(`${failures.length} 条搜索文档同步失败。`);
|
||||
}
|
||||
|
||||
console.log(`已同步 ${results.length} 条文档到 Typesense 集合 ${collectionName}。`);
|
||||
};
|
||||
|
||||
await sync();
|
||||
Reference in New Issue
Block a user