我的动态页
06 - 个人动态页开发笔记
创建日期:2026-04-28
用途:/my_activity独立页面的维护参考,包含阅读动态、ACG 动态(Bangumi)和 Steam 动态三个模块的实现细节
页面基本信息
| 项目 | 内容 |
|---|---|
| 页面模板文件 | usr/themes/classic-22/my-activity.php |
| 共享函数文件 | usr/themes/classic-22/inc/activity-functions.php |
| 异步 API 端点 | activity-api.php(项目根目录) |
| Typecho 模板名 | 个人动态 |
| 页面 slug | my_activity |
| 访问地址 | https://kaiwen.work/my_activity |
| 缓存目录 | usr/cache/(已在 .gitignore,服务器自动创建) |
模块一:阅读动态(微信读书 Agent Gateway)
API 基本信息
| 项目 | 内容 |
|---|---|
| Gateway | POST https://i.weread.qq.com/api/agent/gateway |
| 认证 | Authorization: Bearer WEREAD_API_KEY |
| 请求版本 | skill_version: 1.0.4(可由 WEREAD_SKILL_VERSION 覆盖) |
| 客户端 | usr/themes/classic-22/inc/weread-client.php |
| 聚合与渲染 | usr/themes/classic-22/inc/weread-activity.php |
| 缓存文件 | usr/cache/weread_activity.json |
| 永久兜底 | usr/cache/weread_activity_fallback.json |
| 缓存有效期 | 6 小时 |
请求流程
第一波并行请求:
/readdata/detail,mode=monthly/shelf/sync
- 立即过滤
books[].secret=1的私密书籍和readUpdateTime<=0的未读书籍。 - 公开电子书按
readUpdateTime降序取最近WEREAD_BOOK_CANDIDATE_LIMIT本(默认 24)作为候选池。 - 第二波并行请求候选池里每本书的
/book/getprogress(curl_multi并发,总耗时≈最慢单请求)。 - 过滤总阅读时长低于
WEREAD_MIN_BOOK_READ_SECONDS(默认 1800 秒 = 30 分钟)的书;进度请求失败、无法确认时长的书也一并过滤。 - 剩余书籍按
readUpdateTime降序取前 3 本展示;不足 3 本时有几本显示几本,0 本显示"暂无公开阅读记录"。 - 聚合月度统计、封面、作者、最近阅读时间、进度和
deepLink。
月度统计字段单位:
| 字段 | 说明 |
|---|---|
totalReadTime | 本月总阅读时长,单位为秒 |
readDays | 本月有效阅读天数 |
dayAverageReadTime | 按已过去自然日计算的日均时长,单位为秒 |
/book/getprogress 关键字段(注意字段名):
| 字段 | 说明 |
|---|---|
book.readingTime | 总阅读时长(秒),用于时长过滤。recordReadingTime 恒为 0,勿用 |
book.progress | 阅读进度百分比(0-100) |
book.updateTime | 最近阅读时间戳,作为 last_read_time |
隐私与链接规则
- 私密书籍和未读书籍必须在排序和进度请求前过滤,禁止进入短缓存、fallback 或 HTML。
- 只展示
books[]电子书,不混入albums[]有声书。 - 总阅读时长低于阈值或进度请求失败的书不进入展示,不因进度失败回退显示
--(候选池有 24 本兜底)。 - 封面直接使用接口返回的 HTTPS
cover;失败时复用活动卡片封面占位。 - 书籍卡片只使用接口返回的 HTTPS
deepLink,且 host 必须是weread.qq.com或其子域。 - 缺少/非法
deepLink时渲染不可点击卡片,不自行拼接链接。 - 回包出现
upgrade_info或errcode != 0时,本次刷新失败并读取 fallback。
模块二:ACG 动态(Bangumi API)
API 基本信息
| 项目 | 内容 |
|---|---|
| Base URL | 由 config.inc.php 中的 BANGUMI_API_BASE_URLS 配置,默认 fallback 为 https://api.bgm.tv |
| 认证 | Authorization: Bearer BANGUMI_API_TOKEN(BANGUMI_TOKEN 亦可,二者等价) |
| Token 存放 | config.inc.php → define('BANGUMI_API_TOKEN', '...') 和 define('BANGUMI_TOKEN', BANGUMI_API_TOKEN) |
| Web 跳转 | 由 BANGUMI_WEB_BASE_URL 配置,默认 https://bgm.tv |
| 图片域名 | 优先使用 bgmimg.anibt.net,失败时由浏览器依次尝试配置镜像、lain.bangumi.one 和原始 URL |
| 缓存文件 | usr/cache/bangumi_activity.json |
| 缓存有效期 | 6 小时 |
调用接口
# 1. 获取最近 10 条收藏
GET /v0/users/xmicrox/collections?limit=10&type=0
# 2. 获取每个收藏的详细信息(含 infobox)
GET /v0/subjects/{subject_id}关键字段说明
| 字段 | 来源 | 说明 |
|---|---|---|
name_cn / name | collections → subject | 中文名优先,fallback 日文名 |
images.common | collections → subject | 封面图 URL |
ep_status | collections | 我的话数进度 |
vol_status | collections | 我的卷数进度 |
rate | collections | 我的评分(0 = 未评) |
rating.score | subjects detail(非 collections) | 网站评分,collections 接口此字段可能为空 |
infobox | subjects detail | 作者/出版社等元数据,值可能为字符串或嵌套数组 |
subject_type 枚举
| 值 | 类型 |
|---|---|
| 1 | 书籍 |
| 2 | 动画 |
| 3 | 音乐 |
| 4 | 游戏 |
| 6 | 三次元 |
infobox 提取逻辑
// 作者/导演:按优先级匹配 key
$authorKeys = ['作者', '原作', '导演', '监督', '原案'];
// 出版/发行:按优先级匹配 key
$publisherKeys = ['出版社', '发行', '动画制作', '制作公司', '发行方', '开发'];
// 注意:value 可能是字符串,也可能是 [{v: '...', k: '...'}] 形式的数组镜像 / 反代配置(2026-06-01)
Bangumi HTTP 通信复用 usr/themes/classic-22/inc/bangumi-client.php:
bangumiRequest()按BANGUMI_API_BASE_URLS顺序请求,某个 API base 网络失败、非 2xx 或 JSON 非法时自动尝试下一个bangumiWebSubjectUrl()用BANGUMI_WEB_BASE_URL生成条目页跳转bangumiImageUrlCandidates()生成图片候选,保留 path/query/fragment 并去重- ACG 卡片加载失败时由浏览器依次尝试候选,全部失败后显示灰色占位块
- 第三方 API 反代会接收 Bangumi token;仅在信任反代提供方时配置到优先级靠前的位置
config.inc.php 示例(不要在维护文档写入真实 token):
define('BANGUMI_API_BASE_URLS', [
'https://api.bangumi.one',
'https://bgmapi.anibt.net',
'https://api.bgm.tv',
]);
define('BANGUMI_WEB_BASE_URL', 'https://bangumi.one');
define('BANGUMI_IMAGE_HOST_MAP', [
'lain.bgm.tv' => 'bgmimg.anibt.net',
'fast.bgm.tv' => 'bgmimg.anibt.net',
]);已知问题 / 注意事项
rating.score必须从/v0/subjects/{id}获取,collections 接口的 subject 对象中该字段有时为空ep_status优先于vol_status展示进度(有 ep_status 则显示话数,否则显示卷数)- 每次刷新数据需删除缓存:
rm {TYPECHO_ROOT}/usr/cache/bangumi_activity.json
模块三:Steam 动态
API 基本信息
| 项目 | 内容 |
|---|---|
| Base URL | https://api.steampowered.com |
| 认证 | Query param ?key=STEAM_API_KEY |
| Key 存放 | config.inc.php → define('STEAM_API_KEY', '...') |
| SteamID | config.inc.php → define('STEAM_ID', '76561198141733567') |
| 缓存文件 | usr/cache/steam_activity.json(+ steam_activity_fallback.json 永久兜底) |
| 缓存有效期 | 6 小时 |
| 缓存写入判据 | 仅当 fetchSteamPlayerSummary() 成功($player !== null)才写入短缓存和 fallback。渲染端硬依赖 player 字段,若以"任一子接口成功"为判据会用 player=null 的半成品污染缓存,导致页面空白持续整个 TTL 周期。详见 00-dev-errors.md E15。 |
fallback 与接口异常边界(2026-07-27)
- Steam fallback 只处理 Steam 上游数据请求失败:
player拉取失败时读取steam_activity_fallback.json - fallback 数据仍需经过
render_steam_content();PHP 渲染异常不能由数据缓存兜底 activity-api.php捕获Throwable,清理输出缓冲并返回 HTTP 500 JSON,避免错误页 HTML 污染接口响应- 前端先读取响应文本再解析 JSON:HTTP/非 JSON 响应显示“服务响应异常”,只有
fetch()本身失败才显示“网络错误” - Steam 离线时间由独立的
formatActivityTimeAgo()格式化,避免删除其他模块时误删共享函数
调用接口
# 1. 玩家基本资料(头像、昵称、在线状态)
GET /ISteamUser/GetPlayerSummaries/v2/?key={key}&steamids={steamid}
# 2. 近期游戏(最多6条,近2周)
GET /IPlayerService/GetRecentlyPlayedGames/v1/?key={key}&steamid={steamid}&count=6
# 3. 徽章(player_level、badge_count)
GET /IPlayerService/GetBadges/v1/?key={key}&steamid={steamid}personastate 枚举
| 值 | 文字 | 颜色 |
|---|---|---|
| 0 | 离线 | #888888 |
| 1 | 在线 | #57cbde |
| 2 | 忙碌 | #f8c84e |
| 3 | 离开 | #f8c84e |
| 4 | 打盹 | #f8c84e |
| 5 | 想交易 | #57cbde |
| 6 | 想游戏 | #57cbde |
游戏封面 URL 拼装
⚠️ 旧格式对 2024 年后上架的新游戏返回 404,当前使用 IStoreBrowseService/GetItems 获取真实 URL。当前实现(2026-04-29 更新):
调用 fetchSteamGameCovers()(activity-functions.php),批量向 api.steampowered.com/IStoreBrowseService/GetItems/v1/ 请求,从 assets.asset_url_format + assets.header 构造完整封面 URL:
CDN = https://shared.akamai.steamstatic.com/store_item_assets/
URL = CDN + asset_url_format.replace("${FILENAME}", assets.header)
示例(新格式):
https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/4126220/8583acfa.../header.jpg?t=1776328466
示例(老格式,无 hash 段):
https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/730/header.jpg?t=1749053861兜底机制(封面 URL 404 时):onerror 将 <img> 替换为带 Steam 品牌渐变(#1b2838 → #2a475e)的 <div class="steam-cover-missing">。
游戏卡片 UI
游戏卡片使用全幅封面图 + 底部半透明渐变遮罩设计:
- 卡片比例固定
aspect-ratio: 460 / 215(Steam header 图标准比例) - 封面图绝对定位铺满卡片(
object-fit: cover) - 游戏名、时长信息通过
position: absolute; bottom: 0+ 渐变背景(rgba(0,0,0,0.75) → transparent)浮于底部 - 无封面时显示深蓝渐变占位(
.steam-cover-missing)
隐私前提
用户 Steam 个人资料及游戏详情须设为公开,否则 GetRecentlyPlayedGames 返回空数组。
验证字段:communityvisibilitystate = 3
页面布局结构
[折叠标题] ▶ 阅读动态 上次更新 2026-07-28 10:00
<div id="weread-content"> ← 骨架屏占位,JS 异步注入
┌─────────┬─────────┬─────────┐
│ 本月阅读 │ 阅读天数 │ 日均阅读 │ ← 三列等权统计
└─────────┴─────────┴─────────┘
┌─────────┬─────────┬─────────┐
│ 最近书籍 │ 最近书籍 │ 最近书籍 │ ← 真实封面 + 阅读进度
└─────────┴─────────┴─────────┘
[折叠标题] ▶ ACG 动态 上次更新 2026-04-29 18:28
<div id="acg-content"> ← 骨架屏占位,JS 异步注入
┌──────────┬──────────┐
│ 卡片1 │ 卡片2 │ ← .activity-grid(2列 Grid)
├──────────┼──────────┤
│ ... │ ... │ ← 10 条收藏
└──────────┴──────────┘
[折叠标题] ▶ Steam 动态 上次更新 2026-04-29 19:10
<div id="steam-content"> ← 骨架屏占位,JS 异步注入
┌─────────────────────────────┐
│ [头像] 昵称 在线状态 Lv.XX│ ← .steam-profile(用户状态卡)
├─────────────────────────────┤
│ 近 2 周游戏 │
│ ┌───────────┬───────────┐ │
│ │ [封面全幅] │ [封面全幅] │ │ ← .steam-games-grid(2列)
│ │ ▓▓▓ 遮罩 │ ▓▓▓ 遮罩 │ │ ← 渐变遮罩覆盖底部
│ │ 游戏名 │ 游戏名 │ │
│ │ 时长 │ 时长 │ │
│ └───────────┴───────────┘ │
└─────────────────────────────┘
响应式断点: 900px(ACG 卡片和 Steam 游戏网格 2列 → 单列)
异步加载架构
页面采用前端 JS 异步加载,消除 API 调用阻塞首屏渲染。
数据流:
浏览器请求 /my_activity
↓(极快,仅输出骨架屏 HTML)
my-activity.php(无 API 调用)
↓ JS fetch(并行)
├── GET /activity-api.php?module=weread → {"html": "..."}
├── GET /activity-api.php?module=bangumi → {"html": "..."}
├── GET /activity-api.php?module=steam → {"html": "..."}
↓ JS 注入 innerHTML
├── #weread-content ← 月度统计 + 最近三本公开书籍
├── #acg-content ← ACG 动态卡片
├── #steam-content ← 用户状态卡 + 游戏网格关键文件:
| 文件 | 职责 |
|---|---|
my-activity.php | 渲染骨架屏页面骨架 + CSS + JS 加载逻辑 |
activity-api.php | JSON API 端点,接收 ?module=weread/bangumi/steam,返回 {"html":"..."} |
usr/themes/classic-22/inc/activity-functions.php | 所有数据函数 + render_bangumi_content() + render_steam_content() |
usr/themes/classic-22/inc/weread-client.php | 微信读书 Gateway 鉴权、批量请求和响应校验 |
usr/themes/classic-22/inc/weread-activity.php | 微信读书数据聚合、缓存和 render_weread_content() |
activity-api.php 响应格式:
// 成功
{ "html": "<div class=\"activity-grid\">...</div>", "updated_at": "2026-04-29 18:28" }
// 失败(非法 module)
HTTP 400 { "error": "invalid module" }
// 失败(模块执行异常)
HTTP 500 { "error": "module unavailable", "message": "服务暂时不可用,请稍后重试。" }updated_at 来源规则:
| 数据来源 | updated_at 取值 |
|---|---|
| 实时拉取成功 | 当前时间 |
| 6 小时短缓存命中 | 短缓存文件的 filemtime |
| Fallback 命中 | Fallback 文件的 filemtime |
注意: 服务器 PHP 时区需设为Asia/Shanghai(在/etc/php/x.x/fpm/php.ini中设置date.timezone = Asia/Shanghai),否则date()返回 UTC 时间。
配置文件(config.inc.php)
// 以下 token 存放在 config.inc.php(已在 .gitignore,不提交 git)
define('BANGUMI_API_TOKEN', '...'); // 用于 ACG 动态模块 API 调用
define('BANGUMI_TOKEN', BANGUMI_API_TOKEN); // 等价别名,供 rating API 体系使用
define('STEAM_API_KEY', '...'); // 用于 Steam 动态模块 API 调用
define('STEAM_ID', '...'); // Steam 用户 64位 ID
define('WEREAD_API_KEY', '...'); // 微信读书个人 API Key
define('WEREAD_SKILL_VERSION', '1.0.4'); // 可选,默认已内置
define('WEREAD_MIN_BOOK_READ_SECONDS', 1800); // 可选,阅读动态展示书籍的最小总阅读时长(秒),默认 1800(30 分钟)常用维护操作
# 清除 Bangumi 缓存(强制下次访问刷新数据)
rm {TYPECHO_ROOT}/usr/cache/bangumi_activity.json
# 清除 Steam 缓存
rm {TYPECHO_ROOT}/usr/cache/steam_activity.json
# 清除微信读书短缓存(强制下次访问刷新)
rm {TYPECHO_ROOT}/usr/cache/weread_activity.json
# 同时清除微信读书永久兜底
rm {TYPECHO_ROOT}/usr/cache/weread_activity_fallback.json
# 清除全部缓存
rm {TYPECHO_ROOT}/usr/cache/*.json
# 查看缓存状态
ls -la {TYPECHO_ROOT}/usr/cache/
# 检查 PHP 错误日志(如果页面异常)
tail -50 /var/log/nginx/error.log后续扩展计划
- [x] 页面保留阅读 + ACG + Steam 三模块异步加载