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

两个表格合并成一张:保留来源列,不用手工复制

把多个 CSV 放进一个文件夹,脚本按列名取并集,自动加一列来源。默认只预览,确认后加 --write 输出 merged.csv,原文件不动。

约 5 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 准备两个样本
  3. 合并规则
  4. 写脚本
  5. 先预览
  6. 写入
  7. 合并后检查
  8. 常见坑
  9. 1. 列名不一致
  10. 2. 表格编码不同
  11. 3. 第一个文件没有表头
  12. 4. 输出文件已存在
  13. 下一步读什么

30 秒速查

  • 所有表格放一个文件夹,第一行是列名
  • 预览:node resources/merge-tables/merge.mjs data/
  • 确认后加 --write,输出 data/merged.csv
  • 列名不同也能合,缺的列留空

这篇教程解决什么问题

三个平台各导一份表,想做一张总表;或者把 12 个月的表格拼成一年的数据。

手工复制有三个麻烦:列的顺序不一样,粘错位;多出来的列没地方放;粘完不知道哪一行来自哪个文件。数据一多,还会漏行。

这篇用一个脚本做三件事:

  • 按列名合并,不按列的位置;
  • 列名取并集,缺的列留空;
  • 加一列 source_file,标出每行来自哪个文件。

默认只预览,确认后加 --write 才写盘。

准备两个样本

新建 data/,放两个表格。第一行是列名:

1-xiaohongshu.csv
标题,播放量,点赞
第一条,1200,86
第二条,980,42
2-douyin.csv
标题,播放量,评论
第三条,5300,31
"第五条,带逗号",800,9

注意第二个文件有 评论、没有 点赞;最后一个标题里带逗号,所以用双引号包住。

两个表格:1-表A.csv 有姓名金额两列,2-表B.csv 有姓名日期两列
① 两个表都有「姓名」 ② 一个多「金额」,一个多「日期」

合并规则

情况 怎么处理
列名相同 放在同一列
某个文件缺列 这一格留空
列顺序不同 按列名对齐,不看位置
来源 加 source_file 列,写文件名

输出统一用 UTF-8 带 BOM,Excel 双击打开中文正常;给程序读时加 --no-bom。

写脚本

把下面整段保存为 resources/merge-tables/merge.mjs:

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

// 合并多个 CSV:列名取并集,多出来的列留空,并加一列 source_file。
// 默认只预览;加 --write 才写入 merged.csv。输入文件不会被改动。

export 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 === '"') {
        if (text[i + 1] === '"') {
          field += '"';
          i += 1;
        } else {
          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 if (char !== "\r") {
      field += char;
    }
  }

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

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

export function toCsv(headers, rows) {
  const escape = (value) => {
    const text = value ?? "";
    return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
  };

  return [
    headers.map(escape).join(","),
    ...rows.map((row) => headers.map((header) => escape(row[header])).join(",")),
  ].join("\n") + "\n";
}

export async function collectCsvFiles(target) {
  const info = await stat(target);
  if (!info.isDirectory()) return [target];

  const entries = await readdir(target, { withFileTypes: true });
  return entries
    .filter(
      (entry) =>
        entry.isFile() &&
        entry.name.toLowerCase().endsWith(".csv") &&
        entry.name.toLowerCase() !== "merged.csv" &&
        !entry.name.startsWith("."),
    )
    .sort((a, b) => a.name.localeCompare(b.name))
    .map((entry) => path.join(target, entry.name));
}

export async function mergeFiles(files) {
  const headers = [];
  const rows = [];

  for (const file of files) {
    const text = (await readFile(file, "utf8")).replace(/^\uFEFF/, "");
    const table = parseCsv(text);
    if (table.length === 0) continue;

    const [head, ...body] = table;
    for (const name of head) {
      if (name !== "source_file" && !headers.includes(name)) headers.push(name);
    }

    for (const cells of body) {
      const row = {};
      head.forEach((name, index) => {
        row[name] = cells[index] ?? "";
      });
      row.source_file = path.basename(file);
      rows.push(row);
    }
  }

  headers.push("source_file");
  return { headers, rows, files };
}

const isDirectRun =
  process.argv[1] &&
  pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (isDirectRun) {
  const args = process.argv.slice(2);
  const write = args.includes("--write");
  const bom = !args.includes("--no-bom");
  const target = args.find((arg) => !arg.startsWith("--")) ?? "data";

  try {
    const files = await collectCsvFiles(target);

    if (files.length === 0) {
      console.error("没有找到 CSV 文件。");
      process.exitCode = 1;
    } else {
      const merged = await mergeFiles(files);
      const isDir = (await stat(target)).isDirectory();
      const outFile = path.join(isDir ? target : path.dirname(target), "merged.csv");

      console.log(`读取 ${files.length} 个文件,共 ${merged.rows.length} 行`);
      console.log(`列:${merged.headers.join(", ")}`);

      if (write) {
        const text = toCsv(merged.headers, merged.rows);
        await writeFile(outFile, (bom ? "\uFEFF" : "") + text, "utf8");
        console.log(`已写入:${outFile}`);
      } else {
        console.log(`[预览] 合并后写入:${outFile}(加 --write 执行)`);
      }
    }
  } catch (error) {
    if (error.code === "ENOENT" || error.code === "ENOTDIR") {
      console.error(`打不开路径:${target}`);
      process.exitCode = 1;
    } else {
      throw error;
    }
  }
}

读代码时抓住三件事:

  • parseCsv 按 CSV 规则处理引号,单元格里的逗号不会把列拆开。
  • mergeFiles 先收集所有列名,再逐行按列名填值。
  • 默认 write=false,只算不写;--write 才生成 merged.csv。

先预览

node resources/merge-tables/merge.mjs data

真实输出:

读取 2 个文件,共 4 行
列:标题, 播放量, 点赞, 评论, source_file
[预览] 合并后写入:data/merged.csv(加 --write 执行)

预览只读文件,不会生成 merged.csv。

写入

node resources/merge-tables/merge.mjs data --write
读取 2 个文件,共 4 行
列:标题, 播放量, 点赞, 评论, source_file
已写入:data/merged.csv

打开 data/merged.csv:

标题,播放量,点赞,评论,source_file
第一条,1200,86,,1-xiaohongshu.csv
第二条,980,42,,1-xiaohongshu.csv
第三条,5300,,31,2-douyin.csv
"第五条,带逗号",800,,9,2-douyin.csv

两个文件缺的列是空的,source_file 能看出每行来自哪张表。

合并后的 merged.csv,四列分别是姓名金额日期来源,四行数据,缺的格子是空的
① 所有列都在 ② 来源列 source_file(4 行 = 2 + 2)

合并后检查

  • 行数等于几份表的数据行之和,没有漏表。
  • 每一行的 source_file 都能对上原文件。
  • 用 Excel 打开,中文和带逗号的标题没有错列。
  • 如果某一列全是空,说明两份表的列名没对上,回去统一列名。

列的顺序按第一次出现的顺序排;想固定顺序,就在脚本里改 headers 的顺序。

常见坑

1. 列名不一致

播放量 和 播放数 会被当成两列。合并前统一列名,或先用表格软件的查找替换改好。

第二个表用「名字」而不是「姓名」,合并后出现姓名和名字两列,每行各空一半
① 「姓名」和「名字」成了两列 ② 每行空一半(本机真跑,脚本按列名精确匹配)

2. 表格编码不同

脚本按 UTF-8 读。GBK 的表格先用 fix-csv 转一次,再合并;否则中文会变乱码。

3. 第一个文件没有表头

脚本把第一行当列名。如果某张表是从第二行开始的数据,先补一行表头。

4. 输出文件已存在

--write 会覆盖同目录的 merged.csv。想保留旧结果,先改名或换一个文件夹。

下一步读什么

表格合完,下一步是把它变成结论:每周数据复盘 会读多个平台的表,统一字段、算互动率、出周报。

这篇也属于从 0 开始:第一台自动化工作台的文件处理环节。

常见问题

两个表格的列名不一样,能合吗?

能。脚本按列名取并集,某个文件没有的列就留空;最后多一列 source_file,记录这一行来自哪个文件。

会覆盖原来的表格吗?

不会。它只读输入文件,输出固定叫 merged.csv;加 --write 才写盘,之前只打印预览。

单元格里有逗号或换行怎么办?

用双引号包住整个单元格,脚本按 CSV 规则解析;输出时也会自动加引号,Excel 打开不会错列。

表格是 GBK,打开是乱码?

先跑 fix-csv 转成 UTF-8,再合并。合并脚本按 UTF-8 读,不猜编码,避免越合越乱。

每周一张复习表

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

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

同方向继续

全部教程