feat(adapter-wp): saxes 流式 WXR 解析器

parseWxrStream(readable, {limits, now}) → AsyncGenerator<WxrEvent>:
逐 chunk 喂入 saxes(^6.0.0,ISC;计划所写 ^0.9 不存在于版本历史),
item/term 按文档序交错产出;任一时刻只持有当前聚合状态 + 单 chunk 内
待 yield 队列,绝不收集已产出 item 列表(峰值内存与文件大小解耦)。

- 字段:postId(数值解析)/postType/status/postName/link/title/
  contentEncoded/excerptEncoded/postDate/postModified/attachmentUrl/
  creator + metas{key,value}[] + categories{domain,nicename,value}[] +
  rawLocation(wxr://item/<n>、wxr://term/<n>)
- 错误体系照 PhpError 先例:WxrError 基类;WxrParseError 带 saxes
  行列位置(error 事件同步抛出时捕获);WxrLimitError 按 kind 区分
- 限额:bytes/items/terms/depth/cdata/fieldLength(先检查后追加,
  超限内容永不入缓冲)/metaPerItem/time(可注入时钟)——超限即抛,
  不静默截断
- 实体防线(测试钉死):saxes 实体表固定为五预定义 + 数字引用,
  DOCTYPE 内部/SYSTEM 实体不注册——引用即 WxrParseError,替换文本
  不进产出;纯字符串解析,全程无网络 I/O
- 跨 chunk 喂入(7 字节/块)与单 chunk 产出一致(StringDecoder 处理
  多字节边界);畸形 XML(提前闭合/截断)→ WxrParseError 带行列

130 测试全绿(新增 22)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 08:55:11 +08:00
parent 6eb5fa6dda
commit 25e8b9cb5c
5 changed files with 772 additions and 1 deletions
+1
View File
@@ -1,2 +1,3 @@
export * from './limits.js'
export * from './parse/php-unserialize.js'
export * from './parse/wxr-stream.js'
@@ -0,0 +1,389 @@
// saxes 流式 WXR 解析器(Plan 2 Task 3,ADR-005):
// Node Readable(WXR 文件字节流)→ AsyncGenerator<WxrEvent>,逐个产出 item/term 事件。
//
// 流式纪律:任一时刻只持有"当前 item/term 的聚合状态"与一个待 yield 队列(队列仅在
// 单个 write() chunk 内暂存已完成事件、紧随其后排空,绝不跨 chunk 累积)——不存在
// 已产出 item 的列表;峰值内存与文件大小解耦(Task 8 以真实 5.5MB 文件实测兜底)。
//
// 实体防线(前置事实 4,测试钉死):saxes 6 的实体表固定为 XML 五预定义实体
// (amp/gt/lt/quot/apos)+ 数字字符引用,DOCTYPE 内部/SYSTEM 实体声明一律不注册——
// 引用未定义实体即结构错误 "undefined entity."(WxrParseError)。saxes 是纯字符串
// 解析器:外部 SYSTEM 标识符从不被抓取,解析全程无任何网络 I/O,替换文本不可能进入产出。
//
// 限额防线(WxrLimits,超限抛 WxrLimitError 而非静默截断):字节累计(bytes)、
// item/term 计数(items/terms)、嵌套深度(depth)、单 CDATA 节长度(cdata)、
// 单字段累计长度(fieldLength——先检查后追加,超限文本永不入缓冲,防单个 title 灌爆
// 内存)、单 item postmeta 数(metaPerItem)、总时限(time,可注入时钟 now() 测试)。
//
// 错误体系照 PhpError 先例:WxrError 公共基类;WxrParseError 带 saxes 行列位置
// (在 error 事件同步抛出时捕获 parser.line/column,即出错瞬间的准确位置);
// WxrLimitError 以 kind 区分限额维度。
import { StringDecoder } from 'node:string_decoder'
import type { Readable } from 'node:stream'
import { SaxesParser, type SaxesTagPlain } from 'saxes'
import { DEFAULT_WXR_LIMITS, type WxrLimits } from '../limits.js'
/** item 内 <wp:postmeta> 聚合产物:{key, value} 原样字符串(PHP 串解析是 Task 4 的职责) */
export interface WxrPostmeta {
key: string
value: string
}
/** item 内 <category domain nicename>text</category> 聚合产物 */
export interface WxrCategoryRef {
domain: string
nicename: string
value: string
}
/** WXR <item> 的解析产物(字段即 WordPress 导出结构;缺省字段为 '' / null / 空数组) */
export interface WxrItem {
/** 来源定位:wxr://item/<文档内序号从1起>(按 item 出现顺序编号) */
rawLocation: string
/** <wp:post_id>(整数解析;缺失/非数字 → null) */
postId: number | null
/** <wp:post_type> */
postType: string
/** <wp:status> */
status: string
/** <wp:post_name> */
postName: string
/** <link> */
link: string
/** <title>(CDATA 已解包) */
title: string
/** <content:encoded>(CDATA 已解包,原文不清洗——清洗是 Task 5 的职责) */
contentEncoded: string
/** <excerpt:encoded>(CDATA 已解包) */
excerptEncoded: string
/** <wp:post_date>(原始字符串,格式化是下游职责) */
postDate: string
/** <wp:post_modified> */
postModified: string
/** <wp:attachment_url>(非 attachment 为 '') */
attachmentUrl: string
/** <dc:creator> */
creator: string
/** <wp:postmeta> 按文档序聚合 */
metas: WxrPostmeta[]
/** <category> 按文档序聚合 */
categories: WxrCategoryRef[]
}
/** WXR <wp:term> 的解析产物 */
export interface WxrTerm {
/** 来源定位:wxr://term/<文档内序号从1起> */
rawLocation: string
/** <wp:term_id>(整数解析;缺失/非数字 → null) */
termId: number | null
/** <wp:term_taxonomy> */
taxonomy: string
/** <wp:term_slug> */
slug: string
/** <wp:term_name> */
name: string
}
/** 解析器产出事件:kind 判别联合(item 与 term 按文档序交错产出) */
export type WxrEvent = { kind: 'item'; item: WxrItem } | { kind: 'term'; term: WxrTerm }
/** 本模块错误公共基类:统一 name 约定(仅经子类实例化,照 PhpError 先例) */
export abstract class WxrError extends Error {
constructor(name: string, message: string) {
super(message)
this.name = name
}
}
/** 结构/语法错误(含 saxes 原始信息):line/column 为出错瞬间 saxes 报告的位置 */
export class WxrParseError extends WxrError {
readonly line: number
readonly column: number
constructor(message: string, line: number, column: number) {
super('WxrParseError', `${message}(line ${line}, column ${column})`)
this.line = line
this.column = column
}
}
/** 限额错误 kind:与 WxrLimits 的流式解析维度一一对应(任务清单六种 + bytes/metaPerItem 补全契约全维度) */
export type WxrLimitKind =
| 'items'
| 'terms'
| 'depth'
| 'cdata'
| 'fieldLength'
| 'time'
| 'bytes'
| 'metaPerItem'
/** 限额错误(区别于 WxrParseError):kind 指明超限维度 */
export class WxrLimitError extends WxrError {
readonly kind: WxrLimitKind
constructor(kind: WxrLimitKind, message: string) {
super('WxrLimitError', message)
this.kind = kind
}
}
/** parseWxrStream 选项:limits 支持部分覆盖(缺省维度取 DEFAULT_WXR_LIMITS);now 可注入时钟(测试 time 预算) */
export interface WxrStreamOptions {
limits?: Partial<WxrLimits>
now?: () => number
}
/** item 直接子字段:元素限定名 → WxrItem 字段(数值字段 postId 在提交时单独解析) */
const ITEM_FIELDS: Record<string, keyof WxrItem> = {
'wp:post_id': 'postId',
title: 'title',
link: 'link',
'content:encoded': 'contentEncoded',
'excerpt:encoded': 'excerptEncoded',
'wp:post_date': 'postDate',
'wp:post_modified': 'postModified',
'wp:post_name': 'postName',
'wp:post_type': 'postType',
'wp:status': 'status',
'wp:attachment_url': 'attachmentUrl',
'dc:creator': 'creator',
}
/** term 直接子字段:元素限定名 → WxrTerm 字段(数值字段 termId 在提交时单独解析) */
const TERM_FIELDS: Record<string, keyof WxrTerm> = {
'wp:term_id': 'termId',
'wp:term_taxonomy': 'taxonomy',
'wp:term_slug': 'slug',
'wp:term_name': 'name',
}
/** 当前文本捕获目标:一个元素一个目标,闭标签时提交并清空 */
type Target =
| { el: string; owner: 'item'; field: keyof WxrItem }
| { el: string; owner: 'term'; field: keyof WxrTerm }
| { el: string; owner: 'metaKey' }
| { el: string; owner: 'metaValue' }
| { el: string; owner: 'category'; domain: string; nicename: string }
function parseIntOrNull(text: string): number | null {
const n = Number.parseInt(text.trim(), 10)
return Number.isFinite(n) ? n : null
}
/**
* 流式解析 WXR 文件:逐 chunk 喂入 saxes、逐事件产出,绝不整文件进内存、绝不访问网络。
*
* 产出契约:
* - item/term 按文档序交错产出;每个事件在其闭合标签被处理后立即排队
* - 限额/结构错误以 WxrLimitError / WxrParseError 拒绝整个流(已 yield 的事件不撤回)
* - 消费方提前 break/throw 时销毁上游流(finally 兜底)
*/
export async function* parseWxrStream(
readable: Readable,
opts: WxrStreamOptions = {},
): AsyncGenerator<WxrEvent, void, void> {
const limits: WxrLimits = { ...DEFAULT_WXR_LIMITS, ...opts.limits }
const now = opts.now ?? Date.now
const startedAt = now()
const decoder = new StringDecoder('utf8')
const parser = new SaxesParser({ position: true, xmlns: false, fragment: false })
const queue: WxrEvent[] = []
let totalBytes = 0
let depth = 0
let itemIndex = 0
let termIndex = 0
let mode: 'outside' | 'item' | 'term' = 'outside'
let item: WxrItem | null = null
let term: WxrTerm | null = null
let target: Target | null = null
let buf = ''
let meta: WxrPostmeta | null = null
const fail = (kind: WxrLimitKind, message: string): never => {
throw new WxrLimitError(kind, `${message}(line ${parser.line}, column ${parser.column})`)
}
/** 追加文本到当前字段缓冲:先限额检查后追加——超限内容永不入缓冲(内存上界保证) */
const append = (text: string, isCdata: boolean): void => {
if (isCdata && text.length > limits.maxCdataLength) {
fail('cdata', `CDATA 长度 ${text.length} 超过上限 ${limits.maxCdataLength}`)
}
if (target === null) return // 字段元素之外的文本(标签间空白等)——丢弃
if (buf.length + text.length > limits.maxFieldLength) {
fail(
'fieldLength',
`字段 <${target.el}> 累计长度 ${buf.length + text.length} 超过上限 ${limits.maxFieldLength}`,
)
}
buf += text
}
parser.on('error', (err: Error) => {
// 同步抛出即刻中止 write()/close();此刻 parser.line/column 即出错位置(测试钉死)
throw new WxrParseError(`saxes: ${err.message}`, parser.line ?? 0, parser.column ?? 0)
})
parser.on('opentag', (tag: SaxesTagPlain) => {
const name = tag.name
depth++
if (depth > limits.maxDepth) {
fail('depth', `嵌套深度 ${depth} 超过上限 ${limits.maxDepth}(<${name}>)`)
}
if (mode === 'outside' && name === 'item') {
itemIndex++
if (itemIndex > limits.maxItems) {
fail('items', `item 数 ${itemIndex} 超过上限 ${limits.maxItems}`)
}
mode = 'item'
item = {
rawLocation: `wxr://item/${itemIndex}`,
postId: null,
postType: '',
status: '',
postName: '',
link: '',
title: '',
contentEncoded: '',
excerptEncoded: '',
postDate: '',
postModified: '',
attachmentUrl: '',
creator: '',
metas: [],
categories: [],
}
return
}
if (mode === 'outside' && name === 'wp:term') {
termIndex++
if (termIndex > limits.maxTermCount) {
fail('terms', `term 数 ${termIndex} 超过上限 ${limits.maxTermCount}`)
}
mode = 'term'
term = {
rawLocation: `wxr://term/${termIndex}`,
termId: null,
taxonomy: '',
slug: '',
name: '',
}
return
}
if (mode === 'item' && item !== null) {
if (meta !== null) {
// postmeta 内部:key/value 二选一
if (name === 'wp:meta_key') target = { el: name, owner: 'metaKey' }
else if (name === 'wp:meta_value') target = { el: name, owner: 'metaValue' }
} else if (name === 'wp:postmeta') {
if (item.metas.length + 1 > limits.maxMetaPerItem) {
fail(
'metaPerItem',
`item ${item.rawLocation} 的 postmeta 数 ${item.metas.length + 1} 超过上限 ${limits.maxMetaPerItem}`,
)
}
meta = { key: '', value: '' }
} else if (name === 'category') {
target = {
el: name,
owner: 'category',
domain: tag.attributes['domain'] ?? '',
nicename: tag.attributes['nicename'] ?? '',
}
} else if (target === null && ITEM_FIELDS[name] !== undefined) {
target = { el: name, owner: 'item', field: ITEM_FIELDS[name] as keyof WxrItem }
}
return
}
if (mode === 'term' && term !== null && target === null && TERM_FIELDS[name] !== undefined) {
target = { el: name, owner: 'term', field: TERM_FIELDS[name] as keyof WxrTerm }
}
})
parser.on('text', (text: string) => {
append(text, false)
})
parser.on('cdata', (text: string) => {
append(text, true)
})
parser.on('closetag', (tag: SaxesTagPlain) => {
const name = tag.name
depth--
// 先提交文本字段(字段闭标签紧邻结构闭标签之前)
if (target !== null && target.el === name) {
if (target.owner === 'item' && item !== null) {
if (name === 'wp:post_id') item.postId = parseIntOrNull(buf)
else (item[target.field] as string) = buf
} else if (target.owner === 'term' && term !== null) {
if (name === 'wp:term_id') term.termId = parseIntOrNull(buf)
else (term[target.field] as string) = buf
} else if (target.owner === 'metaKey' && meta !== null) {
meta.key = buf
} else if (target.owner === 'metaValue' && meta !== null) {
meta.value = buf
} else if (target.owner === 'category' && item !== null) {
item.categories.push({ domain: target.domain, nicename: target.nicename, value: buf })
}
target = null
buf = ''
}
if (name === 'wp:postmeta' && mode === 'item' && item !== null && meta !== null) {
item.metas.push(meta)
meta = null
return
}
if (name === 'item' && mode === 'item' && item !== null) {
queue.push({ kind: 'item', item })
item = null
mode = 'outside'
return
}
if (name === 'wp:term' && mode === 'term' && term !== null) {
queue.push({ kind: 'term', term })
term = null
mode = 'outside'
}
})
try {
for await (const chunk of readable as AsyncIterable<Buffer | string>) {
totalBytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
if (totalBytes > limits.maxWxrBytes) {
fail('bytes', `累计输入 ${totalBytes} 字节超过上限 ${limits.maxWxrBytes}`)
}
const elapsed = now() - startedAt
if (elapsed > limits.timeBudgetMs) {
throw new WxrLimitError('time', `处理时长 ${elapsed}ms 超过预算 ${limits.timeBudgetMs}ms`)
}
parser.write(decoder.write(chunk))
// 排空本次 chunk 内完成的事件(队列不跨 chunk 累积——结构即流式证明)
while (queue.length > 0) {
const ev = queue.shift()
if (ev !== undefined) yield ev
}
}
// StringDecoder 尾部(跨 chunk 的多字节残余)先写空再 close——saxes 类型签名的 close() 无参
const tail = decoder.end()
if (tail !== '') parser.write(tail)
parser.close()
while (queue.length > 0) {
const ev = queue.shift()
if (ev !== undefined) yield ev
}
} finally {
// 消费方提前退出或解析失败:销毁上游流,避免泄漏(已完成/已失败流 destroy 为无害幂等)
readable.destroy()
}
}