Files
2026-05-31 19:47:50 +08:00

35 KiB
Raw Permalink Blame History

name, description
name description
fanwei-e10-api-doc 泛微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 实例。

# 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 命令都必须:

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: 导航到登录页

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 位或以下。

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 渲染空白),则点击验证码图片刷新后重新截图:

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 + 事件派发登录:

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 释放资源:

# 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/indexReact SPA,内容由 JS 动态渲染)。

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() 导航到目标文档页:

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,直接导航:

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 SPAcurl 只能拿到空壳 HTML<div id="root">)。必须用浏览器渲染后通过 js("document.body.innerText") 提取内容。

提取文档内容

文档是纯文本格式,用缩进表示参数层级。直接抓取全部 body 文本:

browser-harness <<'PY'
content = js("document.body.innerText.substring(0, 15000)")
print(content[:5000])
PY

如果内容被截断,滚动页面再提取后续部分:

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

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() 在流程卡片上经常失效。用坐标点击代替:

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 的那个。

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

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.用车审批流程",表单叫"用车审批流程")。用流程名称的核心关键词搜索。

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
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
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 不是 pagepageSize 不是 sizeformFieldSearchEntity 是必传嵌套对象,至少含 formIdisDelete: 0。主表字段需额外传 formTableId

Step 5: 获取 formTableId + 明细表字段

主表 formTableId 从 Step 4 返回的任意字段中提取(每个字段都有 formTableId 属性)。

明细表:需要获取明细表的 formTableId。方法:

  • 先通过 UI 点击切换一次 明细表 标签,同时用 XHR 拦截捕获请求体中的 formTableId(见下方 Fallback
  • 如果页面显示"您暂时还没有明细表",说明该流程无明细表

XHR 拦截获取 formTableIdFallback

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 提取字段(不推荐)

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:

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,例如:

{"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 引用(带 uploadParamuploadType: "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"
  • 如果流程无明细表,直接省略明细表章节,不要写"无明细表"

模板

# <接口名称>

## 接口说明
<一句话描述接口用途>

## 前置条件

### 获取 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)
... ... ... ...

请求示例

最小请求

curl -X POST "https://<E10_BASE>/papi/openapi/xxx" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

完整请求

curl -X POST "https://<E10_BASE>/papi/openapi/xxx" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

响应参数

参数名 类型 说明
errcode String 返回码,"0" 表示成功
... ... ...

响应示例

成功

{ ... }

失败

{ ... }

错误码

错误码 说明
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`。