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:
@@ -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
@@ -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']> = {}
|
||||
|
||||
Reference in New Issue
Block a user