174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
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();
|