Files
NewBlog/scripts/fetch-reading.mjs

262 lines
10 KiB
JavaScript
Raw Permalink 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,但回包里有,是「开始阅读时间」
* 4. albums[](专辑/有声书)和 books[] 是平行数组,数据结构也不一样:
* 内容包在 albumInfo / albumInfoExtra 两层里,且**没有进度、没有 deepLink**
* (至少官方文档没写)。--top 只作用于 books,专辑一次全取。
*/
import fs from "node:fs"
import path from "node:path"
const ENDPOINT = "https://i.weread.qq.com/api/agent/gateway"
/**
* 与官方 SKILL.md 顶部的 version 保持一致;服务端会用它对版本。
*
* 升级就是改这个数字,**不需要**下载官方的 weread-skills.zip:那个包是给 agent 读的
* 提示词文档(SKILL.md + shelf.md / book.md 等),本项目一个文件都不消费它;
* 网关只拿这里的 skill_version 做比对。2026-10-10 起服务端要求 1.0.5。
* 参考:https://cdn.weread.qq.com/skills/weread-skills.zip
*/
const SKILL_VERSION = "1.0.5"
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))
/**
* 时间戳转 ISO。
*
* 微信读书的书架时间戳实测是**秒**;但专辑那边的 lectureReadUpdateTime 官方文档
* 没写单位,万一是毫秒(13 位)就会得到公元 5 万年。用位数兜一下,两边都安全。
*/
function toIso(value) {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return null
return new Date(n > 1e12 ? n : n * 1000).toISOString()
}
/**
* 专辑/有声书。和 books[] 平行,但结构完全不同:内容在 albumInfo / albumInfoExtra 两层。
*
* 官方文档给的字段里没有 progress、也没有 deepLink,所以:
* - 不伪造进度,前端只展示「集数 / 完结状态 / 最近收听」;
* - deepLink 按几个可能的位置兜一下,取不到就是空串(卡片退化成不可点击)。
* 私密收听(secret === 1)和书一样必须过滤掉 —— 这是要发到公开网站上的。
*/
function normalizeAlbums(shelf) {
return (shelf.albums ?? [])
.filter((album) => album?.albumInfoExtra?.secret !== 1)
.map((album) => {
const info = album.albumInfo ?? {}
const extra = album.albumInfoExtra ?? {}
return {
albumId: info.albumId ?? "",
name: info.name ?? "",
/** 演播 / 作者 */
author: info.authorName ?? "",
cover: info.cover ?? "",
trackCount: Number(info.trackCount) || 0,
finished: Number(info.finish) === 1,
/** 完结状态原文,如「已完结」 */
finishStatus: info.finishStatus ?? "",
intro: info.intro ?? "",
lastListenedAt: toIso(extra.lectureReadUpdateTime),
deepLink: info.deepLink ?? album.deepLink ?? "",
}
})
.sort((a, b) => {
const at = a.lastListenedAt ? Date.parse(a.lastListenedAt) : 0
const bt = b.lastListenedAt ? Date.parse(b.lastListenedAt) : 0
return bt - at
})
}
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) {
/*
* 服务端只拿 skill_version 做版本比对。原来的写法只打了 message,把
* current_version / latest_version / upgrade_url 一起丢掉了,于是提示退化成
* 一句无解的「请立即升级」—— 而 message 本身是写给 agent 的通用话术
* (「必须立即暂停当前操作…不得忽略该字段」),照做就等于让远端文案驱动本地文件。
* 这里改成把可执行的信息全打出来:差了多少版、去哪儿看、改哪一行。
*/
const { current_version, latest_version, upgrade_url, message } = json.upgrade_info
console.warn(
`⚠️ 微信读书 Skill 版本过期:本地 ${current_version ?? SKILL_VERSION} → 服务端要求 ${latest_version ?? "?"}(接口仍可用,只是提醒)`,
)
if (message) console.warn(` ${message}`)
if (upgrade_url) console.warn(` 升级包(仅供查阅,本项目不消费):${upgrade_url}`)
console.warn(
` 处理方式:把本文件顶部的 SKILL_VERSION 改成 "${latest_version ?? "最新版本"}",无需下载安装任何文件。`,
)
}
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")
// 专辑一次全取(--top 只管书),所以先算出来,避免下面提前 return 时把它丢了
const albums = normalizeAlbums(shelf)
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: [], albums, 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, albums }
}
async function main() {
if (!apiKey) {
throw new Error(
"缺少 WEREAD_API_KEY。在项目根目录的 .env 里加一行 WEREAD_API_KEY=wrk-... ," +
"或直接 export 到环境变量。",
)
}
const { books, albums, note } = await fetchReading()
if (note) console.warn(`⚠️ ${note}`)
const payload = {
updatedAt: new Date().toISOString(),
source: "weread",
books,
albums,
}
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true })
fs.writeFileSync(OUT_FILE, `${JSON.stringify(payload, null, 2)}\n`)
console.log(`✅ 写入 ${books.length} 本 + ${albums.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 ? "(已读完)" : ""}`)
}
for (const album of albums) {
console.log(` 🎧 ${album.name}${album.trackCount ? `(${album.trackCount} 集)` : ""}`)
}
}
main().catch((error) => {
if (SOFT && fs.existsSync(OUT_FILE)) {
// 构建流程用这条路径:微信读书挂了 / key 过期 / 断网,都不该阻塞部署
console.warn(`⚠️ 抓取失败,继续用上一次的数据构建:${error.message}`)
process.exit(0)
}
console.error(`❌ ${error.message}`)
process.exit(1)
})