制作生产级数字人
本文面向有一定技术背景的用户。帮助你从"能跑通 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 抓取:
// 推荐:拦截平台自身 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 实时推理,三个问题都不存在。
// Skill 脚本内部封装点击、填表等操作 —— 这是可以的
document.querySelector('[data-testid="submit-btn"]').click()AI 只需调用一次 browser_run,不参与任何 DOM 决策。
Skill 返回的链接必须包含安全 Token:
// 正确:完整链接,含 Token
link: `${baseUrl}/${item.id}?token=${encodeURIComponent(item.security_token)}`
// 错误:裸链接,会被拦截
link: `${baseUrl}/${item.id}`每个 Skill 脚本必须满足:
- 是单个
async (params) => { ... }箭头函数 - 返回 JSON 可序列化的结果
- 失败时返回
{ success: false, error: "..." },不抛出异常 - 在
finally块中清理所有拦截器
第三阶段:Spec 设计(system_prompt)
system_prompt 是数字人的"工作手册"。针对弱模型的核心规则:
- 编号步骤,不用段落。 每一步 = 一次工具调用。
- 写出精确调用语法。 写
browser_run({ file: "...", params: {...} }),不要写"调用搜索 Skill"(模糊)。 - 显式列出禁止项。 弱模型会自由发挥,必须逐一禁止:
- 禁止自己拼接 URL
- 禁止用 Skill / Task 工具调用浏览器 Skill(必须用
browser_run) - 禁止用
browser_click/browser_fill操作 Skill 已处理的输入框
- 首次导航用
browser_new_page。 自动化运行启动时没有活跃页面。 - 每步独立错误处理。 失败时明确说明:跳过 / 停止 / 报告。
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(点击展开)
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: 4Skill:搜索帖子 xhs-search — XHR 拦截 + Pinia store 触发搜索
/**
* 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 拦截确认
/**
* 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(点击展开)
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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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 ID | Spec 明确写 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)测试通过——零自由发挥