Files
NewBlog/scripts/fetch-reading.mjs
T
dsh 95dd0d9552 书架页新增「最近在读」:构建时抓微信读书,顺带移除漫画/论文
数据链路
- scripts/fetch-reading.mjs:调腾讯官方 Agent Gateway
  (POST https://i.weread.qq.com/api/agent/gateway,Bearer wrk-...),
  /shelf/sync 拿书架,再对前 N 本调 /book/getprogress 拿进度,
  写出 src/data/reading.json
- src/lib/reading.ts:读 JSON + 按 YYYY-MM-DD 格式化(官方硬性要求),
  文件缺失返回 null 让页面走空态而不是构建报错
- src/components/bookshelf-content.astro:只渲染,页面本身不发请求

接口实测得到的几个结论(都写进脚本注释和 README 了)
- skill_version 必填,业务参数平铺顶层
- /shelf/sync 一次给全封面/书名/作者/分类/最近阅读时间/deepLink,
  只有进度要额外请求,所以请求数 = 1 + N
- progress 是 0-100 整数,只有 100 算读完
- recordReadingTime 恒为 0,所以卡片不做「累计时长」
- deepLink 实测是普通 https,浏览器能直接打开

失败兜底(这次最关键的一条)
- pnpm reading:失败 exit 1,明确报错
- pnpm build:串的是 `fetch-reading.mjs --soft`,Key 失效/断网/接口挂了
  都保留上一次的 reading.json 并 exit 0,不会阻塞部署
- 实测:错误 Key 时 pnpm reading exit 1;pnpm build 完整跑通 exit 0 且
  reading.json 未被改动;不设 Key 也 exit 0
- process.loadEnvFile 不覆盖已存在的环境变量,所以 CI 设的 Key 优先于 .env

Key 安全
- 只写进 .env(gitignore,权限 600),已确认 git status 里没有它
- deploy.sh 是本地构建再 rsync,凭据不会上传服务器
- .env.example 补了占位和申请地址

书架页改版
- 移除漫画/论文两个分类:删掉 tab 切换、两个视图、对应筛选和 JS,
  956 行 -> 620 行;sidebar 的 tab 按钮改成 #reading / #books 页内锚点
- 新增「最近在读」区块置顶(Collection · 01),书籍顺延为 02
- 卡片:封面(hot-link 腾讯 CDN,referrerpolicy=no-referrer)+ 书名 +
  作者 · 分类 + 进度条 + 最近阅读日期,整卡跳 weread.qq.com
- 读完标「已读完」,0% 标「还没开始」
- 过滤私密阅读(secret=1)和从没打开过的书;有声书不处理

静态验证(dist/bookshelf/index.html)
- 8 张在读卡片,链接均指向 weread.qq.com/book-detail
- 8 条进度条宽度 30/0/77/100/28/13/97/0,与脚本输出一致
- 8 张封面全部 hot-link,无本地副本
- 漫画/论文/comics/papers/data-type-btn/switchType 均已从产物消失
- 其他页面(首页/时间线/关于/文章页)不含该区块,未受影响
2026-09-15 10:40:11 +08:00

163 lines
5.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 本,默认 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)
})