二维码数据摆渡:隔离网络自动化填表的原理与实现

运维场景里有一种很典型但很少被正经讨论的困境:网络是隔离的,而工作是连通的

一边是封闭内网里的交换机、防火墙、虚拟化主机,每天要采集 CPU、内存指标;另一边是互联网上的在线表格,填报系统要求按日期逐格填写。中间隔着一道刻意筑起的墙:内网出不去,外部进不来,USB 口被封,连安装软件都要审批。

最朴素的做法是人工:内网电脑上看一眼数字,拿笔记下来(或者拿手机拍屏),回到互联网电脑上打开表格,一格一格敲进去。几十台设备、每天一次、每次十来分钟,不出错靠的是细心,出了错没处查。

这篇文章拆解一套替代方案的完整实现:用二维码做单向数据摆渡通道,让手机充当“人肉网线”,再由服务端驱动浏览器自动填表。整套工具最终打包成单文件 exe,拷进内网即用。

方案选型:为什么是二维码

先明确约束,再谈方案:

  • 内网与互联网物理隔离,不存在路由可达性,内网穿透类工具(frp、反向代理)直接出局——既不可行,也不合规;
  • USB 存储被管控,U 盘摆渡出局;
  • 数据量极小:几十台设备,每台就 CPU、内存两个数,撑死几 KB;
  • 频率高:每天一次,方案必须“零思考”可用。

把这几个条件摆在一起,一个反直觉的结论浮现出来:数据这么小,根本不需要一条“网络通道”,只需要一次“注视”。二维码恰好就是“把几百字节钉在屏幕上”的技术,而人兜里永远揣着一台能扫码的手机。

方案 是否可行 问题
内网穿透 网络不可达,且违反隔离策略
U 盘拷贝 存储设备被管控
人工抄录 易错、耗时、不可审计
发邮件/IM 传文件 内网无外部邮件,IM 被封
二维码摆渡 只需手机与服务端电脑同一 WiFi

最后一条是关键:手机扫码后要访问的服务端,部署在互联网电脑上,手机连上与服务端相同的 WiFi 即可触达——这段链路完全是合法的局域网访问,不涉及任何穿透。

于是整体链路是:

┌──────────────────────┐                     ┌──────────────────────┐
│ 客户端(内网电脑)      │                     │ 服务端(互联网电脑)    │
│  ① 自动采集设备日志   │                     │  HTTP 监听 :3000     │
│  ② 解析出 CPU/内存   │                     │  Playwright 自动填表  │
│  ③ 数据+服务端地址    │                     │                      │
│     编码为二维码      │                     │                      │
└──────────┬───────────┘                     └──────────▲───────────┘
           │                                           │
      屏幕显示二维码                          POST /api/submit
           │                                           │
           ▼                                           │
        👤 手机(与服务端同一 WiFi)──────────────────────┘
           扫码 → 打开落地页 → 核对数据 → 点击发送

四个角色:内网客户端负责采集与编码,二维码负责承载,手机负责搬运与确认,服务端负责接收与填表。注意数据流向是严格单向的(内网 → 互联网),且中间经过了人的确认——这一点后面会反复出现,它是整个方案的安全边界。

数据通道设计:gzip + base64url + URL Fragment

整个方案最核心的一行代码,是把采集结果变成一个 URL:

import { gzipSync } from "node:zlib";

const payload = { v: 1, time: "2026-09-03 09:00:00", files: { ... } };
const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf8"));
const url = `${serverUrl}/push#p=${compressed.toString("base64url")}`;

这个 URL 就是二维码的内容。三个设计决策值得展开。

容量测算

QR 码 Version 40 在 L 级纠错下,字节模式最多容纳 2953 字节。这是硬上限,所以编码链路必须足够狠:

  • 原始 JSON:几十台设备,每台 {device, cpuUsage, memUsage} 约 6080 字节,合计 25 KB——直接编码会超限;
  • gzip 后:键名重复度极高,压缩率非常可观,结果通常只有几百字节;
  • base64url 编码后:体积膨胀约 4/3,仍在千字节以内,余量充足。

为什么是 base64url 而不是 base64

数据要放进 URL,而标准 base64 的 +/= 在 URL 语境里都是保留字符,会被浏览器编码或截断。base64url 用 -_ 替换这两个字符、去掉填充,产出的字符串可以原封不动地拼进 URL,解码端一行 Buffer.from(str, "base64url") 还原。

为什么放在 # 后面

URL fragment(hash 部分)有一个天然性质:浏览器不会把它发送给服务器

这意味着数据被扫码打开时,只存在于手机页面的内存里,由页面 JS 从 location.hash 自行解析、渲染摘要;服务端返回的落地页只是一份静态 HTML,与数据无关。数据真正到达服务端,是在用户核对摘要、点击“发送”之后的 POST /api/submit,请求体里带着编码后的 payload。

换句话说,二维码里同时打包了两样东西:服务端地址(让手机知道往哪发)和数据本身(hash 里躺着,不经过任何第三方)。用户全程不需要手动输入任何 IP 或数字。

payload 契约:一个包,两端共用

编码端(客户端)和解码端(服务端)必须对数据结构达成完全一致的理解。实践中最容易烂掉的就是这种“两端口头约定”——客户端改了字段,服务端三天后才发现。

解法是把契约收敛成一个共享包(common),编码、解码、结构校验、摘要生成四个函数住在同一个文件里:

export function decodePayload(encoded) {
  const payload = JSON.parse(gunzipSync(Buffer.from(encoded, "base64url")).toString("utf8"));
  if (!payload?.files || typeof payload.files !== "object") {
    throw new Error("payload 结构无效(缺少 files 字段)");
  }
  // 逐文件校验:必须是列表,每条记录必须有 device/cpuUsage/memUsage
  ...
  return payload;
}

解码即校验:任何结构漂移都会在服务端入口处被拦下并返回 400,而不是带着残缺数据去填表。附带版本号字段(v: 1),给未来的格式演进留了口子。

内网端:把“人肉巡检”变成程序

内网客户端的任务链是:自动登录设备 → 执行采集命令 → 日志落盘 → 解析出指标 → 编码成二维码

终端会话自动化

网络设备登录采集离不开终端软件。以 SecureCRT 为例,它支持命令行打开会话:

// 启动主程序后,依次以"新标签页"方式打开每个预配置的会话
spawnDetached(["/T", "/S", session]);

真正的采集逻辑不在 Node 侧,而在每个会话预先配置好的自动登录脚本里:连接成功后自动执行 display cpu-usagedisplay memory 一类命令,并按会话日志约定落盘。Node 程序只负责编排:

启动终端主程序 → sleep 3s → 逐会话 /T /S 打开(每个间隔 3s)
→ sleep waitSeconds(等登录脚本跑完)→ 杀掉进程

这是刻意的粗粒度:与其用脆弱的方案去解析终端输出,不如把“采集哪些命令”留给终端软件自己的脚本机制——那是它几十年打磨成熟的领域。waitSeconds 暴露成配置,采集命令多的设备就等久一点。

日志定位:约定优于配置

日志目录按设备名分目录,目录内文件名统一为 YYYYMMDD-HHMMSS.log 格式。于是“取每台设备最新日志”就退化成一行代码:

const latest = fs.readdirSync(dir)
  .filter((name) => name.endsWith(".log"))
  .sort()   // 定长时间戳格式,字典序 == 时间序
  .at(-1);

不需要 stat 比较修改时间(文件拷贝、还原都会破坏 mtime),文件名本身就是权威的时间戳。

声明式解析模板:正则 + 聚合

不同厂商、不同型号的设备,display 输出格式天差地别。把解析规则写死在代码里,意味着每接一个新型号就要改代码、重新打包、重新走审批把 exe 拷进内网——不可接受。

所以解析规则被外置成 YAML 模板,与 exe 同目录部署,新增型号只需加一个模板文件:

cpu:
  regex: '(\d+)% in last 5 minutes'
  aggregate: max          # 多个匹配值取最大
  decimals: 0
memory:
  regex: '^\s*(?:\[[^\]]*\]\s*)?Mem:.*\s([\d.]+)%\s*$'
  aggregate: min
  freeRatioToUsage: true  # 日志给的是空闲率,使用率 = 100 - 聚合值
  decimals: 1

每条规则是一个四元组,对应一条小型处理管道:

整份日志全局匹配 → 收集捕获组1(数值) → 聚合(max/min/avg/first/last)
→ 可选空闲率换算(100 - x) → 按小数位格式化

两个字段设计得尤其用心:

  • aggregate:一份日志里同一指标往往出现多次(多核 CPU 各报一个百分比、内存表多行各带占比)。“取哪个”是业务判断——巡检关注最坏情况,所以 CPU 取 max——把它做成配置而不是写死,模板就兼容了“取均值”类设备;
  • freeRatioToUsage:有些系统报的是空闲百分比而不是使用率,不换算的话填进表格就是错的,而且错得非常隐蔽(内存 90% 空闲被填成 90% 占用)。一个布尔字段把这个坑显式化了。

模板加载时做严格校验:正则必须合法、至少含 1 个捕获组、聚合方式必须在白名单里。有个统计捕获组数的小技巧值得一提:

function countCaptureGroups(regex) {
  // 拼一个空分支匹配空串,exec 结果数组长度减一即捕获组数
  return new RegExp(`${regex.source}|`).exec("").length - 1;
}

给正则追加 |(空分支)后,它必然能匹配空串,而 exec 返回数组的长度恒等于“捕获组数 + 1”——不用解析正则源码就拿到了组数。

解析器是纯函数

parseDeviceLog(template, logContent, label) 不碰文件系统、不碰网络,输入模板和字符串,输出两个数字。这带来了一个朴素但珍贵的性质:能拿真实日志直接做单元测试

test("解析真实巡检日志", () => {
  const log = fs.readFileSync("test/fixtures/real.log", "utf8");
  const result = parseDeviceLog(compile(h3cTemplate), log, "测试设备");
  assert.match(result.cpuUsage, /^\d+$/);
});

测试夹具就是脱敏后的真实设备日志。解析逻辑改一行,几十份真实日志立刻重放一遍,这比任何 mock 都有说服力。

增量落盘:每采集一台就保存一次

采集循环里有一个不起眼但救命的细节:每解析完一台设备,立刻把当前已累积的数组写一次 JSON。哪怕第十台设备解析抛异常导致程序退出,前九台的数据还在——重启后不必从头再来。

REST API 直采

虚拟化平台则完全不同:它有现成的管理 API。带着登录后的 JSESSIONID Cookie 和 Token 直接 GET 主机列表接口,一次拿到全部主机的 CPU/内存指标,连日志都不用碰。用原生 node:http/node:https 而非 fetch 的原因是需要精细控制 Cookie 头。

同一个程序里,采集层根据数据源特性分化成两条路径:有 API 用 API,没 API 啃日志——但殊途同归,最终都归一到同一个 payload 结构里。

服务端:零框架 HTTP 与单任务状态机

服务端跑在互联网电脑上,职责是三件事:给手机一个落地页、接收数据、驱动浏览器填表。

HTTP 层用原生 node:http 手写,总共三个路由,引入任何 Web 框架都是杀鸡用牛刀:

方法 路径 职责
GET /push 返回手机落地页(静态 HTML)
POST /api/submit 接收编码后的 payload,启动填表任务
GET /api/status 查询任务状态与最近日志

两个值得说的设计。

单任务状态机

填表任务是天生串行的——就一个浏览器、一张表。于是任务被建模成一个小状态机:

idle → running → done
              ↘ error

POST /api/submitrunning 状态时直接返回 409:“已有填表任务正在执行,请稍后再试”。没有队列、没有锁、没有并发控制库,一个状态字段就消灭了竞态:

if (job.state === "running") {
  sendJson(res, 409, { ok: false, error: "已有填表任务正在执行,请稍后再试" });
  return;
}
job.start(payload);   // 异步执行,立即返回 200

job.start() 内部用立即执行的异步函数跑填表,无论成败都在 finally 里记下结束时间;snapshot() 暴露状态、错误摘要和最近 80 行日志——这正是落地页实时进度的数据源。

顺手的安全细节:请求体读取带 2 MB 硬上限,超限直接断开连接。正常 payload 压缩后只有几百字节,这个上限纯粹是防御异常请求。

落地页:人在回路

手机落地页不是简单的“确认”按钮。它做三件事:

  1. location.hash 解出 payload,渲染数据摘要(几台设备、采集时间),让发送者亲眼核对;
  2. 点击发送后,轮询 /api/status,把服务端填表日志逐行实时显示在手机上;
  3. 完成/失败给出明确终态。

这里体现的是整个方案的安全哲学:人是回路的一部分,而不是被绕过的障碍。数据离内网前经过一次目视确认,进表格前还有一次目视确认,手机屏幕就是审计日志。

浏览器自动化:复用登录态与“名称框”锚点

填表用 Playwright,但有两个非常规决策,它们决定了方案的可用性。

connectOverCDP,而不是 launch

Playwright 的常规用法是 chromium.launch()——拉起一个干净的浏览器实例。但在线表格需要登录,launch 出来的实例每次都是白板,要么每次手动登录,要么把密码交给程序,两条路都不体面。

替代思路:让程序去连接用户日常使用的那个浏览器。Chromium 系浏览器支持远程调试端口,启动时加 --remote-debugging-port=9222 即可;Playwright 对此有一等支持:

const browser = await chromium.connectOverCDP("http://localhost:9222");
const context = browser.contexts()[0];   // 复用现有上下文(含登录态)

配套的是一个“会话”抽象,封装了完整的生命周期:

探测 CDP 端口(TCP connect 探活)
  ├─ 通 → 直接连接
  └─ 不通 → 用固定 userDataDir 拉起浏览器 → 轮询等待端口就绪 → 连接
  • 浏览器指定固定的用户数据目录,首次使用人工登录一次,此后登录态长期保留;
  • 打开表格页面时优先复用已打开的同域标签页,其次复用空白标签页,避免标签页越开越多;
  • 任务结束只调用 browser.close()——对 CDP 连接而言这只是断开连接,浏览器进程原封不动地继续运行,页面、登录态全部保留,下次任务秒连。

端口探活用最朴素的 TCP connect,连上即断:

const socket = net.connect({ host: "localhost", port });
socket.on("connect", () => { socket.destroy(); resolve(true); });
socket.on("error", () => resolve(false));

不发 HTTP 请求、不解析 JSON,比调用 /json/version 之类的接口轻得多,轮询等待时也不给浏览器添负担。

名称框:整个页面里唯一稳定的锚点

自动化操作在线表格,最大的敌人是 DOM。这类产品的单元格区域往往是 Canvas 渲染或虚拟滚动,class 名是构建产物哈希,今天能跑的 selector 明天就失效。

但有一处例外:表格左上角的“名称框”。输入 E17 回车即可跳转到对应单元格——这是从 Excel 时代继承下来的交互契约,每家在线表格都实现它,而且它是一个朴素的 <input type="text">。于是单元格定位完全不依赖表格内部结构:

async fillCell(position, value) {
  const box = this.page.locator('#root input[type="text"]');  // 名称框
  await box.fill(position);       // "E17"
  await box.press("Enter");

  const cell = this.page.locator("#component_container_0");   // 激活的单元格编辑器
  await cell.waitFor({ state: "visible", timeout: 5000 });
  await cell.click();
  await cell.getByRole("textbox").fill(value);
  await cell.getByRole("textbox").press("Enter");
  await this.page.waitForTimeout(500);   // 节流,给服务端喘息
}

跳转到目标单元格 → 编辑器激活 → 填值 → 回车提交 → 等待 500ms。每格固定 500ms 的节流看似笨拙,实则是给在线表格的服务端留出同步窗口——满速狂填很容易触发协同编辑冲突或限流。

容错:单格续跑,全败刹车

几十个格子里总有个别失败:网络抖一下、单元格恰好被别人占着编辑。策略分两层:

  • 单格失败:记日志、继续下一格。失败后往页面空白处 (100, 100) 点一下,关掉可能残留的编辑器或弹出层,避免污染下一次定位——一个 .catch(() => {}) 包着的鼠标点击,换后续所有格子的稳定;
  • 全部失败:任务置为 error不推进起始列。这最后一条刹车至关重要,见下文。

起始列状态与 Excel 列进位

表格约定每天填一列,那么“今天该填哪一列”就是必须跨天持久化的状态。答案朴素到可笑:程序目录下一个 start_column.txt,内容就是一个列名,填完 +1 写回。

配合上面的刹车逻辑看:如果某天浏览器抽风,所有格子都填失败,而起始列照常 +1,那么今天空跑一列,明天会从错误的列开始,把数据填进昨天的空列——错误会静默传播。所以“全部失败时不推进”不是优化,是正确性的一部分。

列名递增本身是个小小的进位算法,Excel 的列是二十六进制但没有零(Z 之后是 AA,AZ 之后是 BA,ZZ 之后是 AAA):

function nextColumn(column) {
  if (column.length === 1) {
    return column === "Z" ? "AA" : String.fromCharCode(column.charCodeAt(0) + 1);
  }
  if (column[1] === "Z") {
    return column[0] === "Z" ? "AAA"
      : String.fromCharCode(column.charCodeAt(0) + 1) + "A";
  }
  return column[0] + String.fromCharCode(column.charCodeAt(1) + 1);
}

这个函数被单元测试覆盖:E→FZ→AAAZ→BAZZ→AAA——恰好是所有进位边界。

打包成单文件 exe:Node.js SEA

最后一块拼图:内网电脑没有 Node.js,也不允许安装。方案必须以单文件 exe 的形态进去。

Node.js 官方的 SEA(Single Executable Application)正好干这个,但直接拿来用会撞上一个相当深的坑。

基本流程

SEA 只能嵌入单个脚本,而项目是多文件 ESM,所以先打包再嵌入:

esbuild(ESM 源码 + 全部依赖 → 单文件 CJS bundle, minify)
  → node --build-sea(blob 生成 + 注入)→ 独立 exe

esbuild 负责消灭模块结构,SEA 负责把 bundle 和 Node 运行时封进一个可执行文件。产物拷进内网,连解压安装都不需要。

Playwright 的动态 require 之坑

坑出在 Playwright 身上。它内部有这样的运行时代码:

require(path.join(packageRoot, "package.json"));
require(path.join(packageRoot, "browsers.json"));

参数是运行时拼出来的表达式,esbuild 的静态分析无能为力,这两个调用原样留在了 bundle 里。而 SEA 环境中的 require 只支持内置模块——跑到这里直接抛 ERR_UNKNOWN_BUILTIN_MODULE

解法分两步,都在构建期完成:

第一步,用 esbuild 插件在 onLoad 阶段改写源码,把所有”require(某个 join 表达式)“形态的调用重定向到自定义函数:

const patched = source.replace(
  /\brequire\((\w+(?:\.\w+)*\.join\([^()]*\))\)/g,
  "__pwDynRequire($1)"
);

第二步,通过 bundle 的 banner 注入这个自定义函数,以及构建时就读好的两份 JSON:

var __PW_PACKAGE_JSON  = /* 构建机上读取的 playwright package.json */;
var __PW_BROWSERS_JSON = /* 构建机上读取的 browsers.json */;

function __pwDynRequire(request) {
  if (typeof request === "string") {
    if (request.endsWith("browsers.json")) return __PW_BROWSERS_JSON;
    if (/[\\/]playwright(-core)?[\\/]package[.]json$/.test(request)) return __PW_PACKAGE_JSON;
  }
  try { return require(request); } catch { return {}; }
}

运行时的动态读取,被偷换成构建期的静态内联——产物真正自包含。这正是 SEA 打包的通用手法:凡是依赖运行时文件系统读取的库,都要想办法在构建期把数据焊死进产物

另外两个细节:

  • chromium-bidi(Playwright 的可选依赖,仅 WebDriver BiDi 协议使用)被标记为 external。本项目只用经典 CDP,触达不了那条代码路径,标记外部后 esbuild 不会尝试打包它;
  • useCodeCache: true 生成的代码缓存与平台绑定,交叉编译不可行——要给 Windows 用,就得在 Windows 上打包。

还有一个纯体验层面的收尾:exe 退出前打印“按回车键退出”并等待输入。双击运行的窗口不会一闪而过,出错时用户来得及看清日志——命令行工具的最后一公里,往往是给看的。

取舍与总结

回看整个方案,它由几个“够用就好”的决策堆叠而成:

决策 放弃了什么 换来了什么
二维码摆渡 双向通信、大文件 零基础设施、天然单向、合规
人在回路确认 全自动无人值守 安全边界 + 可审计
复用日常浏览器(CDP) 干净隔离的实例 登录态免维护、用户无感
名称框定位 精细的 DOM 操作 对页面改版免疫
声明式解析模板 解析能力上限 新设备零代码接入
单任务状态机 吞吐量、并发 无锁、无队列、无竞态
SEA 单文件 exe 启动速度、体积 内网零依赖部署

如果只能带走一个想法,我希望是这条:当网络被切断时,重新审视“通道”的定义。二维码 + 手机 + 一次点击,就是一条带宽几百字节、延迟十秒、自带人工审核的数据链路——对于每天几十条记录的巡检填报,它不比任何隧道差,而且永远不会被防火墙封掉。

至于工程上的启示,则是另一句话:自动化系统里最贵的不是“能跑”,而是“坏了以后知道坏在哪、且不会把错误放大到明天”。单格续跑、全败刹车、起始列不推进、增量落盘——这些容错设计的分量,最终超过了那条能把数字填进表格的主路径。

0%