← 中文资源

简体中文版本

构建 CSV 转 HTML 报表生成器

读取 CSV、按指定列分组、求和并写出自带样式与转义的安全 HTML 报表。

阅读英文原文

项目目标

本项目把 CSV 转换为分组后的 HTML 摘要报表,把两处难点——正确的 CSV 解析与安全的 HTML 转义——显式化并加以测试。分组与求和是对行对象的纯函数,因此可在不重读文件的前提下按不同列重新生成报表。

核心特性

  • 解析带引号字段的 CSV 与表头行
  • 按指定键列分组并对数值列求和小计
  • 输出含内联样式与汇总表的独立 HTML 文件
  • 转义每个单元格,阻止 CSV 值注入标记

架构与分层

CSV 解析器按表头产出行对象;分组器分桶并累加数值列;HTML 渲染器据此生成文档;CLI 把参数接到管道。所有取值都经过同一个转义函数,使注入只有一个收口点。

实施阶段

六个阶段为:CSV 解析、分组与求和、HTML 渲染与转义、CLI 管道、分组列选择与边界情况、测试与加固。注入测试是不可省略的一环。

测试与验收

用 vitest 运行。CSV 测试覆盖引号字段、内嵌逗号与引号以及缺列行;分组测试断言小计与排序;渲染测试喂入含脚本标签的取值并断言输出只有转义实体、没有原始标记。

延伸方向

新增图表、分组明细与 JSON 导出都是同一分组数据上的新渲染器。从表头选择分组列使管道天然适配任意数据集。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function groupRows(rows, keyColumn, sumColumns = []) {
  const groups = new Map();
  for (const row of rows) {
    const key = row[keyColumn] ?? '(blank)';
    if (!groups.has(key)) groups.set(key, { key, count: 0, sums: {} });
    const bucket = groups.get(key);
    bucket.count += 1;
    for (const col of sumColumns) {
      bucket.sums[col] = (bucket.sums[col] ?? 0) + (Number(row[col]) || 0);
    }
  }
  return [...groups.values()].sort((a, b) => b.count - a.count);
}
mkdir csv-report && cd csv-report
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "csv-report",
  "type": "module",
  "scripts": {
    "report": "node src/cli.js",
    "test": "vitest run"
  }
}
export function parseCsv(text) {
  const rows = splitRows(text);
  const header = rows.shift();
  return rows.filter((r) => r.length > 0).map((cells) => {
    const row = {};
    header.forEach((h, i) => { row[h] = cells[i] ?? ''; });
    return row;
  });
}

function splitRows(text) {
  const rows = [];
  let field = '';
  let row = [];
  let quoted = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') quoted = false;
      else field += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; }
    else if (c !== '\r') field += c;
  }
  if (field.length || row.length) { row.push(field); rows.push(row); }
  return rows;
}
export function groupRows(rows, keyColumn, sumColumns = []) {
  const groups = new Map();
  for (const row of rows) {
    const key = row[keyColumn] ?? '(blank)';
    if (!groups.has(key)) groups.set(key, { key, count: 0, sums: {} });
    const bucket = groups.get(key);
    bucket.count += 1;
    for (const col of sumColumns) {
      bucket.sums[col] = (bucket.sums[col] ?? 0) + (Number(row[col]) || 0);
    }
  }
  return [...groups.values()].sort((a, b) => b.count - a.count);
}
export function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

export function renderReport(groups, { title = 'Report', sumColumns = [] } = {}) {
  const head = ['Group', 'Count', ...sumColumns].map((h) => '<th>' + escapeHtml(h) + '</th>').join('');
  const body = groups.map((g) => {
    const cells = [g.key, g.count, ...sumColumns.map((c) => g.sums[c] ?? 0)]
      .map((v) => '<td>' + escapeHtml(v) + '</td>').join('');
    return '<tr>' + cells + '</tr>';
  }).join('');
  return '<!doctype html><html><head><meta charset="utf-8"><title>' + escapeHtml(title) +
    '</title><style>table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>' +
    '</head><body><h1>' + escapeHtml(title) + '</h1><table><thead><tr>' + head +
    '</tr></thead><tbody>' + body + '</tbody></table></body></html>';
}
import { readFile, writeFile } from 'node:fs/promises';
import { parseCsv } from './csv.js';
import { groupRows } from './group.js';
import { renderReport } from './render.js';

const args = parse(process.argv.slice(2));
if (!args.input || !args.by) {
  console.error('usage: report --input <csv> --by <column> [--out report.html]');
  process.exit(1);
}
const rows = parseCsv(await readFile(args.input, 'utf8'));
const sumColumns = (args.sum ?? '').split(',').filter(Boolean);
const groups = groupRows(rows, args.by, sumColumns);
await writeFile(args.out ?? 'report.html', renderReport(groups, { title: args.title ?? 'Report', sumColumns }));
console.log('rows=' + rows.length + ' groups=' + groups.length);
export function resolveColumns(header, requested) {
  const missing = requested.filter((col) => !header.includes(col));
  if (missing.length) return { ok: false, error: 'missing columns: ' + missing.join(', ') };
  return { ok: true, columns: requested };
}

export function blankPolicy(rows, keyColumn) {
  const blanks = rows.filter((r) => !r[keyColumn]).length;
  return { blanks, label: blanks ? '(blank)' : null };
}
import { renderReport } from '../src/render.js';

test('cell values are escaped and cannot inject markup', () => {
  const groups = [{ key: '<script>alert(1)</script>', count: 1, sums: { amount: 5 } }];
  const html = renderReport(groups, { sumColumns: ['amount'] });
  expect(html).not.toContain('<script>');
  expect(html).toContain('&lt;script&gt;');
});