跳转至

从入门到进阶:封装一个 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          # 接口字段、响应示例、错误码

使用时不重写代码,直接执行脚本,把用户原文整段传进去:

手动执行脚本
python3 ~/.workbuddy/skills/bilibili-video-info/scripts/resolve_bili.py "【手指发力技术教学-哔哩哔哩】 https://b23.tv/HlWsuVM"

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>) &nbsp; `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 站公开接口字段映射、响应示例、错误码与网络降级说明。
#!/usr/bin/env python3
"""Resolve a Bilibili share link, b23.tv short link, full video URL or BV id
into canonical video information.

Always outputs: BV id + canonical URL containing the BV id.
Additionally outputs (when metadata is available): video title, publish time,
duration and uploader name (after applying user-defined aliases).

Usage:
  python3 resolve_bili.py "【手指发力技术教学-哔哩哔哩】 https://b23.tv/HlWsuVM"
  python3 resolve_bili.py "https://www.bilibili.com/video/BV1grWu6zEZ7?share_source=COPY"
  python3 resolve_bili.py BV1grWu6zEZ7 --json
  python3 resolve_bili.py --from-json -            # feed WebFetch API JSON via stdin
  python3 resolve_bili.py --from-json meta.json
"""

import argparse
import json
import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

BV_RE = re.compile(r"BV[0-9A-Za-z]{10}")
URL_RE = re.compile(r"https?://[^\s<>\"',。;)】]+")
API = "https://api.bilibili.com/x/web-interface/view?bvid={bv}"
CST = timezone(timedelta(hours=8))
ALIASES_FILE = Path(__file__).with_name("uploader_aliases.json")


def curl(*args, timeout=20):
    """Run curl and return stdout, or None on an empty result.

    A short-link redirect can print a usable %{url_effective} even when the
    final TLS connection fails (exit 35). Keep stdout in that case and let the
    caller decide whether the payload is usable.
    """
    if shutil.which("curl") is None:
        return None
    try:
        proc = subprocess.run(
            ["curl", "-sS", "--max-time", "15", *args],
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    return proc.stdout or None


def load_aliases():
    """Load the user-defined uploader name mappings."""
    try:
        with ALIASES_FILE.open(encoding="utf-8") as handle:
            aliases = json.load(handle)
    except (OSError, json.JSONDecodeError):
        return {}
    return aliases if isinstance(aliases, dict) else {}


def parse_api_payload(raw):
    """Extract data{} from an API response, tolerating Markdown code fences."""
    if not raw or not raw.strip():
        return None
    start = raw.find("{")
    if start < 0:
        return None
    try:
        obj, _ = json.JSONDecoder().raw_decode(raw[start:])
    except (json.JSONDecodeError, ValueError):
        return None
    if not isinstance(obj, dict) or obj.get("code") not in (0, None):
        return None
    return obj.get("data") or None


def resolve_bv(text):
    """Extract a BV id directly, or by following a short link's redirects."""
    text = text or ""
    match = BV_RE.search(text)
    if match:
        return match.group(0)
    url_match = URL_RE.search(text)
    if url_match:
        url = url_match.group(0).rstrip(".,;,。、)]】>")
        effective = curl("-L", "-o", "/dev/null", "-w", "%{url_effective}", url)
        if effective:
            match = BV_RE.search(effective)
            if match:
                return match.group(0)
    return None


def fmt_duration(seconds):
    try:
        total = int(seconds)
    except (TypeError, ValueError):
        return ""
    if total < 3600:
        return f"{total // 60:02d}:{total % 60:02d}"
    return f"{total // 3600:d}:{total % 3600 // 60:02d}:{total % 60:02d}"


def fmt_pubdate(timestamp):
    try:
        return datetime.fromtimestamp(int(timestamp), CST).strftime("%Y-%m-%d %H:%M:%S")
    except (TypeError, ValueError, OSError, OverflowError):
        return ""


def build_result(bv, data):
    data = data or {}
    bv = data.get("bvid") or bv or ""
    owner = data.get("owner") or {}
    raw_uploader = owner.get("name") or ""
    uploader = load_aliases().get(raw_uploader, raw_uploader)
    return {
        "bvid": bv,
        "title": data.get("title") or "",
        "published_at": fmt_pubdate(data.get("pubdate")),
        "duration": fmt_duration(data.get("duration")),
        "uploader": uploader,
        "uploader_mid": owner.get("mid") or "",
        "url": f"https://www.bilibili.com/video/{bv}" if bv else "",
        "meta_complete": bool(data.get("title")),
    }


def render(result, as_json=False):
    if as_json:
        out = dict(result)
        if not result["meta_complete"]:
            out["hint"] = (
                "用 WebFetch 打开 "
                + API.format(bv=result["bvid"])
                + ",把返回的 JSON 通过 --from-json - 传回本脚本"
            )
        return json.dumps(out, ensure_ascii=False, indent=2)

    lines = [
        f"BV 号    : {result['bvid'] or '(未解析)'}",
        f"视频标题 : {result['title'] or '(未获取)'}",
        f"UP 主    : {result['uploader'] or '(未获取)'}"
        + (f"  (mid {result['uploader_mid']})" if result["uploader_mid"] else ""),
        f"发布时间 : {result['published_at'] or '(未获取)'}  (UTC+8)",
        f"视频时长 : {result['duration'] or '(未获取)'}",
        f"规范 URL : {result['url'] or '(未解析)'}",
    ]
    if not result["meta_complete"]:
        lines += [
            "",
            "[元数据未获取] 本机直连 api.bilibili.com 可能被网络策略拦截。",
            "下一步:用 WebFetch 打开以下 API 地址,再把返回内容通过",
            "`python3 resolve_bili.py --from-json -` 传回本脚本以补全字段:",
            API.format(bv=result["bvid"]),
        ]
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(
        description="解析 B 站分享链接/b23.tv 短链/BV 号,输出视频标题、时间、UP 主与规范 URL"
    )
    parser.add_argument("input", nargs="?", help="分享文本、短链、完整视频 URL 或 BV 号")
    parser.add_argument(
        "--from-json",
        metavar="FILE",
        help="从文件、或 - (stdin) 读取 WebFetch 返回的 B 站 API JSON",
    )
    parser.add_argument("--json", action="store_true", help="以 JSON 格式输出")
    args = parser.parse_args()

    if args.from_json:
        if args.from_json == "-":
            raw = sys.stdin.read()
        else:
            with open(args.from_json, encoding="utf-8") as handle:
                raw = handle.read()
        data = parse_api_payload(raw)
        if not data:
            print("错误:--from-json 内容中未找到有效的 B 站 API data 对象", file=sys.stderr)
            return 1
        print(render(build_result(None, data), args.json))
        return 0

    if not args.input:
        parser.print_help()
        return 1

    bv = resolve_bv(args.input)
    if not bv:
        print(
            "错误:未能解析出 BV 号(支持 b23.tv 短链、完整视频 URL、BV 号)",
            file=sys.stderr,
        )
        return 1

    api_url = API.format(bv=bv)
    raw = curl(
        "-H",
        "User-Agent: Mozilla/5.0",
        "-H",
        "Referer: https://www.bilibili.com/",
        api_url,
    )
    print(render(build_result(bv, parse_api_payload(raw)), args.json))
    return 0


if __name__ == "__main__":
    sys.exit(main())
{
  "羽球小宋老师": "小宋老师",
  "广益羽球": "黄广益"
}
# 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=...&timestamp=...&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`:页面结构与排障笔记
{
  "user": "<账号>",
  "password": "<密码>",
  "overtimeAfter": "18:00"
  "pageSize": 30
}
  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
#!/usr/bin/env node
'use strict';

/**
 * iTalent 我的出勤 - 逐条详情页截图归档
 *
 * 依赖:agent-browser CLI(vercel-labs/agent-browser,已随 WorkBuddy agent-browser skill 安装)
 * 运行:node capture.js --start 2026-11-01 --end 2026-11-06 --out ./attendance-shots
 *
 * 行为:
 *   1. 账号密码登录 iTalent -> 我的假勤 -> 我的出勤
 *   2. 设置「考勤日期」起止范围
 *   3. 按 30 条/页分批遍历列表,逐条打开详情页并整页截图
 *   4. 文件名 = 日期;若「实打卡」时间晚于 18:00,追加「加班」
 *   5. 已抓取过的日期记录在 captured.json,默认跳过,不重复截图
 */

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const SKILL_DIR = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const SESSION = 'italent-attendance-capture';
const HOME_URL = 'https://www.italent.cn/portal/iTalentHome';

// ---------------------------------------------------------------- 参数
function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (!a.startsWith('--')) continue;
    const key = a.slice(2);
    if (key === 'force') { args.force = true; continue; }
    if (key === 'help') { args.help = true; continue; }
    args[key] = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : '';
  }
  return args;
}

function pad(n) { return String(n).padStart(2, '0'); }
function iso(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
function validDate(s) { return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(new Date(s + 'T00:00:00').getTime()); }
function shiftDay(s, delta) {
  const d = new Date(s + 'T00:00:00');
  d.setDate(d.getDate() + delta);
  return iso(d);
}

function loadConfig() {
  let c = {};
  try { c = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch (_) { /* optional */ }
  return c;
}

// ---------------------------------------------------------------- CLI 封装
function resolveBrowserBin() {
  if (process.env.AGENT_BROWSER_BIN) return process.env.AGENT_BROWSER_BIN;
  try {
    const p = execFileSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
    const candidate = path.join(p, 'bin', 'agent-browser');
    if (fs.existsSync(candidate)) return candidate;
  } catch (_) { /* fall through */ }
  return 'agent-browser';
}
const AB = resolveBrowserBin();
const AB_PREFIX = ['--session', SESSION];

function ab(args, timeout = 120000) {
  const full = [...AB_PREFIX, ...args];
  try {
    return execFileSync(AB, full, {
      encoding: 'utf8',
      timeout,
      maxBuffer: 32 * 1024 * 1024,
      stdio: ['ignore', 'pipe', 'pipe'],
    }).trim();
  } catch (e) {
    const detail = (e.stderr || e.stdout || e.message || '').toString().trim();
    throw new Error(`agent-browser ${args[0]} 失败: ${detail.slice(0, 500)}`);
  }
}

/** 执行页面 JS;返回原始输出文本 */
function js(code) { return ab(['eval', code]); }

/** 执行页面 JS 并解析 JSON 返回值(页面代码需自行 JSON.stringify) */
function jsJson(code) {
  const out = js(code);
  const cleaned = out.split('\n').filter(l => !/^[✓✗]/.test(l.trim())).join('\n').trim();
  let parsed;
  try { parsed = JSON.parse(cleaned); }
  catch (_) { throw new Error(`页面返回值无法解析为 JSON: ${cleaned.slice(0, 300)}`); }
  // eval 会把 JSON.stringify 的结果再次包成 JSON 字符串,需解两层
  if (typeof parsed === 'string') {
    try { parsed = JSON.parse(parsed); } catch (_) { /* 本身就是普通字符串,保留 */ }
  }
  return parsed;
}

function wait(ms) { ab(['wait', String(ms)]); }
function log(msg) { process.stderr.write(`[capture] ${msg}\n`); }

// ---------------------------------------------------------------- 页面操作
function login(user, password) {
  log('登录 iTalent(账号密码方式)');
  // 短时间内多次独立运行(按自然月分段抓取)时,登录按钮偶发点击过早或跳转延迟,
  // 表现为「仍停留在登录页」。重新填写并重试即可,不必整段任务失败。
  for (let attempt = 1; attempt <= 3; attempt++) {
    if (!ab(['get', 'url']).includes('/Login')) { log('登录成功'); return; }
    ab(['fill', '#form-item-account', user]);
    ab(['fill', '#form-item-password', password]);
    try { ab(['check', '.phoenix-checkbox__input']); } catch (_) { /* 已勾选时可忽略 */ }
    js(String.raw`(()=>{const b=Array.from(document.querySelectorAll('.phoenix-button__content')).find(e=>e.textContent.trim()==='登录');if(!b)return 'no-button';b.parentElement.click();return 'clicked'})()`);
    wait(6000);
    if (!ab(['get', 'url']).includes('/Login')) { log('登录成功'); return; }
    log(`  · 第 ${attempt} 次登录未跳转,重试`);
    wait(2000);
  }
  throw new Error('登录未成功,仍停留在登录页(已重试 3 次;请检查账号密码或是否触发验证码)');
}

function navigateToAttendance() {
  log('进入 我的假勤 -> 我的出勤');
  // 连续多次独立运行时,首页应用宫格可能尚未渲染完,或跳转落在「我的月报」中间页;
  // 因此按「是否已落到列表页」为准做多轮校准,而不是一次找不到就抛错。
  for (let attempt = 1; attempt <= 6; attempt++) {
    if (ab(['get', 'url']).includes('AttendanceDataRecordNavView')) return;

    // 偶发卡在带 _userSpecialType 参数的首页(宫格不渲染),重载一次可恢复
    if (attempt === 3) {
      log('  · 导航仍未落地,重新加载页面后继续');
      try { ab(['reload']); } catch (_) { /* ignore */ }
      wait(5000);
    }

    const clickedApp = jsJson(String.raw`JSON.stringify((()=>{
      const els=Array.from(document.querySelectorAll('.menu-name')).filter(x=>x.textContent.trim()==='我的假勤');
      els.forEach(x=>{x.click();if(x.parentElement)x.parentElement.click()});
      return els.length
    })())`);
    wait(clickedApp > 0 ? 5000 : 3500);

    const clickedMenu = jsJson(String.raw`JSON.stringify((()=>{
      const a=Array.from(document.querySelectorAll('a.list-2nd-a')).find(x=>x.textContent.trim()==='我的出勤');
      if(a){a.parentElement.click();return 1}return 0
    })())`);
    if (clickedMenu) {
      wait(5000);
      if (ab(['get', 'url']).includes('AttendanceDataRecordNavView')) return;
    } else {
      wait(2500);
    }
    log(`  · 第 ${attempt} 次导航未落地,重试(应用入口 ${clickedApp} / 出勤菜单 ${clickedMenu})`);
  }
  const url = ab(['get', 'url']);
  throw new Error(`未进入「我的出勤」列表页(重试 6 次后),当前 URL: ${url.slice(0, 200)}`);
}

/** 打开「考勤日期」筛选弹层 */
function openDateFilter() {
  const ok = jsJson(String.raw`JSON.stringify((()=>{
    const f=document.querySelector('.search-form-field.date-range-search .field-left');
    if(!f)return false;f.click();return true
  })())`);
  if (!ok) throw new Error('未找到「考勤日期」筛选控件');
  wait(700);
}

function pickerValues() {
  return jsJson(String.raw`JSON.stringify(Array.from(document.querySelectorAll('.ant-calendar-range-picker')).map(i=>i.value))`);
}

let calIdSeq = 0;
/** 给端点 index 对应的日历输入框打一个唯一临时 id,返回 {value, id}。
 *  两个陷阱:日历面板会残留上一次的输入框,因此 (1) id 必须唯一,
 *  (2) 必须按 index 取对应的输入框,不能一律取「最后一个」——否则改起始时会改到结束端点。 */
function markCalendarInput(index) {
  const id = `tmp-cal-${++calIdSeq}`;
  const value = jsJson(String.raw`JSON.stringify((()=>{
    const ins=Array.from(document.querySelectorAll('.ant-calendar-input'));
    if(!ins.length)return null;
    const el=ins[Math.min(${index},ins.length-1)];el.id=${JSON.stringify(id)};return el.value
  })())`);
  return value === null ? null : { value, id };
}

/**
 * 设置范围的一个端点。
 * 仅在端点当前值与目标不同时调用:React 受控输入框在值相同时不更新内部 state,
 * 会导致另一端被修改后本端变空(曾出现「~ 2026-11-06」查到 887 条历史数据)。
 */
function setEndpoint(index, target) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    js(String.raw`(()=>{const p=document.querySelectorAll('.ant-calendar-range-picker')[${index}];if(p)p.click();return true})()`);
    wait(600);
    const mark = markCalendarInput(index);
    if (mark === null) { continue; }
    if (mark.value === target) { return; } // 已一致,无需触发输入事件
    ab(['fill', `#${mark.id}`, target]);
    ab(['press', 'Enter']);
    wait(700);

    const vals = pickerValues();
    if (vals[index] === target) { return; }
    log(`  · 第 ${attempt} 次设置端点 ${index} 后值为 ${JSON.stringify(vals)},重试`);
  }
  throw new Error(`无法设置考勤日期端点 ${index} 为 ${target},当前 ${JSON.stringify(pickerValues())}`);
}

function ensureDatePopover() {
  const n = jsJson(String.raw`JSON.stringify(document.querySelectorAll('.ant-calendar-range-picker').length)`);
  if (!n) { openDateFilter(); }
  return n >= 2;
}

function setDateRange(start, end) {
  log(`设置考勤日期 ${start} ~ ${end}`);
  openDateFilter();

  // 逐轮校准:某端点可能被组件清空,下一轮会补齐(此时值不同,输入才会生效)
  let final = [];
  for (let round = 1; round <= 4; round++) {
    if (ensureDatePopover()) {
      let vals = pickerValues();
      if (vals[0] !== start) setEndpoint(0, start);
      vals = pickerValues();
      if (vals[1] !== end) setEndpoint(1, end);
      final = pickerValues();
      if (final[0] === start && final[1] === end) break;
      log(`  · 第 ${round} 轮端点值 ${JSON.stringify(final)},继续校准`);
    } else {
      wait(500);
    }
  }
  if (!(final[0] === start && final[1] === end)) {
    throw new Error(`考勤日期端点校准失败: ${JSON.stringify(final)},期望 ${start} ~ ${end}`);
  }

  // 提交:日历面板常盖住确定按钮,使用 DOM click 派发到按钮内部 span 以绕过遮挡
  js(String.raw`(()=>{const s=Array.from(document.querySelectorAll('.base-btn-title')).find(e=>e.textContent.trim()==='确定');if(!s)return false;s.click();return true})()`);
  wait(2500);

  const labels = jsJson(String.raw`JSON.stringify(Array.from(document.querySelectorAll('span.search-field-value')).map(e=>e.textContent.trim()).filter(Boolean))`);
  const expected = `${start} ~ ${end}`;
  if (!labels.some(l => l.replace(/\s+/g, '') === expected.replace(/\s+/g, ''))) {
    throw new Error(`考勤日期未生效,筛选标签为 ${JSON.stringify(labels)},期望 ${expected}`);
  }
  log(`筛选生效:${expected}`);
}

/** 结果超过一页时页面出现「N条/页」下拉,切换为 pageSize 条/页(默认 30)。
 *  注意:表格是虚拟渲染,一页内 DOM 仍可能只保留约 12 行,缺失日期由逐日筛选补齐。 */
function trySetPageSize(pageSize) {
  const before = jsJson(String.raw`JSON.stringify((()=>{
    const t=Array.from(document.querySelectorAll('.drop-down-btn-title')).find(e=>/\d+\s*条\s*\/\s*页/.test(e.textContent));
    return t?t.textContent.trim():''
  })())`);
  if (!before) { log('结果不足一页,页面未出现「每页条数」控件'); return false; }

  const desired = `${pageSize}条/页`;
  if (before.replace(/\s/g, '') === desired) { log(`每页已为 ${desired}`); return true; }

  js(String.raw`(()=>{const t=Array.from(document.querySelectorAll('.drop-down-btn-title')).find(e=>/\d+\s*条\s*\/\s*页/.test(e.textContent));if(!t)return false;const svg=t.parentElement.querySelector('[class*=arrowdown],.drop-down-btn-svg');(svg||t.parentElement).click();return true})()`);
  wait(600);
  const chosen = jsJson(String.raw`JSON.stringify((()=>{
    const e=Array.from(document.querySelectorAll('.dropDownEllipsis')).find(x=>x.textContent.trim().replace(/\s/g,'')===${JSON.stringify(desired)});
    if(!e)return 0;e.click();if(e.parentElement)e.parentElement.click();return 1
  })())`);
  wait(2200);
  const after = jsJson(String.raw`JSON.stringify((()=>{
    const t=Array.from(document.querySelectorAll('.drop-down-btn-title')).find(e=>/\d+\s*条\s*\/\s*页/.test(e.textContent));
    return t?t.textContent.trim():''
  })())`);
  log(`每页条数:${before} -> ${after || '未变更'}${chosen ? '' : '(未找到该选项)'}`);
  return true;
}

function readTotalCount() {
  return Number(jsJson(String.raw`JSON.stringify((document.body.innerText.match(/共(\d+)条/)||[])[1]||0)`)) || 0;
}

function eachDay(start, end) {
  const out = [];
  const d = new Date(start + 'T00:00:00');
  const last = new Date(end + 'T00:00:00');
  while (d <= last && out.length < 400) {
    out.push(iso(d));
    d.setDate(d.getDate() + 1);
  }
  return out;
}

function listDates() {
  return jsJson(String.raw`JSON.stringify((()=>{
    const out=[];
    for(const a of document.querySelectorAll('a')){
      const t=a.textContent.trim();
      const m=t.match(/^(\d{4}-\d{2}-\d{2})/);
      if(m&&!out.includes(m[1]))out.push(m[1]);
    }
    return out
  })())`);
}

function parseTabs(text) {
  return text.split('\n').map(l => l.match(/\[(t\d+)\]/)).filter(Boolean).map(m => m[1]);
}

/** 打开某天详情,返回 {times, date, tabId, sameTab} */
function openDetail(date) {
  const before = parseTabs(ab(['tab', 'list']));
  const clicked = jsJson(String.raw`JSON.stringify((()=>{
    const a=Array.from(document.querySelectorAll('a')).find(x=>x.textContent.trim().indexOf(${JSON.stringify(date)})===0);
    if(!a)return 0;a.click();return 1
  })())`);
  if (!clicked) throw new Error(`列表中没有找到 ${date} 的链接`);

  let tabId = null;
  for (let i = 0; i < 12; i++) {
    wait(700);
    const after = parseTabs(ab(['tab', 'list']));
    const fresh = after.filter(t => !before.includes(t));
    if (fresh.length) { tabId = fresh[fresh.length - 1]; break; }
  }
  if (tabId) {
    ab(['tab', tabId]);
  } else {
    // 少数情况下在当前标签页内打开详情
    tabId = null;
  }

  for (let i = 0; i < 15; i++) {
    const ready = jsJson(String.raw`JSON.stringify((()=>{
      const t=document.body.innerText;
      return t.indexOf('打卡信息')>=0 && t.indexOf(${JSON.stringify(date)})>=0
    })())`);
    if (ready) break;
    wait(700);
  }

  const info = jsJson(String.raw`JSON.stringify((()=>{
    const vals=[];
    for(const g of document.querySelectorAll('[role="grid"]')){
      const rows=Array.from(g.querySelectorAll('[role="row"]'));
      const h=rows.find(r=>r.innerText.indexOf('应打卡')>=0 && r.innerText.indexOf('实打卡')>=0);
      if(!h)continue;
      const heads=Array.from(h.querySelectorAll('[role="columnheader"]')).map(e=>e.innerText.trim());
      const idx=heads.indexOf('实打卡');
      if(idx<0)continue;
      for(const r of rows){
        const c=r.querySelectorAll('[role="gridcell"]');
        if(c.length>idx){
          const v=c[idx].innerText.trim();
          if(/\d{1,2}:\d{2}/.test(v))vals.push(v);
        }
      }
    }
    const body=document.body.innerText;
    const m=body.match(/考勤日期\s*(\d{4}-\d{2}-\d{2})/);
    // 兜底:若表格结构变化,扫描「打卡信息」区块中的时间
    let fallback=[];
    if(!vals.length){
      const seg=(body.split('打卡信息')[1]||'').split('打卡数据')[0]||'';
      fallback=seg.match(/\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(:\d{2})?/g)||[];
    }
    return {date:m?m[1]:'',vals,fallback}
  })())`);

  return { ...info, tabId, sameTab: tabId === null };
}

function closeDetail(detail) {
  if (detail.sameTab) { ab(['back']); wait(1500); }
  else { ab(['tab', 'close']); wait(600); }
}

// ---------------------------------------------------------------- 主流程
function minutesOf(hhmm) {
  const [h, m] = hhmm.split(':').map(Number);
  return h * 60 + m;
}
function hasOvertime(info, thresholdMin) {
  const all = [...(info.vals || []), ...(info.fallback || [])];
  return all.some(v => {
    const m = String(v).match(/(\d{1,2}):(\d{2})/);
    if (!m) return false;
    return Number(m[1]) * 60 + Number(m[2]) > thresholdMin;
  });
}

/** 当日加班分钟数 = 最晚「实打卡」时间 − 阈值(默认 18:00),早于阈值则为 0 */
function overtimeOf(clockTimes, thresholdMin) {
  const times = [];
  for (const v of clockTimes || []) {
    const m = String(v).match(/(\d{1,2}):(\d{2})/);
    if (!m) continue;
    const minutes = Number(m[1]) * 60 + Number(m[2]);
    const text = `${String(m[1]).padStart(2, '0')}:${m[2]}`;
    times.push({ minutes, text });
  }
  if (!times.length) return { minutes: 0, firstClock: '', lastClock: '' };
  times.sort((a, b) => a.minutes - b.minutes);
  const last = times[times.length - 1];
  return {
    minutes: Math.max(0, last.minutes - thresholdMin),
    firstClock: times[0].text,
    lastClock: last.text,
  };
}

/** 把 captured.json 全量台账同步进 Excel(已存在日期更新、新日期追加、按日期排序) */
function syncExcel(state, summary, thresholdMin, excelPath) {
  const rows = Object.keys(state.entries)
    .filter(d => validDate(d))
    .map(d => {
      const rec = state.entries[d];
      const o = overtimeOf(rec.clockTimes, thresholdMin);
      return {
        date: d,
        weekday: '',
        firstClock: o.firstClock,
        lastClock: o.lastClock,
        overtime: o.minutes > 0,
        minutes: o.minutes,
        file: rec.file || '',
        updatedAt: rec.capturedAt || '',
      };
    })
    .sort((a, b) => a.date.localeCompare(b.date));

  const payload = {
    excelPath,
    rows,
    runDates: summary.captured.map(x => x.date),
  };
  const helper = path.join(__dirname, 'excel_report.py');
  const existedBefore = fs.existsSync(excelPath);
  // 台账行数已达数百行、且浏览器进程占用资源时,python3 helper 偶发 spawnSync ETIMEDOUT。
  // 放宽超时并重试一次;captured.json 是全量源,即使单次失败也不会丢数据。
  let raw = '';
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      raw = execFileSync('python3', [helper], {
        input: JSON.stringify(payload),
        encoding: 'utf8',
        timeout: 180000,
        maxBuffer: 8 * 1024 * 1024,
        stdio: ['pipe', 'pipe', 'pipe'],
      }).trim();
      break;
    } catch (e) {
      if (attempt === 2) throw e;
      log(`  · Excel helper 第 ${attempt} 次调用失败(${String(e.code || e.message).slice(0, 100)}),重试`);
    }
  }
  const report = JSON.parse(raw.split('\n').filter(Boolean).pop());
  // 以调用前的文件状态为准,避免统计口径受重复写入影响
  report.created = !existedBefore;
  if (!existedBefore) { report.added = report.rowCount; report.updated = 0; }
  summary.excel = report;
  summary.overtimeMinutesTotal = report.totalMinutes;
  summary.overtimeMinutesThisRun = report.runMinutes;
  summary.overtimeMinutesInRange = rows
    .filter(r => r.date >= summary.start && r.date <= summary.end)
    .reduce((s, r) => s + r.minutes, 0);
  summary.dailyOvertime = rows.map(r => ({ date: r.date, minutes: r.minutes }));

  const h = Math.floor(report.totalMinutes / 60);
  const m = report.totalMinutes % 60;
  log(`Excel 台账:${report.excelPath}(${report.rowCount} 天,新增 ${report.added} / 更新 ${report.updated})`);
  log(`加班分钟数:本次新抓 ${report.runMinutes} 分钟,查询区间 ${summary.overtimeMinutesInRange} 分钟,累计 ${report.totalMinutes} 分钟(${h} 小时 ${m} 分钟)`);
  return report;
}

function main() {
  const args = parseArgs(process.argv.slice(2));
  if (args.help) {
    process.stdout.write([
      '用法: node capture.js [options]',
      '  --start YYYY-MM-DD     考勤日期起始(默认当月 1 日)',
      '  --end   YYYY-MM-DD     考勤日期结束(默认今天)',
      '  --out   DIR            截图输出目录(默认 ./attendance-shots)',
      '  --excel PATH          加班时长 Excel 路径(默认 <out>/加班时长统计.xlsx)',
      '  --user  USER           iTalent 账号(覆盖 config.json / ITALENT_USER)',
      '  --password PASS        iTalent 密码(覆盖 config.json / ITALENT_PASSWORD)',
      '  --overtime-after HH:MM 加班判定阈值(默认 18:00,严格晚于该时刻)',
      '  --page-size N          每批处理条数(默认 30)',
      '  --force                强制重抓,忽略已抓取记录',
      '',
    ].join('\n'));
    return;
  }

  const cfg = loadConfig();
  const now = new Date();
  const start = args.start || iso(new Date(now.getFullYear(), now.getMonth(), 1));
  const end = args.end || iso(now);
  if (!validDate(start) || !validDate(end)) throw new Error(`日期格式不合法: ${start} ~ ${end}`);
  if (start > end) throw new Error(`起始日期晚于结束日期: ${start} > ${end}`);

  const user = args.user || process.env.ITALENT_USER || cfg.user;
  const password = args.password || process.env.ITALENT_PASSWORD || cfg.password;
  if (!user || !password) throw new Error('缺少 iTalent 账号密码,请通过 config.json / ITALENT_USER / ITALENT_PASSWORD / --user --password 提供');

  const outDir = path.resolve(args.out || path.join(process.cwd(), 'attendance-shots'));
  fs.mkdirSync(outDir, { recursive: true });
  const excelPath = path.resolve(args.excel || cfg.excelPath || path.join(outDir, '加班时长统计.xlsx'));
  const statePath = path.join(outDir, 'captured.json');
  let state = { version: 1, entries: {} };
  try {
    const loaded = JSON.parse(fs.readFileSync(statePath, 'utf8'));
    if (loaded && loaded.entries) state = loaded;
  } catch (_) { /* 首次运行 */ }

  const threshold = args['overtime-after'] || cfg.overtimeAfter || '18:00';
  if (!/^\d{1,2}:\d{2}$/.test(threshold)) throw new Error(`加班阈值格式不合法: ${threshold}`);
  const thresholdMin = minutesOf(threshold);
  const pageSize = Math.max(1, Number(args['page-size'] || cfg.pageSize || 30));
  const force = !!args.force;

  const summary = {
    start, end, outDir, pageSize, overtimeAfter: threshold,
    excelPath,
    totalRows: 0, batches: 0, captured: [], skipped: [], errors: [],
  };

  const saveState = () => {
    fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
  };

  try {
    log(`打开 ${HOME_URL}`);
    ab(['open', HOME_URL]);
    wait(3000);
    if (ab(['get', 'url']).includes('/Login')) login(user, password);
    else log('会话已登录,跳过登录步骤');

    navigateToAttendance();
    const listTabId = parseTabs(ab(['tab', 'list']))[0] || 't1';
    setDateRange(start, end);
    trySetPageSize(pageSize);

    const totalFromPager = readTotalCount();
    const visible = listDates().filter(d => d >= start && d <= end);
    const allDays = eachDay(start, end);
    const missing = allDays.filter(d => !visible.includes(d));
    summary.totalRows = totalFromPager || visible.length;
    log(`列表共 ${totalFromPager || visible.length} 条记录,当前页可见 ${visible.length} 天,需单日补漏 ${missing.length} 天`);

    const needsCapture = (d) => {
      const rec = state.entries[d];
      const file = rec && path.join(outDir, rec.file);
      if (!force && rec && file && fs.existsSync(file)) return false;
      return true;
    };

    // 抓取单条:打开详情 -> 判定加班 -> 整页截图 -> 写台账 -> 关闭详情标签
    const captureOne = (date) => {
      try { ab(['tab', listTabId]); } catch (_) { /* 单标签页时可忽略 */ }
      let detail = null;
      try {
        detail = openDetail(date);
        const overtime = hasOvertime(detail, thresholdMin);
        const filename = `${date}${overtime ? '加班' : ''}.png`;
        const target = path.join(outDir, filename);

        for (const old of [`${date}.png`, `${date}加班.png`]) {
          const p = path.join(outDir, old);
          if (p !== target && fs.existsSync(p)) fs.unlinkSync(p);
        }

        ab(['screenshot', target, '--full'], 120000);
        if (!fs.existsSync(target)) throw new Error('截图文件未生成');

        state.entries[date] = {
          file: filename,
          overtime,
          clockTimes: detail.vals || detail.fallback || [],
          capturedAt: new Date().toISOString(),
        };
        saveState();
        summary.captured.push({ date, file: filename, overtime, clockTimes: state.entries[date].clockTimes });
        log(`  ✓ ${filename}${overtime ? '(实打卡晚于 ' + threshold + ')' : ''}`);
      } catch (e) {
        summary.errors.push({ date, message: String(e.message || e).slice(0, 300) });
        log(`  ✗ ${date}: ${String(e.message || e).slice(0, 200)}`);
      } finally {
        if (detail) { try { closeDetail(detail); } catch (_) { /* ignore */ } }
      }
    };

    const pendingVisible = visible.filter(needsCapture);
    const pendingMissing = missing.filter(needsCapture);
    const pending = [...pendingVisible, ...pendingMissing];
    summary.skipped = [...visible, ...missing].filter(d => !needsCapture(d)).sort();
    if (summary.skipped.length) log(`跳过已抓取 ${summary.skipped.length} 天`);
    summary.batches = Math.ceil(pending.length / pageSize);

    // 阶段 A:当前页已渲染的日期,直接逐条点击
    for (const date of pendingVisible) {
      captureOne(date);
    }

    // 阶段 B:虚拟表格未渲染的日期,改为单日筛选后再点击
    if (pendingMissing.length) {
      log(`启用逐日筛选补漏 ${pendingMissing.length} 天(每页 ${pageSize} 条,本批 ${Math.min(pendingMissing.length, pageSize)} 条起)`);
      for (const date of pendingMissing) {
        let done = false;
        for (let attempt = 1; attempt <= 2 && !done; attempt++) {
          try {
            if (attempt > 1) { try { ab(['press', 'Escape']); } catch (_) {} wait(400); }
            setDateRange(date, date);
            wait(1200);
            const nowVisible = listDates();
            if (!nowVisible.includes(date)) {
              log(`  · ${date} 无考勤记录,跳过`);
              done = true;
              break;
            }
            captureOne(date);
            done = true;
          } catch (e) {
            const msg = String(e.message || e).slice(0, 200);
            log(`  · ${date} 第 ${attempt} 次单日筛选失败: ${msg}`);
            if (attempt === 2) summary.errors.push({ date, message: msg });
          }
        }
      }
    }

    // 无论本次是否有新截图,都把全量台账同步进 Excel(后续抓取其他时期时追加新行)
    try {
      syncExcel(state, summary, thresholdMin, excelPath);
    } catch (e) {
      summary.excelError = String(e.message || e).slice(0, 400);
      summary.errors.push({ date: 'excel', message: summary.excelError });
      log(`✗ Excel 台账写入失败: ${summary.excelError}`);
    }
  } finally {
    try { ab(['close']); } catch (_) { /* ignore */ }
  }

  fs.writeFileSync(path.join(outDir, 'last-run.json'), JSON.stringify(summary, null, 2), 'utf8');
  process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
  if (summary.errors.length) process.exitCode = 2;
}

try { main(); }
catch (e) {
  process.stderr.write(`[capture] 致命错误: ${e.stack || e.message}\n`);
  process.exitCode = 1;
}
#!/usr/bin/env python3
"""iTalent 加班时长台账:创建 / 追加写入 xlsx,并返回加班分钟数汇总。

输入(stdin JSON):
  {
    "excelPath": "/abs/path/加班时长统计.xlsx",
    "rows": [
      {"date":"2026-11-05","weekday":"星期四","firstClock":"08:46",
       "lastClock":"18:50","overtime":true,"minutes":50,
       "file":"2026-11-05加班.png","updatedAt":"2026-11-06T10:01:09Z"}
    ],
    "runDates": ["2026-11-05"]   // 本次新抓取的日期,用于计算本次分钟数
  }

输出(stdout JSON):
  {"excelPath":..., "rowCount":6, "added":6, "updated":0,
   "totalMinutes":313, "runMinutes":50, "created":true}
"""

import json
import os
import sys
from datetime import date, datetime

from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side

HEADERS = ['考勤日期', '星期', '最早实打卡', '最晚实打卡', '加班判定', '加班分钟数', '截图文件', '更新时间']
WIDTHS = [14, 10, 14, 14, 10, 13, 26, 21]

WEEKDAYS = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']

HEADER_FILL = PatternFill('solid', fgColor='1F4E79')
HEADER_FONT = Font(bold=True, color='FFFFFF', size=11)
OT_FILL = PatternFill('solid', fgColor='FCE4D6')
OT_FONT = Font(bold=True, color='C00000')
TOTAL_FILL = PatternFill('solid', fgColor='E7E6E6')
TOTAL_FONT = Font(bold=True, size=11)
CENTER = Alignment(horizontal='center', vertical='center')
THIN = Side(style='thin', color='D9D9D9')
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)


def weekday_of(s):
    try:
        d = date.fromisoformat(s[:10])
        return WEEKDAYS[d.weekday()]
    except Exception:
        return ''


def short_time(s):
    if not s:
        return ''
    t = str(s).strip()
    if 'T' in t:
        t = t.split('T', 1)[1]
    if ' ' in t:
        t = t.split(' ', 1)[1]
    return t[:5]


def stamp(s):
    if not s:
        return ''
    t = str(s).strip()
    if 'T' in t:
        t = t.replace('T', ' ')
    return t[:19]


def read_existing(path):
    """读取已有台账,返回 {date: [8 列值]};文件不存在或损坏时返回空。"""
    if not os.path.exists(path):
        return {}
    try:
        wb = load_workbook(path, data_only=True)
        ws = wb.active
        header = [c.value for c in ws[1]]
        col = {str(v).strip(): i for i, v in enumerate(header) if v}
        date_i = col.get('考勤日期', 0)
        minute_i = col.get('加班分钟数', 5)
        out = {}
        for row in ws.iter_rows(min_row=2, values_only=True):
            d = row[date_i] if date_i < len(row) else None
            if d is None:
                continue
            d = str(d).strip()[:10]
            if not d or d in ('合计', '总计') or not d[:4].isdigit():
                continue
            vals = list(row[:len(HEADERS)]) + [''] * max(0, len(HEADERS) - len(row))
            vals = vals[:len(HEADERS)]
            vals[0] = d
            vals[1] = vals[1] or weekday_of(d)
            try:
                vals[5] = int(float(vals[5] or 0))
            except (TypeError, ValueError):
                vals[5] = 0
            out[d] = vals
        return out
    except Exception:
        return {}


def main():
    payload = json.loads(sys.stdin.read() or '{}')
    excel_path = payload.get('excelPath')
    if not excel_path:
        raise SystemExit('缺少 excelPath')
    rows = payload.get('rows', [])
    run_dates = set(payload.get('runDates', []))
    created = not os.path.exists(excel_path)

    merged = read_existing(excel_path)
    added = updated = 0
    for item in rows:
        d = str(item.get('date', ''))[:10]
        if not d:
            continue
        if d in merged:
            updated += 1
        else:
            added += 1
        merged[d] = [
            d,
            item.get('weekday') or weekday_of(d),
            short_time(item.get('firstClock')),
            short_time(item.get('lastClock')),
            '是' if item.get('overtime') else '否',
            int(item.get('minutes', 0) or 0),
            item.get('file', ''),
            stamp(item.get('updatedAt')),
        ]

    keys = sorted(merged)
    wb = Workbook()
    ws = wb.active
    ws.title = '加班时长'
    ws.append(HEADERS)
    for i, w in enumerate(WIDTHS, start=1):
        ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
    for c in ws[1]:
        c.fill = HEADER_FILL
        c.font = HEADER_FONT
        c.alignment = CENTER
        c.border = BORDER
    ws.row_dimensions[1].height = 24

    for d in keys:
        ws.append(merged[d])
        r = ws.max_row
        for c in ws[r]:
            c.border = BORDER
            c.alignment = CENTER if c.column != 7 else Alignment(horizontal='left', vertical='center')
        minutes = merged[d][5]
        if minutes > 0:
            ws.cell(row=r, column=6).fill = OT_FILL
            ws.cell(row=r, column=6).font = OT_FONT
        ws.cell(row=r, column=6).number_format = '0'

    total = sum(int(merged[d][5]) for d in keys)
    ws.append(['合计', '', '', '', '', total, '', ''])
    r = ws.max_row
    for c in ws[r]:
        c.fill = TOTAL_FILL
        c.font = TOTAL_FONT
        c.border = BORDER
        c.alignment = CENTER
    ws.cell(row=r, column=6).number_format = '0'

    ws.freeze_panes = 'A2'
    ws.auto_filter.ref = f'A1:H{max(1, ws.max_row - 1)}'

    parent = os.path.dirname(os.path.abspath(excel_path))
    if parent:
        os.makedirs(parent, exist_ok=True)
    wb.save(excel_path)

    run_minutes = sum(int(merged[d][5]) for d in run_dates if d in merged)
    print(json.dumps({
        'excelPath': os.path.abspath(excel_path),
        'rowCount': len(keys),
        'added': added,
        'updated': updated,
        'totalMinutes': total,
        'runMinutes': run_minutes,
        'created': created,
    }, ensure_ascii=False))


if __name__ == '__main__':
    main()
# 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/