Files
cms-website/packages/adapter-wordpress/test/wxr-stream.test.ts
T
浅念 25e8b9cb5c 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>
2026-10-21 08:55:11 +08:00

365 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createReadStream } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import {
parseWxrStream,
WxrError,
WxrLimitError,
WxrParseError,
type WxrEvent,
type WxrItem,
} from '../src/parse/wxr-stream.js'
// mini.xml(Plan 1 夹具):5 item(post 101 / product 111 / page 121 / attachment 201,202)
// + 2 term(31 category / 32 product_cat);term 块在第三个 item 之后、attachment 之前。
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** 整串一次性喂入(单 chunk 路径) */
function fromString(xml: string): Readable {
const s = new PassThrough()
s.end(xml)
return s
}
/** 按字节切块喂入(跨 chunk 的 UTF-8 多字节与 token 边界路径;7 字节必拆中文与 ]]>) */
function fromStringChunked(xml: string, size = 7): Readable {
return Readable.from(
(function* () {
const buf = Buffer.from(xml, 'utf8')
for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size)
})(),
)
}
async function collect(gen: AsyncGenerator<WxrEvent>): Promise<WxrEvent[]> {
const out: WxrEvent[] = []
for await (const ev of gen) out.push(ev)
return out
}
/** 捕获 reject 的错误实例(而非仅匹配),以便断言 kind / line / column 细节(照 php-unserialize 测试先例) */
async function captureRejection(p: Promise<unknown>): Promise<unknown> {
try {
await p
} catch (e) {
return e
}
throw new Error('期望抛出但未抛出')
}
function itemsOf(events: WxrEvent[]): WxrItem[] {
return events
.filter((ev): ev is Extract<WxrEvent, { kind: 'item' }> => ev.kind === 'item')
.map((ev) => ev.item)
}
/** 概要签名:item:<postId>:<postType> / term:<termId>:<taxonomy>(按文档序) */
function signature(events: WxrEvent[]): string[] {
return events.map((ev) =>
ev.kind === 'item'
? `item:${ev.item.postId}:${ev.item.postType}`
: `term:${ev.term.termId}:${ev.term.taxonomy}`,
)
}
describe('parseWxrStream:mini.xml 流式产出', () => {
it('产出顺序与文档一致:post → product → page → term → term → attachment → attachment', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('跨 chunk 切块喂入(7 字节/块)产出与单 chunk 完全一致', async () => {
const { readFileSync } = await import('node:fs')
const xml = readFileSync(FIXTURE, 'utf8')
const events = await collect(parseWxrStream(fromStringChunked(xml)))
expect(signature(events)).toStrictEqual([
'item:101:post',
'item:111:product',
'item:121:page',
'term:31:category',
'term:32:product_cat',
'item:201:attachment',
'item:202:attachment',
])
})
it('item 事件字段完整:post 101 逐一断言具体值', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item).toBeDefined()
expect(item).toMatchObject({
postId: 101,
postType: 'post',
status: 'publish',
postName: 'fixture-news-a',
link: 'https://fixture.example/news/fixture-news-a.html',
title: 'Fixture News A',
contentEncoded:
'<p>Body with <a href="/products/fixture-product/">link</a> and <img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"/></p>',
excerptEncoded: 'Fixture excerpt A',
postDate: '2026-10-05 09:00:00',
postModified: '2026-10-06 10:00:00',
attachmentUrl: '',
creator: 'editor',
})
})
it('attachment item:attachmentUrl 与 inherit 状态', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const a201 = itemsOf(events).find((it) => it.postId === 201)
expect(a201).toMatchObject({
postType: 'attachment',
status: 'inherit',
attachmentUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png',
})
})
it('term 事件字段:term_id/taxonomy/slug/name(term 31)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const term = events.find((ev) => ev.kind === 'term' && ev.term.termId === 31)
expect(term).toBeDefined()
expect(term?.kind === 'term' && term.term).toMatchObject({
termId: 31,
taxonomy: 'category',
slug: 'company',
name: 'Company',
})
})
it('postmeta 聚合:post 101 恰 3 条(_thumbnail_id + Yoast title/metadesc)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 101)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '201' },
{ key: '_yoast_wpseo_title', value: 'Fixture SEO Title A' },
{ key: '_yoast_wpseo_metadesc', value: 'Fixture SEO description A.' },
])
})
it('postmeta 聚合:product 111 恰 3 条(gallery 与 wppop_sections 原样字符串)', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const item = itemsOf(events).find((it) => it.postId === 111)
expect(item?.metas).toStrictEqual([
{ key: '_thumbnail_id', value: '202' },
{ key: '_product_image_gallery', value: '203,204' },
{
key: 'wppop_sections',
value: 'a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}',
},
])
})
it('category 元素聚合进 item 事件:post 101 → category/company/Company;product 111 无', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
const post101 = itemsOf(events).find((it) => it.postId === 101)
expect(post101?.categories).toStrictEqual([{ domain: 'category', nicename: 'company', value: 'Company' }])
const product111 = itemsOf(events).find((it) => it.postId === 111)
expect(product111?.categories).toStrictEqual([])
})
it('rawLocation:wxr://item/<文档内序号从1起>,term 同理', async () => {
const events = await collect(parseWxrStream(createReadStream(FIXTURE)))
expect(itemsOf(events).map((it) => it.rawLocation)).toStrictEqual([
'wxr://item/1',
'wxr://item/2',
'wxr://item/3',
'wxr://item/4',
'wxr://item/5',
])
expect(
events
.filter((ev) => ev.kind === 'term')
.map((ev) => (ev.kind === 'term' ? ev.term.rawLocation : '')),
).toStrictEqual(['wxr://term/1', 'wxr://term/2'])
})
})
// 标题 CDATA 长 16 字符:供 maxCdataLength / maxFieldLength(=10)触发超限
const SMALL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<item><title><![CDATA[AAAAAAAAAAAAAAAA]]></title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[BBBBBBBBBBBBBBBB]]></title><wp:post_id>2</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
<item><title><![CDATA[CCCCCCCCCCCCCCCC]]></title><wp:post_id>3</wp:post_id><wp:post_type>post</wp:post_type><wp:status>publish</wp:status></item>
</channel>
</rss>`
describe('parseWxrStream:限额防线(WxrLimitError,kind 区分)', () => {
it('maxItems=2:第 3 个 item 开始前抛 kind items', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxItems: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err).toBeInstanceOf(WxrError)
expect(err.kind).toBe('items')
})
it('maxCdataLength=10:超长 CDATA 抛 kind cdata', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxCdataLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('cdata')
})
it('maxFieldLength=10:单字段累计超限抛 kind fieldLength(防单个 title 灌爆内存)', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxFieldLength: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('fieldLength')
})
it('maxDepth=2:rss>channel>item 嵌套超限抛 kind depth', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxDepth: 2 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('depth')
})
it('timeBudgetMs:可注入时钟超预算 → kind time', async () => {
let calls = 0
const now = () => (calls++ === 0 ? 0 : 1_000_000) // 第 1 次取起点,其后均"超预算"
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { timeBudgetMs: 120_000 }, now })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('time')
})
it('maxTermCount=1:第 2 个 term 抛 kind terms', async () => {
const xml = SMALL_XML.replace(
'</channel>',
'<wp:term><wp:term_id>1</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>a</wp:term_slug><wp:term_name>A</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>2</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy><wp:term_slug>b</wp:term_slug><wp:term_name>B</wp:term_name></wp:term></channel>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxTermCount: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('terms')
})
it('maxWxrBytes:累计字节数超限抛 kind bytes', async () => {
const err = (await captureRejection(
collect(parseWxrStream(fromString(SMALL_XML), { limits: { maxWxrBytes: 10 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('bytes')
})
it('maxMetaPerItem:单 item postmeta 数超限抛 kind metaPerItem', async () => {
const xml = SMALL_XML.replace(
'</item>',
'<wp:postmeta><wp:meta_key><![CDATA[k1]]></wp:meta_key><wp:meta_value><![CDATA[v1]]></wp:meta_value></wp:postmeta>' +
'<wp:postmeta><wp:meta_key><![CDATA[k2]]></wp:meta_key><wp:meta_value><![CDATA[v2]]></wp:meta_value></wp:postmeta></item>',
)
const err = (await captureRejection(
collect(parseWxrStream(fromString(xml), { limits: { maxMetaPerItem: 1 } })),
)) as WxrLimitError
expect(err).toBeInstanceOf(WxrLimitError)
expect(err.kind).toBe('metaPerItem')
})
})
describe('parseWxrStream:外部实体防线(不解析实体、不注入替换文本、不访问网络)', () => {
// 选定行为(钉死):saxes 6 实体表固定为 XML 五预定义 + 数字字符引用,DOCTYPE
// 内部/SYSTEM 实体声明一律不注册——引用即 "undefined entity." 结构错误(WxrParseError),
// 替换文本绝不进入任何产出字段;saxes 为纯字符串解析器,外部 SYSTEM 标识符从不被抓取。
const SECRET = 'SECRET-REPLACEMENT-TEXT'
async function withTempEntityXml(
doctype: string,
body: string,
run: (path: string) => Promise<void>,
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'wxr-entity-'))
const path = join(dir, 'entity.xml')
await writeFile(
path,
`<?xml version="1.0" encoding="UTF-8"?>\n${doctype}\n<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>${body}</channel></rss>`,
'utf8',
)
try {
await run(path)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
it('DOCTYPE 内部 ENTITY:引用处抛 WxrParseError,替换文本不进产出(临时文件 + createReadStream)', async () => {
await withTempEntityXml(
`<!DOCTYPE rss [<!ENTITY xxe "${SECRET}">]>`,
'<item><title>&xxe;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const events: WxrEvent[] = []
const err = (await captureRejection(
(async () => {
for await (const ev of parseWxrStream(createReadStream(path))) events.push(ev)
})(),
)) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
// 无论解析走到哪一步:替换文本绝不出现于任何已产出事件
expect(JSON.stringify(events)).not.toContain(SECRET)
},
)
})
it('SYSTEM 外部实体:同样拒绝(不解析、不抓取——无任何网络 I/O)', async () => {
await withTempEntityXml(
'<!DOCTYPE rss [<!ENTITY ext SYSTEM "file:///etc/passwd">]>',
'<item><title>&ext;</title><wp:post_id>1</wp:post_id><wp:post_type>post</wp:post_type></item>',
async (path) => {
const err = (await captureRejection(collect(parseWxrStream(createReadStream(path))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.message).toContain('undefined entity')
},
)
})
it('预定义实体不受影响:&amp; / 数字字符引用正常解析', async () => {
const xml =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
'<item><title>plain</title><wp:post_name>a&amp;b&#65;</wp:post_name><wp:post_id>7</wp:post_id><wp:post_type>post</wp:post_type></item>' +
'</channel></rss>'
const events = await collect(parseWxrStream(fromString(xml)))
expect(itemsOf(events)[0]?.postName).toBe('a&bA')
})
})
describe('parseWxrStream:畸形 XML → WxrParseError(带 saxes 行列位置)', () => {
it('提前闭合:精确 line/column(4:17)与原始信息', async () => {
const xml =
'<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel></item></rss>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err).toBeInstanceOf(WxrError)
expect(err.line).toBe(4)
expect(err.column).toBe(17)
expect(err.message).toContain('unexpected close tag')
})
it('未闭合(截断)文件:close 阶段报错,行列为有效正数', async () => {
const xml = '<?xml version="1.0"?>\n<rss><channel>\n<item><title>a</title></item>\n</channel>'
const err = (await captureRejection(collect(parseWxrStream(fromString(xml))))) as WxrParseError
expect(err).toBeInstanceOf(WxrParseError)
expect(err.line).toBeGreaterThanOrEqual(1)
expect(err.column).toBeGreaterThanOrEqual(0)
expect(err.message).toContain('unclosed')
})
})