ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaScript渗透测试报告工具:JSON模板渲染与PDF/Word导出

JavaScript渗透测试报告工具:JSON模板渲染与PDF/Word导出

简介:面向渗透测试团队的报告与扫描协同工具,适合需要规范参与管理、范围界定和重复扫描自动化的安全从业者与中高级学习者。它将参与列表、测试范围、发现记录和报告导出串联起来,内置 nmap、dirb、showmount、nikto 与 EyeWitness 截图等扫描动作,支持按严重程度归类发现、附加截图,并通过可自定义模板导出 .docx;实验性知识模块还能借助已知 SSH 凭据辅助横向移动演练。压缩包共 109 个文件、约 90.93MB,以 26 个 js、16 个 py 构建前后端及动作模块,11 个 css 与图片资源支撑界面,10 个 sh、2 个 dockerfile 和配置文件用于容器化部署,另含 docx、xlsx、txt、md 等文档模板。已有 416 人学习,目录清晰,可用作渗透测试流程规范化与报告生成的参考实现。

1. 一份六十页的交付文档,逼出了这个报告工具

做安全评估的团队大多经历过同一个场面:测试做完两周,交付文档还卡在排版上。扫描器导出几十个条目,人工挑出真实存在的、补上复现描述、截图、修复建议,再粘进 Word,改一遍编号,导一次 PDF,发现图片全跑版。渗透测试报告工具这类项目的出发点就在这里——把「写报告」从文档编辑变成数据结构化。pentest-report 用 JavaScript 写了一套前后端同构的生成链路,前端填数据、预览,后端渲染成 HTML 再转 PDF,模板和漏洞条目彻底解耦。适合谁?给客户交付评估结论的安全服务团队、需要定期出内网自查报告的企业安全岗,以及想把报告产出接进自有流程的开发者。它不负责扫描,只负责把已有结果整理成一份能交出去的文档。

2. 技术栈与工程结构:一个纯 JavaScript 的报告生成器是怎么搭起来的

选型上这个项目走的是保守路线,没有引入重型框架全家桶。理由很实际:报告工具的使用者是安全工程师,不是前端,环境要能在一台离线机器上三步跑起来;同时 PDF 渲染的坑极多,越少抽象层越容易定位问题。

核心依赖大致是这几块:

层常见选型承担的工作
构建Vite开发态热更新、生产打包,配置少
视图Vue 3 + 原生 JS 混写表单联动、条目增删、右侧实时预览
模板原生模板字符串 / 轻量模板函数把报告 JSON 映射成 HTML 片段
导出Puppeteer 或浏览器打印HTML 转 PDF,保留分页与页眉页脚
文档导出docx 类库生成可二次编辑的 Word
持久化本地 JSON 文件 / 浏览器 IndexedDB项目存盘、草稿恢复

这套组合的特点是每一层都能单独替换。你不想用 Puppeteer,换成浏览器window.print()加一段打印样式表也能出活;你不想用 Vue,把渲染层抽出来接到任何框架上都行。

2.1 目录结构:数据、模板、渲染三层分离

工程目录按职责切开,不按文件类型堆。这种切法的好处是二次开发时能一眼看出该改哪儿——加字段去schema,改版式去templates,改导出行为去renderer。

pentest-report/ ├── src/ │ ├── schema/ # 数据模型与校验,报告对象的唯一真相来源 │ │ ├── report.js # 报告顶层结构:项目信息、范围、结论 │ │ ├── finding.js # 单条漏洞/风险项的结构定义 │ │ └── validate.js # 必填校验、枚举校验、版本兼容 │ ├── templates/ │ │ ├── default/ # 默认模板:封面、目录、正文、附录 │ │ │ ├── cover.html # 封面片段 │ │ │ ├── toc.html # 目录片段 │ │ │ └── finding.html # 单条风险项片段 │ │ └── registry.js # 模板注册表,按名字取模板 │ ├── renderer/ │ │ ├── html.js # JSON -> HTML 字符串 │ │ ├── pdf.js # HTML -> PDF(Puppeteer / 打印) │ │ └── docx.js # JSON -> Word │ ├── editor/ # 前端编辑界面 │ │ ├── App.vue │ │ └── components/ │ ├── io/ │ │ ├── import.js # 导入扫描结果 / 历史 JSON │ │ └── export.js # 导出 JSON、PDF、Word │ └── main.js ├── public/ │ └── assets/ # 截图、Logo、字体 ├── server/ │ └── index.js # 本地服务:接收渲染请求、调 Puppeteer ├── package.json └── vite.config.js

registry.js这一层是刻意留的扩展点。它维护一张「模板名 → 渲染函数」的表,新增一套客户专用模板不需要改任何业务代码,注册进去就能在选择器里出现。

// src/templates/registry.js // 模板注册表:新增模板只需在末尾追加一项 import defaultTemplate from './default/index.js'; import compactTemplate from './compact/index.js'; const registry = new Map(); export function registerTemplate(name, template) { if (registry.has(name)) { throw new Error(`模板 ${name} 已存在,请换一个名字`); } registry.set(name, template); } export function getTemplate(name) { const tpl = registry.get(name) || registry.get('default'); return tpl; } export function listTemplates() { return [...registry.keys()]; } // 内置模板注册 registerTemplate('default', defaultTemplate); // 完整版:封面+目录+详情 registerTemplate('compact', compactTemplate); // 精简版:只出风险汇总表

这里用Map而不是普通对象,是因为模板名来自用户输入,对象键存在原型污染风险,Map干净得多。getTemplate做了兜底:模板名拼错时回落到default,不至于导出直接抛错;但如果用户明确选了某个模板,前端应当在选择阶段就拦住无效值,而不是等到渲染时才悄悄降级——这个降级只是为了不让流程崩掉。重复注册直接抛错,避免热更新时同名模板覆盖导致行为诡异。

2.2 数据流:从表单到 PDF 的一条直线

整个链路是单向的:编辑态改动写入报告对象 → 报告对象是唯一真相 → 预览与导出都从同一个对象渲染。没有双向绑定回写模板,也没有「导出时临时补字段」这类暗箱操作。这一点在多人协作时特别值钱,因为 JSON 存下来就是全量状态。

// src/renderer/html.js import { getTemplate } from '../templates/registry.js'; import { validateReport } from '../schema/validate.js'; /** * 把报告对象渲染成完整 HTML 字符串 * @param {object} report 报告数据对象 * @param {object} options { templateName, includeToc, baseUrl } */ export async function renderHtml(report, options = {}) { const { templateName = 'default', includeToc = true } = options; // 1. 先校验,把问题挡在渲染之前 const { valid, errors } = validateReport(report); if (!valid) { throw new Error(`报告数据不完整:${errors.slice(0, 3).join(';')}`); } const tpl = getTemplate(templateName); // 2. 分块渲染:封面、目录、正文、附录 const cover = await tpl.renderCover(report.meta); // 3. 风险项按风险等级排序,保证同一份数据每次输出顺序一致 const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; const findings = [...report.findings].sort( (a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9) ); const toc = includeToc ? await tpl.renderToc(findings) : ''; const body = await tpl.renderFindings(findings, { baseUrl: options.baseUrl }); const appendix = await tpl.renderAppendix(report.appendix || {}); // 4. 套外层骨架,内联样式表,避免外链样式在无网络环境下失效 return tpl.wrapDocument({ cover, toc, body, appendix, meta: report.meta }); }

这段是整个工具的中枢。参数report是完整报告对象,options.templateName决定用哪套版式,includeToc控制是否生成目录(精简版模板通常关掉),baseUrl用于把截图相对路径拼成绝对路径——离线导出时如果路径给错,PDF 里的图片会变成灰框。

排序那几行值得单独说。渲染前对findings做一次稳定排序,而不是依赖录入顺序。原因很实在:同一份报告上午导一次、下午导一次,如果中间有人编辑过某条,顺序就变了,客户会以为你改了什么。显式排序把这件事钉死。未知等级落到最后(?? 9),不至于因为写错一个枚举值就直接排到头部。

wrapDocument里做的事情是把各片段拼成<html>骨架,并把 CSS 内联进去。外链样式表在 Puppeteer 里能生效,但一旦用户用浏览器「另存为 PDF」的路径,相对路径的 CSS 很容易丢。内联多花几 KB,换来的是稳定。

2.3 跑起来:本地启动与第一次导出

环境要求不高,Node 18 以上,能装下 Chromium 的磁盘空间。Puppeteer 首次安装会下浏览器内核,内网机器要么提前把内核放到缓存目录,要么把PUPPETEER_SKIP_DOWNLOAD设上,改用系统已装浏览器。

# 1. 安装依赖 npm install # 2. 起开发态,前端在 5173,渲染服务在 3001 npm run dev # 3. 生产构建 + 启动服务 npm run build npm run start

npm run dev会并行起两个进程:Vite 的开发服务器和server/index.js。前者管界面热更新,后者管 PDF 渲染,因为 Puppeteer 必须在 Node 侧跑,浏览器里调不动。两者通过 Vite 的proxy打通,/api/render转发到 3001。

第一次跑最容易翻车的地方是渲染服务单独起。有人只跑npm run dev看到界面正常,点导出就报 502,以为是代码 bug,其实是后端进程没起来。看端口通不通就知道:curl http://localhost:3001/api/health返回{"ok":true}才算齐活。

导出接口本身很薄:

// server/index.js import express from 'express'; import puppeteer from 'puppeteer'; import { renderHtml } from '../src/renderer/html.js'; const app = express(); app.use(express.json({ limit: '20mb' })); // 截图以 base64 内嵌时需要放大 let browserPromise = null; function getBrowser() { // 复用同一个浏览器实例,避免每次导出都重启内核(启动一次约 1~2 秒) if (!browserPromise) { browserPromise = puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] // 容器里跑必需 }); } return browserPromise; } app.post('/api/render', async (req, res) => { const { report, options } = req.body; try { const html = await renderHtml(report, options); const browser = await getBrowser(); const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); // 等图片加载完 const pdf = await page.pdf({ format: 'A4', printBackground: true, // 不开这个,背景色和表头底色全丢 margin: { top: '20mm', bottom: '20mm', left: '18mm', right: '18mm' } }); await page.close(); res.type('application/pdf').send(pdf); } catch (err) { res.status(500).json({ error: err.message }); } });

浏览器实例提到模块级复用,是踩过坑之后的改法。早期版本每次请求都launch一次,五个人的团队同时导出,机器内存直接打满。waitUntil: 'networkidle0'表示等到网络空闲才截图,配合内嵌 base64 图片能保证图片全部落地;如果图片走的是外链,这个参数就得配合超时使用,否则一个加载失败的图会让请求一直挂着。

printBackground默认为 false,这条不知道坑过多少人——表格斑马纹、风险等级色块、封面底图,在浏览器预览里都在,导出来一片白。加上它才能和预览对齐。

3. 数据模型拆解:一条风险项到底该有哪些字段

报告工具最容易失控的地方不是渲染,是字段。第一个版本往往随手加字段,等到要按等级统计、要生成执行摘要、要对接客户的风险台账时,才发现数据结构撑不住。项目里schema/finding.js定义的结构是这份工程的骨架,值得逐字段看一遍。

3.1 报告对象与风险项的字段设计

顶层对象分四块:元信息、范围、风险项列表、附录。元信息放客户名、评估周期、报告版本、密级;范围记录被测系统清单和测试边界;风险项是主体;附录放工具清单、术语表这类不出现在正文但交付必需的内容。

风险项的字段设计有这么几条硬规矩:

// src/schema/finding.js export const SEVERITY = ['critical', 'high', 'medium', 'low', 'info']; export function createFinding(partial = {}) { return { id: partial.id || `F-${Date.now().toString(36)}`, // 稳定唯一标识 title: partial.title || '', // 风险标题,一句话说清 severity: partial.severity || 'medium', // 等级,枚举受限 category: partial.category || '', // 分类:配置/权限/传输/其他 affected: partial.affected || [], // 受影响资产列表 description: partial.description || '', // 现象描述 impact: partial.impact || '', // 业务影响 evidence: partial.evidence || [], // 证据:文字+截图引用 remediation: partial.remediation || '', // 修复建议 references: partial.references || [], // 标准条款/内部规范引用 status: partial.status || 'open', // open/fixed/accepted custom: partial.custom || {} // 各团队自定义字段的落脚点 }; }

id用时间戳加随机后缀生成,而不是数组下标。下标在增删排序后会漂移,导致「按 id 跳转」在预览里点错条目,这种 bug 极难复现。severity限定在五个枚举值内,校验层会拦非法值——如果放任自由文本,统计图表就废了,高危/高/High会被算成三类。

evidence设计成数组而不是单张图片字段,因为一条风险经常需要多张截图配合说明。每项是{ type: 'image' | 'text', value, caption }的结构,渲染时按顺序输出。

custom这个口子是被现实逼出来的。不同客户对报告字段的要求不一样,有的要求填 CVE 编号,有的要求填内部系统编号。与其让每个人改 schema 再合并冲突,不如留一个自由对象,模板里按需读取。代价是校验层不能对它做强约束,得靠模板作者自己保证渲染时不炸。

提示:custom里不要塞大对象或 base64 图片。它会被完整写进 JSON 存盘,一旦混进几 MB 的字符串,草稿文件会膨胀到打不开。

3.2 校验层:把错误挡在导出之前

校验函数是这份代码里最不显眼但最省事的部分。它的定位不是「完美校验」,而是「拦住会让渲染崩掉或让交付出洋相的情况」。

// src/schema/validate.js import { SEVERITY } from './finding.js'; export function validateReport(report) { const errors = []; const warnings = []; if (!report || typeof report !== 'object') { return { valid: false, errors: ['报告对象为空'], warnings }; } const meta = report.meta || {}; if (!meta.title) errors.push('缺少报告标题'); if (!meta.client) warnings.push('未填写客户名称,封面将显示占位符'); if (!meta.version) warnings.push('未填写报告版本号'); const findings = Array.isArray(report.findings) ? report.findings : []; if (findings.length === 0) warnings.push('风险项为空,导出的是空报告'); const seenIds = new Set(); findings.forEach((f, i) => { const at = `第 ${i + 1} 条`; if (!f.title) errors.push(`${at}:标题为空`); if (!SEVERITY.includes(f.severity)) { errors.push(`${at}:风险等级 "${f.severity}" 不在允许范围内`); } if (seenIds.has(f.id)) errors.push(`${at}:id 重复(${f.id})`); seenIds.add(f.id); if (!f.remediation) warnings.push(`${at}:未填写修复建议`); if (f.severity === 'critical' && !f.evidence?.length) { warnings.push(`${at}:严重风险缺少证据`); } }); return { valid: errors.length === 0, errors, warnings }; }

这里把问题分成errors和warnings两类,是有意为之。errors阻断导出,warnings只提示。分级依据是「这条缺失会不会导致交付物不可用」:标题空、等级非法、id 重复,这些会让文档结构错乱,必须拦;缺修复建议、缺客户名,属于质量问题,拦下来反而妨碍有人先导出一版草稿给人看。

严重风险缺证据单独给 warning,是血泪经验。真正会出事的不是格式错误,是报告里写了一条「严重」却不给任何截图和请求记录,客户安全部门第一个质疑的就是这条。工具没法强制人补,但至少在导出前提醒一句。

3.3 导入导出:让历史数据能复用

导出 JSON 是保命功能。报告写到一半浏览器崩了、换机器接手、跨版本升级,全靠这份文件。导入侧还要处理另一类输入:扫描器导出的结果文件。

// src/io/import.js import { createFinding } from '../schema/finding.js'; /** * 从通用扫描结果条目转换为内部 finding 结构 * @param {Array} items 外部条目数组 * @param {object} mapping 字段映射表,例如 { title: 'name', severity: 'risk' } */ export function importFindings(items, mapping = {}) { const map = { title: 'title', severity: 'severity', description: 'description', ...mapping }; const severityAlias = { critical: 'critical', crit: 'critical', '严重': 'critical', high: 'high', '高': 'high', medium: 'medium', med: 'medium', '中': 'medium', low: 'low', '低': 'low', info: 'info', informational: 'info', '信息': 'info' }; return items.map((item) => { const rawSev = String(item[map.severity] ?? '').toLowerCase().trim(); return createFinding({ title: item[map.title] || '未命名风险', // 映射不上的等级统一落到 medium,并在 category 里留痕 severity: severityAlias[rawSev] || 'medium', description: item[map.description] || '', category: severityAlias[rawSev] ? '' : '待人工确认' }); }); }

字段映射表做参数,是因为各家扫描器的字段名差异很大,硬编码等于给自己挖坑。severityAlias处理中英文和缩写混用的情况,这个表在真实项目里只会越来越长。

映射不上的等级没扔进low也没报错,而是落到medium并在category里写「待人工确认」。这是刻意的设计:导入只是辅助录入,人工必须过一遍。全落到low会让人以为没风险直接跳过,报错会让整批导入失败。留个显眼标记,逼着人回头看。

4. 模板与渲染链路:从 JSON 到一份能直接交付的文档

模板层是这个工具真正的产出车间。前面所有结构化的工作,最终都要在这里变成分页正确、目录能跳、图表能看的文档。这一章拆开看模板接口、HTML 渲染和 PDF 转换三段。

4.1 模板接口:一套约定胜过一堆配置

模板不是纯字符串,而是一个带方法的对象。约定固定为五个方法,实现方只要凑齐就能用:

// src/templates/default/index.js export default { name: 'default', label: '标准版(封面 + 目录 + 详情)', // 封面:只依赖 meta async renderCover(meta) { return ` <section class="cover"> <div class="cover-mark">${meta.classification || '内部资料'}</div> <h1>${escapeHtml(meta.title)}</h1> <p class="cover-client">${escapeHtml(meta.client || '客户名称待填')}</p> <p class="cover-period">${meta.periodStart || ''} ~ ${meta.periodEnd || ''}</p> <p class="cover-version">版本 ${escapeHtml(meta.version || 'v0.1')}</p> </section>`; }, // 目录:由 findings 生成锚点 async renderToc(findings) { const items = findings .map((f, i) => `<li><a href="#f-${f.id}">${i + 1}. ${escapeHtml(f.title)}</a></li>`) .join(''); return `<nav class="toc"><h2>风险清单</h2><ol>${items}</ol></nav>`; }, // 正文:每条风险一个 section,带等级色标 async renderFindings(findings, ctx) { return findings.map((f, i) => ` <section class="finding" id="f-${f.id}"> <h3>${i + 1}. ${escapeHtml(f.title)}</h3> <div class="badge badge-${f.severity}">${severityLabel(f.severity)}</div> <p class="desc">${escapeHtml(f.description)}</p> <p class="impact"><strong>影响:</strong>${escapeHtml(f.impact)}</p> ${renderEvidence(f.evidence, ctx.baseUrl)} <p class="fix"><strong>修复建议:</strong>${escapeHtml(f.remediation)}</p> </section>`).join(''); }, async renderAppendix(appendix) { return appendix.items?.length ? `<section class="appendix"><h2>附录</h2>${appendix.items .map((it) => `<p>${escapeHtml(it)}</p>`).join('')}</section>` : ''; }, // 外层骨架:内联 CSS,避免导出时丢样式 wrapDocument({ cover, toc, body, appendix }) { return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"> <style>${BASE_CSS}</style></head> <body>${cover}${toc}${body}${appendix}</body></html>`; } };

五个方法各自职责清晰:renderCover只吃meta,不碰风险项;renderToc只吃列表,负责锚点;renderFindings是主体;renderAppendix可以为空;wrapDocument负责骨架和样式。这套切法的意义在于,加一套新模板时你可以只改renderFindings的排版,其余方法直接复用默认实现。

每个字段都套了escapeHtml,不是形式主义。报告内容里出现尖括号、引号极其常见(描述配置项、写代码片段),不转义轻则版式错乱,重则整段内容被浏览器当成标签吞掉。这个工具虽然只在本机跑,但报告里塞进客户提供的字符串时,谁也不能保证里面干净。

4.2 分页与样式:让 PDF 不在奇怪的地方断开

HTML 转 PDF 最典型的问题是分页失控——标题在页底,正文翻到下一页;表格被从中间切开;一张截图横跨两页。这些问题都得在 CSS 里解决,靠 JS 后处理基本无解。

/* src/templates/default/style.css 节选 */ @page { size: A4; margin: 20mm 18mm; /* 页脚页码,Puppeteer 会渲染这段 */ @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt; color: #888; } } .finding { break-inside: avoid; /* 单条风险尽量不跨页 */ page-break-inside: avoid; /* 老内核兼容写法 */ margin-bottom: 14mm; } .finding h3 { break-after: avoid; /* 标题不和正文分离 */ } .finding table, .finding pre { break-inside: avoid; } img.evidence { max-width: 100%; max-height: 110mm; /* 限制单图高度,防止一张图吃掉整页 */ object-fit: contain; }

break-inside: avoid不是万能的。一条风险内容足够长(比如六张截图加三大段描述)时,浏览器只能忽略这条规则强行切分,否则会出现整页空白。所以真正的解法是双层保险:CSS 上尽量不切,同时在renderFindings里对evidence数量做软限制,超过四张时提示作者拆分成多条或移到附录。这个提示没有强制力,但用过一次就知道,一张横跨两页的截图在评审会上有多尴尬。

页码用 CSS 的@bottom-center加counter(page)实现,前提是走 Puppeteer 的page.pdf(),它支持这套 paged media 规则。如果改走浏览器打印,页码要靠打印对话框里的「页眉页脚」选项,而且样式不受控。这也是为什么项目默认推荐服务端渲染这条路径。

4.3 Word 导出:为什么不能从 PDF 反向转

有人在需求里提过「能不能把 PDF 转成 Word 给客户改」,这个方向从一开始就不该走。PDF 是版面描述,不含语义结构,反向转出来的 Word 是一堆定位文本框,客户改一个字整段就跑版。

正确做法是从同一份报告对象直接生成 docx。字段到段落的映射是显式的:标题用 Heading 1,风险项用 Heading 2,描述用普通段落,截图用内嵌图片,表格用原生表格。代价是样式要重写一遍,好处是客户拿到的是真能编辑的文档。

// src/renderer/docx.js(结构示意) export async function renderDocx(report) { const doc = new Document({ sections: [] }); const children = []; // 封面段落 children.push(new Paragraph({ text: report.meta.title, heading: HeadingLevel.TITLE })); children.push(new Paragraph({ text: `客户:${report.meta.client || '—'}` })); // 每条风险:标题 + 等级 + 描述 + 建议 report.findings.forEach((f, i) => { children.push(new Paragraph({ text: `${i + 1}. ${f.title}`, heading: HeadingLevel.HEADING_1 })); children.push(new Paragraph({ text: `风险等级:${severityLabel(f.severity)}` })); children.push(new Paragraph({ text: f.description })); children.push(new Paragraph({ text: `修复建议:${f.remediation}` })); }); doc.addSection({ children }); return Packer.toBuffer(doc); }

关键是「同一份数据、两条渲染路径」。PDF 给最终交付,Word 给需要批注的客户,两者字段完全对齐,不会出现「Word 里写了修复建议、PDF 里漏了」这种版本不一致。维护成本确实是双份样式,但比起人工核对两份文档,这个成本划算。

5. 常见问题排查:导出空白、图片丢失与中文乱码

这个工具上线初期收到的报障,八成集中在这几类。每条都按现象、原因、解决三段记,遇到同类问题可以直接对照。

现象:导出的 PDF 只有封面,正文一片空白。原因多半是渲染时findings为空但校验没拦住。校验层对空列表只给 warning 不阻断,导出流程照常跑完,结果生成一份只有封面的文档。另一种可能是模板里renderFindings抛了异常但被上层吞掉,返回了空字符串。 解决:先看/api/render的响应里有没有error字段;再打开返回的 HTML(渲染接口加个?debug=html参数只返回 HTML 不转 PDF),直接看 body 里有没有.finding节点。如果 nodes 数量对但 PDF 空,问题就在 Puppeteer 的等待策略上,把waitUntil从networkidle0改成domcontentloaded再加载一次看是否恢复。

现象:PDF 里所有截图位置都是灰框或空白。原因是图片路径失效。常见三种:截图用的是本地绝对路径(换机器就没了)、相对路径拼错(baseUrl少了一级)、图片以文件引用而非内嵌的方式传入,而渲染服务在另一个工作目录启动。 解决:导出前统一把证据图片转成 base64 内嵌进 HTML。这一步在renderHtml之前做,在renderEvidence里判断value.startsWith('data:'),是就直出,不是就走fs.readFileSync转码。内嵌会让 HTML 变大,但换来的是「一份 HTML 到哪都能渲染」。顺带把page.setContent的waitUntil保持networkidle0,确保内嵌图片解码完成。

现象:中文变成方块或者整段消失。Puppeteer 用的无头浏览器如果跑在精简容器里,系统里没有中文字体,中文会渲染成豆腐块。另一种情况是 HTML 头里缺<meta charset="utf-8">,编码识别错。 解决:容器镜像里显式装一套中文字体(把字体文件拷进/usr/share/fonts再fc-cache -f即可),或者在模板 CSS 里用@font-face引入字体文件并用font-family指定。编码问题检查wrapDocument返回的头部,<meta charset>必须在<style>之前。这类问题在本机开发时完全看不出来,一进容器就翻车,属于必须提前在镜像里验证的一项。

现象:表格从中间被切开,表头跑到上一页底部。原因是表格没有声明分页行为,浏览器默认允许在任意行之间断开。 解决:给table加break-inside: avoid,给thead加display: table-header-group,让表头在跨页时重复。内容特别长的表格不要硬塞进正文,直接移到附录,或者拆成多张小表。经验是单表超过 15 行就该考虑拆,读者翻页找上下文很痛苦。

现象:点了导出没反应,控制台也没有报错。最常见的是渲染服务没起,请求打到 5173 被 Vite 当成前端路由吃了,返回一个 index.html,前端拿到 HTML 当 PDF 处理,静默失败。其次是express.json的 body 限制太小,报告里带几张截图就超过 100kb 默认值,请求直接 413。 解决:先curl一下渲染服务的健康检查接口确认进程在;再把express.json({ limit: '20mb' })显式写上。前端侧给导出按钮加个 loading 态和失败提示,别让用户对着没反应的按钮反复点。

注意:排查这类问题别一上来就改代码。先确认「数据对不对、HTML 出没出、PDF 转没转」这三个环节各自的状态,八成问题能定位在具体一层,比盲改快得多。

6. 二次开发:给团队加一套自己的报告模板

改这个项目最常做的一件事,是加一套符合客户要求的版式。流程比想象中简单,因为模板接口是固定的——在templates/下新建目录,实现五个方法,注册进去就完事。

先复制default目录改名,然后重点改renderFindings。如果客户要求「每条风险必须带风险编号和复测状态」,就在这个方法里加两行:

// src/templates/enterprise/index.js async renderFindings(findings, ctx) { return findings.map((f, i) => ` <section class="finding" id="f-${f.id}"> <div class="finding-head"> <span class="fno">${escapeHtml(f.custom?.clientNo || `R-${String(i + 1).padStart(3, '0')}`)}</span> <h3>${escapeHtml(f.title)}</h3> <span class="badge badge-${f.severity}">${severityLabel(f.severity)}</span> </div> <p class="retest">复测状态:${f.status === 'fixed' ? '已修复' : '待复测'}</p> ${f.description ? `<p>${escapeHtml(f.description)}</p>` : ''} ${renderEvidence(f.evidence, ctx.baseUrl)} ${f.remediation ? `<p class="fix"><strong>修复建议:</strong>${escapeHtml(f.remediation)}</p>` : ''} </section>`).join(''); }

f.custom?.clientNo这个取值就是前面留的扩展口的实际用法:客户内部有编号体系,让录入的人填在custom里,模板按需读取,schema不用动。status字段本来就存在,直接拿来做复测标识,不用另加字段。

改完模板,验证要走一遍完整链路,不能只看预览:

第一,用一份真实历史报告 JSON 导入,检查每条风险是否都渲染出来,编号有没有断号;第二,导出 PDF,逐页翻一遍,重点看分页位置、表格跨页、图片清晰度;第三,把 PDF 里的文字复制出来粘到记事本,确认没有乱码和错位——有些模板用 CSS 做了特殊排版,视觉正常但文字层是乱的,客户检索时就会发现;第四,导一份 Word,确认客户能直接在上面批注。

有一个我每次都强制走一遍的检查:把导出的 PDF 发给一个没参与过这个项目的人,让他只看文档回答「这条风险影响什么、怎么修」。回答不上来,说明模板的信息层级有问题,不是数据的问题。这一步看着麻烦,但它拦住过好几次「字段都填了、排版也漂亮、但读的人抓不到重点」的情况。

从那以后我加任何新模板,都会先拿同一份样例数据把默认版和新版并排导一遍做对比,字段少了、顺序变了、色标丢了,一眼就能看出来。希望这套拆法能帮你在自己的报告流程里省下几个加班的晚上。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进