Files
fanwei-e10-api-doc/SKILL.md
T
2026-05-31 19:47:50 +08:00

950 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: fanwei-e10-api-doc
description: 泛微OA E10 系统接口文档撰写 — 自动登录、浏览 API 文档、发现流程字段、生成 MD 文档。
---
# 泛微OA E10 接口文档撰写
## ⛔ 硬性约束:只读操作
**绝对禁止对 E10 系统进行任何修改操作。** 包括但不限于:
- 不创建、编辑、删除流程定义
- 不修改表单字段配置
- 不新增、编辑、删除任何系统数据
- 不调用任何写操作的 APIPOST/PUT/DELETE 等)
- 浏览器操作仅限于导航、查看、截图、提取文本
**允许的操作:**
- 查看页面内容
- 截图记录
- 提取 API 文档文本
- 提取流程字段定义
- 提取表单结构信息
如果某个操作看起来可能会修改数据,必须先向用户确认。
## Trigger
用户说"帮我写 E10 的 xxx 接口文档"或"写一个创建 xxx 流程实例的接口文档"时使用。
## Phase 0: 获取凭据
**每次都向用户确认以下信息,不依赖任何缓存的凭据:**
- E10 系统地址 (base URL,如 `https://e10.example.com`)
- 账号
- 密码
不要从 memory 中读取凭据,不要缓存凭据到 memory。即使之前用过同一个系统也要重新确认。
## Phase 1: 启动独立浏览器 + 登录
### ⚠️ 跨平台兼容说明
本 skill 在 Linux 和 Windows 上均可使用。下面用变量表示平台差异,Agent 执行时自动替换:
| 变量 | Linux | Windows |
|------|-------|---------|
| `<CHROME>` | `google-chrome` | `"C:\Program Files\Google\Chrome\Application\chrome.exe"` |
| `<TEMP_DIR>` | `/tmp/bh-e10` | `%TEMP%\bh-e10` |
| `<SCREENSHOT_DIR>` | `/tmp` | `%TEMP%` |
| `<KILL_DAEMON>` | `pkill -f browser_harness.daemon 2>/dev/null; rm -f /tmp/bu-*.sock /tmp/bu-*.pid 2>/dev/null` | `taskkill /F /IM python.exe /FI "WINDOWTITLE eq browser_harness*" 2>nul & del /Q %TEMP%\bu-*.sock %TEMP%\bu-*.pid 2>nul` |
### Step 0: 启动独立 Chrome(必须)
**绝对不要连接用户的日常浏览器**。每次使用前启动一个独立的 headless Chrome 实例。
```bash
# 1. 清理旧 daemon(关键!daemon 跨命令持久存活,残留的会连到用户浏览器)
<KILL_DAEMON>
# 2. 启动独立 headless Chrome
<CHROME> --headless=new --remote-debugging-port=9223 --user-data-dir=<TEMP_DIR> --no-first-run --no-default-browser-check --window-size=1920,1080
# 3. 确认端口就绪
# Linux/macOS: sleep 3 && curl -s http://127.0.0.1:9223/json/version | grep webSocketDebuggerUrl
# Windows: 等待 3 秒后浏览器访问 http://127.0.0.1:9223/json/version 确认有响应
```
启动 Chrome 用 `terminal(background=true)`,之后**每个** browser-harness 命令都必须:
```bash
export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY'
...
PY
```
> **严禁分开执行** `export BU_CDP_URL=...` 然后 `browser-harness` — daemon 是独立进程,不会继承后续 shell 的环境变量。必须 `export && browser-harness` 在同一行。Windows 用 `set BU_CDP_URL=http://127.0.0.1:9223 && browser-harness`。
### Step 1: 导航到登录页
```bash
export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY'
new_tab("https://<E10_BASE>/login")
wait_for_load()
capture_screenshot("<SCREENSHOT_DIR>/e10_login.png")
# 确认在登录页
url = js("window.location.href")
print("URL:", url)
# 如果 URL 不是 /login 而是其他页面(如 /attend/info),说明之前的 session 还在,
# 这是正常的 — 直接用即可,跳到 Phase 3C
PY
```
### Step 2: 识别验证码 + 登录
如果 Step 1 已在登录页,继续登录;如果已登录(URL 不含 `/login`),跳到 Phase 3C。
**验证码规则**:E10 图形验证码**始终为 4 位数字**,不会出现 3 位或以下。
```bash
export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY'
# 截图识别验证码
capture_screenshot("<SCREENSHOT_DIR>/e10_captcha.png")
PY
```
`vision_analyze` 识别截图。**如果返回少于 4 位**(识别不全或 headless 渲染空白),则点击验证码图片刷新后重新截图:
```bash
export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY'
import json
# 点击验证码图片刷新
info = json.loads(js('''
var imgs = document.querySelectorAll("img");
for (var i = 0; i < imgs.length; i++) {
var r = imgs[i].getBoundingClientRect();
if (r.width > 20 && r.height > 20) {
// 验证码图片通常在输入框右侧,尺寸约 120x30
return JSON.stringify({x: Math.round(r.left+r.width/2), y: Math.round(r.top+r.height/2)});
}
}
// fallback: 第三个 input 右侧 180px
var inputs = document.querySelectorAll("input");
var r = inputs[2].getBoundingClientRect();
return JSON.stringify({x: Math.round(r.right + 90), y: Math.round(r.top + r.height/2)});
'''))
click_at_xy(info["x"], info["y"])
wait(2)
capture_screenshot("<SCREENSHOT_DIR>/e10_captcha.png")
PY
```
拿到 4 位验证码后,用 native setter + 事件派发登录:
```bash
export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness <<'PY'
import json
# 获取 input 坐标(每次都重新获取,data-id 在页面刷新后会变)
info = json.loads(js('''
var inputs = document.querySelectorAll("input");
var result = [];
for (var i = 0; i < inputs.length; i++) {
var r = inputs[i].getBoundingClientRect();
result.push({
dataId: inputs[i].getAttribute("data-id"),
x: Math.round(r.left + r.width/2),
y: Math.round(r.top + r.height/2)
});
}
var btn = document.querySelector("button");
var br = btn ? btn.getBoundingClientRect() : null;
return JSON.stringify({inputs: result, btn: br ? {x: Math.round(br.left+br.width/2), y: Math.round(br.top+br.height/2)} : null});
'''))
u = info["inputs"][0]; p = info["inputs"][1]; c = info["inputs"][2]; b = info["btn"]
# 注入 fillInput
js('''
var ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
window.__fi = function(dataId, value) {
var el = document.querySelector("[data-id='" + dataId + "']");
el.focus(); el.value = "";
ns.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("blur", { bubbles: true }));
};
''')
click_at_xy(u["x"], u["y"]); wait(0.2)
js('window.__fi("' + u["dataId"] + '", "<USERNAME>")')
click_at_xy(p["x"], p["y"]); wait(0.2)
js('window.__fi("' + p["dataId"] + '", "<PASSWORD>")')
click_at_xy(c["x"], c["y"]); wait(0.2)
js('window.__fi("' + c["dataId"] + '", "<CAPTCHA>")')
# 验证字段都有值再登录
vals = js('''
var u = document.querySelector("[data-id='" + u["dataId"] + "']");
var p = document.querySelector("[data-id='" + p["dataId"] + "']");
var c = document.querySelector("[data-id='" + c["dataId"] + "']");
return JSON.stringify({user: u?u.value:"?", pass: p?"***":"?", captcha: c?c.value:"?"});
''')
print("Values:", vals)
wait(0.3)
click_at_xy(b["x"], b["y"])
wait(5)
wait_for_load()
url = js("window.location.href")
print("After login:", url)
# 如果仍在 /login,验证码错误或登录失败,重新识别验证码
PY
```
登录成功后,后续所有 `browser-harness` 命令都继续使用 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀。
### 清理
任务完成后,杀 headless Chrome 释放资源:
```bash
# Linux/macOS
pkill -f "chrome.*9223" 2>/dev/null
# Windows
taskkill /F /IM chrome.exe 2>nul
```
## Phase 2: 确定文档类型
### Case A — 通用接口文档
用户没提到具体流程名称(如"帮我写 E10 的人员同步接口"),直接跳到 Phase 3A。
### Case B — 流程实例接口文档
用户提到具体流程名称(如"帮我写创建采购申请流程实例的接口文档"),先执行 Phase 3B 发现流程字段,再执行 Phase 3A 查阅 API 文档。
## Phase 3A: 浏览 API 文档主页
> 以下所有 `browser-harness` 命令都需要 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀,为简洁省略。
导航到 `/sp/openapi/base/doc/index`React SPA,内容由 JS 动态渲染)。
```bash
new_tab("https://<E10_BASE>/sp/openapi/base/doc/index")
wait(5)
wait_for_load()
wait(3)
capture_screenshot("/tmp/e10_api_doc_home.png")
PY
```
### 获取所有 API 文档链接
文档首页使用 `<A>` 标签组成的网格。用 JS 提取所有链接,然后直接 `new_tab()` 导航到目标文档页:
```bash
browser-harness <<'PY'
import json
links = json.loads(js('''
var all = document.querySelectorAll("a");
var result = [];
for (var i = 0; i < all.length; i++) {
var a = all[i];
var txt = a.textContent.trim();
if (txt.length > 0 && txt.length < 30 && a.href) {
result.push({text: txt, href: a.href});
}
}
return JSON.stringify(result);
'''))
for l in links:
print(l["text"], "->", l["href"])
PY
```
找到目标分类(如"工作流程")的 href,直接导航:
```bash
browser-harness <<'PY'
new_tab("<TARGET_HREF>") # 如 /sp/opendoc/freepass/10.0.2508.01/zh_cn/840714220321120257
wait(8) # React SPA 需要更长时间渲染
wait_for_load()
wait(3)
PY
```
> **注意**:这些文档页是 React SPA`curl` 只能拿到空壳 HTML`<div id="root">`)。必须用浏览器渲染后通过 `js("document.body.innerText")` 提取内容。
### 提取文档内容
文档是纯文本格式,用缩进表示参数层级。直接抓取全部 body 文本:
```bash
browser-harness <<'PY'
content = js("document.body.innerText.substring(0, 15000)")
print(content[:5000])
PY
```
如果内容被截断,滚动页面再提取后续部分:
```bash
browser-harness <<'PY'
js("window.scrollBy(0, 800)")
wait(2)
content = js("document.body.innerText.substring(8000, 15000)")
print(content[:5000])
PY
```
**⚠️ 注意接口名称**:流程相关的接口有两个容易混淆:
- **"新增流程"** — 创建流程**定义/模板**(管理员用,不要看这个)
- **"创建流程实例" / "工作流程"** — 创建流程**实例**(外部系统调用,看这个)
文档分类中"工作流程"下的内容才是外部系统需要的流程实例创建接口。
## Phase 3B: 流程发现与字段检查
### Step 1: 查看所有流程
导航到 `/info/engine_wf/pathdef/list/company`
```bash
browser-harness <<'PY'
new_tab("https://<E10_BASE>/info/engine_wf/pathdef/list/company")
wait(5)
wait_for_load()
wait(3)
capture_screenshot("/tmp/e10_workflows.png")
PY
```
页面展示流程卡片列表。确认目标流程是否存在。
### Step 2: 点击目标流程卡片(用坐标点击)
JS `click()` 在流程卡片上经常失效。用坐标点击代替:
```bash
browser-harness <<'PY'
import json
# 找到 "06.用车审批流程" 卡片的精确坐标
info = json.loads(js('''
var all = document.querySelectorAll("*");
var result = [];
for (var i = 0; i < all.length; i++) {
var el = all[i];
var txt = el.textContent.trim();
// 找叶子节点(children.length === 0)且文本匹配
if (txt === "06.用车审批流程" && el.children.length === 0) {
// 向上找卡片容器(width > 100, height > 40
var p = el;
while (p && p.tagName !== "BODY") {
var r = p.getBoundingClientRect();
if (r.width > 100 && r.height > 40) break;
p = p.parentElement;
}
if (p) {
var r = p.getBoundingClientRect();
result.push({
x: Math.round(r.left + r.width/2),
y: Math.round(r.top + r.height/2),
w: Math.round(r.width), h: Math.round(r.height)
});
}
}
}
return JSON.stringify(result);
'''))
# 用坐标点击
click_at_xy(info[0]["x"], info[0]["y"])
wait(5)
wait_for_load()
print("URL:", js("window.location.href"))
capture_screenshot("/tmp/e10_workflow_detail.png")
PY
```
成功后将跳转到 `/info/engine_wf/pathdef/list/company/pathset/<WORKFLOW_ID>/base`。URL 中的数字即为该流程的 workflowId。
### Step 3: 进入字段管理(多级标签页导航)
流程详情页右上角标签栏结构:
```
[基础设置] [功能设置] [表单管理] [布局管理] [更多]
```
点击 **"表单管理"** 标签(坐标约 y=38),URL 会变为 `.../formManager`
然后在 **"表单管理"** 页面内,还有一组子标签:
```
[基础信息] [字段设置] [数据设置] [布局设置] [其他设置]
```
以及另一组功能标签:
```
[字段管理] [字段查重校验] [附件设置] [字段显示格式] [自定义e-code开发] [索引管理]
```
点击 **"字段管理"**(坐标约 y=74 的第二组标签),才能看到真正的字段列表。
> **注意**:左边栏菜单也有一个"字段管理",那是全局入口,坐标在 y≈58。不要点错 — 要点详情页内 y≈74 的那个。
```bash
browser-harness <<'PY'
import json
# Step 3a: 点击"表单管理"tab
info = json.loads(js('''
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
var result = [];
while (walker.nextNode()) {
var el = walker.currentNode;
if (el.textContent.trim() === "表单管理" && el.children.length === 0) {
var r = el.getBoundingClientRect();
result.push({x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2), w: r.width, h: r.height});
}
}
return JSON.stringify(result);
'''))
# 选 y 坐标较大的那个(y>100 的是页面标签,y<100 的是侧边栏)
tab = [p for p in info if p["y"] > 30][0] if len(info) > 1 else info[0]
click_at_xy(tab["x"], tab["y"])
wait(5)
wait_for_load()
# Step 3b: 点击"字段管理"子标签
info2 = json.loads(js('''
// 同上找 y≈74 的元素
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
var result = [];
while (walker.nextNode()) {
var el = walker.currentNode;
if (el.textContent.trim() === "字段管理" && el.children.length === 0) {
var r = el.getBoundingClientRect();
result.push({x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2)});
}
}
return JSON.stringify(result);
'''))
# 选 y 在 60-120 之间的(字段管理子标签),排除侧边栏菜单的
subtab = [p for p in info2 if 60 < p["y"] < 120][0]
click_at_xy(subtab["x"], subtab["y"])
wait(5)
capture_screenshot("/tmp/e10_fields_page1.png")
PY
```
### Step 4: 提取字段信息 + 切换主表/明细表
字段列表有三个子标签:**主表 | 明细表 | 其他**。默认显示主表。
**推荐方法:用内部 API 获取字段(Phase 3C**,避免分页和 DOM 提取问题。DOM 提取方法仅在 Phase 3C 不可用时作为备选。
## Phase 3C: 通过内部 API 获取精确字段数据(推荐)
> 以下所有 `browser-harness` 命令都需要 `export BU_CDP_URL=http://127.0.0.1:9223 &&` 前缀,为简洁省略。登录后 browser session cookie 自动携带。
> 完整的 API 列表和响应格式见 `references/internal-apis.md`。
### 完整链路(4 步,无需任何 UI 点击)
登录后直接按以下顺序调用,全部走 `fetch()`
#### Step 1: 获取所有流程 → 找到目标 workflowId
```
POST /api/bs/workflow/pathdef/baseSet/getBaseInfoListTree
```
搜索目标流程名称(如用户指定"用车审批流程"),从返回的树形结构中获取 `workflowId`
```bash
browser-harness <<'PY'
import json
result = json.loads(js('''
(async () => {
let resp = await fetch("/api/bs/workflow/pathdef/baseSet/getBaseInfoListTree", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
searchParams: { otherSearchDatas: { workflowType: "", workflowId: "", subCompanyId: "" } },
isTemplate: 0,
belongType: 1
})
});
return JSON.stringify(await resp.json());
})();
'''))
# 按名称匹配目标流程
target_name = "用车审批流程"
for group in result["data"]["datas"]:
for child in group["children"]:
if target_name in child["title"]:
wf_id = child["id"]
print(f"找到: {child['title']} | workflowId={wf_id} | 分类={group['typeName']}")
PY
```
#### Step 2: 搜索表单 → 获取 formId
```
POST /api/workflow/core/form/formmanage/getFormList
```
表单名称可能与流程名称不同(如流程叫"06.用车审批流程",表单叫"用车审批流程")。用流程名称的**核心关键词**搜索。
```bash
browser-harness <<'PY'
import json
result = json.loads(js('''
(async () => {
let resp = await fetch("/api/workflow/core/form/formmanage/getFormList", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ module: "workflow", pageNo: 1, pageSize: 50, name: "用车" })
});
return JSON.stringify(await resp.json());
})();
'''))
for f in result["data"]["pageDatas"]["result"]:
if target_name in f.get("name", ""):
form_id = f["id"]
print(f"表单: {f['name']} | formId={form_id}")
PY
```
> **备选**:如果 `getFormList` 不可用,可从 `localStorage` 提取 formId。访问表单管理页面后,`localStorage` 会有 key `Form_0_<formId>_checkNewForm`。
#### Step 3: 获取表单详情 → 确认表名和类型
```
POST /api/workflow/core/form/formmanage/getForm
```
```bash
browser-harness <<'PY'
import json
result = json.loads(js('''
(async () => {
let resp = await fetch("/api/workflow/core/form/formmanage/getForm", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ module: "workflow", form: { id: "<FORMID>" } })
});
return JSON.stringify(await resp.json());
})();
'''))
data = result["data"]
print(f"表名: {data['tableName']} | 类型: {data['tableType']} | 名称: {data['name']}")
# 检查是否有明细表
dts = data.get("detailTable", [])
print(f"明细表数量: {len(dts)}")
for dt in dts:
print(f" 明细表: {dt.get('name')} | id: {dt.get('id')}")
PY
```
#### Step 4: 获取字段列表 → 全部字段一次拿到
```
POST /api/workflow/core/form/field/manage/getFormFieldPage
```
```bash
browser-harness <<'PY'
import json
result = json.loads(js('''
(async () => {
let resp = await fetch("/api/workflow/core/form/field/manage/getFormFieldPage", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
pageNo: 1,
pageSize: 20,
module: "workflow",
formFieldSearchEntity: {
isDelete: 0,
status: "enable",
formId: "<FORMID>",
formTableId: "<FORMTABLEID>"
}
})
});
return JSON.stringify(await resp.json());
})();
'''))
records = result["data"]["pageDatas"]["result"]
print(f"主表字段: {len(records)} 个")
for r in records:
print(f" {r['title']} | key={r['dataKey']} | type={r['type']} | fieldId={r['fieldId']} | sort={r['showOrder']} | group={r['groupName']}")
PY
```
> **重要参数细节**`pageNo` 不是 `page``pageSize` 不是 `size`。`formFieldSearchEntity` 是必传嵌套对象,至少含 `formId` 和 `isDelete: 0`。主表字段需额外传 `formTableId`。
#### Step 5: 获取 formTableId + 明细表字段
**主表 formTableId** 从 Step 4 返回的任意字段中提取(每个字段都有 `formTableId` 属性)。
**明细表**:需要获取明细表的 `formTableId`。方法:
- 先通过 UI 点击切换一次 明细表 标签,同时用 XHR 拦截捕获请求体中的 `formTableId`(见下方 Fallback
- 如果页面显示"您暂时还没有明细表",说明该流程无明细表
**XHR 拦截获取 formTableIdFallback**
```bash
browser-harness <<'PY'
import json
# 注入 XHR 拦截器
js('''
window.__BH_XHR = [];
var OrigXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
var xhr = new OrigXHR();
var origOpen = xhr.open, origSend = xhr.send;
xhr.open = function(m, u) { this.__url = u; return origOpen.apply(this, arguments); };
xhr.send = function(b) { window.__BH_XHR.push({url: this.__url, body: b}); return origSend.apply(this, arguments); };
return xhr;
};
''')
# 触发 API 调用:鼠标事件点击明细表标签(MouseEvent 比 click_at_xy 更可靠)
js('''
var divs = document.querySelectorAll("div");
for (var i = 0; i < divs.length; i++) {
if (divs[i].textContent.trim() === "明细表" && divs[i].className.indexOf("menu") !== -1) {
divs[i].dispatchEvent(new MouseEvent("click", {bubbles: true}));
break;
}
}
''')
wait(4)
xhr = json.loads(js('JSON.stringify(window.__BH_XHR || [])'))
for r in xhr:
if "getFormFieldPage" in str(r.get("url", "")):
print("Body:", r["body"]) # 从中提取 formTableId
PY
```
得到明细表 `formTableId` 后,用 Step 4 相同的 API(换 `formTableId`)即可获取明细表字段。返回空数组则说明明细表为空。这在文档中注明即可,不要报错。
#### UI Fallback(仅在 API 不可用时使用)
如果上述 API 全部不可用(极少数情况),回退到 Phase 3B 的坐标点击 + DOM 提取方案。但优先尝试 API。
#### 备选:DOM 提取字段(不推荐)
```bash
browser-harness <<'PY'
import json
fields = json.loads(js('''
var rows = document.querySelectorAll("table tr");
var result = [];
for (var i = 0; i < rows.length; i++) {
var cells = rows[i].querySelectorAll("td");
if (cells.length >= 5) {
var rowData = [];
for (var j = 0; j < cells.length; j++) {
rowData.push(cells[j].textContent.trim());
}
result.push(rowData);
}
}
return JSON.stringify(result);
'''))
print("Total:", len(fields))
for row in fields:
print("|", " | ".join(row[:8]), "|")
PY
```
**切换明细表**:找文本为"明细表"的 SPAN 元素,点击其父 DIV
```bash
browser-harness <<'PY'
# 先确保"明细表"tab 不处于 active 状态(避免重复点击)
import json
active = json.loads(js('''
var el = document.querySelector("[class*='ui-menu-list-item-active']");
return el ? el.textContent.trim() : "none";
'''))
# 如果当前是"主表"active,切换到"明细表"
if active == "主表":
js('''
var divs = document.querySelectorAll("div");
for (var i = 0; i < divs.length; i++) {
if (divs[i].textContent.trim() === "明细表" && divs[i].className.indexOf("menu") !== -1) {
divs[i].dispatchEvent(new MouseEvent("mousedown", {bubbles: true}));
divs[i].dispatchEvent(new MouseEvent("mouseup", {bubbles: true}));
divs[i].dispatchEvent(new MouseEvent("click", {bubbles: true}));
return true;
}
}
return false;
''')
wait(3)
capture_screenshot("/tmp/e10_detail_table.png")
# 再次提取字段
fields = json.loads(js('''...''')) # 同上
PY
```
明细表可能为空(显示"您暂时还没有明细表"),这是正常的。在文档中注明即可。
## Phase 4: Token 获取(如 API 需要)
E10 OpenAPI 使用 **OAuth2 code 换 access_token** 模式(非 Bearer token header)。
### Step 1: 获取 code
- **URL**: `POST /papi/openapi/oauth2/authorize`
- **Method**: POST
- **Content-Type**: application/json
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| corpid | String | 是 | 企业 corpId,开发者资料中查看 |
| response_type | String | 是 | 固定为 `code` |
| state | String | 是 | 自定义参数,a-zA-Z0-9,最长128字节 |
**响应**: `{"errcode": "0", "code": "..."}` — code 有效期 10 分钟且只能使用一次。
### Step 2: code 换 access_token
access_token 作为请求参数(**不是 Header**)传入 API,例如:
```json
{"access_token": "210b03...7c7a", "userid": 123, "workflowId": "456"}
```
Access_token 交换接口在"认证"分类下的独立子页面(文档 ID 与获取 code 不同)。从"对接说明"页面可找到"获取accessToken信息"链接。接口详情:
- **URL**: `POST /papi/openapi/oauth2/access_token`
- **参数**: app_key(应用 key, app_secret(应用密钥), grant_type="authorization_code", code
- **响应**: `{"errcode":"0", "accessToken":"...", "refreshToken":"...", "expires_in":7200}`
- access_token 有效期 2 小时
在输出 MD 的"前置条件"章节中写入 OAuth2 两步流程。
## Phase 5: 文件上传(如 API 涉及文件)
如果目标 API 接受文件参数(附件字段),需要查阅文件上传接口。文件上传文档在 **"知识文档" → "文件上传"** 分类下,不是独立的一级入口。
从 Phase 3A 获取的链接列表中找"知识文档"或"文件上传"相关链接,直接 `new_tab()` 导航获取上传 API 规范。
常见上传模式:
- 先调用文件上传接口获取 `fileId` / `attachmentId`
- 再在业务 API 的 `formData.dataDetails` 中通过 `dataOptions` 引用(带 `uploadParam``uploadType: "loadUrl"`
在输出 MD 中加入"前置条件 — 文件上传"章节。
## Phase 6: 生成 MD 文档
### 文档定位
这份文档 **99% 的读者是外部公司的开发人员**,他们只关心怎么调接口、传什么参数、返回什么。他们不关心也不该看到 E10 系统内部的任何信息。
### 文档规范(必读)
**URL 规范**
- 所有示例 URL 使用占位符 `https://<E10_BASE>`**禁止出现真实 IP 地址**
- `access_token` 等其他敏感值也使用占位符 `"<ACCESS_TOKEN>"``"<USERID>"`
**禁止出现的内容**
- E10 系统版本号(如"10.0.2508.01"
- 数据库表名(如 `ft_hysydlc`)— 外部调用者不需要知道
- 内部 API 路径(如 `/api/bs/workflow/...`)— 这些不是 OpenAPI
- E10 内部管理界面的操作说明(如"登录开放平台")
- "未分组"、"辅助信息" 等 E10 界面的内部分组名 — 字段按逻辑含义分组
- Python urlencode `+` 号替换 `%20` — 这是内部已知的 quirks,不是 API 规范
- "数据来源"、"文档生成时间" 等元信息
- 任何"注意"、"提示"、"陷阱" 等口语化标记 — 用规范的表格或说明代替
- 任何 E10 内部系统的名词和操作流程
**必须包含**
- 完整的鉴权流程(OAuth2 code → access_token),包括每个步骤的 URL、参数、响应
- 鉴权方式说明:access_token 作为**请求体参数**传入,不是 HTTP Header
- **身份标识转换章节**(非常关键):外部系统不持有 E10 内部 ID,必须说明如何用工号、部门编号等外部标识替代内部 ID。包括:
- 顶层 `userType` 参数:JOB_NUM / EMAIL / MOBILE / idNos / loginID / account
- 顶层 `deptType` 参数:DEPT_CODE / DEPT_NAME
- 表单 dataOptions 中的 `userType` / `deptType` 用法(Employee/Department 类型字段)
- 每个参数的类型、必填、说明
- curl 请求示例(至少 2 个:最小请求 + 完整请求),**示例中必须使用 userType/DEPT_CODE 等外部标识而非内部 ID**
- 响应示例和错误码
**字段列表规范**
- 不写内部 API 返回的 groupName(如"未分组"、"基础信息"),按字段的实际业务含义分组
- 字段表头统一:`| 字段名 | 类型 | 必填 | 说明 |`
- 关联型字段在"说明"列注明取值方式(如"dataOptions 传值,type=resource"
- 如果流程无明细表,直接省略明细表章节,不要写"无明细表"
**模板**
```markdown
# <接口名称>
## 接口说明
<一句话描述接口用途>
## 前置条件
### 获取 access_token
#### Step 1: 获取 code
| 参数 | 必填 | 类型 | 说明 |
|------|------|------|------|
| ... | ... | ... | ... |
```json
// 请求
{ ... }
// 响应
{ ... }
```
#### Step 2: code 换 access_token
| 参数 | 必填 | 类型 | 说明 |
|------|------|------|------|
| ... | ... | ... | ... |
(如果有文件上传需求,在此加 "### 文件上传" 章节)
## 请求信息
- **URL**: `POST https://<E10_BASE>/papi/openapi/xxx`
- **Content-Type**: `application/json`
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| access_token | String | 是 | 接口调用凭证(请求体参数,不是 Header) |
| ... | ... | ... | ... |
## 请求示例
### 最小请求
```bash
curl -X POST "https://<E10_BASE>/papi/openapi/xxx" \
-H "Content-Type: application/json" \
-d '{ ... }'
```
### 完整请求
```bash
curl -X POST "https://<E10_BASE>/papi/openapi/xxx" \
-H "Content-Type: application/json" \
-d '{ ... }'
```
## 响应参数
| 参数名 | 类型 | 说明 |
|--------|------|------|
| errcode | String | 返回码,"0" 表示成功 |
| ... | ... | ... |
## 响应示例
### 成功
```json
{ ... }
```
### 失败
```json
{ ... }
```
## 错误码
| 错误码 | 说明 |
|--------|------|
| 0 | 成功 |
| ... | ... |
## 流程字段说明
### <字段分组名>
| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| ... | ... | ... | ... |
```
文件保存到用户指定的目录,文件名 `E10_<流程名称>_API.md`。
## Pitfalls
0. **API 优先,UI 兜底**:登录后能用内部 API 解决的就别去点 UI。页面有多级标签、坐标点击不稳定、分页控件难操作。推荐链路:`getBaseInfoListTree` → `getFormList` → `getForm` → `getFormFieldPage`。详见 `references/internal-apis.md`。
1. **SPA 渲染延迟**:E10 是动态渲染页面,每次导航后必须 `wait(3-5)` 再截图/提取内容。API 文档页是 React SPA,需要 `wait(8)` 以上才能渲染完成。
2. **登录必须用 native setter + 事件派发**E10 表单框架不响应直接 `js().value = "..."` 赋值。必须:点击输入框获得焦点 → `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set` 设值 → 派发 `input`/`change`/`blur` 事件。
3. **用 `data-id` 定位 input**E10 的 input 元素没有有意义的 name/placeholder,但每个都有唯一的 `data-id`(如 `ui-input-fxv2kf`)。先 `js()` 提取所有 input 的 data-id,再用 `[data-id='xxx']` 选择器。
4. **流程卡片点击用坐标不用 JS click**:`el.closest("[class*=card]").click()` 在流程列表卡片上经常失效。用树遍历找到叶子文本节点 → 向上找容器 → `getBoundingClientRect()` → `click_at_xy()`。
5. **多级标签页导航容易点错**:流程详情页的"字段管理"入口不在侧边栏菜单,而在页面内部的多级标签中:
- 一级标签:基础设置 | 功能设置 | **表单管理** | 布局管理 | 更多
- 点击"表单管理"后 → 二级子标签:字段管理 | 字段查重校验 | 附件设置 | ...
- 点击"字段管理"后 → 三级标签:**主表** | **明细表** | 其他
- 左右两侧都有"表单管理"/"字段管理"菜单,坐标不同,要点右侧详情面板内的
6. **"字段设置" ≠ "字段管理"**:两个子标签名称极其相似,但功能完全不同。"字段设置"只有三个开关(系统设置/自定义),"字段管理"才是真正的字段列表。不要点错。
7. **分页提取不可靠**:字段列表的分页控件用 JS click 和坐标点击都可能失效。**直接用 API 的 `pageSize=20` 一次拿全部字段,不要跟分页控件较劲。**
8. **API 文档链接在 `<A>` 标签 href 中**`/sp/openapi/base/doc/index` 的每个分类入口是 `<a href="/sp/opendoc/...">`。用 `js()` 提取所有链接后 `new_tab()` 直接导航,不要尝试 JS click。
9. **文档页是纯文本格式**:提取内容用 `js("document.body.innerText")` 获取全部文本。参数层级用缩进区分(类似 YAML),用 `scrollBy()` 分段提取长文档。
10. **OAuth2 文档分两页,不在同一页面**:"认证"分类下有两个独立子页面:
- 获取 code:文档 ID 不同于获取 token 的页面
- 获取 accessToken:另一个文档 ID
从"对接说明"页面可找到两个链接。不要以为"认证"一个页面就包含完整流程,找不到第二页就去"对接说明"找链接。两个接口:
- Step 1: `POST /papi/openapi/oauth2/authorize`code 有效期 10 分钟)
- Step 2: `POST /papi/openapi/oauth2/access_token`(参数 app_key, app_secret, grant_type="authorization_code", code
Token 是 OAuth2 模式,不是 Bearer。access_token 作为请求参数(不是 Header)传入。
11. **登录失败静默检测**:登录后务必检查 URL 是否跳转到主页而非留在 login 页面,否则后续所有操作都会拿到登录页 HTML。
11b. **验证码固定 4 位数,少于 4 位就是识别不全**:E10 验证码始终 4 位数字。`vision_analyze` 可能漏读。如果返回少于 4 位,点击验证码图片刷新后重新截图识别。填完所有字段后、点击登录前,用 `js()` 验证三个 input 的 value 都有值。
12. **明细表检测优先用 getForm API**`getForm` 返回的 `data.detailTable` 数组直接告诉你是否有明细表(空数组 = 无明细表)。比 Phase 3B 的 XHR 拦截 + UI 点击方案更简单可靠。XHR 方案仅作为备选。
13. **内部 API 用 XHR 不是 fetch**E10 前端调用内部 API 用的是 `XMLHttpRequest`。如果要拦截请求体,必须 hook `XMLHttpRequest.prototype.send`。
14. **内部 API 参数命名不同**`pageNo`(不是 `page`)、`pageSize`(不是 `size`)。`formFieldSearchEntity` 是必传嵌套对象,至少含 `formId` 和 `isDelete: 0`。
- **formTableId 陷阱**:主表查询时传 `formTableId` 可能返回 0 条记录,**先不传 formTableId 尝试**。
15. **workflowId ≠ formId**:工作流 IDpathSetId)和表单 ID 是不同的实体。`getBaseInfoListTree` 返回的是 workflowId`getFormList` 返回的是 formId。两者不能混用。
16. **表单名称与流程名称可能不同**:流程名可能带编号前缀(如"06.用车审批流程"),表单名可能不带(如"用车审批流程")。搜索表单时用核心关键词模糊匹配。
17. **"新增流程" ≠ "创建流程实例"**:API 文档中这两个是不同的接口。"新增流程"是创建流程定义/模板(管理员用),外部系统调用要看"工作流程"分类下的流程实例创建接口。
18. **文件上传文档在"知识文档"分类下**:不是独立的顶级入口。从文档首页链接列表中找到"知识文档"或"文件上传"即可。
19. **身份标识转换是必写章节**:外部系统的开发人员不知道 E10 内部的用户 ID / 部门 ID。文档必须包含独立的"身份标识转换"章节,说明 `userType`JOB_NUM/EMAIL/MOBILE/idNos/loginID/account)和 `deptType`DEPT_CODE/DEPT_NAME)的用法。请求示例中全部使用外部标识(如工号 `"EMP001"`、部门编号 `"DEPT_TECH"`),不要出现 `<USERID>` 这样的占位符。
20. **daemon 持久存活 + 环境变量陷阱**:`browser-harness` 的 daemon 是独立进程,跨命令存活。`export BU_CDP_URL=...` 然后下一行 `browser-harness` 不会生效。必须写成一行:`export BU_CDP_URL=http://127.0.0.1:9223 && browser-harness`。每次新开任务前清理旧 daemon。
21. **headless 截图可能首次空白**headless Chrome 的 CSS 背景图可能首次渲染不完整,`vision_analyze` 返回"纯白"。此时重新 `capture_screenshot` 再识别即可。
22. **CloakBrowser 不兼容**CloakBrowser 是 Playwright 封装,不走 CDP 协议,无法与 browser-harness 对接。
23. **Windows 兼容**:本 skill 分享给同事时,Windows 上需调整:① Chrome 路径 `"C:\Program Files\Google\Chrome\Application\chrome.exe"`,② 临时目录用 `%TEMP%`,③ 环境变量用 `set` 而非 `export`,④ `pkill`/`rm` 换成 `taskkill`/`del`。