#!/usr/bin/env node /** * Enrich plaza post pages with polished HTML + update summaries. * Then run: pnpm sync:plaza-prod -- --include-offline */ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createDbPool } from '../db.mjs'; import { getEnrichment } from './plaza-rich-content.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const storageRoot = path.resolve(process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace')); function loadEnvFile(filePath) { if (!fsSync.existsSync(filePath)) return; for (const line of fsSync.readFileSync(filePath, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq < 0) continue; const key = trimmed.slice(0, eq).trim(); const value = trimmed.slice(eq + 1).trim(); if (!process.env[key]) process.env[key] = value; } } loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); function absStorageKey(storageKey) { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { throw new Error(`非法 storage_key: ${storageKey}`); } return resolved; } async function ensureBundle(conn, row, html) { const now = Date.now(); const checksum = crypto.createHash('sha256').update(html).digest('hex'); const sizeBytes = Buffer.byteLength(html, 'utf8'); const storageKey = `users/${row.user_id}/publications/${row.publication_id}/index.html`; const filePath = absStorageKey(storageKey); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, html, 'utf8'); let bundleAssetId = row.bundle_asset_id; let assetVersionId = row.bundle_version_id; if (!bundleAssetId) { bundleAssetId = crypto.randomUUID(); assetVersionId = crypto.randomUUID(); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, display_name, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) VALUES (?, ?, ?, ?, 'html', 'text/html', 'index.html', ?, ?, ?, ?, 'none', 'public_candidate', 'ready', 'generated', ?, ?)`, [ bundleAssetId, row.user_id, row.space_id, row.category_id, `${row.title} · 发布快照`, assetVersionId, sizeBytes, checksum, now, now, ], ); await conn.query( `INSERT INTO h5_asset_versions (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, created_by, change_note, scan_status, created_at) VALUES (?, ?, 1, ?, ?, ?, 'text/html', ?, '广场内容完善', 'passed', ?)`, [assetVersionId, bundleAssetId, storageKey, sizeBytes, checksum, row.user_id, now], ); await conn.query(`UPDATE h5_page_versions SET bundle_asset_id = ? WHERE id = ?`, [ bundleAssetId, row.page_version_id, ]); } else { await conn.query( `UPDATE h5_asset_versions SET storage_key = ?, size_bytes = ?, checksum = ?, scan_status = 'passed' WHERE asset_id = ? AND version_no = 1`, [storageKey, sizeBytes, checksum, bundleAssetId], ); await conn.query( `UPDATE h5_assets SET size_bytes = ?, checksum = ?, updated_at = ? WHERE id = ?`, [sizeBytes, checksum, now, bundleAssetId], ); } return { storageKey, checksum, sizeBytes }; } async function main() { const pool = createDbPool(); const [rows] = await pool.query( `SELECT pp.id AS post_id, pp.title, pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.user_id, pr.status AS pub_status, p.space_id, p.category_id, p.title AS page_title, pv.bundle_asset_id, (SELECT av.id FROM h5_asset_versions av WHERE av.asset_id = pv.bundle_asset_id AND av.version_no = 1 LIMIT 1) AS bundle_version_id FROM plaza_posts pp JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id WHERE pp.status = 'published' ORDER BY pp.hot_score DESC`, ); const conn = await pool.getConnection(); let updated = 0; try { await conn.beginTransaction(); for (const row of rows) { const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title); const summaryOnly = skipHtml && row.bundle_asset_id; if (!summaryOnly) { await ensureBundle(conn, row, html); } await conn.query( `UPDATE plaza_posts SET summary = ?, updated_at = ? WHERE id = ?`, [summary.slice(0, 500), Date.now(), row.post_id], ); await conn.query( `UPDATE h5_page_records SET title = ?, summary = ?, updated_at = ? WHERE id = ?`, [row.title, summary.slice(0, 1000), Date.now(), row.page_id], ); if (row.pub_status !== 'online') { await conn.query( `UPDATE h5_publish_records SET status = 'online', offline_at = NULL, updated_at = ? WHERE id = ?`, [Date.now(), row.publication_id], ); } if (row.post_id === '4c1bf255-d7fd-4065-9044-d347579f85e4') { await conn.query(`UPDATE plaza_posts SET title = ?, updated_at = ? WHERE id = ?`, [ 'MindSpace 公开发布指南', Date.now(), row.post_id, ]); } updated += 1; console.log(`✓ ${row.title}`); } await conn.commit(); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); await pool.end(); } console.log(`\n完成:已完善 ${updated} 张卡片的内容与摘要。`); console.log('同步到线上:pnpm sync:plaza-prod -- --include-offline'); } main().catch((error) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });