Skip to content

制作生产级数字人

本文面向有一定技术背景的用户。帮助你从"能跑通 demo"升级到"可以稳定运行的生产级数字人"。借助 AI,即使没有编程经验也能完成大部分操作。


为什么 AI 浏览器不够用

很多人第一次创建数字人时,会让 AI 浏览器去操作页面——点击、填表、截图。这在探索阶段没问题,但在生产环境会遇到三个无法回避的问题:

问题说明
token 消耗巨大每次 DOM 快照都要消耗大量 token,频繁运行成本极高
响应慢每个操作步骤都依赖 AI 实时推理,整体延迟高
不确定性高AI 每次推理路径不同,同样的操作结果可能不一致

结论:AI 浏览器只适合一次性操作或探索阶段。 生产环境中,AI 不能直接操作页面——所有重复性操作必须封装进 Skill 脚本,由脚本确定性执行。脚本内部用 API 调用还是 DOM 操作都可以,关键是 AI 不参与逐步推理。


正确架构:AI 编排,Skill 执行

生产级数字人的核心原则只有一句话:

AI 负责编排,Skill 负责执行。

AI 的工作是:读取配置 → 调用 Skill A → 读结果 → 决定下一步 → 调用 Skill B。所有复杂的浏览器交互(DOM 操作、XHR 拦截、表单提交)都封装在确定性的 Skill 脚本中,AI 不直接碰页面。

AI 数字人
├── system_prompt    → 编排逻辑(编号步骤 + 精确工具调用)
├── Skill 脚本       → 确定性执行(XHR 拦截、API 调用)
└── memory_schema    → 状态持久化(已处理记录、上次运行时间)

制作流程:四个阶段

第一阶段:侦察

在写任何代码之前,先用 AI 浏览器交互式了解目标平台。

要搞清楚的事情:

  • 完整的操作路径(搜索 → 列表 → 详情 → 动作)
  • 哪些页面需要登录,哪些不需要
  • 每个用户操作背后调用了哪个 API 接口(用 browser_console 观察 XHR 请求)
  • 平台是否有 CSRF Token、Session Token 或频率限制

URL Token 陷阱

部分平台要求 URL 中携带 Session 级别的安全 Token。直接拼接 /item/{id} 访问会被 302 跳转到首页。如果 AI 浏览器点击跳转正常、但直接访问 URL 失败,就是这个原因——Skill 必须捕获并传递这些 Token。

第二阶段:Skill 开发

Skill 是封装好的确定性脚本,每个 Skill 负责一个具体操作。

数据采集优先走 XHR 拦截,而非 DOM 抓取:

js
// 推荐:拦截平台自身 API 响应,直接拿结构化 JSON
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
  this.__captured_url = url
  return origOpen.call(this, method, url, ...rest)
}
// 触发平台 UI 动作 → 收集拦截到的 API 响应

执行操作可以用封装的 DOM 操作:

对于点击按钮、提交表单等动作,Skill 脚本内部直接操作 DOM 完全没问题。脚本是确定性的 JavaScript,不依赖 AI 实时推理,三个问题都不存在。

js
// Skill 脚本内部封装点击、填表等操作 —— 这是可以的
document.querySelector('[data-testid="submit-btn"]').click()

AI 只需调用一次 browser_run,不参与任何 DOM 决策。

Skill 返回的链接必须包含安全 Token:

js
// 正确:完整链接,含 Token
link: `${baseUrl}/${item.id}?token=${encodeURIComponent(item.security_token)}`

// 错误:裸链接,会被拦截
link: `${baseUrl}/${item.id}`

每个 Skill 脚本必须满足:

  1. 是单个 async (params) => { ... } 箭头函数
  2. 返回 JSON 可序列化的结果
  3. 失败时返回 { success: false, error: "..." },不抛出异常
  4. finally 块中清理所有拦截器

第三阶段:Spec 设计(system_prompt)

system_prompt 是数字人的"工作手册"。针对弱模型的核心规则:

  1. 编号步骤,不用段落。 每一步 = 一次工具调用。
  2. 写出精确调用语法。browser_run({ file: "...", params: {...} }),不要写"调用搜索 Skill"(模糊)。
  3. 显式列出禁止项。 弱模型会自由发挥,必须逐一禁止:
    • 禁止自己拼接 URL
    • 禁止用 Skill / Task 工具调用浏览器 Skill(必须用 browser_run
    • 禁止用 browser_click / browser_fill 操作 Skill 已处理的输入框
  4. 首次导航用 browser_new_page 自动化运行启动时没有活跃页面。
  5. 每步独立错误处理。 失败时明确说明:跳过 / 停止 / 报告。

system_prompt 结构模板:

你是...(一句话角色描述)
严格按照下面的步骤执行,不要跳步,不要自由发挥。

## 禁止事项
- 禁止...(逐条列出)

## 配置
从 User Configuration(JSON)读取。缺失项使用默认值:
- key: 默认值

## 第一步:(操作名称)
1. 调用:browser_new_page({ url: "..." })
2. 等待页面加载
3. 检查返回值:失败 → 停止;成功 → 继续

## 第二步:...

## 最后一步:更新记忆并报告

第四阶段:测试

用最弱的目标模型(如 Qwen)运行,验证零自由发挥。 如果弱模型都能严格按步骤执行,强模型更不会出问题。


用 AI 辅助创建生产级数字人

你不需要从零手写 spec。在 Halo 对话中使用下面的提示词,AI 会自动理解提示工程的设计要求,并和你讨论细节:

生成一个 [名称] 数字人,背景是:[背景描述]
数字人的主要功能是:[功能描述]
目标:[目标]

可参考下面的经典设计案例:
- 小红书互动数字人:https://github.com/openkursar/digital-human-protocol/tree/main/packages/digital-humans/xiaohongshu-ai-engager
- 会议室预订数字人:https://github.com/openkursar/digital-human-protocol/tree/main/packages/digital-humans/meeting-room-booker

进行提示工程优化,参考 Halo 内置的浏览器自动化设计手册的设计思路。
有任何细节和我讨论。
生成一个高质量、100% 完整的数字人。

TIP

使用 Claude Sonnet 或更强的模型生成,质量更高。AI 会主动询问你平台的 API 细节、登录方式、操作频率等关键信息,不要跳过这些讨论。


标杆案例解析

下面是两个完整的生产级数字人案例,包含全部源码,可以直接参考或复制。


案例一:小红书 AI 互动数字人

定期搜索指定关键词下的笔记,用 AI 生成个性化评论并发布。

核心设计要点:

  • 搜索 Skill:拦截小红书搜索 API 的 XHR 响应,返回含完整安全 Token 的帖子链接
  • 评论 Skill:通过 execCommand + Vue 内部机制注入文字,而非模拟键盘输入
  • 去重机制memory_schema 中的 commented_posts 记录已评论帖子 ID,避免重复操作
  • 禁止项:明确禁止 AI 自己拼接 /explore/{id} 格式的 URL(会被平台拦截)
spec.yaml(点击展开)
yaml
spec_version: "1"
name: 小红书互动助手
version: "2.1.0"
author: openkursar
description: 自动搜索小红书相关帖子,以真实用户语气发表简短评论,促进社区互动。
type: automation
icon: social
system_prompt: |-
  你是一名真实的小红书用户,会主动搜索相关帖子并发表简短、自然的评论。
  严格按照下面的步骤执行,不要跳步,不要自由发挥。

  ## 禁止事项
  - 禁止自己拼接 /explore/{id} URL。打开帖子只能用 xhs-search 返回的 post.link 字段。
  - 禁止用 browser_click / browser_fill 操作评论框。发评论只能用 browser_run 调用 xhs-comment skill。
  - 禁止在 browser_run(xhs-comment) 之前做任何 DOM 预操作(snapshot、click 评论框等)。
  - xhs-comment 返回 success: false → 记录错误,跳过该帖,禁止任何补救操作。
  - 禁止用 Skill 工具或 Task 工具调用 xhs-search / xhs-comment。只能用 browser_run。

  ## 配置
  从 User Configuration(JSON)读取。缺失项使用默认值:
  - keywords: "AI"
  - min_likes: 50
  - max_comments_per_run: 3
  - exclude_keywords: "广告, 合作, 赞助"

  ## 第一步:搜索帖子

  1. 取 keywords 第一个词(逗号或中文逗号前的部分)
  2. 打开搜索页:
     browser_new_page({ url: "https://www.xiaohongshu.com/search_result?keyword=<关键词>&source=web_search_result_notes" })
  3. 等待页面加载(browser_wait_for "筛选",超时 10 秒)
  4. 调用搜索 skill 获取帖子列表:
     browser_run({ file: ".claude/skills/xhs-search/index.js", params: { sort_by: "time_descending", time_range: "一周内", pages: 2 } })
  5. 检查返回值:
     - success: false → 报告错误并停止
     - success: true → 拿到 posts 数组,每个 post 包含 id, title, liked_count, link(已含 xsec_token)

  ## 第二步:筛选帖子

  从 posts 中去掉:
  - post.id 已在 memory.commented_posts 中的
  - post.liked_count < min_likes 的
  - post.title 含 exclude_keywords 中任意词的

  按 liked_count 从高到低排序,取前 max_comments_per_run 个。
  如果一个都没有 → 报告"本次未找到新的合适帖子"并结束。

  ## 第三步:逐帖评论

  对筛选出的每个 post,严格按顺序执行以下 4 小步:

  **3a** browser_navigate({ url: post.link })
  (直接使用 xhs-search 返回的 link,不要自己拼 URL)

  **3b** browser_wait_for({ text: "关注", timeout: 15000 })
       browser_wait({ timeout: 3000 })
  - 超时 → 跳过此帖,继续下一个

  **3c** 根据 post.title 和 persona_description 写一句评论:
  - 中文,不超过 30 字
  - 回应帖子具体内容(禁止"好棒""学到了"等空泛夸赞)
  - 不带链接、话题标签、品牌推广

  **3d** browser_run({ file: ".claude/skills/xhs-comment/index.js", params: { content: "<评论>" } })
  - success: true → 记录 post.id 到已评论列表
  - success: false → 记录 error,跳过

  ## 第四步:更新记忆并报告

  更新 memory.md:
  - 追加成功评论的 post.id 到 commented_posts(上限 2000)
  - 设置 last_run_at 为当前时间

  调用 report_to_user 输出 Markdown 报告:
  - 搜索帖子总数 / 符合条件数 / 成功评论数 / 失败数
  - 每条成功评论:帖子标题(附 link)+ 评论内容
  - 跳过原因汇总

config_schema:
  - key: keywords
    label: 搜索关键词
    type: string
    required: true
    default: "AI"
    placeholder: "AI, 大模型, Agent"
    description: 在小红书搜索的关键词,多个词用逗号分隔;第一个词用于搜索 URL,所有词用于相关性参考
  - key: persona_description
    label: 人设描述
    type: text
    required: false
    default: ""
    placeholder: "我是一名每天使用 AI 工具的软件开发者,喜欢分享实用经验。"
    description: 评论者的背景身份,引导评论语气和视角;留空则使用默认人设(AI 开发者 / 技术爱好者)
  - key: min_likes
    label: 最低点赞数
    type: number
    required: true
    default: 50
    description: 帖子点赞数须达到此值才纳入评论范围
  - key: max_comments_per_run
    label: 单次最大评论数
    type: number
    required: true
    default: 3
    description: 每次运行最多发表评论的帖子数量
  - key: exclude_keywords
    label: 排除关键词
    type: string
    required: false
    default: "广告, 合作, 赞助"
    placeholder: "广告, 合作"
    description: 逗号分隔,标题包含这些词的帖子将被跳过

subscriptions:
  - id: engagement-run
    source:
      type: schedule
      config:
        every: 4h
    frequency:
      default: 4h
      min: 2h
      max: 24h

requires:
  mcps:
    - id: ai-browser
      reason: 浏览小红书搜索结果、打开帖子详情页、执行 skill 脚本
  skills:
    - id: xhs-search
      reason: 通过 XHR 拦截 + Pinia store 触发搜索,返回结构化帖子数据
      bundled: true
      files:
        - SKILL.md
        - index.js
    - id: xhs-comment
      reason: 通过 execCommand + XHR 拦截可靠地发表评论并确认成功
      bundled: true
      files:
        - SKILL.md
        - index.js

memory_schema:
  commented_posts:
    type: array
    description: 已评论的帖子 ID 列表,防止重复评论(最多保留 2000 条)
  last_run_at:
    type: date
    description: 最近一次成功运行的时间戳

output:
  notify:
    system: true
  format: markdown

browser_login:
  - url: "https://www.xiaohongshu.com"
    label: "小红书"

permissions:
  - ai-browser

escalation:
  enabled: true
  timeout_hours: 24

store:
  slug: xiaohongshu-ai-engager
  category: social
  tags: [xiaohongshu, social, engagement, AI, community, 小红书, 互动]
  locale: zh-CN
  min_app_version: "0.5.0"
  license: MIT
  repository: https://github.com/openkursar/digital-human-protocol
  meta:
    rank: 4
Skill:搜索帖子 xhs-search — XHR 拦截 + Pinia store 触发搜索
js
/**
 * XHS Search — browser_run script
 *
 * Executes inside the Xiaohongshu search results page context.
 * Checks login via direct fetch, installs XHR interceptor for search results,
 * triggers search via Pinia store, and collects structured post data.
 *
 * Contract:
 *   - Single async arrow function (invoked by browser_run via evaluateScript)
 *   - Receives params object as first argument
 *   - Returns JSON-serializable result
 *   - Page must already be navigated to XHS search results URL
 *   - User session/cookies are automatically available
 *   - Errors returned as { success: false, error: "..." }, never thrown
 */
async (params) => {
  const {
    sort_by = 'general',
    time_range = '不限',
    pages = 1
  } = params || {}

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  const log = (...a) => console.log('[xhs]', ...a)

  log('start', { sort_by, time_range, pages })

  // ------------------------------------
  // 1. Install XHR interceptor for search results
  // ------------------------------------
  window.__xhs_searchResps = []

  const origOpen = XMLHttpRequest.prototype.open
  const origSend = XMLHttpRequest.prototype.send

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this.__xhs_url = typeof url === 'string' ? url : String(url || '')
    return origOpen.call(this, method, url, ...rest)
  }

  XMLHttpRequest.prototype.send = function (body) {
    const url = this.__xhs_url || ''
    if (url.includes('search/notes')) {
      log('XHR matched:', url)
      this.addEventListener('load', function () {
        log('XHR load, status:', this.status, 'length:', this.responseText?.length)
        try { window.__xhs_searchResps.push(JSON.parse(this.responseText)) } catch (_) {}
      })
      this.addEventListener('error', () => log('XHR error'))
      this.addEventListener('abort', () => log('XHR abort'))
    }
    return origSend.call(this, body)
  }

  log('XHR interceptor installed')

  // ------------------------------------
  // 2. Check login via direct fetch
  // ------------------------------------
  try {
    // log('fetching user/me...')
    // const meData = await fetch('https://edith.xiaohongshu.com/api/sns/web/v2/user/me', { credentials: 'include' }).then(r => r.json())
    // log('user/me response:', meData.success, 'guest:', meData.data?.guest)

    // if (!meData.success || meData.data?.guest !== false) {
    //   return { success: false, logged_in: false, error: 'Not logged in to Xiaohongshu' }
    // }
    // const user = {
    //   user_id: meData.data.user_id || '',
    //   nickname: meData.data.nickname || ''
    // }
    // log('logged in:', user.nickname)

    // ------------------------------------
    // 3. Trigger search with filters
    // ------------------------------------
    log('accessing Pinia...')
    const pinia = document.querySelector('#app')?.__vue_app__?.config?.globalProperties?.$pinia
    log('pinia found:', !!pinia)
    const searchStore = pinia?._s.get('search')
    log('searchStore found:', !!searchStore)

    if (!searchStore) {
      return { success: false, logged_in: true, error: 'Search store not found' }
    }

    const sortTag = sort_by === 'general' ? 'general' : sort_by
    searchStore.searchContext.filters = [
      { tags: [sortTag], type: 'sort_type' },
      { tags: ['不限'], type: 'filter_note_type' },
      { tags: [time_range], type: 'filter_note_time' },
      { tags: ['不限'], type: 'filter_note_range' },
      { tags: ['不限'], type: 'filter_pos_distance' }
    ]
    searchStore.feeds = []
    searchStore.searchContext.page = 1
    window.__xhs_searchResps = []
    log('calling searchNotes...')
    searchStore.searchNotes()
    log('searchNotes called')

    // ------------------------------------
    // 4. Collect results (with pagination)
    // ------------------------------------
    const maxPages = Math.min(Math.max(pages, 1), 5)
    const allPosts = []

    for (let page = 1; page <= maxPages; page++) {
      log(`waiting for page ${page}...`)
      for (let i = 0; i < 15; i++) {
        await sleep(500)
        if (window.__xhs_searchResps.length >= page) break
      }
      log(`page ${page} captured: ${window.__xhs_searchResps.length}`)

      const resp = window.__xhs_searchResps[page - 1]
      if (!resp || !resp.data) {
        if (page === 1) return { success: false, logged_in: true, error: 'Search API did not respond within timeout' }
        break
      }

      const items = (resp.data.items || []).filter(i => i.model_type === 'note' && i.note_card)
      log(`page ${page} items:`, items.length)
      for (const item of items) allPosts.push(extractPost(item))

      const hasMore = resp.data.has_more === true
      if (!hasMore || page >= maxPages) break

      searchStore.searchContext.page = page + 1
      searchStore.searchNotes()
      await sleep(1500)
    }

    // ------------------------------------
    // 5. Cleanup and return
    // ------------------------------------
    XMLHttpRequest.prototype.open = origOpen
    XMLHttpRequest.prototype.send = origSend
    const lastResp = window.__xhs_searchResps?.[window.__xhs_searchResps?.length - 1]
    delete window.__xhs_searchResps

    log('done, total posts:', allPosts.length)
    return {
      success: true,
      logged_in: true,
      posts: allPosts,
      total: allPosts.length,
      has_more: lastResp?.data?.has_more === true
    }
  } catch (err) {
    XMLHttpRequest.prototype.open = origOpen
    XMLHttpRequest.prototype.send = origSend
    delete window.__xhs_searchResps
    log('error:', err?.message)
    return { success: false, error: String(err?.message || err) }
  }

  function extractPost(item) {
    const card = item.note_card
    const interact = card.interact_info || {}
    const token = item.xsec_token || ''
    const base = `https://www.xiaohongshu.com/explore/${item.id}`
    return {
      id: item.id,
      title: card.display_title || '',
      author: card.user?.nickname || card.user?.nick_name || '',
      liked_count: parseCount(interact.liked_count),
      collected_count: parseCount(interact.collected_count),
      comment_count: parseCount(interact.comment_count),
      type: card.type || 'normal',
      xsec_token: token,
      link: token
        ? `${base}?xsec_token=${encodeURIComponent(token)}&xsec_source=pc_search`
        : base
    }
  }

  function parseCount(val) {
    if (val == null) return 0
    const str = String(val).trim()
    if (!str) return 0
    if (str.endsWith('万')) return Math.round(parseFloat(str) * 10000)
    const num = parseInt(str, 10)
    return isNaN(num) ? 0 : num
  }
}
Skill:发表评论 xhs-comment — execCommand 注入 + XHR 拦截确认
js
/**
 * XHS Comment — browser_run 脚本
 *
 * 在小红书帖子详情页上下文中执行。
 * 通过 execCommand + input 事件注入评论文字,触发 Vue 响应式,
 * 点击发送按钮,并拦截 XHR 确认评论已发布。
 *
 * 契约:
 *   - 单个 async 箭头函数,由 browser_run 通过 evaluateScript 调用
 *   - 接收 params 对象作为第一个参数
 *   - 返回 JSON 可序列化的结果
 *   - 页面必须已导航到帖子详情页(explore/{note_id})
 *   - 用户 session/cookies 自动可用
 *   - 错误以 { success: false, error: "..." } 形式返回,不抛出异常
 */
async (params) => {
  const { content } = params
  const log = (...a) => console.log('[xhs-comment]', ...a)
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))

  if (!content?.trim()) {
    return { success: false, error: '评论内容不能为空' }
  }

  log('开始,内容长度:', content.length)

  // ------------------------------------
  // 1. 安装 XHR 拦截器,监听 comment/post 响应
  // ------------------------------------
  window.__xhs_commentResp = null
  const origOpen = XMLHttpRequest.prototype.open
  const origSend = XMLHttpRequest.prototype.send

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this.__xhs_url = typeof url === 'string' ? url : String(url || '')
    return origOpen.call(this, method, url, ...rest)
  }

  XMLHttpRequest.prototype.send = function (body) {
    if (this.__xhs_url?.includes('comment/post')) {
      log('XHR 匹配: comment/post')
      this.addEventListener('load', function () {
        log('XHR load, status:', this.status)
        try {
          window.__xhs_commentResp = { status: this.status, body: JSON.parse(this.responseText) }
        } catch (_) {
          window.__xhs_commentResp = { status: this.status, parseError: true, raw: this.responseText?.slice(0, 100) }
        }
      })
    }
    return origSend.call(this, body)
  }

  try {
    // ------------------------------------
    // 2. 找到评论输入框
    // ------------------------------------
    const input = document.querySelector('.content-input')
    if (!input) {
      return { success: false, error: '未找到评论输入框(.content-input)' }
    }
    log('找到输入框:', input.tagName, input.className)

    // ------------------------------------
    // 3. 注入文字(execCommand 适配 contenteditable)
    //    + 派发 input 事件触发 Vue 响应式
    // ------------------------------------
    input.focus()
    document.execCommand('insertText', false, content)
    input.dispatchEvent(new Event('input', { bubbles: true }))
    log('文字已注入:', input.innerText?.slice(0, 30))

    // ------------------------------------
    // 4. 等待 Vue nextTick 激活发送按钮(约 400ms)
    // ------------------------------------
    await sleep(400)

    // ------------------------------------
    // 5. 找到发送按钮并点击
    // ------------------------------------
    const sendBtn = [...document.querySelectorAll('button')].find(b => b.innerText.trim() === '发送')
    if (!sendBtn) {
      return { success: false, error: '未找到发送按钮', inputText: input.innerText }
    }
    if (sendBtn.disabled) {
      return { success: false, error: '发送按钮仍处于禁用状态(Vue 未激活)', inputText: input.innerText }
    }

    log('点击发送...')
    sendBtn.click()

    // ------------------------------------
    // 6. 等待 API 响应(最多 6 秒)
    // ------------------------------------
    for (let i = 0; i < 20; i++) {
      await sleep(300)
      if (window.__xhs_commentResp) break
    }

    const resp = window.__xhs_commentResp
    if (!resp) {
      return { success: false, error: 'API 超时:comment/post 未在 6 秒内响应' }
    }

    if (!resp.body?.success) {
      return {
        success: false,
        error: resp.body?.msg || 'API 返回失败',
        code: resp.body?.code
      }
    }

    log('成功:', resp.body.data?.toast)
    return {
      success: true,
      comment_id: resp.body.data?.comment?.id,
      content: resp.body.data?.comment?.content,
      toast: resp.body.data?.toast
    }
  } finally {
    // ------------------------------------
    // 7. 清理拦截器
    // ------------------------------------
    XMLHttpRequest.prototype.open = origOpen
    XMLHttpRequest.prototype.send = origSend
    delete window.__xhs_commentResp
  }
}

案例二:会议室自动预订数字人

典型的企业内网自动化案例。完全走 API 接口,没有任何 DOM 操作。每天自动预订 14 天窗口内的会议室,按楼层优先级查找,支持预检查和结果验证。

INFO

以下源码中的 URL 和公司名称已脱敏处理,实际使用时替换为你自己的内网地址即可。

核心设计要点:

  • 五个 Skill,各负责一个 API 操作:查用户信息、查会议室、预订、查已有预订、取消预订
  • memory_schema 持久化已预订日期,避免重复预订
  • system_prompt 严格禁止所有 DOM 操作,AI 只做编排
spec.yaml(点击展开)
yaml
spec_version: "1"
name: 会议室自动预订数字人
version: "1.0.0"
author: your-team
description: 定期自动预订14天窗口内的周一例会会议室,按楼层优先级查找中会议室,支持预检查和结果验证。
type: automation
icon: calendar

subscriptions:
  - source:
      type: schedule
      config:
        every: 24h

escalation:
  enabled: true

requires:
  skills:
    - id: meeting-get-user-info
      bundled: true
      files:
        - index.js
    - id: meeting-get-rooms
      bundled: true
      files:
        - index.js
    - id: meeting-book-room
      bundled: true
      files:
        - index.js
    - id: meeting-get-my-bookings
      bundled: true
      files:
        - index.js
    - id: meeting-cancel-booking
      bundled: true
      files:
        - index.js

memory_schema:
  booked_dates:
    type: object
    description: 已成功预订的日期映射,key 为日期字符串(YYYY-MM-DD),value 为 {roomCode, roomId, bookingId, timestamp}
  last_run_time:
    type: string
    description: 上次运行时间,ISO 8601 格式
  consecutive_failures:
    type: number
    description: 连续失败次数,成功后归零

config_schema:
  - key: meeting_time_start
    label: 会议开始时间
    type: string
    required: true
    default: "16:00"
    description: 会议开始时间(HH:mm 格式)
    placeholder: "16:00"

  - key: meeting_time_end
    label: 会议结束时间
    type: string
    required: true
    default: "19:00"
    description: 会议结束时间(HH:mm 格式)
    placeholder: "19:00"

  - key: meeting_type
    label: 会议室类型
    type: select
    required: true
    default: 中会议室
    description: 优先预订的会议室类型
    options:
      - label: 中会议室
        value: 中会议室
      - label: 小会议室
        value: 小会议室
      - label: 洽谈室
        value: 洽谈室
      - label: 大会议室
        value: 大会议室

  - key: floor_priority
    label: 楼层优先级
    type: string
    required: true
    default: "20,19,21"
    description: 按逗号分隔的楼层优先级,从高到低
    placeholder: "20,19,21"

  - key: meeting_theme
    label: 会议主题
    type: string
    required: false
    default: 周一例会
    description: 预订时填写的会议主题
    placeholder: 周一例会

  - key: target_weekday
    label: 目标星期几
    type: select
    required: true
    default: "1"
    description: 需要预订的星期几(1=周一,2=周二...)
    options:
      - label: 周一
        value: "1"
      - label: 周二
        value: "2"
      - label: 周三
        value: "3"
      - label: 周四
        value: "4"
      - label: 周五
        value: "5"

  - key: check_bookers
    label: 预检查预订人
    type: string
    required: false
    default: ""
    description: 预检查时除自己外还要检查的预订人(逗号分隔),如有任一人已预订则跳过该日期
    placeholder: "user1,user2"

output:
  notify:
    system: true
  format: "会议室预订:{date} {roomCode} {status}"

permissions:
  - ai-browser

system_prompt: |-
  你是一个会议室自动预订数字人。严格按照下面的步骤执行,不要跳步,不要自由发挥。

  ## 禁止事项
  - 禁止用 browser_click / browser_fill / browser_snapshot / browser_evaluate / browser_console 操作会议室页面;所有操作只能通过 browser_run 调用 skill 脚本
  - 禁止用 Skill 工具或 Task 工具调用 skill(必须用 browser_run,file 参数用精确路径)
  - 禁止自己拼接 API URL 或直接 fetch 接口(必须通过 skill 脚本)
  - 禁止重复预订 memory.booked_dates 中已有记录的日期

  ## 配置读取
  从 User Configuration(JSON)中读取以下字段,缺失时使用默认值:
  - meeting_time_start: 默认 "16:00"
  - meeting_time_end: 默认 "19:00"
  - meeting_type: 默认 "中会议室"
  - floor_priority: 默认 "20,19,21"(字符串,逗号分隔)
  - meeting_theme: 默认 "周一例会"
  - target_weekday: 默认 "1"(周一)
  - check_bookers: 默认 ""(逗号分隔的额外检查预订人)

  将 floor_priority 解析为数组,例如 "20,19,21" → ["20", "19", "21"]
  将 meeting_time_start/end 解析为小时数,例如 "16:00" → 16,"19:00" → 19

  ## 第一步:建立浏览器会话

  调用:
  browser_new_page({ url: "https://meeting.your-company.com/#/" })

  等待约 3 秒页面加载。

  ## 第二步:获取当前用户信息

  调用:
  browser_run({ file: ".claude/skills/meeting-get-user-info/index.js" })

  如果 success: false → 停止本次运行,报告错误"未登录或会话已过期"。
  记录 username 和 deptName 供后续使用。

  ## 第三步:计算需要预订的日期

  1. 获取当前日期(today)
  2. 计算14天窗口:today 到 today+13 天
  3. 在这个窗口内,找出所有目标星期几(target_weekday)的日期
  4. 排除 memory.booked_dates 中已有记录的日期
  5. 将剩余日期记为 pending_dates 列表

  如果 pending_dates 为空 → 更新 memory.last_run_time,报告"14天窗口内所有目标日期均已预订",停止。

  ## 第四步:预检查已有预订

  对 pending_dates 中的每个日期执行预检查:

  调用:
  browser_run({ file: ".claude/skills/meeting-get-my-bookings/index.js", params: { filterDate: "{日期}" } })

  检查返回的 bookings 列表中是否有:
  - 当前用户(username)在 meeting_time_start ~ meeting_time_end 时段的预订
  - check_bookers 中任一用户在该时段的预订

  如果已有预订 → 从 pending_dates 中移除该日期,记录 memory.booked_dates[日期] = { roomCode: "已有预订", timestamp: now }

  如果预检查后 pending_dates 为空 → 报告"所有日期已有预订",停止。

  ## 第五步:按楼层优先级查找并预订

  对 pending_dates 中的每个日期,按以下逻辑执行:

  ```
  for each date in pending_dates:
      booked = false
      for each floor in floor_priority:
          调用:
          browser_run({
              file: ".claude/skills/meeting-get-rooms/index.js",
              params: {
                  bookDate: "{date}",
                  floor: "{floor}",
                  meetingType: "{meeting_type}",
                  startHour: {startHour},
                  endHour: {endHour}
              }
          })

          如果 success: false → 记录错误,尝试下一楼层
          如果 rooms 列表为空 → 尝试下一楼层

          取第一个可用房间 room = rooms[0]

          调用:
          browser_run({
              file: ".claude/skills/meeting-book-room/index.js",
              params: {
                  roomId: room.roomId,
                  startTime: "{date} {meeting_time_start}:00",
                  endTime: "{date} {meeting_time_end}:00",
                  booker: "{username}",
                  bookerDept: "{deptName}",
                  theme: "{meeting_theme}"
              }
          })

          如果 success: true →
              记录 memory.booked_dates[date] = {
                  roomCode: room.roomCode,
                  roomId: room.roomId,
                  bookingId: result.bookingId,
                  floor: floor,
                  timestamp: now
              }
              booked = true
              跳出楼层循环(break)

          如果 success: false →
              记录错误信息,尝试同楼层下一个房间或下一楼层

      如果 booked == false → 记录该日期预订失败(所有楼层均无可用房间)
  ```

  ## 第六步:验证预订结果

  对每个在第五步中成功预订的日期进行验证:

  调用:
  browser_run({
      file: ".claude/skills/meeting-get-my-bookings/index.js",
      params: { filterDate: "{date}" }
  })

  检查返回的 bookings 中是否包含刚预订的记录(匹配 roomId + 时间段)。
  如果找不到 → 标记验证失败。

  ## 第七步:更新记忆,生成报告

  1. memory.last_run_time = 当前 ISO 8601 时间
  2. 如果全部成功 → memory.consecutive_failures = 0
  3. 如果有失败 → memory.consecutive_failures += 1
  4. 生成报告,包含:
     - 每个日期的预订结果(成功/失败)
     - 成功的:房间号 + 楼层 + 时间段
     - 失败的:原因说明
     - 总结:X 个成功 / Y 个失败

  报告示例:
  ```
  会议室预订报告
  ━━━━━━━━━━━━━
  ✅ 2026-03-31(周一)→ 20F 2003 中会议室 16:00-19:00
  ✅ 2026-04-07(周一)→ 19F 1905 中会议室 16:00-19:00
  ━━━━━━━━━━━━━
  总计:2 个成功 / 0 个失败
  ```
Skill:获取用户信息 meeting-get-user-info
js
/**
 * meeting-get-user-info
 * Fetches current logged-in user info from meeting system.
 * Must run in the browser context of a logged-in meeting page.
 *
 * Returns: { success: true, username: "zhangsan(张三)", deptName: "技术部" }
 */
async (params) => {
  try {
    const resp = await fetch('https://meeting.your-company.com/eoss-meeting/sys/user/info', {
      method: 'GET',
      credentials: 'include'
    })

    if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }

    const data = await resp.json()
    if (data.code !== 0) {
      return { success: false, error: data.msg || JSON.stringify(data) }
    }

    return {
      success: true,
      username: data.data.username,
      deptName: data.data.deptName,
      workplace: data.data.workplace,
      floor: data.data.floor,
      isBlackList: data.data.blackList
    }
  } catch (e) {
    return { success: false, error: e.message }
  }
}
Skill:查询可用会议室 meeting-get-rooms
js
/**
 * meeting-get-rooms
 * Fetches meeting rooms availability for a given date.
 * Must run in the browser context of a logged-in meeting page.
 *
 * Params:
 *   bookDate       string   date in YYYY-MM-DD format, default today
 *   workplace      string   default '总部大厦'
 *   floor          string   optional floor filter e.g. '20'
 *   meetingType    string   optional type filter e.g. '中会议室'
 *   startHour      number   optional: only return rooms available at this hour (e.g. 16)
 *   endHour        number   optional: only return rooms available through this hour (e.g. 19)
 *
 * Returns rooms with availability info, filtered by params.
 */
async (params) => {
  const {
    bookDate,
    workplace = '总部大厦',
    floor,
    meetingType,
    startHour,
    endHour
  } = params || {}

  try {
    // Determine date
    const dateStr = bookDate || new Date().toISOString().slice(0, 10)
    const bookTime = `${dateStr} 00:00:00`

    // Build URL
    let url = `https://meeting.your-company.com/eoss-meeting/meeting/meetingrooms/getRooms?bookTime=${encodeURIComponent(bookTime)}&workplace=${encodeURIComponent(workplace)}&page=1&limit=999`
    if (floor) {
      url += `&floor=${encodeURIComponent(floor)}`
    }

    const resp = await fetch(url, {
      method: 'GET',
      credentials: 'include'
    })

    if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }

    const data = await resp.json()
    if (data.code !== 0) {
      return { success: false, error: data.msg || JSON.stringify(data) }
    }

    let rooms = data.data.list || []

    // Client-side filter by meetingType
    if (meetingType) {
      rooms = rooms.filter(r => r.meetingType === meetingType)
    }

    // Process availability: check if requested time slots are all free
    const result = rooms.map(room => {
      const slots = room.meetingInfoMaps || {}
      const slotEntries = []
      let allRequestedFree = true

      for (const [timeKey, value] of Object.entries(slots)) {
        const hour = parseInt(timeKey.split('T')[1].split(':')[0], 10)
        const minute = parseInt(timeKey.split('T')[1].split(':')[1], 10)
        const status = (value !== null && typeof value === 'object') ? 'booked' : 'free'
        const bookedBy = (status === 'booked' && value) ? value.booker : null
        const theme = (status === 'booked' && value) ? value.theme : null

        slotEntries.push({ time: timeKey, hour, minute, status, bookedBy, theme })

        // Check if this slot falls in requested range
        if (startHour !== undefined && endHour !== undefined) {
          const slotHourDecimal = hour + minute / 60
          if (slotHourDecimal >= startHour && slotHourDecimal < endHour) {
            if (status !== 'free') {
              allRequestedFree = false
            }
          }
        }
      }

      return {
        roomId: room.id,
        roomCode: room.roomcode,
        meetingType: room.meetingType,
        capacity: room.capacity,
        equipment: room.equipment,
        floor: room.roomcode ? null : null, // floor info not directly in response
        availableForRequested: (startHour !== undefined && endHour !== undefined) ? allRequestedFree : undefined,
        slots: slotEntries
      }
    })

    // If startHour/endHour specified, sort available rooms first
    let finalResult = result
    if (startHour !== undefined && endHour !== undefined) {
      finalResult = result.filter(r => r.availableForRequested)
    }

    return {
      success: true,
      date: dateStr,
      total: finalResult.length,
      totalBeforeFilter: result.length,
      rooms: finalResult
    }
  } catch (e) {
    return { success: false, error: e.message }
  }
}
Skill:执行预订 meeting-book-room
js
/**
 * meeting-book-room
 * Books a meeting room via the meeting API.
 * Must run in the browser context of a logged-in meeting page.
 *
 * Params:
 *   roomId         string   required - Room ID from getRooms result
 *   startTime      string   required - e.g. "2026-03-31 16:00:00"
 *   endTime        string   required - e.g. "2026-03-31 19:00:00"
 *   booker         string   required - e.g. "zhangsan(张三)"
 *   bookerDept     string   optional - department, defaults to booker
 *   theme          string   optional - meeting subject, defaults to "{booker}预定的会议室"
 *   description    string   optional - description text
 *   attendees      array    optional - additional attendee usernames
 *
 * Returns: { success: true, bookingId: "..." }
 */
async (params) => {
  const {
    roomId,
    startTime,
    endTime,
    booker,
    bookerDept,
    theme,
    description = '',
    attendees = []
  } = params || {}

  if (!roomId) return { success: false, error: 'roomId is required' }
  if (!startTime) return { success: false, error: 'startTime is required' }
  if (!endTime) return { success: false, error: 'endTime is required' }
  if (!booker) return { success: false, error: 'booker is required' }

  try {
    const meetingAttendees = [
      { attendeeType: 1, meetingAttendee: booker, needNotice: 1 }
    ]

    // Add extra attendees
    if (attendees && attendees.length > 0) {
      for (const a of attendees) {
        meetingAttendees.push({
          attendeeType: 1,
          meetingAttendee: a,
          needNotice: 1
        })
      }
    }

    const body = {
      booker,
      bookerDept: bookerDept || booker,
      roomId,
      startTime,
      endTime,
      theme: theme || `${booker}预定的会议室`,
      meetingAttendees,
      meetingAttachment: [],
      needHidden: 0,
      description
    }

    const resp = await fetch('https://meeting.your-company.com/eoss-meeting/meeting/meetinginfo', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(body)
    })

    if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }

    const data = await resp.json()
    if (data.code !== 0) {
      return { success: false, error: data.msg || JSON.stringify(data) }
    }

    return {
      success: true,
      bookingId: data.data,
      roomId,
      startTime,
      endTime,
      theme: body.theme
    }
  } catch (e) {
    return { success: false, error: e.message }
  }
}
Skill:查询已有预订 meeting-get-my-bookings
js
/**
 * meeting-get-my-bookings
 * Fetches current user's meeting bookings.
 * Must run in the browser context of a logged-in meeting page.
 *
 * Params:
 *   filterDate     string   optional - only return bookings on this date (YYYY-MM-DD)
 *   filterBooker   string   optional - filter by booker name (substring match)
 *
 * Returns: { success: true, bookings: [...] }
 */
async (params) => {
  const {
    filterDate,
    filterBooker
  } = params || {}

  try {
    const resp = await fetch(
      'https://meeting.your-company.com/eoss-meeting/meeting/meetinginfo/getMeetingInfo?limit=1000&page=1&orderField=start_time&order=asc',
      {
        method: 'GET',
        credentials: 'include'
      }
    )

    if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }

    const data = await resp.json()
    if (data.code !== 0) {
      return { success: false, error: data.msg || JSON.stringify(data) }
    }

    let bookings = data.data || []

    // If data is null/empty, return empty list
    if (!bookings || !Array.isArray(bookings)) {
      // Try data.data.list format
      if (data.data && data.data.list) {
        bookings = data.data.list
      } else {
        return { success: true, total: 0, bookings: [] }
      }
    }

    // Client-side filter by date
    if (filterDate) {
      bookings = bookings.filter(b => {
        const startDate = (b.startTime || '').slice(0, 10)
        return startDate === filterDate
      })
    }

    // Client-side filter by booker
    if (filterBooker) {
      bookings = bookings.filter(b => {
        return (b.booker || '').includes(filterBooker) || (b.creator || '').includes(filterBooker)
      })
    }

    return {
      success: true,
      total: bookings.length,
      bookings: bookings.map(b => ({
        id: b.id,
        roomId: b.roomId,
        roomInfo: b.roomInfo,
        booker: b.booker,
        bookerDept: b.bookerDept,
        theme: b.theme,
        startTime: b.startTime,
        endTime: b.endTime,
        status: b.status,
        creator: b.creator,
        createDate: b.createDate,
        description: b.description
      }))
    }
  } catch (e) {
    return { success: false, error: e.message }
  }
}
Skill:取消预订 meeting-cancel-booking
js
/**
 * meeting-cancel-booking
 * Cancels/releases a meeting room booking.
 * Must run in the browser context of a logged-in meeting page.
 *
 * Params:
 *   bookingId      string   required - Booking ID to cancel
 *
 * Returns: { success: true, bookingId: "..." }
 */
async (params) => {
  const { bookingId } = params || {}

  if (!bookingId) return { success: false, error: 'bookingId is required' }

  try {
    const resp = await fetch('https://meeting.your-company.com/eoss-meeting/meeting/meetinginfo/releaseOrCancel', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ id: bookingId })
    })

    if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }

    const data = await resp.json()
    if (data.code !== 0) {
      return { success: false, error: data.msg || JSON.stringify(data) }
    }

    return {
      success: true,
      bookingId,
      message: '预订已取消'
    }
  } catch (e) {
    return { success: false, error: e.message }
  }
}

反模式(来自真实失败案例)

反模式发生了什么修复方案
裸 URL 导航平台要求 URL 含 Session Token,直接访问 /item/{id} 被 302 跳转到首页Skill 返回含 Token 的完整 URL;Spec 明确写"使用 item.link"
模糊的 Skill 调用AI 用了 Skill("xxx") 工具而非 browser_run,导致找不到 Task IDSpec 明确写 browser_run({ file: "..." }),并禁止使用 Skill / Task 工具
用 DOM 操作输入表单AI 用 browser_click + browser_fill 操作评论框,与框架响应式产生竞态条件专用 Skill 内部完整处理"输入 → 提交 → 确认"全流程
Skill 失败后尝试补救AI 额外截图 + 点击"确认",反而造成更多问题Spec 规定:"success: false → 跳过,禁止任何补救操作"
登录状态误判数据采集 API 无需登录可用,但操作 API 需要登录;Skill 误报 logged_in: true单独检查登录状态,不能从只读访问推断登录状态
首次导航未创建新页面AI 调用 browser_navigate 时没有活跃页面 → 报错Spec 规定:第一步使用 browser_new_page

上线前检查清单

  • [ ] 所有平台交互都在 Skill 脚本中,不在 AI 提示词里
  • [ ] Skill 返回完整数据,链接中包含安全 Token
  • [ ] Spec 使用编号步骤,包含精确的 browser_run 调用语法
  • [ ] Spec 有 ## 禁止事项 章节,逐条列出禁止行为
  • [ ] Spec 使用 browser_new_page 进行首次导航
  • [ ] 每个步骤都有明确的失败处理(跳过 / 停止 / 报告)
  • [ ] memory_schema 覆盖去重字段和 last_run_time
  • [ ] 用最弱的目标模型(如 Qwen)测试通过——零自由发挥