feat(adapter-wp): 全链组装与导入前报告

- src/versions.ts:ADAPTER/MAPPING/SANITIZER_VERSION 三常量(bump 语义 JSDoc 冻结:
  映射表/清洗白名单实质变更才 bump 对应项,三者独立演进)+ PARSER_VERSION(saxes 硬编码)
- src/adapter.ts:runWxrAdapter 串联 parseWxrStream→mapItem→sanitizeBodyHtml 为
  AsyncGenerator<AdapterEvent>;term 旁路直通;bodyHtml 为空跳过清洗零 diff;
  changed 用 html!==input(Task 5 移交:diffs.length===0 ≠ 未变更);validateUniversalRow
  加法式契约复检(不动 contracts);解析/清洗分段计时(parser.next() await 窗口归属解析)
- src/report.ts:runWxrAdapterReport 聚合(首条版本块/按 sourceType Created-Skipped(无
  变化)-Failed-Unsupported/清洗统计按 diff.type+changedRows/行级错误 rawLocation+message
  +excerpt/致命 WxrError 记 fatalError 不上抛——wxr:probe 消费通道);renderReportHtml
  所有动态文本经 escapeHtml(<>&"',Task 5 移交的标记注入面防线)
- test/adapter.test.ts:19 用例(mini 全链/changed 语义/报告结构/恶意样本转义/
  unsupported-error 汇聚/wppop 失败贯通/JSON 可序列化/致命解析错误交付确定性)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 11:44:55 +08:00
parent 15e1e51a14
commit 6573b8b326
5 changed files with 867 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
// 适配器全链组装(Plan 2 Task 6):parseWxrStream → mapItem → sanitizeBodyHtml 串联为
// AsyncGenerator<AdapterEvent>。三段件均为既有纯函数/纯流,本模块只做组装与计时,
// 不引入新的业务判定。
//
// 事件契约(spec §7.7 / Plan 2 移交):
// - term 旁路直通:terms 是表数据不是内容行,不经映射不经清洗,原样转发;
// - item 经 mapItem 三分支:row → 组装层追加清洗(bodyHtml 为空时跳过清洗零 diff);
// unsupported / error 原样透传(error 分支补 sourceType 报告锚点,见下);
// - changed 语义(Task 5 审查移交钉死):`html !== input` 字符串比较——diffs.length===0
// 不等于未变更(img 自闭合规范化等零 diff 等价变形),报告层"无变化 Skipped"以此为准。
//
// 计时分段(Task 5 移交:解析与清洗分开计时):手动泵 parser.next(),await 窗口内的
// 时间全部归属解析(parseWxrStream 是拉取式生成器,其全部工作都发生在 next() 期间;
// map/sanitize 发生在两次 next() 之间的循环体内),sanitizeBodyHtml 调用单独累计。
// 经可选 timings 累计对象侧信道输出(消费完成后读取——生成器无法附带返回元数据)。
import type { Readable } from 'node:stream'
import { isUniversalRow, type UniversalRow } from '@sf/contracts'
import { parseWxrStream, type WxrEvent, type WxrItem, type WxrStreamOptions, type WxrTerm } from './parse/wxr-stream.js'
import { mapItem, type MapConfig } from './map/item-to-row.js'
import { sanitizeBodyHtml, type SanitizeDiff } from './sanitize/html.js'
import type { WxrLimits } from './limits.js'
/** row 事件:映射产物 + 清洗结果(issues 为 mapItem issues 与契约复检 issues 的并集,文档序) */
export interface RowResult {
kind: 'row'
row: UniversalRow
/** 非致命问题清单(不阻碍 row 产出;报告层逐条收录为行级错误) */
issues: string[]
/** 清洗差异(文档序);bodyHtml 为空(跳过清洗)时恒为空数组 */
sanitizeDiffs: SanitizeDiff[]
/** `html !== input`:清洗输出与输入原文的字符串比较(≠ diffs.length>0) */
changed: boolean
}
/**
* 全链事件判别联合。unsupported/error 分支与 mapItem 的 MappedResult 同形,仅 error
* 分支补 `sourceType`(`wp:${postType}` 报告锚点:post_id 缺失时 mapItem 拒猜身份,
* 但 post_type 本身仍可作计数归桶依据——命名约定同 unsupported 分支,仅为报告可读性)。
*/
export type AdapterEvent =
| RowResult
| { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string }
| { kind: 'error'; sourceType: string; issue: string; rawLocation: string }
| { kind: 'term'; term: WxrTerm }
/** 分段计时累计(可注入对象,运行中被本模块累加;消费完成后读取终值) */
export interface AdapterTimings {
/** 解析耗时(parser.next() await 窗口累计,毫秒) */
parseMs: number
/** 清洗耗时(sanitizeBodyHtml 调用累计,毫秒;不含映射) */
sanitizeMs: number
}
/** runWxrAdapter 选项 */
export interface AdapterOptions {
/** 透传 parseWxrStream 的限额部分覆盖 */
limits?: Partial<WxrLimits>
/** 透传 parseWxrStream 的可注入时钟(time 预算测试用;非计时用途) */
now?: () => number
/** mapItem 配置;缺省 { locale: '' }(locale 空串与否由调用方自证) */
mapConfig?: MapConfig
/** 计时累计对象(缺省内部自建;报告层注入以在消费后读取分段耗时) */
timings?: AdapterTimings
}
/**
* 契约复检(Plan 1 移交的"加法式"检查):复用 isUniversalRow 守卫 + 浅层不变量复检,
* 返回 issue 清单(空数组=通过)。不修改 contracts 包、不猜修复——发现即报告。
* 与 mapItem 内部守卫的关系:mapItem 产出时自证(测试钉死),本函数在组装层再复检,
* 防御未来映射层变更引入的回归(两道闸,加法不替代)。
*/
export function validateUniversalRow(row: UniversalRow): string[] {
const issues: string[] = []
if (!isUniversalRow(row)) {
issues.push('契约守卫 isUniversalRow 未通过(row 结构不完整)')
return issues
}
if (row.sourceType === '') issues.push('sourceType 为空串')
if (row.sourceId === '') issues.push('sourceId 为空串')
if (row.rawLocation === '') issues.push('rawLocation 为空串')
row.taxonomyRefs.forEach((ref, i) => {
if (ref.taxonomy === '' || ref.sourceTermId === '') {
issues.push(`taxonomyRefs[${i}] 含空锚点(taxonomy/sourceTermId)`)
}
})
row.attachmentRefs.forEach((ref, i) => {
if (ref.role === '') issues.push(`attachmentRefs[${i}] role 为空串`)
if (ref.sourceId !== undefined && ref.sourceId === '') {
issues.push(`attachmentRefs[${i}] sourceId 为空串`)
}
if (ref.sourceId === undefined && ref.sourceUrl === undefined) {
issues.push(`attachmentRefs[${i}] 无 sourceId/sourceUrl 锚点`)
}
})
return issues
}
/** 单个 item 事件的组装:映射 → 契约复检 → 清洗 → RowResult */
function assembleItem(
item: WxrItem,
mapConfig: MapConfig,
timings: AdapterTimings,
): AdapterEvent {
const mapped = mapItem(item, mapConfig)
if (mapped.kind === 'unsupported') return mapped
if (mapped.kind === 'error') {
return { ...mapped, sourceType: `wp:${item.postType}` }
}
const { row } = mapped
const issues = [...mapped.issues, ...validateUniversalRow(row)]
// bodyHtml 为空(缺席或空串)→ 跳过清洗零 diff:attachment 无正文是常态,
// 空串调用 sanitizeBodyHtml 虽无害但会虚增清洗计时
let sanitizeDiffs: SanitizeDiff[] = []
let changed = false
if (row.bodyHtml !== undefined && row.bodyHtml !== '') {
const input = row.bodyHtml
const t0 = performance.now()
const result = sanitizeBodyHtml(input)
timings.sanitizeMs += performance.now() - t0
row.bodyHtml = result.html
sanitizeDiffs = result.diffs
changed = result.html !== input
}
return { kind: 'row', row, issues, sanitizeDiffs, changed }
}
/**
* WXR 全链适配(组装层):Readable(WXR 字节流)→ AsyncGenerator<AdapterEvent>。
*
* 事件按文档序交错产出(row/unsupported/error/term);解析的限额/结构错误
* (WxrLimitError/WxrParseError)原样向上抛——错误前已产出的事件不撤回
* (交付确定性由 parseWxrStream 保证,本层不吞不补)。消费方提前 break 时
* 经 finally 关闭解析器(级联 destroy 上游流)。
*/
export async function* runWxrAdapter(
readable: Readable,
opts: AdapterOptions = {},
): AsyncGenerator<AdapterEvent, void, void> {
const timings = opts.timings ?? { parseMs: 0, sanitizeMs: 0 }
const mapConfig = opts.mapConfig ?? { locale: '' }
// exactOptionalPropertyTypes:显式缺席键(不传 undefined 值),与 WxrStreamOptions 形态对齐
const streamOpts: WxrStreamOptions = {}
if (opts.limits !== undefined) streamOpts.limits = opts.limits
if (opts.now !== undefined) streamOpts.now = opts.now
const parser = parseWxrStream(readable, streamOpts)
try {
// 手动泵而非 for-await:await 窗口 = 解析器自身工作时间(拉取式生成器的全部
// 工作都发生在 next() 内),与循环体内的映射/清洗天然分段
while (true) {
const t0 = performance.now()
const res: IteratorResult<WxrEvent> = await parser.next()
timings.parseMs += performance.now() - t0
if (res.done) break
const ev = res.value
if (ev.kind === 'term') {
yield { kind: 'term', term: ev.term }
} else {
yield assembleItem(ev.item, mapConfig, timings)
}
}
} finally {
// 正常完结(幂等无害)/ 消费方提前退出 / 解析抛错后:级联关闭解析器,
// 触发其 finally(destroy 上游流)
await parser.return()
}
}
+3
View File
@@ -1,5 +1,8 @@
export * from './versions.js'
export * from './limits.js'
export * from './parse/php-unserialize.js'
export * from './parse/wxr-stream.js'
export * from './map/item-to-row.js'
export * from './sanitize/html.js'
export * from './adapter.js'
export * from './report.js'
+261
View File
@@ -0,0 +1,261 @@
// 导入前报告(Plan 2 Task 6,spec §7.7 对齐):消费 runWxrAdapter 全链事件流并聚合为
// 可 JSON 序列化的报告对象(WxrAdapterReport),另提供 HTML 渲染(renderReportHtml)。
//
// 对象层与渲染层职责分离:
// - 报告对象保留**原文**(excerpt/message 未转义)——JSON 归档、diff、机器消费的语义层;
// - renderReportHtml 对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本
// 来自不可信 WXR 内容(清洗差异 excerpt 含 `<script src="x">` 形态),渲染进 HTML
// 必须转义,否则报告本身成为存储型 XSS 载体(Task 5 审查移交)。
//
// 计数语义(导入前投影,非入库结果):
// - Created:changed=true 的 row(清洗输出 ≠ 输入原文 → 导入将产生新内容);
// - Skipped(无变化):changed=false 的 row(spec §7.5:无业务变化 → Skip);
// - Failed:error 事件(post_id 缺失/非法,无法建立稳定身份);
// - Unsupported:unsupported 事件(post_type 未配置路由)。
// 归桶键:row 用 row.sourceType;unsupported 用 `wp:${postType}`;error 用组装层补的
// sourceType 报告锚点。行级错误(rowErrors)收录全部非致命问题:映射 issues、契约复检
// issues、清洗差异(含 excerpt)、unsupported/error 事件——宁多报不漏报(审计定位用途)。
//
// 致命错误策略:WxrError(解析限额/结构错误)使流终止——报告不向上抛,记入 fatalError
// 并保留已聚合部分(Task 7 wxr:probe 对损坏文件要出报告而非崩溃);非 WxrError 的意外
// 异常视为程序 bug,原样上抛。
import type { Readable } from 'node:stream'
import { runWxrAdapter, type AdapterEvent, type AdapterOptions, type AdapterTimings } from './adapter.js'
import { WxrError } from './parse/wxr-stream.js'
import type { SanitizeDiff } from './sanitize/html.js'
import { ADAPTER_VERSION, MAPPING_VERSION, PARSER_VERSION, SANITIZER_VERSION } from './versions.js'
/** 版本块(报告首条):三段件版本 + 解析库版本(spec §7.7) */
export interface WxrVersions {
adapter: string
mapping: string
sanitizer: string
parser: string
}
/** 按 sourceType 的计数(导入前投影语义见模块头) */
export interface SourceTypeCounts {
created: number
skipped: number
failed: number
unsupported: number
}
/** 行级错误条目:rawLocation 定位 + message + 可选 excerpt(原文,渲染层转义) */
export interface RowErrorEntry {
rawLocation: string
message: string
excerpt?: string
}
/** 导入前报告对象(纯数据,JSON.stringify 直接可序列化——无 Map/循环引用) */
export interface WxrAdapterReport {
versions: WxrVersions
counts: Record<string, SourceTypeCounts>
totals: { rows: number; terms: number; unsupported: number; failed: number }
sanitize: {
byDiffType: { 'removed-tag': number; 'removed-attr': number }
changedRows: number
}
rowErrors: RowErrorEntry[]
timing: { parseMs: number; sanitizeMs: number; totalMs: number }
/** 致命错误(WxrError.name + message);流正常完结为 null */
fatalError: string | null
}
/** 取指定类型的计数桶(缺则建桶——报告对象自建自持,无需预注册类型清单) */
function countsBucket(report: WxrAdapterReport, sourceType: string): SourceTypeCounts {
let bucket = report.counts[sourceType]
if (bucket === undefined) {
bucket = { created: 0, skipped: 0, failed: 0, unsupported: 0 }
report.counts[sourceType] = bucket
}
return bucket
}
/** 清洗差异 → 行级错误条目(message 携带标签形态,excerpt 为被移除内容重构摘要) */
function diffEntry(rawLocation: string, diff: SanitizeDiff): RowErrorEntry {
const target =
diff.type === 'removed-tag' ? `<${diff.tag}>` : `${diff.attr}(标签 ${diff.tag})`
return {
rawLocation,
message: `清洗移除未批准${diff.type === 'removed-tag' ? '标签' : '属性'}:${target}`,
excerpt: diff.excerpt,
}
}
/** 单事件聚合(纯函数式地累积进 report——顺序敏感仅在于 rowErrors 的文档序) */
function aggregate(report: WxrAdapterReport, ev: AdapterEvent): void {
switch (ev.kind) {
case 'term':
report.totals.terms++
break
case 'row': {
countsBucket(report, ev.row.sourceType)[ev.changed ? 'created' : 'skipped']++
report.totals.rows++
if (ev.changed) report.sanitize.changedRows++
for (const issue of ev.issues) {
report.rowErrors.push({ rawLocation: ev.row.rawLocation, message: issue })
}
for (const diff of ev.sanitizeDiffs) {
report.sanitize.byDiffType[diff.type]++
report.rowErrors.push(diffEntry(ev.row.rawLocation, diff))
}
break
}
case 'unsupported':
countsBucket(report, ev.sourceType).unsupported++
report.totals.unsupported++
report.rowErrors.push({
rawLocation: ev.rawLocation,
message: `${ev.sourceType}:${ev.reason}`,
})
break
case 'error':
countsBucket(report, ev.sourceType).failed++
report.totals.failed++
report.rowErrors.push({ rawLocation: ev.rawLocation, message: ev.issue })
break
}
}
/**
* 全链运行并聚合报告:内部消费 {@link runWxrAdapter} 的事件流,返回 WxrAdapterReport。
* 计时:totalMs 覆盖整次消费(performance.now 分段——解析/清洗经注入的 timings 累计,
* 见 adapter.ts 的分段依据;totalMs ≥ parseMs 不含严格子式保证,映射耗时计入差值)。
*/
export async function runWxrAdapterReport(
readable: Readable,
opts: AdapterOptions = {},
): Promise<WxrAdapterReport> {
const timings: AdapterTimings = opts.timings ?? { parseMs: 0, sanitizeMs: 0 }
const startedAt = performance.now()
const report: WxrAdapterReport = {
versions: {
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
sanitizer: SANITIZER_VERSION,
parser: PARSER_VERSION,
},
counts: {},
totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 },
sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 },
rowErrors: [],
timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 },
fatalError: null,
}
try {
for await (const ev of runWxrAdapter(readable, { ...opts, timings })) {
aggregate(report, ev)
}
} catch (err) {
// 致命错误策略见模块头:WxrError 记账不抛(保留已聚合部分),其余视为程序 bug
if (!(err instanceof WxrError)) throw err
report.fatalError = `${err.name}: ${err.message}`
}
report.timing = {
parseMs: timings.parseMs,
sanitizeMs: timings.sanitizeMs,
totalMs: performance.now() - startedAt,
}
return report
}
/** 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
/**
* 报告 → HTML 片段(结构简单:版本块/计数表/汇总/清洗统计/错误清单/耗时)。
* **所有**动态文本经 {@link escapeHtml};静态骨架无 script/事件属性。
* 计数表按 sourceType 字典序排列(对象键序不保证,渲染须确定)。
*/
export function renderReportHtml(report: WxrAdapterReport): string {
const lines: string[] = []
lines.push('<h2>WXR 导入前报告</h2>')
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(
'<table><thead><tr><th>sourceType</th><th>Created</th><th>Skipped(无变化)</th>' +
'<th>Failed</th><th>Unsupported</th></tr></thead><tbody>',
)
const types = Object.keys(report.counts).sort()
if (types.length === 0) {
lines.push('<tr><td colspan="5">(无)</td></tr>')
}
for (const type of types) {
const c = report.counts[type]
if (c === undefined) continue
lines.push(
`<tr><td>${esc(type)}</td><td>${c.created}</td><td>${c.skipped}</td>` +
`<td>${c.failed}</td><td>${c.unsupported}</td></tr>`,
)
}
lines.push('</tbody></table>')
lines.push('<h3>汇总</h3>')
lines.push(
`<ul><li>rows: ${report.totals.rows}</li><li>terms: ${report.totals.terms}</li>` +
`<li>unsupported: ${report.totals.unsupported}</li><li>failed: ${report.totals.failed}</li></ul>`,
)
lines.push('<h3>清洗统计</h3>')
lines.push(
`<ul><li>removed-tag: ${report.sanitize.byDiffType['removed-tag']}</li>` +
`<li>removed-attr: ${report.sanitize.byDiffType['removed-attr']}</li>` +
`<li>changed rows: ${report.sanitize.changedRows}</li></ul>`,
)
lines.push('<h3>行级错误</h3>')
if (report.rowErrors.length === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const entry of report.rowErrors) {
const excerpt =
entry.excerpt === undefined ? '' : `<blockquote>${esc(entry.excerpt)}</blockquote>`
lines.push(`<li>${esc(entry.rawLocation)}:${esc(entry.message)}${excerpt}</li>`)
}
lines.push('</ul>')
}
if (report.fatalError !== null) {
lines.push(`<h3>致命错误</h3><p>${esc(report.fatalError)}</p>`)
}
lines.push('<h3>耗时</h3>')
lines.push(
`<ul><li>parse: ${report.timing.parseMs.toFixed(3)}ms</li>` +
`<li>sanitize: ${report.timing.sanitizeMs.toFixed(3)}ms</li>` +
`<li>total: ${report.timing.totalMs.toFixed(3)}ms</li></ul>`,
)
return lines.join('\n')
}
@@ -0,0 +1,28 @@
// 适配器版本常量(Plan 2 Task 6):导入前报告的首条版本块(spec §7.7)由此单源供给。
//
// bump 语义(JSDoc 冻结,未来随各模块实质变更 bump 对应项):
// - ADAPTER_VERSION:全链组装行为变更(事件契约/清洗接入方式/报告聚合语义)时 bump;
// - MAPPING_VERSION:**映射表**实质变更时 bump——DEFAULT_TYPE_ROUTES 路由增删、
// STATUS_MAP 值域、META_KEYS 分流键或字段映射语义(含 knownFields 键名约定);
// - SANITIZER_VERSION:**清洗白名单**实质变更时 bump——SANITIZE_CONFIG 的标签/属性/
// 协议白名单任何增删改(差异收集机制本身不变更产出时可不 bump)。
// 三者独立演进:改映射表不必 bump 清洗版本,反之亦然。下游(阶段 3 导入)以版本块
// 判断是否需要重新标准化(spec §7.5:适配器、映射或清洗版本变化可显式触发)。
//
// 版本号不 bump 的变更:纯 bug 修复(恢复既定语义)、测试、文档、内部性能优化。
/** 适配器组装版本(parse → map → sanitize 全链行为) */
export const ADAPTER_VERSION = '1.0.0'
/** 映射表版本(类型路由/status 值域/meta 分流/字段映射语义) */
export const MAPPING_VERSION = '1.0.0'
/** 清洗白名单版本(SANITIZE_CONFIG 标签/属性/协议白名单) */
export const SANITIZER_VERSION = '1.0.0'
/**
* 解析库版本(硬编码常量):saxes 实际解析版本,升级依赖(package.json + lockfile)
* 时须同步改这里。不读 package.json 的原因:运行时读文件引入 IO 与路径假设,且
* `^6.0.0` 语义版本范围不等于实际解析版本——显式常量更诚实,漂移由评审把关。
*/
export const PARSER_VERSION = 'saxes 6.0.0'