Files
cms-website/apps/api/src/modules/import/report-html.ts
T

156 lines
6.2 KiB
TypeScript
Raw Normal View History

// 导入报告 HTML 渲染(Plan 3 Task 8——Task 6 推迟项兑现):沿用 Plan 2 Task 6 模板纪律
// (@sf/adapter-wp 的 renderReportHtml 同款):
// - 对象层(import.json)保留**原文**(issue 字符串未转义)——JSON 归档/机器消费的语义层;
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本来自不可信
// WXR 内容(行级问题携带清洗前 excerpt 形态,可含 `<script src="x">`),未转义渲染会使
// 报告本身成为存储型 XSS 载体(Plan 2 Task 5 审查移交纪律的导入侧续录);
// - 静态骨架无 script/事件属性;计数表键按字典序渲染(对象键序不保证,渲染须确定)。
//
// escapeHtml 与 adapter-wp/report.ts 的私有实现同形独立维护(17 行纯函数)——不为此扩
// @sf/adapter-wp 公共导出面;两处若任一改判集,由对抗测试各自护栏(本文件测试钉恶意
// payload 转义)。
import type { ImportReport, SnapshotRefSlim } from './import.service.js'
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
default:
return '&#39;'
}
})
}
const esc = escapeHtml
/** 分组渲染顺序(固定——对象键序不保证,人类视图须确定) */
const GROUP_ORDER = ['content', 'term', 'media', 'route', 'adapter', 'orchestrator'] as const
const GROUP_LABELS: Record<(typeof GROUP_ORDER)[number], string> = {
content: '内容(content-writer)',
term: '分类(term-writer)',
media: '媒体(media-writer)',
route: '路由(route-writer)',
adapter: '适配器(行级/unsupported/error)',
orchestrator: '编排器(orchestrator)',
}
function snapshotLine(label: string, ref: SnapshotRefSlim | null): string {
if (ref === null) return `<li>${label}:(无)</li>`
return `<li>${label}:${esc(ref.id)}(${esc(ref.kind)},sha256=${esc(ref.sha256.slice(0, 12))}…,${ref.byteSize} bytes)</li>`
}
/**
* 导入报告 → HTML 片段(结构:概览/快照/版本/路径/计数表/清洗统计/问题清单/错误/耗时)。
* 问题清单优先渲染分组视图(编排管道恒产 issueGroups——六组按固定序);分组缺席
* (注入管道的 Task 1 形态)回退 flat 清单。封顶溢出以 issuesTruncated 注记呈现。
*/
export function renderImportReportHtml(report: ImportReport): string {
const lines: string[] = []
lines.push('<h2>导入报告</h2>')
lines.push('<h3>概览</h3>')
lines.push(
`<ul><li>job: ${esc(report.jobId)}</li><li>状态: ${esc(report.status)}</li>` +
`<li>站点: ${esc(report.siteKey)}(${esc(report.siteId)})</li><li>connection: ${esc(report.connectionId)}</li>` +
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>attempt: ${report.attempt}</li>` +
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
)
lines.push('<h3>快照</h3>')
lines.push(
`<ul>${snapshotLine('wxr(主输入)', report.snapshots.wxr)}` +
`${snapshotLine('media_bundle', report.snapshots.media_bundle)}` +
`${snapshotLine('manual_url_list', report.snapshots.manual_url_list)}</ul>`,
)
lines.push('<h3>版本</h3>')
lines.push(
`<ul><li>adapter: ${esc(report.versions.adapter)}</li>` +
`<li>mapping: ${esc(report.versions.mapping)}</li>` +
`<li>sanitizer: ${esc(report.versions.sanitizer)}</li>` +
`<li>parser: ${esc(report.versions.parser)}</li></ul>`,
)
lines.push('<h3>路径</h3>')
lines.push(
`<ul><li>productsPath: ${esc(report.paths.productsPath)}</li>` +
`<li>newsPath: ${esc(report.paths.newsPath)}</li></ul>`,
)
lines.push('<h3>计数</h3>')
lines.push('<table><thead><tr><th>键</th><th>值</th></tr></thead><tbody>')
const keys = Object.keys(report.counts).sort()
if (keys.length === 0) {
lines.push('<tr><td colspan="2">(无)</td></tr>')
}
for (const key of keys) {
lines.push(`<tr><td>${esc(key)}</td><td>${report.counts[key]}</td></tr>`)
}
lines.push('</tbody></table>')
if (report.sanitize !== undefined) {
lines.push('<h3>清洗统计</h3>')
const diffTypes = Object.keys(report.sanitize.byDiffType).sort()
const diffs =
diffTypes.length === 0
? ''
: `<li>byDiffType: ${diffTypes.map((t) => `${esc(t)}=${report.sanitize!.byDiffType[t]}`).join('、')}</li>`
lines.push(`<ul><li>changed rows: ${report.sanitize.changedRows}</li>${diffs}</ul>`)
}
lines.push('<h3>问题清单</h3>')
const groups = report.issueGroups
if (groups !== undefined) {
for (const key of GROUP_ORDER) {
const groupIssues = groups[key]
if (groupIssues === undefined) continue
lines.push(`<h4>${esc(GROUP_LABELS[key])}(${groupIssues.length} 条)</h4>`)
if (groupIssues.length === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of groupIssues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
}
} else if (report.issues.length === 0 && report.issuesTruncated === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of report.issues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
if (report.issuesTruncated > 0) {
lines.push(`<p>(另有 ${report.issuesTruncated} 条超出固化上限,仅计数)</p>`)
}
if (report.error !== null) {
lines.push(
`<h3>错误</h3><ul><li>code: ${esc(report.error.code)}</li><li>summary: ${esc(report.error.summary)}</li></ul>`,
)
}
lines.push('<h3>耗时</h3>')
const adapterTiming =
report.adapterTiming === undefined
? ''
: `<li>adapter parse: ${report.adapterTiming.parseMs.toFixed(3)}ms</li>` +
`<li>adapter sanitize: ${report.adapterTiming.sanitizeMs.toFixed(3)}ms</li>`
lines.push(`<ul>${adapterTiming}<li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
return lines.join('\n')
}