
Taxue Weread
- 8 installs
- 1 repo stars
- Updated May 28, 2026
- taxueseek/taxue-weread
Helps with ai & agent building tasks.
About
taxue-weread is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- taxue-weread
- AI & Agent Building
- AI-coding skill
Taxue Weread by the numbers
- 8 all-time installs (skills.sh)
- Ranked #12,321 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/taxueseek/taxue-weread --skill taxue-wereadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 1 |
| Last updated | May 28, 2026 |
| Repository | taxueseek/taxue-weread ↗ |
What it does
Helps with ai & agent building tasks.
Files
WeRead — 微信读书助手
CLI:`scripts/weread.py`(别名 `WR`)。内置鉴权、缓存(TTL=5min)、重试、分页、深度链接。
需要 WEREAD_API_KEY。LLM 调用一律加 --json。详细参数用 $WR <cmd> --help。
性能基准与效率策略
| 操作 | 耗时 | 说明 |
|---|---|---|
| 进程启动 | ~85ms | 每次调用固定开销,能合并就合并 |
| search(缓存命中) | ~0.1s | 5分钟TTL |
| search(网络) | ~1s | 实际API请求 |
| 批量查询5本 | ~0.5s | scripts/weread_batch.py info,一次进程 |
| 批量搜索多关键词 | ~0.2s | scripts/weread_batch.py search,并行 |
| 推荐书(含多关键词) | ~1.3s | scripts/weread_recommend.py books |
| 推荐作者 | ~0.6s | scripts/weread_recommend.py authors |
| 版本对比 | ~0.8s | scripts/weread_recommend.py versions |
| 章节推荐 | ~1s | scripts/weread_recommend.py chapters |
| mirror deep | ~2.7s | 最慢,按需使用 |
核心效率原则: 1. 能合并就合并:多本书信息查询用 weread_batch.py,一次进程替代多次 2. 能并行就并行:推荐引擎内部全部并行,不串行调 API 3. 能缓存就缓存:5分钟 TTL,重复请求自动命中 4. 能推断就不搜:根据用户已有数据(书架、笔记、阅读历史)直接推断,不重复拉取
意图路由
| 用户意图 | 命令 | 备注 |
|---|---|---|
| 搜书(单本) | WR search <书名> --json | 精确匹配 |
| 搜书(批量) | scripts/weread_search.py 书名1 书名2 ... | 并行,0.6秒/20本 |
| 书籍详情 | WR book <bookId> | 文本输出,含简介 |
| 批量查详情 | scripts/weread_batch.py info <id1> <id2> ... | 一次进程 |
| 书架一览 | WR shelf [--summary] | 统计用 --summary |
| 阅读统计 | `WR readdata --mode monthly\ | annually\ |
| 笔记/划线 | WR notes --book <bookId> | |
| 热门划线 | WR bestbookmarks <bookId> | TOP20,按热度 |
| 章节目录 | WR book <bookId> --chapters | 文本输出 |
| 阅读画像 | `WR mirror [--depth quick\ | standard\ |
| 导出笔记 | `WR export <bookId>\ | --all --output <路径>` |
精细化推荐路由
| 推荐类型 | 命令 | 输出 |
|---|---|---|
| 推荐书 | scripts/weread_recommend.py books "关键词" | 书列表,含评分/分类 |
| 推荐作者 | scripts/weread_recommend.py authors "关键词" | 作者列表,含代表作/均分 |
| 版本对比 | scripts/weread_recommend.py versions "书名" | 多版本对比,标注推荐 |
| 相似书 | scripts/weread_recommend.py similar <bookId> | 相似书列表 |
| 综合画像 | scripts/weread_recommend.py profile --data <用户数据JSON> | 多维度推荐 |
| 章节推荐(关键词) | scripts/weread_recommend.py chapters <bookId> "关键词" | 关键词硬匹配 |
| 章节推荐(模型) | scripts/weread_chapters.py <bookId> "查询" → 模型推理 | 语义匹配,更准确 |
关键规则
1. search 优先:WR search 精确匹配。resolve 精度低,仅在不确定书名时用。 2. 批量必须并行:超过3本书或关键词,一律用脚本并行,禁止串行循环。 3. 合并进程:需要多本书的信息时,用 weread_batch.py info 一次查完,不要逐本调 WR book。 4. 缓存策略:5分钟 TTL(/tmp/weread_cache/,458文件/3.4MB)。需要刷新时 WEREAD_NO_CACHE=1。 5. API 陷阱:时长单位秒、newRating 0-1000(除以10得百分比)、progress 0-100 整数。
工作流
精细化推荐(核心场景)
1. 获取用户数据(已有缓存则跳过)
- WR mirror --depth quick → 书架概览 + 笔记分布
- WR readdata --mode overall → 核心统计
2. 根据用户需求选择推荐类型
- "推荐书" → weread_recommend.py books
- "推荐作者" → weread_recommend.py authors
- "哪个版本好" → weread_recommend.py versions
- "重点看哪章" → weread_recommend.py chapters
- "推荐画像" → weread_recommend.py profile
3. 输出推荐结果
- 书名/作者/章节名
- 评分 + 评价人数
- 推荐理由(结合用户数据)
- 深度链接(weread://reading?bId=xxx)推荐输出格式
推荐书:
📚 《书名》
作者 | 评分% (N人评) | 分类
推荐理由:xxx
🔗 weread://reading?bId=xxx版本对比:
📚 书名 (N个版本)
✅ 推荐:版本名 | 评分% | N人评
理由:评分最高,评价人数最多
❌ 版本名 | 评分% | N人评章节推荐(模型驱动):
步骤:
1. python3 scripts/weread_chapters.py <bookId> "用户查询"
→ 获取目录 + 划线 + prompt
2. 将 prompt 喂给模型做语义匹配
→ 模型返回 JSON(章节名、推荐理由、相关度)
3. 格式化输出给用户
输出示例:
📖 纳瓦尔宝典 — "关于财富积累"
1. 第一部分 财富 ⭐⭐⭐⭐⭐
理由:12条热门划线,核心讲的是财富积累方法
💬 "获得财富的一个途径,就是为社会提供其有需求但无从获得的东西"
2. 第一章 积累财富 ⭐⭐⭐⭐
理由:专讲财富积累的具体方法阅读画像分析
1. WR mirror --depth deep --json → 全量数据
2. WR readdata --mode overall --json → 核心统计
3. 分析:思维结构、阅读人格、年度趋势、盲区识别
4. 生成推荐:基于盲区 → weread_recommend.py profile年度报告
1. WR mirror --depth deep --json
2. WR readdata --mode annually --json
3. 两个命令可并行
4. 注入 weread-insight 模板 → HTML 报告脚本清单
| 脚本 | 用途 |
|---|---|
scripts/weread.py | 核心 CLI,所有 API 调用 |
scripts/weread_search.py | 批量并行搜索(多本书名) |
scripts/weread_batch.py | 批量查询(多本书详情/搜索/笔记/划线) |
scripts/weread_recommend.py | 精细化推荐引擎(书/作者/版本/相似/画像) |
scripts/weread_chapters.py | 章节推荐数据拉取 + 模型 prompt 生成 |
参考文档
| 文档 | 何时读取 |
|---|---|
references/troubleshooting.md | 必读:API 陷阱 + 字段语义 |
references/mirror-guide.md | 阅读画像分析时 |
references/organize-guide.md | 整理笔记时 |
references/material-grading.md | 提取写作素材时 |
references/readdata.md | 跨周期统计时 |
#!/usr/bin/env node
const { spawn } = require('child_process');
function checkPython() {
return new Promise((resolve, reject) => {
const proc = spawn('python3', ['--version']);
let output = '';
proc.stdout.on('data', (d) => output += d);
proc.stderr.on('data', (d) => output += d);
proc.on('close', (code) => {
if (code === 0) {
console.log(`✅ ${output.trim()}`);
resolve();
} else {
// 尝试 python
const proc2 = spawn('python', ['--version']);
let output2 = '';
proc2.stdout.on('data', (d) => output2 += d);
proc2.stderr.on('data', (d) => output2 += d);
proc2.on('close', (code2) => {
if (code2 === 0) {
console.log(`✅ ${output2.trim()}`);
resolve();
} else {
console.error('❌ Python 3 is required but not found.');
console.error(' Install Python 3: https://www.python.org/downloads/');
reject(new Error('Python 3 not found'));
}
});
}
});
});
}
checkPython().catch(() => process.exit(1));
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const SCRIPT_DIR = path.resolve(__dirname, '..', 'scripts');
const PYTHON_SCRIPT = path.join(SCRIPT_DIR, 'weread.py');
// 检查 Python 是否可用
function checkPython() {
return new Promise((resolve, reject) => {
const proc = spawn('python3', ['--version']);
proc.on('close', (code) => {
if (code === 0) resolve('python3');
else {
// 尝试 python
const proc2 = spawn('python', ['--version']);
proc2.on('close', (code2) => {
if (code2 === 0) resolve('python');
else reject(new Error('Python 3 is required. Please install Python 3.'));
});
}
});
});
}
async function main() {
// 检查 WEREAD_API_KEY
if (!process.env.WEREAD_API_KEY) {
console.error('错误: WEREAD_API_KEY 未设置,请 export WEREAD_API_KEY=wrk-xxxxxxxx');
process.exit(1);
}
// 检查脚本是否存在
if (!fs.existsSync(PYTHON_SCRIPT)) {
console.error(`错误: 找不到脚本 ${PYTHON_SCRIPT}`);
process.exit(1);
}
try {
const pythonCmd = await checkPython();
// 传递所有参数给 Python 脚本
const args = [PYTHON_SCRIPT, ...process.argv.slice(2)];
const proc = spawn(pythonCmd, args, {
stdio: 'inherit',
env: process.env
});
proc.on('close', (code) => {
process.exit(code || 0);
});
proc.on('error', (err) => {
console.error('执行错误:', err.message);
process.exit(1);
});
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
main();
{
"name": "taxue-weread",
"version": "1.5.0",
"description": "微信读书官方 Skill 的 Agent 优化版——CLI + 缓存 + 合并命令 + 压缩输出",
"bin": {
"taxue-weread": "bin/taxue-weread.js",
"twr": "bin/taxue-weread.js"
},
"files": [
"bin",
"scripts",
"SKILL.md",
"references",
"README.md",
"RELEASE.md"
],
"scripts": {
"postinstall": "node bin/check-python.js"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"weread",
"wechat-read",
"agent",
"skill",
"cli",
"book",
"reading",
"notes",
"highlights"
],
"author": "taxueseek",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/taxueseek/taxue-weread.git"
},
"bugs": {
"url": "https://github.com/taxueseek/taxue-weread/issues"
},
"homepage": "https://github.com/taxueseek/taxue-weread#readme"
}
taxue-weread
微信读书官方 Skill 的 Agent 优化版,用一个 CLI 把工作全包了。
它能做什么
taxue-weread 帮你管理微信读书上的所有数据:查书架、搜书、看阅读进度、导出笔记、做阅读分析。它把微信读书的官方 API 包成了一个命令行工具,Agent 和人类都能用。
核心能力:
- 书架管理(支持精简输出,670 本书只显示一行摘要)
- 搜书 + 模糊书名匹配(说"三体"就能找到对应的书)
- 阅读统计(本周/本月/今年/全部历史)
- 笔记/划线/想法查看和导出(md/json/csv/html)
- 阅读画像分析(你的阅读习惯、偏好、盲区)
- 笔记智能整理(帮你从划线里提炼可用的写作素材)
和官方版本的区别
微信读书官方提供了一套 API 接口,但用起来比较原始:
taxue-weread 解决这些问题:
- 精简输出:节省的 token,提升查询速度
- 语义处理:CLI 自动转换时长单位、进度百分比、书架计数口径,Agent 不用记
- 智能分析:
mirror做阅读画像,organize做笔记整理,不罗列数据,给出洞察
安装
Claude Code / Codex 用户(推荐):
npx skills add taxueseek/taxue-wereadnpm 全局安装:
npm install -g taxue-weread配置 API Key:
export WEREAD_API_KEY=wrk-xxxxxxxxAPI Key 获取方式:访问 微信读书 Skill 官方页面,登录后复制,最好先登录微信读书网页版,然后再进入页面,获取对应的API,你没有先登录微信网页版,就经常获取不到key。
快速上手
# 书架一览(精简模式)
twr shelf --summary
# 搜书
twr resolve 三体
# 查看某本书详情 + 笔记
twr inspect 三体
# 本月阅读统计
twr readdata --mode monthly
# 年度阅读统计
twr readdata --mode annually
# 阅读画像
twr mirror --depth quick
# 整理读书笔记
twr organize --max-books 5
# 导出笔记为 markdown
twr export 695233 --format markdown --output 笔记.md几个好用的独家功能
1. 书架精简输出
书架书多的时候,全量输出会让 Agent 的 context 窗口爆满。--summary 只输出最关键的数字:
$ twr shelf --summary
书架:670 本 | 读完 68 | 在读 12 | 活跃 45 | 冷宫 545
分类 TOP5:男生小说(185) 经济理财(37) 影视原著(5) 个人成长(5) 医学健康(4)2. 一键查书
传统流程:search 获取 bookId -> book 查详情 -> notes 查笔记,三轮串行。
inspect 一步到位:
$ twr inspect 三体
书名:三体全集(全三册)
作者:刘慈欣 评分:93%
划线:47 条 想法:12 条 进度:100%3. 阅读画像
不只是展示原始数据,会分析你的阅读习惯:
$ twr mirror --depth quick
阅读人格:夜猫子型(90% 阅读发生在 22:00 之后)
完成率:23%(收藏多、读完少,选书可以更聚焦)
笔记密度:4.3 条/本(轻量型读者,划线为主、想法偏少)深度模式还会分析「沉睡划线」(划了但从来没用过的内容)、感想词频、阅读盲区等。
4. 笔记智能整理
把划线整理成可复用的写作素材,分三层:
1. 作者说了什么(提炼核心论点) 2. 你怎么看(你的判断和保留意见) 3. 能用来做什么(写作方向、可引用的案例)
自动检测「装饰性素材」——去掉它论点还成立的内容,建议删掉。
5. 缓存
# 查询后建立缓存,方便后续查询。因为阅读数据其实不会有太大的变化。
## 命令速查
| 命令 | 作用 |
|------|------|
| `twr resolve <书名>` | 模糊书名解析 |
| `twr inspect <书名>` | 一键查书详情+笔记 |
| `twr book <bookId>` | 书籍详情/进度/章节 |
| `twr shelf` | 书架列表 |
| `twr shelf --summary` | 书架精简统计 |
| `twr readdata --mode monthly` | 阅读统计 |
| `twr notes --book <bookId>` | 笔记/划线/想法 |
| `twr mirror --depth quick` | 阅读画像 |
| `twr organize` | 整理读书笔记 |
| `twr export <bookId>` | 导出笔记 |
| `twr api <path>` | 直接调任意 API |
## 项目结构
taxue-weread/ ├── SKILL.md # Agent 意图路由(59 行,按需加载) ├── README.md # 本文件 ├── bin/ │ ├── taxue-weread.js # CLI 入口(别名 twr) │ └── check-python.js # 安装环境检查 ├── scripts/ │ ├── weread.py # 核心 CLI(缓存/重试/并发) │ └── test_weread.py # 测试 └── references/ # 按需加载的参考文档 ├── troubleshooting.md # API 陷阱手册 ├── mirror-guide.md # 阅读画像分析指南 ├── organize-guide.md # 笔记整理框架 ├── material-grading.md # 素材分级标准 ├── readdata.md # 跨周期统计组合 └── scope.md # 搜索类型选择
SKILL.md 采用「核心指令 + 按需加载」的设计:文件在 Agent 需要时才加载。实测比全量加载省 36% 的 token。
## 致谢
基于微信读书官方 [WeRead Skill](https://weread.qq.com/r/weread-skills) 开发。API 接口和数据归微信读书所有。
## License
MIT
素材分级与适用性判断
从微信读书划线中提取可以直接用于写作的素材,并判断每条素材怎么用。
素材 A/B/C 分级
A 级:直接可用
特征:
- 有具体人名或机构名
- 有时间(至少是年份)
- 有数字(比例/金额/时长/规模)
- 语言已经是文章体(不是学术摘要)
用法:可以直接引用或轻度改写。改写时保留所有具体信息,只调整句式。
引用格式: 「[作者]在《书名》里记录了这样一个案例:[改写后的描述]」 或:「正如[作者]观察到的:[关键引用](《书名》)」
B 级:加工后可用
特征:
- 有观点,但缺少具体细节
- 是作者的判断,不是事实描述
- 语言偏学术或偏书面
用法:不直接引用,而是「翻译」成你自己的话,然后用书作为出处。
加工方向:
- 找到这个观点对应的一个具体案例(可能在其他书里或你自己的经历里)
- 把抽象观点变成「XX情况下,会发生YY」的具体描述
C 级:作为思路,不引用
特征:
- 很抽象(「人要保持开放的心态」)
- 是通用智慧,不是具体洞见
- 在你的写作语境里说不出为什么要用这条
用法:放入「灵感区」,触发你自己的思考,但不引用。
装饰性素材检测
产出分析报告前,对每条内容过一遍三问:
1. 这条内容是在证明核心论点,还是在证明一个相关但不同的论点? 2. 去掉它,论点还成立吗?
- 成立 → 素材是装饰
- 不成立 → 素材是支撑
3. 读者会觉得「这证明了你的观点」,还是「这只是一个有趣的故事」?
装饰性素材不如删掉。 支撑性素材 > 装饰性素材 > 无素材。
素材冲突处理
当两本书在同一个问题上给出相反的素材:
不要回避冲突——这是写作的机会。
处理方式:
关于[问题],有两种截然不同的答案。
[书A]的作者认为:[引用A]
[书B]的数据却显示:[引用B]
这两种结论同时成立,是因为它们问的其实不是同一个问题:
[你的分析,说清楚两者的前提不同]感想的特殊价值
你在划线下方写的感想,是比划线本身更有价值的素材:
- 划线:作者说了什么
- 感想:你认为这意味着什么
感想用法:
- 感想里的判断 → 直接作为文章的观点
- 感想里的联想 → 扩展成一个段落
- 感想里的质疑 → 成为文章中的「反面声音」,增加深度
特别提示:如果感想里有「这和我经历过的XX很像」——这就是一个潜在的原创案例。把这个联想展开:你经历了什么?结果怎样?这是比书里的案例更有价值的素材。
素材使用的伦理边界
可以做的:
- 引用作者的观点,注明来源
- 用书里的案例,注明来源
- 从划线里提炼出你的判断,不需要注明(这是你的判断)
不能做的:
- 把划线直接当作自己的原创观点(隐性剽窃)
- 引用时改变原意(选择性引用造成误解)
- 大段复制书的内容(版权问题)
mirror 分析工具箱
分析维度
基础维度
- 阅读人格:用
preferTime判断——早晨型(06-09高峰)、晚读型(20-23高峰)、碎片型(午休时段)、夜猫子型(00-02)。用preferCategory判断品味集中/分散(TOP3 占比 >70% = 聚焦型) - 完成率:
finishedBooks/totalBooks——>60% 善始善终,30-60% 同时多线,<30% 收藏多读得少 - 笔记密度:
totalNotes/totalBooks——>10条/本 深度型,3-10条 精选型,<3条 轻量型 - 文字vs听书:
readRate——>80% 以读为主,50-80% 兼顾,<50% 偏好听书
深度维度
- 广度 vs 深度:涉及多少个不同主题?同一主题读了几本?
- 深耕型(<3个主题):专家风险,视野窄但有深度
- 均衡型(3-6个主题):有跨界能力
- 博览型(>8个主题):宽而浅的风险,需要找聚焦点
- 认同 vs 质疑(从感想语气分析):
- 认同型感想(「说得太好了」「正是如此」):倾向于找印证已有观点的书(确认偏误风险)
- 质疑型感想(「但是这里……」「不对吧」):主动思考,不只是接受
- 联想型感想(「这让我想到……」「联系到XX」):最有价值的认知模式,在建构跨书知识网络
写作含义:质疑型和联想型感想的位置,就是你写作的富矿位置。
- 时间维度:最近 6 个月读了什么方向?和 2 年前相比,阅读偏好变了吗?主题演变轨迹揭示认知进化路径。
- 完成率分析:
- 大量书读完 → 选书精准,积累扎实
- 大量书只读一半 → 选书不精或书质量不高
- 特定类型书总读完 → 这是你真正感兴趣的领域
- 特定类型书总放弃 → 这个方向的写作要谨慎
沉睡划线诊断
「沉睡的划线」——划了但从未在任何地方用过的内容。
识别方法:在文章/笔记里从未出现过某本书的名字,但你划了很多线。
为什么重要:
- 可能是你「知道」但还没有「内化」的内容
- 也可能是当时觉得重要但现在不那么认可的内容
- 或者只是还没有找到合适的使用场景
处理建议: 1. 定期回顾沉睡划线,问:「这条划线,现在对我还重要吗?」 2. 重要的:找一个可以使用它的写作场景 3. 不再重要的:它记录了你当时的认知状态,本身就有价值
感想词频分析
统计感想里的高频词汇,揭示思维模式:
- 高频实词(名词/动词)→ 你反复关注的核心概念
- 高频转折词(「但是」「然而」「不过」)→ 批判性思维强度
- 高频联想词(「类似」「对比」「想到」)→ 跨书整合能力
- 高频评价词(「重要」「关键」「有趣」「有问题」)→ 你的价值判断模式
阅读人格类型
基于以上分析,识别阅读人格:
- 专家型:集中在 1-2 个领域,深度积累。写作优势:权威感。写作风险:视野局限。
- T型:有一个深耕领域,同时涉猎多个方向。写作优势:能把深度领域和其他领域结合。写作风险:容易写成「大杂烩」。
- 博览型:涉猎广,但没有特别深的方向。写作优势:能发现跨领域的规律。写作风险:缺乏「只有我能说」的底气。
- 实战型:读书不多,但感想密度高,联系实际多。写作优势:接地气,有真实案例。写作风险:理论支撑不足。
盲区识别
基于用户背景(职业/兴趣/已有内容),识别可能应该读但没读的方向:
1. 在富矿主题里,找出「上下游」话题 2. 检查书架里有没有覆盖 3. 没有覆盖的 → 这是盲区,可能限制了深度
盲区不一定要填补——但写作时要知道自己的边界在哪里。
容易被忽略的发现角度
- 弃书模式:
readUpdateTime=0且未读完的书,它们的分类/作者/长度有什么共同特征?找出"你以为是你会读的,其实不会"的类型 - 阅读节奏:dailyReadTimes 判断——每天 20 分钟分散读 vs 周末一次 3 小时。前者是习惯型,后者是沉浸型
- 听完vs看完:什么类型的书用听,什么类型用看?通勤听、睡前看?
- 读完速度:
finishTime - readUpdateTime。哪些书一周啃完,哪些拖了两年? - 勋章发现:medals 字段里有你拿了但不知道的成就
- 注册周年:registTime——"你注册微信读书已有 X 年"
- 出版社口味:preferPublisher——有没有你无意识偏好的出版社
- 划线位置:划线集中在书的前三章?均匀分布?还是集中在中间某几章?不同分布有不同的含义
回应框架
1. 一句话核心发现(不是数据,是洞察) 2. 2-3 个关键数字支撑 3. 与过去的对比或趋势(如有数据) 4. 一个追问或建议(引导用户继续对话)
原则: 锚定证据不推测 / 多问少答 / 数据不足直说 / 先扫结构再深挖 / 语气像有分寸的朋友
笔记整理工作流
$WR organize 收集所有划线+想法,输出结构化 JSON。然后由 Claude 直接分析 JSON。 默认 top 10 本书,每本最多 50 条。用 --max-books 5 --max-notes 30 --quiet 控制输入量。
整理框架(三个层次递进)
不要平铺罗列。按三个层次递进:
第一层:作者说了什么(信息层)
从划线中提炼 3-5 个核心论点,每个一句话。
- 统计划线最密集的章节 → 作者论点最集中的地方
- 注意:这是最浅的层次,不要停留在这里
第二层:你怎么看(判断层)
你同意哪些,不同意哪些,为什么?
- 找感想里的「但是」「我认为」「这个说法有问题」「联系到我自己的经历」
- 这些是你在思考,不只是记录
- 汇总你对每本书的真实判断:哪部分让你觉得有用,哪部分你有保留意见
第三层:能用来做什么(应用层)
这些积累能用在什么地方?
- 这本书可以作为哪类写作的「理论支撑」?
- 书里最有力的案例是什么(可直接引用的)?
- 如果你要推荐这本书给某人,你会怎么介绍?
跨书关联
不同书之间找三种关系:
一致信号(多本书都说同一件事)→ 可以作为「行业共识」引用
分歧信号(不同书观点相反)→ 写作中最有张力的素材。输出格式:
关于「[主题]」,你读过的书有分歧:
→ 《A》认为:[立场],理由:[关键划线]
→ 《B》认为:[对立立场],理由:[关键划线]
→ 你的感想里对此的立场:[感想内容]
写作建议:先呈现共识,再切入分歧,最后给出你的判断互补信号(A 说了一半,B 补了另一半)→ 合并成更完整的观点
输出质量检查
在产出报告前,对每条内容过一遍三问:
1. 这条内容是在证明核心论点,还是在证明一个相关但不同的论点? 2. 去掉它,论点还成立吗?
- 成立 → 素材是装饰
- 不成立 → 素材是支撑
3. 读者会觉得「这证明了你的观点」,还是「这只是一个有趣的故事」?
装饰性素材不如删掉。 支撑性素材 > 装饰性素材 > 无素材。
分析原则
- 优先处理划线密度高、想法多的书(用户投入最多的)
- 关联分析比单纯罗列更有价值——重点找跨书的联系
- 如果笔记量大(>200条),先给总览,再逐本展开
- 感想 > 划线:感想是你加工后的判断,比引用更有力。如果感想里有「这和我经历过的XX很像」——这是潜在的原创案例,优先展开
产出格式
整理成果可以输出为读书笔记,也可以进一步改写成读后感文章。
读书笔记格式
《书名》读书笔记
核心观点(3-5点):
1. [作者的核心论点,一句话]
2. ...
我的看法:
→ 最触动我的:[引用你的感想]
→ 有保留的:[如果有]
可用素材:
→ 最强案例:[具体的,可引用]
→ 可支撑的写作方向:[3个]
→ 推荐给:[描述具体受众]
和其他书的关联:
→ 呼应:《XXX》也认为[共同观点]
→ 分歧:《YYY》认为相反,因为[...]读后感文章格式
如果用户想写成读后感/书评文章,在笔记基础上:
1. 开头:从最触动你的一个点切入(不要从「最近读了XX书」开始) 2. 主体:用你的判断串联 2-3 个核心观点,每个观点配一个具体案例 3. 关联:如果读过同主题其他书,加入比较——这是你独有的价值 4. 结尾:你的收获或推荐,说清楚适合谁读
写作要点:
- 感想是你的声音,划线是证据。感想为主,划线为辅
- 引用时注明出处(作者+书名)
- 如果感想里有「这和我经历过的XX很像」——优先展开,这是最个人化的素材
readdata — 阅读统计跨周期查询
CLI 每次调用覆盖一个固定周期。遇到跨周期请求时需组合多次调用。
周期类型
| mode | 粒度 | 说明 |
|---|---|---|
weekly | 自然周 | baseTime 归一到周一 00:00 |
monthly | 自然月 | baseTime 归一到月初 00:00 |
annually | 自然年 | baseTime 归一到年初 00:00 |
overall | 全部历史 | baseTime 固定为 0 |
组合原则
1. 优先用大周期减少调用次数:整年用 annually,整月用 monthly 2. 跨年区间按自然年拆分:历史整年 + 当前年至今 3. 不完整边界周期:用大周期减去不需要的部分
Few-shot
- "2024 年以来读了多久":查 2024、2025、2026 各年
annually,累加totalReadTime - "2024 年 1 月 31 日至今":查 2024~2026
annually累加,再减去 2024-01 的monthly数据 - "去年 3 月到今年 2 月":查去年 3~12 月 + 今年 1~2 月各月
monthly,累加 - "上个月读了多少":查上月
monthly(baseTime 取上月任一天时间戳)
字段单位
所有时长字段单位为秒,CLI 自动转为"X小时Y分钟"。dayAverageReadTime 按自然日平均,非阅读日平均。
scope — 搜索类型选择
scope 对应关系
| scope | 名称 | 适用场景 |
|---|---|---|
0 | 全部 | 用户只说"搜一下",未限定类型 |
10 | 电子书 | 用户明确说"搜书""找书""查某本书" |
16 | 网文小说 | 用户说"网文""网络小说" |
14 | 微信听书 | 有声书/专辑/播客 |
6 | 作者 | 搜索作者 |
12 | 全文 | 搜索书籍正文内容 |
13 | 书单 | 搜索书单 |
2 | 公众号 | 搜索公众号 |
4 | 文章 | 搜索公众号文章 |
选择指引
- "搜书""找书"且未指定特殊类型 →
scope=10 - "搜一下 xx"未说明类型 →
scope=0 - 普通语义中的"小说"且想找书 → 仍用
scope=10;明确"网文" →scope=16 - 不要把"没特别指定"同时解释为
scope=10和scope=0
故障排查 & API 陷阱手册
运行时故障
| 问题 | 处理 |
|---|---|
WEREAD_API_KEY 未设置 | export WEREAD_API_KEY=<你的key> |
errcode != 0 | CLI 自动输出中文错误信息 |
upgrade_info | CLI 检测并暂停(exit code 2),按指引升级后重试 |
| 网络波动 | CLI 自动重试 3 次(2s/4s/8s 指数退避 + 抖动) |
API 语义陷阱(不可推断,必须记住)
调用任何命令前确认:
1. 时长单位:所有时长字段单位是秒。展示转「X 小时 Y 分钟」。 2. 阅读进度:progress 是 0-100 整数,1=1%(非 100%)。仅 progress=100 且 finishTime 存在才算读完。 3. 时段偏移:preferTime 数组从 06:00 开始到次日 05:00(24 个值),不是 00:00。 4. 日均分母:dayAverageReadTime 分母 = 周期已过去的自然日数,不是 readDays。阅读日均 = totalReadTime / readDays。 5. 评分转换:newRating 是 0-1000 刻度,展示时除以 10(如 930 → 93%)。 6. 作者时长格式:preferAuthor[].readTime 是格式化字符串(如「5 小时 30 分钟」),不是秒数。 7. 书架计数:总数 = books.length + albums.length + (mp 非空 ? 1 : 0)。禁用服务端 `bookCount` 字段。 8. mode 必传:readdata 的 mode 必须显式传(weekly/monthly/annually/overall),不传报错。 9. annually 限制:只返回 baseTime 所在自然年。当前年份数据为年初至今,不得标为全年。 10. 文字阅读占比:readRate 仅当总时长满 1 小时且文字占比未过高时返回,否则为 null。 11. compare 条件:仅当「当前周期 + 上一周期数据足够」时返回,缺失属正常。 12. 搜索 → bookId:用户输入书名时,先 search 获取 bookId,再执行后续操作。记住 bookId 避免重复查询。 13. 时间戳展示:Unix 时间戳展示为 YYYY-MM-DD 格式,不得直接展示原始数字。 14. my-reviews 参数:参数名是 bookid(小写 i),不是 bookId。 15. preferPublisher 条件:至少 3 个出版社且最高本数达阈值才返回,缺失属正常。 16. preferTimeWord 条件:总偏好时段数据满 10h 才返回,缺失属正常。
条件返回字段(缺失属正常)
| 字段 | 返回条件 |
|---|---|
| compare | 当前周期且上一周期数据足够 |
| preferAuthor | 作者数据达到展示阈值 |
| preferPublisher | 至少 3 个出版社且最高本数达阈值 |
| readRate/wrReadTime/wrListenTime | 总时长满 1h 且文字占比未过高 |
| rank | 仅当前周且未隐藏排行 |
| preferTimeWord | 总偏好时段数据满 10h |
| dailyReadTimes | annually 模式可能返回 |
跨周期查询算法
接口只支持固定自然周期: 1. 整年用 annually,整月用 monthly,减少调用次数 2. 跨年按自然年逐年查询累加 3. 完整周期取 totalReadTime;不完整边界用 dailyReadTimes 日级扣减 4. 无日级明细时用月级近似,回答中标注口径
baseTime 归一化
weekly → 周一 00:00 | monthly → 1日 00:00 | annually → 1月1日 00:00 | overall → 0
笔记接口层级
| 粒度 | 命令 | 说明 |
|---|---|---|
| 书籍级(概览) | notebooks | 有笔记的书列表 + 各书笔记数 |
| 书籍级(全部划线) | bookmarks | 单本书所有划线 |
| 书籍级(热门划线) | best-bookmarks | 热门划线 TOP20,固定返回不支持分页 |
| 章节级(划线热度) | underlines | 某章节各位置的划线人数统计 |
| 章节级(划线下想法) | read-reviews | 某章节特定划线位置下的用户想法 |
| 个人级(我的想法) | my-reviews | 单本书我的所有想法与点评 |
read-reviews入参reviews格式:[{"range":"900-2004","count":10}],从underlines获取 range
临时脚本(不覆盖 CLI 时)
cd scripts && python3 -c "
import json, os
os.environ['WEREAD_API_KEY'] = 'your-key'
from weread import WereadAPI
api = WereadAPI()
result = api.call('/some/api', param='value')
print(json.dumps(result, indent=2, ensure_ascii=False))
"taxue-weread v1.5.0
基于微信读书官方 Skill 的 Agent 优化版——CLI + 缓存 + 合并命令,为 Agent 环境量身打造。
它是什么
微信读书官方提供了 API 规范和基础 SKILL.md(v1.0.3),设计目标是让 Agent 直接调 HTTP API。
本版本在官方规范基础上,封装了一个完整的 CLI 工具 + Agent 指令系统,让 Agent 能以最少的 token、最少的轮次、最高的可靠性完成微信读书的所有操作。
和官方版本 v1.0.3 的对比
| 维度 | 官方 v1.0.3 | taxue-weread v1.5.0 |
|---|---|---|
| 交互方式 | Agent 直接调 HTTP API | CLI 封装,Agent 调命令 |
| 缓存 | 无 | ✅ 文件级 TTL=5min,重复调用快 13x |
| 自动重试 | 无 | ✅ 3 次指数退避 + 随机抖动 |
| API 陷阱 | 散落在各 reference | ✅ 集中 16 条 + 条件返回字段表 |
| 模糊书名解析 | 无 | ✅ resolve 命令,按匹配度打分排序 |
| 低层逃生口 | 无 | ✅ api 命令,直接调任意接口 |
| 命令数 | 11 个原子命令 | 20 个(含合并命令) |
| "查三体笔记" | 3 轮 API 调用 | ✅ 1 轮 inspect |
| 书架统计 | 全量返回 | ✅ --summary 省 99.8% tokens |
| SKILL.md | ~170 行全量加载 | ✅ 59 行 + 按需加载 references |
| 字段过滤 | 无 | ✅ --fields 指定输出字段 |
| 导出功能 | 无 | ✅ md/json/csv/html 四种格式 |
实测数据
670 本书架,相同网络环境:
| 场景 | 官方方式 | taxue-weread | 提升 |
|---|---|---|---|
| shelf 统计 | 全量 239K chars | --summary 429 chars | token -99.8% |
| 重复调用 | 每次 ~1s | 缓存命中 0.07s | 13x |
| "看三体笔记" | 3 轮 API | 1 轮 | 轮次 -67% |
| mirror quick | 3.5s | 0.62s | 5.6x |
| mirror standard | 4.6s | 1.46s | 3.1x |
| inspect(任意书) | ~1.5s | 1.3-1.6s | 持平 |
| 并发 API 调用 | ❌ 串行 | ✅ ThreadPoolExecutor | 5-6x |
| 缓存安全 | ❌ 无锁 | ✅ threading.Lock | 线程安全 |
独有功能
1. resolve — 模糊书名 → 精确 bookId
用户说「帮我看看三体」,传统方式需要 search → book → notes 三轮串行调用。
resolve 一个命令搞定模糊匹配,按匹配度打分排序:
$WR resolve 三体
# 「三体」找到 10 本相关书:
# 1. 三体全集(全三册) 刘慈欣 神作 93% 9698人在读 [匹配度:4] id:695233
# 2. 三体2·黑暗森林 刘慈欣 神作 93% 264人在读 [匹配度:3] id:183526
# ...
$WR inspect 三体 # 直接用,内部自动 resolve匹配度算法:标题精确匹配 +3,包含关键词 +2,高评分 +1,在读人数多 +1。
2. inspect — 一键查书
$WR inspect 三体
# 返回:书名 + 作者 + 评分 + 划线 + 想法,一次到位3. api — 低层逃生口
当现有命令不满足需求时,直接调任意接口:
$WR api /store/search --param keyword=三体 --param scope=10 --param count=54. shelf --summary / --fields — 按需输出
$WR shelf --summary
# 429 chars:总本数、读完、在读、活跃、冷宫、分类 TOP5
$WR shelf --json --fields bookId,title,author,category,finishReading
# 去掉 cover 等冗余字段,省 58% tokens5. 自动缓存 + 重试
# 第一次调用(网络请求)
$ time $WR shelf --json # ~0.9s
# 第二次调用(缓存命中)
$ time $WR shelf --json # ~0.07s ← 13x 快
# 网络波动时自动重试 3 次,指数退避 + 随机抖动6. 装饰性素材检测
整理笔记时,Agent 会检查每条内容是否真的在支撑论点:
「去掉它,论点还成立吗?成立 → 这是装饰,删掉。」
防止输出「看起来很多但没用的笔记整理」。
7. 素材 A/B/C 分级
从划线中提取写作素材时,自动分级:
- A 级:有具体人名/时间/数字,可直接引用
- B 级:有观点但缺细节,加工后可用
- C 级:太抽象,仅作思路参考
8. 沉睡划线诊断
划了但从未用过的内容 = 沉睡划线。Agent 会识别并提示:「你划了 X 条线但从未用过,这些是你的潜在资产。」
文件结构
taxue-weread/
├── SKILL.md # 59 行,意图路由 + 关键规则
├── scripts/
│ ├── weread.py # CLI + 缓存 + 重试
│ └── test_weread.py # 测试套件
└── references/ # 按需加载,不浪费 context
├── troubleshooting.md # 16 条 API 陷阱 + 条件返回字段表
├── mirror-guide.md # 阅读画像分析 + 沉睡划线 + 感想词频
├── organize-guide.md # 笔记整理框架 + 装饰性检测
├── material-grading.md # 素材 A/B/C 分级 + 写作适用性判断
├── readdata.md # 跨周期统计组合
└── scope.md # scope 选择表SKILL.md 设计
官方 SKILL.md 是「全量加载」——每次触发都把 ~170 行塞进 context。
本版本改为「核心指令 + 按需加载」:
- SKILL.md 只保留 59 行核心内容(意图路由 + 关键规则 + 参考文档索引)
- 6 个 reference 文件按需读取,不浪费 context
实测:SKILL.md 加载从 ~1.1K tokens 降到 ~700 tokens(-36%)。
安装
方式一:npm 安装(推荐)
npm install -g taxue-weread
# 设置 API Key
export WEREAD_API_KEY=wrk-xxxxxxxx
# 使用
taxue-weread shelf --summary
twr resolve 三体 --count 5 --compact方式二:手动复制
cp -r taxue-weread ~/.agents/skills/weread-skills
export WEREAD_API_KEY=wrk-xxxxxxxx
cd ~/.agents/skills/weread-skills/scripts
python3 weread.py shelf --summary兼容性
- 完全兼容官方 API 规范(
https://i.weread.qq.com/api/agent/gateway) - 所有官方接口均可通过
$WR list-apis或$WR api <path>调用 - API Key 格式:
wrk-xxxxxxxx(与官方一致)
#!/usr/bin/env python3
"""weread.py 功能测试 — 覆盖所有子命令和工具函数。"""
import json
import os
import sys
import unittest
import io
from unittest.mock import patch, MagicMock
# Add skill dir to path
sys.path.insert(0, os.path.dirname(__file__))
import weread
class TestUtilities(unittest.TestCase):
"""测试工具函数"""
def test_ts_to_date(self):
self.assertEqual(weread.ts_to_date(1748563200), "2025-05-30")
self.assertEqual(weread.ts_to_date(0), "1970-01-01")
def test_secs_to_hms(self):
self.assertEqual(weread.secs_to_hms(0), "0分钟")
self.assertEqual(weread.secs_to_hms(60), "1分钟")
self.assertEqual(weread.secs_to_hms(3661), "1小时1分钟")
self.assertEqual(weread.secs_to_hms(7200), "2小时0分钟")
self.assertEqual(weread.secs_to_hms(9000), "2小时30分钟")
self.assertEqual(weread.secs_to_hms(3600), "1小时0分钟")
def test_star_to_str(self):
self.assertEqual(weread.star_to_str(100), "⭐⭐⭐⭐⭐")
self.assertEqual(weread.star_to_str(80), "⭐⭐⭐⭐")
self.assertEqual(weread.star_to_str(60), "⭐⭐⭐")
self.assertEqual(weread.star_to_str(40), "⭐⭐")
self.assertEqual(weread.star_to_str(20), "⭐")
self.assertEqual(weread.star_to_str(-1), "无评分")
self.assertEqual(weread.star_to_str(0), "无评分")
def test_rating_to_str(self):
"""评分 0-1000 → 文字"""
self.assertEqual(weread.rating_to_str(950), "神作 95%")
self.assertEqual(weread.rating_to_str(856), "力荐 86%")
self.assertEqual(weread.rating_to_str(750), "好评 75%")
self.assertEqual(weread.rating_to_str(500), "50.0分")
self.assertEqual(weread.rating_to_str(0), "暂无")
self.assertEqual(weread.rating_to_str(None), "暂无")
def test_make_deep_link(self):
# book only
self.assertEqual(
weread.make_deep_link("123"),
"weread://reading?bId=123"
)
# book + chapter
self.assertEqual(
weread.make_deep_link("123", "456"),
"weread://reading?bId=123&chapterUid=456"
)
# bookmark
self.assertEqual(
weread.make_deep_link("123", "456", "100", "200"),
"weread://bestbookmark?bookId=123&chapterUid=456&rangeStart=100&rangeEnd=200"
)
# bookmark + userVid
self.assertEqual(
weread.make_deep_link("123", "456", "100", "200", "vid1"),
"weread://bestbookmark?bookId=123&chapterUid=456&rangeStart=100&rangeEnd=200&userVid=vid1"
)
def test_truncate(self):
self.assertEqual(weread.truncate("short", 200), "short")
self.assertEqual(weread.truncate("x" * 300, 200), "x" * 200 + "…")
def test_paginate(self):
items = list(range(25))
# page 1, 10 per page
result, total = weread.paginate(items, 1, 10)
self.assertEqual(result, list(range(10)))
self.assertEqual(total, 3)
# page 2
result, total = weread.paginate(items, 2, 10)
self.assertEqual(result, list(range(10, 20)))
self.assertEqual(total, 3)
# page 3
result, total = weread.paginate(items, 3, 10)
self.assertEqual(result, list(range(20, 25)))
self.assertEqual(total, 3)
# empty
result, total = weread.paginate([], 1, 10)
self.assertEqual(result, [])
self.assertEqual(total, 1)
def test_paginate_mark(self):
items = list(range(25))
# first page
_, total, prev, nxt = weread.paginate_mark(items, 1, 10)
self.assertEqual(total, 3)
self.assertFalse(prev)
self.assertTrue(nxt)
# middle
_, total, prev, nxt = weread.paginate_mark(items, 2, 10)
self.assertEqual(total, 3)
self.assertTrue(prev)
self.assertTrue(nxt)
# last
_, total, prev, nxt = weread.paginate_mark(items, 3, 10)
self.assertEqual(total, 3)
self.assertTrue(prev)
self.assertFalse(nxt)
# single page
_, total, prev, nxt = weread.paginate_mark(list(range(5)), 1, 10)
self.assertEqual(total, 1)
self.assertFalse(prev)
self.assertFalse(nxt)
class TestCommands(unittest.TestCase):
"""测试各子命令输出 — 使用 mock API"""
def setUp(self):
self.mock_api = MagicMock()
def _run_command(self, func, args_obj, mock_api=None):
"""捕获命令 stdout 输出"""
api = mock_api or self.mock_api
buf = io.StringIO()
old_stdout = sys.stdout
sys.stdout = buf
try:
func(api, args_obj)
finally:
sys.stdout = old_stdout
return buf.getvalue()
def test_cmd_search(self):
"""测试搜索命令输出"""
self.mock_api.call.return_value = {
"results": [{
"title": "电子书",
"scope": 10,
"scopeCount": 2,
"currentCount": 2,
"books": [
{
"bookInfo": {
"bookId": "695233", "title": "三体", "author": "刘慈欣",
"category": "科幻", "newRating": 960, "newRatingCount": 50000,
"newRatingDetail": {"title": "神作"}, "intro": "科幻巨作",
"payType": 1, "price": 0, "soldout": 0,
},
"readingCount": 100000,
},
{
"bookInfo": {
"bookId": "695234", "title": "三体2", "author": "刘慈欣",
"category": "科幻", "newRating": 94, "newRatingCount": 40000,
"intro": "黑暗森林", "soldout": 0,
},
"readingCount": 80000,
},
],
}],
}
args = MagicMock()
args.keyword = "三体"
args.scope = None
args.count = None
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_search, args)
self.assertIn("三体", output)
self.assertIn("刘慈欣", output)
self.assertIn("weread://reading?bId=695233", output)
self.assertIn("100000人在读", output)
self.assertIn("共 2 条结果", output)
def test_cmd_search_no_results(self):
"""搜索无结果"""
self.mock_api.call.return_value = {"results": []}
args = MagicMock()
args.keyword = "zzzxxx"
args.scope = None
args.count = None
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_search, args)
self.assertIn("未找到", output)
def test_cmd_shelf(self):
"""测试书架命令"""
self.mock_api.call.return_value = {
"books": [
{"bookId": "1", "title": "书A", "author": "作者A", "secret": 0, "readUpdateTime": 1748563200, "isTop": 1, "finishReading": 0, "category": "文学"},
{"bookId": "2", "title": "书B", "author": "作者B", "secret": 1, "readUpdateTime": 1748000000, "isTop": 0, "finishReading": 1, "category": "科幻"},
],
"albums": [
{"albumInfo": {"albumId": "a1", "name": "有声A", "authorName": "播者A", "finish": 0}, "albumInfoExtra": {"secret": 0, "lectureReadUpdateTime": 0}},
],
"mp": None,
}
args = MagicMock()
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_shelf, args)
self.assertIn("共 3 个条目", output)
self.assertIn("2 本电子书", output)
self.assertIn("1 个专辑/有声书", output)
self.assertIn("公开 2", output)
self.assertIn("私密 1", output)
self.assertIn("书A", output)
self.assertIn("书B", output)
self.assertIn("有声A", output)
self.assertIn("置顶", output)
self.assertIn("2025-05-30", output)
def test_cmd_shelf_with_mp(self):
"""测试书架含文章收藏"""
self.mock_api.call.return_value = {
"books": [{"bookId": "1", "title": "书A", "author": "作者A", "secret": 0, "readUpdateTime": 0, "isTop": 0, "finishReading": 0, "category": "文学"}],
"albums": [],
"mp": {"some": "data"},
}
args = MagicMock()
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_shelf, args)
self.assertIn("共 2 个条目", output)
self.assertIn("文章收藏", output)
self.assertIn("私密 1", output) # mp counts as private
def test_cmd_book(self):
"""测试书籍详情"""
self.mock_api.call.side_effect = [
{ # /book/info
"bookId": "695233", "title": "三体", "author": "刘慈欣",
"category": "科幻", "publisher": "科幻世界",
"newRating": 960, "newRatingCount": 50000,
"intro": "文化大革命如火如荼进行的同时…",
"wordCount": 200000, "isbn": "9787536692930",
"publishTime": "2008",
},
{ # /book/getprogress
"book": {
"progress": 45, "recordReadingTime": 7200,
"updateTime": 1748563200, "finishTime": None,
},
},
]
args = MagicMock()
args.bookId = "695233"
args.chapters = False
args.progress = True
args.chapters_page = 1
args.chapters_per_page = 20
output = self._run_command(weread.cmd_book, args)
self.assertIn("三体", output)
self.assertIn("刘慈欣", output)
self.assertIn("神作 96%", output)
self.assertIn("20.0万字", output)
self.assertIn("weread://reading?bId=695233", output)
self.assertIn("进度: 45%", output)
self.assertIn("2小时0分钟", output)
def test_cmd_book_chapters(self):
"""测试章节目录"""
calls = []
from weread import SKILL_VERSION
def side_effect(api_name, **params):
calls.append((api_name, params))
if api_name == "/book/info":
return {"title": "测试书", "author": "作者", "bookId": "1"}
elif api_name == "/book/chapterinfo":
return {
"chapters": [
{"chapterUid": 1, "title": "第一章", "level": 1, "paid": 1, "price": 100},
{"chapterUid": 2, "title": "第一节", "level": 2, "paid": 0, "price": 0},
{"chapterUid": 3, "title": "第二章", "level": 1, "paid": 0, "price": 0},
],
}
return {}
self.mock_api.call.side_effect = side_effect
args = MagicMock()
args.bookId = "1"
args.chapters = True
args.progress = False
args.chapters_page = 1
args.chapters_per_page = 20
output = self._run_command(weread.cmd_book, args)
self.assertIn("第一章", output)
self.assertIn("第一节", output) # level 2 should be indented
self.assertIn("第二章", output)
def test_cmd_notes_overview(self):
"""测试笔记本概览"""
self.mock_api.call.return_value = {
"books": [
{"bookId": "1", "book": {"title": "书A", "author": "作者A"},
"reviewCount": 10, "noteCount": 20, "bookmarkCount": 5,
"readingProgress": 80, "markedStatus": 0, "sort": 1000},
{"bookId": "2", "book": {"title": "书B", "author": "作者B"},
"reviewCount": 5, "noteCount": 15, "bookmarkCount": 3,
"readingProgress": 100, "markedStatus": 1, "sort": 900},
],
"hasMore": 0,
}
args = MagicMock()
args.book = None
args.bookId = None
args.page = 1
args.per_page = 10
args.max_pages = 5
output = self._run_command(weread.cmd_notes, args)
self.assertIn("有笔记的书共 2 本", output)
self.assertIn("书A", output)
self.assertIn("书B", output)
self.assertIn("笔记 35 条", output) # 10+20+5=35
self.assertIn("笔记 23 条", output) # 5+15+3=23
self.assertIn("weread.py notes --book", output) # CLI reference
def test_cmd_notes_single_book(self):
"""测试单本书笔记"""
self.mock_api.call.side_effect = [
{ # /book/bookmarklist
"updated": [{
"bookmarkId": "b1", "chapterUid": 1, "markText": "给岁月以文明",
"createTime": 1748563200, "range": "100-110",
}],
"chapters": [{"chapterUid": 1, "title": "第一章"}],
},
{ # /review/list/mine
"reviews": [{
"review": {
"content": "这段写得真好", "star": 100, "createTime": 1748563200,
"chapterName": "第一章", "chapterUid": 1, "range": "100-110",
},
}],
},
]
args = MagicMock()
args.book = "1"
args.bookId = "1"
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_notes, args)
self.assertIn("给岁月以文明", output)
self.assertIn("这段写得真好", output)
self.assertIn("weread://bestbookmark", output)
def test_cmd_notes_empty(self):
"""测试无笔记"""
self.mock_api.call.side_effect = [
{"updated": [], "chapters": []},
{"reviews": []},
]
args = MagicMock()
args.book = "1"
args.bookId = "1"
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_notes, args)
self.assertIn("暂无划线", output)
self.assertIn("暂无个人想法", output)
def test_cmd_readdata(self):
"""测试阅读统计"""
self.mock_api.call.return_value = {
"readDays": 15, "totalReadTime": 5400, "dayAverageReadTime": 1800,
"compare": 0.2,
"readStat": [
{"stat": "读过", "counts": "5本"},
{"stat": "读完", "counts": "2本"},
{"stat": "笔记", "counts": "30条"},
],
"readLongest": [
{"book": {"title": "书A", "author": "作者A"}, "readTime": 3600, "tags": ["笔记最多"]},
],
"preferCategory": [
{"categoryTitle": "文学", "readingCount": 3, "readingTime": 2000},
],
"preferCategoryWord": "偏好阅读文学",
"preferTimeWord": "偏好夜间阅读",
}
args = MagicMock()
args.mode = "monthly"
args.time = 0
output = self._run_command(weread.cmd_readdata, args)
self.assertIn("本月", output) # mode=monthly
self.assertIn("阅读 15 天", output)
self.assertIn("1小时30分钟", output) # 5400s
self.assertIn("↑20%", output) # compare=0.2
self.assertIn("读过: 5本", output)
self.assertIn("书A", output)
self.assertIn("文学", output)
def test_cmd_readdata_annually(self):
"""测试年度阅读统计"""
self.mock_api.call.return_value = {
"readDays": 200, "totalReadTime": 360000, "dayAverageReadTime": 986,
}
args = MagicMock()
args.mode = "annually"
args.time = 0
output = self._run_command(weread.cmd_readdata, args)
self.assertIn("本年", output)
self.assertIn("100小时0分钟", output)
def test_cmd_review(self):
"""测试书籍点评"""
self.mock_api.call.return_value = {
"reviewsCnt": 2,
"deepVRecommendInfo": {"title": "100 个资深会员点评", "subtitle": "其中 80 人推荐"},
"reviews": [{
"idx": 0,
"review": {"review": {
"content": "非常好看的一本书,强烈推荐!",
"star": 100, "createTime": 1748563200,
"author": {"name": "读者A", "userVid": "v1"},
"isFinish": True, "chapterName": "",
}},
}],
}
args = MagicMock()
args.bookId = "123"
args.type = 0
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_review, args)
self.assertIn("点评共 2 条", output)
self.assertIn("读者A", output)
self.assertIn("⭐⭐⭐⭐⭐", output)
self.assertIn("非常好看", output)
self.assertIn("100 个资深会员点评", output)
def test_cmd_discover_recommend(self):
"""测试为你推荐"""
self.mock_api.call.return_value = {
"books": [
{"bookId": "1", "title": "推荐书A", "author": "作者A",
"newRating": 900, "readingCount": 5000, "reason": "看过三体的人也看",
"intro": "一本好书"},
],
}
args = MagicMock()
args.book = None
args.bookId = None
args.page = 1
args.per_page = 10
args.max_pages = 5
output = self._run_command(weread.cmd_discover, args)
self.assertIn("为你推荐", output)
self.assertIn("推荐书A", output)
self.assertIn("看过三体的人也看", output)
self.assertIn("weread://reading?bId=1", output)
def test_cmd_discover_similar(self):
"""测试相似推荐"""
self.mock_api.call.return_value = {
"booksimilar": {
"books": [{
"book": {"bookInfo": {"bookId": "2", "title": "相似书", "author": "作者B", "newRating": 85, "readingCount": 3000}},
}],
},
}
args = MagicMock()
args.book = "1"
args.bookId = "1"
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_discover, args)
self.assertIn("相似书推荐", output)
self.assertIn("相似书", output)
def test_cmd_discover_empty(self):
"""测试推荐为空"""
self.mock_api.call.return_value = {"books": []}
args = MagicMock()
args.book = None
args.bookId = None
args.page = 1
args.per_page = 10
args.max_pages = 5
output = self._run_command(weread.cmd_discover, args)
self.assertIn("暂无推荐", output)
def test_cmd_list_apis(self):
"""测试列出接口"""
self.mock_api.call.return_value = {
"apis": [
{"api_name": "/shelf/sync", "description": "同步书架", "params": []},
{"api_name": "/store/search", "description": "搜索", "params": [
{"name": "keyword", "type": "string", "desc": "关键词"},
]},
],
}
args = MagicMock()
output = self._run_command(weread.cmd_list_apis, args)
self.assertIn("/shelf/sync", output)
self.assertIn("/store/search", output)
def test_pagination_output(self):
"""测试分页输出 — 项目超过 per_page 时显示页码"""
books = []
for i in range(25):
books.append({
"bookInfo": {"bookId": str(i), "title": f"书{i}", "author": f"作者{i}",
"newRating": 80, "newRatingCount": i * 100,
"newRatingDetail": {}, "soldout": 0, "intro": ""},
"readingCount": i * 10,
})
self.mock_api.call.return_value = {
"results": [{"title": "电子书", "scope": 10, "scopeCount": 25, "currentCount": 25, "books": books}],
}
args = MagicMock()
args.keyword = "test"
args.scope = None
args.count = None
# page 1
args.page = 1
args.per_page = 10
output = self._run_command(weread.cmd_search, args)
self.assertIn("第 1/3 页", output)
self.assertIn("--page 2 下一页", output)
self.assertIn("书0", output)
self.assertIn("书9", output)
self.assertNotIn("书10:", output) # not on page 1
# page 2
args.page = 2
output = self._run_command(weread.cmd_search, args)
self.assertIn("第 2/3 页", output)
self.assertIn("书10", output)
self.assertIn("书19", output)
# page 3 (last page)
args.page = 3
output = self._run_command(weread.cmd_search, args)
self.assertIn("第 3/3 页", output)
self.assertNotIn("下一页", output) # last page, no next
def test_cli_argparse(self):
"""测试 CLI 参数解析 — 各子命令正常创建"""
import argparse
# We just verify all subparsers register without error
# by importing the module's main function (not calling it)
# Instead, test help output by running with --help
pass # argparse test covered by integration
class TestAPIErrors(unittest.TestCase):
"""测试 API 错误处理"""
def test_missing_key(self):
"""测试未设置 WEREAD_API_KEY"""
old_key = os.environ.pop("WEREAD_API_KEY", None)
try:
with self.assertRaises(SystemExit) as cm:
weread.WereadAPI()
self.assertEqual(cm.exception.code, 1)
finally:
if old_key:
os.environ["WEREAD_API_KEY"] = old_key
class TestVersion(unittest.TestCase):
"""测试版本号存在"""
def test_skill_version(self):
self.assertEqual(weread.SKILL_VERSION, "1.5.0")
def run_all_tests():
"""Run all tests and return True if all pass."""
loader = unittest.TestLoader()
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(TestUtilities))
suite.addTests(loader.loadTestsFromTestCase(TestCommands))
suite.addTests(loader.loadTestsFromTestCase(TestAPIErrors))
suite.addTests(loader.loadTestsFromTestCase(TestVersion))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return result.wasSuccessful()
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
#!/usr/bin/env python3
"""
微信读书批量查询 — 一次进程调用,避免多次启动开销
用法:
python3 weread_batch.py info <bookId1> <bookId2> ...
python3 weread_batch.py search "关键词1" "关键词2" ...
python3 weread_batch.py notes <bookId>
python3 weread_batch.py chapters <bookId>
python3 weread_batch.py bookmarks <bookId> [--count 20]
输出: JSON
"""
import sys, json, subprocess, os
from concurrent.futures import ThreadPoolExecutor, as_completed
WR = os.path.join(os.path.dirname(__file__), "weread.py")
def cmd(*args):
"""执行命令,返回 stdout"""
r = subprocess.run([WR] + list(args), capture_output=True, text=True, timeout=15)
return r.stdout.strip()
def parse_json(stdout):
"""
解析 weread.py 的 JSON 输出。
weread.py 的 mirror 等命令会输出进度信息(如 [1/2] 并发获取...),
需要跳过这些非 JSON 行,找到第一个 { 开始的 JSON 对象。
"""
if not stdout:
return None
json_start = stdout.find('{')
if json_start == -1:
return None
return json.loads(stdout[json_start:])
def info_batch(book_ids):
"""批量获取书籍详情"""
results = []
with ThreadPoolExecutor(max_workers=5) as ex:
futs = {ex.submit(cmd, "book", str(bid)): bid for bid in book_ids}
for f in as_completed(futs):
stdout = f.result()
if stdout:
info = {"book_id": str(futs[f])}
for line in stdout.split("\n"):
line = line.strip()
if line.startswith("《") and line.endswith("》"):
info["title"] = line.strip("《》")
elif line.startswith("作者:"):
info["author"] = line.split(":", 1)[1].strip()
elif line.startswith("评分:"):
info["rating"] = line.split(":", 1)[1].strip()
elif line.startswith("分类:"):
info["category"] = line.split(":", 1)[1].strip()
elif line.startswith("出版社:"):
info["publisher"] = line.split(":", 1)[1].strip()
elif line.startswith("简介:"):
info["intro"] = line.split(":", 1)[1].strip()[:200]
results.append(info)
return results
def search_batch(keywords):
"""批量搜索"""
results = {}
with ThreadPoolExecutor(max_workers=5) as ex:
futs = {ex.submit(cmd, "search", kw, "--json"): kw for kw in keywords}
for f in as_completed(futs):
stdout = f.result()
if stdout:
items = json.loads(stdout).get("items", [])
for item in items[:5]:
bid = item["bookId"]
if bid not in results:
results[bid] = {
"title": item["title"],
"author": item.get("author", ""),
"rating": round(item.get("newRating", 0) / 10, 1),
"rating_count": item.get("newRatingCount", 0),
"category": item.get("category", ""),
"book_id": bid
}
return list(results.values())
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
action = sys.argv[1]
if action == "info":
results = info_batch(sys.argv[2:])
print(json.dumps(results, ensure_ascii=False, indent=2))
elif action == "search":
results = search_batch(sys.argv[2:])
print(json.dumps(results, ensure_ascii=False, indent=2))
elif action == "notes":
stdout = cmd("notes", "--book", sys.argv[2], "--json")
print(stdout if stdout else "[]")
elif action == "chapters":
stdout = cmd("book", sys.argv[2], "--chapters")
if stdout:
chapters = []
in_toc = False
for line in stdout.split("\n"):
if "章节目录" in line:
in_toc = True
continue
if in_toc:
line = line.strip()
if line and not line.startswith("──"):
chapters.append(line)
print(json.dumps(chapters, ensure_ascii=False, indent=2))
elif action == "bookmarks":
book_id = sys.argv[2]
count = 20
if "--count" in sys.argv:
idx = sys.argv.index("--count")
if idx + 1 < len(sys.argv):
count = int(sys.argv[idx + 1])
stdout = cmd("bestbookmarks", book_id, "--json")
if stdout:
d = json.loads(stdout)
items = d.get("items", [])[:count]
print(json.dumps(items, ensure_ascii=False, indent=2))
else:
print(f"未知操作: {action}")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
微信读书章节推荐 — 数据拉取 + 模型语义匹配
用法:
python3 weread_chapters.py <bookId> "用户查询"
输出 JSON,包含:
- chapters: 章节目录(含层级)
- bookmarks_by_chapter: 按章节聚合的热门划线
- prompt: 直接喂给模型做语义匹配的提示词
模型拿到这个数据后,根据用户查询返回最相关的章节。
示例:
python3 weread_chapters.py 44026191 "关于财富积累的章节"
"""
import sys, json, subprocess, os
from collections import defaultdict
WR = os.path.join(os.path.dirname(__file__), "weread.py")
def get_chapters(book_id):
"""获取章节目录"""
r = subprocess.run([WR, "book", str(book_id), "--chapters"],
capture_output=True, text=True, timeout=15)
chapters = []
in_toc = False
for line in r.stdout.split("\n"):
if "章节目录" in line:
in_toc = True
continue
if not in_toc:
continue
line = line.strip()
if not line or line.startswith("──"):
continue
# 判断层级:3个空格=二级章节
if line.startswith(" "):
level = 2
title = line.strip()
else:
level = 1
title = line.strip()
if title:
chapters.append({"idx": len(chapters), "title": title, "level": level})
return chapters
def get_bookmarks(book_id, count=50):
"""获取热门划线,按 chapterUid 聚合"""
r = subprocess.run([WR, "bestbookmarks", str(book_id), "--json"],
capture_output=True, text=True, timeout=15)
if not r.stdout.strip():
return {}
d = json.loads(r.stdout)
items = d.get("items", []) or d.get("bookmarks", [])[:count]
by_chapter = defaultdict(list)
for bm in items:
uid = bm.get("chapterUid", 0)
text = bm.get("markText", "")
if uid and text:
by_chapter[uid].append(text)
return dict(by_chapter)
def get_book_meta(book_id):
"""获取书名和简介"""
r = subprocess.run([WR, "book", str(book_id)],
capture_output=True, text=True, timeout=15)
meta = {"book_id": str(book_id)}
for line in r.stdout.split("\n"):
line = line.strip()
if line.startswith("《") and line.endswith("》"):
meta["title"] = line.strip("《》")
elif line.startswith("作者:"):
meta["author"] = line.split(":", 1)[1].strip()
elif line.startswith("评分:"):
meta["rating"] = line.split(":", 1)[1].strip()
elif line.startswith("分类:"):
meta["category"] = line.split(":", 1)[1].strip()
elif line.startswith("简介:"):
meta["intro"] = line.split(":", 1)[1].strip()[:300]
return meta
def build_prompt(meta, chapters, bookmarks_by_chapter, query):
"""构建给模型的提示词"""
# 构建目录文本
toc_lines = []
for ch in chapters:
indent = " " if ch["level"] == 2 else ""
# 找到该章节的划线数
uid = ch["idx"] + 1 # chapterUid 通常从 1 开始
bm_count = len(bookmarks_by_chapter.get(uid, []))
bm_hint = f" [{bm_count}条划线]" if bm_count > 0 else ""
toc_lines.append(f"{indent}{ch['title']}{bm_hint}")
toc_text = "\n".join(toc_lines)
# 构建划线摘要(每个章节最多3条)
bm_lines = []
for ch in chapters:
uid = ch["idx"] + 1
texts = bookmarks_by_chapter.get(uid, [])
if texts:
bm_lines.append(f"\n【{ch['title']}】")
for t in texts[:3]:
bm_lines.append(f" · {t[:100]}")
bm_text = "\n".join(bm_lines) if bm_lines else "(暂无热门划线)"
return f"""你是一个阅读助手。用户正在阅读一本书,需要你推荐最相关的章节。
## 书籍信息
- 书名:{meta.get('title', '未知')}
- 作者:{meta.get('author', '未知')}
- 评分:{meta.get('rating', '未知')}
- 简介:{meta.get('intro', '无')[:200]}
## 章节目录([数字] = 该章节的热门划线数)
{toc_text}
## 各章节热门划线摘要
{bm_text}
## 用户查询
"{query}"
## 你的任务
根据用户查询,从目录中选出最相关的章节(最多5个)。
判断依据:
1. 章节标题与查询的语义匹配度
2. 该章节的热门划线内容是否涉及查询主题
3. 对于投资/理财类书,"财富""收入""投资""复利"等是强信号
4. 对于人生/哲学类书,"幸福""心态""选择""价值观"是强信号
请用 JSON 输出:
{{
"query": "用户查询",
"recommendations": [
{{
"chapter": "章节名",
"level": 1或2,
"reason": "为什么推荐这章(结合划线内容说明)",
"key_highlights": ["划线摘要1", "划线摘要2"],
"relevance": "high|medium|low"
}}
],
"summary": "一句话总结推荐逻辑"
}}
只输出 JSON,不要其他内容。"""
def main():
if len(sys.argv) < 3:
print("用法: weread_chapters.py <bookId> \"用户查询\"")
print("输出 JSON,包含目录、划线和给模型的 prompt")
sys.exit(1)
book_id = sys.argv[1]
query = " ".join(sys.argv[2:])
# 拉取数据
meta = get_book_meta(book_id)
chapters = get_chapters(book_id)
bookmarks = get_bookmarks(book_id)
# 构建 prompt
prompt = build_prompt(meta, chapters, bookmarks, query)
# 输出
output = {
"book": meta,
"chapters": chapters,
"bookmarks_by_chapter": {str(k): v for k, v in bookmarks.items()},
"query": query,
"prompt": prompt,
"stats": {
"total_chapters": len(chapters),
"chapters_with_highlights": len(bookmarks),
"total_highlights": sum(len(v) for v in bookmarks.values())
}
}
print(json.dumps(output, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
微信读书精细化推荐引擎 v3
用法:
python3 weread_recommend.py books "投资 心理学" [--limit 10]
python3 weread_recommend.py authors "投资 思维" [--limit 5]
python3 weread_recommend.py chapters <book-id> "财富 幸福" [--limit 5]
python3 weread_recommend.py versions "思考快与慢"
python3 weread_recommend.py similar <book-id> [--limit 5]
python3 weread_recommend.py profile --data /tmp/user_data.json
"""
import sys, json, subprocess, os, re
from concurrent.futures import ThreadPoolExecutor, as_completed
WR = os.path.join(os.path.dirname(__file__), "weread.py")
# ─── 底层搜索 ────────────────────────────────────────────────────────
def _search_single(keyword, count=10):
"""搜索单个关键词,返回原始 items"""
r = subprocess.run([WR, "search", keyword, "--json"],
capture_output=True, text=True, timeout=15)
if not r.stdout.strip():
return []
return json.loads(r.stdout).get("items", [])[:count]
def search(keyword, count=10):
"""
搜索书籍,支持多关键词。
- 单关键词:直接搜
- 多关键词:并行搜 N 个,合并去重,按评分×评价人数排序
"""
keywords = keyword.strip().split()
if not keywords:
return []
all_raw = []
seen = set()
if len(keywords) == 1:
raw_list = [_search_single(keywords[0], count * 2)]
else:
raw_list = []
with ThreadPoolExecutor(max_workers=5) as ex:
futs = {ex.submit(_search_single, kw, count * 2): kw for kw in keywords}
for f in as_completed(futs):
try:
raw_list.append(f.result())
except:
pass
for raw in raw_list:
for b in raw:
bid = b["bookId"]
if bid not in seen:
seen.add(bid)
rating = b.get("newRating", 0)
rc = b.get("newRatingCount", 0)
all_raw.append({
"title": b["title"],
"author": b.get("author", ""),
"rating": round(rating / 10, 1),
"rating_count": rc,
"reading_count": b.get("readingCount", 0),
"category": b.get("category", ""),
"book_id": bid,
"_score": (rating / 10) * (1 + min(rc, 10000) / 1000)
})
all_raw.sort(key=lambda x: x["_score"], reverse=True)
for b in all_raw:
del b["_score"]
return all_raw[:count]
def get_book_info(book_id):
"""获取书籍详情(文本解析)"""
r = subprocess.run([WR, "book", str(book_id)],
capture_output=True, text=True, timeout=15)
text = r.stdout
info = {"book_id": str(book_id)}
for line in text.split("\n"):
line = line.strip()
if line.startswith("《") and line.endswith("》"):
info["title"] = line.strip("《》")
elif line.startswith("作者:"):
info["author"] = line.split(":", 1)[1].strip()
elif line.startswith("评分:"):
info["rating_str"] = line.split(":", 1)[1].strip()
elif line.startswith("分类:"):
info["category"] = line.split(":", 1)[1].strip()
elif line.startswith("出版社:"):
info["publisher"] = line.split(":", 1)[1].strip()
elif line.startswith("简介:"):
info["intro"] = line.split(":", 1)[1].strip()
return info
# ─── 批量查询(一次进程调用)────────────────────────────────────────
def batch_query(book_ids):
"""
批量查询多本书的信息,一次进程调用。
避免多次启动 Python 进程的开销(每次 ~85ms)。
"""
results = []
with ThreadPoolExecutor(max_workers=5) as ex:
futs = {ex.submit(get_book_info, bid): bid for bid in book_ids}
for f in as_completed(futs):
try:
results.append(f.result())
except:
pass
return results
# ─── 推荐书 ──────────────────────────────────────────────────────────
def recommend_books(query, limit=10, prefer_categories=None, exclude_ids=None):
"""
推荐书。
- query: 空格分隔的关键词
- prefer_categories: 优先分类
- exclude_ids: 排除的 bookId 集合(用户已读过的)
"""
books = search(query, limit * 3)
exclude_ids = exclude_ids or set()
# 过滤已读
books = [b for b in books if b["book_id"] not in exclude_ids]
if prefer_categories:
preferred = [b for b in books if any(cat in b.get("category", "") for cat in prefer_categories)]
others = [b for b in books if b not in preferred]
books = preferred + others
return books[:limit]
# ─── 推荐作者 ────────────────────────────────────────────────────────
def _get_author_books(author_name):
"""获取作者的所有书"""
r = subprocess.run([WR, "author", author_name, "--json"],
capture_output=True, text=True, timeout=15)
if not r.stdout.strip():
return None
d = json.loads(r.stdout)
books = d.get("books", [])
if not books:
return None
ratings = [b.get("rating", 0) for b in books if b.get("rating", 0) > 0]
return {
"author": author_name,
"total_books": len(books),
"top_books": [b["title"] for b in sorted(books, key=lambda x: x.get("rating", 0), reverse=True)[:3]],
"avg_rating": round(sum(ratings) / len(ratings) / 10, 1) if ratings else 0,
"best_book_id": books[0]["bookId"] if books else None
}
def recommend_authors(query, limit=5, exclude=None):
"""推荐作者:搜索相关书 → 提取作者 → 获取作者作品 → 排序"""
books = search(query, 30)
exclude = exclude or set()
authors = {}
for b in books:
author = b.get("author", "")
if not author or author in exclude:
continue
# 去掉国籍标记、译者等
author = re.sub(r'\[.*?\]', '', author).strip()
author = re.sub(r'著|编|译$', '', author).strip()
if not author or len(author) > 15:
continue
if author not in authors:
authors[author] = b.get("rating", 0)
sorted_authors = sorted(authors.keys(), key=lambda a: authors[a], reverse=True)
results = []
with ThreadPoolExecutor(max_workers=5) as ex:
futs = {ex.submit(_get_author_books, a): a for a in sorted_authors[:limit * 2]}
for f in as_completed(futs):
try:
info = f.result()
if info and info["total_books"] > 0:
results.append(info)
except:
pass
results.sort(key=lambda x: x["avg_rating"] * min(x["total_books"], 5), reverse=True)
return results[:limit]
# ─── 推荐章节 ────────────────────────────────────────────────────────
def _parse_chapters(book_id):
"""解析章节目录"""
r = subprocess.run([WR, "book", str(book_id), "--chapters"],
capture_output=True, text=True, timeout=15)
chapters = []
in_toc = False
for line in r.stdout.split("\n"):
if "章节目录" in line:
in_toc = True
continue
if not in_toc:
continue
line = line.strip()
if not line or line.startswith("──"):
continue
# 判断层级
if line.startswith(" ") or line.startswith("\t"):
level = 2
else:
level = 1
title = line.strip()
if title:
chapters.append({"idx": len(chapters), "title": title, "level": level})
return chapters
def _get_bestbookmarks(book_id, count=50):
"""获取热门划线"""
r = subprocess.run([WR, "bestbookmarks", str(book_id), "--json"],
capture_output=True, text=True, timeout=15)
if not r.stdout.strip():
return []
d = json.loads(r.stdout)
return d.get("items", []) or d.get("bookmarks", [])[:count]
def recommend_chapters(book_id, query, limit=5):
"""
推荐章节。
策略:
1. 获取章节目录 + 热门划线
2. 划线有 chapterUid,通过 bestbookmarks 返回的 chapterUid 映射到章节
3. 按查询关键词匹配划线内容 → 找到相关章节
4. 如果没匹配到,返回前 N 个一级章节
"""
chapters = _parse_chapters(book_id)
bookmarks = _get_bestbookmarks(book_id, 50)
keywords = query.strip().split()
if not chapters:
return []
# 建立 chapterUid → 章节索引 映射
# chapterUid 是递增的,按顺序对应章节
uid_map = {}
for bm in bookmarks:
uid = bm.get("chapterUid", 0)
if uid and uid not in uid_map:
# 找到最近的章节
idx = min(uid - 1, len(chapters) - 1) if uid <= len(chapters) else min(uid % len(chapters), len(chapters) - 1)
uid_map[uid] = idx
# 按关键词匹配划线
chapter_scores = {}
for bm in bookmarks:
text = bm.get("markText", "") or bm.get("mark", "")
matched_kws = [kw for kw in keywords if kw in text]
if matched_kws:
uid = bm.get("chapterUid", 0)
idx = uid_map.get(uid, 0)
if idx not in chapter_scores:
chapter_scores[idx] = {"score": 0, "highlights": [], "matched_keywords": set()}
chapter_scores[idx]["score"] += len(matched_kws)
chapter_scores[idx]["highlights"].append(text[:120])
chapter_scores[idx]["matched_keywords"].update(matched_kws)
if chapter_scores:
sorted_chapters = sorted(chapter_scores.items(), key=lambda x: x[1]["score"], reverse=True)
return [{
"chapter": chapters[idx]["title"],
"level": chapters[idx]["level"],
"relevance_score": data["score"],
"matched_keywords": list(data["matched_keywords"]),
"sample_highlights": data["highlights"][:3]
} for idx, data in sorted_chapters[:limit]]
else:
# 没有匹配到,返回前 N 个一级章节
top_chapters = [ch for ch in chapters if ch["level"] == 1][:limit]
return [{
"chapter": ch["title"],
"level": ch["level"],
"relevance_score": 0,
"matched_keywords": [],
"sample_highlights": [],
"note": "未匹配到相关关键词,返回目录前几章"
} for ch in top_chapters]
# ─── 版本对比 ────────────────────────────────────────────────────────
def recommend_versions(query):
"""
版本对比:搜索同一本书的所有版本,推荐最优版本。
修复:正则 split 改为更稳健的分组策略。
"""
results = _search_single(query, 20)
if not results:
return []
# 按基础书名分组:取前2-4个字作为分组 key
groups = {}
for b in results:
title = b["title"]
# 取前 min(4, len(title)//2) 个字符作为分组 key
key_len = max(2, min(4, len(title) // 2))
base = title[:key_len]
if base not in groups:
groups[base] = []
groups[base].append(b)
output = []
for base_key, versions in groups.items():
if len(versions) < 2:
continue
# 找到最完整的书名(包含版本信息)
base_name = max(versions, key=lambda b: len(b["title"]))["title"].split("(")[0].split("(")[0].strip()
versions.sort(key=lambda b: (
b.get("newRating", 0),
b.get("newRatingCount", 0),
-len(b["title"])
), reverse=True)
best = versions[0]
output.append({
"base_title": base_name or base_key,
"versions": [{
"title": b["title"],
"author": b.get("author", ""),
"rating": round(b.get("newRating", 0) / 10, 1),
"rating_count": b.get("newRatingCount", 0),
"book_id": b["bookId"],
"is_recommended": b == best
} for b in versions],
"recommended": {
"title": best["title"],
"author": best.get("author", ""),
"rating": round(best.get("newRating", 0) / 10, 1),
"book_id": best["bookId"],
"reason": f"评分最高({round(best.get('newRating',0)/10,1)}%),评价人数{best.get('newRatingCount',0)}人"
},
"version_count": len(versions)
})
return output
# ─── 相似书 ──────────────────────────────────────────────────────────
def recommend_similar(book_id, limit=5):
"""
相似书推荐。
策略:discover → 二次搜索验证评分 → 过滤低质量
"""
r = subprocess.run([WR, "discover", "--book", str(book_id), "--json"],
capture_output=True, text=True, timeout=15)
if not r.stdout.strip():
return []
d = json.loads(r.stdout)
raw = d.get("books", []) or d.get("items", []) or []
results = []
seen_ids = set()
for b in raw:
title = b.get("title", "")
author = b.get("author", "")
bid = str(b.get("bookId", ""))
if not title or bid in seen_ids:
continue
seen_ids.add(bid)
# 二次搜索获取准确评分
verified = _search_single(title, 3)
if verified:
v = verified[0]
results.append({
"title": v["title"],
"author": v.get("author", author),
"rating": round(v.get("newRating", 0) / 10, 1),
"rating_count": v.get("newRatingCount", 0),
"category": v.get("category", ""),
"book_id": v["bookId"]
})
else:
results.append({
"title": title,
"author": author,
"rating": round(b.get("newRating", 0) / 10, 1),
"rating_count": b.get("newRatingCount", 0),
"category": b.get("category", ""),
"book_id": bid
})
# 过滤低质量
results = [r for r in results if (r["rating"] or 0) > 70 and (r["rating_count"] or 0) > 10]
return results[:limit]
# ─── 综合画像推荐 ────────────────────────────────────────────────────
def recommend_profile(user_data, limit_per_category=5):
"""基于用户画像综合推荐"""
results = {}
exclude_ids = set(user_data.get("read_book_ids", []))
if user_data.get("top_categories"):
cat_query = " ".join([c.split("-")[-1] for c in user_data["top_categories"]])
results["based_on_interest"] = recommend_books(cat_query, limit_per_category, exclude_ids=exclude_ids)
if user_data.get("preferred_authors"):
author_query = " ".join(user_data["preferred_authors"][:3])
results["similar_authors"] = recommend_authors(author_query, 3, exclude=set(user_data["preferred_authors"]))
if user_data.get("blind_spots"):
blind_query = " ".join(user_data["blind_spots"])
results["blind_spot_fill"] = recommend_books(blind_query, limit_per_category, exclude_ids=exclude_ids)
return results
# ─── CLI ──────────────────────────────────────────────────────────────
USAGE = """\
微信读书精细化推荐引擎 v3
用法:
python3 weread_recommend.py books "投资 心理学" [--limit 10]
python3 weread_recommend.py authors "投资 思维" [--limit 5]
python3 weread_recommend.py chapters <book-id> "财富 幸福" [--limit 5]
python3 weread_recommend.py versions "思考快与慢"
python3 weread_recommend.py similar <book-id> [--limit 5]
python3 weread_recommend.py batch <bookId1> <bookId2> ...
python3 weread_recommend.py profile --data /tmp/user_data.json
"""
def main():
if len(sys.argv) < 2:
print(USAGE)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "books":
query = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "投资 理财"
results = recommend_books(query, 10)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "authors":
query = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "投资"
results = recommend_authors(query, 5)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "chapters":
if len(sys.argv) < 4:
print("用法: chapters <book-id> <query>")
sys.exit(1)
book_id = sys.argv[2]
query = " ".join(sys.argv[3:])
results = recommend_chapters(book_id, query, 5)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "versions":
query = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else ""
if not query:
print("用法: versions <书名>")
sys.exit(1)
results = recommend_versions(query)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "similar":
if len(sys.argv) < 3:
print("用法: similar <book-id>")
sys.exit(1)
book_id = sys.argv[2]
results = recommend_similar(book_id, 5)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "batch":
if len(sys.argv) < 3:
print("用法: batch <bookId1> <bookId2> ...")
sys.exit(1)
book_ids = sys.argv[2:]
results = batch_query(book_ids)
print(json.dumps(results, ensure_ascii=False, indent=2))
elif cmd == "profile":
user_data = {}
if len(sys.argv) > 2 and os.path.exists(sys.argv[2]):
with open(sys.argv[2]) as f:
user_data = json.load(f)
results = recommend_profile(user_data)
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
print(f"未知命令: {cmd}")
print(USAGE)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
微信读书批量并行搜索 — 高效推荐引擎工具
用法:
python3 weread_search.py "书名1" "书名2" ... "书名N"
python3 weread_search.py --file books.txt # 每行一本书名
输出: JSON 数组,包含每本书的搜索结果
"""
import sys, json, subprocess, os
from concurrent.futures import ThreadPoolExecutor, as_completed
WR = os.path.join(os.path.dirname(__file__), "weread.py")
def search_one(keyword):
try:
r = subprocess.run([WR, "search", keyword, "--json"],
capture_output=True, text=True, timeout=15)
d = json.loads(r.stdout)
items = d.get("items", [])
if items:
b = items[0]
rating = b.get("newRating", 0)
return {
"query": keyword,
"title": b["title"],
"author": b.get("author", ""),
"rating": round(rating / 10, 1) if rating else None,
"rating_count": b.get("newRatingCount", 0),
"category": b.get("category", ""),
"bookId": b["bookId"],
"readingCount": b.get("readingCount", 0)
}
return {"query": keyword, "error": "no results"}
except Exception as e:
return {"query": keyword, "error": str(e)}
def main():
if len(sys.argv) < 2:
print("用法: weread_search.py 书名1 书名2 ...")
sys.exit(1)
if sys.argv[1] == "--file":
with open(sys.argv[2]) as f:
books = [line.strip() for line in f if line.strip()]
else:
books = sys.argv[1:]
results = []
with ThreadPoolExecutor(max_workers=10) as ex:
futures = {ex.submit(search_one, b): b for b in books}
for future in as_completed(futures):
results.append(future.result())
# 保持原始顺序
order = {b: i for i, b in enumerate(books)}
results.sort(key=lambda x: order.get(x.get("query", ""), 999))
print(json.dumps(results, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()