feat: scaffold rednote content projects

This commit is contained in:
2026-10-04 20:06:21 +08:00
parent 46c4d06db7
commit 0690ecf050
13 changed files with 327 additions and 220 deletions
+58 -4
View File
@@ -2,10 +2,37 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
import type { ProjectMeta } from "./types";
import type { ContentProjectRecipe, ProjectMeta } from "./types";
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
const DEFAULT_RECIPE: ContentProjectRecipe = {
positioning: "分享真实、可复用的经验",
audience: "对这个主题感兴趣的小红书用户",
tone: "真诚自然",
contentFormat: "干货教程",
titleMethod: "突出具体收益,不夸张、不制造焦虑",
bodyStructure: "痛点开场 → 核心方法 → 分点说明 → 行动建议",
defaultTags: ["经验分享", "实用干货"],
};
function cleanText(value: unknown, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 2000) : fallback;
}
function normalizeRecipe(value: unknown, previous = DEFAULT_RECIPE): ContentProjectRecipe {
const input = value && typeof value === "object" ? value as Partial<ContentProjectRecipe> : {};
return {
positioning: cleanText(input.positioning, previous.positioning),
audience: cleanText(input.audience, previous.audience),
tone: cleanText(input.tone, previous.tone),
contentFormat: cleanText(input.contentFormat, previous.contentFormat),
titleMethod: cleanText(input.titleMethod, previous.titleMethod),
bodyStructure: cleanText(input.bodyStructure, previous.bodyStructure),
defaultTags: Array.isArray(input.defaultTags) ? input.defaultTags.map((item) => cleanText(item, "")).filter(Boolean).slice(0, 20) : previous.defaultTags,
};
}
export function assertProjectId(id: string) {
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
}
@@ -51,21 +78,48 @@ export async function listProjects() {
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createProject(input: { name?: string; template?: string }) {
export async function createProject(input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; template?: string }) {
const template = input.template || "basic";
const templateRoot = path.join(/* turbopackIgnore: true */ TEMPLATES_ROOT, template);
await fs.access(templateRoot);
const slug = (input.name || "新小工具").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "tool";
const slug = (input.name || "新内容项目").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "project";
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
const now = new Date().toISOString();
const meta: ProjectMeta = { id, name: input.name || "新小工具", template, revision: 1, createdAt: now, updatedAt: now, lastAccessAt: now };
const meta: ProjectMeta = {
id,
name: cleanText(input.name, "新内容项目").slice(0, 60),
description: cleanText(input.description, "用同一套方法持续生成一类小红书内容"),
recipe: normalizeRecipe(input.recipe),
template,
revision: 1,
createdAt: now,
updatedAt: now,
lastAccessAt: now,
};
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function updateProject(id: string, input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; revision?: number }) {
const meta = await readMetaFile(id);
if (input.revision !== undefined && input.revision !== meta.revision) {
const error = new Error(`Revision conflict: expected ${input.revision}, current ${meta.revision}`);
(error as Error & { status?: number }).status = 409;
throw error;
}
meta.name = cleanText(input.name, meta.name).slice(0, 60);
meta.description = cleanText(input.description, meta.description);
meta.recipe = normalizeRecipe(input.recipe, meta.recipe || DEFAULT_RECIPE);
meta.revision += 1;
meta.updatedAt = new Date().toISOString();
meta.lastAccessAt = meta.updatedAt;
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function listFiles(id: string) {
await touchProject(id);
const root = workspaceRoot(id);
+12
View File
@@ -1,6 +1,18 @@
export type ContentProjectRecipe = {
positioning: string;
audience: string;
tone: string;
contentFormat: string;
titleMethod: string;
bodyStructure: string;
defaultTags: string[];
};
export type ProjectMeta = {
id: string;
name: string;
description: string;
recipe: ContentProjectRecipe;
template: string;
revision: number;
createdAt: string;