每周不知道写什么:用一张 CSV 维护选题库和两周内容日历
把散落在备忘录和聊天记录里的想法收进一张表,用固定字段打分、查重,再让脚本排出未来两周的发布日期和写作提纲。
30 秒速查
- 新建
topics.csv:id、title、audience、pain、source、status- 每周补 3 条,别攒到写的时候才找
- 排期:
node topic-calendar.mjs topics.csv calendar.md- 只排周二、周四;相似标题进「待人工合并」
这篇教程解决什么问题
选题的问题通常不是“没有想法”,而是:
- 想法散在备忘录、收藏夹和聊天记录里,要用的时候找不到;
- 每周打开编辑器才开始想,最后随便写一个;
- 同一个话题写了两遍,自己没发现;
- 临时插进来的热点打乱节奏,计划永远停在计划阶段。
这篇用一张 CSV 维护选题库,再用一段 Node 脚本做三件事:
- 按固定字段给选题打分并排序;
- 用标题相似度提示重复项;
- 排出未来两周的发布日期和写作提纲。
脚本只负责整理和提醒,最终写什么、什么时候写,仍然由你决定。
先定义字段
字段不要多,够用就好。下面这一套覆盖了“写给谁、解决什么、从哪来、值不值得先做”:
| 字段 | 含义 | 示例 |
|---|---|---|
id |
唯一标识,建议用日期加序号 | 20261024-01 |
title |
工作标题,不要求最终标题 | 批量改尺寸为什么总失败 |
pillar |
内容方向 | 自媒体自动化 |
audience |
具体人群 | 刚开始做短视频的上班族 |
pain |
具体问题,不写“想变好” | 一条视频要手动导四个尺寸 |
hook |
开头第一句的钩子 | 你不是不会剪,是参数没配对 |
format |
形式 | 教程 / 清单 / 复盘 / 案例 |
source |
想法来源,尽量带链接 | 评论区第 3 条留言 |
demand |
需求强度,1 到 5 | 5 |
reuse |
可复用程度,1 到 5 | 4 |
timely |
时效性,1 到 5 | 2 |
cost |
制作成本,1 到 5 | 3 |
status |
idea / planned / published / rejected |
idea |
notes |
备注,写放弃原因也可以 | 需要先录素材 |
几个约定:
- 一行只放一个选题,不要把三个想法塞进一个单元格;
pain写得越具体,越容易判断值不值得写;source尽量可追溯,半年后还能知道当时为什么记下它;status只用这四个值,脚本和人工都按它筛选;- 放弃的选题改成
rejected,不要删除,避免过几周又把它当成新想法加回来。
评分:只用来排序,不用来决定命运
给四个维度打分,套一个简单公式:
score = demand × 2 + reuse × 1.5 + timely × 1 - cost × 1
权重这样分配的理由:
- 需求强度权重最高:没人问的问题,做得再精致也没人看;
- 复用性其次:能拆成多篇、能反复出现在不同平台的内容更划算;
- 时效性保持中性:追热点有价值,但不应该压过长期需求;
- 制作成本做减法:需要大量拍摄或数据的选题,先排后面。
所有维度都是 1 到 5。分数范围大约从 -0.5 到 21.5,不必追求绝对值,只看同一批选题之间的相对顺序。
评分不是科学。它的作用是在你犹豫时给一个第二意见,而不是替你承担判断责任。
准备 CSV
在表格软件里建好列,填十几条真实想法,导出为 UTF-8 CSV:
id,title,pillar,audience,pain,hook,format,source,demand,reuse,timely,cost,status,notes
20261020-01,批量改尺寸为什么总失败,自媒体自动化,刚开始做短视频的上班族,一条视频要手动导四个尺寸,你不是不会剪是参数没配对,教程,评论区第 3 条留言,5,4,2,3,idea,先录屏
20261021-01,每周复盘只看三个数,自媒体自动化,更新三个月没起色的创作者,数据太多不知道看哪个,数据不是越多越好,复盘,自己的周报,4,3,2,2,idea,
20261022-01,封面标题和内容不一致,自媒体自动化,小红书新号,点击高但完播低,骗来的点击会还回去,案例,同行账号观察,4,4,3,2,idea,
导出时注意:
- 编码选 UTF-8,不要用 Excel 默认的本地编码;
- 去掉合并单元格和多余的空行;
- 表头保持英文小写,和脚本里的字段名一致;
- 标题里如果有英文逗号,用表格软件导出的引号包裹格式,不要手工拼接。
脚本:读 CSV、打分、查重、排期
新建 topic-calendar.mjs:
import { readFile, writeFile } from "node:fs/promises";
const INPUT = process.argv[2] ?? "topics.csv";
const OUTPUT = process.argv[3] ?? "calendar.md";
const SLOTS = Number(process.env.SLOTS ?? 4);
const WEEKDAYS = [2, 4]; // 周二、周四,0 是周日
const SIMILARITY_LIMIT = 0.6;
function parseCsv(text) {
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (quoted) {
if (char === '"' && text[i + 1] === '"') {
field += '"';
i += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
} else if (char === '"') {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
const [header, ...data] = rows;
if (!header) return [];
return data
.filter((cells) => cells.some((cell) => cell.trim().length > 0))
.map((cells) =>
Object.fromEntries(
header.map((name, index) => [name.trim(), (cells[index] ?? "").trim()]),
),
);
}
function level(value) {
const number = Number(value);
if (!Number.isFinite(number)) return 1;
return Math.min(5, Math.max(1, number));
}
function score(topic) {
return (
level(topic.demand) * 2 +
level(topic.reuse) * 1.5 +
level(topic.timely) -
level(topic.cost)
);
}
function normalize(title) {
return title.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, "");
}
function bigrams(text) {
const set = new Set();
for (let i = 0; i < text.length - 1; i += 1) {
set.add(text.slice(i, i + 2));
}
return set;
}
function similarity(a, b) {
const left = bigrams(normalize(a));
const right = bigrams(normalize(b));
if (left.size === 0 || right.size === 0) return 0;
let hit = 0;
for (const token of left) {
if (right.has(token)) hit += 1;
}
return (2 * hit) / (left.size + right.size);
}
function dedupe(topics) {
const kept = [];
const dropped = [];
for (const topic of [...topics].sort((a, b) => score(b) - score(a))) {
const similar = kept.find(
(existing) => similarity(existing.title, topic.title) >= SIMILARITY_LIMIT,
);
if (similar) dropped.push({ topic, similar });
else kept.push(topic);
}
return { kept, dropped };
}
function nextDates(count, from = new Date()) {
const dates = [];
const cursor = new Date(from);
cursor.setDate(cursor.getDate() + 1);
while (dates.length < count) {
if (WEEKDAYS.includes(cursor.getDay())) {
dates.push(new Date(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return dates;
}
function pickForSlots(topics, count) {
const picked = [];
const pool = [...topics];
while (picked.length < count && pool.length > 0) {
const lastPillar = picked.at(-1)?.pillar;
let index = pool.findIndex((topic) => topic.pillar !== lastPillar);
if (index === -1) index = 0;
picked.push(pool.splice(index, 1)[0]);
}
return picked;
}
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function weekdayName(date) {
return ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][date.getDay()];
}
function renderTopic(topic, date) {
return [
`## ${formatDate(date)}(${weekdayName(date)})|${topic.title}`,
"",
`- 人群:${topic.audience || "待补"}`,
`- 痛点:${topic.pain || "待补"}`,
`- 钩子:${topic.hook || "待补"}`,
`- 形式:${topic.format || "待定"}`,
`- 来源:${topic.source || "待补"}`,
`- 评分:${score(topic).toFixed(1)}`,
`- 选题 ID:${topic.id}`,
"",
].join("\n");
}
async function main() {
const topics = parseCsv(await readFile(INPUT, "utf8"));
const active = topics.filter((topic) =>
["idea", "planned"].includes(topic.status),
);
const { kept, dropped } = dedupe(active);
const slots = pickForSlots(kept, SLOTS);
const dates = nextDates(slots.length);
const lines = [
"# 未来两周内容日历",
"",
`由 ${INPUT} 生成,共 ${topics.length} 条选题,其中可排期 ${active.length} 条。`,
"",
];
slots.forEach((topic, index) => {
lines.push(renderTopic(topic, dates[index]));
});
if (dropped.length > 0) {
lines.push("## 疑似重复,待人工合并", "");
for (const { topic, similar } of dropped) {
lines.push(
`- 「${topic.title}」与「${similar.title}」相似,已暂时跳过(ID:${topic.id})`,
);
}
lines.push("");
}
const waiting = kept.slice(slots.length);
if (waiting.length > 0) {
lines.push("## 候补选题", "");
for (const topic of waiting.slice(0, 10)) {
lines.push(`- ${topic.title}(${score(topic).toFixed(1)} 分)`);
}
lines.push("");
}
await writeFile(OUTPUT, lines.join("\n"), "utf8");
console.log(`已生成 ${OUTPUT},排期 ${slots.length} 条,候补 ${waiting.length} 条`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
运行:
node topic-calendar.mjs topics.csv calendar.md
默认排 4 条,想排两周共 6 条可以这样:
SLOTS=6 node topic-calendar.mjs topics.csv calendar.md
排期规则背后的取舍
脚本里有两个看起来很小、但很影响体验的规则。
第一,避免连续两个同方向选题。 pickForSlots 会优先选和上一条 pillar 不同的内容。读者连续看到两篇同一主题容易疲劳,你自己写起来也容易重复。
第二,疑似重复的选题不直接删除。 相似度超过 0.6 时,分数较低的那条会被跳过,但会出现在日历末尾的“待人工合并”里。标题相似不代表内容重复,可能只是角度不同;这个判断交给写作者,脚本只负责提醒。
日期按本机的周几计算。如果你在别的时区生成,发布日可能差一天,跨时区协作时把生成结果再核对一遍。
每周怎么用
把流程压缩到十分钟:
- 收集:这一周看到的评论、搜索词、客户问题,随手加进 CSV,只填标题、人群、痛点、来源。
- 补分:给新条目打四个 1 到 5 的分,拿不准就先给 3。
- 生成:跑一次脚本,看未来两周的排期和重复提示。
- 调整:手动改
status,把确定要写的改成planned,把合并掉的改成rejected并写原因。 - 复盘:发布后把状态改成
published,数据和下周判断交给每周复盘流程。
脚本不往 CSV 里写任何东西,所有状态变更都由你完成。这是故意的:自动改状态看起来省事,一旦判断错了,回头很难还原当时为什么这么排。
和复盘数据接起来
内容日历只解决“写什么”,不解决“写得怎么样”。每周复盘时,把已发布内容的播放、阅读、互动率导出来,和选题 ID 对一下,就能回答两个问题:
- 哪类痛点带来的互动最高?
- 哪种形式的制作成本和效果最匹配?
把结论写回 notes,下次评分时你会更有依据。比如连续三条“工具清单”形式的互动都一般,就可以在评分时主动把同类选题的成本调高,或者干脆降低它的优先级。
选题库是长期资产。写得越久,它越能告诉你:哪些问题被反复问到,哪些角度已经写过,哪些方向看起来热闹但没人真正需要。
下一步读什么
标题定完,用Markdown 转公众号排版生成能直接粘贴的 HTML。
常见问题
表里只有十条选题,值得搭这套流程吗? 值得。重点不是脚本,而是固定字段和固定回顾节奏。十条也能帮你发现自己一直在重复同一个话题。
要不要把最终标题也放进去? 不需要。title 是工作标题,最终标题在写作时再定。过早定标题容易把内容锁死在标题上。
热点怎么处理? 单独加一个 timely 为 5 的条目,排到最近的空档。不要让热点频繁插队,否则两周计划永远执行不完。
脚本能直接调用 AI 生成内容吗? 可以生成候选,但不要直接发布。没有真实来源和具体人群的内容,很难建立信任。这个流程的价值恰恰在于把真实需求放在第一位。
常见问题
用 CSV 还是直接用表格软件?
日常编辑用表格软件,只有跑脚本时才导出成 UTF-8 的 CSV。这样既保留了筛选和拖拽的便利,也让脚本有一个稳定的输入格式。不要把在线表格当成唯一存储,定期导出一份到本地。
评分真的能决定写什么吗?
不能。评分只是把“哪个更值得先做”变成一个可比较的数字,避免完全凭当天心情。最终仍然由你判断:有些高分选题可能不适合现在的账号阶段,有些低分但你特别想写的反而应该做。
多久维护一次选题库?
每周十到二十分钟,和复盘放在一起。补几条新想法,更新已发布内容的状态,合并重复项。不要攒到要写的时候才临时翻找,那样又会回到靠灵感的状态。
能不能让 AI 自动生成选题?
可以让它列候选,但必须由你筛选和改写。没有具体人群、真实痛点和来源的选题,写出来很容易是空话。这个流程更鼓励你从评论、搜索词和客户问题里收集真实需求。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。