From 95dd0d9552c29473360414b017a5d3d294f0610a Mon Sep 17 00:00:00 2001 From: dsh Date: Tue, 15 Sep 2026 10:40:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B9=A6=E6=9E=B6=E9=A1=B5=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E6=9C=80=E8=BF=91=E5=9C=A8=E8=AF=BB=E3=80=8D=EF=BC=9A?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=97=B6=E6=8A=93=E5=BE=AE=E4=BF=A1=E8=AF=BB?= =?UTF-8?q?=E4=B9=A6=EF=BC=8C=E9=A1=BA=E5=B8=A6=E7=A7=BB=E9=99=A4=E6=BC=AB?= =?UTF-8?q?=E7=94=BB/=E8=AE=BA=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据链路 - 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 均已从产物消失 - 其他页面(首页/时间线/关于/文章页)不含该区块,未受影响 --- .env.example | 5 + README.md | 35 +- docs/操作指南.md | 44 +- package.json | 5 +- scripts/fetch-reading.mjs | 162 +++++++ src/components/bookshelf-content.astro | 566 +++++-------------------- src/data/reading.json | 94 ++++ src/lib/reading.ts | 62 +++ 8 files changed, 513 insertions(+), 460 deletions(-) create mode 100644 scripts/fetch-reading.mjs create mode 100644 src/data/reading.json create mode 100644 src/lib/reading.ts diff --git a/.env.example b/.env.example index 5f8c4bf..88c9d72 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ # 评论系统:twikoo 后端地址。留空则整个评论区不渲染。 # 默认值写在 src/data/comments.ts,这里设置会覆盖它。 PUBLIC_TWIKOO_ENV_ID=https://twikoo.tianxuetao.top + +# 微信读书「最近在读」:Agent API Key,scripts/fetch-reading.mjs 用。 +# 申请:https://weread.qq.com/r/weread-skills 扫码后拿到 wrk- 开头的串。 +# 不填的话 `pnpm reading` 会报错;`pnpm build` 会用上一次的数据继续构建。 +WEREAD_API_KEY= diff --git a/README.md b/README.md index 01aa1b4..0b9cdd1 100644 --- a/README.md +++ b/README.md @@ -273,15 +273,38 @@ prose-p:leading-[1.78] md:prose-p:leading-[1.85] > 移动端页眉目前**故意关掉**了常驻磨砂(`site-header.astro` 的 `@media (max-width: 767px)` 那段):固定元素持续模糊会让移动端合成开销变大。要开的话建议用「滚动后才开」的方式,别让它一直生效。 -## 书架数据 +## 书架与「最近在读」 -书架页的数据来自这三个文件,由 `src/components/bookshelf-content.astro` 在**构建时 import**(所以文件必须存在,删掉会导致构建失败): +书架页(`/bookshelf/`)有两块内容: -- `public/data/books.json` -- `public/data/papers.json` -- `public/data/comics.json` +**1. 最近在读** —— 来自微信读书,构建时抓取,页面本身不发任何请求。 -目前都是空的 `{"data":{"items":[],"total":0}}`,所以书架页显示为空。要填充就按同样的结构往 `items` 里加记录。 +``` +微信读书 Agent Gateway + ↓ scripts/fetch-reading.mjs(读 .env 的 WEREAD_API_KEY) +src/data/reading.json + ↓ src/lib/reading.ts +src/components/bookshelf-content.astro +``` + +- 刷新数据:`pnpm reading`(失败会明确报错并 exit 1);`pnpm reading --top 12` 可改抓取条数(默认 8) +- **`pnpm build` 会自动先跑一次抓取,但用 `--soft` 模式**:微信读书挂了 / Key 过期 / 断网时,保留上一次的 `reading.json` 继续构建,**不会阻塞部署** +- `src/data/reading.json` 是数据快照,**要提交进 git**,这样 CI 或新克隆的仓库没 Key 也能构建 +- Key 申请: 扫码,拿到 `wrk-` 开头的串,写进 `.env` 的 `WEREAD_API_KEY`(`.env` 已 gitignore)。**只在本地构建**时用得上 —— `deploy.sh` 是本地构建再 rsync,凭据不会上传服务器;如果另外接了 Vercel / GitHub Actions 构建,要在那边单独配 secret + +接口是腾讯官方的 Agent Gateway(`POST https://i.weread.qq.com/api/agent/gateway`)。几个实测得到的结论,改脚本前值得知道: + +- 请求体里 `skill_version` **必填**,业务参数**平铺在顶层**,不能包进 `params` +- `/shelf/sync` 一次就给全封面/书名/作者/分类/最近阅读时间/跳转链接,**只有阅读进度**要额外调 `/book/getprogress`,所以请求数 = 1 + N +- `book.progress` 是 0–100 的整数,**只有 100 才算读完** +- `book.recordReadingTime` **恒为 0**,所以卡片不做「累计时长」 +- `deepLink` 实测是普通 https(`https://weread.qq.com/book-detail?...`),浏览器能直接打开 +- `category` 是 `文学-外国文学` 这种「大类-子类」复合串 +- 时间戳统一按 `YYYY-MM-DD` 展示(官方硬性要求),顺带避开「N 天前」在静态站上会随构建时间变旧 + +展示逻辑:过滤掉私密阅读(`secret === 1`)和从没打开过的书;读完的照常显示,标「已读完」;0% 标「还没开始」;有声书不管。 + +**2. 书籍** —— 来自 `public/data/books.json`,由 `bookshelf-content.astro` 在构建时 import(文件必须存在)。另外两个文件 `papers.json` / `comics.json` 对应的漫画、论文分类已经移除,文件保留但不再被引用。 ## 部署 diff --git a/docs/操作指南.md b/docs/操作指南.md index 0e935b8..7bb0d32 100644 --- a/docs/操作指南.md +++ b/docs/操作指南.md @@ -193,6 +193,35 @@ pnpm preview # 本地预览构建产物(搜索功能只有这里才生效 --- +## 7.5 刷新「最近在读」 + +书架页(`/bookshelf/`)顶部的「最近在读」是从微信读书抓来的,数据存在 `src/data/reading.json`。 + +**只想更新书单**(不动别的): + +```bash +pnpm reading +``` + +会打印抓到的每本书和进度。想看更多本就 `pnpm reading --top 12`(默认 8 本)。 + +**平时不用管**:`pnpm build` 和 `./deploy.sh` 都会自动先抓一次。抓不到就沿用上一次的数据,**不会让部署失败**。 + +**首次配置**(只做一次): + +1. 打开 扫码,拿到 `wrk-` 开头的 Key +2. 在项目根目录的 `.env` 里加一行: + + ``` + WEREAD_API_KEY=wrk-你的key + ``` + + `.env` 已被 gitignore,不会提交,也不会随 `deploy.sh` 上传到服务器。 + +不想出现在页面上就改 `scripts/fetch-reading.mjs` 里的过滤条件(默认跳过私密阅读和从没打开过的书)。 + +--- + ## 8. 常见问题 **改了文件但浏览器没变** @@ -247,8 +276,21 @@ sudo sysctl fs.inotify.max_user_watches=524288 `twikoo-custom.css` 之前)。如果又出现这个症状,先确认这两行 `` 还在, 并且 `public/assets/css/twikoo.css` 没被误删。 -**想回退到之前的状态** +**「最近在读」空了 / 数据不更新** +先看构建日志里有没有 `⚠️ 抓取失败,继续用上一次的数据构建`,后面会跟具体原因: + +| 日志里的话 | 怎么办 | +|---|---| +| `WEREAD_API_KEY 无效或已过期` | Key 失效了,重新扫码拿一个,更新 `.env` | +| `缺少 WEREAD_API_KEY` | `.env` 里没配,见第 7.5 节 | +| `fetch failed` / 超时 | 网络问题,重跑 `pnpm reading` 即可 | + +**重要**:以上任何一种都**不会**让 `pnpm build` 失败 —— 页面会继续用 `src/data/reading.json` 里上一次的数据。所以「站点发布成功但书单是旧的」是正常现象,不是 bug。 + +如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。 + +**想回退到之前的状态** ```bash git log --oneline # 看提交 git reset --hard <提交号> diff --git a/package.json b/package.json index 364f393..5469c41 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro build && pagefind --site dist", + "build": "node scripts/fetch-reading.mjs --soft && astro build && pagefind --site dist", "preview": "astro preview", "check": "astro check", "new-post": "node scripts/new-post.mjs", - "new-thought": "node scripts/new-thought.mjs" + "new-thought": "node scripts/new-thought.mjs", + "reading": "node scripts/fetch-reading.mjs" }, "dependencies": { "@astrojs/mdx": "^5.0.6", diff --git a/scripts/fetch-reading.mjs b/scripts/fetch-reading.mjs new file mode 100644 index 0000000..43c8a75 --- /dev/null +++ b/scripts/fetch-reading.mjs @@ -0,0 +1,162 @@ +/* 抓取微信读书「最近在读」,写出 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) +}) diff --git a/src/components/bookshelf-content.astro b/src/components/bookshelf-content.astro index 23a2dcf..d7856a2 100644 --- a/src/components/bookshelf-content.astro +++ b/src/components/bookshelf-content.astro @@ -3,8 +3,12 @@ import SiteHeader from "@/components/site-header.astro" import Footer from "@/components/footer.astro" import { cn } from "@/lib/utils" import booksData from "../../public/data/books.json" -import comicsData from "../../public/data/comics.json" -import papersData from "../../public/data/papers.json" +import { formatReadingDate, getReadingData } from "@/lib/reading" + +// 微信读书「最近在读」。数据缺失时 reading 为 null,页面退化成空态,不报错。 +const reading = getReadingData() +const readingBooks = reading?.books ?? [] +const readingCount = readingBooks.length // ---- Types @@ -25,42 +29,6 @@ interface BookRecord { record_id: string } -interface ComicFields { - 作品名?: string - 作者?: string - 封面?: { local_path: string }[] - 短评?: string - 推荐状态?: "推荐" | "中庸" | "不行" - 连载状态?: "连载中" | "完结" | "腰斩" - 追读状态?: "追更中" | "已读完" | "弃坑" | "想读" - 最新话数?: string - 最新卷数?: string - 分类?: string[] - 年份?: number - komiic_url?: string -} -interface ComicRecord { - fields: ComicFields - id: string - record_id: string -} - -interface PaperFields { - 标题?: string - 作者?: string - 年份?: string | number - PDF链接?: { link: string; text?: string } - 领域?: string - 核心观点?: string - 我的笔记?: string - 推荐状态?: "推荐" | "中庸" | "不行" -} -interface PaperRecord { - fields: PaperFields - id: string - record_id: string -} - // ---- Books const allBooks = (booksData.data.items as BookRecord[]).filter( @@ -84,46 +52,6 @@ const bookStats = { const getBookCover = (b: BookRecord): string | null => b.fields.封面?.[0]?.local_path ?? null -// ---- Comics - -const allComics = (comicsData.data.items as ComicRecord[]).filter((c) => c.fields.作品名) -const comicSerialStatuses = ["全部", "连载中", "完结", "腰斩"] - -const comicStats = { - total: allComics.length, - recommended: allComics.filter((c) => c.fields.推荐状态 === "推荐").length, - ongoing: allComics.filter((c) => c.fields.连载状态 === "连载中").length, -} - -const getComicCover = (c: ComicRecord): string | null => c.fields.封面?.[0]?.local_path ?? null - -// ---- Papers - -const allPapers = (papersData.data.items as PaperRecord[]).filter((p) => p.fields.标题) - -const papersByYear = allPapers.reduce>((acc, p) => { - const y = String(p.fields.年份 ?? "未知") - ;(acc[y] = acc[y] || []).push(p) - return acc -}, {}) -const paperYears = Object.keys(papersByYear).sort((a, b) => { - const na = Number(a), nb = Number(b) - if (Number.isNaN(na) && Number.isNaN(nb)) return a.localeCompare(b) - if (Number.isNaN(na)) return 1 - if (Number.isNaN(nb)) return -1 - return nb - na -}) -const paperFields = Array.from(new Set(allPapers.map((p) => p.fields.领域).filter(Boolean))) as string[] - -const paperYearFilters = ["全部", ...paperYears] -const paperFieldFilters = ["全部", ...paperFields] - -const paperStats = { - total: allPapers.length, - recommended: allPapers.filter((p) => p.fields.推荐状态 === "推荐").length, - fields: paperFields.length, -} - // ---- Data for client scripts (modal, filters) const booksForClient = allBooks.map((b) => ({ @@ -138,32 +66,6 @@ const booksForClient = allBooks.map((b) => ({ finished: b.fields.完成阅读时期 ?? "", })) -const comicsForClient = allComics.map((c) => ({ - id: c.id, - title: c.fields.作品名 ?? "", - author: c.fields.作者 ?? "", - cover: getComicCover(c) ?? "", - review: c.fields.短评 ?? "", - serial: c.fields.连载状态 ?? "", - tracking: c.fields.追读状态 ?? "", - status: c.fields.推荐状态 ?? "", - chapters: c.fields.最新话数 ?? "", - categories: c.fields.分类 ?? [], - url: c.fields.komiic_url ?? "", -})) - -const papersForClient = allPapers.map((p) => ({ - id: p.id, - title: p.fields.标题 ?? "", - authors: p.fields.作者 ?? "", - year: String(p.fields.年份 ?? ""), - link: p.fields.PDF链接?.link ?? "", - field: p.fields.领域 ?? "", - insight: p.fields.核心观点 ?? "", - note: p.fields.我的笔记 ?? "", - status: p.fields.推荐状态 ?? "", -})) - // Status badge color helpers const recommendBadge = (status?: string) => { switch (status) { @@ -197,44 +99,33 @@ const recommendBadge = (status?: string) => {
馆藏
+ {/* 漫画和论文两个分类已移除,只剩书籍;这里改成页内锚点,不再是 tab 切换 */} @@ -243,39 +134,102 @@ const recommendBadge = (status?: string) => { {/* ========================= Main ========================= */}
- {/* ---------- Header (per-type) ---------- */} -
-
Collection · 01
+ {/* ========================= 最近在读(微信读书) ========================= + 数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json, + 这里只负责渲染,页面本身不发任何请求。 */} +
+
+
+
Collection · 01
+

最近在读

+
翻开的书,都还热着
+
+ {reading ? ( +

+ 来自微信读书 · 更新于 {formatReadingDate(reading.updatedAt)} +

+ ) : null} +
+ + {readingBooks.length > 0 ? ( + + + {/* ---------- Header ---------- */} +
+
Collection · 02

书籍

博学而笃志,切问而近思
- - - {/* ---------- Stats (per-type) ---------- */} -
+ {/* ---------- Stats ---------- */} +
{bookStats.total}本已读 {bookStats.recommended}本推荐 {bookStats.categories}个领域
- - {/* ---------- Search + filters ---------- */}
@@ -286,16 +240,13 @@ const recommendBadge = (status?: string) => {
{/* Books filters */} -
+
领域 {bookCategories.map((cat) => ( @@ -329,69 +280,12 @@ const recommendBadge = (status?: string) => {
- {/* Comics filters */} - - - {/* Papers filters */} - {/* ========================= Views ========================= */} {/* ---- Books ---- */} -
+
{Object.entries(booksByCategory).map(([category, books]) => (
@@ -460,137 +354,6 @@ const recommendBadge = (status?: string) => { ))}
- {/* ---- Comics ---- */} - - - {/* ---- Papers ---- */} - - {/* Empty state (shared, shown when filters return nothing) */}
- {/* ====== Styles: switch animation ====== */} + {/* ====== Styles ====== */} {/* ====== Client script ====== */}