This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
/**
* 把 content/posts/ 下的 Markdown 文件导入数据库。
*
* 用途:一次性把现有的文件式内容迁移进 posts 表,
* 让数据库成为唯一内容源。
*
* 行为:
* - 幂等:按 slug upsert(存在则更新,不存在则插入)
* - 解析 YAML frontmatter 并做与 API 相同的校验
* - `draft: true` 映射为 status='draft'
* - 打印每条记录的处理结果
*
* 用法:
* node scripts/seed-posts.mjs # 导入 / 更新
* node scripts/seed-posts.mjs --dry-run # 只解析并打印,不写库
*/
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";
import pg from "pg";
import matter from "gray-matter";
import { config } from "dotenv";
config({ path: ".env.local" });
const DRY_RUN = process.argv.includes("--dry-run");
const POSTS_DIR = join(process.cwd(), "content", "posts");
if (!existsSync(POSTS_DIR)) {
console.error(`内容目录不存在: ${POSTS_DIR}`);
process.exit(1);
}
/** 估算阅读时长(与 lib/validation/post.ts 保持一致) */
function estimateReadingMinutes(content) {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g)?.length ??
0;
return Math.max(1, Math.round(chineseCharacters / 350 + latinWords / 200));
}
/** 从 frontmatter 解析出可写入数据库的记录 */
function parseFile(fileName) {
const slug = fileName.replace(/\.(mdx|md)$/i, "");
const raw = readFileSync(join(POSTS_DIR, fileName), "utf8");
const { data, content } = matter(raw);
const problems = [];
const title = typeof data.title === "string" ? data.title.trim() : "";
if (!title) problems.push("缺少 title");
let date = "";
if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) {
date = data.date.toISOString().slice(0, 10);
} else if (typeof data.date === "string" && data.date.trim()) {
date = data.date.trim();
}
if (!date || Number.isNaN(new Date(date).getTime())) {
problems.push("缺少或非法的 date");
}
const summary = typeof data.summary === "string" ? data.summary.trim() : "";
if (!summary) problems.push("缺少 summary");
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
problems.push(`slug "${slug}" 不符合 ^[a-z0-9]+(-[a-z0-9]+)*$(数据库 CHECK 约束会拒绝)`);
}
if (problems.length > 0) {
return { slug, error: problems.join("; ") };
}
return {
slug,
title,
summary,
content,
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
status: data.draft === true ? "draft" : "published",
publishedAt: new Date(`${date}T00:00:00Z`),
cover: typeof data.cover === "string" ? data.cover : null,
description:
typeof data.description === "string" ? data.description : null,
readingMinutes: estimateReadingMinutes(content),
};
}
const files = readdirSync(POSTS_DIR).filter((f) =>
[".md", ".mdx"].includes(extname(f).toLowerCase()),
);
if (files.length === 0) {
console.log("content/posts/ 下没有 Markdown 文件");
process.exit(0);
}
console.log(`发现 ${files.length} 个内容文件${DRY_RUN ? "(dry-run,不写库)" : ""}\n`);
const records = [];
for (const file of files) {
const parsed = parseFile(file);
if (parsed.error) {
console.error(`✗ ${file}: ${parsed.error}`);
process.exitCode = 1;
continue;
}
records.push(parsed);
}
if (process.exitCode === 1) {
console.error("\n存在解析失败的文件,已中止导入。");
process.exit(1);
}
if (DRY_RUN) {
for (const r of records) {
console.log(
` ${r.status === "draft" ? "[草稿]" : "[已发布]"} ${r.slug} 日期=${r.publishedAt
.toISOString()
.slice(0, 10)} 标签=${r.tags.join(",") || "-"} ${r.readingMinutes}分钟`,
);
}
process.exit(0);
}
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
? undefined
: { rejectUnauthorized: false },
});
await client.connect();
let inserted = 0;
let updated = 0;
try {
for (const r of records) {
// upsert:以 slug 为冲突键
const result = await client.query(
`insert into posts
(slug, title, summary, content, tags, status, published_at,
cover, description, reading_minutes, created_at, updated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())
on conflict (slug) do update set
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
status = excluded.status,
published_at = excluded.published_at,
cover = excluded.cover,
description = excluded.description,
reading_minutes = excluded.reading_minutes,
updated_at = now()
returning (xmax = 0) as is_insert`,
[
r.slug,
r.title,
r.summary,
r.content,
r.tags,
r.status,
r.status === "published" ? r.publishedAt : null,
r.cover,
r.description,
r.readingMinutes,
],
);
const isInsert = result.rows[0].is_insert;
if (isInsert) inserted++;
else updated++;
console.log(
` ${isInsert ? "+ 新增" : "~ 更新"} ${r.slug} (${r.status}, ${r.readingMinutes} 分钟)`,
);
}
const total = await client.query("select count(*)::int as n from posts");
console.log(`\n完成:新增 ${inserted},更新 ${updated},表内共 ${total.rows[0].n} 行`);
} catch (error) {
console.error("\n导入失败:", error.message);
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}