2026-10-21 14:42:32 +08:00
|
|
|
|
#!/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'
|
|
|
|
|
|
|
2026-10-21 15:12:41 +08:00
|
|
|
|
/** 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 不带出) */
|
2026-10-21 14:42:32 +08:00
|
|
|
|
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')
|
2026-10-21 15:12:41 +08:00
|
|
|
|
// 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] ?? '')
|
2026-10-21 14:42:32 +08:00
|
|
|
|
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))
|
2026-10-21 15:12:41 +08:00
|
|
|
|
assertValidSiteId(args.siteId)
|
2026-10-21 14:42:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|