简介:这份资源是一套用 Python 编写的微信读书书籍与笔记导出工具,面向有基础 Python 使用经验、希望把个人书架和读书笔记批量保存到本地的读者,可用于个人知识库整理、笔记归档与二次分析等场景。压缩包共 8 个文件,约 277KB,以 3 个 py 脚本为核心,分别承担爬取逻辑、Excel 处理与 PyQt 图形界面入口,另含 2 个 txt 依赖与说明文件、2 张 png 界面截图和 1 个 md 说明文档,结构紧凑、便于快速上手。目前已有 2180 人学习下载。代码附带依赖安装与运行说明,读者可据此完成环境配置并启动图形界面,将微信读书中的书籍信息与笔记导出为可编辑表格,同时可参考脚本中的请求与解析思路,理解爬虫与 GUI 结合的实现方式,并自行调试适配个人账号环境。资源仅供个人学习交流,请勿用于商业用途。
1. 一键导出微信读书的书籍和笔记:为什么我最后放弃了截图存档
微信读书里的划线、想法和书评,攒到几百条之后,靠手动复制粘贴基本等于自虐。我最早的做法是每本书开一个备忘录,读到金句就切出去粘贴一次,坚持了不到两周就放弃了——切屏打断心流是小事,真正要命的是笔记散落在不同 App 里,想按书检索、想导出成 Markdown 归档,完全无从下手。后来我换了个思路:既然阅读数据都在账号里,那就用 Python 写个爬虫,把书架、划线、想法、书评一次性拉下来,落成结构化文件。这篇笔记讲的就是这条路怎么走通,包括登录态怎么拿、接口怎么找、翻页怎么处理、频率怎么控。适合有 Python 基础、想给自己做一套个人阅读归档的开发者,也适合想练手接口分析的新手。需要提前说清楚:只导出自己账号下的数据,别碰别人的,也别拿去批量分发。
2. 先搞清楚数据从哪来:微信读书的接口结构与登录态
动手写代码之前,得先弄明白一件事:你要的数据到底藏在哪个请求里。很多人一上来就打开开发者工具乱翻,翻半天找不到划线列表,最后怀疑是不是加密了。其实微信读书 Web 端的数据流并不复杂,关键是分清「页面渲染数据」和「接口返回数据」这两类,前者是 HTML 里塞的,后者是 XHR 异步拉的,你要的是后者。
2.1 书架、划线、想法分别对应哪类请求
打开 Web 端书架页,按 F12 切到 Network 面板,筛选 XHR,刷新页面,你会看到几个明显的请求。书架列表通常是一个带bookId数组的接口,返回的是你账号下所有书的元信息,包括书名、作者、封面、阅读进度。点进某本书的笔记页,又会触发另一批请求,划线(underline)和想法(thought)往往是分开返回的,有的版本会合并成一个bookmarklist之类的接口。这里有个血泪经验:不同端的接口路径不一样,Web 端和 App 端的参数结构经常对不上,你照着某篇老教程抄的 URL,很可能早就失效了。所以别背接口,学会自己抓。
判断方法很简单:在笔记页滚动加载,看哪个请求的响应里出现了你刚划的那句话。找到之后,把它的 Request URL、Method、Headers、Payload 全部复制出来,这就是你的「数据源模板」。一般来说,划线接口会带bookId、chapterUid之类的参数,想法接口还会多一个分页游标。
2.2 登录态怎么拿:Cookie 与关键 Header 的取舍
微信读书的接口基本都要登录态,核心就是 Cookie。你登录之后,浏览器里会存一串wr_skey、wr_vid之类的字段,请求时带上就能通过校验。最省事的做法是直接从浏览器复制完整 Cookie 字符串,塞进代码里跑。但这里有个坑:Cookie 是有有效期的,而且换设备、换网络环境可能触发重新验证,所以别把它硬编码进仓库,用环境变量或者本地配置文件存。
除了 Cookie,还有几个 Header 值得注意。User-Agent最好伪装成正常浏览器,别用 requests 默认的,否则容易被识别。Referer有时也校验,填成对应的页面地址即可。至于Authorization之类的字段,Web 端一般用不到,App 端才常见。我一般会先把浏览器里那条成功请求的 Headers 原样抄下来,跑通之后再逐个删减,看哪些是必需的,这样比盲猜高效得多。
import os import requests # 从环境变量读取,避免硬编码进代码仓库 COOKIE = os.environ.get("WEREAD_COOKIE", "") BASE_HEADERS = { "Cookie": COOKIE, "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Referer": "https://weread.qq.com/", "Accept": "application/json, text/plain, */*", } def fetch_json(url, params=None): """统一封装 GET 请求,方便后续替换成 POST""" resp = requests.get(url, headers=BASE_HEADERS, params=params, timeout=10) resp.raise_for_status() return resp.json()这段代码做了三件事:把 Cookie 从环境变量读进来,避免泄露;统一了请求头,减少被拦截的概率;封装了一个fetch_json,后面所有接口调用都走它。参数说明上,timeout=10是防止某个请求卡死拖垮整个脚本,raise_for_status让你在状态码异常时立刻发现,而不是拿到一堆空数据还以为是解析问题。实际跑的时候,如果返回 401 或 403,八成是 Cookie 过期了,重新复制一份即可。
3. 把书架和笔记拉下来:分页、解析与落盘
拿到接口模板之后,下一步就是把它变成能批量跑的脚本。这里分三块:先拉书架拿到所有书的 ID,再针对每本书拉划线和想法,最后把数据落成 Markdown 或 JSON。听起来线性,但真正写起来,分页和字段解析是最容易翻车的地方。
3.1 书架列表的翻页逻辑与字段提取
书架接口一般不会一次性返回所有书,尤其是书多的时候,会有分页或者滚动加载。常见做法是带一个offset或lastBookId参数,每次返回一批,直到返回空列表为止。我一般会写一个循环,把每页的结果累加,同时加一个最大页数保护,防止接口异常时死循环。
def fetch_bookshelf(): """循环拉取书架,直到没有更多数据""" books = [] offset = 0 while True: data = fetch_json( "https://weread.qq.com/api/user/bookshelf", params={"offset": offset, "limit": 20}, ) batch = data.get("books", []) if not batch: break for item in batch: books.append({ "bookId": item.get("bookId"), "title": item.get("title"), "author": item.get("author"), "progress": item.get("readingProgress", 0), }) offset += len(batch) if offset > 2000: # 保护阈值,按自己书量调整 break return books逻辑上,每次请求拿 20 本,拿到空列表就停。offset累加的是实际返回数量,而不是固定步长,这样即使接口返回数量有波动也不会漏。字段提取时只保留你需要的,别把整个响应塞进内存,书多了会很占资源。progress字段有的版本叫readingProgress,有的叫finishReading,以你实际抓到的为准,别照抄。
3.2 划线、想法、书评的解析与 Markdown 落盘
拿到 bookId 之后,针对每本书调笔记接口。划线通常返回一个数组,每条包含章节名、划线原文、位置信息;想法则多一个content字段和创建时间。解析的时候要注意,有些字段是嵌套的,比如章节信息可能在chapter对象里,别直接取顶层。落盘我习惯用 Markdown,一本书一个文件,划线用引用块,想法用普通段落加时间戳,这样在 Obsidian 或 Typora 里直接能看。
def export_book_notes(book): """导出单本书的划线和想法为 Markdown""" data = fetch_json( "https://weread.qq.com/api/book/bookmarklist", params={"bookId": book["bookId"]}, ) lines = [f"# {book['title']}\n", f"作者:{book['author']}\n"] for bm in data.get("updated", []): chapter = bm.get("chapterName", "未知章节") text = bm.get("markText", "").strip() if not text: continue lines.append(f"## {chapter}\n") lines.append(f"> {text}\n") # 想法可能挂在同一条记录下 for thought in bm.get("thoughts", []): lines.append(f"- {thought.get('content', '')}\n") filename = f"notes/{book['bookId']}.md" with open(filename, "w", encoding="utf-8") as f: f.write("\n".join(lines)) return filename这里的关键是字段容错:markText可能为空,thoughts可能不存在,所以用get加默认值,避免 KeyError 直接崩掉。章节名有的接口返回chapterName,有的返回chapterTitle,跑之前先打印一条完整响应确认。落盘路径建议单独建目录,文件名用 bookId 而不是书名,避免书名里有特殊字符导致写入失败。
3.3 用并发把几百本书的导出时间压下来
串行跑几百本书,每本一次请求,加上网络延迟,可能要十几分钟。这时候可以用concurrent.futures开几个线程并发拉取。但注意别开太多,微信读书的接口对频率敏感,线程数控制在 3 到 5 比较稳妥,再高容易触发限流。
from concurrent.futures import ThreadPoolExecutor, as_completed def export_all(books): """并发导出,控制线程数避免触发限流""" results = [] with ThreadPoolExecutor(max_workers=3) as pool: futures = {pool.submit(export_book_notes, b): b for b in books} for future in as_completed(futures): book = futures[future] try: path = future.result() results.append(path) print(f"完成:{book['title']} -> {path}") except Exception as e: print(f"失败:{book['title']},原因:{e}") return resultsmax_workers=3是经验值,你可以从 2 开始试,观察有没有返回 429 或空数据。每个任务独立捕获异常,避免一本书失败导致整批中断。打印进度是为了长任务时心里有数,不然跑半天不知道卡在哪。
4. 避坑与排查:导出微信读书笔记时最容易翻车的五件事
这一章是我踩过的坑合集,每一条都按「现象 → 原因 → 解决」写,你遇到问题时可以直接对号入座。
现象一:请求返回 200,但数据是空的。原因通常是 Cookie 过期或者缺少关键 Header,接口没报错但返回了空结构。解决方法是先打印完整响应,确认是不是{"updated": []}这种;如果是,重新复制 Cookie,并检查Referer是否填对。
现象二:翻页翻到一半开始重复。原因是offset用了固定步长,而接口实际返回数量小于步长,导致偏移错位。解决方法是每次用实际返回的len(batch)累加,而不是写死 20。
现象三:划线内容里出现 HTML 标签或转义字符。原因是接口返回的是富文本片段,直接写进 Markdown 会显示异常。解决方法是做一次简单清洗,把<br>换成换行,把 换成空格,或者用html.unescape处理。
现象四:跑几十本之后开始大量失败。原因是并发过高或请求间隔太短,触发了频率限制。解决方法是把线程数降到 2 到 3,并在每次请求之间加time.sleep(0.5)到1秒的随机延迟。
现象五:书名里有斜杠或冒号,写文件时报错。原因是文件名非法字符。解决方法是文件名统一用 bookId,书名只写在文件内容里,或者用正则把非法字符替换掉。
提示:调试阶段建议只跑一本书,把完整响应打印出来看结构,确认字段名之后再批量跑。直接上全量,出了问题很难定位。
5. 进阶:把导出结果接进自己的知识库
跑通基础版之后,你手里就有了一堆 Markdown 文件。但光导出还不够,真正有价值的是让这些笔记能被检索、被关联。我现在的做法是:导出时在文件头部加 YAML front matter,写上书名、作者、导出时间、划线数量,这样 Obsidian 的 Dataview 插件就能直接按作者或时间筛选。另外,我会把同一本书的划线和想法按章节聚合,而不是按接口返回顺序平铺,读起来更接近原书结构。
还有一个技巧是增量导出。每次全量跑太浪费,可以在本地存一个last_export.json,记录每本书上次导出的时间戳,下次只拉更新时间晚于这个点的书。接口里一般有updateTime字段,拿来比较即可。这样日常维护只需要几秒钟。
import json import os from datetime import datetime STATE_FILE = "last_export.json" def load_state(): if os.path.exists(STATE_FILE): with open(STATE_FILE, encoding="utf-8") as f: return json.load(f) return {} def save_state(state): with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def need_update(book, state): """对比本地记录与接口返回的更新时间""" last = state.get(book["bookId"], {}).get("updateTime", 0) return book.get("updateTime", 0) > last这段代码的核心是状态文件,记录每本书最后一次导出的时间。need_update判断是否需要重新拉取,避免无谓请求。注意updateTime的格式可能是秒级时间戳,也可能是毫秒,跑之前打印一条确认,别直接比较。
最后说个我自己的习惯:导出脚本我从来不放在项目根目录裸跑,而是包一层main.py,把 Cookie 读取、目录创建、异常汇总都收进去,跑完打印一份「成功多少本、失败多少本、跳过多少本」的简报。这样每次执行完一眼就知道有没有问题,不用去翻日志。希望帮到你。
本文还有配套的精品资源,点击获取