Files
cms-website/scripts/export-media-map.mjs
浅念 a185254e19 chore(scripts): 侧车导出加固——site-id UUID 校验与 audit 非 CDATA 匹配
评审两条非阻塞加固:--site-id 拼入 SQL 前过 UUID 形态白名单(非 UUID 拒绝并
明确报错);--audit 的 attachment_url 抽取正则同时匹配 CDATA 与裸文本两形态。
对真实数据重跑数字不变(859 条 / 855 对 836 命中,输出哈希一致)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 15:12:41 +08:00

233 lines
9.7 KiB
JavaScript
Raw Permalink 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.
#!/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'
/** site-id 白名单校验:UUID 形态(评审加固)——SQL 里 site_id 以字面量内插,非 UUID 输入在拼 SQL 前拒绝 */
function assertValidSiteId(siteId) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(siteId)) {
throw new Error(`--site-id 不是合法 UUID(36 位形态):${siteId}`)
}
}
/** 唯一一条对 cms 执行的 SQL(只读 SELECT;site_id 已过 UUID 校验;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')
// CDATA 与裸文本两种形态都匹配(评审加固:WXR 导出器未必总包 CDATA);分支互斥,m[1] ?? m[2]
const attachmentUrls = [
...wxr.matchAll(/<wp:attachment_url>(?:<!\[CDATA\[([^\]]*)\]\]>|([^<]*))<\/wp:attachment_url>/g),
].map((m) => m[1] ?? m[2] ?? '')
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))
assertValidSiteId(args.siteId)
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
})