Files
NewBlog/scripts/fetch-reading.mjs
T
dsh 8a867132de 已读完的书卡改成和「在读」同一套样式,补开始/读完时间
可行性(先说结论)
- 「开始阅读时间」在官方文档里没列,但 /book/getprogress 的原始回包里有
  book.startReadingTime,6 本已读完的全都有
- 顺带纠正我上一轮的错误结论:真正常为 0 的是 recordReadingTime,
  book.readingTime 是有值的(西线无战事 6190 秒 = 103 分),
  已存进 JSON 的 readingSeconds,卡片暂未展示

抽公共组件
- 新增 src/components/reading-card.astro,在读和已读完共用,样式强制统一:
  横版卡片、封面在左、信息在右
- 两处只在底部两行不同:
    在读   进度条 + 百分比 / 最近阅读 X
    已读完 开始 X / 读完 Y(无进度条)

数据层
- fetch-reading.mjs 每个条目新增 startedAt / finishedAt / readingSeconds
- src/lib/reading.ts 的 ReadingBook 类型同步补上(都可选,老数据不会炸)
- startReadingTime / finishTime 缺失时卡片自动少一行,不会渲染出「开始 —」

时间显示走 Asia/Shanghai。所以西线无战事的 startReadingTime
1786207913(UTC 08-08T15:31Z)在页面上是「开始 2026-08-09」,
这才是本地日期。

静态验证(dist/bookshelf/index.html)
- 在读区 20 张卡 / 20 条进度条 / 20 个「最近阅读」
- 已读完区 6 张卡 / 0 条进度条 / 6 个「开始 X」/ 6 个「读完 X」
- 抽一张:西线无战事(译文经典) 雷马克 · 文学-外国文学
  开始 2026-08-09 读完 2026-09-06,确认不含进度条
- 搜索/领域筛选的 data-weread-card 仍挂在新卡片上,筛选不受影响
2026-09-15 10:51:50 +08:00

182 lines
6.6 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,但 book.readingTime 是真数据,累计时长取后者
* 3. 官方文档没列 book.startReadingTime,但回包里有,是「开始阅读时间」
*/
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(),
// 下面三个来自 /book/getprogress。官方文档没列 startReadingTime,
// 但原始回包里确实有(读完的 6 本全都有),是「开始阅读时间」。
startedAt: null,
finishedAt: null,
/** 累计阅读秒数。注意是 readingTime,不是恒为 0 的 recordReadingTime */
readingSeconds: null,
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
}
if (reading.startReadingTime) {
entry.startedAt = new Date(reading.startReadingTime * 1000).toISOString()
}
if (reading.finishTime) {
entry.finishedAt = new Date(reading.finishTime * 1000).toISOString()
}
if (typeof reading.readingTime === "number") {
entry.readingSeconds = reading.readingTime
}
} 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)
})