feat(adapter-wp): WXR→UniversalRow 映射器(status 值域/悬空保留/unsupported 分流)
- mapItem 纯函数:WxrItem → MappedResult 三分支(row/unsupported/error), DEFAULT_TYPE_ROUTES/STATUS_MAP/META_KEYS 三冻结表导出 - Plan 1 移交落实:status 值域表驱动(未知→unknown+issue);property 断言 (isUniversalRow+refs 形状非空)测试钉死 - Task 3 审查移交:postId null→error(不产 row 不猜);-7 原样;-0 归一 '0' - taxonomyRefs 锚点=category @nicename(=term slug,Task 8 实数据核对后冻结) - 悬空 gallery 203/204 原样保留;wppop 解析失败→extras.phpRaw+issue(不崩不丢) - Task 2 审查移交补测四例:d: 键拒绝/NUL 往返/s:1:"0"→Map/1MB<50ms 烟雾 - tsconfig.build.json:src 首次引 @sf/contracts 后 build 经 contracts dist 声明解析(rootDir TS6059;typecheck/vitest/tsx 仍 src 直连,ADR-016 归口) - 142→173 测试全绿;typecheck/lint 绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export * from './limits.js'
|
||||
export * from './parse/php-unserialize.js'
|
||||
export * from './parse/wxr-stream.js'
|
||||
export * from './map/item-to-row.js'
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// WxrItem → UniversalRow 映射器(Plan 2 Task 4):纯函数——单条 WxrItem 进、MappedResult
|
||||
// 出;不访问文件/网络/DB,不跨 item 看世界。悬空 attachmentRef(如 mini.xml 的 203/204)
|
||||
// 原样保留:引用是否可解析是入库阶段(阶段 3)以全量数据才能裁决的事,本层不猜。
|
||||
//
|
||||
// Plan 1 移交责任在此落实:
|
||||
// - canonical status 值域:STATUS_MAP 冻结表,未知值 → 'unknown' + issue(不猜语义);
|
||||
// - property 断言:产出 row 满足 isUniversalRow 且 sourceId/sourceType 非空、refs 元素
|
||||
// 形状合法(测试钉死;Task 6 的 validateUniversalRow 再做加法式复检,不动 contracts)。
|
||||
//
|
||||
// 字段提升纪律(有意不提前):title/publishedAt/modifiedAt 留在 knownFields(键名
|
||||
// title/postDate/postModified,值为 WXR 原串);UniversalRow 顶层同名字段的提升与
|
||||
// 日期格式化是阶段 3 导入映射的事——那时任何字段变更须过契约升版评审。
|
||||
//
|
||||
// taxonomyRefs 锚点设计决定(Task 8 实数据核对后冻结):item 内 <category> 只携带
|
||||
// domain/nicename,不带 wp:term 的 term_id(mapItem 只见单条 item,无法查 term 表)。
|
||||
// 真实 WP 中 category @nicename 即 term slug,故 sourceTermId 暂以 nicename 为锚,
|
||||
// 由下游以 wp:term 事件(slug → term_id)解析——锚点若须换,只改本文件与测试。
|
||||
|
||||
import type { UniversalRow } from '@sf/contracts'
|
||||
import { phpUnserialize, phpValueToJson } from '../parse/php-unserialize.js'
|
||||
import type { WxrItem } from '../parse/wxr-stream.js'
|
||||
|
||||
/** 默认类型路由:WXR post_type → UniversalRow.sourceType(冻结;真实基线仅此四类产 row) */
|
||||
export const DEFAULT_TYPE_ROUTES: Readonly<Record<string, string>> = Object.freeze({
|
||||
post: 'wp:post',
|
||||
page: 'wp:page',
|
||||
product: 'wp:product',
|
||||
attachment: 'wp:attachment',
|
||||
})
|
||||
|
||||
/**
|
||||
* canonical status 值域映射表(冻结,Plan 1 移交):已知值原样透传;
|
||||
* 不在表内的值 → {@link UNKNOWN_STATUS} 且结果带 issue。
|
||||
*/
|
||||
export const STATUS_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
publish: 'publish',
|
||||
draft: 'draft',
|
||||
future: 'future',
|
||||
pending: 'pending',
|
||||
private: 'private',
|
||||
trash: 'trash',
|
||||
inherit: 'inherit',
|
||||
})
|
||||
|
||||
/** 未知 status 的统一落点(报告层按 unknown 汇总,不猜具体语义) */
|
||||
export const UNKNOWN_STATUS = 'unknown'
|
||||
|
||||
/**
|
||||
* meta 分流键(冻结):键名来自真实 hesupipe WXR(Task 8 实数据核对)。
|
||||
* thumbnail → attachmentRefs{role:'featured'};gallery(逗号分隔 id)→ {role:'gallery'};
|
||||
* seoTitle/seoDescription → seo.title/description;wppopSections → PHP 解析(见 mapWppop);
|
||||
* 其余 meta 一律原样进 extras。
|
||||
*/
|
||||
export const META_KEYS = Object.freeze({
|
||||
thumbnail: '_thumbnail_id',
|
||||
gallery: '_product_image_gallery',
|
||||
seoTitle: '_yoast_wpseo_title',
|
||||
seoDescription: '_yoast_wpseo_metadesc',
|
||||
wppopSections: 'wppop_sections',
|
||||
} as const)
|
||||
|
||||
/** mapItem 配置 */
|
||||
export interface MapConfig {
|
||||
/** 站点 locale,原样写入 row.locale(空串与否由调用方自证) */
|
||||
locale: string
|
||||
/**
|
||||
* post_type → sourceType 路由;缺省 {@link DEFAULT_TYPE_ROUTES}。
|
||||
* 传则**整体替换**(非合并)——需增改请展开默认表:`{ ...DEFAULT_TYPE_ROUTES, ... }`。
|
||||
*/
|
||||
typeRoutes?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射结果三分支:
|
||||
* - row:产出 UniversalRow(issues 为该 item 的非致命问题清单,可空)
|
||||
* - unsupported:post_type 未配置路由(sourceType 按 `wp:<post_type>` 约定命名,仅为
|
||||
* 报告可读性,不代表已配置路由)——分流不产 row
|
||||
* - error:数据无法建立稳定身份(post_id 缺失/非法)——不产 row 不猜
|
||||
*/
|
||||
export type MappedResult =
|
||||
| { kind: 'row'; row: UniversalRow; issues: string[] }
|
||||
| { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string }
|
||||
| { kind: 'error'; issue: string; rawLocation: string }
|
||||
|
||||
/**
|
||||
* wppop_sections 分流:PHP 串经 phpUnserialize + phpValueToJson 进 extras(Map 陷阱
|
||||
* 防线:phpValueToJson 前的 Map 不可 JSON.stringify,会静默变 {});任何解析失败
|
||||
* (PhpParseError/PhpLimitError)不崩不丢——原串存 extras.phpRaw 并记 issue。
|
||||
*/
|
||||
function mapWppop(raw: string, extras: Record<string, unknown>, issues: string[]): void {
|
||||
try {
|
||||
extras[META_KEYS.wppopSections] = phpValueToJson(phpUnserialize(raw))
|
||||
} catch (err) {
|
||||
extras.phpRaw = raw
|
||||
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
issues.push(`meta ${META_KEYS.wppopSections} PHP 解析失败(${detail}),原串保留于 extras.phpRaw`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 WxrItem → UniversalRow / unsupported / error(纯函数)。
|
||||
*
|
||||
* 决策顺序:post_id 有效性先于路由(无稳定身份的一切输出都是猜测);路由先于字段
|
||||
* 映射(unsupported 不必再解析 meta)。sourceId = String(postId):负值原样('-7'),
|
||||
* -0 归一为 '0'。空串字段(link/postName/contentEncoded 等)不写入 row——契约的可选
|
||||
* 字段以"缺席"表达"源无此值",而非空串。
|
||||
*/
|
||||
export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
||||
// Task 3 审查移交策略:post_id 是 sourceId 唯一来源,缺失/非法即无法建立稳定身份
|
||||
if (item.postId === null) {
|
||||
return { kind: 'error', issue: 'post_id 缺失或非法', rawLocation: item.rawLocation }
|
||||
}
|
||||
|
||||
const routes = config.typeRoutes ?? DEFAULT_TYPE_ROUTES
|
||||
const sourceType = routes[item.postType]
|
||||
if (sourceType === undefined) {
|
||||
return {
|
||||
kind: 'unsupported',
|
||||
sourceType: `wp:${item.postType}`,
|
||||
reason: 'post_type 未配置映射',
|
||||
rawLocation: item.rawLocation,
|
||||
}
|
||||
}
|
||||
|
||||
const issues: string[] = []
|
||||
const knownStatus = STATUS_MAP[item.status]
|
||||
const status = knownStatus ?? UNKNOWN_STATUS
|
||||
if (knownStatus === undefined) {
|
||||
issues.push(`status 值未识别:${JSON.stringify(item.status)},已映射为 '${UNKNOWN_STATUS}'`)
|
||||
}
|
||||
|
||||
// taxonomyRefs:锚点=nicename(见模块头设计决定);缺 domain/nicename 的引用跳过并记
|
||||
// issue——空锚点在下游必然失配,不如显式报告
|
||||
const taxonomyRefs: UniversalRow['taxonomyRefs'] = []
|
||||
for (const category of item.categories) {
|
||||
if (category.domain === '' || category.nicename === '') {
|
||||
issues.push(`category 引用缺 domain/nicename(value=${JSON.stringify(category.value)}),已跳过`)
|
||||
continue
|
||||
}
|
||||
taxonomyRefs.push({ taxonomy: category.domain, sourceTermId: category.nicename })
|
||||
}
|
||||
|
||||
const attachmentRefs: UniversalRow['attachmentRefs'] = []
|
||||
const seo: Record<string, unknown> = {}
|
||||
const extras: Record<string, unknown> = {}
|
||||
for (const meta of item.metas) {
|
||||
if (meta.key === META_KEYS.thumbnail) {
|
||||
const sourceId = meta.value.trim()
|
||||
if (sourceId === '') {
|
||||
// 畸形:meta 存在但值为空(对照:空 gallery 值 = 无图库,合法)
|
||||
issues.push(`meta ${META_KEYS.thumbnail} 值为空,featured 引用已跳过`)
|
||||
continue
|
||||
}
|
||||
attachmentRefs.push({ role: 'featured', sourceId })
|
||||
} else if (meta.key === META_KEYS.gallery) {
|
||||
for (const part of meta.value.split(',')) {
|
||||
const sourceId = part.trim()
|
||||
if (sourceId !== '') attachmentRefs.push({ role: 'gallery', sourceId })
|
||||
}
|
||||
} else if (meta.key === META_KEYS.seoTitle) {
|
||||
seo.title = meta.value
|
||||
} else if (meta.key === META_KEYS.seoDescription) {
|
||||
seo.description = meta.value
|
||||
} else if (meta.key === META_KEYS.wppopSections) {
|
||||
mapWppop(meta.value, extras, issues)
|
||||
} else {
|
||||
// 未识别 meta:原样进 extras(同 key 重复时后者覆盖前者,文档序)
|
||||
extras[meta.key] = meta.value
|
||||
}
|
||||
}
|
||||
|
||||
// knownFields(阶段 3 提升纪律见模块头):空串不写入——键缺席即源无此值
|
||||
const knownFields: Record<string, unknown> = {}
|
||||
if (item.title !== '') knownFields.title = item.title
|
||||
if (item.postDate !== '') knownFields.postDate = item.postDate
|
||||
if (item.postModified !== '') knownFields.postModified = item.postModified
|
||||
if (item.excerptEncoded !== '') knownFields.excerpt = item.excerptEncoded
|
||||
if (item.creator !== '') knownFields.creator = item.creator
|
||||
if (item.attachmentUrl !== '') knownFields.attachmentUrl = item.attachmentUrl
|
||||
|
||||
const row: UniversalRow = {
|
||||
sourceType,
|
||||
sourceId: String(item.postId),
|
||||
status,
|
||||
locale: config.locale,
|
||||
taxonomyRefs,
|
||||
attachmentRefs,
|
||||
seo,
|
||||
knownFields,
|
||||
extras,
|
||||
rawLocation: item.rawLocation,
|
||||
}
|
||||
if (item.link !== '') row.sourceUrl = item.link
|
||||
if (item.postName !== '') row.slug = item.postName
|
||||
if (item.contentEncoded !== '') row.bodyHtml = item.contentEncoded // 原文透传,清洗是 Task 5
|
||||
|
||||
return { kind: 'row', row, issues }
|
||||
}
|
||||
Reference in New Issue
Block a user