☰
我把印象笔记的「加密导出」掰开揉碎,最后用一招绕过了它——本地明文库直提全攻略
2026/10/8 6:48:23 网站建设 项目流程

我把印象笔记的「加密导出」掰开揉碎,最后用一招绕过了它——本地明文库直提全攻略

一次真实的自救:7 个.notes文件、7.7GB、正文全是密文。本文记录从逆向加密格式到最终 601 条笔记无损导出的全过程,包括每一步的踩坑、调试和验证方法。脚本可直接抄作业。

实测环境:印象笔记 Windows 客户端 v6.x(2026-10)· Windows 10/11 · Python 3.8+(仅标准库)

TL;DR

  • 印象笔记 2022 年 7 月起把导出格式改成了专用的.notes,正文自动 AES 加密,和你设没设密码无关,官方不给密钥,第三方工具全灭;
  • 逆向加密格式(ENC0)的结论:这条路数学上走不通,别浪费时间;
  • 但客户端要在本地显示笔记,明文必然躺在磁盘上——找到本地数据库.exb(SQLite),正文、图片、元数据全是明文,直接读;
  • 附:完整可跑脚本、表结构、日期换算标定法、以及如何用「三方对账」证明自己一条不漏。

一、发生了什么:你导出的文件为什么打不开

帮家里整理笔记,从印象笔记 Windows 客户端里导出了 7 个.notes文件(共 7.7GB)。用文本编辑器打开一看,好家伙,XML 骨架是熟悉的 ENEX:

<contentencoding="base64:aes"><![CDATA[RU5DMFrPjZq71RQKnO8AJ6H5eK3...]]></content>

但每条笔记的正文都变成了base64:aes。注意三个事实:

  1. 只有正文加密。图片、视频等附件的<resource>是明文 base64,能直接解出来;
  2. 导出全程没让设置任何密码。这个加密是客户端自动做的;
  3. 这是印象笔记 2022 年 7 月的行为变更:导出格式从开放的 ENEX 悄悄换成了私有格式,社区(知乎上一搜一堆)普遍认为这是在锁定用户数据。

所以如果你的.notes在 Obsidian / Notion / Bear 里打不开——不是你的问题,是格式本身就没打算让你打开。

二、先说为什么「解密」这条路走不通(帮你们省时间)

我是个不信邪的人,真把加密包逆向了一遍,过程和结论都放这,想跳坑的直接看结论。

2.1 加密包结构(ENC0 格式)

把 CDATA 里的 base64 解码,开头 4 字节是ENC0魔数。完整布局:

[0:4] "ENC0" 魔数 [4:20] salt → AES 密钥 = PBKDF2-HMAC-SHA256(密码, salt, 50000 轮, 16 字节) [20:36] salthmac → HMAC 密钥 = PBKDF2-HMAC-SHA256(密码, salthmac, 同参数) [36:52] IV(16 字节) [52:-32] AES-128-CBC 密文(与 16 字节块对齐) [-32:] HMAC-SHA256(body) 校验位

验证密码的代码很短,而且完全离线(每个候选约 40ms,因为 5 万轮 PBKDF2):

importbase64,hashlib,hmacfromCrypto.CipherimportAESdefenc0_decrypt(payload_b64:bytes,password:str):raw=base64.b64decode(b"".join(payload_b64.split()))assertraw[:4]==b"ENC0"salt,salthmac,iv=raw[4:20],raw[20:36],raw[36:52]body,bodyhmac,ct=raw[:-32],raw[-32:],raw[52:-32]keyhmac=hashlib.pbkdf2_hmac("sha256",password.encode(),salthmac,50000,16)ifnothmac.compare_digest(hmac.new(keyhmac,body,hashlib.sha256).digest(),bodyhmac):returnNone# 密码错,HMAC 对不上key=hashlib.pbkdf2_hmac("sha256",password.encode(),salt,50000,16)pt=AES.new(key,AES.MODE_CBC,iv).decrypt(ct)returnpt[:-pt[-1]]# 去 PKCS7 padding

2.2 然后呢?没有密码。

我试过的方向,全部失败,你们不用再试了:

  • 常见弱密码字典(25 个:123456、生日模式、evernote、yinxiang……)→ HMAC 全部不通过;
  • 客户端里的硬编码密钥——加密既然是客户端自动做的,密钥要么硬编码要么可推导。我把 74MB 的Evernote.exe按 ASCII + UTF-16LE 双编码扫了一遍(base64:aes、ENC0、导出模板都能找到字符串痕迹),只有格式常量,没有密钥。密钥大概率在服务器侧生成或走账号派生,本地静态分析拿不到;
  • GitHub 现成工具(evernote-decrypt 等)→ 都是解笔记里<en-crypt>手动加密段的(那个需要用户输密码),和「整条导出加密」是两码事。

结论:放弃解密,绕过去。

三、灵光一现:客户端要在本地显示笔记,明文必然在磁盘上

这是整个事情的转折点。加密只挡住了「导出格式」这一个出口,但印象笔记是本地优先的客户端——你断网也能看笔记,说明明文正文一定躺在本地某个数据库里。

找一下就找到了:

D:\Databases\<你的用户标识>#app.yinxiang.com.exb

(文件名里的#后面是服务域名,中国版是app.yinxiang.com,国际版可能不同。找不到就在安装盘和%LOCALAPPDATA%里搜*.exb。)

一个 28GB 的 SQLite 库,里面是全部笔记的明文。⚠️ 先叠个甲:这个文件等于你的全部笔记裸奔,别往网盘公开目录扔。

四、逆向表结构:4 张表看懂整个库

用 Python 自带sqlite3只读打开(注意第 5 节的两个天坑):

表关键列存什么
note_attruid, title, notebook(明文笔记本名), tags(逗号分隔), date_created, is_deleted笔记元数据,一条一行
attrsuid, aid, dataaid=40 的 data 就是明文 ENML 正文(aid=0 是标题)
resource_attruid, note, file_name, mime, width, height, hash资源元数据;hash 可与正文里的引用精确配对
resourcesuid, data资源二进制(图片/视频原文件)

4.1 「aid=40」是怎么找出来的(版本变了你也照这个办法找)

这套表结构(印象笔记叫 SDB)没有官方文档,全靠探针。我不想只给结论,把探针也给你们——客户端版本升级后表结构变了,跑一遍这个就知道正文挪到哪了:

uid,=con.execute("SELECT uid FROM note_attr LIMIT 1").fetchone()foraid,lnincon.execute("SELECT aid, LENGTH(data) FROM attrs WHERE uid=?",(uid,)):data=con.execute("SELECT data FROM attrs WHERE uid=? AND aid=?",(uid,aid)).fetchone()[0]head=bytes(data[:80])tag=""ifb"<?xml"inheadorb"en-note"inhead[:400]:tag=" ← 正文在这!(ENML)"print(f"aid={aid:<4}len={ln:<8}{head[:60]!r}{tag}")

原理:ENML 正文一定带<?xml ... ?><!DOCTYPE en-note ...>签名,长度也明显比其他属性大(几 KB 到几百 KB)。跑一遍,哪个 aid 是正文一目了然。我这里实测是40。

4.2 正文长什么样

aid=40 的 data 解码后就是标准 ENML(Evernote 的 XHTML 方言):

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPEen-noteSYSTEM"http://xml.evernote.com/pub/enml2.dtd"><en-note><div>正文文字……</div><en-mediahash="926559fa89a53545ad0509fc17c3d79e"type="video/mp4"/></en-note>

图片/视频通过<en-media>的hash属性引用,和resource_attr.hash精确对应。

五、两个必踩的天坑(我都替你们踩了)

坑 1:路径里的#会让你静默打开一个空库

.exb文件名自带#(xxx#app.yinxiang.com.exb),而 SQLite 的file:URI 把#当 fragment 分隔符。症状是 connect 不报错、一查表就no such table。修复:

fromurllib.parseimportquote con=sqlite3.connect("file:"+quote(DB_PATH)+"?mode=ro&immutable=1",uri=True)

这个 bug 阴险在没有任何报错指向路径,我在no such table: note_attr上愣了好几分钟才反应过来。

坑 2:自定义排序规则NOCASEUTF8

库的文本列挂了自定义 collation,任何对这些列的 SQLWHERE/GROUP BY/ORDER BY都会炸:

sqlite3.OperationalError: no such collation sequence: NOCASEUTF8

对策简单粗暴:整表拉到 Python 里再过滤。几千条笔记的元数据毫无压力。

坑 2.5:日期既不是时间戳也不是 TDateTime

date_created是个浮点数,比如739028.3247。我第一反应是 Delphi TDateTime(基准 1899-12-30),算出来年份跑到 3922 年——错。正确姿势是标定法(这个方法万能,版本不同也能用):

  1. 挑一条笔记,从客户端界面(或导出的 ENEX)看它的真实创建时间,比如2024-05-22 15:47;
  2. 查库里对应的浮点值739028.3247;
  3. 反推基准:真实时间 - timedelta(days=浮点值),看结果落在哪。

我这里反推出基准是datetime(1, 1, 1)(公元 1 年 1 月 1 日),且实测存在-1 天的系统性偏移,再加上北京时间 +8 小时:

importdatetimedefto_beijing(v:float)->datetime.datetime:# 我的机器上实测: datetime(1,1,1) + v = UTC + 1 天# 你的版本请先用「标定法」验证,别直接抄!returndatetime.datetime(1,1,1)+datetime.timedelta(days=v-1+8/24)

不确定点:-1 天这个偏移我没找到官方解释(怀疑和服务端时区写入有关),我是拿 3 条已知时间的笔记交叉验证的。你们上手时务必先标定——拿两三条笔记对一下客户端显示的时间,对得上再全量转。

六、完整脚本(可直接抄作业)

笔记本 → Markdown + 图片文件夹,只读直连,客户端开着也能跑(immutable=1拿到的是打开瞬间的一致性快照)。依赖只要 Python 3.8+ 标准库:

# -*- coding: utf-8 -*-"""印象笔记本地库 -> Markdown(按笔记本导出)。只读,不影响客户端。"""importdatetime,html,re,sqlite3,sysfrompathlibimportPathfromurllib.parseimportquote# 路径含 #,必须编码!sys.stdout.reconfigure(encoding="utf-8",errors="replace")DB=r"D:\Databases\<你的用户标识>#app.yinxiang.com.exb"# ← 改成你的OUT=Path(r"D:\印象笔记导出")NOTEBOOKS=["工作日志","读书笔记"]# ← 改成你想导出的笔记本名con=sqlite3.connect("file:"+quote(DB)+"?mode=ro&immutable=1",uri=True)con.text_factory=lambdab:b.decode("utf-8","replace")# 1) 笔记元数据(过滤必须在 Python 侧做,SQL 侧会触发 NOCASEUTF8 报错)rows=con.execute("SELECT uid, title, notebook, tags, date_created, is_deleted FROM note_attr").fetchall()notes=[rforrinrowsifnotr[5]andr[2]inNOTEBOOKS]notes.sort(key=lambdar:r[4])# 按创建时间排序# 2) 资源元数据,按 note 批量取(resource_attr 有 note 索引,200 条一批)by_note,uids={},[n[0]forninnotes]foriinrange(0,len(uids),200):chunk=uids[i:i+200]q=",".join("?"*len(chunk))foruid,note,fname,mime,hshincon.execute(f"SELECT uid, note, file_name, mime, hash FROM resource_attr "f"WHERE note IN ({q}) AND (is_deleted IS NULL OR is_deleted=0)",chunk):by_note.setdefault(note,[]).append((uid,fname,mime,hsh))defto_bj(v):return(datetime.datetime(1,1,1)+datetime.timedelta(days=v-1+8/24))ifvelse"?"# 3) 逐条导出fork,(uid,title,nb,tags,dc,_)inenumerate(notes,1):safe=re.sub(r'[\\/:*?"<>|]',"_",titleorf"无标题{k}")[:80]md=OUT/re.sub(r'[\\/:*?"<>|]',"_",nb)(md/"images").mkdir(parents=True,exist_ok=True)text=f"#{title}\n\n> 创建于{to_bj(dc)}"+(f" | 标签:{tags}"iftagselse"")+"\n\n"body=con.execute("SELECT data FROM attrs WHERE uid=? AND aid=40",(uid,)).fetchone()ifbodyandbody[0]:xml=body[0]ifisinstance(body[0],str)elsebody[0].decode("utf-8","replace")# 3a) 图片:按 hash 精确配对,落盘并替换 en-media 引用forra_uid,fname,mime,hshinby_note.get(uid,[]):ifnot(mimeor"").startswith("image/"):continuedata=con.execute("SELECT data FROM resources WHERE uid=?",(ra_uid,)).fetchone()ifnot(dataanddata[0]):continueimg=md/"images"/f"{ra_uid}_{fnameor(hsh[:8]+'.img')}"ifnotimg.exists():img.write_bytes(data[0])xml=re.sub(f'<en-media[^>]*hash="{hsh}"[^>]*/?>',f"![](images/{img.name})",xml)# 3b) 剩余标签粗转纯文本(想要加粗/表格级还原的上 lxml 遍历)xml=re.sub(r"<br\s*/?>","\n",xml)xml=re.sub(r"</div>|</p>|</li>","\n",xml)xml=re.sub(r'<en-todo[^>]*checked=["\']true["\'][^>]*/?>',"☑ ",xml)xml=re.sub(r"<en-todo[^>]*/?>","☐ ",xml)xml=re.sub(r"<en-media[^>]*>","📎[音视频/附件]",xml)xml=re.sub(r"<[^>]+>","",xml)text+=html.unescape(xml)(md/f"{k:04d}_{safe}.md").write_text(text,encoding="utf-8")ifk%50==0:print(f"{k}/{len(notes)}")print(f"完成:{len(notes)}条 ->{OUT}")

几个设计说明:

  • 图片先落盘再替换引用:按hash而不是按顺序配对。顺序配对在笔记有多个资源时容易错位,hash 是精确匹配;
  • 视频音频只在正文里标注:28GB 的库里大头是视频,只导文字+图片的话务必先按mime过滤再读data列——我第一版没过滤,光读资源数据就拉了 9GB 进内存;
  • immutable=1的语义是「假设文件不会变,给我当前一致性视图」。客户端正在写库时严格说应该复制.exb再读副本;我实测直连没出过问题(笔记数据页改动概率低),但追求绝对稳的话,复制一份再跑。

七、进阶坑:合并过的笔记,图片会「大面积失踪」

这是个非常阴的坑,值得单独一节。

现象:导出的 Word/Markdown 里,某些笔记(尤其是合并产生的长笔记)内出现几十处[图片资源缺失],但明明图片好好的。

排查过程:我先怀疑是自己 hash 配对写错了,dump 了一条缺图笔记的 ENML,把它引用的所有 hash 拿去resource_attr里查——这些 hash 在全库都查不到对应行。再对比正常的笔记,hash 都能查到。所以不是我的代码问题。

根因:印象笔记的「合并笔记」功能,是把多条笔记的正文拼成一条新笔记,但资源(图片附件)仍然挂在被合并的那些旧笔记名下(resource_attr.note还指着旧 uid)。你按「笔记 → 资源」的关系查,合并笔记名下空空如也。

修复:换个方向配对——不按笔记关系查,直接拿正文 ENML 里引用的 hash 列表,去resource_attr走hash 索引全库反查:

want={h.decode()forhinre.findall(rb'hash="([0-9a-fA-F]+)"',enml_bytes)}q=",".join("?"*len(want))forra_uid,fname,mime,w,h,hshincon.execute(f"SELECT uid, file_name, mime, width, height, hash "f"FROM resource_attr WHERE hash IN ({q})",sorted(want)):...# 走 resources.uid 取数据,塞回渲染管线

一行 SQL 的事,但如果你不知道合并机制,会像我一样先怀疑人生半小时。

八、怎么证明「一条没漏」:三方对账

导出类任务最大的自我欺骗是「脚本跑完了 = 数据全了」。我的做法是三方对账,而且不是对总数,是对标题多重集:

importhtml,re,sqlite3fromcollectionsimportCounterfrompathlibimportPathfromdocximportDocument# 或直接数生成的 .md 文件名# ① 库侧:该笔记本所有未删笔记标题lib=Counter(html.unescape(r[0])forrinlib_rowsifnotr[2]andr[1]inbooks)# ② 成品侧:文档里实际出现的标题(Word 里数 Heading 2;Markdown 数文件名也行)docx=Counter(html.unescape(re.sub(r"^\d+\. ","",p.text))forpindoc.paragraphsifp.style.name=="Heading 2")# ③ 源文件侧(可选):如果你有 .notes 导出,解析它里面的 <title> 集合missing,extra=lib-docx,docx-libprint("缺:",list(missing),"多:",list(extra))

两个容易翻车的细节:

  1. 必须html.unescape再比。ENEX 里&存成&amp;,直接比会出现一批「看起来不一样其实一样」的假差异(我第一次对账就撞上 5 条);
  2. 多重集,不是集合。两条同名的「无标题笔记」用set比会互相抵消,Counter相减才严格。

另外两个事后才发现的隐藏 bug,都是靠「数文件」抓出来的,分享给大家当反面教材:

  • 我在过滤「只导图片」时只改了数据读取端,忘了改落盘端——结果音视频资源以 243 个0 字节文件的形式混进了附件目录。教训:一个过滤条件,每一处落盘点都要过一遍;
  • 冒烟测试时某条笔记导出只有 61 个字符,我以为是标签清洗的正则吃掉了正文,dump 原始 ENML 一看——这条笔记本来就只有 256 字节,内容就一个视频,没有文字。代码没错,是数据长那样。教训:先怀疑代码,再怀疑数据,但别忘了数据本身可能就长得「像 bug」。

九、仍然无法恢复的内容(诚实清单)

即使是明文库,也有几种内容救不回来,提前说明免得你们白找:

情况症状能否恢复
资源没同步到本地正文引用的 hash 在resource_attr里查不到行(我遇到 16 张图)❌ 本地没有就是没有,只有云端有。可试试 evernote-backup 走 API 拉
源图片文件损坏resources.data有字节但 Pillow 解不开(遇到 2 张)❌ 数据层面就坏了
笔记内手动加密段ENML 里是<en-crypt>标签(和导出加密是两回事!)⚠️ 可以解,用第二节的 ENC0 代码 +你当时设的 passphrase

最后再强调一次区分:<en-crypt>是你在笔记里手动加密的段落( Evernote 的老功能,需要 passphrase,能解);base64:aes是 2022.7 之后导出时客户端给你上的锁(无密码,解不了)。网上很多工具混淆了这两者。

十、我的最终产出(供参考)

用这套方法 + python-docx 渲染管线(ENML → 加粗/斜体/待办/表格/内嵌图片,图片超 2000px 自动压缩),从一个 28GB 的库里导出了 4 个笔记本、601 条笔记:正文、时间、标签全部无损,1800+ 张图片既内嵌文档又保留原图文件夹,视频音频按需求只留文件名。Word 文档再用 Word COM 转 PDF 渲染成图抽查排版——这类「生成文档」的活,XML 层验证 + 视觉抽查两轮缺一不可,我第一轮就抓出封面跑到文档末尾、图片被分页切成两半这种纯代码检查永远发现不了的问题。

对账结果:库中 601 条 = 文档 601 条,标题多重集逐条一致,零缺失。

十一、安全性提醒(重要)

  • .exb是全部笔记的明文,本身比加密的.notes敏感得多,分享/备份时注意;
  • 所有操作我只用了mode=ro只读连接,不写库、不动客户端,理论上安全;但动手前复制一份.exb永远是免费的保险;
  • 表结构是逆向出来的(2026-10 的 Windows 客户端),印象笔记更新后字段可能变——所以我把 4.1 的探针和 5 节的标定法都写出来了,版本变了你们自己跑一遍就能适配。

写在最后

平台锁数据,用户总有办法——因为显示必须依赖明文,这是所有「加密导出」方案的阿喀琉斯之踵。这次经历最大的感悟:解密是零和游戏,绕过去是降维打击。

脚本和本文随意转载使用,注明出处即可。如果你也踩了别的坑(尤其是国际版 Evernote 的库结构、或者 aid 不是 40 的版本),评论区见,我把结果更新进文中。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询