从入门到进阶:封装一个 Skill¶
Skill 不是插件,也不是一段提示词:一个目录 + 一份
SKILL.md,把每次都要重新交代的流程、口径、脚本与模板,打包成可被自动触发的能力。
元数据常驻上下文,正文触发时才加载,脚本与资料按需取用 —— 这套 渐进式披露 决定了 Skill 该写多少、该把什么拆出去。
1. 先看结构:三层加载¶
| 层级 | 内容 | 何时进入上下文 | 体量 |
|---|---|---|---|
| L1 元数据 | name + description |
常驻,决定要不要触发 | 约 100 词 |
| L2 正文 | SKILL.md 指令 |
技能被触发时 | 建议 < 5k 词 |
| L3 资源 | scripts/ references/ assets/ |
按需加载或直接执行 | 不限 |
为什么分层
元数据回答「用不用」,正文回答「怎么用」,资源只在真正需要时取用。把几万字的 API 文档塞进 SKILL.md,等于每次触发都先付一遍上下文成本。
skill-name/
├── SKILL.md # 必需:YAML frontmatter + Markdown 指令
├── scripts/ # 可选:确定性脚本,可直接执行,不必读入上下文
├── references/ # 可选:按需读入的 schema / API / 规范
└── assets/ # 可选:产出物要用的模板、图片、字体
2. 入门实例:20 行的最小 Skill¶
场景:每次写周报都要重复交代字段、百分比、金额口径。把它固化成 weekly-report-style:
---
name: weekly-report-style
description: 统一周报的输出口径与字段格式,用于生成或整理项目周报、复盘周报、数据周报。当用户要求「写周报」「整理本周进展」「汇总回收率数据」时使用。关键词:周报、进展同步、复盘、回收率、口径。
agent_created: true
---
# 周报口径
## Overview
把周报固定为「进展 / 数值 / 风险 / 下一步」四段,统一百分比与金额写法。
## 输出规则
- 百分比保留 1 位小数并标注升降方向;涨用红色语义、跌用绿色语义。
- 金额统一用 ¥ 千分位,不写币种缩写。
- 每条进展必须带一个可核对的数值;没有数值就写「待补数据」。
- 不写套话,不推测未给出的数据。
## 工作流程
1. 确认周期与数据范围。
2. 按四段输出,数值只取自用户材料,缺失字段显式标注。
3. 输出前逐条核对数值与单位。
| 字段 | 必填 | 作用 | 约束 |
|---|---|---|---|
name |
是 | 唯一标识,须与目录同名 | hyphen-case:小写字母 / 数字 / 连字符,≤ 40 字符,不以 - 开头结尾、不含 -- |
description |
是 | 唯一的触发依据 | 第三人称;写清「做什么 + 何时用 + 用户会说的词」;不含尖括号 |
agent_created |
是(本机约定) | 允许后续用技能管理命令修改 / 删除 | true |
description 是触发器,不是简介
写「周报相关」既会在无关任务里误触发,也常在真正需要时不触发。把用户可能说出口的原话写进去。
| 反例 | 正例 |
|---|---|
处理周报 |
统一周报口径……当用户要求「写周报」「整理本周进展」时使用。关键词:周报、复盘、口径。 |
This skill helps with data |
写明具体场景、文件类型、触发词,并用第三人称描述 |
3. 入门实例:四步做出第一个 Skill¶
S=/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/resources/plugins/workbuddy-builtin/skills/skill-creator/scripts
# 1. 生成骨架(同时创建 scripts/ references/ assets/ 三个示例文件)
python3 "$S/init_skill.py" weekly-report-style --path ~/.workbuddy/skills
# 2. 编辑 SKILL.md,并删除用不到的示例文件(此处应全部删掉,只留 SKILL.md)
# 3. 校验结构
python3 "$S/quick_validate.py" ~/.workbuddy/skills/weekly-report-style
# 4. 打包分发(先自动校验,再产出 zip)
python3 "$S/package_skill.py" ~/.workbuddy/skills/weekly-report-style ./dist
| 步骤 | 脚本 | 产出 / 检查 |
|---|---|---|
| 1 初始化 | init_skill.py |
目录 + 模板 SKILL.md + 3 个示例文件 |
| 2 编辑 | — | 补全 description、删 TODO、删多余目录 |
| 3 校验 | quick_validate.py |
SKILL.md 存在、frontmatter 完整、name 合规、description 无尖括号 |
| 4 打包 | package_skill.py |
weekly-report-style.zip,zip 内保留技能目录层级 |
~/.workbuddy/skills/<name>/:跨所有项目可用,适合个人口径、别名映射、常用流程。范围不明确时一律选它。
<workspace>/.workbuddy/skills/<name>/:随仓库走,适合团队约定、项目 schema、共用工作流。
本机实测:--path 必须显式给
skill-creator 的说明把 --path 写成「可省略、默认用户级」,但本机 init_skill.py 的 main() 要求参数 ≥ 4 个且第 3 个必须是 --path;省略会直接打印用法并退出。上面四步命令已在 /tmp 实测通过:初始化 → Skill is valid! → demo-skill.zip。
4. 进阶实例¶
4.1 实例一:scripts/ 把确定性逻辑固化¶
真实案例 ~/.workbuddy/skills/bilibili-video-info/:解析 B 站分享文本 / 短链,输出标题、时间、UP 主、规范 URL。
bilibili-video-info/
├── SKILL.md
├── scripts/
│ ├── resolve_bili.py # 302 跳转、BV 提取、元数据格式化
│ └── uploader_aliases.json # UP 主名称映射(数据与逻辑分离)
└── references/
└── bilibili_api.md # 接口字段、响应示例、错误码
使用时不重写代码,直接执行脚本,把用户原文整段传进去:
| 手动执行脚本 | |
|---|---|
SKILL.md 只写「何时用、怎么调、失败怎么降级」,例如:
优先执行 `scripts/resolve_bili.py`,把用户原文整段作为位置参数传入,不需要手工清洗;脚本自动跟随 302 跳转、提取 BV 号、读取 `scripts/uploader_aliases.json` 完成名称映射。字段映射与响应示例见 `references/bilibili_api.md`。
| 做法 | 原因 |
|---|---|
| 脚本可直接执行,不必读入上下文 | 省 token、结果确定、可复测 |
| 映射表 / 配置与脚本逻辑分离 | 改数据不改代码;同一条规则只存在一处 |
降级路径写进 SKILL.md |
本机直连 API 常被拦截,固定走「WebFetch 取 JSON → 喂回脚本」 |
| 缺失字段显式标注「未获取」 | 防止模型凭猜测补齐 |
bilibili-video-info SKILL源码
---
name: bilibili-video-info
description: 把 B 站视频分享文本、b23.tv 短链、完整视频 URL 或 BV 号解析成规范视频信息,输出视频标题、发布时间与视频时长、UP 主名称,以及带 BV 号的规范视频 URL。当用户要求「提取 BV 号」「解析 B 站短链」「查这个视频的标题/UP 主/发布时间」,或需要把 B 站视频整理进文档、表格、Markdown 卡片时使用。关键词:B站、哔哩哔哩、bilibili、b23.tv、BV号、视频号、UP主、视频信息。
agent_created: true
---
# B 站视频信息解析
## Overview
把一个 B 站分享链接(常带「【标题-哔哩哔哩】」前缀)、`b23.tv` 短链、完整视频 URL 或裸 BV 号,转换成固定四要素:**视频标题、时间(发布时间 + 时长)、UP 主、带 BV 号的规范 URL**。该技能同时负责剥离短链跳转后附带的分享追踪参数、应用用户指定的 UP 主名称映射,输出可直接写入文档或表格的干净链接。
## 何时使用
- 用户给出 B 站链接并要求提取 BV 号 / 视频号。
- 用户询问某个 B 站视频的标题、UP 主、发布时间、时长。
- 需要把 B 站视频批量整理成清单、表格或文档卡片,且需要统一链接格式与 UP 主 tag。
- 用户给出的是 `b23.tv`、`bilibili.com/video/...?spmid=...` 等带参数的链接。
## 工作流程
### Step 1 — 解析链接并把整段分享文本交给脚本
优先执行 `scripts/resolve_bili.py`,把用户原文(含标题前缀、多余参数)**整段**作为位置参数传入,不需要手工清洗:
```bash
python3 ~/.workbuddy/skills/bilibili-video-info/scripts/resolve_bili.py "【手指发力技术教学-哔哩哔哩】 https://b23.tv/HlWsuVM"
```
脚本行为:正则命中裸 BV 号则直接采用;否则取原文中的 URL,用 `curl -L -w '%{url_effective}'` 跟随 302 跳转,从最终地址里提取 BV 号,并生成 `https://www.bilibili.com/video/<BV>`。加 `--json` 可输出机器可读结果。
### Step 2 — 补全元数据(标题 / 时间 / UP 主)
脚本会尝试请求公开接口 `https://api.bilibili.com/x/web-interface/view?bvid=<BV>`。若输出中出现「[元数据未获取]」,执行降级流程(本机常见,见「常见坑」):
1. 用 **WebFetch** 打开输出里给出的 API 地址(注意:是 API 地址,不是视频页面 —— 视频页面通常只能拿到标题,拿不到 UP 主)。
2. 把 WebFetch 返回的 JSON 原文喂回脚本的 `--from-json`:
```bash
# 方式 A:管道传入 stdin
printf '%s' '<WebFetch 返回的 JSON 原文>' | python3 ~/.workbuddy/skills/bilibili-video-info/scripts/resolve_bili.py --from-json -
# 方式 B:先把 JSON 存成文件
python3 ~/.workbuddy/skills/bilibili-video-info/scripts/resolve_bili.py --from-json meta.json
```
脚本能容忍 Markdown 代码块包裹、前后多余文字,会自动定位第一个 JSON 对象并读取 `data`。字段映射与响应示例见 `references/bilibili_api.md`。
### Step 3 — 按固定字段输出结果
始终输出以下字段,缺失字段显式标注「未获取」,不要凭猜测补写:
| 字段 | 说明 |
|---|---|
| 视频标题 | `data.title` |
| 发布时间 | `data.pubdate`(Unix 秒 → UTC+8,`YYYY-MM-DD HH:MM:SS`) |
| 视频时长 | `data.duration`(秒 → `mm:ss`,超过 1 小时用 `h:mm:ss`) |
| UP 主 | `data.owner.name` **经名称映射后**的写法,可附 `data.owner.mid` |
| 规范 URL | `https://www.bilibili.com/video/<BV>`,不含任何查询参数 |
| BV 号 | 一并给出,便于文档引用 |
「时间」同时给发布时间与时长两项,避免歧义。用户只要其中一项时,按需省略另一项。
### Step 4 — UP 主名称映射(强制)
输出视频信息、写入文档卡片或任何面向用户的文本时,**直接把下列 B 站账号名替换为指定写法**,不要保留原名、不要在括号里附原名、不要额外解释:
| B 站账号名 | 输出写法 |
|---|---|
| `羽球小宋老师` | `小宋老师` |
| `广益羽球` | `黄广益` |
映射表存于 `scripts/uploader_aliases.json`,脚本会自动读取并应用,因此脚本输出本身就是映射后的名字,直接拿来用即可。新增或修改映射时只编辑该 JSON,不要改动脚本逻辑,也不要在 SKILL.md 里另写一份规则。
### Step 5 — 写入文档时(可选)
需要把视频加进 Zensical / MkDocs 文档时,使用统一的卡片格式,UP 主名保持与站内已有 tag 一致(映射后的写法即站内约定写法):
```markdown
- :fontawesome-brands-bilibili:{ .bl } [视频标题](https://www.bilibili.com/video/<BV>) `UP主`{.md-tag}
---
**动作要点**:
```
「动作要点」在没看过视频内容时留空,不要编造。
## 常见坑
- `b23.tv/<id>` 是 302 短链,跳转后的地址带 `buvid` / `spmid` / `timestamp` / `unique_k` / `up_id` 等分享追踪参数;**只保留 BV 号**,其余一律丢弃。
- BV 号格式为 `BV` + 10 位字母数字(共 12 字符),正则用 `BV[0-9A-Za-z]{10}`。
- 本机 `curl` 直连 `api.bilibili.com` / `www.bilibili.com` 可能被网络策略拦截(空响应或 `SSL_ERROR_SYSCALL`),但 `b23.tv` 可通、WebFetch 可通 API;因此 Step 2 的降级流程是常规路径而非异常,遇到即按流程走,不要反复重试直连。
- `data.pubdate` 是 Unix 秒,`data.duration` 是秒;竖屏短视频(`is_story: true`)同样有这两个字段。
- B 站 API 返回 `code != 0`(如 `-404` 视频不存在)时,脚本会判定元数据缺失,此时如实告知用户,不要编造标题或 UP 主。
- B 站账号名与站内 tag 写法常常不同;一律按 Step 4 的映射表输出,不要临时判断或保留原始账号名。
## Resources
- `scripts/resolve_bili.py` — 链接解析、BV 提取、元数据获取与格式化输出(可直接执行,无需读入上下文)。
- `scripts/uploader_aliases.json` — 用户指定的 UP 主名称映射表,脚本自动读取;新增映射只改此文件。
- `references/bilibili_api.md` — B 站公开接口字段映射、响应示例、错误码与网络降级说明。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
# B 站视频信息接口参考
## 公开接口
```
GET https://api.bilibili.com/x/web-interface/view?bvid=<BV号>
```
也可用 `?aid=<aid>`。无需登录态即可读取公开视频的基本信息。请求时建议带:
```
User-Agent: Mozilla/5.0
Referer: https://www.bilibili.com/
```
## 字段映射
| 输出字段 | JSON 路径 | 类型 / 说明 |
|---|---|---|
| BV 号 | `data.bvid` | 字符串,`BV` + 10 位字符 |
| 视频标题 | `data.title` | 字符串 |
| 发布时间 | `data.pubdate` | Unix 秒(东八区展示用 `UTC+8`) |
| 视频时长 | `data.duration` | 秒(整数) |
| UP 主 | `data.owner.name` | 字符串 |
| UP 主 ID | `data.owner.mid` | 整数 |
| 封面 | `data.pic` | URL |
| 简介 | `data.desc` | 字符串 |
| 分 P 数 | `data.videos` | 整数;多 P 时各 P 信息在 `data.pages[]` |
| 是否竖屏短视频 | `data.is_story` | 布尔;不影响上述字段 |
顶层 `code == 0` 表示成功;`code` 为 `-404`(视频不存在)、`-403`(权限不足)、`62002`(稿件不可见)等时,`data` 为空或不存在,必须如实报告。
## 精简响应示例
```json
{
"code": 0,
"message": "OK",
"data": {
"bvid": "BV1grWu6zEZ7",
"title": "手指发力技术教学",
"pubdate": 1794315600,
"duration": 101,
"desc": "手指发力技术教学",
"is_story": true,
"owner": { "mid": 695949856, "name": "广益羽球" },
"pages": [ { "page": 1, "part": "手指发力技术教学", "duration": 101 } ]
}
}
```
对应输出:标题「手指发力技术教学」、发布时间 `2026-11-10 21:00:00`(UTC+8)、时长 `01:41`、UP 主「广益羽球」、规范 URL `https://www.bilibili.com/video/BV1grWu6zEZ7`。
## 短链与链接形态
| 输入形态 | 处理方式 |
|---|---|
| `【标题-哔哩哔哩】 https://b23.tv/HlWsuVM` | 正则取 URL → 跟随 302 → 提取 BV |
| `https://b23.tv/HlWsuVM` | 同上 |
| `https://www.bilibili.com/video/BV1grWu6zEZ7?spmid=...` | 直接正则命中 BV,丢弃查询串 |
| `BV1grWu6zEZ7` | 直接采用 |
| `https://bilibili.com/video/av<aid>` | 无 BV 号时用 API 的 `?aid=<aid>` 换取 `data.bvid` |
`b23.tv` 跳转后的完整地址示例(参数必须全部丢弃):
```
https://www.bilibili.com/video/BV1grWu6zEZ7?buvid=...&is_story_h5=false&p=1&share_source=COPY&spmid=...×tamp=...&unique_k=HlWsuVM&up_id=695949856
```
## 网络降级
本机直连 `api.bilibili.com` 与 `www.bilibili.com` 常失败(`SSL_ERROR_SYSCALL` 或 0 字节响应),而 `b23.tv` 短链解析正常。此时改用 WebFetch 打开 API 地址获取 JSON,再通过脚本 `--from-json -` 格式化。WebFetch 打开视频页面通常只能拿到 `<title>`,无法拿到 UP 主,因此务必使用 API 地址。
脚本 `--from-json` 会跳过首个 `{` 之前的所有内容,可容忍 Markdown 代码块围栏与前后说明文字。
4.2 实例二:references/ 与 assets/ 的取舍¶
| 目录 | 放什么 | 判据 | 别放 |
|---|---|---|---|
scripts/ |
可执行的 Python / JS / Shell | 同一段代码被反复重写,或需要确定性 | 纯说明文档 |
references/ |
schema、API 文档、规范、政策 | 执行时才需要查的知识 | 每次都必读的核心流程 |
assets/ |
pptx 模板、HTML 骨架、图片、字体 | 会被复制进最终产物 | 需要「读进上下文」的说明 |
大文件与去重
单份 reference 超过约 10k 词时,在 SKILL.md 里写明 grep 搜索模式,让执行方按需定位片段。同一条信息只在 SKILL.md 或 references/ 出现一次:正文留「何时用 + 怎么用」,细节放参考资料,避免两处不一致。
更完整的组合案例 ~/.workbuddy/skills/italent-attendance-capture/:
italent-attendance-capture/
├── SKILL.md
├── config.json
├── scripts/
│ ├── capture.js # 浏览器抓取、逐条截图、去重
│ └── excel_report.py # 加班台账写入与汇总
└── references/
└── workflow.md # 参数优先级、长区间耗时、降级说明
node ~/.workbuddy/skills/italent-attendance-capture/scripts/capture.js \
--start 2026-11-01 --end 2026-11-06 --out ./attendance-shots
关键数值:30 条/页分批、18:00 判定加班、captured.json 记录已抓日期实现幂等、stdout 返回摘要 JSON。这类「多脚本 + 状态文件 + 参考文档」的结构,正是从单文件 Skill 升级到可长期运行工具的分界线。
italent-attendance-capture SKILL源码
---
name: italent-attendance-capture
description: 抓取北森 iTalent「我的假勤 → 我的出勤」考勤详情并逐条截图归档。支持自定义考勤日期起止范围、按 30 条/页分批、实打卡晚于 18:00 自动在文件名追加「加班」、把每天加班分钟数追加写入 Excel 台账并返回累计加班分钟数、已抓取日期自动跳过不重复截图。当用户要求抓取出勤截图、考勤打卡记录、加班时长统计、加班分钟数、补抓某段日期的考勤时使用。
agent_created: true
---
# iTalent 出勤详情截图与加班台账
## Overview
自动登录 iTalent,进入「我的假勤 → 我的出勤」,按指定「考勤日期」区间逐条打开考勤详情页并整页截图。
截图以考勤日期命名;若详情页「实打卡」时间晚于阈值(默认 18:00),文件名追加「加班」。
已抓取过的日期记录在输出目录的 `captured.json`,重复运行默认跳过。
每次运行都会把全部已抓日期的**加班分钟数**同步进 Excel 台账(新日期追加、已有日期更新、按日期排序),并返回累计与本次加班分钟数。
## 使用方式
直接运行脚本,无需手工逐步操作浏览器:
```bash
node ~/.workbuddy/skills/italent-attendance-capture/scripts/capture.js \
--start 2026-11-01 --end 2026-11-06 \
--out /Users/yumm/Documents/hxworkhard/attendance-shots
```
运行前确认 `agent-browser` 可用(WorkBuddy 的 agent-browser skill 会自动安装),以及 Python 3 已装 `openpyxl`。
脚本会在独立的 `--session italent-attendance-capture` 会话中启动浏览器,结束时自动关闭。
> 大区间(如跨 3 个月)因列表虚拟渲染需逐日补漏,约 12–15 秒/天。想更快可按自然月分多次运行,
> 台账会自动累加;最后再用完整区间跑一次做零遗漏校验(此时全部日期命中去重,只做台账同步)。
> 详见 `references/workflow.md` 的「长区间耗时」。
### 参数
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `--start YYYY-MM-DD` | 考勤日期起始 | 当月 1 日 |
| `--end YYYY-MM-DD` | 考勤日期结束 | 今天 |
| `--out DIR` | 截图输出目录 | `./attendance-shots` |
| `--excel PATH` | 加班时长 Excel 路径 | `<out>/加班时长统计.xlsx` |
| `--overtime-after HH:MM` | 加班判定与时长起算阈值,严格「晚于」该时刻 | `18:00` |
| `--page-size N` | 每批处理条数 | `30` |
| `--force` | 忽略去重记录,强制重新截图 | 关闭 |
| `--user` / `--password` | 覆盖账号密码 | 读 `config.json` |
| `--help` | 打印参数说明 | — |
账号密码优先级:命令行 > 环境变量 `ITALENT_USER` / `ITALENT_PASSWORD` > `config.json`(已预置用户提供的账号)。
`config.json` 为明文凭据,仅限本机使用;换账号时编辑该文件即可。
### 输出
- `<日期>.png` / `<日期>加班.png`:详情页整页截图
- `加班时长统计.xlsx`:每日加班分钟数台账,末行为「合计」
- `captured.json`:已抓取日期 → 文件名、是否加班、实打卡时间、抓取时间
- `last-run.json`:本次运行摘要(总条数、批次数、新增、跳过、错误、加班分钟数)
- stdout:同样的摘要 JSON,关键字段 `overtimeMinutesTotal`、`overtimeMinutesInRange`、`overtimeMinutesThisRun`、`dailyOvertime`、`excel`
## 加班时长与 Excel 台账
- **每日加班分钟数** = 当天最晚「实打卡」时间 − 阈值(默认 `18:00`);早于阈值记为 0,无打卡记录记为 0。
例:`08:46 / 18:50` → 50 分钟;`08:46 / 20:03` → 123 分钟。`18:00` 整不算加班。
- Excel 列:`考勤日期 / 星期 / 最早实打卡 / 最晚实打卡 / 加班判定 / 加班分钟数 / 截图文件 / 更新时间`,
加班行高亮,表头冻结并带筛选,末行「合计」为累计分钟数。
- **追加写入**:每次运行以 `captured.json` 全量为准同步 Excel,先读入已有行再按日期合并
(已有日期更新、新日期追加),最后按日期升序重写。因此后续抓取其他时期只会累加新日期,
不会丢历史、也不会产生重复行;即使某次运行全部日期都因去重被跳过,台账仍会同步更新。
- 返回值:`overtimeMinutesTotal`(台账累计分钟数)、`overtimeMinutesInRange`(本次查询区间分钟数)、
`overtimeMinutesThisRun`(本次新抓日期分钟数)、`dailyOvertime`(每日明细),
日志中同时打印「累计 X 分钟(Y 小时 Z 分钟)」。
- Excel 由脚本维护;手工在表内加列/改结构可能在下次同步时被覆盖。
## 去重规则
每次运行前读取 `<out>/captured.json`。若某日期已有记录且对应截图文件仍存在,则跳过;
文件被删除或指定 `--force` 时会重新抓取,并清理同日期的旧命名文件后再写入。
因此多次运行不同区间(例如先抓 11-01~11-06,再抓 10 月)只会补齐新增日期。
## 关键行为说明
- 「实打卡」取自详情页「打卡信息」表格的 `实打卡` 列;任一时间严格晚于阈值即判定加班。
- 当结果超过一页时页面会出现「N条/页」下拉(15 / 30 / 60 / 100)与「共N页」,脚本会自动切换为 `--page-size 30`。
表格是虚拟渲染,一页内 DOM 通常只保留约 12 行;脚本先抓取当前可见行,再对区间内其余日期用**单日筛选**(把起止都设为当天,列表只剩 1 条)逐日补漏,确保一条不漏。
- 日期区间通过「考勤日期」筛选面板设置并点击「确定」,脚本会校验筛选标签是否变为目标区间,避免抓到全量历史数据。
支持任意起始日期(不限于当月 1 日);两端点都需修改时,每次标记日历输入框必须使用唯一临时 id,且按端点索引取对应输入框。
- 每条详情在新标签页打开,截完即关闭,回到列表页继续下一条。
## 故障排查与页面结构
页面 DOM 选择器与日期面板的已知陷阱(值相同不触发 React 更新、日历面板遮挡确定按钮、
重复临时 id 导致改错端点、eval 返回需解两层 JSON)记录在 `references/workflow.md`。
若 iTalent 改版导致脚本失败,先查阅该文件再调整脚本。
## 资源
- `scripts/capture.js`:完整自动化脚本(Node.js,零额外 npm 依赖)
- `scripts/excel_report.py`:Excel 台账创建/追加(Python 3 + openpyxl)
- `config.json`:账号密码与默认阈值
- `references/workflow.md`:页面结构与排障笔记
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | |
# iTalent 出勤抓取:页面结构与排障笔记
供维护 `scripts/capture.js` 时参考。所有选择器均在 2026-11-06 于 macOS + Chrome 157 实测。
## 导航链路
1. `https://www.italent.cn/portal/iTalentHome` 未登录时跳转 `/Login?ReturnUrl=...`。
2. 登录页(账号登录 tab 默认选中):
- 账号 `#form-item-account`,密码 `#form-item-password`
- 协议复选框 `.phoenix-checkbox__input`(**必须勾选**)
- 登录按钮:`.phoenix-button__content` 文本为「登录」的元素,需点击其 `parentElement`
(`.phoenix-button__wraper--primary`)。注意 `.phoenix-button__content` 有两个,
另一个是隐藏的「获取验证码」,直接 `click .phoenix-button__content` 会点错。
3. 首页「员工自助」应用宫格中「我的假勤」为 `.menu-name`,需同时调用
`el.click()` 与其 `parentElement.click()`(只点父元素不生效),随后跳转「我的月报」页。
4. 左侧二级菜单「我的出勤」为 `a.list-2nd-a`(文本精确匹配),点击其 `parentElement`(`li.list-2nd-li`)。
落地 URL 含 `viewName=Attendance.AttendanceDataRecordNavView`。
5. 列表行日期链接:`a.saveHashHistory`,文本形如 `2026-11-05 星期四`。点击后打开**新标签页**,
详情页 URL 含 `cloud.italent.cn/attendance?...&_qpt=detailpage`,含记录 id 与一次性签名,
因此不能预先构造 URL,必须从列表点击进入。
6. **连续多次独立运行时导航会偶发失败**(2026-11-09 抓 1–5 月时,按自然月分 5 段跑,
第 3/4/5 段连续报「未找到「我的出勤」菜单项」):登录已成功,但首页应用宫格尚未渲染完,
或跳转停在「我的月报」中间页,导致 `a.list-2nd-a` 查不到。原实现一次找不到就抛错。
现 `navigateToAttendance()` 以 URL 是否含 `AttendanceDataRecordNavView` 为准做最多 **6 轮**校准:
每轮尝试点「我的假勤」入口、再点侧栏「我的出勤」,落地即返回;单次找不到不再直接失败。
另有一种更顽固的状态:卡在带 `_userSpecialType=1` 的首页、宫格完全不渲染,反复点击都无效
(2026-11-09 抓 2025 上半年时探测阶段 4 轮重试仍失败)。因此第 3 轮会先 `reload` 当前页
再继续;正式 5 段运行中该 reload 被触发 1 次并成功恢复,5 段与完整区间收尾全部 EXIT 0。
另外,分段运行中若某段整体失败,最后的**完整区间运行会自动兜底补漏**(未抓日期仍会被
逐日筛选补上),因此不会丢数据,只是耗时变长。
7. **登录也会偶发失败**(2026-11-09 抓 2025 下半年,7 段中第 2 段报「仍停留在登录页」):
登录按钮点击过早或跳转延迟,6 秒后 URL 仍在 `/Login`。现 `login()` 最多重试 3 次,
每轮重新填账号密码、勾选协议、点登录并复查 URL;同时 `check` 复选框加了容错。
注意该加固上线后各段均一次登录成功,重试逻辑尚未被真实触发过,但无回归。
## 考勤日期筛选(最容易出错的环节)
- 筛选标签容器:`.search-form-field.date-range-search`,点击其内部 `.field-left` 展开 popover。
- popover 内两个输入框:`.ant-calendar-range-picker`(起始 / 结束)。
- 点击某个 range-picker 后,出现对应日历面板,面板内输入框为 `.ant-calendar-input`。
**最后出现的 `.ant-calendar-input` 就是当前编辑的那个端点**(点起始时只有 1 个,点结束时 2 个)。
- 在该输入框 `fill` 完整日期 `YYYY-MM-DD` 后按 `Enter`,即可同时完成月份跳转与赋值。
- **陷阱 1:值相同不生效。** 若输入框当前值已等于目标值,React 不更新内部 state,
后续切换端点时该端点会变成空,提交后得到 `~ 2026-11-06` 这类半截区间,查询出全量历史数据(887 条)。
解决:先填一个不同的「哨兵日期」(目标日期 −1 天)+ Enter,再重新聚焦端点填目标值 + Enter,
并在每一步后校验 `.ant-calendar-range-picker` 的值。
- **陷阱 2:日历面板遮挡「确定」按钮。** 面板浮在按钮上方,真实鼠标点击会落到日历
(曾误触年份选择面板)。解决:用 DOM `click()` 派发到 `span.base-btn-title`(文本「确定」)
本身,事件冒泡到绑定 onClick 的 `span.base-bg-ripple`,绕过遮挡。
点击最外层 `div.base-button-component` 无效(React 只从 target 向上找 handler)。
- **陷阱 3:两端点都要改时,临时 id 必须唯一。** 点击第二个 picker 后 DOM 里会同时存在两个
`.ant-calendar-input`(起始日历与结束日历,后者值形如 `2026-11-30`)。若两次都给「最后一个」
加同一个 `id="tmp-cal-input"`,`fill #tmp-cal-input` 会命中第一个(起始),
表现为「想改结束日期却把起始日期改掉」,最终区间校准失败。
解决:每次标记用递增唯一 id(如 `tmp-cal-1`、`tmp-cal-2`),并按返回的 id 执行 fill。
此问题仅在起始日期不等于默认「当月 1 日」、两个端点都需要修改时暴露。
- 提交后校验 `span.search-field-value` 文本是否为 `start ~ end`,否则报错中止。
## 详情页「实打卡」
- 结构:`[role="grid"]` → `[role="row"]`。表头 `[role="columnheader"]` 顺序为
`["", "应打卡", "实打卡", "缺勤时长", ...]`;数据行 `[role="gridcell"]` 与表头下标对齐,
`实打卡` 列下标 2。典型一天两行(上班 09:00 / 下班 18:00 应打卡)。
- 只在包含「应打卡」+「实打卡」表头的那个 grid 内取值,避免误取「打卡数据」表格的同下标列。
- 示例:`2026-11-05` 实打卡为 `08:46` 与 `18:50` → 晚于 18:00 → 文件名追加「加班」。
`2026-11-06` 只有 `08:46`(下班尚未打卡)→ 不加。
- 兜底:若表格结构变化取不到值,截取 `打卡信息` 与 `打卡数据` 之间的文本,
用正则提取 `YYYY-MM-DD HH:MM(:SS)`。
## 分页与虚拟表格(2026-11-09 实测修正)
- **结果少于一页时**(如 6 条)分页控件隐藏,曾误判为「无每页条数控件」。
结果超过一页时,列表底部出现:`共24条 / 下一页 / 页码 / 上一页 / 15条/页 / 共2页`。
- 「15条/页」是 `span.drop-down-btn-title`,点击其父元素内的箭头
`[class*=arrowdown]` / `.drop-down-btn-svg` 展开菜单,选项为 `.dropDownEllipsis`
(15 / 30 / 60 / 100 条/页)。脚本 `trySetPageSize()` 会切到 30 条/页。
- 表格是 **虚拟渲染**(`.pps-virtual-table` / `.fixedDataTableLayout_rowsContainer`),
一页内 DOM 只保留约 12 行。`scrollTop` 调整、原生滚轮、WheelEvent 派发、调高 viewport
均无法让它渲染更多行(实测)。选 30 条/页后变成 1 页,但仍只有 12 行可见。
- 因此脚本采用两阶段:`listDates()` 抓当前可见行;对 `eachDay(start,end)` 中未出现在可见行里的日期,
用 `setDateRange(d, d)` 做**单日筛选**(列表只剩 1 条,必然可点)逐日补漏。
这样 24 条记录可完整抓取,不受分页/虚拟渲染影响。
- 单日筛选时日历面板会残留上一次的 `.ant-calendar-input`(可能同时有 2 个),
因此 `markCalendarInput(index)` 必须按 `ins[min(index, ins.length-1)]` 取对应端点,
且每次用唯一 id;否则「改起始」会误改到结束端点。
端点被组件清空时 `setDateRange()` 的 4 轮校准会自动补齐,偶发失败会重试一次。
- 表格右上角齿轮 `.icon-datatable-setting-normal` 只有「设置显示字段」「选择间距类型」,与分页无关。
### 长区间耗时(2026-11-09 实测)
- 一次跑完整区间 `2026-06-01 ~ 2026-08-31`(92 天)耗时约 **21 分钟**:可见行约 12 天直接抓,
其余 80 天全部走单日筛选,每天约 12–15 秒(含重设起止日期、打开详情、整页截图、关闭标签)。
- 想缩短耗时可按自然月分多次运行(如 6 月 / 7 月 / 8 月各一次),每次只需补漏约 18 天,
台账按 `captured.json` 全量同步,多次运行结果与一次全区间完全等价;最后再用完整区间跑一次,
此时全部日期命中去重,只做台账同步并返回 `overtimeMinutesInRange`,可用于校验零遗漏。
- 串联多次运行时注意 **zsh 不做未加引号变量的单词拆分**:`R="2026-06-01 2026-06-30"; set -- $R`
在 zsh 下 `$1` 会是整串、`$2` 为空,导致 `日期格式不合法`。请直接写三条独立命令,
或用 `${=R}` / `read start end <<< "$R"` 显式拆分。
- 单日筛选时起始端点常与上一天结束值相同,会触发一次「哨兵值」校准重试(日志里的
`第 1 次设置端点 0 后值为 ...,重试`),属正常现象,不影响结果。
## 加班台账(scripts/excel_report.py)
- Node 侧 `syncExcel()` 从 `captured.json` **全量**生成行数据(不只本次区间),经 stdin 以 JSON 传给 Python helper。
- 每日加班分钟数 = 当天最晚实打卡时间 − 阈值(`--overtime-after`,默认 18:00);早于阈值或无打卡记 0。
- Python helper 先用 `load_workbook` 读已有行(跳过「合计」行),按日期合并(传入数据覆盖同日期),
升序重写整张表,末尾追加静态「合计」行;表头冻结、加班行高亮、带筛选。
输出 JSON:`rowCount / added / updated / totalMinutes / runMinutes / created`。
- 即使本次所有日期都因去重被跳过,也会执行同步,保证台账与 `captured.json` 一致;
因此后续抓取其他时期只会累加新日期,不会丢历史或产生重复行。
- 依赖 Python 3 + `openpyxl`;Excel 路径默认 `<out>/加班时长统计.xlsx`,可用 `--excel` 覆盖。
- **helper 偶发 `spawnSync python3 ETIMEDOUT`**:台账达数百行且浏览器进程占用资源时,
120 秒超时会触发,表现为该段 `✗ Excel 台账写入失败` 并以退出码 2 结束(截图其实已全部落盘)。
现已放宽到 180 秒并自动重试一次;由于 `captured.json` 是全量源,即使单次失败,
下一次运行(含完整区间收尾)也会重建台账,不会丢数据。
## agent-browser CLI 要点
- 所有命令统一带 `--session <name>`,与手工会话隔离;`tab <id>` 接受 `t1`/`t2` 形式,
不接受纯数字。`tab close` 关闭当前活动标签页并自动回到列表页。
- `screenshot <path> --full` 保存整页截图;目标目录需事先存在。
- `eval` 返回体为 JSON 文本时用 `JSON.stringify(...)` 包裹,便于 Node 侧解析。
- 每条详情截完后关闭标签页;脚本 finally 中 `close` 释放会话,避免残留 Chromium。
## 数据覆盖范围(2026-11-09 实测)
- 「我的出勤」全量记录为 **887 天:2024-06-03 ~ 2026-11-06**(当日)。这正是最早误判为
「日期筛选半截导致查出 887 条历史数据」的那个数字,其实是系统全部考勤记录。
- `2024-06-01`、`2024-06-02` 在单日筛选下均返回「无考勤记录」,推测为入职前,系统本身没有数据。
因此早于 `2024-06-03` 的区间无需抓取。
- 区间自然天数 > `readTotalCount()` 时属正常:差出来的日期会在阶段 B 打印
`无考勤记录,跳过`,不写入 `captured.json` 与 Excel,也不计入 `errors`。
例:`2024-06-01 ~ 2024-12-31` 共 214 天,实际 212 条记录。
4.3 实例三:让 Skill 越用越准¶
| 观察到的现象 | 改哪里 | 怎么验证 |
|---|---|---|
| 从没被触发 | description 补场景与用户原话 |
用真实提问复测 |
| 触发后做错步骤 | SKILL.md 补边界、顺序、降级流程 |
重跑同一任务 |
| 每次都重写同一段代码 | 下沉到 scripts/ |
直接执行脚本 |
| 上下文被长文档挤占 | 移到 references/ 并给 grep 模式 |
观察是否只加载必要片段 |
| 规则两处不一致 | 只保留一处(数据放 JSON,规则放 SKILL.md) |
全局搜关键词 |
| 市场安装的技能被更新覆盖 | 保存编辑后在 _skillhub_meta.json / _knot_meta.json 写 "userModified": true |
检查 meta 文件 |
写作用的固定口径:用祈使句 / 不定式(「执行 X」「把 Y 传入 Z」),不用第二人称;只写对另一个实例 非显而易见 的知识,不复述常识;SKILL.md 里引用到的每个资源都要说明何时使用。
5. 发布前校验清单¶
在你的 SKILL 发布之前,需要检查下是否符合以下要求:
-
name与目录名一致,且为 hyphen-case -
description含「做什么 / 何时用 / 触发词」,无尖括号 -
agent_created: true已写入 frontmatter - 正文只留核心流程,长文档已拆到
references/ -
example.py/api_reference.md/example_asset.txt已删除或替换 -
quick_validate.py输出Skill is valid! - 用真实任务跑过一遍,必要时回到第 6 节迭代
-
package_skill.py打出<name>.zip
6. 常见坑速查¶
| 坑 | 后果 | 修法 |
|---|---|---|
省略 --path |
初始化直接退出 | 显式带 --path |
name 与目录名不一致、含大写或下划线 |
校验失败 / 无法被管理 | 目录与 name 同步改名 |
description 太泛或含尖括号 |
误触发、不触发、校验失败 | 场景 + 触发词,去掉尖括号 |
忘写 agent_created: true |
后续无法用管理命令修改 / 删除 | 补字段 |
规则在 SKILL.md 与脚本 / JSON 里各写一份 |
改一处漏一处,行为漂移 | 数据归数据,流程归正文 |
| 初始化示例文件没删 | 打包把占位文件一起分发出去 | 用不到的目录整个删掉 |
| 把整本 API 文档塞进正文 | 每次触发都撑爆上下文 | 拆到 references/ 并给检索模式 |
参考¶
# 内置 skill-creator(初始化 / 校验 / 打包)
/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/resources/plugins/workbuddy-builtin/skills/skill-creator/scripts/
# 本文引用的两个用户级实例
~/.workbuddy/skills/bilibili-video-info/
~/.workbuddy/skills/italent-attendance-capture/