/* 抓取微信读书「最近在读」,写出 src/data/reading.json * * 用法: * pnpm reading 手动刷新(失败会 exit 1,明确报错) * pnpm reading --top 12 抓前 12 本,默认 8 * node scripts/fetch-reading.mjs --soft 失败时保留旧数据并 exit 0(构建流程用) * * 接口是腾讯官方的微信读书 Agent Gateway: * POST https://i.weread.qq.com/api/agent/gateway * Authorization: Bearer wrk-... ← 从 .env 的 WEREAD_API_KEY 读 * 体里必须有 api_name + skill_version,业务参数平铺在顶层(不能包进 params) * * Key 申请:https://weread.qq.com/r/weread-skills 扫码 * * 两处实测得到的结论,别被直觉带偏: * 1. /shelf/sync 一次就给全了封面/书名/作者/分类/最近阅读时间/跳转链接, * 只有「阅读进度」要额外调 /book/getprogress,所以请求数 = 1 + N * 2. book.recordReadingTime 恒为 0,拿不到累计时长,所以卡片不做这一项 */ import fs from "node:fs" import path from "node:path" const ENDPOINT = "https://i.weread.qq.com/api/agent/gateway" /** 与官方 SKILL.md 顶部的 version 保持一致;服务端会用它对版本 */ const SKILL_VERSION = "1.0.4" const OUT_FILE = "src/data/reading.json" const DEFAULT_TOP = 8 const REQUEST_TIMEOUT_MS = 15_000 const DELAY_BETWEEN_CALLS_MS = 150 const args = process.argv.slice(2) const SOFT = args.includes("--soft") const topIndex = args.indexOf("--top") const TOP_N = topIndex >= 0 ? Number(args[topIndex + 1]) || DEFAULT_TOP : DEFAULT_TOP // .env 已 gitignore;Node 22 自带 loadEnvFile,不需要 dotenv try { process.loadEnvFile(".env") } catch { // 没有 .env 就只认真正的环境变量 } const apiKey = process.env.WEREAD_API_KEY const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) async function call(apiName, params = {}) { const res = await fetch(ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ api_name: apiName, skill_version: SKILL_VERSION, ...params }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) const json = await res.json().catch(() => null) if (json?.upgrade_info) { console.warn(`⚠️ 服务端要求升级 Skill:${json.upgrade_info.message ?? "(无说明)"}`) } if (res.status === 401 || json?.errcode === -2010) { throw new Error( `WEREAD_API_KEY 无效或已过期(HTTP ${res.status}${json?.errmsg ? `:${json.errmsg}` : ""})。` + `去 https://weread.qq.com/r/weread-skills 重新拿一个。`, ) } if (!res.ok || (json?.errcode !== undefined && json.errcode !== 0)) { throw new Error( `${apiName} 失败:HTTP ${res.status} errcode=${json?.errcode} ${json?.errmsg ?? ""}`.trim(), ) } return json ?? {} } async function fetchReading() { const shelf = await call("/shelf/sync") const candidates = (shelf.books ?? []) // 私密阅读(secret=1)不要发到公开网站上 .filter((book) => book.secret !== 1) // readUpdateTime 为 0 表示从没打开过,不算「在读」 .filter((book) => Number(book.readUpdateTime) > 0) .sort((a, b) => Number(b.readUpdateTime) - Number(a.readUpdateTime)) .slice(0, TOP_N) if (candidates.length === 0) { return { books: [], note: "书架里没有可展示的阅读记录" } } const books = [] for (const book of candidates) { const entry = { bookId: book.bookId, title: book.title ?? "", author: book.author ?? "", category: book.category ?? "", cover: book.cover ?? "", progress: null, finished: false, lastReadAt: new Date(Number(book.readUpdateTime) * 1000).toISOString(), deepLink: book.deepLink ?? "", } try { const detail = await call("/book/getprogress", { bookId: book.bookId }) const reading = detail.book ?? {} if (typeof reading.progress === "number") { entry.progress = reading.progress // progress 是 0-100 的整数,只有正好 100 才算读完 entry.finished = reading.progress === 100 } } catch (error) { // 单本节失败不该让整次抓取挂掉,卡片退化成「不显示进度条」 console.warn(` ⚠️ ${book.title}:进度获取失败 — ${error.message}`) } books.push(entry) await sleep(DELAY_BETWEEN_CALLS_MS) } return { books } } async function main() { if (!apiKey) { throw new Error( "缺少 WEREAD_API_KEY。在项目根目录的 .env 里加一行 WEREAD_API_KEY=wrk-... ," + "或直接 export 到环境变量。", ) } const { books, note } = await fetchReading() if (note) console.warn(`⚠️ ${note}`) const payload = { updatedAt: new Date().toISOString(), source: "weread", books, } fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }) fs.writeFileSync(OUT_FILE, `${JSON.stringify(payload, null, 2)}\n`) console.log(`✅ 写入 ${books.length} 本到 ${OUT_FILE}`) for (const book of books) { const progress = book.progress === null ? " ?" : `${String(book.progress).padStart(3)}%` console.log(` ${progress} ${book.title}${book.finished ? "(已读完)" : ""}`) } } main().catch((error) => { if (SOFT && fs.existsSync(OUT_FILE)) { // 构建流程用这条路径:微信读书挂了 / key 过期 / 断网,都不该阻塞部署 console.warn(`⚠️ 抓取失败,继续用上一次的数据构建:${error.message}`) process.exit(0) } console.error(`❌ ${error.message}`) process.exit(1) })