a7177afde7
- report-html.ts:renderImportReportHtml(Plan 2 Task 6 模板纪律——全动态文本 转义/静态骨架无 script/分组固定序渲染/封顶注记),import.html 与 import.json 同 try 原子落盘(同 REPORT_WRITE_FAILED 失败口径) - 报告 issues 封顶(Task 6 推迟项):REPORT_ISSUES_CAP=10k——flat 清单与 issueGroups 各组同限截断 + issuesTruncated 计数;终态判定(succeeded_with_issues) 在全量长度上做,截断不吞终态事实;counts/sanitize 始终完整 - orchestrator.ts 抽取(审查触发器 import.service 849 行):orchestratedPipeline/ mergeOutcomes/sumCounts/trySyncFinish 迁出为纯函数 over ctx;契约类型单源 import.service.ts 不动;既有测试全绿即证零行为变更 - 测试 +8:单元 7(恶意 payload 转义/结构/分组序/封顶注记/渲染不改输入)+ 集成 1(10_003 条注入:截断计数/JSON 原文保留/HTML 转义/终态判定);333 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
// 导入报告 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 '&'
|
||
case '<':
|
||
return '<'
|
||
case '>':
|
||
return '>'
|
||
case '"':
|
||
return '"'
|
||
default:
|
||
return '''
|
||
}
|
||
})
|
||
}
|
||
|
||
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')
|
||
}
|