Skip to content

About

将小米笔记一键导出到本地

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

小米笔记导出

将小米笔记(i.mi.com)的所有笔记导出为 Obsidian Markdown 文件。

一键执行(推荐)

直接把下面这段话发给 Claude Code,它会自动帮你完成所有操作:

帮我按照 https://github.com/chen1guan/xiaomi-notes-export 这个仓库的说明,把小米笔记导出到 F:\shuju\小米笔记

手动操作

1. 安装依赖

pip install playwright
playwright install chromium

2. 保存脚本

将下方代码保存为 export.py:

"""
小米笔记导出 → Obsidian
从 i.mi.com 获取所有笔记并保存为 Markdown

依赖: pip install playwright && playwright install chromium
用法: python export.py [--output OUTPUT_DIR] [--browser edge|chrome]
"""

import os
import re
import json
import sys
import time
import argparse
from pathlib import Path
from datetime import datetime
from collections import Counter

# ─── 工具函数 ───────────────────────────────────────────────

def log(msg):
    print(msg, flush=True)

def safe_filename(name, max_len=80):
    """清理文件名中的非法字符"""
    name = re.sub(r'[\\/:*?"<>|\n\r\t]', '_', name)
    name = re.sub(r'_+', '_', name)
    name = name.strip('. _')
    if len(name) > max_len:
        name = name[:max_len]
    return name or "无标题"

def html_to_markdown(html):
    """HTML → Markdown"""
    if not html:
        return ""
    m = html
    # 标题
    for tag, prefix in [('h1', '# '), ('h2', '## '), ('h3', '### ')]:
        m = re.sub(rf'<{tag}[^>]*>(.*?)</{tag}>', prefix + r'\1\n\n', m, flags=re.I)
    # 行内样式
    m = re.sub(r'<strong[^>]*>(.*?)</strong>', r'**\1**', m, flags=re.I)
    m = re.sub(r'<b[^>]*>(.*?)</b>', r'**\1**', m, flags=re.I)
    m = re.sub(r'<em[^>]*>(.*?)</em>', r'*\1*', m, flags=re.I)
    m = re.sub(r'<i[^>]*>(.*?)</i>', r'*\1*', m, flags=re.I)
    m = re.sub(r'<del[^>]*>(.*?)</del>', r'~~\1~~', m, flags=re.I)
    m = re.sub(r'<code[^>]*>(.*?)</code>', r'`\1`', m, flags=re.I)
    # 列表
    m = re.sub(r'<li[^>]*>(.*?)</li>', r'- \1\n', m, flags=re.I)
    m = re.sub(r'</?[uo]l[^>]*>', '\n', m, flags=re.I)
    # 段落 / 换行
    m = re.sub(r'<br\s*/?>', '\n', m, flags=re.I)
    m = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n\n', m, flags=re.I)
    # 链接 / 图片
    m = re.sub(r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>', r'[\2](\1)', m, flags=re.I)
    m = re.sub(r'<img[^>]*src="([^"]*)"[^>]*/?>', r'![](\1)', m, flags=re.I)
    # 引用
    m = re.sub(r'<blockquote[^>]*>(.*?)</blockquote>', r'> \1\n', m, flags=re.I)
    # 移除剩余标签
    m = re.sub(r'<[^>]+>', '', m)
    # HTML 实体
    for entity, char in [('&nbsp;', ' '), ('&amp;', '&'), ('&lt;', '<'), ('&gt;', '>'), ('&quot;', '"'), ('&#39;', "'")]:
        m = m.replace(entity, char)
    # 清理空行
    m = re.sub(r'\n{3,}', '\n\n', m)
    return m.strip()

def format_timestamp(ts):
    """Unix 毫秒时间戳 → 可读字符串"""
    if not ts:
        return ""
    try:
        if isinstance(ts, (int, float)):
            if ts > 1e12:
                ts = ts / 1000
            return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
    except:
        pass
    return str(ts)

# ─── 浏览器启动 ─────────────────────────────────────────────

def launch_browser(playwright, browser_type="edge"):
    """启动浏览器,使用用户的登录状态"""
    user_data = os.path.expanduser(
        r"~\AppData\Local\Microsoft\Edge\User Data"
        if browser_type == "edge"
        else r"~\AppData\Local\Google\Chrome\User Data"
    )

    if not os.path.exists(user_data):
        raise FileNotFoundError(f"未找到 {browser_type} 用户数据目录: {user_data}")

    # 关闭浏览器(释放 profile 锁)
    exe = "msedge" if browser_type == "edge" else "chrome"
    os.system(f"taskkill /f /im {exe}.exe >nul 2>&1")
    time.sleep(2)

    ctx = playwright.chromium.launch_persistent_context(
        user_data,
        headless=False,
        channel="msedge" if browser_type == "edge" else "chrome",
        args=["--disable-blink-features=AutomationControlled"],
        viewport={"width": 1200, "height": 800},
        ignore_default_args=["--enable-automation"],
    )
    return ctx

def wait_for_login(page, timeout=300):
    """如果需要登录,等待用户完成"""
    if "account" in page.url or "login" in page.url.lower():
        log("⚠️  请在弹出的浏览器窗口中登录小米账号...")
        log("   登录后会自动继续...")
        try:
            page.wait_for_url("**/note/**", timeout=timeout * 1000)
        except:
            pass
        page.wait_for_load_state("networkidle", timeout=30000)
        page.wait_for_timeout(3000)
        log("✅ 登录成功")

# ─── API 调用 ───────────────────────────────────────────────

def api_fetch(page, url):
    """通过浏览器上下文调用 API(自动携带 cookies)"""
    return page.evaluate("""(url) => {
        return fetch(url, {
            credentials: 'include',
            headers: { 'Accept': 'application/json' }
        }).then(r => r.json()).catch(e => ({ error: e.message }));
    }""", url)

def fetch_all_entries(page):
    """使用 syncTag 游标获取所有笔记条目"""
    log("\n📡 获取笔记列表...")

    # 第一次请求
    data = api_fetch(page, "https://i.mi.com/note/full?pageNo=1&pageSize=200")
    if "error" in data:
        log(f"❌ API 错误: {data['error']}")
        return []

    entries = data.get("data", {}).get("entries", [])
    sync_tag = data.get("data", {}).get("syncTag", "")

    seen_ids = set()
    all_entries = []
    for e in entries:
        eid = e.get("id")
        if eid and eid not in seen_ids:
            seen_ids.add(eid)
            all_entries.append(e)

    log(f"  首批: {len(all_entries)} 条")

    # 用 syncTag 游标继续获取
    no_new_count = 0
    while sync_tag and no_new_count < 3:
        data = api_fetch(page, f"https://i.mi.com/note/full?syncTag={sync_tag}&pageSize=200")
        if "error" in data:
            break

        entries = data.get("data", {}).get("entries", [])
        new_tag = data.get("data", {}).get("syncTag", "")

        new_count = 0
        for e in entries:
            eid = e.get("id")
            if eid and eid not in seen_ids:
                seen_ids.add(eid)
                all_entries.append(e)
                new_count += 1

        if new_count == 0:
            no_new_count += 1
        else:
            no_new_count = 0
            log(f"  +{new_count} 条 (共 {len(all_entries)})")

        if new_tag == sync_tag:
            break
        sync_tag = new_tag
        time.sleep(0.3)

    log(f"📊 共获取 {len(all_entries)} 条笔记")
    return all_entries

def fetch_note_detail(page, note_id):
    """获取单条笔记的完整内容"""
    data = api_fetch(page, f"https://i.mi.com/note/note/{note_id}")
    if data and "data" in data:
        return data["data"]
    return None

# ─── 解析与保存 ─────────────────────────────────────────────

def parse_entry(entry):
    """解析笔记条目,提取标题等元数据"""
    title = ""
    try:
        ei = entry.get("extraInfo", "")
        if ei:
            extra = json.loads(ei) if isinstance(ei, str) else ei
            title = extra.get("title", "")
    except:
        pass
    return {
        "id": entry.get("id"),
        "title": title,
        "snippet": entry.get("snippet", ""),
        "created": entry.get("createDate", 0),
        "modified": entry.get("modifyDate", 0),
    }

def save_note_markdown(output_dir, note):
    """将笔记保存为带 frontmatter 的 Markdown 文件"""
    title = re.sub(r'[\n\r]+', ' ', (note.get("title") or "无标题").strip())
    markdown = note.get("markdown", "")
    created = format_timestamp(note.get("created"))
    modified = format_timestamp(note.get("modified"))

    # YAML frontmatter
    fm = "---\n"
    fm += f'title: "{title}"\n'
    if created:
        fm += f"created: {created}\n"
    if modified:
        fm += f"modified: {modified}\n"
    fm += "source: 小米笔记\n---\n\n"

    content = fm + f"# {title}\n\n{markdown}"

    # 文件名(处理重名)
    filename = safe_filename(title) + ".md"
    filepath = output_dir / filename
    counter = 1
    while filepath.exists():
        filepath = output_dir / f"{safe_filename(title)}_{counter}.md"
        counter += 1

    filepath.write_text(content, encoding="utf-8")
    return filepath

# ─── 主流程 ─────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="小米笔记导出到 Obsidian")
    parser.add_argument("--output", "-o", default=r"F:\shuju\小米笔记",
                        help="输出目录 (默认: F:\\shuju\\小米笔记)")
    parser.add_argument("--browser", "-b", default="edge", choices=["edge", "chrome"],
                        help="浏览器 (默认: edge)")
    parser.add_argument("--no-detail", action="store_true",
                        help="跳过获取详情(只导出标题和摘要,速度更快)")
    args = parser.parse_args()

    output_dir = Path(args.output)
    output_dir.mkdir(parents=True, exist_ok=True)

    log("🚀 小米笔记导出工具")
    log(f"📁 输出目录: {output_dir}")
    log(f"🌐 浏览器: {args.browser}")

    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        log("\n🌐 启动浏览器...")
        ctx = launch_browser(p, args.browser)
        page = ctx.pages[0] if ctx.pages else ctx.new_page()

        log("🔗 打开小米笔记...")
        page.goto("https://i.mi.com/note/h5#/", wait_until="networkidle", timeout=60000)
        page.wait_for_timeout(3000)

        wait_for_login(page)
        log(f"📍 当前页面: {page.url}")

        # 获取所有笔记条目
        entries = fetch_all_entries(page)
        if not entries:
            log("❌ 未获取到笔记")
            ctx.close()
            return

        # 日期分布
        ym = Counter()
        for e in entries:
            try:
                d = datetime.fromtimestamp(e.get("createDate", 0) / 1000)
                ym[d.strftime("%Y-%m")] += 1
            except:
                pass
        log("\n📅 日期分布:")
        for k in sorted(ym.keys()):
            log(f"  {k}: {ym[k]} 条")

        # 获取详情并保存
        log(f"\n📖 开始保存 {len(entries)} 条笔记...")
        saved = 0
        failed = 0
        notes_data = []

        for i, entry in enumerate(entries):
            meta = parse_entry(entry)

            # 获取详情
            full_content = ""
            if not args.no_detail and meta["id"]:
                try:
                    detail = fetch_note_detail(page, meta["id"])
                    if detail:
                        full_content = (detail.get("content", "")
                                        or detail.get("text", "")
                                        or detail.get("body", ""))
                        if not meta["title"]:
                            meta["title"] = detail.get("title", "")
                        if detail.get("createDate"):
                            meta["created"] = detail["createDate"]
                        if detail.get("modifyDate"):
                            meta["modified"] = detail["modifyDate"]
                except:
                    failed += 1

                # 限速
                if (i + 1) % 100 == 0:
                    log(f"  📖 {i+1}/{len(entries)} (失败:{failed})")
                    time.sleep(0.5)
                elif (i + 1) % 20 == 0:
                    time.sleep(0.2)

            # 组装内容
            raw = full_content or meta["snippet"]
            markdown = html_to_markdown(raw) if ("<" in raw and ">" in raw) else raw

            title = (meta["title"] or meta["snippet"][:50] or f"笔记{i+1}").strip()

            note = {
                "title": title,
                "markdown": markdown,
                "created": meta["created"],
                "modified": meta["modified"],
            }
            notes_data.append(note)
            save_note_markdown(output_dir, note)
            saved += 1

        # JSON 备份
        backup_path = output_dir / "_backup.json"
        with open(backup_path, "w", encoding="utf-8") as f:
            json.dump(notes_data, f, ensure_ascii=False, indent=2)

        with_content = sum(1 for n in notes_data if len(n.get("markdown", "")) > 10)

        log(f"\n{'='*45}")
        log(f"🎉 导出完成!")
        log(f"  📝 总计: {saved} 条笔记")
        log(f"  📄 有内容: {with_content} 条")
        log(f"  ❌ 失败: {failed} 条")
        log(f"  📁 保存位置: {output_dir}")
        log(f"  💾 JSON 备份: {backup_path}")

        ctx.close()

if __name__ == "__main__":
    main()

3. 运行

python export.py

参数

参数 说明 默认值
--output / -o 输出目录 F:\shuju\小米笔记
--browser / -b 浏览器 (edge 或 chrome) edge
--no-detail 只导出标题和摘要,不获取全文(更快) 否

前置条件

  • Windows 系统
  • Python 3.8+
  • Edge 或 Chrome 浏览器
  • 浏览器中已登录小米账号(i.mi.com)

工作流程

  1. 脚本自动关闭浏览器并用 Playwright 启动(复用你的登录状态)
  2. 打开小米笔记页面,如需登录会提示
  3. 通过 API 分页获取所有笔记
  4. 逐条获取详情,转换 HTML → Markdown
  5. 保存为带 YAML frontmatter 的 .md 文件
  6. 同时生成 _backup.json 备份

About

将小米笔记一键导出到本地

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors