diff --git a/docs/操作指南.md b/docs/操作指南.md index 4c998b6..4bc0234 100644 --- a/docs/操作指南.md +++ b/docs/操作指南.md @@ -214,6 +214,11 @@ pnpm reading 读完的书会自动出现在「书籍」区底部的「微信读书 · 已读完」分组里,不用手工搬。 +**有声书(专辑)**:同一次请求里还会抓书架上的专辑,写进 `reading.json` 的 `albums` 字段。 +「在读」页会自动多出一个「有声书」区块 —— 但没有专辑时整块不显示,所以现在看不到是正常的。 +专辑没有阅读进度也没有跳转链接(微信读书那边就没给),卡片只显示集数、完结状态和最近收听。 +`--top` 只作用于书,专辑一次全取;`secret: 1` 的私密收听同样会被过滤掉。 + **平时不用管**:`pnpm build` 和 `./deploy.sh` 都会自动先抓一次。抓不到就沿用上一次的数据,**不会让部署失败**。 **首次配置**(只做一次): diff --git a/scripts/fetch-reading.mjs b/scripts/fetch-reading.mjs index 788e129..5d2e869 100644 --- a/scripts/fetch-reading.mjs +++ b/scripts/fetch-reading.mjs @@ -1,4 +1,4 @@ -/* 抓取微信读书「最近在读」,写出 src/data/reading.json +/* 抓取微信读书「最近在读」与「有声书专辑」,写出 src/data/reading.json * * 用法: * pnpm reading 手动刷新(失败会 exit 1,明确报错) @@ -17,14 +17,24 @@ * 只有「阅读进度」要额外调 /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 保持一致;服务端会用它对版本 */ -const SKILL_VERSION = "1.0.4" +/** + * 与官方 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 @@ -47,6 +57,54 @@ 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", @@ -61,7 +119,22 @@ async function call(apiName, params = {}) { const json = await res.json().catch(() => null) if (json?.upgrade_info) { - console.warn(`⚠️ 服务端要求升级 Skill:${json.upgrade_info.message ?? "(无说明)"}`) + /* + * 服务端只拿 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( @@ -80,6 +153,9 @@ async function call(apiName, params = {}) { 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) @@ -91,7 +167,7 @@ async function fetchReading() { const candidates = TOP_N > 0 ? sorted.slice(0, TOP_N) : sorted if (candidates.length === 0) { - return { books: [], note: "书架里没有可展示的阅读记录" } + return { books: [], albums, note: "书架里没有可展示的阅读记录" } } const books = [] @@ -140,7 +216,7 @@ async function fetchReading() { await sleep(DELAY_BETWEEN_CALLS_MS) } - return { books } + return { books, albums } } async function main() { @@ -151,23 +227,27 @@ async function main() { ) } - const { books, note } = await fetchReading() + 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} 本到 ${OUT_FILE}`) + 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) => { diff --git a/src/components/album-card.astro b/src/components/album-card.astro new file mode 100644 index 0000000..605eb28 --- /dev/null +++ b/src/components/album-card.astro @@ -0,0 +1,71 @@ +--- +import { formatReadingDate, type ReadingAlbum } from "@/lib/reading" + +/** + * 微信读书「专辑 / 有声书」卡片。 + * + * 为什么不复用 ReadingCard:两边能拿到的字段不一样。专辑没有 progress(官方结构里 + * 就没有),文档也没给 deepLink,还少了分类、开始/读完时间。硬塞进书卡会出现 + * 「进度条永远是 0%」这种假信息,所以单独一张,底部换成「集数 / 完结状态 / 最近收听」。 + * + * deepLink 拿不到时不渲染成 ,只做展示 —— 一个点不动的链接比没有链接更糟。 + */ +interface Props { + album: ReadingAlbum +} + +const { album } = Astro.props + +const Tag = album.deepLink ? "a" : "div" +const linkable = Boolean(album.deepLink) +const lastListened = formatReadingDate(album.lastListenedAt) +const meta = [album.trackCount > 0 ? `${album.trackCount} 集` : "", album.finishStatus] + .filter(Boolean) + .join(" · ") +--- + + +
+ {album.cover ? ( + {album.name} + ) : ( +
+

{album.name}

+
+ )} +
+ +
+

+ {album.name} +

+

{album.author}

+ +
+ {meta &&

{meta}

} +

+ {lastListened ? `最近收听 ${lastListened}` : "还没有收听记录"} +

+
+
+
diff --git a/src/components/bookshelf-content.astro b/src/components/bookshelf-content.astro index f8718c4..60a02a0 100644 --- a/src/components/bookshelf-content.astro +++ b/src/components/bookshelf-content.astro @@ -4,6 +4,7 @@ import Footer from "@/components/footer.astro" import { cn } from "@/lib/utils" import booksData from "../../public/data/books.json" import ReadingCard from "@/components/reading-card.astro" +import AlbumCard from "@/components/album-card.astro" import { formatReadingDate, getReadingData } from "@/lib/reading" /** @@ -26,6 +27,11 @@ const inProgressReading = readingBooks.filter((book) => !book.finished) const finishedReading = readingBooks.filter((book) => book.finished) const readingCount = inProgressReading.length +// 有声书专辑:与 books[] 平行的另一组数据。 +// 用 ?? [] 兜住两种情况:老版 reading.json 没有 albums 字段(--soft 回退时), +// 以及你根本没听过有声书(服务端返回空数组)。 +const readingAlbums = reading?.albums ?? [] + /** 「书籍」区里给微信读书已读书记一个分类名,同时作为可筛选的领域 */ const WEREAD_CATEGORY = "微信读书" @@ -194,6 +200,27 @@ const recommendBadge = (status?: string) => { )} + {/* ========================= 有声书(微信读书专辑) ========================= + 与 books[] 平行的一组数据,只有真的听过有声书时服务端才会返回内容。 + readingAlbums 为空就整块不渲染 —— 所以在你有第一张专辑之前,这个区块 + 既不存在也不占位。数据由 scripts/fetch-reading.mjs 一并抓取。 */} + {isReadingView && readingAlbums.length > 0 && ( +
+
+
+
Collection · 02
+

有声书

+
耳朵也在读书
+
+

{readingAlbums.length} 张专辑

+
+ +
+ {readingAlbums.map((album) => )} +
+
+ )} + {!isReadingView && ( <> {/* ---------- Header ---------- */} diff --git a/src/content/posts/生活/执行力的影响.md b/src/content/posts/生活/执行力的影响.md index a4b4131..e6ba341 100644 --- a/src/content/posts/生活/执行力的影响.md +++ b/src/content/posts/生活/执行力的影响.md @@ -28,5 +28,13 @@ slug: impact-of-execution **想到和得到之间还有一个做到** -有的时候我也在想这个问题, +有的时候我也在想这个问题,**做到**突出的是个人实现的过程,是改变这个物质世界的必然。很多时候,我会选择逃避,便会以拖延的形式出现。干工作没有动力,明明知道这件事情很重要了,但是却一拖再拖。 +内心的驱动力就是这个中间体,映射到行动上就是执行力。自己需要不断提升自己的内驱动力,在自己的精神世界里里面不断建立一个又一个里程碑,完成一个又一个目标形成正反馈,让自己成为一个高执行力的人。 +很多时候,很多事情是很难看到回报的,或者说没有那么明显的正反馈,就容易懈怠,半途而废。 +然而每个人之间的差距就会体现在这里。引用一段话:每个人,每个年龄段,每个层次都有不熟悉的领域,不精通的专业。一部分人碍于面子不懂装懂,一部分人放低自己潜心学习。这与你的年龄段,与你的层次没有关系,只与你态度发生转变有关。 + +人非生而知之, + + +现在就是感觉自己很迷茫,不想工作,但又不得不工作。 diff --git a/src/data/reading.json b/src/data/reading.json index f95387d..337a5f3 100644 --- a/src/data/reading.json +++ b/src/data/reading.json @@ -1,19 +1,75 @@ { - "updatedAt": "2026-10-04T12:16:26.963Z", + "updatedAt": "2026-10-11T08:52:01.398Z", "source": "weread", "books": [ + { + "bookId": "3300207468", + "title": "快乐的死(果麦经典)", + "author": "[法]加缪", + "category": "文学-外国文学", + "cover": "https://cdn.weread.qq.com/weread/cover/12/cpplatform_tgx43jt54ezt7bamqcmjbk/t6_cpplatform_tgx43jt54ezt7bamqcmjbk1779878190.jpg", + "progress": 5, + "finished": false, + "lastReadAt": "2026-10-08T15:27:08.000Z", + "startedAt": null, + "finishedAt": null, + "readingSeconds": 262, + "deepLink": "https://weread.qq.com/book-detail?type=1&v=66b32530813abb78ag018a22" + }, + { + "bookId": "3300086064", + "title": "河西走廊", + "author": "《河西走廊》摄制组编", + "category": "历史-历史地理", + "cover": "https://cdn.weread.qq.com/weread/cover/89/cpplatform_htt13rz3ky797h8pvypqq1/t6_cpplatform_htt13rz3ky797h8pvypqq11707017069.jpg", + "progress": 0, + "finished": false, + "lastReadAt": "2026-10-08T12:06:21.000Z", + "startedAt": null, + "finishedAt": null, + "readingSeconds": 0, + "deepLink": "https://weread.qq.com/book-detail?type=1&v=de932a80813ab881eg014870" + }, + { + "bookId": "3300175025", + "title": "此刻是春天(微信读书特别版)", + "author": "卢思浩", + "category": "精品小说-治愈小说", + "cover": "https://cdn.weread.qq.com/weread/cover/4/cpplatform_3hsoybvrwapq1efqha1mvz/t6_cpplatform_3hsoybvrwapq1efqha1mvz1762325065.jpg", + "progress": 4, + "finished": false, + "lastReadAt": "2026-10-08T12:05:30.000Z", + "startedAt": "2026-01-11T10:32:09.000Z", + "finishedAt": "2026-09-27T16:39:36.000Z", + "readingSeconds": 295, + "deepLink": "https://weread.qq.com/book-detail?type=1&v=b0b32630813abaadeg0157d1" + }, + { + "bookId": "3300095415", + "title": "觉醒年代(全集|同名影视原著)", + "author": "龙平平", + "category": "历史-历史小说", + "cover": "https://cdn.weread.qq.com/weread/cover/66/cpplatform_4opftuyz4szxsagdfgsn1i/t6_cpplatform_4opftuyz4szxsagdfgsn1i1713258044.jpg", + "progress": 0, + "finished": false, + "lastReadAt": "2026-10-08T11:44:34.000Z", + "startedAt": null, + "finishedAt": null, + "readingSeconds": 0, + "deepLink": "https://weread.qq.com/book-detail?type=1&v=e6032ec0813ab8bc5g015fd3" + }, { "bookId": "182788", "title": "北京法源寺(文学性与思想性相结合的经典作品)", "author": "李敖", "category": "历史-历史小说", "cover": "https://cdn.weread.qq.com/weread/cover/16/yuewen_182788/t6_yuewen_1827881778320200.jpg", - "progress": 51, - "finished": false, - "lastReadAt": "2026-10-04T10:57:59.000Z", + "progress": 100, + "finished": true, + "lastReadAt": "2026-10-08T11:42:40.000Z", "startedAt": "2026-09-21T06:26:14.000Z", - "finishedAt": null, - "readingSeconds": 4379, + "finishedAt": "2026-10-08T11:30:06.000Z", + "readingSeconds": 11837, "deepLink": "https://weread.qq.com/book-detail?type=1&v=dcc3296052ca04dccdbef6c" }, { @@ -30,20 +86,6 @@ "readingSeconds": 14442, "deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db" }, - { - "bookId": "3300175025", - "title": "此刻是春天(微信读书特别版)", - "author": "卢思浩", - "category": "精品小说-治愈小说", - "cover": "https://cdn.weread.qq.com/weread/cover/4/cpplatform_3hsoybvrwapq1efqha1mvz/t6_cpplatform_3hsoybvrwapq1efqha1mvz1762325065.jpg", - "progress": 4, - "finished": false, - "lastReadAt": "2026-09-27T16:39:50.000Z", - "startedAt": "2026-01-11T10:32:09.000Z", - "finishedAt": "2026-09-27T16:39:36.000Z", - "readingSeconds": 231, - "deepLink": "https://weread.qq.com/book-detail?type=1&v=b0b32630813abaadeg0157d1" - }, { "bookId": "23914527", "title": "树莓派开始,玩转Linux", @@ -58,20 +100,6 @@ "readingSeconds": 14629, "deepLink": "https://weread.qq.com/book-detail?type=1&v=140329d0716ce81f140468e" }, - { - "bookId": "3300207468", - "title": "快乐的死(果麦经典)", - "author": "[法]加缪", - "category": "文学-外国文学", - "cover": "https://cdn.weread.qq.com/weread/cover/12/cpplatform_tgx43jt54ezt7bamqcmjbk/t6_cpplatform_tgx43jt54ezt7bamqcmjbk1779878190.jpg", - "progress": 0, - "finished": false, - "lastReadAt": "2026-09-27T10:00:32.000Z", - "startedAt": null, - "finishedAt": null, - "readingSeconds": 38, - "deepLink": "https://weread.qq.com/book-detail?type=1&v=66b32530813abb78ag018a22" - }, { "bookId": "23796324", "title": "昨日的世界", @@ -380,5 +408,6 @@ "readingSeconds": 137, "deepLink": "https://weread.qq.com/book-detail?type=1&v=47532ac05c659b47554a825" } - ] + ], + "albums": [] } diff --git a/src/lib/reading.ts b/src/lib/reading.ts index 6ab9e26..4e7b385 100644 --- a/src/lib/reading.ts +++ b/src/lib/reading.ts @@ -36,6 +36,39 @@ export type ReadingData = { updatedAt: string source: string books: ReadingBook[] + /** + * 专辑 / 有声书。 + * + * 可选:`--soft` 构建路径下用的可能是更早版本的 reading.json,那时还没有这个字段。 + * 调用方一律写 `data.albums ?? []`。 + */ + albums?: ReadingAlbum[] +} + +/** + * 一张有声书专辑。 + * + * 字段刻意比 ReadingBook 少 —— 微信读书给专辑的原始结构里(albumInfo / + * albumInfoExtra)**没有阅读进度,也没有 deepLink**,所以这里没有 progress, + * 前端也不该伪造一条进度条。哪天官方补了字段,先改 scripts/fetch-reading.mjs。 + */ +export type ReadingAlbum = { + albumId: string + name: string + /** 演播 / 作者 */ + author: string + cover: string + /** 音频集数;取不到时为 0 */ + trackCount: number + /** 是否已完结(原始字段 finish === 1) */ + finished: boolean + /** 完结状态原文,如「已完结」 */ + finishStatus: string + intro: string + /** 最近收听时间,ISO;从没听过时为 null */ + lastListenedAt: string | null + /** 跳转链接。官方文档未列此字段,实测可能为空串 */ + deepLink: string } const DATA_FILE = path.join(process.cwd(), "src/data/reading.json")