Files
NewBlog/scripts/fetch-reading.mjs
T
dsh 4df235ea21 已读完的书挪进「书籍」,抓取上限去掉(8 -> 整架 26 本)
抓取范围
- TOP_N 默认从 8 改成 0=不限制。原来那个 8 是我拍的「最近动过的前 8 本」,
  不是接口限制;整架 26 本也就 1+26 个请求,完全够用
- --top N 仍可用,想只抓最近几本时加

分组调整
- 「最近在读」只留 progress < 100 的(20 本),进度条不再出现 100%
- 新增「书籍」区底部的「微信读书 · 已读完」分组(6 本),卡片结构与手工
  书单一致,左上角带「已读完」徽标;因为微信读书的数据没有书评/简介,
  这一组点击直接跳 weread.qq.com,不弹窗
- 这一组带 data-weread-card + data-category="微信读书",所以搜索框和
  「领域」筛选同样作用得到;领域筛选里会多出一个「微信读书」选项
- 筛选逻辑把 [data-book-card] 的选择器扩成 [data-book-card],
  [data-weread-card],分组计数也一并算上

实测数据分布(整架 26 本)
- 已读完 6 / 在读中 16(1%-97%)/ 没开始 4(0%)

静态验证(dist/bookshelf/index.html)
- 在读区 20 张卡片,进度条 30/0/77/28/13/97/0/5/16/3/10/1/4/13/4/0/1/10/6/0,
  区内无「已读完」字样
- 已读完分组 6 张卡片,6 个徽标、6 个微信读书跳转链接,均带 data-weread-card
- 手工书单 3 张卡片未受影响;领域筛选新增「微信读书」按钮
2026-09-15 10:45:29 +08:00

166 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 抓取微信读书「最近在读」,写出 src/data/reading.json
*
* 用法:
* pnpm reading 手动刷新(失败会 exit 1,明确报错)
* pnpm reading --top 12 只抓最近动过的 12 本;不加则整架都抓(默认不限)
* 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"
/** 0 = 不限制,整架都抓。书架一般也就几十本,1+N 个请求完全够用 */
const DEFAULT_TOP = 0
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 // 0 = 不限
// .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 sorted = (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))
// TOP_N 为 0 时整架都取;页面再按「读完 / 在读」自己分组
const candidates = TOP_N > 0 ? sorted.slice(0, TOP_N) : sorted
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)
})