feat(source): 媒体映射侧车导出与登记

Plan 3 Task 0(评审 Issue 1):旧 cms 库 media_assets 的 source_url→file_path
映射只读导出为侧车 JSON(859 条,覆盖率对照 WXR 855 attachment_url:精确命中
836 / 归一命中 0 / 未命中 19,97.8%),register 新增 --media-map 输入通道
(kind=manual_url_list 快照,形状校验 INVALID_MEDIA_MAP,metadata 含条目数,
幂等复用)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 14:42:32 +08:00
parent ceb6106025
commit a227cfac49
5 changed files with 515 additions and 14 deletions
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
// 媒体映射侧车导出(Plan 3 · Task 0,评审 Issue 1——真实数据实证):
// 旧 cms 库(容器 infrastructure-postgres-1,宿主 5433)media_assets 表存有该站唯一的
// WXR attachment_url(yyyy/mm 形态)→ 媒体清单扁平 GUID 文件名 映射。本脚本只读导出为
// [{sourceUrl, filePath}] JSON 数组,供 `sf source:register --media-map` 登记
// kind=manual_url_list 快照(导入管道对拍三级中的第二级数据源)。
//
// 连接方式(二选一里选了 docker exec psql):经 child_process execFile 调
// `docker exec <container> psql --csv -t -c "<SELECT>"`——宿主无需装 pg 客户端,
// SQL 作为单参数传递不经 shell(无注入面),凭证由容器内环境承担(compose:cms/cms)。
// 备选 node-postgres 直连 5433 需在脚本里钉死 compose 凭证且依赖仓库外端口可达——弃。
//
// **只读纪律**:本脚本对 cms 只执行下方 EXPort_SQL 一条 SELECT(无任何写语句、
// 不挂载、不 --user 变更);对 cms 文件系统零接触。
//
// 幂等可重跑:输出文件整体覆写(重跑 = 重新 SELECT + 重写同一路径),无追加语义。
//
// 用法(仓库根):
// pnpm exec node scripts/export-media-map.mjs # 缺省站点/输出路径
// pnpm exec node scripts/export-media-map.mjs --audit <wxr路径> # 追加覆盖率对照(只读)
// 可选覆盖:--site-id <uuid> / --out <path> / --container <name> / --user <u> / --db <d>
import { createHash } from 'node:crypto'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFile as execFileCallback } from 'node:child_process'
import { promisify } from 'node:util'
const execFile = promisify(execFileCallback)
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** 缺省导出对象:hesupipe 站(评审钉死);输出落 storage/incoming(常规登记通道,已 gitignore) */
const DEFAULT_SITE_ID = '54f91c49-fffb-4c58-87c8-6c2cf6aaef79'
const DEFAULT_OUT = join(REPO_ROOT, 'storage', 'incoming', 'media-map-hesupipe.json')
const DEFAULT_CONTAINER = 'infrastructure-postgres-1'
const DEFAULT_USER = 'cms'
const DEFAULT_DB = 'cms'
/** 唯一一条对 cms 执行的 SQL(只读 SELECT;source_url IS NOT NULL——867 中 8 行 NULL 不带出) */
function exportSql(siteId) {
return (
`SELECT source_url, file_path FROM media_assets ` +
`WHERE site_id = '${siteId}' AND source_url IS NOT NULL ORDER BY source_url`
)
}
/**
* 极简 CSV 行解析(psql --csv -t 输出):支持双引号包裹字段(内嵌 "" 转义与逗号)。
* 本数据域(URL/GUID 文件名)不出现字段内换行,按行切分安全。
*/
function parseCsvLine(line) {
const fields = []
let cur = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
cur += '"'
i++
} else {
inQuotes = false
}
} else {
cur += ch
}
} else if (ch === '"') {
inQuotes = true
} else if (ch === ',') {
fields.push(cur)
cur = ''
} else {
cur += ch
}
}
fields.push(cur)
return fields
}
/** URL 归一(覆盖率对照用):去首尾空白 + 去尾斜杠(可多个)+ 整串小写——估计口径,非精确匹配口径 */
function normalizeUrl(url) {
return url.trim().replace(/\/+$/, '').toLowerCase()
}
/** 覆盖率对照(只读):侧车 source_url × WXR attachment_url 归一化 join——精确命中/未命中/未命中清单 */
async function auditAgainstWxr(mapPath, wxrPath) {
const map = JSON.parse(await readFile(mapPath, 'utf8'))
const sidecarByExact = new Set(map.map((e) => e.sourceUrl))
const sidecarByNormalized = new Set(map.map((e) => normalizeUrl(e.sourceUrl)))
const wxr = await readFile(wxrPath, 'utf8')
const attachmentUrls = [...wxr.matchAll(/<wp:attachment_url><!\[CDATA\[([^\]]*)\]\]><\/wp:attachment_url>/g)].map(
(m) => m[1],
)
const seen = new Set()
const hitsExact = [] // 归一前即完全相等
const hitsNormalized = [] // 仅归一后相等(大小写/尾斜杠救回)
const misses = []
for (const url of attachmentUrls) {
if (seen.has(url)) continue
seen.add(url)
if (sidecarByExact.has(url)) {
hitsExact.push(url)
} else if (sidecarByNormalized.has(normalizeUrl(url))) {
hitsNormalized.push(url)
} else {
misses.push(url)
}
}
const wxrNormalized = new Set([...seen].map(normalizeUrl))
const sidecarHit = hitsExact.length + hitsNormalized.length
return {
wxrAttachmentUrls: attachmentUrls.length,
wxrDistinct: seen.size,
sidecarEntries: map.length,
hitsExact: hitsExact.length,
hitsNormalizedOnly: hitsNormalized.length,
misses: misses.length,
hitRate: seen.size === 0 ? null : sidecarHit / seen.size,
missList: misses.slice(0, 20),
missListTruncated: misses.length > 20,
/** 反向:侧车中 WXR(含归一口径)从未引用的条目数——导入三级对拍用不上的悬空行 */
sidecarUnreferenced: map.filter((e) => !wxrNormalized.has(normalizeUrl(e.sourceUrl))).length,
}
}
/** --key <v> / --key=<v> 取值;未知参数报错(防静默拼错键名导出错误范围) */
function parseArgs(argv) {
const defaults = {
siteId: DEFAULT_SITE_ID,
out: DEFAULT_OUT,
container: DEFAULT_CONTAINER,
user: DEFAULT_USER,
db: DEFAULT_DB,
auditWxr: undefined,
}
const keys = { '--site-id': 'siteId', '--out': 'out', '--container': 'container', '--user': 'user', '--db': 'db', '--audit': 'auditWxr' }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
let key = arg
let value = argv[i + 1]
if (arg.includes('=')) {
key = arg.slice(0, arg.indexOf('='))
value = arg.slice(arg.indexOf('=') + 1)
} else {
i++
}
const field = keys[key]
if (field === undefined) throw new Error(`未知参数:${arg}(支持 ${Object.keys(keys).join(' ')})`)
if (value === undefined || value === '') throw new Error(`${key} 缺少值参数`)
defaults[field] = value
}
return defaults
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const sql = exportSql(args.siteId)
process.stderr.write(`[export-media-map] 只读查询:docker exec ${args.container} psql -U ${args.user} -d ${args.db}\n`)
process.stderr.write(`[export-media-map] SQL:${sql}\n`)
const { stdout } = await execFile('docker', [
'exec',
args.container,
'psql',
'-U',
args.user,
'-d',
args.db,
'--csv',
'-t',
'-c',
sql,
])
const entries = stdout
.split(/\r?\n/)
.filter((line) => line !== '')
.map((line) => {
const [sourceUrl, filePath] = parseCsvLine(line)
if (sourceUrl === undefined || filePath === undefined || filePath === '') {
throw new Error(`CSV 行字段不全(预期 2 列):${line}`)
}
return { sourceUrl, filePath }
})
// SQL ORDER BY source_url 已保序;此处复核非降序(防 psql 输出形态意外变化静默乱序)
for (let i = 1; i < entries.length; i++) {
if (entries[i - 1].sourceUrl > entries[i].sourceUrl) {
throw new Error('输出未按 source_url 排序——psql 输出形态与预期不符,中止(不写半坏文件)')
}
}
const json = `${JSON.stringify(entries, null, 2)}\n`
const outPath = resolve(args.out)
await mkdir(dirname(outPath), { recursive: true })
await writeFile(outPath, json, 'utf8')
const outStat = await stat(outPath)
const sha256 = createHash('sha256').update(json, 'utf8').digest('hex')
process.stderr.write(`[export-media-map] 导出 ${entries.length} 条 → ${outPath}\n`)
process.stderr.write(`[export-media-map] bytes=${outStat.size} sha256=${sha256}\n`)
// 可选覆盖率对照(--audit <wxr>):证据文档数字的复算通道
if (args.auditWxr !== undefined) {
const audit = await auditAgainstWxr(outPath, resolve(args.auditWxr))
process.stderr.write(`[export-media-map] 覆盖率对照(归一:去尾斜杠+小写):\n`)
process.stderr.write(` WXR attachment_url:${audit.wxrAttachmentUrls}(去重 ${audit.wxrDistinct})\n`)
process.stderr.write(` 侧车条目:${audit.sidecarEntries}\n`)
process.stderr.write(` 精确命中:${audit.hitsExact};归一命中:${audit.hitsNormalizedOnly};未命中:${audit.misses}\n`)
process.stderr.write(` 命中率(去重口径):${audit.hitRate === null ? 'n/a' : `${(audit.hitRate * 100).toFixed(1)}%`}\n`)
process.stderr.write(` 侧车未被 WXR 引用的条目:${audit.sidecarUnreferenced}\n`)
if (audit.misses > 0) {
process.stderr.write(` 未命中清单(前 ${audit.missList.length}${audit.missListTruncated ? `/${audit.misses}` : ''} 条):\n`)
for (const url of audit.missList) process.stderr.write(` - ${url}\n`)
}
}
}
main().catch((err) => {
process.stderr.write(`[export-media-map] 失败:${err instanceof Error ? err.message : String(err)}\n`)
process.exitCode = 1
})
+11 -3
View File
@@ -66,11 +66,12 @@ program
program
.command('source:register')
.description('登记来源输入为 content-addressed 快照(WXR / sitemap / 媒体目录,可多选)')
.description('登记来源输入为 content-addressed 快照(WXR / sitemap / 媒体目录 / 媒体映射侧车 JSON,可多选)')
.requiredOption('--site <key>', '站点 key(不存在则自动创建)')
.option('--wxr <path>', 'WXR 文件路径')
.option('--sitemap <path>', 'sitemap 文件路径')
.option('--media <dir>', '媒体目录路径(已解包目录,递归登记)')
.option('--media-map <path>', '媒体映射侧车 JSON 路径([{sourceUrl, filePath}] 数组,登记为 manual_url_list 快照)')
.option('--allow <dir...>', '额外输入白名单根目录(可多个;默认含 storage/incoming 与当前目录)', [])
.option('--name <name>', '新建站点时的显示名(缺省 = site key)')
.option('--locale <locale>', '新建站点时的默认 locale(缺省 en)')
@@ -80,13 +81,19 @@ program
wxr?: string
sitemap?: string
media?: string
mediaMap?: string
allow: string[]
name?: string
locale?: string
origin?: string
}) => {
if (opts.wxr === undefined && opts.sitemap === undefined && opts.media === undefined) {
throw new SourceRegisterError('NO_INPUTS', '至少提供 --wxr / --sitemap / --media 之一')
if (
opts.wxr === undefined &&
opts.sitemap === undefined &&
opts.media === undefined &&
opts.mediaMap === undefined
) {
throw new SourceRegisterError('NO_INPUTS', '至少提供 --wxr / --sitemap / --media / --media-map 之一')
}
// fail fast:建站默认列在服务端只在"站点新建"时生效——先做形状校验,避免跑完摄取才报错
if (opts.origin !== undefined) {
@@ -116,6 +123,7 @@ program
if (opts.wxr !== undefined) input.wxrPath = opts.wxr
if (opts.sitemap !== undefined) input.sitemapPath = opts.sitemap
if (opts.media !== undefined) input.mediaDir = opts.media
if (opts.mediaMap !== undefined) input.mediaMapPath = opts.mediaMap
const defaultsPassed = opts.name !== undefined || opts.locale !== undefined || opts.origin !== undefined
if (defaultsPassed) {
const defaults: NonNullable<RegisterInput['siteDefaults']> = {}