跳到正文
跟我做
自媒体自动化6 分钟Node.js

每周不知道写什么:用一张 CSV 维护选题库和两周内容日历

把散落在备忘录和聊天记录里的想法收进一张表,用固定字段打分、查重,再让脚本排出未来两周的发布日期和写作提纲。

约 6 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 先定义字段
  3. 评分:只用来排序,不用来决定命运
  4. 准备 CSV
  5. 脚本:读 CSV、打分、查重、排期
  6. 排期规则背后的取舍
  7. 每周怎么用
  8. 和复盘数据接起来
  9. 下一步读什么
  10. 常见问题

30 秒速查

  • 新建 topics.csv:id、title、audience、pain、source、status
  • 每周补 3 条,别攒到写的时候才找
  • 排期:node topic-calendar.mjs topics.csv calendar.md
  • 只排周二、周四;相似标题进「待人工合并」

这篇教程解决什么问题

选题的问题通常不是“没有想法”,而是:

  • 想法散在备忘录、收藏夹和聊天记录里,要用的时候找不到;
  • 每周打开编辑器才开始想,最后随便写一个;
  • 同一个话题写了两遍,自己没发现;
  • 临时插进来的热点打乱节奏,计划永远停在计划阶段。

这篇用一张 CSV 维护选题库,再用一段 Node 脚本做三件事:

  1. 按固定字段给选题打分并排序;
  2. 用标题相似度提示重复项;
  3. 排出未来两周的发布日期和写作提纲。

脚本只负责整理和提醒,最终写什么、什么时候写,仍然由你决定。

先定义字段

字段不要多,够用就好。下面这一套覆盖了“写给谁、解决什么、从哪来、值不值得先做”:

字段 含义 示例
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 时,分数较低的那条会被跳过,但会出现在日历末尾的“待人工合并”里。标题相似不代表内容重复,可能只是角度不同;这个判断交给写作者,脚本只负责提醒。

日期按本机的周几计算。如果你在别的时区生成,发布日可能差一天,跨时区协作时把生成结果再核对一遍。

每周怎么用

把流程压缩到十分钟:

  1. 收集:这一周看到的评论、搜索词、客户问题,随手加进 CSV,只填标题、人群、痛点、来源。
  2. 补分:给新条目打四个 1 到 5 的分,拿不准就先给 3。
  3. 生成:跑一次脚本,看未来两周的排期和重复提示。
  4. 调整:手动改 status,把确定要写的改成 planned,把合并掉的改成 rejected 并写原因。
  5. 复盘:发布后把状态改成 published,数据和下周判断交给每周复盘流程。

脚本不往 CSV 里写任何东西,所有状态变更都由你完成。这是故意的:自动改状态看起来省事,一旦判断错了,回头很难还原当时为什么这么排。

和复盘数据接起来

内容日历只解决“写什么”,不解决“写得怎么样”。每周复盘时,把已发布内容的播放、阅读、互动率导出来,和选题 ID 对一下,就能回答两个问题:

  • 哪类痛点带来的互动最高?
  • 哪种形式的制作成本和效果最匹配?

把结论写回 notes,下次评分时你会更有依据。比如连续三条“工具清单”形式的互动都一般,就可以在评分时主动把同类选题的成本调高,或者干脆降低它的优先级。

选题库是长期资产。写得越久,它越能告诉你:哪些问题被反复问到,哪些角度已经写过,哪些方向看起来热闹但没人真正需要。

下一步读什么

标题定完,用Markdown 转公众号排版生成能直接粘贴的 HTML。

常见问题

表里只有十条选题,值得搭这套流程吗? 值得。重点不是脚本,而是固定字段和固定回顾节奏。十条也能帮你发现自己一直在重复同一个话题。

要不要把最终标题也放进去? 不需要。title 是工作标题,最终标题在写作时再定。过早定标题容易把内容锁死在标题上。

热点怎么处理? 单独加一个 timely 为 5 的条目,排到最近的空档。不要让热点频繁插队,否则两周计划永远执行不完。

脚本能直接调用 AI 生成内容吗? 可以生成候选,但不要直接发布。没有真实来源和具体人群的内容,很难建立信任。这个流程的价值恰恰在于把真实需求放在第一位。

常见问题

用 CSV 还是直接用表格软件?

日常编辑用表格软件,只有跑脚本时才导出成 UTF-8 的 CSV。这样既保留了筛选和拖拽的便利,也让脚本有一个稳定的输入格式。不要把在线表格当成唯一存储,定期导出一份到本地。

评分真的能决定写什么吗?

不能。评分只是把“哪个更值得先做”变成一个可比较的数字,避免完全凭当天心情。最终仍然由你判断:有些高分选题可能不适合现在的账号阶段,有些低分但你特别想写的反而应该做。

多久维护一次选题库?

每周十到二十分钟,和复盘放在一起。补几条新想法,更新已发布内容的状态,合并重复项。不要攒到要写的时候才临时翻找,那样又会回到靠灵感的状态。

能不能让 AI 自动生成选题?

可以让它列候选,但必须由你筛选和改写。没有具体人群、真实痛点和来源的选题,写出来很容易是空话。这个流程更鼓励你从评论、搜索词和客户问题里收集真实需求。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程