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();