#!/usr/bin/env node /** * Remove duplicate MindSpace pages for one user, keeping one page per workspace path/title. * * Usage: * node scripts/dedupe-user-pages.mjs --username=john --dry-run * node scripts/dedupe-user-pages.mjs --username=john --yes */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; import { createDbPool } from '../db.mjs'; import { createPageService } from '../mindspace-pages.mjs'; import { loadH5Environment } from './load-env.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(scriptDir, '..'); loadH5Environment(scriptDir); function readArg(name) { const prefix = `--${name}=`; const hit = process.argv.find((arg) => arg.startsWith(prefix)); return hit ? hit.slice(prefix.length).trim() : null; } function loadEnvFromFile(filePath) { if (!fs.existsSync(filePath)) return; for (const line of fs.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; } } const prodEnv = '/Users/john/Project/Memind/.env'; if (fs.existsSync(prodEnv)) loadEnvFromFile(prodEnv); const dryRun = process.argv.includes('--dry-run'); const confirmed = process.argv.includes('--yes'); const removeFromPlaza = process.argv.includes('--remove-from-plaza'); const username = readArg('username'); const userIdArg = readArg('user-id'); const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ? path.resolve(process.env.MINDSPACE_STORAGE_ROOT) : path.join(root, 'data', 'mindspace'); const h5Root = process.env.MINDSPACE_H5_ROOT ? path.resolve(process.env.MINDSPACE_H5_ROOT) : root; async function resolveUserId(pool) { if (userIdArg) return userIdArg; if (!username) throw new Error('请指定 --username=... 或 --user-id=...'); const [rows] = await pool.query( `SELECT id, username, email FROM h5_users WHERE username = ? LIMIT 1`, [username], ); const row = rows[0]; if (!row) throw new Error(`用户不存在: ${username}`); return row.id; } function groupKey(row) { const relativePath = String(row.relative_path ?? '').trim(); if (relativePath) return `path:${relativePath}`; const title = String(row.title ?? '').trim().toLowerCase(); return `title:${title || row.id}`; } function mergeGroupsByTitle(groups) { const titleToPathKey = new Map(); for (const [key, items] of groups.entries()) { if (!key.startsWith('path:')) continue; const title = String(items[0]?.title ?? '').trim().toLowerCase(); if (title) titleToPathKey.set(title, key); } for (const [key, items] of [...groups.entries()]) { if (!key.startsWith('title:')) continue; const title = key.slice('title:'.length); const pathKey = titleToPathKey.get(title); if (!pathKey) continue; groups.get(pathKey).push(...items); groups.delete(key); } return groups; } function pickKeeper(rows) { return [...rows].sort((left, right) => { const leftHasPath = Boolean(String(left.relative_path ?? '').trim()); const rightHasPath = Boolean(String(right.relative_path ?? '').trim()); if (leftHasPath !== rightHasPath) return rightHasPath ? 1 : -1; const pubDiff = Number(right.has_online_pub ?? 0) - Number(left.has_online_pub ?? 0); if (pubDiff !== 0) return pubDiff; const sessionDiff = Number(Boolean(right.source_session_id)) - Number(Boolean(left.source_session_id)); if (sessionDiff !== 0) return sessionDiff; const updatedDiff = Number(right.updated_at ?? 0) - Number(left.updated_at ?? 0); if (updatedDiff !== 0) return updatedDiff; return String(left.id).localeCompare(String(right.id)); })[0]; } const pool = createDbPool(); const pageService = createPageService(pool, { h5Root, storageRoot }); try { const userId = await resolveUserId(pool); const [userRows] = await pool.query( `SELECT username, email FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); const user = userRows[0]; const [rows] = await pool.query( `SELECT p.id, p.title, p.source_session_id, p.created_at, p.updated_at, JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS relative_path, EXISTS( SELECT 1 FROM h5_publish_records pr WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' ) AS has_online_pub FROM h5_page_records p JOIN h5_page_versions pv ON pv.id = p.current_version_id WHERE p.user_id = ? AND p.status <> 'deleted' ORDER BY p.updated_at DESC, p.id DESC`, [userId], ); const groups = new Map(); for (const row of rows) { const key = groupKey(row); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row); } mergeGroupsByTitle(groups); const duplicateGroups = [...groups.entries()].filter(([, items]) => items.length > 1); const toDelete = []; for (const [key, items] of duplicateGroups) { const keeper = pickKeeper(items); for (const item of items) { if (item.id !== keeper.id) { toDelete.push({ key, keeperId: keeper.id, page: item }); } } } console.log(`用户: ${user?.username ?? userId} (${user?.email ?? 'unknown'})`); console.log(`总页面: ${rows.length}`); console.log(`重复组: ${duplicateGroups.length}`); console.log(`待删除重复页: ${toDelete.length}`); for (const [key, items] of duplicateGroups.sort((a, b) => b[1].length - a[1].length).slice(0, 20)) { const keeper = pickKeeper(items); console.log(`- ${key}: ${items.length} 份,保留 ${keeper.id},删除 ${items.length - 1} 份`); } if (duplicateGroups.length > 20) { console.log(`... 另有 ${duplicateGroups.length - 20} 组未展开`); } if (toDelete.length === 0) { console.log('没有需要清理的重复页面。'); process.exit(0); } if (dryRun) { console.log('[dry-run] 未执行删除。'); process.exit(0); } if (!confirmed) { console.error('请加 --yes 确认删除,或先用 --dry-run 查看。'); process.exit(1); } let deleted = 0; let failed = 0; for (const entry of toDelete) { try { await pageService.deletePage(userId, entry.page.id, { removeFromPlaza }); deleted += 1; if (deleted % 25 === 0) { console.log(`已删除 ${deleted}/${toDelete.length} ...`); } } catch (error) { failed += 1; const message = error instanceof Error ? error.message : String(error); console.error(`删除失败 ${entry.page.id} (${entry.page.title}): ${message}`); } } const [remainingRows] = await pool.query( `SELECT COUNT(*) AS count FROM h5_page_records WHERE user_id = ? AND status <> 'deleted'`, [userId], ); console.log('---'); console.log(`完成:删除 ${deleted},失败 ${failed},剩余 ${Number(remainingRows[0]?.count ?? 0)} 页`); process.exit(failed > 0 ? 1 : 0); } finally { await pool.end(); }