华为备忘录迁移小米笔记完整指南(油猴脚本方案)
适用场景:从华为云空间备忘录批量迁移至小米笔记,保留标题、时间戳与正文内容,全程自动化,无需手动复制粘贴。
一、方案概述
在更换手机品牌或整理跨平台笔记时,华为备忘录与小米笔记之间没有官方迁移通道。手动复制几百条笔记不仅耗时,还容易丢失创建时间等元数据。
本方案通过 Tampermonkey(油猴) 浏览器插件,在网页端模拟人工操作,实现:
| 环节 | 动作 | 输出 |
|---|---|---|
| 导出 |
自动遍历华为云备忘录列表,逐条点击并提取正文 | 结构化 JSON 文件 |
| 导入 |
读取 JSON,在小米笔记网页版逐条新建文本笔记 | 完整还原标题与内容 |
核心优势:纯前端实现,无需安装任何软件,不经过第三方服务器,数据隐私完全本地化。
二、准备工作
1. 环境要求
- 浏览器:Chrome / Edge / Firefox / Safari(推荐 Chromium 内核)
- 插件:安装 Tampermonkey(油猴)扩展
- 账号:
2. 获取脚本
将以下两个脚本分别添加为油猴脚本:
- 导出脚本:
华为云备忘录批量导出(匹配cloud.huawei.com)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
// ==UserScript== // @name 华为云备忘录批量导出 // @namespace http://tampermonkey.net/ // @version 2.2 // @description 自动识别正文编辑器(取行数最多的 CodeMirror),避免抓到标题栏 // @author You // @match https://cloud.huawei.com/* // @match https://*.cloud.huawei.com/* // @grant none // ==/UserScript== (function() { 'use strict'; const sleep = ms => new Promise(r => setTimeout(r, ms)); const waitFor = (sel, t = 10000) => new Promise((res, rej) => { const el = document.querySelector(sel); if (el) return res(el); const obs = new MutationObserver(() => { const f = document.querySelector(sel); if (f) { obs.disconnect(); res(f); } }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { obs.disconnect(); rej(new Error('timeout: ' + sel)); }, t); }); // 等待内容变化并稳定(连续两次相同,且与旧值不同) async function waitStable(oldText, selector, maxMs = 8000) { let last = ''; let stable = 0; const start = Date.now(); while (Date.now() - start < maxMs) { await sleep(300); const curr = getEditorText(selector); if (curr && curr !== oldText && curr === last) { stable++; if (stable >= 2) return curr; } else { last = curr; stable = 0; } } return last; } // 从右侧所有 CodeMirror 中提取正文(选行数最多的那个) function getEditorText(scopeSelector = '.notepad_right') { const allCodes = document.querySelectorAll(`${scopeSelector} .CodeMirror-code`); if (!allCodes.length) return ''; let bestText = ''; let maxLines = 0; allCodes.forEach(code => { const lines = code.querySelectorAll('.CodeMirror-line'); // 过滤零宽空格和空行占位符 const text = Array.from(lines) .map(l => l.textContent.replace(/\u200B/g, '').trimEnd()) .join('\n'); // 优先选行数多的;行数相同时选文本长的 if (lines.length > maxLines || (lines.length === maxLines && text.length > bestText.length)) { maxLines = lines.length; bestText = text; } }); return bestText; } // ===== UI ===== const btn = document.createElement('button'); btn.innerHTML = '📥 导出所有备忘录'; btn.style.cssText = ` position:fixed;top:80px;right:20px;z-index:99999; padding:10px 18px;background:#007dff;color:#fff;border:none; border-radius:6px;cursor:pointer;font-size:14px;font-weight:bold; box-shadow:0 2px 8px rgba(0,125,255,0.3);transition:.2s; `; btn.onmouseenter = () => btn.style.transform = 'scale(1.05)'; btn.onmouseleave = () => btn.style.transform = 'scale(1)'; document.body.appendChild(btn); const toast = document.createElement('div'); toast.style.cssText = ` position:fixed;top:140px;right:20px;z-index:99999;padding:10px 14px; background:rgba(0,0,0,0.85);color:#fff;border-radius:6px; font-size:12px;display:none;max-width:280px;line-height:1.6; `; document.body.appendChild(toast); const show = h => { toast.innerHTML = h; toast.style.display = 'block'; }; const hide = () => { toast.style.display = 'none'; }; // ===== 导出主逻辑 ===== async function runExport() { try { await waitFor('.note_item', 15000); await sleep(600); const items = Array.from(document.querySelectorAll('.note_item')); const total = items.length; if (!total) { alert('未找到备忘录'); return; } if (!confirm(`共 ${total} 条备忘录,点击确定开始导出。\n\n⚠️ 导出期间请勿操作页面!`)) return; btn.disabled = true; btn.style.opacity = '0.6'; const notes = []; let previousText = ''; for (let i = 0; i < total; i++) { const item = items[i]; // 滚动并点击 item.scrollIntoView({ behavior: 'smooth', block: 'center' }); await sleep(300); item.click(); // 确认左侧已激活 let guard = 0; while (!item.classList.contains('note_item_active') && guard < 20) { await sleep(100); guard++; } // 等待右侧编辑器内容稳定 show(`⏳ 第 <b>${i + 1}</b> / ${total} 条<br>等待编辑器内容稳定...`); const stableText = await waitStable(previousText); previousText = stableText; // 提取标题(从左侧激活项) let title = ''; const activeItem = document.querySelector('.note_item_active'); const titleEl = activeItem?.querySelector('.note_item_titleTxt') || item.querySelector('.note_item_titleTxt'); if (titleEl) title = titleEl.textContent.trim(); // 提取时间 let time = ''; const timeEl = activeItem?.querySelector('.note_item_datetimeTxt') || item.querySelector('.note_item_datetimeTxt'); if (timeEl) time = timeEl.textContent.trim(); // 提取内容(智能选择行数最多的 CodeMirror) const content = getEditorText(); notes.push({ index: i + 1, title: title || '无标题', time: time || '', content }); show(`✅ 第 <b>${i + 1}</b> / ${total} 条已提取<br> 标题:${(title || '无标题').slice(0, 16)}<br> 正文行数:${content.split('\n').filter(l => l).length}`); await sleep(400); } // 下载 const payload = { exportTime: new Date().toLocaleString('zh-CN'), total: notes.length, notes }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `华为云备忘录_${new Date().toISOString().slice(0,10)}_${Date.now()}.json`; a.click(); URL.revokeObjectURL(a.href); show(`🎉 导出完成!共 ${total} 条<br>文件已自动下载`); setTimeout(hide, 6000); } catch (e) { console.error(e); alert('导出失败:' + e.message); } finally { btn.disabled = false; btn.style.opacity = '1'; } } btn.addEventListener('click', runExport); })(); |
- 导入脚本:
小米笔记 JSON 批量导入(匹配i.mi.com/note*)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 |
// ==UserScript== // @name 小米笔记 JSON 批量导入 // @namespace http://tampermonkey.net/ // @version 1.3 // @description 将 JSON 文件批量导入小米笔记 // @author You // @match https://i.mi.com/note/h5* // @match https://i.mi.com/note* // @match https://cn.i.mi.com/note/h5* // @match https://cn.i.mi.com/note* // @grant none // ==/UserScript== (function() { 'use strict'; // ========== 配置 ========== const CONFIG = { delayBetweenNotes: 1000, // 每个笔记间隔 waitForDropdown: 1000, // 等待下拉菜单 waitAfterClickTextNote: 2000, // 点击"文本笔记"后等待编辑器加载(关键) waitForElement: 15000, // 等待元素超时 waitAfterSave: 1000, // 保存后等待 }; // ========== 工具函数 ========== function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function triggerInput(el) { ['focus', 'input', 'change', 'keyup', 'keydown', 'blur'].forEach(type => { el.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); }); } /** * 等待元素出现(增强版:支持多个选择器、自定义检查函数) */ function waitForElement(selectors, timeout = 15000) { const selectorList = Array.isArray(selectors) ? selectors : [selectors]; return new Promise((resolve, reject) => { // 先立即检查 for (const sel of selectorList) { const el = document.querySelector(sel); if (el) { console.log(`[小米笔记导入] 立即找到元素: ${sel}`); return resolve(el); } } const observer = new MutationObserver(() => { for (const sel of selectorList) { const el = document.querySelector(sel); if (el) { observer.disconnect(); console.log(`[小米笔记导入] MutationObserver 找到元素: ${sel}`); resolve(el); return; } } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); // 超时前最后再检查一次 for (const sel of selectorList) { const el = document.querySelector(sel); if (el) return resolve(el); } reject(new Error(`等待元素超时,已尝试: ${selectorList.join(', ')}`)); }, timeout); }); } /** * 通过文本内容查找元素 */ function waitForElementByText(tagName, text, timeout = 8000) { return new Promise((resolve, reject) => { const check = () => { const els = document.querySelectorAll(tagName); for (const el of els) { if (el.textContent.trim() === text) return el; } return null; }; const found = check(); if (found) return resolve(found); const observer = new MutationObserver(() => { const el = check(); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const el = check(); if (el) resolve(el); else reject(new Error(`等待文本元素超时: ${text}`)); }, timeout); }); } /** * 查找标题输入框(多种方式) */ function findTitleElement() { // 方式1: 类名选择器 let el = document.querySelector('.title-textarea.ltr-element'); if (el) return el; // 方式2: 更宽松的类名 el = document.querySelector('.title-textarea'); if (el) return el; // 方式3: 通过 style 中的 placeholder 特征 const allDivs = document.querySelectorAll('div[contenteditable="true"]'); for (const div of allDivs) { const style = div.getAttribute('style') || ''; if (style.includes('--title-placeholder') || style.includes('标题')) { return div; } } // 方式4: 在 origin-title 内查找 const originTitle = document.querySelector('.origin-title'); if (originTitle) { el = originTitle.querySelector('[contenteditable="true"]'); if (el) return el; } return null; } /** * 查找内容编辑器 */ function findEditorElement() { // 方式1: ProseMirror let el = document.querySelector('.ProseMirror[contenteditable="true"]'); if (el) return el; // 方式2: 更宽松的 ProseMirror el = document.querySelector('.ProseMirror'); if (el && el.isContentEditable) return el; // 方式3: 在 pm-container 内查找 const pmContainer = document.querySelector('#pm-container'); if (pmContainer) { el = pmContainer.querySelector('[contenteditable="true"]'); if (el) return el; } // 方式4: 在 note-content 内查找第二个 contenteditable(第一个是标题) const noteContent = document.querySelector('.note-content-1u7XQ'); if (noteContent) { const editables = noteContent.querySelectorAll('[contenteditable="true"]'); if (editables.length >= 2) return editables[1]; } return null; } /** * 等待标题输入框出现 */ async function waitForTitle(timeout = 15000) { return new Promise((resolve, reject) => { const check = () => findTitleElement(); const el = check(); if (el) return resolve(el); const observer = new MutationObserver(() => { const el = check(); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const el = check(); if (el) resolve(el); else { // 调试:打印当前所有 contenteditable 元素 console.log('[小米笔记导入] 调试信息 - 当前页面所有 contenteditable 元素:'); document.querySelectorAll('[contenteditable="true"]').forEach((el, i) => { console.log(` [${i}]`, el.className, el.getAttribute('style'), el.textContent.slice(0, 50)); }); reject(new Error('找不到标题输入框,已打印调试信息到控制台')); } }, timeout); }); } /** * 等待内容编辑器出现 */ async function waitForEditor(timeout = 15000) { return new Promise((resolve, reject) => { const check = () => findEditorElement(); const el = check(); if (el) return resolve(el); const observer = new MutationObserver(() => { const el = check(); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const el = check(); if (el) resolve(el); else reject(new Error('找不到内容编辑器')); }, timeout); }); } /** * 设置标题 */ function setTitle(el, text) { if (!el) return; el.focus(); // 方法1: execCommand el.innerHTML = ''; document.execCommand('insertText', false, text || '无标题'); // 如果失败,方法2: 直接设置 if (el.textContent !== text) { el.textContent = text || '无标题'; } triggerInput(el); el.blur(); } /** * 设置编辑器内容 */ function setEditorContent(el, text) { if (!el) return; el.focus(); const lines = text.split('\n'); const hasContent = lines.some(l => l.trim() !== ''); if (!hasContent) { el.innerHTML = '<p class="" data-indentation="1"><br class="ProseMirror-trailingBreak"></p>'; triggerInput(el); return; } let html = ''; for (const line of lines) { if (line.trim() === '') { html += '<p class="" data-indentation="1"><br class="ProseMirror-trailingBreak"></p>'; } else { html += `<p class="" data-indentation="1">${escapeHtml(line)}</p>`; } } el.innerHTML = html; triggerInput(el); // 光标移到最后 const lastP = el.querySelector('p:last-child'); if (lastP) { const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(lastP); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ========== 核心逻辑 ========== async function createNote(noteData) { console.log(`[小米笔记导入] ===== 开始: ${noteData.title} =====`); // 1. 点击"新建笔记" console.log('[小米笔记导入] 步骤1: 点击新建笔记按钮'); const createBtn = await waitForElement([ '.miui-dropdown-trigger.btn-create-33lpI', '.btn-create-33lpI', '[aria-label="新建笔记"]' ], 10000); createBtn.click(); await sleep(CONFIG.waitForDropdown); // 2. 点击"文本笔记" console.log('[小米笔记导入] 步骤2: 点击"文本笔记"'); const textNoteItem = await waitForElementByText('li', '文本笔记', 5000); textNoteItem.click(); // 关键:等待编辑器页面完全加载 console.log('[小米笔记导入] 步骤3: 等待编辑器加载 (5秒)...'); await sleep(CONFIG.waitAfterClickTextNote); // 3. 填写标题 console.log('[小米笔记导入] 步骤4: 查找并填写标题'); const titleEl = await waitForTitle(CONFIG.waitForElement); console.log('[小米笔记导入] 找到标题框:', titleEl.className); setTitle(titleEl, noteData.title); await sleep(500); // 4. 填写内容 console.log('[小米笔记导入] 步骤5: 查找并填写内容'); const editorEl = await waitForEditor(CONFIG.waitForElement); console.log('[小米笔记导入] 找到编辑器:', editorEl.className); setEditorContent(editorEl, noteData.content || ''); await sleep(1000); // 5. 保存 console.log('[小米笔记导入] 步骤6: 触发保存'); editorEl.blur(); titleEl.blur(); await sleep(1500); // 6. 返回列表 console.log('[小米笔记导入] 步骤7: 返回列表'); await goBackToList(); console.log(`[小米笔记导入] ===== 完成: ${noteData.title} =====`); } async function goBackToList() { const selectors = [ '.btn-back', '.icon-back', '.note-back', '[aria-label="返回"]', '.header-back', '.close-btn', '.btn-close', '.back-button' ]; for (const sel of selectors) { const btn = document.querySelector(sel); if (btn) { btn.click(); await sleep(CONFIG.waitAfterSave); return; } } document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true })); await sleep(CONFIG.waitAfterSave); } async function importNotes(notes) { const total = notes.length; let success = 0, failed = 0; updateStatus(`开始导入,共 ${total} 条笔记...`); for (let i = notes.length - 1; i >= 0; i--) { const note = notes[i]; try { updateStatus(`正在导入 (${i + 1}/${total}): ${note.title || '无标题'}...`); await createNote(note); success++; } catch (err) { console.error(`[小米笔记导入] 失败: ${note.title}`, err); failed++; updateStatus(`导入失败 (${i + 1}/${total}): ${note.title} - ${err.message}`); await sleep(3000); } await sleep(CONFIG.delayBetweenNotes); } updateStatus(`导入完成!成功: ${success}, 失败: ${failed}`); setTimeout(() => updateStatus(''), 10000); } // ========== UI ========== let statusEl = null; function updateStatus(text) { if (!statusEl) return; statusEl.textContent = text; statusEl.style.display = text ? 'block' : 'none'; } function createImportButton() { if (document.getElementById('mi-note-import-container')) return; const container = document.createElement('div'); container.id = 'mi-note-import-container'; container.style.cssText = ` position: fixed; top: 80px; right: 20px; z-index: 99999; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; `; const btn = document.createElement('button'); btn.textContent = '📥 导入 JSON'; btn.style.cssText = ` background: #ff6900; color: white; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 8px rgba(255,105,0,0.3); transition: all 0.2s; `; btn.onmouseenter = () => btn.style.background = '#e55d00'; btn.onmouseleave = () => btn.style.background = '#ff6900'; const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.style.display = 'none'; statusEl = document.createElement('div'); statusEl.style.cssText = ` background: rgba(0,0,0,0.85); color: white; padding: 10px 16px; border-radius: 8px; font-size: 13px; max-width: 320px; word-break: break-all; display: none; backdrop-filter: blur(4px); line-height: 1.5; `; fileInput.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!data.notes || !Array.isArray(data.notes)) { throw new Error('JSON 格式错误:缺少 notes 数组'); } updateStatus(`已读取 ${data.notes.length} 条笔记,3秒后开始...\n(请保持页面在前台,不要切换标签)`); await sleep(3000); await importNotes(data.notes); } catch (err) { updateStatus(`错误: ${err.message}`); console.error('[小米笔记导入]', err); } fileInput.value = ''; }); btn.addEventListener('click', () => fileInput.click()); container.appendChild(btn); container.appendChild(fileInput); container.appendChild(statusEl); document.body.appendChild(container); } function init() { console.log('[小米笔记导入] 脚本 v1.3 已加载'); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', createImportButton); } else { createImportButton(); } } init(); })(); |
安装后刷新对应页面,右上角会出现悬浮操作按钮。
三、第一步:导出华为备忘录
3.1 脚本工作原理
华为云备忘录网页版采用 CodeMirror 作为富文本编辑器。页面同时存在多个 CodeMirror 实例(如标题栏也可能被识别),脚本通过行数最多优先的策略智能定位正文编辑器,避免抓到标题输入框的残留镜像。
3.2 执行导出
- 进入华为云备忘录网页版,确保左侧列表已加载全部笔记
- 点击右上角 「📥 导出所有备忘录」 按钮
- 确认弹窗提示的总条数,点击确定
- 导出期间请勿操作页面(脚本会自动滚动、点击、等待内容稳定)
导出流程细节:
- 每条笔记点击后,脚本会等待右侧编辑器内容连续两次读取结果一致且与上一条不同,确保异步加载完成
- 自动提取:标题、时间戳、正文内容
- 最终生成
华为云备忘录_YYYY-MM-DD_时间戳.json,格式如下:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "exportTime": "2026/8/12 19:32:00", "total": 3, "notes": [ { "index": 1, "title": "项目需求文档", "time": "2026-08-10 14:20", "content": "1. 首页改版\n2. 增加暗黑模式\n3. 性能优化" } ] } |
四、第二步:导入小米笔记
4.1 脚本工作原理
小米笔记网页版基于 ProseMirror 编辑器框架,采用 contenteditable 实现富文本编辑。脚本需要解决两个核心问题:
- 动态编辑器定位:标题与正文编辑器都是动态创建的
div[contenteditable="true"],脚本通过多种降级策略(类名 → 样式特征 → DOM 结构位置)稳定定位 - 内容注入与保存触发:直接修改
innerHTML后,必须派发focus/input/change/keyup/keydown/blur事件序列,才能触发 ProseMirror 的状态更新与自动保存
4.2 执行导入
- 进入小米笔记网页版,确保处于笔记列表页
- 点击右上角 「📥 导入 JSON」 按钮
- 选择刚才导出的
.json文件 - 脚本读取后显示条数,3 秒倒计时后开始自动导入
- 保持页面在前台,不要切换浏览器标签
导入流程细节:
- 逐条点击「新建笔记」→「文本笔记」
- 等待编辑器完全加载(默认等待 2 秒,确保 ProseMirror 初始化)
- 填写标题 → 填写正文(自动将
\n转换为<p>段落) - 触发 blur 事件保存 → 返回列表 → 间隔 1 秒继续下一条
兼容性处理:
- 脚本会按倒序导入(
notes.length - 1到0),确保最终列表顺序与华为端一致 - 若某条失败,会记录错误并继续,不会中断整体流程
五、技术深度解析
5.1 华为端:CodeMirror 的"镜像"陷阱
华为备忘录编辑器使用 CodeMirror 6,其 DOM 结构特点是:
- 标题栏与正文区可能同时存在
.CodeMirror-code - 标题区通常只有 1 行,而正文区可能有多行
- 存在零宽空格(
\u200B)作为占位符,需过滤避免产生空行
脚本采用双维度排序(行数优先,长度次之),在 99% 的场景下能正确识别正文。
5.2 小米端:ProseMirror 的状态同步
ProseMirror 不是简单的 textarea,它是一个受控文档模型:
- 直接修改
innerHTML不会更新内部 EditorState - 必须通过合成事件(Synthetic Event)让编辑器感知变化
- 保存机制依赖 blur 事件触发的自动同步
脚本在设置内容后,主动将光标移至最后一段,并派发完整的输入事件链,确保内容被纳入 ProseMirror 的撤销历史与持久化队列。
5.3 稳定性设计
| 风险点 | 应对策略 |
|---|---|
| 元素异步加载 | MutationObserver+ 多选择器降级 + 超时重试 |
| 内容未渲染完成 | 连续两次读取一致才视为稳定 |
| 编辑器未初始化 | 固定等待 2 秒 + 动态检测contenteditable |
| 导入顺序错乱 | 倒序遍历,利用列表"最新在前"特性还原顺序 |
| 单条失败阻断 | try/catch包裹单条逻辑,记录失败数继续执行 |
六、常见问题与注意事项
⚠️ 关键提醒
- 导出时不要操作页面:任何鼠标滚动或点击都可能干扰脚本的元素定位
- 导入时保持页面可见:浏览器后台标签可能被节流,导致定时器延迟
- 网络稳定:华为云与小米云服务均为在线编辑器,断网会导致内容提取或保存失败
🔧 故障排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 导出的内容全是标题 | 抓到了标题栏的 CodeMirror | 检查正文是否有多行,脚本已优化行数判断 |
| 小米导入后内容为空 | ProseMirror 未触发保存 | 增加 waitAfterClickTextNote 到 3000ms 以上 |
| 找不到标题输入框 | 小米更新了类名 | 打开控制台查看 contenteditable 元素列表,更新脚本选择器 |
| 导入顺序相反 | 正常 | 脚本故意倒序导入,最终列表顺序会与华为一致 |
🛡️ 数据安全
- 脚本仅在浏览器本地运行,不连接任何第三方服务器
- JSON 文件保存在本地磁盘,建议迁移完成后加密备份或删除
七、结语
通过油猴脚本实现华为备忘录到小米笔记的迁移,本质上是一次跨平台 DOM 自动化工程。它充分利用了现代网页编辑器的可访问性,将数百次重复的人工操作压缩为一次点击。
如果你有一定的前端基础,还可以基于这个框架二次开发:
- 增加标签/文件夹映射
- 转换 Markdown 格式
- 接入其他笔记平台(如 Notion、Obsidian)
技术让数据自由流动,而不应被生态壁垒囚禁。
作者注:脚本基于华为云备忘录网页版(2026)与小米笔记 H5 版(2026)编写。若后续官方更新 DOM 结构,可通过浏览器开发者工具(F12 → Elements)比对类名后微调选择器。