ECS 自动化爬取 Cookie 服务搭建

一、想法背景

前面做的自动化跟踪表,数据源都是目标网站的业务系统。业务系统是单点登录(SSO)的,登录态(SESSION cookie)隔一阵就过期。原来的链路是:WPS 多维表触发 → Coze 工作流 → 用我的账号密码去抓业务系统数据。一旦 cookie 失效,整条工作流就废了,我得手动去浏览器登一次再把新 cookie 塞回去,非常麻烦,而且账号密码硬编码在节点里也不安全。

于是我换了个思路:单独弄一台云服务器,让它 7×24 小时开着真实浏览器,自动把各基地的登录态维持好,把 cookie 缓存起来。 Coze / WPS 工作流不再自己登录,而是直接来这台服务器”借”现成的 cookie。谁要数据谁就带 token 来取,登录态过期由服务器在后台默默续期。这样登录这件最容易断的事就被”外包”给了常驻服务,自动化链路彻底闭环。

二、项目介绍

本项目部署在云服务器上,并且利用 FastAPI + Playwright + 真实 Chromium,模拟人工完成目标网站 IAM 登录 → 进入应用门户 → 点对应业务系统应用触发 SSO → 提取目标业务域(如 tms.example.com)的会话 cookie,按基地缓存,对外提供 HTTP 接口供 Coze / WPS 工作流调用。

整体架构图

简单说,它干三件事:

  1. 自动登录多个目标网站基地
    每个基地在 sites.json 里配专属账号,统一以超级账号作回退,确保一定能拿到 cookie。
  2. cookie 按基地缓存+后台续期
    客户端来取时秒回,过期前在后台悄悄重新登录,调用方不会被卡在 16~42 秒的登录里。
  3. 运单截图落盘返回直链
    给定调度单号,自动进业务系统截”运输单元明细”图,存成 PNG 并返回图片 URL,WPS 直接用 =IMAGE() 写进单元格。

服务运行效果

为什么不用“接口逆向”而要用真实浏览器?因为业务系统的 SSO 跳转、风控验证码、应用门户是重前端逻辑,逆向成本极高且极易随版本坏。真实浏览器登录是最稳的,代价是服务器要多花一点内存跑 Chromium(2C2G 够用,靠信号量限制并发)。

三、技术原理

(一)登录态的本质

业务系统的权限来自目标业务域(如 tms.example.com)下的 SESSION / JSESSIONID 等 cookie。只要这个 cookie 有效,拿着它去请求业务数据接口(如 业务数据接口.vue)就能拿到运单数据。所以”爬cookie”的本质就是:用一个能登录的浏览器,把登录成功后浏览器里的这些 cookie 读出来。

(二)为什么需要”常驻 + 缓存”

真实浏览器登录一次要 16~42 秒(冷启动),而且 cookie 会过期。如果每次工作流都现登,Coze 的网关 30 秒就超时掐断了(后面”趟坑”会讲这个坑)。所以服务把 cookie 缓存在内存里,客户端来取时立刻返回旧 cookie,过期由后台线程重新登录换新,调用方完全无感。

(三)截图服务的长轮询

截图比取 cookie 更慢(要进业务系统页面渲染)。为了不被网关超时,设计成:单次调用耗时硬封顶在 wait 秒(默认 8 秒)。

  • 命中缓存(15 分钟内截过)→ 毫秒级返回 status:done + image_url
  • 热态(约 5 秒)→ wait 内完成,一次调用直接拿到 URL
  • 冷态(16~42 秒)→ 到点返回 status:pending + job_id,调用方再带 job_id 轮询

这样任何一次 HTTP 调用耗时都 ≤ wait,网关永远不可能再 504。

四、搭建步骤

下面以一台云服务器为例(公网 IP 用 1.2.3.4 代指,2C2G / 40G ESSD,Ubuntu)。其它云同理。

(一)云服务器准备(装 Docker)

先 SSH 上去:

1
ssh root@1.2.3.4

连不上就先去云厂商控制台「防火墙 / 安全组」放行 22 端口。连上后装 Docker:

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 更新系统包
apt update && apt upgrade -y

# 2. 官方一键脚本装 Docker
curl -fsSL https://get.docker.com | sh

# 3. 当前用户加入 docker 组(免 sudo)
usermod -aG docker $USER

# 4. 验证
docker --version
docker compose version

⚠️ 第 3 步后断开 SSH 重连一次让 docker 组生效,再验证第 4 步。

SSH 连接与 Docker 安装

(二)上传项目代码

把本地的 cookie-server/ 目录打包传到服务器:

1
2
3
4
# 本机电脑执行(不是服务器上)
cd C:\你的项目路径\cookie-project
tar -czvf cookie-server.tar.gz cookie-server/
scp cookie-server.tar.gz root@1.2.3.4:/root/

回到服务器解压:

1
2
3
cd /root
tar -xzvf cookie-server.tar.gz
ls cookie-server/ # 应看到 main.py login.py sites.json requirements.txt 等

💡 如果服务器拉 python:3.11-slim 基础镜像很慢(国内常事),可以先在本机 docker build 成镜像再 docker save 成 tar 上传离线加载(deploy_offline_image.sh 就是干这个的)。本项目 Dockerfile 首行是 FROM python:3.11-slim,国内服务器注意:云厂商容器镜像仓库的 library/ 公共命名空间需要授权,直接 build 可能报 insufficient_scope,改成可达的镜像源(如 docker.m.daocloud.io/library/python:3.11-slim)即可。

(三)配置 .env 与 sites.json

1. 生成强随机 Token

1
python3 -c "import secrets; print('COOKIE_API_TOKEN=' + secrets.token_urlsafe(32))"

把输出复制下来,这是生产 token,不要短、不要复用。

2. 编辑 .env

1
2
3
cd /root/cookie-server
cp .env.example .env
nano .env

改成(token 换成上一步生成的):

1
2
3
4
5
6
7
# 生产环境 token(强随机值)
COOKIE_API_TOKEN=这里粘贴生成的随机token

# 服务策略
COOKIE_TTL_SECONDS=1800
LOGIN_TIMEOUT=60
SITES_CONFIG=sites.json

Ctrl+O → 回车 → Ctrl+X 保存。

3. sites.json(基地与账号)

sites.json 已填好真实账号,千万别提交进 Git(含明文密码)。每个基地配专属账号,另设超级账号作回退:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"login_url": "https://iam.example.com/login/#/",
"timeout": 60,
"cookie_names": ["SESSION", "JSESSIONID", "uaa_session_id", "SECKEY_ABVK", "BMAP_SECKEY"],
"session_cookie_name": "SESSION",
"sites": [
{
"key": "base_a",
"name": "基地A",
"username": "TMS专属账号(占位,请勿填真实账号)",
"password": "XXXX******!",
"tms_app": "业务应用(基地A)",
"target_url": "https://tms.example.com:8445/业务数据接口.vue",
"cookie_domains": ["tms.example.com"]
}
]
}

字段含义:

字段 说明
key 基地标识(Coze 可填,也可直接填中文 name)
name 人类可读名(Coze 输入值就填这个,如 基地A)
username/password IAM 专属账号(来自账号本)
fallback_username/fallback_password 回退账号(超级账号),主账号失败时自动用
tms_app 应用门户里要点的业务系统应用代码,是区分基地的关键
sso_url (可选)固定 SSO 地址,跳过应用发现(售后用)
select_account (可选)IAM 账号选择页要选的子账号(售后用)

sites.json 配置示例

基地靠内部应用门户里的业务系统应用代码区分,不是中文名。售后应用比较特殊:SSO 会先弹账号选择页,所以单独用 sso_url + select_account 处理。

(四)构建并启动

1
2
3
4
5
6
7
cd /root/cookie-server

# 构建并启动(首次会下载 Chromium,约 150MB,等几分钟)
docker compose up -d --build

# 看日志确认启动成功
docker compose logs -f

看到 Uvicorn running on http://0.0.0.0:8000 就成功了。Ctrl+C 退出日志,服务后台继续跑。
这里我的cookie还服务器自动截图项目。
docker compose 启动成功

(五)放行端口 + 外部验证

去云厂商控制台「防火墙 / 安全组」加规则:协议 TCP、端口 8000、策略允许、授权对象 0.0.0.0/0(后续建议收紧到 Coze 出口 IP)。

服务器内部先验证:

1
2
3
curl "http://localhost:8000/health"
# 期望:{"ok":true,"memory_available_mb":...,"sites":{"base_a":false}}
curl "http://localhost:8000/sites?token=你的TOKEN"

本机电脑验证公网可达:

1
curl "http://1.2.3.4:8000/health"

(六)Coze 工作流接线

节点 1(HTTP 请求 · 取 cookie)

  • 方法:GET
  • URL:http://1.2.3.4:8000/cookie/{{基地名}}?token=你的TOKEN
    • {{基地名}} 就是 基地A 这种(支持中文名,也兼容 key 如 base_a)
  • 取返回 JSON 的 cookie 字段作为变量 auth_cookie

节点 2(HTTP 请求 · 取数据)

  • 方法:POST
  • URL:https://tms.example.com:8445/业务数据接口.vue
  • Headers:Cookie: {{auth_cookie}},Content-Type: application/json
  • Body:单号 + 请求签名参数(沿用已有算法)
  • 若返回 401/空,可在节点 1 加 &refresh=1 强制重登重试

Coze 工作流取 cookie + 取数据

(七)WPS 多维表 / 自动化接线

WPS(金山文档)自动化里,我用一个工作流:事件触发(表格改了某行)→ 执行 AirScript 脚本 → 发送 HTTP 请求。其中”发送 HTTP 请求”节点去调 Coze 工作流。

WPS 自动化工作流

⚠️ 关键坑:WPS 自动化不能直接出网到 api.coze.cn,需要走金山文档的出站代理(后面趟坑第 5 条详述)。另外 AirScript 写 =IMAGE() 时,要把服务器返回的图片直链(形如 http://1.2.3.4:8000/img/xxx.png)写进去,而不是把整段 JSON 或 Coze 调试页当图片——否则单元格会 #VALUE!。

AirScript数据回写脚本参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
function colLetter(n) {
let s = "";
while (n > 0) {
const m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}

// 循环解析被多次 JSON.stringify / 转义的字符串,直到得到对象或无法继续
function deepParse(v) {
let current = v;
for (let i = 0; i < 5; i++) {
if (typeof current !== "string") break;
const trimmed = current.trim();
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) break;
try {
current = JSON.parse(trimmed);
} catch (e) {
break;
}
}
return current;
}

// 解析 Coze 返回:外层可能是 {data:"{...}"},内层 data 是 JSON 字符串;也可能直接是对象
function parseCozeResult(raw) {
if (!raw) return { outer: null, inner: null };
let outer = deepParse(raw);
let inner = null;
if (outer && typeof outer === "object" && typeof outer.data === "string") {
inner = deepParse(outer.data);
if (typeof inner !== "object") inner = null;
}
return {
outer: (outer && typeof outer === "object") ? outer : null,
inner: inner
};
}

// 单号候选 key(中英文 + 大小写兼容)
const ORDERNO_KEYS = ["单号", "OrderNo", "order_no", "orderNo", "orderno", "billNo", "运单号", "单据号"];
function findOrderNo(obj) {
if (!obj || typeof obj !== "object") return "";
for (const k of ORDERNO_KEYS) {
if (obj[k] != null && String(obj[k]).trim() !== "") return String(obj[k]).trim();
}
const lowerKeys = ORDERNO_KEYS.map(k => k.toLowerCase());
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (lowerKeys.indexOf(String(key).toLowerCase()) >= 0) {
if (obj[key] != null && String(obj[key]).trim() !== "") return String(obj[key]).trim();
}
}
return "";
}

// 轻量清洗:托数/重量提取数值(去单位),其余仅去首尾空白
const CLEAN_NUMBERS = true;
function cleanValue(wpsColName, val) {
if (val === null || val === undefined) return val;
const s = String(val).trim();
if (CLEAN_NUMBERS && (wpsColName === "托数" || wpsColName === "重量")) {
const m = s.match(/-?\d+(?:[.,]\d+)?/);
if (m) {
const num = m[0].replace(/,/g, "");
return isNaN(Number(num)) ? s : Number(num);
}
}
return s;
}

function main() {
try {
const argv = (typeof Context !== "undefined" && Context && Context.argv) ? Context.argv : {};
const sheetName = argv.sheet_name || (typeof Context !== "undefined" && Context && Context.sheet_name) || "sheet1";
const rawInput = argv.coze_result || argv.result || argv.data || argv.body || argv.payload || argv.response || "";

console.log("argv.sheet_name=", sheetName, " coze.code=", (rawInput && rawInput.code != null ? rawInput.code : "(无)"));
console.log("rawInput类型=", typeof rawInput, " 前200字=", (typeof rawInput === "object" ? JSON.stringify(rawInput).slice(0, 200) : String(rawInput).slice(0, 200)));

// ===== 上游 Coze 工作流错误拦截 =====
if (rawInput && typeof rawInput === "object" && rawInput.code != null && rawInput.code !== 0 && rawInput.code !== 200) {
const cozeMsg = rawInput.msg || "";
console.log("【上游Coze错误】code=" + rawInput.code + " msg=" + cozeMsg);
return {
ok: false,
error: "上游Coze工作流返回错误(code=" + rawInput.code + ")",
coze_msg: cozeMsg,
debug_url: rawInput.debug_url || "",
hint: "这是 Coze 服务端内部错误,与脚本无关;请稍后重试该单,或在 Coze 控制台用 debug_url 排查。"
};
}

const parsed = parseCozeResult(rawInput);
const cozeData = parsed.inner || parsed.outer;
console.log("cozeData=", cozeData === null ? "null" : JSON.stringify(cozeData).slice(0, 500));

let 单号 = findOrderNo(parsed.inner) || findOrderNo(parsed.outer);
let 单号来源 = "coze返回";
if (!单号 && argv.order_no) {
单号 = String(argv.order_no).trim();
单号来源 = "入参order_no";
}
console.log("解析得到的单号=", 单号, " 单号来源=", 单号来源);

if (!单号) {
return {
ok: false,
error: "单号缺失",
sheet_name: sheetName,
hint: "请确认 Coze 工作流最终输出含单号字段(建议 OrderNo/单号),或在本节点额外传入 order_no",
cozeData_keys: cozeData ? Object.keys(cozeData) : []
};
}

// ================= 打开工作表 =================
console.log("准备打开工作表 sheetName=", sheetName, "(脚本默认维护到 sheet1 )");
let ws;
try {
ws = Application.Sheets(sheetName);
} catch (e) {
let availableSheets = [];
try {
const cnt = Application.Sheets.Count;
for (let i = 1; i <= cnt; i++) {
try { availableSheets.push(Application.Sheets(i).Name); } catch (e2) {}
}
} catch (e2) {}
return {
ok: false,
error: "打开工作表失败:表名不存在或拼写不一致(Application.Sheets 抛异常)",
received_sheet_name: sheetName,
exception: (e && e.message) ? String(e.message) : String(e),
available_sheets: availableSheets,
hint: "请检查本节点传入的 sheet_name 是否与 WPS 实际表名完全一致;当前订单为9月单,正确表名应为「sheet1」"
};
}
if (!ws) {
let availableSheets = [];
try {
const cnt = Application.Sheets.Count;
for (let i = 1; i <= cnt; i++) {
try { availableSheets.push(Application.Sheets(i).Name); } catch (e) {}
}
} catch (e) {}
return {
ok: false,
error: "找不到工作表,请检查 sheet_name 参数",
received_sheet_name: sheetName,
available_sheets: availableSheets
};
}

// ================= 读表头 =================
const rowCount = ws.UsedRange.Rows.Count;
console.log("rowCount=", rowCount);

// 一次性读取整行表头(替代逐列 80 次 Range 往返,云端每次往返都耗时)
const headerIndex = {};
const headerColNum = {};
let headerRowRaw;
try { headerRowRaw = ws.Range("A1:" + colLetter(80) + "1").Value2; } catch (e) { headerRowRaw = null; }
if (headerRowRaw != null) {
const hs = Array.isArray(headerRowRaw)
? (Array.isArray(headerRowRaw[0]) ? headerRowRaw[0] : headerRowRaw)
: [headerRowRaw];
for (let c = 0; c < hs.length; c++) {
const h = hs[c];
if (h != null && h !== "") {
const name = String(h).trim();
const letter = colLetter(c + 1);
headerIndex[name] = letter;
headerColNum[name] = c + 1;
}
}
}
console.log("表头=", JSON.stringify(headerIndex));

// 定位「单号」列:先按候选名精确匹配,找不到则回退到 A 列(历史默认位置)
const ORDER_COL_CANDIDATES = ["单号", "订单号", "运单号", "单据号"];
let col单号 = "";
for (const cand of ORDER_COL_CANDIDATES) {
if (headerIndex[cand]) { col单号 = headerIndex[cand]; break; }
}
if (!col单号) {
// 兼容「A1 表头被清空」的情况:A 列历史上就是单号列,且若 A2 有数据则回退使用
try {
const aVal = ws.Range("A2").Value2;
if (aVal != null && String(aVal).trim() !== "") {
col单号 = "A";
console.log("【警告】表头未找到单号列,回退使用 A 列作为单号列(请确认 A 列确为单号;建议把 A1 表头填回「单号」)");
}
} catch (e) {}
}
if (!col单号) {
return {
ok: false,
error: "WPS 表头没有「单号」列,且 A 列也无数据,无法定位",
表头: headerIndex,
建议: "请确认表中存在单号列(列名含 单号/订单号/运单号),或在 A1 填回「单号」"
};
}
console.log("单号列=", col单号);

// ================= 找匹配行(多行取最下面一行) =================
if (rowCount < 2) {
return { ok: false, error: "表中只有表头行,没有数据行", rowCount: rowCount };
}

const colRng = ws.Range(col单号 + "2:" + col单号 + rowCount);
const colRaw = colRng.Value2;
let colArr;
if (Array.isArray(colRaw)) {
colArr = Array.isArray(colRaw[0]) ? colRaw.map(x => x[0]) : colRaw.slice();
} else {
colArr = [colRaw];
}

let matchedRow = -1;
let matchedCount = 0;
for (let r = 2; r <= rowCount; r++) {
const v = colArr[r - 2];
if (v === undefined || v === null || v === "") continue;
if (String(v) === String(单号)) {
matchedRow = r;
matchedCount++;
}
}

console.log("单号=", 单号, "单号来源=", 单号来源, "matchedRow=", matchedRow, "matchedCount=", matchedCount);

if (matchedRow === -1) {
return {
ok: false,
单号: 单号,
单号来源: 单号来源,
error: "未找到匹配行",
hint: "请确认 coze_result 已回传单号,且 WPS 表中存在该单号行"
};
}

if (!cozeData || typeof cozeData !== "object") {
return {
ok: false,
单号: 单号,
matchedRow: matchedRow,
error: "无法解析 coze_result,请检查传入的是否为 Coze 返回的 JSON 字符串",
received_raw: (typeof rawInput === "object" ? JSON.stringify(rawInput).slice(0, 500) : String(rawInput).slice(0, 500))
};
}

// ============ 字段映射:WPS列 -> [候选Coze字段名],命中第一个即写入 ============
const FIELD_MAP = {
"要求车型|需求车型|车型": ["ReqVeh", "reqVeh", "要求车型", "需求车型", "车型", "requireVeh", "reqVehicle", "demandVeh"],
"托数": ["Count", "count", "托数", "pallets", "palletCount", "数量"],
"重量": ["Weight", "weight", "重量", "kg", "gw", "grossWeight"],
"发运地址|装货地址|起运地": ["LoadAddress", "发运地址", "fromAddress", "loadAddr", "load_address", "起运地", "装货地址", "发货地址"],
"收货地址|卸货地址|目的地": ["ShipAddress", "收货地址", "toAddress", "shipAddr", "ship_address", "卸货地址", "目的地", "到货地址"],
"线路|路线": ["Route", "线路", "route", "Line", "line", "luXian", "path", "路线"],
"到厂时间": ["Remark", "备注", "remark", "DaoChangTime", "arriveNote", "arriveTime"], // Coze的Remark(备注) -> 表的「到厂时间」
"日期": ["orderTime", "下单时间", "OrderTime", "order_time", "createTime", "create_time", "下单日期", "orderDate"], // Coze的orderTime(下单时间) -> 表的「日期」
"运输时效|时效|标准运输时效": ["shipTime", "ShipTime", "ship_time", "standardShipTime", "standardTime", "运输时效", "时效", "标准运输时效"], // Coze的shipTime(标准运输时效) -> 表的「运输时效」
"下单时间": ["orederTime", "orderTime", "OrderTime", "order_time", "下单时间"], // Coze的orederTime(下单时间) -> 表的「下单时间」
"提货时效": ["deliTime", "DeliTime", "deli_time", "提货时效", "标准提货时效"] // Coze的deliTime(标准提货时效) -> 表的「提货时效」
};

// 空值是否覆盖已有数据:默认 false(Coze 未查到返回空时,跳过写入,保留原单元格内容,避免误清空)
const OVERWRITE_EMPTY = false;

// ================= 回写字段(批量:读整行→内存改数组→一次写回,避免逐字段多次 Range 往返) =================
const written = {};
const missing = [];
const skipped = [];
const emptyVal = [];

// 建立 Coze 数据的小写 key 索引:实现大小写不敏感匹配,
// 避免 ReqVeh / reqVeh / RequireVeh 等命名差异导致字段漏写
const cozeLowerMap = {};
for (const k in cozeData) {
if (cozeData.hasOwnProperty(k)) cozeLowerMap[String(k).toLowerCase()] = k;
}

// ================= 回写字段(精准写入:只写 FIELD_MAP 命中的列) =================
let pendingWrites = 0;

for (const wpsColKey in FIELD_MAP) {
if (!FIELD_MAP.hasOwnProperty(wpsColKey)) continue;
const candidates = FIELD_MAP[wpsColKey];

// 防御:任何情况下都不动「备注」列(截图图片所在列),避免与截图脚本互相覆盖
const wpsColCandidates = wpsColKey.split("|");
if (wpsColCandidates.indexOf("备注") >= 0) {
skipped.push(wpsColKey + "(已跳过:不写入「备注」列,避免覆盖截图图片)");
continue;
}

let hitKey = null;
let hitVal = null;
for (const ck of candidates) {
const realKey = cozeLowerMap[ck.toLowerCase()];
if (realKey != null) {
hitKey = realKey;
hitVal = cozeData[realKey];
break;
}
}
if (hitKey === null) {
skipped.push(wpsColKey + "(候选:" + candidates.join("/") + ")");
continue;
}

// 在表头里查找 WPS 列(支持竖线分隔的多候选列名,如 "要求车型|需求车型|车型")
let wpsColName = null;
let colNum = -1;
for (const wc of wpsColCandidates) {
if (headerColNum[wc] != null) { wpsColName = wc; colNum = headerColNum[wc]; break; }
}
if (colNum < 0) {
missing.push(wpsColKey + "(Coze字段:" + hitKey + ", 候选列:" + wpsColCandidates.join("/") + ")");
continue;
}

let val = cleanValue(wpsColName, hitVal);
if (val === "" || val === null || val === undefined) {
emptyVal.push(wpsColName + "(" + hitKey + ")");
if (!OVERWRITE_EMPTY) continue;
}

// 精准写入单个单元格:只动当前命中列,不影响同行其它列(如「备注」图片)
try {
ws.Range(colLetter(colNum) + matchedRow).Value2 = val;
written[wpsColName] = val;
pendingWrites++;
} catch (e) {
missing.push(wpsColName + "(写入异常:" + (e && e.message ? e.message : e) + ")");
}
}

// ================= 返回结果(普通对象) =================
const result = {
ok: true,
单号: 单号,
单号来源: 单号来源,
sheet_name: sheetName,
matchedRow: matchedRow,
matchedRows: matchedCount,
note: matchedCount > 1 ? "该单号在源表存在多行,已取最下面一行(最新录入)为准" : "",
written: written,
writtenCount: Object.keys(written).length,
missingColumns: missing,
skippedNoData: skipped,
emptyValues: emptyVal
};

console.log("回写结果=", JSON.stringify(result));
return result;

} catch (err) {
// 任何未预料的异常都包成普通对象返回,避免 WPS 报「输出结果不是json格式」
let msg = "";
let stack = "";
try {
msg = err && err.message ? String(err.message) : String(err);
} catch (e) { msg = "(无法读取异常 message)"; }
try {
stack = err && err.stack ? String(err.stack) : "";
} catch (e) { stack = "(无法读取异常 stack)"; }
// 关键:把异常打到运行日志,避免「执行完毕」却无任何线索
console.log("【脚本异常】message=", msg, " stack=", stack);

return {
ok: false,
error: "脚本执行异常",
message: msg,
stack: stack
};
}
}

// 顶层再包一层,确保最终输出一定是普通对象(WPS 自动化节点要求返回对象格式)
let finalOutput;
try {
finalOutput = main();
if (typeof finalOutput === "string") {
try {
finalOutput = JSON.parse(finalOutput);
} catch (e) {
finalOutput = { ok: false, error: "脚本返回了非对象字符串", raw: finalOutput.slice(0, 500) };
}
}
} catch (e) {
const msg = e && e.message ? String(e.message) : String(e);
finalOutput = { ok: false, error: "顶层异常", message: msg };
}
if (!finalOutput || typeof finalOutput !== "object") {
finalOutput = { ok: false, error: "脚本输出不是对象", type: typeof finalOutput };
}
try {
console.log("BACKFILL_RESULT: " + JSON.stringify(finalOutput).slice(0, 2000));
} catch (e) {
console.log("BACKFILL_RESULT: (无法序列化) " + String(finalOutput));
}
return finalOutput;

(八)开机自启(重要)

确认 docker-compose.yml 里是:

1
2
3
services:
cookie-server:
restart: unless-stopped

并让 Docker 开机自启:

1
systemctl enable docker

⚠️ 更新代码请 docker compose up -d --build 或 docker compose restart,**千万别用 docker compose down**——down 会删掉容器,而 restart: always/unless-stopped 只能重启”存在的”容器,删了就再也自愈不了(趟坑第 3 条)。

五、报错分析与趟坑记录

这部分是本项目踩过的真坑,按”现象 → 原因 → 解决”记录,省得以后再掉。

1. Coze 返回 522 Timeout(来自 ByteProxy)

现象:工作流偶发 522 Timeout,尤其容器刚重启、要重新登录那次。

原因:实测单次接口耗时 —— 容器冷启动 16~42 秒,浏览器热态约 5 秒。Coze 的出站代理(ByteProxy)超时阈值远小于 42 秒,碰上冷启动那一次就被掐断。服务器其实没挂,/screenshot/{order} 一直 200。

解决:改成”长轮询 + 封顶”。单次 HTTP 调用耗时被 wait 硬卡(默认 8 秒),到点没好就返回 status:pending + job_id,调用方再轮询。另提供极简接口 /shot/url?order=...&wait=20 直接返回一行纯文本 URL,WPS 取值最省事。图片直链 /img/{filename} 无需 token。

522 Timeout 与长轮询改造

2. WPS 写入 #VALUE! 且 AirScript 报 Unexpected token 'const'

现象:脚本跑通了,但 WPS 单元格写进 #VALUE!;有时直接报 Unexpected token 'const'。

原因有两个:

  • 一是脚本把 Coze 的 debug_url(一个 coze.cn 调试页)误当成图片 URL,写成了 =IMAGE("https://www.coze.cn/work_flow?..."),自然是非法图片 → #VALUE!。
  • 二是 WPS 的 JS 引擎解析不了我新加的复杂正则(转义斜杠 / 与 | 交替、\.、? 量词混用),把下一行的 const 判成意外 token。

解决:

  • 把脚本里所有复杂正则改成零正则(用 indexOf / split / charCodeAt)。
  • 取图 URL 时排除 coze.cn 链接,只认含 /img/、/shot、.png 这些图 URL,且必须是服务器 1.2.3.4 的直链。
  • 在 main() 顶部加上游 Coze 错误码拦截:body.code != null 且 != 0/200 就早返回清晰诊断,不再误报”未拿到单号”。
  • 加 const 的 TDZ 保护(变量先声明再日志)等。

经验:往受限环境(网页终端 / WPS 编辑器)投放代码,新增逻辑尽量用 indexOf/split 代替复杂正则,避免多行表达式。

3. 容器被 docker compose down 删掉,再也不自愈

现象:某天所有工作流突然 521(服务器没监听 :8000)。上服务器一看 docker ps -a 空空如也,curl 127.0.0.1:8000/health 无响应。

原因:容器是被 docker compose down 清掉的。restart: always 只负责”容器存在时崩溃重启”,删了就无能为力。而且这台机没有开机自启 docker 的 systemd 配置,重启后连容器都没有。

解决:

  • 拉起:cd /root/cookie-server && docker compose up -d(镜像还在,秒起)。
  • 防复发:compose 改 restart: unless-stopped;systemctl enable docker 保证 docker 守护进程开机自启;**日常更新用 restart/stop,绝不用 down**。
  • 顺手清理服务器上其它不相关的项目,只留 cookie-server + 截图服务 + 基础设施,降低 OOM 互相挤占。

4. 售后登录失败(验证码 OCR + Playwright 异步报错)

现象:/health 显示 shouhou:false,/refresh/shouhou?wait=1 报 登录未拿到会话 cookie;日志里两种错误交替:

1
2
3
[验证码] OCR 结束: 'p2um' / 'emg' / 'vplm' ...   # 全是垃圾
[bg-refresh] shouhou 后台刷新失败: 验证码识别失败(已达最大重试次数)
[shotjob] ... 所有候选账号均登录失败: It looks like you are using Playwright Sync API inside the asyncio loop.

原因:

  • 验证码 OCR 全错。目标网站的验证码是 4 字符彩色 + 大量干扰线,login.py 的 _recognize_captcha 先试 ddddocr 再退 pytesseract。如果 ddddocr 没装好,就退化到 tesseract,基本无解。
  • 更诡异的是第二句:源码里完全没有 asyncio,全是同步 def 接口,理论上 sync_playwright() 不该报”在 asyncio 循环里”。高度怀疑容器里跑的代码 ≠ 宿主机 /root/cookie-server 的代码(镜像是别处 build 的 tar 上传的,可能带一版 async 登录)。

解决(诊断脚本):写了一个一次性诊断 diag_login_root_cause.txt,做四件事:

  • 容器 /app vs 宿主机 /root/cookie-server 逐文件 md5 对比(找 DIFF)
  • 检查 ddddocr / pytesseract / tesseract 二进制是否可用
  • 抓一张真实验证码双引擎识别,并打印 base64 供人工核对
  • 触发一次真实登录打全日志

拿到 md5 表就能确认是不是”容器代码滞后”:是的话 docker cp 宿主机 *.py 进容器再重启即可;否则就是 OCR 引擎没装对,进容器 pip install ddddocr 并让识别失败有明确日志。

补充判断:镜像 image_created 比所有源码 mtime 都新,重建镜像不改变任何行为,所以这条路不用急着 build;先比对代码再决定。

5. WPS 工作流集体报 context deadline exceeded

现象:所有 WPS 自动化工作流都失败在”发送 HTTP 请求”节点:

1
请求失败,状态码:500,响应体: do request err: Post "https://120.92.111.228:19100": context deadline exceeded

运行耗时 30 / 32 秒(正好顶到节点 30 秒上限)。

原因(实测定位):120.92.111.228:19100 不是我的服务器,它是金山文档(WPS)自己的出站代理 kdocs_proxy(返回 {"kdocs_proxy_err_msg":...})。链路是:WPS 节点 → POST 该代理 {url: api.coze.cn...} → 代理转发到 Coze 工作流。这个代理有主机名白名单,实测 api.coze.cn / 1.2.3.4 / www.baidu.com 全部 not in whitelists。白名单里把 api.coze.cn 移除了 → 经代理的 WPS→Coze 调用整体不通,卡到 WPS 30 秒超时 → 全挂。

kdocs_proxy 白名单报错

解决:

  • 找公司 space 管理员 / IT,把 api.coze.cn 加回代理白名单,工作流立刻恢复。
  • 或者改方案,让”发送 HTTP 请求”节点原生直连 https://api.coze.cn/v1/workflow/run(不填代理),前提是 WPS 能直出外网。
  • 顺带提醒:WPS 节点只有 30 秒上限,而服务器冷启动截图要 16~42 秒,所以即便代理修好,冷启动那几次仍可能超时。正确姿势是”提交任务立刻拿 job_id → 第二步轮询”,或把 wait 压到 8 秒以内。

六、改进设想

  1. 验证码彻底自动化。ddddocr 离线打包进镜像 + 识别失败时人工兜底(把验证码图推到钉钉/邮箱让人填一次),把”风控验证码”这唯一会断的点也闭环。
  2. 安全加固。加 HTTPS 反代(Caddy + Let’s Encrypt)+ Coze 出口 IP 白名单,避免 token 明文传输、避免被扫。
  3. 监控告警。cookie 连续续期失败时主动推钉钉告警,而不是等工作流挂了才发现。
  4. 多机容灾。目前 2C2G 单机,Chromium + 并发登录有 OOM 风险(内存曾掉到 288MB 逼近暂停阈值)。后续可拆 cookie 服务与截图服务,或加 Swap / 限制并发。
  5. 对外开放成标准接口。截图直链 + cookie 接口已经很通用,后续可以把更多业务表单(在途确认、要求到达时间等)都接进来,让跟踪表所有字段全自动闭环。

如果搭建过程遇到上面没提到的坑,欢迎在评论区交流,看到会回。