Skip to main content

Skill 质量检查(skill-quality-check)

· 3 min read
Bowen Zhang
本文作者

检查 skill 目录是否符合上传应用市场的质量标准,兼容输出接近内部评估接口的预检信息(SKILL.md 质量 / 脚本静态分析 / 模拟运行 / 一致性 / 兼容性 / skill 类型 / 沙箱可执行性 / Token 估算 / 安全发现)。 本检查器将 __pycache__ 编译缓存目录及其中的编译产物文件如实判为垃圾文件,不再排除。

检查维度(对齐正式报告)

维度检查内容
SKILL.md 质量目录/SKILL.md 存在、frontmatter 键与 YAML、name(长度/kebab-case/与目录名一致)、description(存在/≤1024/50-500/触发词数量)、正文行数、脚本布局、无不当目录、scripts/ 内容合规、assets/ 内容合规、垃圾/临时文件、根目录非标准文件、路径风格(正斜杠)、引用文件是否存在、引用嵌套深度、目录结构总评
脚本静态分析scripts/ 下每个 .py 脚本:语法正确;存在外部调用(requests/subprocess 等)时必须设置 timeout
模拟运行实际执行每个脚本:--help 正常退出(退出码 0、无堆栈);传入非法参数时优雅报错(退出码非 0、无 Traceback、有错误提示)
一致性代码中的环境变量均在 SKILL.md 文档中提及;requirements.txt 必须存在;所有第三方 import 均在 requirements.txt 中声明
兼容性Python 3.10+ 运行时、Claude Code/OpenClaw 兼容性预检
内部接口预检--json 输出 company_compatible,包括 skill 类型、外部依赖、沙箱可执行性、Token 估算、依赖/环境变量一致性和安全发现

快速开始

# 全量扫描 skills 项目根目录下所有 skill
python scripts/checker.py

# 指定一个或多个 skill 目录逐个检查
python scripts/checker.py cheyun-vehicle-cloud-online-status
python scripts/checker.py ../foo-skill ../bar-skill

# 覆盖扫描根目录、关闭模拟运行、JSON 输出
python scripts/checker.py --root D:/path/to/skills --no-run
python scripts/checker.py --json

--json 输出中的 company_compatible 是本地静态预检,不会访问公司服务端,也不能替代最终上传检测;它的字段和严重级别尽量贴近 AI Market 返回结构。

参数说明

参数说明
dirs要检查的 skill 目录(位置参数,可多个);缺省扫描根目录下所有含 SKILL.md 的目录
--root <dir>覆盖扫描根目录(默认 D:\bowen\project\skills
--no-run关闭「模拟运行」,只做静态检查(零副作用)
--json输出 JSON 结果(供自动化脚本使用)

执行步骤(供 Claude 使用)

1. 确认参数

  • 目标:检查某个具体 skill,还是全量扫描?缺省即全量扫描。
  • 是否要模拟运行:默认开启;若用户担心副作用或目标目录含会真实发送消息/调接口的脚本,可加 --no-run

2. 运行检查器

python scripts/checker.py [skill_dir...] [--no-run]

3. 解读报告

  • 每个 skill 一行 [目录名] 整体得分 xx | 全部通过 / 有 N 项未通过,各维度给出分类得分。
  • [PASS] 通过;[LOW ] 次要问题(如触发词缺失、assets 混入非资源文件);[MED ] 中等问题(如垃圾文件、语法错误、缺超时、依赖未声明)。
  • 有未通过项时,把对应项的 明细 逐条反馈给用户并给出修复建议。

4. 常见修复

问题修复
垃圾/临时文件(明细含 __pycache__ 缓存目录)删除该缓存目录后复查(运行脚本后必现,上传前再清一次;用 rm -rf 删除即可)
scripts/ 内容合规(存在非脚本文件)把模板等移入 scripts/ 下专用的模板子目录,其余非 .py 文件移出 scripts/
description 长度不足 50补充触发词与能力描述,扩展 description
触发词数量检查: 0 个在 description 末尾加 当用户提到"xxx"时触发(引号内为触发词)
存在外部调用但未设置超时给 requests/subprocess 调用补 timeout= 参数
未声明的依赖: xxx在 requirements.txt 补一行 xxx==版本(纯标准库则写 # Pure standard library…
出现堆栈跟踪脚本顶层需 if __name__ == "__main__": 包裹 try/except Exception 优雅退出
引用文件是否存在(正文引用了磁盘上不存在的路径)把正文中反引号内的路径改成真实存在的相对路径,或用描述性措辞,不要写成本就不存在的路径字符串(如示例、通配符、占位目录)

故障排查

问题原因解决
未在 … 下找到含 SKILL.md 的 skill 目录扫描根目录下没有带 SKILL.md 的目录传入 dirs 指定目标,或用 --root 指定正确根目录
模拟运行报错/超时目标脚本依赖特定环境或交互输入--no-run 跳过模拟运行
结果中 __pycache__ 反复出现每次运行脚本都会生成编译缓存上传前删除 scripts/ 下的 __pycache__ 目录,并注意本检查器运行后自身也会生成缓存
Python 3.10 以下依赖 list[str] 等新语法升级到 Python 3.10+

车云:通过群机器人「罗伯特」添加白名单 / 切换环境

· 3 min read
Bowen Zhang
本文作者

通过飞书群聊 @机器人「罗伯特」,发送线上/线下白名单添加切换/查询车辆云端环境等指令。 使用 lark-cli 以用户身份发送,罗伯特才会响应。

环境准备

前置:必须先加入「罗伯特」所在飞书群

使用本 skill 前,必须先进张博文邀请的飞书群,否则无法向群里 @机器人「罗伯特」发消息。

张博文 邀请你加入飞书群,快点击 https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=f61m352d-9482-44f8-9380-70c632fb8b66 加入吧!

  • 若发送时提示无权限 / 找不到群聊(尚未进群),请先通过上方邀请链接加入飞书群,加入成功后再重试;
  • 若不确定是否已进群,可在飞书客户端搜索群名确认。

本地如果没有 lark-cli,先按文档安装:

lark-cli 安装指南

安装后在终端执行 lark-cli --version 确认可用。首次使用或 token 过期时,先执行 lark-cli auth login 重新授权。

命令一览

# 添加白名单(默认线上 + 线下都发送,间隔 1.5s)
python scripts/send.py whitelist <VIN>

# 只添加线上白名单
python scripts/send.py whitelist <VIN> --type online

# 只添加线下白名单
python scripts/send.py whitelist <VIN> --type offline

# 切换车辆云端环境(prod -> testtwo)
python scripts/send.py switch-env <VIN> prod testtwo

# 切换车辆云端环境(testtwo -> prod)
python scripts/send.py switch-env <VIN> testtwo prod

# 查询车辆云端环境
python scripts/send.py query-env <VIN>

# 不真正发送,只打印将生成的命令(用于核对)
python scripts/send.py whitelist <VIN> --dry-run

底层本质

以上命令最终执行的是 lark-cli 发送一条群消息,等价于:

lark-cli im +messages-send --as user --chat-id oc_826d073ba8c9bf029ad38bef60253e9c \
--text '<at user_id="ou_f18b74520bac526a0242f42b615153be">罗伯特</at> 添加线上X01白名单 <VIN>'
部分说明
--as user以当前登录的用户身份发送(必须加,否则以机器人身份发送,罗伯特不会响应)
--chat-id oc_826d073ba8c9bf029ad38bef60253e9c目标群聊 ID(罗伯特所在的群)
<at user_id="ou_f18b74520bac526a0242f42b615153be">罗伯特</at>在群里 @罗伯特
消息内容添加线上/线下X01白名单 + 空格 + VIN;切换车辆云端环境 <VIN> <当前> <目标>查询车辆云端环境 <VIN>

执行步骤(供 Claude 使用)

1. 确认参数

先问用户以下信息,缺什么问什么:

  • 操作类型:添加白名单 / 切换环境 / 查询环境
  • VIN:目标车辆 VIN(必填)
  • 添加白名单需确认是线上、线下还是都要(默认两条都发)
  • 切换环境需确认当前环境 → 目标环境(prod / testtwo)

2. 检查 lark-cli

lark-cli --version

如果未安装,引导用户按上面文档安装;如果报 token/auth 相关错误,先 lark-cli auth login

3. 发送指令

# 白名单(默认线上+线下)
python scripts/send.py whitelist <VIN>

# 切换环境
python scripts/send.py switch-env <VIN> <当前环境> <目标环境>

# 查询环境
python scripts/send.py query-env <VIN>

不确定时先加 --dry-run 核对要发送的内容,确认无误后再去掉发送。

4. 向用户报告结果

  • 发送成功:告知已发送的操作与 VIN,白名单说明线上/线下均已添加,切换/查询说明请求已投递。
  • 发送失败:展示错误信息;token 相关错误引导执行 lark-cli auth login

注意事项(务必遵守)

  • 必须使用 --as user 以用户身份发送,否则罗伯特不会响应机器人消息。
  • 由于飞书平台限制,不能跨 app 给第三方 bot 发 P2P 私聊,必须通过群聊 @ 的方式。
  • 无论什么车型,都按 X01 发送(罗伯特只识别 X01 格式)。
  • 线上和线下白名单都要添加,不能漏掉任何一个;脚本默认分两条发送,间隔 1.5s。
  • VIN 不要加引号
  • 不要在 <at> 标签中的 @ 前加反斜杠:错误写法 <at>...</at> \罗伯特</at> 会导致无法正常 @ 用户。
  • 当前 lark-cli 登录应用为 cli_aaa26a27d8b91bc2,罗伯特属于另一应用 cli_9f7979022e3d500e

故障排查

问题原因解决
发送报「无权限」/「找不到群聊」/「不是群成员」尚未加入目标飞书群提示用户先通过张博文的邀请链接加入飞书群(见「环境准备」),加入后重试
lark-cli 执行失败 / token 相关token 过期 / 未登录执行 lark-cli auth login 重新授权

参数说明

子命令必填参数说明
whitelistVIN添加 X01 白名单;--type online/offline 只发一条
switch-envVIN from to切换云端环境;from 当前环境,to 目标环境
query-envVIN查询车辆云端环境
通用---dry-run 只打印命令不发送

车云车辆云端环境·在线状态查询

· 2 min read
Bowen Zhang
本文作者

📖 不知道怎么获取 x-chj-gwtoken?看这里! 👉 飞书文档 - 获取 x-chj-gwtoken 操作指南(含截图步骤)

查询车辆在云端(testtwo / prod)各域控模块的在线状态策略:先查 testtwo(无需鉴权),若无数据再兜底查 prod(需 x-chj-gwtoken)。

环境信息

环境域名鉴权
testtwo(开发测试)https://ssp-licar-platform-service.testtwo.k8s.chehejia.com无需鉴权
prod(生产)https://ssp-licar-platform-service.prod.k8s.chehejia.com需要 x-chj-gwtoken

域名可通过环境变量覆盖(默认使用上表中的值):

  • VEHICLE_QUERY_TESTTWO_URL:testtwo 环境域名
  • VEHICLE_QUERY_PROD_URL:prod 环境域名

接口说明

GET /api/icn-veh-domains-all?vin={VIN}

返回车辆的 Domain 域控环境配置信息,每个域控模块(5G、fsd-a、hu-f、xcu 等)包含:

  • isConnected:连接状态
  • ip:连接 IP
  • env:所在环境
  • cell:cell
  • latest:最近在线时间(毫秒时间戳)
  • leaveTime:离线时间(如有)

车辆无云端数据时,接口返回 {}(空对象)。

快速开始

# 安装依赖
pip install -r requirements.txt

# 查询(自动:先查 testtwo,无数据再查 prod)
python scripts/query.py <VIN>

# 指定 token 查询 prod(也可在提示时粘贴,会保存供下次使用)
python scripts/query.py <VIN> --token "your-x-chj-gwtoken"

# 只查 testtwo,不兜底查 prod
python scripts/query.py <VIN> --no-fallback

获取 x-chj-gwtoken

不知道怎么获取 x-chj-gwtoken?查看飞书文档 👉 获取 x-chj-gwtoken 操作指南(含截图)

x-chj-gwtoken 有过期时间,如果 prod 查询报 401/403,重新获取一次即可。

执行步骤(供 Claude 使用)

1. 确认参数

先问用户以下信息,缺什么问什么:

  • VIN:要查询的车辆 VIN(必填)

如果用户只给了 VIN,直接进入下一步;token 仅在 testtwo 无数据、需要兜底查 prod 时才需要。

2. 检查 Python 环境

python --version

确认 Python 3.10+ 可用。

3. 安装依赖

pip install -r requirements.txt

4. 运行查询脚本

python scripts/query.py <VIN>
  • 若 testtwo 查到数据:直接展示各域控模块状态,结束。
  • 若 testtwo 无数据:脚本会提示输入 x-chj-gwtoken(或可提前用 --token 传入),再查询 prod。

5. 向用户报告结果

  • 查到数据:列出各域控模块的连接状态、IP、环境、最近在线时间。
  • 未查到:说明 testtwo、prod 均无该车辆的 Domain 数据,可能是车辆未接入云端或 VIN 有误。

参数说明

参数必填说明
VIN目标车辆 VIN(位置参数,17 位)
--tokenx-chj-gwtoken,不传则 testtwo 无数据时交互式输入
--no-fallback只查 testtwo,testtwo 无数据时不查 prod

故障排查

问题原因解决
401 Unauthorizedx-chj-gwtoken 过期重新获取 x-chj-gwtoken
vin 码格式异常VIN 不是 17 位确认 VIN 是否正确
testtwo 与 prod 均无数据车辆未接入云端或 VIN 有误确认 VIN,或到 licar 平台确认车辆状态

🎯 Everything Claude Code(ECC)上手分享:给 Claude Code 装上「AI 操作系统」

· 5 min read
Bowen Zhang
本文作者

一个痛点开场

用 AI 干活干多了,我发现最扎心的不是"它写不出来",而是这三件事:

一是配置天天重来。Claude Code 换了新会话就不认识你;CLAUDE.md、agents、hooks、rules 每个项目都要重新搭一遍,搭完上个项目的经验又带不过来。

二是干完没人把关。让 AI 并行拉起来跑得挺热闹,但「看起来都对」和「真的对」之间差一道独立检核。好几次交付物差点带病出门,就是少了这一道。

三是没有章法。想让 AI 先规划再动手、提交前自查、测试写规范……说一次管一次,换个说法它又忘了。

所以我就在想:能不能把「并行干活、常驻记忆、质量把关、越用越懂我、干活有章法」这几件事,打包成一个开箱即用的东西,不用每次重新解释?

ECC(Everything Claude Code)就是这么长出来的。

一句话说清它是什么

ECC 是一套「AI 编程工具的配置全家桶」——把 67 个专业子智能体、281 个技能、94 个命令、一套工程规约和安全审计,封装成插件,装进 Claude Code 就能直接用。

它不是针对某个功能的增强,而是把 Claude Code 从"一个能写代码的对话窗口",升级成一支配置好的 AI 团队

数据卡片(截至 2026-08,均可复现验证):

维度数值说明
交付规模67 代理 / 281 技能 / 94 命令装完即得,不用自己造
社区规模≈23.8 万 star / 3.6 万 forkGitHub 热榜级开源项目
打磨时长10+ 个月高强日常使用作者真实产品开发里迭代出来
安全审计1282 项测试 / 98% 覆盖 / 102 条规则AgentShield,黑客松产物
语言覆盖12+ 种语言文档含简体中文 README
授权MIT自由使用、可改
背书Anthropic 黑客松获胜者作者团队实战验证

一句话:它解决的不是『AI 写不写得快』,而是『AI 写得稳不稳、有没有人把关、还记不记得你是谁』。

我亲测下来,它到底好在哪

1. 开箱就是一支「AI 团队」🎯

不用自己写 planner、code-reviewer、tdd-guide——这些它都配好了:写大功能 /ecc:plan,改完 /ecc:code-review,构建挂了有 build-error-resolver,C++/Go/Rust 各有专属 reviewer。

一句话:67 个 agent,就是一支现成的、各司其职的「虚拟团队」。

2. 281 个技能 = 各技术栈的最佳实践直接抄

frontend-patternsbackend-patternspython-patternsgolang-patterns……让 AI 输出的不是"能跑",而是符合该语言惯用法的代码tdd-workflow 把"先写测试"变成每次都会执行的流程。

一句话:想少踩坑,就先装一套别人踩完坑沉淀出来的 patterns。

3. 一套规约,让 AI 干活「有章法」

rules/ 里是必须遵守的硬约束:代码风格、git 规范、80% 覆盖率、提交前安全自查。装完之后,不管开哪个项目,AI 都默认按这套标准干活。

一句话:等于把团队的工程纪律,也"配置化"了。

4. 跨会话记忆 + 持续学习:AI 终于记得我

hooks 自动在会话开始加载上下文、会话结束存状态;/instinct-status 能看 AI 学到了我哪些习惯,/evolve 把相关习惯聚合成技能。用久了是真的"越用越懂我"。

一句话:新会话里它不再是个陌生人。

5. AgentShield:给 AI 配置上个「安全锁」🤔

扫描 CLAUDE.md / settings.json / MCP / hooks,查密钥泄露、注入风险、权限过宽。一句话命令就能跑:

npx ecc-agentshield scan

一句话:把 AI 接进生产之前,先让它自己给自己体检一遍。

6. 不止 Claude Code:一套配置,多端通用

Claude Code / Codex / Cursor / OpenCode / Gemini 都能用——skills 和 rules 的资产可以带走,不被某个工具绑定。

怎么装上就用(完整教程)

前置条件

  • Claude Code v2.1.0+(ECC 依赖新版插件钩子机制,太老会踩坑)
claude --version

方式一:插件安装(推荐,2 分钟)

/plugin marketplace add https://github.com/affaan-m/ECC
/plugin install ecc@ecc

⚠️ 早期文档见过 everything-claude-code@everything-claude-code 这种旧标识符,现在统一成 ecc@ecc,以仓库最新 README 为准。

方式二:手动安装(想完全掌握装哪些)

git clone https://github.com/affaan-m/ECC.git && cd ECC
npm install

# ⚠️ 关键一步:rules 不随插件分发,必须手动复制,否则「规约不生效」
mkdir -p ~/.claude/rules
cp -R rules/common ~/.claude/rules/
cp -R rules/typescript ~/.claude/rules/ # 按技术栈追加 python/golang 等

常用命令速查

命令作用
/ecc:plan "需求"实现前先规划拆解
/ecc:code-review代码质量 + 安全检查
/ecc:build-fix一键修构建错误
/security-scanAgentShield 审计配置安全
/skill-create从当前仓库 git 历史生成自己的技能
/instinct-status / /evolve看 AI 学会了什么 / 聚合成技能
/sessions管理会话历史

三个容易踩的坑

  1. 别叠加安装:已用 /plugin install 就不要再跑 install.sh --profile fullnpx ecc-install,会技能重复。
  2. MCP 别一下开太多:工具开关太多会挤爆上下文窗口,每个项目实际启用 < 10 个。
  3. multi- 命令需额外装运行时*:/multi-plan 这类要 npx ccg-workflow 初始化才可用。

适合谁 / 不适合谁

🎯 推荐给:

  • 重度 Claude Code 用户——少写重复配置、让 AI 干活更规范
  • 多语言 / 多项目开发——一套 skills/rules 通用所有技术栈
  • 想把"写代码"外包给 AI、但还想保留工程判断力的人——规划 / 评审 / 安全这些"人的活",它帮你兜底

⚠️ 要谨慎的:

  • Claude Code 新手:67 个 agent + 281 个技能信息量巨大,建议先只装 rules + 几个常用 skill
  • 不喜欢被规约束缚的人:它会"管教"AI 的行为,自由发挥型会觉得被唠叨
  • 只想改一行代码就收工的人:完整规划流程有额外开销

看完立即做(10 分钟落地清单)✅

  1. claude --version 确认 ≥ v2.1.0
  2. /plugin marketplace add https://github.com/affaan-m/ECC + /plugin install ecc@ecc
  3. 手动补 rules(git clone + cp rules/commonrules/typescript~/.claude/rules/
  4. npx ecc-agentshield scan 给现有配置体检一遍
  5. 随手试一下 /ecc:plan "给当前项目加个用户认证"

🚀 装上之后你会发现:AI 干活的方式,从"你要什么我给什么",变成了"我按工程标准帮你把关、把成果交给你验收"。


参考资料:

  • GitHub 仓库:affaan-m/ECC | 官网:ecc.tools
  • 作者 @affaanmustafa 的官方精简指南 / 长文指南 / 安全指南
  • 许可:MIT,可直接用、可改造,记得给个 star

本文为个人亲测分享,所有数字均可到仓库 README 复现验证。


提报人:张博文

在车云平台干测试,这两件"顺手活"最磨人——现在我让一句话把它接走了

· 4 min read
Bowen Zhang
本文作者

一句话摘要: 给 testtwo 测试环境加一辆车、排查某辆车今天有没有连上云端——这两件在车云平台上高频又繁琐的"手工活",被我做成了两个 Claude Code Skill。现在只要跟 Claude 说一句人话,它自己跑去 prod 把车搬过来建好、自己去翻各域控的在线状态。本文是实战记录,也顺带把这两个 Skill 安利给同样在干这件事的同事。


你是否也会被这两件事卡住?

  • 测试环境缺一辆车,得从 prod(licar)把它的基础信息、拓扑、HU 配置字、设备、展示信息一个个查出来、再一个个建回去,一不留神漏一项,等到联调才发现。
  • 有人问"这辆车怎么不在线上?"你第一反应是"它在哪个环境?域控连上没?"——然后得去好几个平台翻接口,把 5G、HU、座舱这些模块挨个对状态。
  • 这些活有规则、重复、纯属"会,但很浪费时间",还特别容易因为手抖出错。

这两个 Skill 就是这么长出来的:把有规则但容易出错的车云操作,变成一句话就能触发的能力。


一、两个 Skill 各管什么

Skill一句话典型场景
cheyun-vehicle-sync-prod-to-testtwo从 prod(licar)把车一键迁移到 testtwo 测试环境测试环境加车、环境间数据同步、测试用车准备
cheyun-vehicle-cloud-online-status查车辆在云端的各域控模块在线状态排查"车不在线"、确认车辆在哪个环境、域控是否掉线

两个都发布于我们内部的 AI Market,质量评级 A,均已通过安全认证,创建者:张博文。

按场景触发一句话,例如: 「把车 XX 同步到 testtwo」 / 「查一下这辆车在线吗」


二、场景拆解:从"半小时手工活"到"一句话"

场景 A:给 testtwo 测试环境加车

以前:先拿 VIN 去 prod 查车辆基础信息(车型系列、型号、用途)→ 再查配置字 → 再查设备(SN、ICCID)→ 然后到 testtwo 依次建基础信息、同步拓扑、创建 HU 配置字、绑定设备、更新展示信息。中间任何一个接口断了、字段记岔了,就得从头对。

现在python scripts/sync.py <VIN>,或者在 Claude/LiClaw 里说一句"把这两辆车同步到 testtwo 测试环境"。Skill 自己完成 8 步全流程:基础信息 → 拓扑 → HU 功能配置字 → 设备绑定 → 展示信息更新 → 验证结果,最后给你一份迁移摘要。

场景 B:查某辆车今天在不在线上

以前:问"车在哪个环境"——测试环境和生产环境是两套平台,得先猜一个去翻,找不到再换另一个。确认了环境还要挨个看 5G 模块、HU、ADAS 这些域控到底连没连上、有没有掉线时间。

现在python scripts/query.py <VIN>,或者直接问"查下这几辆车的在线状态"。Skill 的策略是 先查 testtwo(免鉴权)→ 没有再兜底查 prod,一次返回每个域控模块(5G、fsd-a、hu-f、xcu 等)的连接状态、IP、所在环境、最近在线时间,一眼定位问题出在哪个域控、哪个环境。


三、它们是怎么干活的

两个 Skill 本质是轻量 Python 脚本 + Claude 编排,核心都是一条原则:优先走接口,能免鉴权就免鉴权。

  • sync(prod → testtwo):只有 prod 读取需要 x-chj-gwtokentesttwo 写入侧根本不需要鉴权。流程是"先查原车 → 再逐项还原创建",最终落脚在纯接口操作,不依赖浏览器点击。
  • online-status(在线状态):默认 GET /api/icn-veh-domains-all?vin={VIN},先查免鉴权的 testtwo,接口返回空({})才降级去 prod,避免为了看个状态还要先找 token。想只查测试环境、不落生产,加一个 --no-fallback 即可。

设计取舍:免鉴权优先。日常 80% 的排查其实在 testtwo 就能拿到答案;只有确需生产数据时才要求 token,且 token 由你本地提供、不写入任何仓库。


四、怎么装上,怎么用起来

安装(三步)

  1. 打开 AI Market 里的 Skill 详情页;
  2. 点右上角 复制安装命令
  3. 通过飞书发给 OpenClaw / ClaudeCode 机器人,等它装完即可。

也可以直接在详情页 一键安装进 LiClaw,或 下载 Skill 本地放入 ~/.claude/skills

环境要求:Python 3.10+,pip install requests 一个依赖,其余零外部服务。

使用(说人话就行)

需求可以这么说
测试环境加车 / 环境间搬数据"迁移车辆"、"把车从 prod 同步到 testtwo"、"测试环境加车"
排查在线状态 / 定位环境"查询车辆在线状态"、"查车在哪个环境"、"查下这辆车的域控在线情况"

五、提示与避坑

  • x-chj-gwtoken 有过期时间:迁移过程中报 401/403,多半是它到期了,重新获取一份即可(获取方式见 Skill 文档页的飞书指引)。
  • 先测 testtwo 再谈 prod:Online-status 默认先查 testtwo,能省掉大部门鉴权和找 token 的流程。
  • 验证要闭环:sync 每步都做结果确认,最后回读一把,确认车辆在 testtwo 真的"建起来了"而不是接口"假装成功"。
  • 它是工具不是魔法:Skill 负责按规则把活干完,但"这台车该不该上测试环境""这个域控掉线是不是符合预期"仍然要人拍板——AI 省掉的是搬砖,不是判断。

六、下一步还能怎么演进

  • 批量:sync 现在是单 VIN,改成读一个 VIN 列表文件、批量迁移 + 汇总报告;
  • 看板化:online-status 顺手产出一张"哪些车在线/离线"的小结表,适合每次例会前刷一遍;
  • 权限收敛:token 支持从环境变量读取,集中管理,避免散落。

结语

做这两个 Skill 的最大体会是:把有规则、重复、容易错的车云操作交给 Claude,人只留判断。 环境加车、在线排查这类活,不该每天靠人肉去磨。

如果你也常跟 testtwo / prod / 车辆数据打交道,欢迎装上试试:

  • 🚗 车辆迁移(prod → testtwo):https://ai-market.chehejia.com/?page=skills&skill=8hqeaasmwadzjadjk5ti
  • 📡 云端在线状态查询:https://ai-market.chehejia.com/?page=skills&skill=vlddgr6bn7iih5x6uz7d

有新的车云场景想工具化,或者用起来有报错,欢迎在评论区或话题群里聊聊。

English Read

· 6 min read
Bowen Zhang
本文作者

A full-stack EPUB reader: look up words while reading, review with Ebbinghaus intervals (1→30 days), plus study stats, leaderboards, and an English / Chinese UI.

Try it online: https://english-read.bitbw.top/


Overview

English Read is a Next.js 15 full-stack web application combining an online EPUB reader with an SRS (Spaced Repetition System) vocabulary learning workflow. Upload EPUBs or browse a shared public library, look up words while reading, and review them with an Ebbinghaus-curve-based flashcard system.

Features

  • EPUB Reader — paginated reading, font size control, reading progress auto-saved
  • Personal Library & Public Library — upload to your shelf; browse and add books from a shared public catalog
  • Vocabulary — collect unknown words from the reader; optional review plan view
  • Spaced Repetition Review — Ebbinghaus intervals: 1d → 2d → 4d → 7d → 15d → 30d → mastered; phrase (multi-word) review distractors can use Vercel AI Gateway when configured; single-word paths may not need it
  • Study Statistics — reading time, average speed (wpm), words reviewed, errors, and review time; bar charts plus daily breakdown; filter by last 7 / 14 / 30 days or a custom range (learning timezone)
  • Leaderboard — community popular public books; reading, review, and overall user rankings (time, speed, streak, study score, etc.); opt out of user boards in Settings
  • Dictionary & Translation — English definitions (Free Dictionary API) + Chinese translation (MyMemory); optional Google Cloud Translation fallback; cached 24 h
  • Authentication — GitHub and Google OAuth; email/password; phone OTP (Aliyun SMS) via NextAuth v5 with JWT sessions
  • Internationalization — English / Chinese UI (next-intl, cookie-based, URL unchanged)
  • Dark Mode — system-aware theme toggle
  • Observability — Sentry error monitoring; optional PostHog and Vercel Analytics

Tech Stack

LayerTechnology
FrameworkNext.js 15 + React 19 (App Router)
LanguageTypeScript (strict)
UIshadcn/ui v4 (@base-ui/react), Tailwind CSS v4
AuthNextAuth v5 (OAuth + Credentials + phone OTP), JWT sessions
DatabaseDrizzle ORM + Neon serverless PostgreSQL
File StorageVercel Blob
i18nnext-intl ^4.9.1
EPUB Engineepubjs
Errors@sentry/nextjs
AnalyticsOptional: PostHog (posthog-js), @vercel/analytics

Getting Started

Prerequisites

  • Node.js 18+ (Node 20 LTS recommended for local dev and production)
  • npm — this repo ships package-lock.json; npm is the documented package manager (yarn/pnpm work only if you align lockfiles yourself)

Installation

git clone <repo-url>
cd english-read
npm install

Environment Variables

Create .env.local in the project root. Minimum for local auth + DB + uploads:

AUTH_SECRET=
AUTH_URL=http://localhost:5000
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
POSTGRES_URL= # Neon pooled connection
POSTGRES_URL_NON_POOLING= # Neon direct connection (used by drizzle-kit)
BLOB_READ_WRITE_TOKEN= # Vercel Blob

Generate AUTH_SECRET with npx auth secret.

Optional integrations (SMS login, AI Gateway for phrase review distractors, translation fallback, analytics) are documented in [.env.example](#).

Database Setup

npx drizzle-kit generate # Generate migration files from schema
npm run db:migrate # Apply migrations (alias for drizzle-kit migrate)
# or: npx drizzle-kit migrate
npx drizzle-kit studio # Open Drizzle Studio (DB browser)

Development

npm run dev # Dev server at http://localhost:5000
npm run build # Production build (includes ESLint)
npm run start # Start production server after build
npm run lint # ESLint only
npx tsc --noEmit # TypeScript check only

Project Structure

src/
├── app/
│ ├── (app)/ # Authenticated shell (Sidebar + Topbar)
│ │ ├── dashboard/
│ │ ├── dashboard/stats/ # Study statistics
│ │ ├── leaderboard/ # Community & user rankings
│ │ ├── library/ # Personal books + upload
│ │ ├── library/store/ # Public library browse & contribute
│ │ ├── vocabulary/ # Word list
│ │ ├── vocabulary/review/ # SRS flashcards
│ │ ├── vocabulary/plan/ # Review planning
│ │ ├── read/[bookId]/ # Full-screen EPUB reader
│ │ └── settings/
│ ├── (auth)/ # login, signup, error (no app shell)
│ ├── api/
│ └── dev/ # Internal/dev-only pages (optional)
├── components/
│ ├── reader/
│ └── ui/
├── lib/
│ ├── db/ # Drizzle schema, migrations, db client
│ ├── srs.ts # Spaced repetition intervals
│ ├── blob.ts # Vercel Blob uploads
│ ├── auth.ts # NextAuth config
│ ├── review-quiz.ts # Review / distractor logic (example)
│ └── … # reading-time, phone-auth, dictionary helpers, etc.
├── i18n/
└── middleware.ts
messages/
├── en.json
└── zh.json

The src/lib/ listing above is not exhaustive — browse the folder for the full set of helpers (e.g. reading-time.ts, phone-auth.ts, aliyun-dypns.ts).

Architecture Notes

Authentication

middleware.ts protects /dashboard, /leaderboard, /library, /read, /vocabulary, and /settings. The app session is JWT (session.strategy: "jwt"); the sessions table remains for the Drizzle adapter / OAuth linking, not as the primary per-request session store. Providers include GitHub, Google, email/password (hashed with bcrypt), and phone OTP (requires Aliyun env vars). Logged-in users hitting /login or /signup are redirected to /dashboard.

EPUB Reader

EpubReader is dynamically imported with { ssr: false }. Paginated flow with pixel dimensions from getBoundingClientRect(); touch swipe is registered on each iframe view.window for multi-iframe layouts.

Spaced Repetition

Stages: 0→1d, 1→2d, 2→4d, 3→7d, 4→15d, 5→30d, 6+→mastered. “Forgot” resets to stage 0. The review queue uses vocabulary.nextReviewAt ≤ now.

UI Components

shadcn/ui v4 on @base-ui/react (not Radix):

  • No asChild — use render={<Link href="..." />}
  • In Server Components, import buttonVariants from @/components/ui/button-variants

Deployment guide

Hosting target is Vercel. Recommended flow: create Storage → register OAuth apps → copy env into .env.local → run npm run db:migrate once against Neon → connect the GitHub repo and deploy. Migration SQL lives in src/lib/db/migrations/ ([drizzle.config.ts](#) uses POSTGRES_URL_NON_POOLING). Optional env keys are listed in [.env.example](#).

1. Create Vercel Storage

Open the Vercel Dashboard → select or create your project → Storage, then provision:

Postgres (Neon)

FieldValue
TypePostgres
PurposeApp data: users, books, vocabulary, reviews, etc.
Auto-injected envPOSTGRES_URL, POSTGRES_URL_NON_POOLING

Link the database to your Vercel project so these variables appear under Settings → Environment Variables (or copy them from the Storage UI for local .env.local).

Blob

FieldValue
TypeBlob
Suggested nameenglish-read-epub
PurposeUploaded EPUB files
Auto-injected envBLOB_READ_WRITE_TOKEN

2. Create OAuth applications

Google

  1. Open Google Cloud Console.

  2. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client IDs.

  3. Application type: Web application.

  4. Under Authorized redirect URIs, add both local and production callbacks:

    http://localhost:5000/api/auth/callback/google
    https://your-domain.vercel.app/api/auth/callback/google
  5. Save and copy Client ID and Client Secret.

GitHub

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Local development:
    • Homepage URL: http://localhost:5000
    • Authorization callback URL: http://localhost:5000/api/auth/callback/github
  3. Production: use a separate OAuth app or change the callback to https://your-domain.vercel.app/api/auth/callback/github.
  4. Copy Client ID and generate a Client Secret.

3. Configure .env.local

In the project root, create .env.local (same keys as Vercel Production unless you use Preview-specific values):

# Auth.js
AUTH_SECRET= # run: npx auth secret
AUTH_URL=http://localhost:5000

# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# GitHub OAuth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

# Neon — from Vercel Storage / project env
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=

# Vercel Blob
BLOB_READ_WRITE_TOKEN=

Paste the output of npx auth secret into AUTH_SECRET.

4. Apply database migrations

[drizzle.config.ts](#) reads .env.local (via @next/env) and applies migrations from src/lib/db/migrations/ using POSTGRES_URL_NON_POOLING.

  • First deploy / empty database: with POSTGRES_URL_NON_POOLING set (local file pointing at your Neon DB), run:

    npm run db:migrate

    Do not run npx drizzle-kit generate on the server unless you are changing the schema—committed migration files are the source of truth.

  • When you change src/lib/db/schema.ts locally: run npx drizzle-kit generate, commit the new files under src/lib/db/migrations/, deploy, then run npm run db:migrate against each environment that should pick up the change.

Optional: npx drizzle-kit studio opens Drizzle Studio against the same connection.

5. Run locally

npm run dev

Open http://localhost:5000.

6. Deploy on Vercel

Option A — Git integration (recommended)
Import the GitHub repository in Vercel, select the framework (Next.js), add Environment Variables for Production (and Preview if needed), then push to main (or your production branch). Build command is npm run build by default.

Option B — Vercel CLI

npm i -g vercel # if needed
vercel # preview
vercel --prod # production

7. After deployment

  1. Environment variables — Mirror .env.local in Vercel → SettingsEnvironment Variables. Set AUTH_URL to your production site origin (e.g. https://your-domain.vercel.app), not http://localhost:5000.

  2. OAuth — Add production redirect URIs in Google Cloud Console and GitHub OAuth App settings (see §2).

  3. Optional product features — Names and comments match [.env.example](#). Where to obtain:

    VariablesPurposeWhere to obtain
    AI_GATEWAY_API_KEYPhrase (multi-word) review distractors via GET /api/review/similar-words (required for that branch; single-word review may use other sources)Vercel DashboardAIAI Gateway → API keys · Vercel AI Gateway
    GOOGLE_TRANSLATE_API_KEYMachine translation fallback for /api/dictionaryGoogle Cloud Console → enable Cloud Translation APIAPIs & ServicesCredentials → Create API key
    NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOSTPostHog client analyticsPostHogProject settingsProject API Key; host = your region’s ingestion URL (regions, e.g. https://us.i.posthog.com)
    ALIBABA_CLOUD_ACCESS_KEY_ID, ALIBABA_CLOUD_ACCESS_KEY_SECRET, ALIYUN_SMS_SIGN_NAME, ALIYUN_SMS_TEMPLATE_CODE, …Aliyun SMS OTP (DYPNS) loginAccessKey: RAM; SMS / phone verification: 号码认证控制台 (sign & template); RAM needs dypns:SendSmsVerifyCode / CheckSmsVerifyCode; notes in [docs/阿里云/](#)
    SENTRY_AUTH_TOKENUpload source maps during next build (clearer stacks)sentry.io → your organization → SettingsDeveloper SettingsAuth Tokens · Next.js source maps

    Sentry runtime — DSN is embedded; errors are reported when NODE_ENV === "production". SENTRY_AUTH_TOKEN is only required if you want source maps uploaded on Vercel builds.

8. Database tables (overview)

TablePurpose
usersUser profiles (Auth.js); email, phone, password hash
accountsOAuth provider links (Auth.js)
sessionsAuth.js Drizzle adapter table; app uses JWT for the live session, not DB-backed sessions per request
verification_tokensEmail verification tokens (Auth.js)
public_library_booksShared catalog entries
booksPersonal shelf; EPUB blob URL and reading progress
reading_daily_timePer-day reading time (seconds)
vocabularySaved words and SRS schedule
review_logsEach review outcome (remembered / forgotten)

DesktopMobile

next-serverless

· 6 min read
Bowen Zhang
本文作者

基于 Next.js 15 + TypeScript 构建的 Serverless API 服务,集成 NeonDB(PostgreSQL)、Pusher 实时推送、Vercel Blob 文件存储和飞书通知。

快速开始

npm run dev
# or
pnpm dev

访问 http://localhost:3000 查看首页。

环境变量

DATABASE_URL= # NeonDB PostgreSQL 连接字符串(pooled,推荐用于大多数场景)
DATABASE_URL_UNPOOLED= # NeonDB PostgreSQL 连接字符串(不经 pgbouncer,用于需要直连的场景)
BLOB_READ_WRITE_TOKEN= # Vercel Blob 读写 Token

# Pusher(服务端触发事件;前端只需 key + cluster,见下文「前端接入 Pusher」)
PUSHER_APP_ID= # Pusher App ID
PUSHER_KEY= # 前端订阅用公钥(可暴露到浏览器)
PUSHER_SECRET= # 仅服务端,切勿写入前端
PUSHER_CLUSTER= # 例如 ap3

前端接入 Pusher(与本项目配合)

本仓库在 PUT /api/generic/update 更新成功且表名在服务端白名单内时,会调用 pusher.trigger 推送事件。前端使用与后端同一 Pusher 应用key + cluster 即可订阅(不要PUSHER_SECRET 放到前端)。

约定(与 lib/pusher.ts 一致)

项目说明
Channel 名与数据库表名相同(例如 FuxiKuangBiao
事件名当前为 updated(仅 update 接口会触发)
白名单ENABLED_TABLES 中的表会推送;默认包含 FuxiKuangBiao。其它表不会收到事件
Payload{ id, tableName, ...本次 PUT 请求体里除 id/tableName 外的更新字段 }

POST /api/generic/createDELETE /api/generic/delete 内 Pusher 调用当前为注释状态,创建/删除不会推送

安装依赖(前端项目)

npm install pusher-js
# 或 pnpm / yarn 等价安装

最小示例(浏览器 / Vue / React 均可)

YOUR_PUSHER_KEYYOUR_CLUSTER 换成与本服务环境变量 PUSHER_KEYPUSHER_CLUSTER 相同的值(与 lib/pusher.ts 中配置一致)。

import Pusher from 'pusher-js';

const pusher = new Pusher('YOUR_PUSHER_KEY', {
cluster: 'YOUR_CLUSTER',
forceTLS: true,
});

// 与操作的表名一致;仅白名单表在更新时会有事件
const channel = pusher.subscribe('FuxiKuangBiao');

channel.bind('updated', (data: Record<string, unknown>) => {
// data 含 id、tableName 及更新的字段,可据此刷新列表或合并状态
console.log('row updated', data);
});

// 组件卸载时取消订阅,避免泄漏
// channel.unbind_all();
// pusher.unsubscribe('FuxiKuangBiao');

联调注意

  1. 同一应用:前端 key/cluster 必须与部署本 API 时使用的 Pusher 应用一致。
  2. 公开 Channel:当前为公共 channel(表名字符串)。若改为 private- / presence- 前缀,需在服务端配置 Channel authorization,本仓库未内置该接口。
  3. 扩展更多表:在 lib/pusher.tsENABLED_TABLES 中加入表名并重新部署后,对该表执行 PUT /api/generic/update 才会向同名 channel 发 updated

API 接口文档

通用 CRUD 接口 (/api/generic)

这组接口支持对任意数据库表进行增删改查,通过 tableName 参数指定操作的表(默认为 FuxiData)。


POST /api/generic/create — 创建记录

请求体

{
"tableName": "FuxiData",
"field1": "value1",
"field2": "value2"
}
字段类型必填说明
tableNamestring表名,默认 FuxiData
其他字段any写入数据库的字段和值

响应

{
"success": true,
"message": "Record created successfully",
"data": { "id": 1, "field1": "value1", "field2": "value2" }
}

错误码: 400 无字段 / 500 数据库错误


DELETE /api/generic/delete — 删除记录

支持 URL 查询参数或 JSON 请求体两种方式传参。

URL 参数方式

DELETE /api/generic/delete?id=1&tableName=FuxiData

请求体方式

{
"id": 1,
"tableName": "FuxiData"
}
字段类型必填说明
idstring | number记录 ID
tableNamestring表名,默认 FuxiData

响应

{
"success": true,
"message": "Record deleted successfully",
"data": { "id": 1, "field1": "value1" }
}

错误码: 400 缺少 id / 404 记录不存在 / 500 数据库错误


GET /api/generic/query — 查询记录

支持 GET(URL 参数)和 POST(请求体)两种方式。

GET 请求

GET /api/generic/query?tableName=FuxiData&orderBy=id&order=DESC&limit=10&offset=0

POST 请求体

{
"tableName": "FuxiData",
"filters": [
{ "field": "type", "operator": "=", "value": "sensor" },
{ "field": "name", "operator": "ILIKE", "value": "%test%" }
],
"logic": "AND",
"orderBy": "id",
"order": "DESC",
"limit": 10,
"offset": 0
}

参数说明

参数类型必填说明
tableNamestring表名,默认 FuxiData
filtersFilter[]过滤条件数组(GET 时传 JSON 字符串)
logicAND | OR多条件逻辑关系,默认 AND
orderBystring排序字段
orderASC | DESC排序方向,默认 DESC
limitnumber每页条数
offsetnumber偏移量

Filter 结构

{
field: string;
operator: '=' | '<>' | '>' | '>=' | '<' | '<=' | 'LIKE' | 'ILIKE' | 'NOT LIKE' | 'IN' | 'IS NULL' | 'IS NOT NULL';
value: any;
}

响应

{
"success": true,
"data": [...],
"pagination": {
"total": 100,
"limit": 10,
"offset": 0,
"hasMore": true
}
}

错误码: 400 filters 格式错误 / 500 数据库错误


PUT /api/generic/update — 更新记录

请求体

{
"id": 1,
"tableName": "FuxiData",
"field1": "new_value"
}
字段类型必填说明
idstring | number记录 ID
tableNamestring表名,默认 FuxiData
其他字段any要更新的字段和新值

响应

{
"success": true,
"message": "Record updated successfully",
"data": { "id": 1, "field1": "new_value" }
}

错误码: 400 缺少 id 或无更新字段 / 404 记录不存在 / 500 数据库错误

Pusher:对 lib/pusher.tsENABLED_TABLES 白名单内的表,更新成功后会向 channel = 表名 触发事件 updated。前端订阅方式见上文「前端接入 Pusher(与本项目配合)」。


FuxiData 专用接口 (/api/fuxi-data)

这组接口专门操作 FuxiData 表,该表结构为:id(自增主键)、data(JSON 字段)、type(类型标签)、time(东八区时间戳)。


POST /api/fuxi-data/save-data — 保存数据

请求体

{
"data": { "key": "value", "nested": { "foo": "bar" } },
"type": "sensor"
}
字段类型必填说明
dataany要保存的 JSON 数据对象
typestring数据类型标签,默认 default

响应

{
"success": true,
"message": "Data saved successfully",
"data": {
"id": 123,
"data": { "key": "value" },
"type": "sensor",
"time": "2024-03-31T08:30:00.000Z"
}
}

错误码: 400 缺少 data / 500 数据库错误


GET /api/fuxi-data/get-data — 获取数据

三种查询模式,优先级依次为:按 id 查单条 > 按 type 查列表 > 分页查摘要。

按 id 查询(返回完整记录)

GET /api/fuxi-data/get-data?id=123

按 type 查询(支持时间范围)

GET /api/fuxi-data/get-data?type=sensor&startTime=2024-01-01&endTime=2024-12-31

分页查摘要(仅返回 id、time、type)

GET /api/fuxi-data/get-data?limit=10&offset=0

参数说明

参数类型说明
idstring记录 ID,提供时直接返回单条
typestring数据类型,提供时返回该类型所有记录
startTimestring时间范围开始(与 type 配合使用)
endTimestring时间范围结束(与 type 配合使用)
limitstring分页大小,默认 10,最大 100
offsetstring分页偏移,默认 0

错误码: 404 按 id 查询时记录不存在 / 500 数据库错误


PUT /api/fuxi-data/update-data — 更新数据

请求体

{
"id": 123,
"data": { "key": "updated_value" },
"type": "new_type"
}
字段类型必填说明
idstring | number记录 ID
dataany新的 JSON 数据
typestring更新数据类型,不提供则保留原值

time 字段自动更新为东八区当前时间。

响应

{
"success": true,
"message": "Data updated successfully",
"data": {
"id": 123,
"data": { "key": "updated_value" },
"type": "new_type",
"time": "2024-03-31T09:00:00.000Z"
}
}

错误码: 400 缺少 id 或 data / 404 记录不存在 / 500 数据库错误


GET /api/fuxi-data/list-tables — 列出数据库表

列出数据库 public schema 下的所有表及其元信息。

GET /api/fuxi-data/list-tables

响应

{
"success": true,
"message": "Tables retrieved successfully",
"data": {
"totalTables": 2,
"tables": [
{
"schemaname": "public",
"tablename": "FuxiData",
"tableowner": "postgres",
"hasindexes": true,
"hasrules": false,
"hastriggers": false,
"rowsecurity": false,
"rowCount": 1000
}
]
}
}

文件上传接口 (/api/blob)


POST /api/blob/client-upload — 客户端文件上传

基于 Vercel Blob 的客户端直传接口。

请求体: HandleUploadBody(由 @vercel/blob/client 客户端 SDK 自动处理)

限制

项目限制
支持格式PNG、JPEG、WebP
最大文件大小15 MB

响应

{
"type": "success",
"blob": {
"pathname": "/my-file-abc123.png",
"contentType": "image/png",
"url": "https://...",
"downloadUrl": "https://..."
}
}

客户端用法(示例)

import { upload } from '@vercel/blob/client';

const blob = await upload('my-file.png', file, {
access: 'public',
handleUploadUrl: '/api/blob/client-upload',
});

错误码: 400 上传失败


Webhook 接口 (/api/webhooks)


POST /api/webhooks/sentry-feishu — Sentry 错误转飞书通知

接收 Sentry Webhook 事件,格式化后自动发送飞书富文本卡片消息。

Sentry 配置: 在 Sentry 项目设置中将 Webhook URL 设置为该接口地址。

请求体(由 Sentry 自动发送)

{
"data": {
"event": {
"title": "TypeError: Cannot read property 'foo' of undefined",
"datetime": "2024-03-31T08:00:00Z",
"tags": [["device", "desktop"], ["os", "Windows 10"], ["browser", "Chrome 122"]],
"url": "https://your-app.com/page",
"web_url": "https://sentry.io/organizations/xxx/issues/yyy/"
}
}
}

飞书卡片内容

  • 错误发生时间
  • 环境标签(device / os / browser)
  • 错误页面 URL
  • 错误标题
  • 跳转到 Sentry 详情的按钮

响应

{
"message": "ok",
"data": { "code": 0, "msg": "ok" },
"status": 200,
"ok": true
}

错误码: 500 Webhook 处理失败


测试接口


GET /api/hello — Hello 测试

GET /api/hello?name=World

响应

{ "message": "Hello World!" }

技术栈

技术版本用途
Next.js15框架
TypeScript5类型检查
NeonDB1.0.1PostgreSQL 无服务器数据库
Pusher5.2.0实时事件推送
Vercel Blob2.3.2文件存储
TailwindCSS4样式

利用博客园的MetaWeblog协议+nodejs同步自建博客中的md文件

· 4 min read
Bowen Zhang
本文作者

背景

因为一直在使用 hexo 自建博客,最近又切换到了 docusaurus ,但是又想同时发布到博客园,所以需要一个工具能将 md 文件直接发布到博客园,所以写了一个 node 自动化上传脚本 ,同时方便需要的人借鉴使用(2023年2月更新) 下面简单描述下 MetaWeblog 协议的使用

博客园的 MetaWeblog 协议的使用

原资料地址

背景资料地址:https://www.cnblogs.com/caipeiyu/p/5354341.html

想实现自己的文章一处编写,多处发布到各大平台(比如博客园,CSDN)等要怎么实现呢。需要由这些组成:

  1. 文章管理:一个管理文章知识的平台(网站),在这里撰写,编辑文章。比如:写博客的客户端软件,博客园等。
  2. 第三方网站(平台)具有开放的 API 接口,比如博客园的 metaWebBlog。
  3. 同步服务:读取文章,调开放的 API,将文章发布出去。

一般来说,写文章的软件很容易获得,如果目标平台再有开放接口,我们可以将文章通过接口进行发布。

博客园支持 metaWebBlog 接口,使得可以接收来自 接口 的文章

1. metaWebBlog 概述

MetaWeblog API(MWA)是一个 Blog 程序接口标准。通过 MetaWeblog API,博客平台可以对外公布 blog 提供的服务,从而允许外面的程序新建,编辑,删除,发布 bolg。

MetaWeblog 使用 xml-RPC 作为通讯协议。

XML-RPC 是一个远程过程调用(远端程序呼叫)(remote procedure call,RPC)的分布式计算协议,通过 XML 将调用函数封装,并使用 HTTP 协议作为传送机制。一个 XML-RPC 消息就是一个请求体为 xml 的 http-post 请求,被调用的方法在服务器端执行并将执行结果以 xml 格式编码后返回。

简单理解就是:在 HTTP 请求 中,发送 xml 格式描述的“调用指令”,如果调用成功,会收到 xml 格式描述的“执行结果”。

2. 博客园文章相关接口

  • blogger.getUsersBlogs —— 获取用户博客信息
  • metaWeblog.getRecentPosts —— 获取最近的文章
  • metaWeblog.getPost —— 获取文章内容
  • metaWeblog.newPost —— 添加文章
  • metaWeblog.editPost —— 编辑文章
  • blogger.deletePost —— 删除文章

还有一些关于 文章分类 的接口,可以在其接口文档中找到。

2.1 接口说明

在 博客园 设置页面的地步可以找到 API 接口的说明,类似这样:

https://rpc.cnblogs.com/metaweblog/{userName}

上面的 {userName} 替换成实际的用户名。

下文仅说明“请求的接口和参数”,响应内容在发送成功后一看便知。

2.2 发送方式

使用 nodejs 完成文件读写和接口调用

资料

index.js


/*
* @Description: 批量将hexo中的md文件上传博客园
* @Autor: Bowen
* @Date: 2021-10-09 16:56:43
* @LastEditors: Bowen
* @LastEditTime: 2023-01-11 10:09:47
*/

const fs = require("fs").promises;
const path = require("path");
const crypto = require("crypto");
const matter = require("gray-matter");
const { newPost, editPost } = require("./api");

// 发布所有的文章
async function handleAllPushPost(dirPath) {
let files = await fs.readdir(dirPath);
for (const fileName of files) {
const filePath = path.resolve(dirPath, fileName);
// dir 继续递归
let stats = await fs.stat(filePath);
if (stats.isDirectory()) {
await handleAllPushPost(filePath);
continue;
}
console.log("[********************************]");
console.log("[fileName]", fileName);
// await new Promise((r) => setTimeout(r, 1000), true);
await handlePushPost(filePath);
}
}

// 根据path修改或者新建文章
async function handlePushPost(filePath) {
const fileName = path.basename(filePath);
// 解析 md 文件
const grayMatterFile = matter.read(filePath);
const { data, content } = grayMatterFile;
if (!data || !data.title) return;
// 获取当前哈希值 对比 之前的 哈希
const hash = crypto.createHash("sha256");
hash.update(content);
let nowContentHash = hash.digest("hex");
let { cnblogs, hash: contentHash } = data;
if (contentHash && contentHash == nowContentHash) {
console.log("[hash值未变退出当前循环]");
return;
}
// yaml中添加 hash
data.hash = nowContentHash;
// 文章数据
const categories = Array.isArray(data.tags) ? data.tags : [];
// TODO: data.? 看自己的 md 文档是如何配置
const post = {
description: content,
title: data.title,
// 注意 要以 Markdown 格式发布 必须在 categories 中 添加 "[Markdown]"
categories: ["[Markdown]"].concat(categories),
};
let res;
// 编辑
if (cnblogs && cnblogs.postid) {
console.log("[-------------修改-------------]");
try {
res = await editPost(cnblogs.postid, post, true);
} catch (error) {
console.log("[修改失败]", error.message);
throw Error(error.message);
}
console.log("[修改成功]", res);
} else {
console.log("[-------------新建-------------]");
data.cnblogs = {};
try {
res = await newPost(post, true);
} catch (error) {
console.log("[上传失败]", error.message);
throw Error(error.message);
}
console.log("[上传成功]", res);
// yaml中添加 postid
data.cnblogs.postid = res;
}
// 回写数据
const str = grayMatterFile.stringify();
await fs.writeFile(filePath, str);
console.log("[回写成功]", fileName);
// 等待 1分钟 后继续下一个
await new Promise((r) => setTimeout(r, 3500, true));
}

(async () => {
await handleAllPushPost("C:/bowen/product/new-blog/docs");
// await handleAllPushPost("C:/bowen/product/new-blog/blog");
})();


api.js

const MetaWeblog = require("metaweblog-api");
const apiUrl = "https://rpc.cnblogs.com/metaweblog/username"; // use your blog API instead
const metaWeblog = new MetaWeblog(apiUrl);

const username ="username"
const password ="password || token"
const blogid ='blogid' // 通过 getUsersBlogs 查询
const appKey =''
const numberOfPosts =1

module.exports = {
getUsersBlogs:()=> metaWeblog.getUsersBlogs(appKey, username, password),
getRecentPosts:()=> metaWeblog.getRecentPosts(blogid, username, password, numberOfPosts),
getCategories:()=> metaWeblog.getCategories(blogid, username, password),
getPost:(postid)=> metaWeblog.getPost(postid, username, password),
newPost: (post, publish)=> metaWeblog.newPost(blogid, username, password, post, publish),
editPost: ( postid ,post, publish)=> metaWeblog.editPost(postid, username, password, post, publish),
deletePost: ()=> metaWeblog.deletePost(appKey, postid, username, password, publish),
}