deepseek harness 导出公司报表实战

别急着写爬虫:接上你「已登录的浏览器」,10 分钟把内网 SPA 表格导成 Excel

公司内网有个 OA 系统,里面「我的项目」页面列着 50 多条项目记录、40 多个字段------项目编号、责任部门、立项时间、合同额、回款金额、项目状态......我想把它导成 Excel,方便筛选和统计。

页面上只有一个「查看」按钮,没有导出功能。手工翻 4 页复制粘贴显然不行。

这篇记录一下我实际是怎么做的。核心结论先放前面:

对于内网系统、SPA、需要登录的页面,最省力的路径不是从零写爬虫,而是让 Agent 接管你已经登录的浏览器,再从页面自身「问出」接口地址。

最后交付的是一个 3 个 Sheet 的 xlsx、一份 UTF-8 BOM 的 CSV,以及一份零字段丢失的原始 JSON 备份。


一、第一反应:curl 一下------然后拿到一个空壳

最朴素的做法是先看看页面返回了什么:

arduino 复制代码
curl -sS -k -I "https://oa.example.com:8080/oaSystem/PersonalCenter/myProject"

返回很健康:

makefile 复制代码
HTTP/1.1 200 OK
Server: nginx/1.20.1
Content-Type: text/html
Content-Length: 12574

200,text/html,一切正常。但把 body 拉下来一看:

xml 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>某企业管理系统</title>
    <script type="module" crossorigin src="/assets/app-1a2b3c4d.js"></script>

一个 12KB 的空壳。 这是个 Vue SPA,真正的数据是前端加载完之后,用 XHR 去后端拉的。你 curl 多少次,都只会拿到这个壳。

到这一步,很多人的选择是去翻 JS bundle,从 webpack 的 chunk 里把接口路径挖出来。这条路能走通,但很慢------路由是懒加载的,业务代码在几十个 chunk 里,还要处理压缩混淆。

我换了个思路。


二、换个思路:不要「模拟登录」,直接借用已登录的浏览器

写爬虫最烦的从来不是解析 HTML,而是登录态。要处理验证码、Token 刷新、企业 SSO、双因子......为了导一次数据去啃这套东西,性价比极低。

而我本地的 Chrome 里,这个系统本来就是登录状态。

所以我用 opencli 的 browser bridge,把 Agent 挂到一个浏览器会话上,直接打开目标页面:

arduino 复制代码
opencli browser oa open "https://oa.example.com:8080/oaSystem/PersonalCenter/myProject"

返回:

json 复制代码
{
  "url": "https://oa.example.com:8080/oaSystem/PersonalCenter/myProject",
  "page": "TAB-7F1C2A9B4E6D8035A1C4E7F2B9D6035A"
}

登录态、Cookie、企业网关、单点登录------全部不用管,因为那就是用户本人正在用的浏览器。

然后看一眼页面结构:

perl 复制代码
opencli browser oa state
yaml 复制代码
title: 某企业管理系统 - 我的项目
viewport: 1280x712
---
[1]<img src=... />
[3]<span>工号 10086</span>
<h4>张三</h4>
<p>研发部</p>
...

确认身份已登录、页面已渲染。接着直接把正文抽成 Markdown:

r 复制代码
opencli browser oa extract

这一步就已经能拿到人眼看到的全部内容了:项目名称、编号、行政区、责任部门、项目经理、立项时间、合同额、项目状态......以及页面底部的分页信息「共 57 条」。

但我不建议停在这里。 从渲染后的 DOM 里抠表格,会遇到三个问题:

  1. 只拿得到当前页的 15 条,剩下 3 页要不停点「下一页」;
  2. 数字带千分位、日期被格式化过,还要反向清洗;
  3. 页面没显示的字段,你永远拿不到。

真正干净的数据在接口里。所以我们继续往下挖。


三、怎么找接口?三个办法,第三个最快

办法 1:读 JS bundle

把入口 JS 下下来,grep 接口前缀:

perl 复制代码
curl -sS -k "https://oa.example.com:8080/assets/app-1a2b3c4d.js" -o app.js
grep -o -E '.{60}baseURL.{100}' app.js

能确认 API 前缀是 /admin-api/、/business/ 这类,但具体到「我的项目」这一页的接口,在懒加载的 chunk 里,找不到。

办法 2:抓网络请求

opencli 有 network 命令可以抓 XHR:

css 复制代码
opencli browser oa network --since 5m

第一次调用确实拿到了漂亮的响应结构预览($.data.records 是 array(57),等等)。但这个方案有个坑:它要把抓包缓存持久化到 ~/.opencli/cache/,而 Agent 沙箱不允许写工作区外的目录,于是:

arduino 复制代码
cache_warning: Could not persist capture cache: EPERM:
operation not permitted, mkdir '/Users/xxx/.opencli/cache/browser-network'

第二次读就只剩 count: 0 了。这条路的可靠性依赖沙箱权限,能用但不稳。

办法 3(推荐):问页面自己要

页面自己发过哪些请求,浏览器记得清清楚楚------用 Performance API 一句话就能列出来:

less 复制代码
opencli browser oa eval "JSON.stringify(
  performance.getEntriesByType('resource')
    .filter(e => /xmlhttprequest|fetch/.test(e.initiatorType))
    .map(e => e.name)
    .filter((v,i,a) => a.indexOf(v) === i)
)"

返回干干净净:

css 复制代码
[  "https://oa.example.com:8080/admin-api/system/dict-data/list-all-simple",  "https://oa.example.com:8080/business/project/page?pageNo=1&pageSize=15&userId=xxx",  "https://oa.example.com:8080/business/project/calculate?userId=xxx",  "https://oa.example.com:8080/business/project/xmzt/count?userId=xxx",  "https://oa.example.com:8080/admin-api/system/area/tree"]

不需要 grep、不需要断点、不需要猜。 页面加载时发过的请求、完整 URL、全部查询参数,一次到手。

这一招适用范围很广:任何 SPA、任何前端框架,只要页面已经把数据渲染出来了,接口就藏在 Performance API 里。比翻 bundle 快一个数量级。

顺带一提,这四个接口刚好凑齐了导出需要的一切:

接口 作用
/business/project/page 项目列表明细(分页)
/business/project/calculate 产值 / 成本汇总
/business/project/xmzt/count 各状态数量统计
/admin-api/system/dict-data/list-all-simple 字典表:把 1/2/3/4 翻成中文状态
/admin-api/system/area/tree 行政区划树:把 320205 翻成区县名

四、认证方式:不是 Cookie,是 localStorage 里的 Bearer Token

有了接口地址,还要知道怎么带认证。先看这个站点在浏览器里存了什么:

less 复制代码
opencli browser oa eval "JSON.stringify(
  Object.keys(localStorage).map(k => [k, String(localStorage.getItem(k)).slice(0,120)])
)"
bash 复制代码
[
  ["ACCESS_TOKEN",  "{"v":"\"9f3c1d7e...\""}"],
  ["REFRESH_TOKEN", "{"v":"\"4a8b2c6f...\""}"],
  ["user",          "{"v":"{\"user\":{\"id\":\"A1B2C3...\"}"}"],
  ["roleRouters",   "{"v":"[{\"id\":2001,...}"}"],
  ["lang",          "{"v":"\"zh-CN\""}"]
]

典型的前后端分离实现(框架特征很像 yudao 那一套):Token 存在 localStorage,请求时由前端塞进 Authorization 头。

于是脱离浏览器直接验证:

ini 复制代码
TOK="你的_access_token"
curl -sS -k "https://oa.example.com:8080/business/project/page?pageNo=1&pageSize=100&userId=xxx" \
  -H "Authorization: Bearer $TOK" -o page.json

{"code":0, ...} ------通了。

到这里我们完成了关键转换:从「操作浏览器」变成「直接调接口」 。后者更快、更稳、字段更全,而且可以随意改 pageSize。

⚠️ 合规提醒:这一切都建立在「登录的是我自己的账号、导的是我自己有权限看的数据」之上。Token 是敏感凭证,别外传、别写进代码库、别拿它去碰权限外的接口。


五、绕过翻页:pageSize 一把梭

页面上每页 15 条,一共 4 页。但接口的 pageSize 是参数,不是常量------直接要 200 条:

bash 复制代码
curl -sS -k "https://oa.example.com:8080/business/project/page?pageNo=1&pageSize=200&userId=xxx" \
  -H "Authorization: Bearer $TOK" -o page.json

jq -r '.data.total, (.data.records|length)' page.json
复制代码
57
57

一次请求,57 条全到。 不用循环翻页,不用 sleep,不用处理分页竞态。这条经验很通用:看到分页接口,先把 pageSize 改大试试,大多数后端不会拦。

拿到后用 jq 看一眼字段清单:

bash 复制代码
jq -r '.data.records[0] | keys[]' page.json | tr '\n' ' '
erlang 复制代码
applyPayAmount applyWorkerDept applyWorkerName belongCompanyname
contractAmount contractNumber createTime finishProgress flowStatus
lxsj outputValue projectCost projectTypeName receivableAmount
returnAmount shareRatio xmbh xmjl xmmc xmzt xzqdm ysdyxx zrbm ...

57 条 × 62 个字段,全部到手。


六、把「天书编码」翻译成人话

接口返回的都是编码,直接导出去没人看得懂:

  • xmzt: 1 → ?
  • isSign: 0/1 → ?
  • xzqdm: "320205" → ?
  • hyId: 3 → ?

字典接口就是为此存在的。把它拉下来建映射:

ini 复制代码
dicts = json.load(open("dicts.json"))["data"]

def dmap(dtype):
    return {str(d["value"]): d["label"]
            for d in dicts if d["dictType"] == dtype}

XMZT   = dmap("project_status")      # 0立项申请中 1进行中 2已结项 3已中止 4已验收
ISSIGN = dmap("contract_is_sign")    # 0未签 1已签 2待拿回 3不签
PROJHY = dmap("project_hy")          # 1公司内部 2IT行业 3政府行业 4其他

行政区则是另一棵 area/tree,递归压平成 code -> name:

scss 复制代码
area_map = {}
def walk(nodes):
    for n in nodes or []:
        if n.get("id") is not None:
            area_map[str(n["id"])] = n.get("name")
        walk(n.get("children"))

walk(json.load(open("area.json"))["data"])

现在 320205 能翻成具体的区名,1 能翻成「进行中」。

这里有个必须做的动作:和页面核对。 页面底部写着:

复制代码
进行中(17)已验收(29)已结项(8)已中止(3)全部(57)

拿我翻译后的数据 group by 一下,数量完全对上------说明字典方向没搞反。

💡 这一步千万别省。字典表里 1 到底是不是「进行中」,靠猜很容易翻车,而数量对账是最便宜的验证手段:状态编码一旦映射错,分布数据立刻就会露馅。


七、最阴的坑:字段单位不统一

明细数据干净了,但我盯着一个字段发愁:projectCost。

  • 有一条记录是 3860.4
  • 另一条是 12470.85
  • 还有 318240.6

这到底是元 还是万元?同名字段在不同系统里单位不同,是数据导出最经典的翻车点。差值 10000 倍,报出去就是事故。

猜是没用的,我用交叉验证:

  1. 先看汇总接口 /business/project/calculate 返回:
css 复制代码
{"code":0, "data": {"cost": 18432650.00, "output": 43218000}}
  1. 再看页面顶部的展示值:项目总成本 1843.265 万元 、项目总产值 4321.8 万元 。
    → 说明接口返回的 cost / output 单位是元。
  2. 最后把 57 条明细的 projectCost 全加起来:
swift 复制代码
jq -r '.data.records as $r | "([$r[].projectCost // 0] | add)"' page.json
# 18432650.0

和汇总接口的 cost 一一对上(误差只是浮点尾数)。

结论:projectCost 单位是元。

方法论:用「明细求和 = 汇总值」来反推单位,比看字段名可靠一万倍。 只要两个数字能对上,单位就不再是猜的。

同理我顺手验证了另一个字段:outputValue 只有 15 条非空,求和是 11742000,对不上 汇总的 43218000;而 contractAmount 求和是 44018000,差了正好 800000。这说明产值和合同额在这套系统里不是同一个口径。

这种不一致我没去硬猜------直接在导出的汇总页里把接口原始汇总值 和明细求和的合计 并排列出,标注清楚各自口径。宁可让数字诚实,也不要让它看起来整齐。


八、生成 Excel:多 Sheet + 冻结窗格 + 自动筛选

用 openpyxl 落盘。三个 Sheet 的分工:

Sheet 内容
我的项目 主表,40+ 中文列,冻结窗格 + 自动筛选
汇总 状态分布、总产值、总成本、各项合计
原始数据 62 个原始字段原样保留,便于追溯

关键代码:

ini 复制代码
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter

wb = Workbook()
ws = wb.active
ws.title = "我的项目"

ws.append(headers)
for c in ws[1]:
    c.fill = PatternFill("solid", fgColor="1F4E79")
    c.font = Font(bold=True, color="FFFFFF", size=10)
    c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)

for row in rows:
    ws.append(row)

# 冻结到第 3 列 + 表头行,横向滚动时项目名不跑
ws.freeze_panes = "C2"
# 自动筛选,交付给业务方直接就能按部门/状态筛
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{ws.max_row}"

wb.save("我的项目_导出.xlsx")

有几个细节值得单独说:

1)列宽不能一把梭。 项目名称、公司全称这类要宽,状态、工期这类要窄:

css 复制代码
widths = {"项目名称": 46, "项目编号": 16, "项目类别": 22, "所属公司": 26}
for i, h in enumerate(headers, 1):
    ws.column_dimensions[get_column_letter(i)].width = \
        widths.get(h, max(10, min(18, len(h) * 2 + 4)))

2)CSV 一定用 utf-8-sig。 否则 Excel 打开中文全是乱码,这是最常见的交付事故:

python 复制代码
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
    csv.writer(f).writerows(rows)

3)时间戳要转。 接口给的是毫秒时间戳 1787464037000,转成人能看的:

perl 复制代码
def ts(ms):
    if not ms: return None
    return datetime.datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M:%S")

4)金额给两套口径。 业务方习惯看万元,但接口是元。两列都给,谁也不用来回换算:

css 复制代码
("合同额(元)",   lambda i, r: r.get("contractAmount")),
("合同额(万元)", lambda i, r: round((r.get("contractAmount") or 0) / 10000, 6)),

5)留一份原始 JSON 备份。 任何我「翻译」过的字段都可能理解有偏,原始接口返回原样存一份,将来出事能对账:

lua 复制代码
json.dump({"source": URL, "exported_at": ..., "records": recs},
          f, ensure_ascii=False, indent=2)

最终产物:

scss 复制代码
   约 45 KB   我的项目_导出.xlsx      (3 sheets, 57 rows x 45 cols)
   约 24 KB   我的项目_导出.csv
  约 110 KB  我的项目_原始数据.json   (62 fields, 零丢失)

九、一个很「Agent 时代」的坑:沙箱写不出去

文件生成好了,复制到桌面目标文件夹------失败:

perl 复制代码
cp: /Users/xxx/Desktop/目标文件夹/我的项目_导出.xlsx: Operation not permitted
[sandbox: file access denied under workspace-write mode]

原因很现代:AI Agent 跑在文件沙箱里,默认只能写自己的会话工作区。目标目录在工作区之外,被策略拦下了。

这不是 bug,是设计------沙箱默认拦下越界写入,需要用户显式授权一次。授权后同一命令立刻成功:

css 复制代码
-rw-r--r--  45120  我的项目_导出_2026-09-27.xlsx
-rw-r--r--  24188  我的项目_导出_2026-09-27.csv
-rw-r--r-- 112640  我的项目_原始数据_2026-09-27.json

顺带一提,前面 network 抓包失败(EPERM: mkdir ~/.opencli/cache/)是同一个根因------Agent 的工具链也会被沙箱管住,写工作区外的缓存目录同样会被拒。

如果说这个流程有什么和传统爬虫不一样的地方,这就是最典型的一条:失败不再是「代码写错了」,而是「权限没给」。 看到 Operation not permitted 的第一反应应该是查沙箱,而不是改代码。


十、复盘:可复用的清单

整套流程走完不到十几分钟,其中一半时间花在验证和核对上。抽象成通用步骤:

  1. 先判断页面类型。 curl 拿到 200 但 body 是空壳 → 别硬爬,直接上浏览器。
  2. 借登录态,别模拟登录。 挂到已登录的浏览器上,SSO/验证码/双因子全部绕过。
  3. 找接口优先用 Performance API。 一句 performance.getEntriesByType('resource') 胜过翻半小时 bundle。
  4. 从 DOM 抽内容只当兜底。 要全字段、要原始类型,就必须走接口。
  5. pageSize 改大试试。 往往能一次拿全,省掉整个翻页循环。
  6. 字典表 + 树结构一定要翻译。 编码直接交付等于没交付。
  7. 用数量分布和页面显示对账。 状态分布对不上,说明映射方向错了。
  8. 单位靠交叉验证,不靠猜。 「明细求和 = 汇总接口」是最便宜的验证。
  9. 交付多格式 + 留原始备份。 xlsx 给人看、CSV 给程序用、JSON 给自己兜底。
  10. 口径不一致就如实标注,别硬凑。 产值和合同额对不上,就把两个数都摆出来。

最后一点,也是最该强调的:

这套方法威力不小,但它不是用来绕过权限的。它省掉的是「重复劳动」,不是「访问控制」。

用你自己的账号、导你自己有权限看的数据、别外传凭证和业务数据------技术无罪的边界,就在这里。


本文所述系统、域名、字段值与数据均已脱敏,代码为示意,请以你实际环境的接口为准。

相关推荐
Thneonl2 小时前
etcd 磁盘写满的那 6 分钟:控制面是怎么一步步瘫的
后端·架构
明月_清风2 小时前
一个完整的数据平台是怎么工作的?从数据源到数据分析
大数据·后端·数据分析
Moment2 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试
Wx-bishekaifayuan3 小时前
django医院营收信息预测系统49414-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
Wx-bishekaifayuan3 小时前
springboot会议室预约管理系统42030-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
AINative软件工程3 小时前
LLM 输出 Guardrail 工程实践:5 层防护栏让 AI 生成的内容不会炸掉生产
后端·llm·ai编程
IT_陈寒3 小时前
Vite热更新失效?你可能漏了这个配置项
前端·人工智能·后端
Wx-bishekaifayuan3 小时前
springboot生活商城系统21035-计算机课程设计、毕业设计
spring boot·后端·python·spring·elasticsearch·golang·课程设计
云浪3 小时前
通过 Goroutine 搞懂 Go 并发与常见的 9 个坑
后端·go