docs(plan): Plan 2 实施计划——阶段2 WXR 流式适配器(9任务)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# SiteForge 纵向切片 · Plan 2:WXR 流式适配器(阶段 2)实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 建立 `source:register` 输入登记(content-addressed 快照)与 WXR 流式解析适配器:任意 WXR → 限额安全的流式解析 → UniversalRow 流 + 未支持对象/清洗差异报告;真实 hesupipe WXR(5.5MB)冒烟计数对照基线通过。
|
||||
|
||||
**Architecture:** 依据设计基线 `docs/superpowers/specs/2026-10-20-siteforge-vertical-slice-design-v2.md` §7(WXR 导入管道)、§15 阶段 2、ADR-004/005/006/009。新增 `packages/adapter-wordpress`(saxes 流式解析 + PHP unserialize + 映射器 + HTML 清洗,纯函数无 DB);`apps/api` 内新增 source 模块(快照登记服务,复用既有表);`scripts/sf.ts` CLI 入口。Plan 1 地基复用:contracts 守卫、21 表、globalSetup 测试基建、mini.xml 夹具。
|
||||
|
||||
**Tech Stack:** saxes(流式 SAX 解析)、commander(CLI)、sanitize-html(白名单清洗)+ 既有栈。**新增依赖三条 ADR 纪律**:锁版本、检查许可证(saxes ISC / commander MIT / sanitize-html MIT 均可)、记入 ADR-016。
|
||||
|
||||
**边界(不做)**:内容入库/修订写入(阶段 3)、媒体魔数校验与 usage(阶段 3)、REST 通道、增量/差量、爬虫。
|
||||
|
||||
---
|
||||
|
||||
## 前置事实(执行者必读)
|
||||
|
||||
1. Plan 1 已收官:75 测试全绿、21 表、四契约冻结(**UniversalRow 任何字段变更须升版**——本阶段只消费不修改)。
|
||||
2. **Plan 1 移交的责任清单**(本计划落实):canonical `status` 值域钉在转换测试;适配器 property 测试(taxonomyRefs/attachmentRefs 元素形状、sourceId/sourceTermId 非空);如需字段级错误用**加法式** `validateUniversalRow(): issue[]`(不动布尔守卫)。
|
||||
3. 夹具:`tests/fixtures/wxr/mini.xml`(5 item/2 term,含 wppop_sections PHP 串与**有意悬空**的图库引用 203/204);真实基线:`docs/evidence/real-wxr-baseline.md`(1122 item = 855 attachment/129 post/48 product/15 page/68 nav_menu_item/3 wpcf7_contact_form/2 elementor_library/2 wp_navigation;451 term)。
|
||||
4. 解析器**绝不访问网络**(saxes 默认不解析外部实体——用测试钉死);媒体输入只接受已解包目录。
|
||||
5. 既有纪律:TDD(红→绿→commit)、测试不碰开发库(globalSetup 临时库 + `DATABASE_URL_TEST`)、身份断言、无包级 test 脚本、typecheck 门禁、cms 目录绝对只读。
|
||||
|
||||
## 仓库结构增量
|
||||
|
||||
```text
|
||||
cms-one/
|
||||
packages/adapter-wordpress/
|
||||
src/parse/wxr-stream.ts # saxes 流式解析 → AsyncIterable<WxrItem|WxrTerm>
|
||||
src/parse/php-unserialize.ts # 受限 PHP serialize 解析(wppop_sections)
|
||||
src/map/item-to-row.ts # WxrItem → UniversalRow(类型路由/meta 提取/known-extras 分流)
|
||||
src/sanitize/html.ts # 白名单清洗 + 差异收集
|
||||
src/adapter.ts # 组装:parse→map→sanitize → AsyncIterable<RowResult>
|
||||
src/limits.ts # 限额配置类型与默认值
|
||||
test/*.test.ts # 纯单元(不连库)
|
||||
apps/api/src/modules/source/
|
||||
register.service.ts # 快照登记(copy→sha256→source_snapshots 落库)
|
||||
source.module.ts
|
||||
scripts/sf.ts # CLI(source:register / wxr:probe)
|
||||
tests/integration/source-register.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: adapter-wordpress 包骨架 + 限额类型
|
||||
|
||||
**Files:** Create `packages/adapter-wordpress/package.json`(@sf/adapter-wp,无 runtime 依赖本任务)、`tsconfig.json`/`tsconfig.build.json`(照 contracts 先例)、`src/limits.ts`;Test `test/limits.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试:默认限额值冻结(maxWxrBytes 64MB、maxItems 200k、maxDepth 64、maxCdataLength 1MB、maxTermCount 50k、maxMetaPerItem 200、timeBudgetMs 120s、phpMaxDepth 8、phpMaxElements 1000)+ 类型字段齐全
|
||||
- [ ] Step 2 实现 `WxrLimits` 接口 + `DEFAULT_WXR_LIMITS` 常量(as const)+ 类型锁(照 contracts 单源派生先例锁值域)
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): 包骨架与限额契约`
|
||||
|
||||
### Task 2: PHP unserialize 受限解析器
|
||||
|
||||
**Files:** Create `src/parse/php-unserialize.ts`;Test `test/php-unserialize.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试(TDD 用例集):
|
||||
- 夹具真实串 `a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}` → 深层对象等值
|
||||
- 标量串 `s:5:"hello"` / `i:42` / `b:1`
|
||||
- **长度前缀错误**(s:7:"SpecsTab")→ 抛 `PhpParseError`(不静默)
|
||||
- 深度超 phpMaxDepth → 抛限额错误;元素数超 phpMaxElements → 抛
|
||||
- 空串/截断串 → 抛(带位置信息)
|
||||
- [ ] Step 2 实现递归下降解析器(约 80 行:i:/b:/d:/s:/a: 四类型,NUL 分隔,位置追踪)
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): PHP serialize 受限解析器`
|
||||
- [ ] 注:只支持 saxes 产出的字符串输入;**绝不 eval**;深度/元素数双限额
|
||||
|
||||
### Task 3: saxes 流式 WXR 解析器
|
||||
|
||||
**Files:** Create `src/parse/wxr-stream.ts`(依赖 saxes,锁 ^0.9);Test `test/wxr-stream.test.ts`(用 mini.xml)
|
||||
|
||||
- [ ] Step 1 失败测试:
|
||||
- mini.xml 流式产出 5 个 item + 2 个 term(顺序与文档一致);每 item 含 post_id/post_type/status/post_name/link/title/content:encoded/excerpt/post_date/post_modified/attachment_url/全部 postmeta/以及**原始字节区间**(rawLocation: `wxr://item/<序号>`)
|
||||
- term 产出:term_id/taxonomy/slug/name
|
||||
- CDATA 正确解包;`<wp:postmeta>` 聚合为 `{key, value}[]`
|
||||
- **限额**:maxItems=2 时跑到第 3 个 item 抛限额错误;maxCdataLength=10 时超长 CDATA 抛
|
||||
- [ ] Step 2 实现:`parseWxrStream(readable: Readable, limits): AsyncGenerator<WxrEvent>`——saxes 前缀解析、状态机聚合 item/term、每 item 完成即 yield(**不整文件进内存**:断言峰值内存——用大夹具在 Task 8 验证,此处以"不 buffer item 列表"的代码结构保证)
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): saxes 流式 WXR 解析器`
|
||||
- [ ] **安全测试并入**:外部实体(`<!ENTITY` DOCTYPE)→ saxes 配置拒绝/不解析(`additionalEntities: false` 或等价);畸形 XML 提前闭合 → 抛带位置错误
|
||||
|
||||
### Task 4: WxrItem → UniversalRow 映射器
|
||||
|
||||
**Files:** Create `src/map/item-to-row.ts`;Test `test/item-to-row.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试(**Plan 1 移交责任清单在此落实**):
|
||||
- post(101) → sourceType 'wp:post'、sourceUrl=permalink、slug=fixture-news-a、seo={title,description}(Yoast)、taxonomyRefs=[{category,31}]、attachmentRefs=[{role:'featured',sourceId:'201'}]、knownFields 含 post_date/post_modified/excerpt、bodyHtml 原文
|
||||
- product(111) → attachmentRefs 含 featured 202 + gallery 203,204(**悬空保留——歧义不猜**,role 区分)、extras 含 wppop_sections 的**已解析对象**(Task 2 产物;解析失败则原串进 extras.phpRaw + 报告 issue)
|
||||
- page(121) → basic_page 路由
|
||||
- attachment(201) → sourceType 'wp:attachment'、attachment_url 进 knownFields
|
||||
- nav_menu_item / wpcf7_contact_form / elementor_library / wp_navigation → **UnsupportedResult**(带类型与原因),不产 UniversalRow
|
||||
- **canonical status 值域**:publish/draft/future/pending/private/trash/inherit → 原样;未知值 → 'unknown' + issue(测试钉死映射表)
|
||||
- **property 断言**:每条产出 row 的 sourceId/sourceType 非空字符串、taxonomyRefs/attachmentRefs 元素形状合法、rawLocation 存在
|
||||
- [ ] Step 2 实现 `mapItem(item, config): MappedResult`(config: 类型路由表 {post:'wp:post', page:'wp:page', product:'wp:product', attachment:'wp:attachment'} + site locale)——纯函数
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): WXR→UniversalRow 映射器(status 值域/悬空保留/unsupported 分流)`
|
||||
|
||||
### Task 5: HTML 白名单清洗
|
||||
|
||||
**Files:** Create `src/sanitize/html.ts`(sanitize-html 锁 ^2);Test `test/html-sanitize.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试:
|
||||
- `<script>`/onclick/`javascript:`/`<iframe>` 未批准 → 移除且进差异清单
|
||||
- `<a href>`/`<img src|srcset|alt>`/`<p|strong|em|h2-h6|ul|ol|li|table|tbody|tr|td|th>` 白名单保留
|
||||
- 清洗差异以 `SanitizeDiff[]`(路径/类型 before/after 摘要)返回
|
||||
- mini.xml 正文(内链 + 外链图片)清洗后结构保留、URL 原样
|
||||
- [ ] Step 2 实现:`sanitizeBodyHtml(html): { html, diffs }`——白名单配置集中导出(后续阶段可复用到 preview CSP 对齐)
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): HTML 白名单清洗与差异收集`
|
||||
|
||||
### Task 6: 适配器组装 + 报告
|
||||
|
||||
**Files:** Create `src/adapter.ts`(parse→map→sanitize 串联为 `AsyncIterable<RowResult>`,RowResult = {kind:'row', row}|{kind:'unsupported',…}|{kind:'error',…});`src/report.ts`(计数汇总);Test `test/adapter.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试:mini.xml 全链 → 3 row(post/product/page)+ 2 attachment row + 0 unsupported + 差异计数(wppop 解析 1 成功);报告结构与 spec §7.7 字段对齐(按类型 Created/Skipped/Failed/Unsupported、清洗统计、行级错误带 rawLocation)
|
||||
- [ ] Step 2 实现(含 validateUniversalRow **加法式**检查:复用 isUniversalRow + 返回 issue[] 的浅扩展,不动 contracts 包)
|
||||
- [ ] Step 3 绿 → commit `feat(adapter-wp): 全链组装与导入前报告`
|
||||
|
||||
### Task 7: source:register 服务与 CLI
|
||||
|
||||
**Files:** Create `apps/api/src/modules/source/{register.service.ts,source.module.ts}`;`scripts/sf.ts`(commander,命令 `source:register --site <key> --wxr <path> [--sitemap <path>] [--media <dir>]`、`wxr:probe --wxr <path>` 只解析出报告不入库);Test `tests/integration/source-register.test.ts`
|
||||
|
||||
- [ ] Step 1 失败测试(临时库):
|
||||
- register WXR+sitemap+media 目录 → 三条 source_snapshots 落库(kind 各异、sha256 与文件一致、storage_path 指向 content-addressed 副本)、source_connections upsert 幂等
|
||||
- **重复 register 同文件** → 复用既有 snapshot(UNIQUE(site,kind,sha256) 生效,不重复行)
|
||||
- 副本落 `storage/snapshots/<kind>/<sha256前2位>/<sha256>`;原输入不再被后续任务读取
|
||||
- 路径安全:输入路径必须在允许目录(storage/incoming 或绝对路径白名单校验——拒绝越界读取)
|
||||
- [ ] Step 2 实现服务(copy 流式+sha256 双算、DB 写入、报告输出 artifacts/jobs/<jobId>/reports/)+ CLI 接线
|
||||
- [ ] Step 3 绿(75+新增)→ commit `feat(source): 输入登记服务与 sf CLI(content-addressed 快照)`
|
||||
|
||||
### Task 8: 真实 WXR 冒烟(阶段 2 验收)
|
||||
|
||||
**Files:** Create `scripts/smoke-real-wxr.mjs`(wxr:probe 真实文件 → 计数 JSON);`docs/evidence/phase2-smoke.md`
|
||||
|
||||
- [ ] Step 1: `node scripts/sf.ts wxr:probe --wxr "D:/project/cms/storage/wxr/fd04d6ba8a1316b6.xml"`(**只读 cms**)→ 计数对照 real-wxr-baseline.md:855/129/48/15 attachment/post/product/page + 75 unsupported(nav_menu_item 68 等)+ 451 term,逐项一致才算过
|
||||
- [ ] Step 2: 峰值内存记录(process.memoryUsage heapUsed)——流式断言:解析 5.5MB 文件峰值 < 100MB(不整读)
|
||||
- [ ] Step 3: 耗时记录;结果写 evidence 文档;commit `test(evidence): 阶段2真实WXR冒烟通过`
|
||||
|
||||
### Task 9: 阶段 2 收尾
|
||||
|
||||
- [ ] ADR-016(saxes/commander/sanitize-html 选型与许可证);根 typecheck 链加 @sf/adapter-wp;lint 全绿;contracts 首个消费者接线验证(adapter-wp workspace 依赖 contracts——**Plan 1 终审前瞻项落实**:import 路径用 dist 还是 src 的决策一并入 ADR-016)
|
||||
- [ ] 全量三绿 + push;plan 勾选;**移交阶段 3 清单**(内容/分类/媒体入库:media 魔数校验、source_records 落库、稳定身份 upsert、checksum skip、route_registry 生成)
|
||||
|
||||
## 验收(对照 spec §15 阶段 2)
|
||||
|
||||
- [ ] `source:register` 三类输入登记幂等,快照 content-addressed 且不可变
|
||||
- [ ] 脱敏夹具全链全绿(mini.xml → 5 row + 0 意外 unsupported)
|
||||
- [ ] 大文件不整读内存(5.5MB 峰值 <100MB)
|
||||
- [ ] 恶意/畸形输入安全失败(外部实体/超限/截断——带位置错误,不崩溃不静默)
|
||||
- [ ] 解析器不访问网络(测试钉死)
|
||||
- [ ] 真实 WXR 冒烟计数与基线逐项一致;unsupported 75 条分类清晰
|
||||
- [ ] 三绿(lint/typecheck/test)+ 全部推送
|
||||
Reference in New Issue
Block a user