
我把印象笔记的「加密导出」掰开揉碎最后用一招绕过了它——本地明文库直提全攻略一次真实的自救7 个.notes文件、7.7GB、正文全是密文。本文记录从逆向加密格式到最终 601 条笔记无损导出的全过程包括每一步的踩坑、调试和验证方法。脚本可直接抄作业。实测环境印象笔记 Windows 客户端 v6.x2026-10· Windows 10/11 · Python 3.8仅标准库TL;DR印象笔记 2022 年 7 月起把导出格式改成了专用的.notes正文自动 AES 加密和你设没设密码无关官方不给密钥第三方工具全灭逆向加密格式ENC0的结论这条路数学上走不通别浪费时间但客户端要在本地显示笔记明文必然躺在磁盘上——找到本地数据库.exbSQLite正文、图片、元数据全是明文直接读附完整可跑脚本、表结构、日期换算标定法、以及如何用「三方对账」证明自己一条不漏。一、发生了什么你导出的文件为什么打不开帮家里整理笔记从印象笔记 Windows 客户端里导出了 7 个.notes文件共 7.7GB。用文本编辑器打开一看好家伙XML 骨架是熟悉的 ENEXcontentencodingbase64:aes![CDATA[RU5DMFrPjZq71RQKnO8AJ6H5eK3...]]/content但每条笔记的正文都变成了base64:aes。注意三个事实只有正文加密。图片、视频等附件的resource是明文 base64能直接解出来导出全程没让设置任何密码。这个加密是客户端自动做的这是印象笔记 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] IV16 字节 [52:-32] AES-128-CBC 密文与 16 字节块对齐 [-32:] HMAC-SHA256(body) 校验位验证密码的代码很短而且完全离线每个候选约 40ms因为 5 万轮 PBKDF2importbase64,hashlib,hmacfromCrypto.CipherimportAESdefenc0_decrypt(payload_b64:bytes,password:str):rawbase64.b64decode(b.join(payload_b64.split()))assertraw[:4]bENC0salt,salthmac,ivraw[4:20],raw[20:36],raw[36:52]body,bodyhmac,ctraw[:-32],raw[-32:],raw[52:-32]keyhmachashlib.pbkdf2_hmac(sha256,password.encode(),salthmac,50000,16)ifnothmac.compare_digest(hmac.new(keyhmac,body,hashlib.sha256).digest(),bodyhmac):returnNone# 密码错HMAC 对不上keyhashlib.pbkdf2_hmac(sha256,password.encode(),salt,50000,16)ptAES.new(key,AES.MODE_CBC,iv).decrypt(ct)returnpt[:-pt[-1]]# 去 PKCS7 padding2.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, dataaid40 的 data 就是明文 ENML 正文aid0 是标题resource_attruid, note, file_name, mime, width, height, hash资源元数据hash 可与正文里的引用精确配对resourcesuid, data资源二进制图片/视频原文件4.1 「aid40」是怎么找出来的版本变了你也照这个办法找这套表结构印象笔记叫 SDB没有官方文档全靠探针。我不想只给结论把探针也给你们——客户端版本升级后表结构变了跑一遍这个就知道正文挪到哪了uid,con.execute(SELECT uid FROM note_attr LIMIT 1).fetchone()foraid,lnincon.execute(SELECT aid, LENGTH(data) FROM attrs WHERE uid?,(uid,)):datacon.execute(SELECT data FROM attrs WHERE uid? AND aid?,(uid,aid)).fetchone()[0]headbytes(data[:80])tagifb?xmlinheadorben-noteinhead[:400]:tag ← 正文在这(ENML)print(faid{aid:4}len{ln:8}{head[:60]!r}{tag})原理ENML 正文一定带?xml ... ?!DOCTYPE en-note ...签名长度也明显比其他属性大几 KB 到几百 KB。跑一遍哪个 aid 是正文一目了然。我这里实测是40。4.2 正文长什么样aid40 的 data 解码后就是标准 ENMLEvernote 的 XHTML 方言?xml version1.0 encodingUTF-8?!DOCTYPEen-noteSYSTEMhttp://xml.evernote.com/pub/enml2.dtden-notediv正文文字……/diven-mediahash926559fa89a53545ad0509fc17c3d79etypevideo/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 consqlite3.connect(file:quote(DB_PATH)?moderoimmutable1,uriTrue)这个 bug 阴险在没有任何报错指向路径我在no such table: note_attr上愣了好几分钟才反应过来。坑 2自定义排序规则NOCASEUTF8库的文本列挂了自定义 collation任何对这些列的 SQLWHERE/GROUP BY/ORDER BY都会炸sqlite3.OperationalError: no such collation sequence: NOCASEUTF8对策简单粗暴整表拉到 Python 里再过滤。几千条笔记的元数据毫无压力。坑 2.5日期既不是时间戳也不是 TDateTimedate_created是个浮点数比如739028.3247。我第一反应是 Delphi TDateTime基准 1899-12-30算出来年份跑到 3922 年——错。正确姿势是标定法这个方法万能版本不同也能用挑一条笔记从客户端界面或导出的 ENEX看它的真实创建时间比如2024-05-22 15:47查库里对应的浮点值739028.3247反推基准真实时间 - 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(daysv-18/24)不确定点-1 天这个偏移我没找到官方解释怀疑和服务端时区写入有关我是拿 3 条已知时间的笔记交叉验证的。你们上手时务必先标定——拿两三条笔记对一下客户端显示的时间对得上再全量转。六、完整脚本可直接抄作业笔记本 → Markdown 图片文件夹只读直连客户端开着也能跑immutable1拿到的是打开瞬间的一致性快照。依赖只要 Python 3.8 标准库# -*- coding: utf-8 -*-印象笔记本地库 - Markdown按笔记本导出。只读不影响客户端。importdatetime,html,re,sqlite3,sysfrompathlibimportPathfromurllib.parseimportquote# 路径含 #必须编码sys.stdout.reconfigure(encodingutf-8,errorsreplace)DBrD:\Databases\你的用户标识#app.yinxiang.com.exb# ← 改成你的OUTPath(rD:\印象笔记导出)NOTEBOOKS[工作日志,读书笔记]# ← 改成你想导出的笔记本名consqlite3.connect(file:quote(DB)?moderoimmutable1,uriTrue)con.text_factorylambdab:b.decode(utf-8,replace)# 1) 笔记元数据过滤必须在 Python 侧做SQL 侧会触发 NOCASEUTF8 报错rowscon.execute(SELECT uid, title, notebook, tags, date_created, is_deleted FROM note_attr).fetchall()notes[rforrinrowsifnotr[5]andr[2]inNOTEBOOKS]notes.sort(keylambdar:r[4])# 按创建时间排序# 2) 资源元数据按 note 批量取resource_attr 有 note 索引200 条一批by_note,uids{},[n[0]forninnotes]foriinrange(0,len(uids),200):chunkuids[i:i200]q,.join(?*len(chunk))foruid,note,fname,mime,hshincon.execute(fSELECT uid, note, file_name, mime, hash FROM resource_attr fWHERE note IN ({q}) AND (is_deleted IS NULL OR is_deleted0),chunk):by_note.setdefault(note,[]).append((uid,fname,mime,hsh))defto_bj(v):return(datetime.datetime(1,1,1)datetime.timedelta(daysv-18/24))ifvelse?# 3) 逐条导出fork,(uid,title,nb,tags,dc,_)inenumerate(notes,1):safere.sub(r[\\/:*?|],_,titleorf无标题{k})[:80]mdOUT/re.sub(r[\\/:*?|],_,nb)(md/images).mkdir(parentsTrue,exist_okTrue)textf#{title}\n\n 创建于{to_bj(dc)}(f | 标签:{tags}iftagselse)\n\nbodycon.execute(SELECT data FROM attrs WHERE uid? AND aid40,(uid,)).fetchone()ifbodyandbody[0]:xmlbody[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/):continuedatacon.execute(SELECT data FROM resources WHERE uid?,(ra_uid,)).fetchone()ifnot(dataanddata[0]):continueimgmd/images/f{ra_uid}_{fnameor(hsh[:8].img)}ifnotimg.exists():img.write_bytes(data[0])xmlre.sub(fen-media[^]*hash{hsh}[^]*/?,f,xml)# 3b) 剩余标签粗转纯文本想要加粗/表格级还原的上 lxml 遍历xmlre.sub(rbr\s*/?,\n,xml)xmlre.sub(r/div|/p|/li,\n,xml)xmlre.sub(ren-todo[^]*checked[\]true[\][^]*/?,☑ ,xml)xmlre.sub(ren-todo[^]*/?,☐ ,xml)xmlre.sub(ren-media[^]*,[音视频/附件],xml)xmlre.sub(r[^],,xml)texthtml.unescape(xml)(md/f{k:04d}_{safe}.md).write_text(text,encodingutf-8)ifk%500:print(f{k}/{len(notes)})print(f完成{len(notes)}条 -{OUT})几个设计说明图片先落盘再替换引用按hash而不是按顺序配对。顺序配对在笔记有多个资源时容易错位hash 是精确匹配视频音频只在正文里标注28GB 的库里大头是视频只导文字图片的话务必先按mime过滤再读data列——我第一版没过滤光读资源数据就拉了 9GB 进内存immutable1的语义是「假设文件不会变给我当前一致性视图」。客户端正在写库时严格说应该复制.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(rbhash([0-9a-fA-F]),enml_bytes)}q,.join(?*len(want))forra_uid,fname,mime,w,h,hshincon.execute(fSELECT uid, file_name, mime, width, height, hash fFROM resource_attr WHERE hash IN ({q}),sorted(want)):...# 走 resources.uid 取数据塞回渲染管线一行 SQL 的事但如果你不知道合并机制会像我一样先怀疑人生半小时。八、怎么证明「一条没漏」三方对账导出类任务最大的自我欺骗是「脚本跑完了 数据全了」。我的做法是三方对账而且不是对总数是对标题多重集importhtml,re,sqlite3fromcollectionsimportCounterfrompathlibimportPathfromdocximportDocument# 或直接数生成的 .md 文件名# ① 库侧该笔记本所有未删笔记标题libCounter(html.unescape(r[0])forrinlib_rowsifnotr[2]andr[1]inbooks)# ② 成品侧文档里实际出现的标题Word 里数 Heading 2Markdown 数文件名也行docxCounter(html.unescape(re.sub(r^\d\. ,,p.text))forpindoc.paragraphsifp.style.nameHeading 2)# ③ 源文件侧可选如果你有 .notes 导出解析它里面的 title 集合missing,extralib-docx,docx-libprint(缺:,list(missing),多:,list(extra))两个容易翻车的细节必须html.unescape再比。ENEX 里存成amp;直接比会出现一批「看起来不一样其实一样」的假差异我第一次对账就撞上 5 条多重集不是集合。两条同名的「无标题笔记」用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敏感得多分享/备份时注意所有操作我只用了modero只读连接不写库、不动客户端理论上安全但动手前复制一份.exb永远是免费的保险表结构是逆向出来的2026-10 的 Windows 客户端印象笔记更新后字段可能变——所以我把 4.1 的探针和 5 节的标定法都写出来了版本变了你们自己跑一遍就能适配。写在最后平台锁数据用户总有办法——因为显示必须依赖明文这是所有「加密导出」方案的阿喀琉斯之踵。这次经历最大的感悟解密是零和游戏绕过去是降维打击。脚本和本文随意转载使用注明出处即可。如果你也踩了别的坑尤其是国际版 Evernote 的库结构、或者 aid 不是 40 的版本评论区见我把结果更新进文中。