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

每周 10 分钟复盘:把三个平台后台导出的 CSV 拼成一张周报

不接 API、不装 BI。用一段 Node 脚本读小红书、公众号、抖音后台导出的 CSV,统一字段、算出互动率、标出异常,直接输出一份能看的 Markdown 周报。

约 9 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 先定三个指标
  3. 从后台导出 CSV
  4. 目录结构
  5. 用配置消化字段差异
  6. 完整脚本
  7. 解析 CSV:别用 split(“,”)
  8. 归一化:先变成同一种记录
  9. 用中位数找真正的异常
  10. 跑出周报
  11. 怎么读这份周报
  12. 先看平台,再看单条
  13. 高互动不等于可复制
  14. 低互动分两种
  15. 进阶方向
  16. 常见坑
  17. 1. 列名改了,脚本没报错
  18. 2. 曝光为 0 的内容参与排名
  19. 3. 把导出文件提交进仓库
  20. 4. 复盘变成看数字
  21. 下一步读什么

30 秒速查

  • 三个平台各导出一份 CSV,放进 sample/
  • 在 config.json 里把平台列名映射到曝光、互动
  • 跑 node review.mjs,看中位数和异常
  • 把下周动作写成具体条目,下周照着做

这篇教程解决什么问题

内容做了几周,数据散在三个后台:小红书看曝光和收藏,公众号看阅读和在看,抖音看播放和完播。每周想复盘一次,光是把数字抄进表格就要半小时,抄完还看不出结论。

更常见的情况是:看到一个高赞就兴奋,看到一个低播放就焦虑,但没有中位数做参照,不知道哪个才算真正的异常。

这篇的做法是:每个平台导出 CSV,脚本负责统一字段和算指标,你只负责解释异常和决定下周写什么。产物是一份 Markdown 周报和一份 JSON,后者可以直接积累成月度趋势。

先定三个指标

不要一上来就做十几列的看板。第一版只看三个数:

指标 算法 用来回答
曝光 各平台曝光/播放/阅读 内容有没有被分发出去
互动 点赞 + 评论 + 收藏 + 分享 看到的人有没有反应
互动率 互动 ÷ 曝光 内容本身留不留人

互动率比绝对互动数更有用,因为它把平台流量波动排除了一部分。但要注意两个边界:

  • 曝光太低时互动率不稳定。 300 曝光里多 10 个赞就能改变排序,所以脚本只对曝光 ≥ 100 的内容计算排名。
  • 平台口径不同。 抖音的“播放”和小红书的“曝光”不是一回事,公众号的“阅读”还包含历史文章入口。跨平台只看趋势,不要精确比大小。
合并后的 14 行总表,列有平台、标题、曝光/播放/阅读、互动、互动率
① 来源列 ② 这一列口径不同:播放 / 曝光 / 阅读

从后台导出 CSV

三个平台都有导出入口,位置会随版本调整,按后台实际菜单找即可:

  • 小红书:创作中心 → 数据中心 → 内容分析,选择时间范围后导出。
  • 公众号:后台 → 内容分析 → 图文分析,按文章导出或复制表格。
  • 抖音:创作者服务中心 → 数据中心 → 作品数据,支持导出明细。
三个 CSV 文件卡片:小红书 6 行 572 字节、公众号 4 行 420 字节、抖音 4 行 345 字节
① 三个平台各导一份 ② 行数加起来 = 14

导出的列名每个平台都不一样,这正是最容易卡住的地方。不要手工改表头,把差异写进配置,以后换账号也不用重来。

目录结构

content-review/
├── config.json
├── review.mjs
├── sample/
│   ├── xiaohongshu-2026-10.csv
│   ├── wechat-2026-10.csv
│   └── douyin-2026-10.csv
└── out/            # 生成结果,加进 .gitignore

完整可运行版本在 scripts/content-review/。先把 sample/ 换成你自己的导出文件,跑通后再改配置。

用配置消化字段差异

config.json 做两件事:告诉脚本去哪个目录读文件,以及每个平台的列名映射到哪个指标。

{
  "inputDir": "sample",
  "outputDir": "out",
  "platforms": {
    "xiaohongshu": {
      "label": "小红书",
      "columns": {
        "title": "笔记标题",
        "date": "发布时间",
        "views": "曝光量",
        "likes": "点赞",
        "comments": "评论",
        "collects": "收藏",
        "shares": "分享"
      }
    },
    "wechat": {
      "label": "公众号",
      "columns": {
        "title": "标题",
        "date": "发表时间",
        "views": "阅读量",
        "likes": "点赞",
        "comments": "留言",
        "collects": "在看",
        "shares": "转发"
      }
    }
  }
}

平台 key 要和文件名前缀一致,比如 xiaohongshu-2026-10.csv。脚本先按文件名识别平台,识别不到再尝试用表头匹配,这样文件改名后不至于直接失败。

完整脚本

review.mjs 的全文如下——只用 Node 内置模块,不需要装任何依赖:

import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

const ROOT = import.meta.dirname;

// 平台后台导出的 CSV 常常带 BOM、CRLF 和引号包裹的逗号,先统一处理再按行切。
export function parseCsv(text) {
  const source = text.replace(/^/, "").replaceAll("\r\n", "\n");
  const rows = [];
  let row = [];
  let field = "";
  let quoted = false;

  for (let i = 0; i < source.length; i += 1) {
    const char = source[i];

    if (quoted) {
      if (char === '"' && source[i + 1] === '"') {
        field += '"';
        i += 1;
      } else if (char === '"') {
        quoted = false;
      } else {
        field += char;
      }
      continue;
    }

    if (char === '"') {
      quoted = true;
    } else if (char === ",") {
      row.push(field);
      field = "";
    } else if (char === "\n") {
      row.push(field);
      rows.push(row);
      row = [];
      field = "";
    } else {
      field += char;
    }
  }

  if (field !== "" || row.length > 0) {
    row.push(field);
    rows.push(row);
  }

  return rows.filter((line) => line.some((cell) => cell.trim() !== ""));
}

export function toNumber(value) {
  const number = Number(String(value ?? "").replaceAll(",", "").trim());
  return Number.isFinite(number) ? number : 0;
}

export function parseDate(value) {
  const text = String(value ?? "").trim().replaceAll("/", "-");
  const date = new Date(text.replace(" ", "T"));
  return Number.isNaN(date.valueOf()) ? null : date;
}

export function median(numbers) {
  if (numbers.length === 0) return 0;
  const sorted = [...numbers].sort((a, b) => a - b);
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0
    ? (sorted[middle - 1] + sorted[middle]) / 2
    : sorted[middle];
}

export function percent(value) {
  return `${(value * 100).toFixed(2)}%`;
}

function pad(value) {
  return String(value).padStart(2, "0");
}

export function formatDate(date) {
  return date ? `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` : "未知";
}

export function detectPlatform(filename, header, config) {
  const byName = Object.entries(config.platforms).find(([key]) => filename.startsWith(key));
  if (byName) return byName[0];

  const byColumn = Object.entries(config.platforms).find(([, platform]) =>
    header.includes(platform.columns.title),
  );
  return byColumn?.[0] ?? null;
}

export function normalizeRow(row, header, platformKey, config) {
  const platform = config.platforms[platformKey];
  const pick = (column) => {
    const index = header.indexOf(column);
    return index === -1 ? "" : row[index];
  };

  const views = toNumber(pick(platform.columns.views));
  const likes = toNumber(pick(platform.columns.likes));
  const comments = toNumber(pick(platform.columns.comments));
  const collects = toNumber(pick(platform.columns.collects));
  const shares = toNumber(pick(platform.columns.shares));
  const engagements = likes + comments + collects + shares;

  return {
    platform: platform.label,
    title: String(pick(platform.columns.title)).trim(),
    date: parseDate(pick(platform.columns.date)),
    views,
    likes,
    comments,
    collects,
    shares,
    engagements,
    rate: views > 0 ? engagements / views : 0,
  };
}

export async function loadRows(inputDir, config) {
  const files = (await readdir(inputDir)).filter((file) => file.toLowerCase().endsWith(".csv"));
  const posts = [];
  const skipped = [];

  for (const file of files.sort()) {
    const rows = parseCsv(await readFile(path.join(inputDir, file), "utf8"));
    if (rows.length < 2) {
      skipped.push({ file, reason: "没有数据行" });
      continue;
    }

    const [header, ...body] = rows;
    const platformKey = detectPlatform(file, header, config);

    if (!platformKey) {
      skipped.push({ file, reason: "无法识别平台,请检查文件名或表头" });
      continue;
    }

    for (const row of body) {
      const post = normalizeRow(row, header, platformKey, config);
      if (post.title) posts.push(post);
    }
  }

  return { posts, skipped };
}

export function summarize(posts) {
  return {
    count: posts.length,
    views: posts.reduce((sum, post) => sum + post.views, 0),
    engagements: posts.reduce((sum, post) => sum + post.engagements, 0),
    rate:
      posts.reduce((sum, post) => sum + post.views, 0) > 0
        ? posts.reduce((sum, post) => sum + post.engagements, 0) /
          posts.reduce((sum, post) => sum + post.views, 0)
        : 0,
  };
}

export function buildMarkdown({ posts, skipped, range }) {
  const platforms = [...new Set(posts.map((post) => post.platform))];
  const overall = summarize(posts);
  const qualified = posts.filter((post) => post.views >= 100);
  const medianRate = median(qualified.map((post) => post.rate));
  const ranked = [...qualified].sort((a, b) => b.rate - a.rate);
  const best = ranked.slice(0, 3);
  const worst = ranked.slice(-3).reverse();
  const high = qualified.filter((post) => medianRate > 0 && post.rate >= medianRate * 1.8);
  const low = qualified.filter((post) => medianRate > 0 && post.rate <= medianRate * 0.5);

  const platformTable = [
    "| 平台 | 条数 | 曝光 | 互动 | 互动率 |",
    "| --- | ---: | ---: | ---: | ---: |",
    ...platforms.map((platform) => {
      const stats = summarize(posts.filter((post) => post.platform === platform));
      return `| ${platform} | ${stats.count} | ${stats.views} | ${stats.engagements} | ${percent(stats.rate)} |`;
    }),
    `| **合计** | ${overall.count} | ${overall.views} | ${overall.engagements} | ${percent(overall.rate)} |`,
  ].join("\n");

  const rankLines = (items) =>
    items.length === 0
      ? `暂无(有效样本不足 100 曝光)`
      : items
          .map(
            (post, index) =>
              `${index + 1}. **${post.title}**(${post.platform})— ${post.views} 曝光 / ${post.engagements} 互动 / ${percent(post.rate)}`,
          )
          .join("\n");

  const anomalies = [
    ...high.map((post) => `- 高于中位数 1.8 倍:**${post.title}**(${post.platform},${percent(post.rate)})`),
    ...low.map((post) => `- 低于中位数一半:**${post.title}**(${post.platform},${percent(post.rate)})`),
  ];

  const actions = [];
  if (best.length > 0) {
    actions.push(
      `把《${best[0].title}》拆成 2–3 个变体,下周同一时段再发一次,验证是选题有效还是时间有效。`,
    );
  }
  if (worst.length > 0) {
    actions.push(
      `回看《${worst[0].title}》的封面和前 3 秒:曝光有 ${worst[0].views},互动率只有 ${percent(worst[0].rate)},优先改钩子而不是改选题。`,
    );
  }
  if (platforms.length > 1 && ranked.length > 0) {
    const others = platforms.filter((platform) => platform !== best[0].platform);
    actions.push(
      `把表现最好的内容改写成 ${others.join("、")} 平台的尺寸和语气,复用素材而不是重新拍。`,
    );
  }
  if (platforms.length === 1) {
    actions.push("再接一个平台的数据:单平台看不出内容结构问题,只能看出平台问题。");
  }
  if (skipped.length > 0) {
    actions.push(`有 ${skipped.length} 个文件没读进来,先修字段映射,避免周报漏数据。`);
  }
  actions.push("从上面两条异常里各挑一条,写下你判断的原因,下周用一条新内容验证。");

  return `# 内容周报(${range})

> 共 ${overall.count} 条内容,覆盖 ${platforms.length} 个平台;曝光 ${overall.views},互动 ${overall.engagements},整体互动率 ${percent(overall.rate)}。

## 总览

${platformTable}

## 表现最好

${rankLines(best)}

## 需要复盘

${rankLines(worst)}

## 异常提醒

中位数互动率:${percent(medianRate)}(仅统计曝光 ≥ 100 的内容)

${anomalies.length > 0 ? anomalies.join("\n") : "本周没有明显异常。"}

## 下周动作

${actions.map((action) => `- ${action}`).join("\n")}
`;
}

async function main() {
  const configPath = path.resolve(ROOT, process.argv[2] ?? "config.json");
  const config = JSON.parse(await readFile(configPath, "utf8"));
  const inputDir = path.resolve(ROOT, config.inputDir);
  const outputDir = path.resolve(ROOT, config.outputDir);

  const { posts, skipped } = await loadRows(inputDir, config);

  if (posts.length === 0) {
    console.error(`没有从 ${inputDir} 读到任何内容,请检查 CSV 和 config.json 的字段映射。`);
    process.exit(1);
  }

  const dates = posts.map((post) => post.date).filter(Boolean).sort((a, b) => a - b);
  const range =
    dates.length > 0
      ? `${formatDate(dates[0])} ~ ${formatDate(dates.at(-1))}`
      : "日期缺失";
  const markdown = buildMarkdown({ posts, skipped, range });

  await mkdir(outputDir, { recursive: true });
  await writeFile(path.join(outputDir, "report.md"), markdown);
  await writeFile(
    path.join(outputDir, "report.json"),
    JSON.stringify(
      {
        range,
        generatedAt: new Date().toISOString(),
        posts: posts.map((post) => ({ ...post, date: formatDate(post.date) })),
        skipped,
      },
      null,
      2,
    ),
  );

  console.log(`已生成 ${path.relative(ROOT, path.join(outputDir, "report.md"))}`);
  console.log(`已生成 ${path.relative(ROOT, path.join(outputDir, "report.json"))}`);
  if (skipped.length > 0) {
    console.log(`跳过 ${skipped.length} 个文件:`);
    for (const item of skipped) console.log(`- ${item.file}: ${item.reason}`);
  }
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
  await main();
}

它按顺序做四件事:读配置 → 找到每个平台的文件并识别平台 → 解析 CSV、归一化成统一字段 → 算中位数、标异常、写出 Markdown 和 JSON。

解析 CSV:别用 split(“,”)

后台导出经常出现下面几种情况,一行 split(",") 就会全错:

  • 文件开头带 BOM。
  • 换行是 CRLF。
  • 标题里本身有逗号,被双引号包起来。
  • 引号内的引号写成两个双引号转义。

所以脚本里有个最小的状态机解析器 parseCsv:逐字符扫描,遇到双引号就进入“引号内”模式,两个连续的双引号还原成一个,引号外的逗号才算分隔符。

引号内的换行也能保留:处于引号内时所有字符都直接写进字段,只有遇到不成对的引号才退出,所以标题跨行不会把一行拆成两行。

归一化:先变成同一种记录

不同平台的原始行读进来后,normalizeRow 把它们统一成同一种对象:曝光、互动、日期,字段名从此不再带平台方言。

数字字段要处理千分位和空值。后台导出偶尔会写成 1,024 或直接留空,Number("1,024") 会得到 NaN,所以 toNumber 先去掉逗号,再兜底成 0。

日期同理:有的导出是 2026/10/13,有的是 2026-10-13 19:20,统一转成 Date,失败就记为 null,不要让一条脏数据把整份周报搞崩。

用中位数找真正的异常

平均互动率会被一条爆款拉高,所以脚本用中位数做基准(median),并且只统计曝光 ≥ 100 的内容。

1.8 倍和 0.5 倍不是标准答案,只是第一版阈值。跑几周后你会知道自己账号的正常波动范围,再按实际分布调整。重点不是阈值多精确,而是每次都用同一套规则,这样异常才可比。

跑出周报

node review.mjs

样例数据跑出来的结果大致是这样:

# 内容周报(2026-10-13 ~ 2026-10-19)

> 共 14 条内容,覆盖 3 个平台;曝光 125710,互动 12368,整体互动率 9.84%。

| 平台 | 条数 | 曝光 | 互动 | 互动率 |
| --- | ---: | ---: | ---: | ---: |
| 抖音 | 4 | 53900 | 5126 | 9.51% |
| 公众号 | 4 | 12610 | 843 | 6.69% |
| 小红书 | 6 | 59200 | 6399 | 10.81% |
| **合计** | 14 | 125710 | 12368 | 9.84% |

## 异常提醒

中位数互动率:7.52%(仅统计曝光 ≥ 100 的内容)

- 高于中位数 1.8 倍:**批量出封面后被限流?先检查这 4 点**(小红书,13.84%)

## 下周动作

- 把《批量出封面后被限流?先检查这 4 点》拆成 2–3 个变体,下周同一时段再发一次,验证是选题有效还是时间有效。
- 回看《复盘不是看数据,是决定下周写什么》的封面和前 3 秒:曝光有 1980,互动率只有 5.20%,优先改钩子而不是改选题。

脚本还会输出 out/report.json,里面保留每条内容的原始指标。把它按周存下来,以后想做月度趋势时不用重新导出。

内容周报总览表:抖音 4 条、公众号 4 条、小红书 6 条,合计 14 条,整体互动率 9.84%
① 一张表看完(本机真跑,14 条汇总)

怎么读这份周报

先看平台,再看单条

如果小红书的互动率长期是公众号的两倍,问题可能不在内容,而在平台匹配度。先决定主战场,再优化单条内容,顺序反了会一直做无效努力。

高互动不等于可复制

一条内容互动率高,可能是选题好、封面好、发布时间好,也可能只是碰上了平台推荐。周报能指出“这条异常”,但不能告诉你原因。原因要你自己写下来,下一周用一条新内容验证。

低互动分两种

  • 曝光高、互动低:分发没问题,钩子或内容不匹配。优先改开头。
  • 曝光低、互动率正常:内容没问题,分发不够。优先改标题、封面和发布时间。

这两种情况的动作完全相反,所以不要把“数据差”当成一个结论。

进阶方向

第一版跑顺后,再考虑这些:

  • 周对比:保留每周的 report.json,下周一并读取,输出“本周 vs 上周”。
  • 发布时段:按 date 的小时分组,看你的账号在什么时间互动率更高。
  • 选题标签:在配置里加一列人工标签(教程、复盘、工具),看哪类内容稳定。
  • 自动发布:把 Markdown 转成飞书文档或公众号草稿,省掉复制粘贴。

常见坑

1. 列名改了,脚本没报错

脚本只会跳过完全读不到标题的行。如果某个平台的列名变了,可能出现大量空标题被丢弃。跑完先看控制台有没有“跳过 N 个文件”,再打开 report.json 抽查几条。

2. 曝光为 0 的内容参与排名

第一版已经用 views >= 100 过滤。如果你的账号刚起步,把阈值降到 30 或 50,但要保证之后一直用同一个值。

3. 把导出文件提交进仓库

导出文件可能包含账号名、作品 ID 和流量来源。把 sample/ 换成真实数据前,先把输入目录加进 .gitignore;仓库里只留脱敏样例。

4. 复盘变成看数字

周报的价值不在表格,而在最后那段“下周动作”。如果一周过去没有执行任何一条动作,这份周报就只是一次数据搬运。

下一步读什么

多个平台的表先合并:两个表格合并。

表格本身乱码,先跑表格乱码怎么解决。

常见问题

为什么不直接看平台后台的数据看板?

单平台看板只能回答“这条好不好”,回答不了“哪个平台值得多投入”“同一选题在三个平台差多少”。把数据拉到一张表里,才能横向比较。

需要每天导出一次吗?

不需要。每周固定时间导出最近 7 天或 30 天的数据即可。频率太高反而会让复盘变成填表,坚持不下去。

互动率怎么算才合理?

这篇统一用(点赞 + 评论 + 收藏 + 分享)÷ 曝光。公众号把“在看”算收藏、“转发”算分享。关键是所有平台用同一个口径,否则横向比较没有意义。

导出的数据能提交到 Git 吗?

不要。后台导出文件可能带账号名、作品 ID 和流量结构。把输入目录和输出目录都加进 .gitignore,仓库里只保留脱敏样例。

每周一张复习表

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

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

同方向继续

全部教程