0%

锤子便签导出到本地 Markdown:370 条实录

我的笔记一直散着。读书时记的放锤子便签,通勤路上随手写的放魅族便签,早年还有有道云笔记和 Notion。时间一长,自己都说不清哪条在哪儿,搜也搜不全。知识管理与消费里问过一句,散落各处的笔记、随想能不能有个地方统一管起来;工作的第一个十年里也提过,说想把所有数据整合到一起。

想把它们收敛到本地一个目录、用自己的方式管起来,就得先把每个来源的数据拿下来。锤子便签是其中一份,另外两份是魅族云便签和魅族云短信。脚本都开在 GitHub 上:phone-cloud-data-export/smartisan-notes,拿到就能跑。

锤子便签的云端在欢喜云 yun.smartisan.com。手机端早就停更,账号中心跳去了 snote.ink,那是字节接手后的另一套产品,和原来的便签不是同一份数据。

我账号里还有 370 条便签挂在旧接口上。产品在收缩,接口没有退场说明,能拿的时候先拿。导出分两步:抓原始 JSON,再转成带 frontmatter 的 Markdown。

1. 接口从哪来

锤子便签的网页版是欢喜云里的子应用,跑在 iframe 里,地址 https://yun.smartisan.com/apps/note/。前端是 Angular 1.x,接口都挂在 https://yun.smartisan.com/apps/note/index.php 下,用查询参数 r= 指定动作,同源带 Cookie。

用到三个:

  • 登录态:GET index.php?r=account/login
  • 分类列表:GET index.php?r=folder/getList
  • 便签列表:GET index.php?r=v2/getList,参数 page、page_size

返回统一是 {code, data},code 为 1 表示出错,未登录是 1701。前端默认每页 3000 条,370 条一页就拉完。

2. 为什么在浏览器控制台里跑

最省事的做法是把 Chrome 的 Cookie 库复制出来,用 curl 批量拉。这条路的代价是,Cookie 库里存着所有站点的登录态,范围远超「导出便签」这一件事。

所以改成在已经登录的页面里跑脚本。脚本只用当前登录态调官方接口,数据从浏览器直接下载到本地,Cookie 不出本机。三篇导出用的都是这个路子,另外两篇是魅族云便签和魅族云短信。

3. 云端没有创建时间

接口返回的字段有这些:

1
2
3
uid, sync_id, seqid, eseqid, folderId, favorite, markdown, pos, call_timestamp,
modify_time, title, detail, formatting_mode, is_locked, folder_type,
position_in_folder, rtf_style, tab_id, conflict_id, from_device

只有 modify_time,没有 create_time。370 条的返回里都没有。

seqid 和 eseqid 是两个 18 至 19 位整数,看着像时间戳。我把 0 至 32 位所有位移和秒、毫秒、微秒、纳秒四种单位都试了一遍,反推出来的纪元离散度最小也有正负两个月,没有干净解。它们是同步序列号,不是时间编码。

锤子被字节收购后的 snote.ink 也走过,那边是另一套 ragapp 应用,数据不通。

所以导出的 frontmatter 里 created 一律写 null。没有拿修改时间冒充创建时间,也没有用 seqid、pos 去反推。

真要创建时间,剩下两条路。一条是还留着当年的锤子手机,便签 App 的本地 SQLite 里有,需要 root 或完整备份。另一条是先留空,以后拿到别的来源再按 sync_id 回填。

网页版界面上显示的也是修改时间,前端模板里就是 {{note.modify_time|editTime}}。这个产品对外只给修改时间。

4. 标题和图片都不是常规字段

服务端的 title 不是独立字段,是拿正文第一行非空文字生成的(NoteStringService.getTitle,超过 80 个显示宽度截断)。

370 条里有 3 条和正文首行对不上:2 条是截断,1 条是纯图片便签,正文没有文字,标题退成「图片便签」。导出的 frontmatter 用服务端原值,文件名也用服务端 title,不另外编。

图片也不是 Markdown,是自定义标记:

1
<image w=宽度 h=高度 describe=描述 name=文件名>

文件地址是 https://yun.smartisan.com/apps/note/notesimage/<name>,要登录态才取得到。这个账号里有 6 张图片,还在云端,要落到本地得再跑一次下载脚本。

5. 换行

370 条里 357 条带换行。单换行原样留在 md 里,标准 Markdown 渲染会把它当空格,笔记会糊成一整段。

默认把夹在文字中间的单换行转成行尾 <br>,空行仍然当段落分隔。转换后核对过,md 里的 <br> 数量和原始数据的单换行数一致,都是 5115。

想换别的风格,加 --line-break= 重跑:

  • br(默认):行尾 <br>,任何渲染器都认
  • space:行尾两个空格,raw 更干净,但容易被编辑器去尾空格弄丢
  • blank:单换行换成空行,每行独立成段,渲染最稳但文件变长
  • keep:原样保留

6. 怎么确认一条没漏

node verify.js smartisan-notes-raw.json notes 会逐条比对 md 和原始 JSON:标题、分类、分类 id、修改时间、时间戳、回收站标记、收藏、pos、formatting_mode、附件清单、正文。正文是先把 md 里的 <br> 和图片链接还原回原始写法再比。

当前结果 370/370,缺失 0,字段全部一致。

7. 结果

370 个 md,按分类分目录:未分类 322、回收站 31、日记 13、生活 2、读书 1、学习 1。时间跨度 2017-05-21 至 2025-04-13,按修改时间。

文件名是「修改日期 标题.md」,顶部 frontmatter 带 sync_id、seqid、category、category_id、folder_type、trash、favorite、created、modified、pos、formatting_mode、attachments 等字段。sync_id 是留给以后回填创建时间的锚点。

另外有 8 条便签指向云端已经不存在的分类 id,没有编造分类名,原始 id 留在 category_id 里。

8. 清空云端(可选)

网页版删一条要点两下,批量很烦。接口能一次做完,但有两个坑。

移到回收站和彻底删除是两个不同的接口:

  • 移到回收站:POST index.php?r=note/updateFolder,参数 sync_ids(JSON 字符串)、folder_type=3、folderId(空)
  • 彻底删除:POST index.php?r=note/deleteAll,参数 sync_ids(逗号拼接)

note/deleteAll 对不在回收站的便签不生效。实测拿它去删 339 条正常便签,一条都没删掉。所以清空必须两步:先移进回收站,再删。

写请求还要带 tab_id。前端每次加载页面随机生成一个 8 位值,服务端拿它区分浏览器标签页。不带的话 note/deleteAll 直接报错:

1
{"code":1,"data":[],"errInfo":{"2109":"便签web标签错误"}}

tab_id 是纯客户端随机值,自己生成 8 位字母数字就行。

清空脚本默认只打印命中的清单,不动数据,要真删得把 CONFIRM 填成「确认」。删除会同步到手机上还在用的锤子便签 App,云端删掉不影响本地已经存下来的 md 和 JSON。

9. 导完之后

三条来源各建一个目录,放进同一个笔记库,标题、分类、时间都进了 frontmatter,全文搜索一次能搜全。

选 Markdown 是因为它不绑定工具。Obsidian、VS Code、ripgrep 都能直接读,换软件也不用重导。frontmatter 里留着的 sync_id 也不白留:以后要跟云端对账,或者补上锤子缺的创建时间,还能对上。

10. 脚本

抓取、转换、校验和清空四个脚本,都用 node 直接跑,没有第三方依赖。

GitHub 仓库:phone-cloud-data-export/smartisan-notes