别急着写爬虫:接上你「已登录的浏览器」,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 里抠表格,会遇到三个问题:
- 只拿得到当前页的 15 条,剩下 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 倍,报出去就是事故。
猜是没用的,我用交叉验证:
- 先看汇总接口
/business/project/calculate返回:
css
{"code":0, "data": {"cost": 18432650.00, "output": 43218000}}
- 再看页面顶部的展示值:项目总成本 1843.265 万元 、项目总产值 4321.8 万元 。
→ 说明接口返回的cost/output单位是元。 - 最后把 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 的第一反应应该是查沙箱,而不是改代码。
十、复盘:可复用的清单
整套流程走完不到十几分钟,其中一半时间花在验证和核对上。抽象成通用步骤:
- 先判断页面类型。
curl拿到 200 但 body 是空壳 → 别硬爬,直接上浏览器。 - 借登录态,别模拟登录。 挂到已登录的浏览器上,SSO/验证码/双因子全部绕过。
- 找接口优先用 Performance API。 一句
performance.getEntriesByType('resource')胜过翻半小时 bundle。 - 从 DOM 抽内容只当兜底。 要全字段、要原始类型,就必须走接口。
pageSize改大试试。 往往能一次拿全,省掉整个翻页循环。- 字典表 + 树结构一定要翻译。 编码直接交付等于没交付。
- 用数量分布和页面显示对账。 状态分布对不上,说明映射方向错了。
- 单位靠交叉验证,不靠猜。 「明细求和 = 汇总接口」是最便宜的验证。
- 交付多格式 + 留原始备份。 xlsx 给人看、CSV 给程序用、JSON 给自己兜底。
- 口径不一致就如实标注,别硬凑。 产值和合同额对不上,就把两个数都摆出来。
最后一点,也是最该强调的:
这套方法威力不小,但它不是用来绕过权限的。它省掉的是「重复劳动」,不是「访问控制」。
用你自己的账号、导你自己有权限看的数据、别外传凭证和业务数据------技术无罪的边界,就在这里。
本文所述系统、域名、字段值与数据均已脱敏,代码为示意,请以你实际环境的接口为准。