722b18326f
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。 Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.0 KiB
JavaScript
205 lines
7.0 KiB
JavaScript
#!/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();
|
|
}
|