项目目标
本项目把 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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('<script>');
});