ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

没有API的老系统?把HTML当接口,自动化数据同步实战

没有API的老系统?把HTML当接口,自动化数据同步实战 干这行十几年我最怕听到的一句话不是系统挂了而是这个系统没有 API。但凡听到这句后面基本都跟着但是我们要做数据统计、要自动同步订单、每天能不能自动出一份报表。前两年我在一家制造企业做内部系统集成就遇到一套上了快十年的 ERP 老系统厂商早就不维护了数据库密码在离职同事的交接文档里是空的唯一能稳定操作的入口就是那个长得像上世纪遗留物的网页界面。当时业务部门提的需求很朴素每天把销售订单的状态同步到新上的 BI 系统里再自动生成一批对账单。没有 API没有数据库直连权限你能怎么办我的答案就是把页面 HTML 当成 API 来用。这不是什么黑科技而是把 Web 系统本来就有的读写能力重新拆解了一遍——GET 请求就是读接口表单提交就是写接口页面返回的 HTML 就是接口返回的 JSON只是格式丑了点。这篇文章把我当时完整的技术方案、踩过的坑以及后来沉淀成一套可维护自动化框架的过程全部写出来适合需要对接老系统、又不想求人给接口的开发和运维同学参考。1. 需求拆解没有 API 的老系统问题到底出在哪1.1 这类系统的典型特征我接触过的无 API 老系统大致有这么几类早期企业自研的 BS 架构系统前后端不分离页面直接用服务端渲染采购的第三方商业软件厂商倒闭或被收购源代码和接口文档一起消失集团内部的遗留系统当年上线时根本没有接口规范全靠页面撑着。这类系统的共同点很明显第一界面稳定但技术栈老旧JSP、ASP.NET WebForm、老 PHP 混着写能跑就不敢动第二数据都在数据库里但你拿不到直连权限安全策略卡得死死的第三页面本身功能完整增删改查都能做只是没有开放给程序调用的入口。我当时面对的那套 ERP 就是这样。报表功能倒是有但只能导出 Excel而且导出还要手动点好几个页面。数据分散在四五个菜单里每天靠人工打开、查询、复制、粘贴一个小时能做完的活干完眼睛都是花的。1.2 为什么不能等官方 API很多人第一反应是去提需求让厂商加 API。理论上没错但现实里这条路基本走不通厂商已经停止维护这个产品提需求根本没人理即便有人理排期三个月起步业务等不起老系统的代码结构混乱动一个接口可能引发一堆连锁问题再现实一点商务上要加钱为一个能用就行的需求付高昂定制费老板这关就过不去。所以当外部接口不可得的时候就得换个思路。系统虽然没有 API但它的页面能够完成所有业务操作。页面是给人用的但技术上页面背后就是一个 Web 服务它接收 HTTP 请求、返回 HTTP 响应。我们能做的就是模拟人的操作把人看页面的过程变成程序读页面的过程。这个思路在行业内其实有专门的说法叫 UI 自动化或者 Web 自动化但我想强调的是另一层理解把页面 HTML 当成 API本质上是在语义层面做了一次接口化重构。页面里的每个表格、每个表单、每个链接都可以被映射成接口的资源和方法。一旦你脑子里完成了这个映射后面写脚本就顺了。1.3 适用边界什么情况下这条路能走通也不是所有老系统都适合用 HTML 当 API。我总结了一下至少需要满足这几个条件页面可以通过 HTTP 直接访问而不是需要特殊客户端核心操作都可以在页面上完成不需要本地控件或插件页面结构相对稳定不会每次刷新都变系统允许程序化登录或者至少可以在内网环境配置访问白名单。如果系统依赖 ActiveX 控件、桌面组件或者特殊证书那纯 HTTP 这条路走不通得考虑 RPA 或者浏览器级别的自动化。我在后面第 5 节会详细讲怎么用 Playwright 处理动态渲染的页面那就是这套思路的升级版方案。2. 核心思路把 HTML 当成 API具体怎么映射2.1 GET 请求就是读接口Restful API 里GET 是查询资源。在老系统的 HTML 世界里这个动作对应的是输入 URL、带上查询参数、发出请求、拿到一个包含数据的页面。比如订单列表页的 URL 可能是这样http://erp.internal/sale/order/list.jsp?pageNo1pageSize50statusconfirmed你完全可以把它理解成GET /api/sale/orders?page1size50statusconfirmed是不是一下子就顺了页面返回的 HTML 里有订单表格每个订单号、客户名称、金额、状态都在tr标签里。从这个角度看HTML 只是把 JSON 变成了带样式的嵌套结构我们需要做的就是用解析器把它重新反序列化成结构化数据。想通这一点后面所有操作都有章可循。2.2 表单提交就是写接口在 Restful 语义里创建和修改资源用的是 POST 和 PUT。老系统里对应的是表单提交用户在页面上填表、点提交按钮浏览器把字段打包成 POST 请求发给服务器。实际操作中我们需要从页面的form标签里读出 action 地址、method、每个 input 的 name 和默认 value然后自己构造同样的请求。这里有个关键点表单里的隐藏字段hidden input往往是老系统用来传递状态的关键比如 session id、时间戳、防重复提交的标记。漏掉任何一个提交都可能失败或者产生脏数据。所以说HTML 当 API 不是拿着页面乱抓而是要像读接口文档一样逐个字段去分析。我第一次做的时候把目标表单的 HTML 打印出来一行一行地读那感觉就像在读一份没有注释的接口文档——但它确实是文档只是没人把它整理成文档而已。2.3 页面里的数据校验与交互逻辑也要模拟用页面当 API还有一个容易被忽略的层面前端的 JS 逻辑。老系统虽然以服务端渲染为主但不少页面还是有基础校验的比如日期格式、必填项、联动下拉框。你直接用脚本请求接口这些 JS 校验不会生效但服务端的校验一定会生效。所以就算页面没有 API服务端代码依然是最终的守门员。这意味着脚本构造的请求必须符合服务端预期的数据格式。我的做法是先用浏览器开发者工具观察一次真实提交的请求记录所有字段和格式再在脚本里复刻一遍。这一步省不掉也千万别省。3. 工具选型与环境准备requests 还是 Playwright3.1 三套主流方案对比要做 HTML 当 API 这件事第一件事是选工具。我试过三套方案各有各的适用场景方案原理优点缺点适用场景requests BeautifulSoup直接构造 HTTP 请求解析静态 HTML轻量、快、资源占用小处理不了 JS 渲染登录态管理要仔细服务端渲染的传统老系统Selenium驱动真实浏览器完全模拟人操作兼容性极强什么页面都能跑重、慢、依赖浏览器环境维护成本高复杂交互、必须真实渲染的页面Playwright协议层控制浏览器支持无头模式比 Selenium 快稳定性好自动等待机制完善相对 requests 还是重学习成本略高动态渲染、SPA 页面、需要截图的场景我的原则很简单能不用浏览器就不用浏览器。先抓包看页面是不是服务端渲染如果是requests 就够了如果数据是 JS 异步加载出来的那再上 Playwright。主次分明效率最高别一上来就套个重型框架。3.2 我最终选定的技术组合回到我那个 ERP 老系统经过抓包分析订单列表页是 JSP 服务端渲染返回的 HTML 里直接有表格数据这部分我用 requests BeautifulSoup。但有一个对账页面是后来某次升级加的前端用了一小段 jQuery 异步加载数据这个页面我单独用 Playwright 处理。所以整套方案的技术组合是Python 3.10 作为主语言requests 处理登录和静态页面的读写BeautifulSoup4 加 lxml 解析 HTMLPlaywright 处理动态页面APScheduler 做定时调度MySQL 存最终落库的数据Logging 加企业微信机器人推送执行结果。为什么用 Python因为它处理 HTML 和 HTTP 的生态最成熟遇到问题搜一搜基本都是答案。requests 和 BeautifulSoup 本身就是这个场景的事实标准新人也容易上手。3.3 环境安装的完整步骤环境准备不复杂但有几个坑提前说。Python 版本别太老3.8 以上requests 和 bs4 直接 pip 装lxml 在某些系统上要编译建议直接装带预编译轮子的版本。# 创建虚拟环境避免污染系统 Python python3 -m venv legacy-automation source legacy-automation/bin/activate # 安装核心依赖 pip install requests beautifulsoup4 lxml playwright # 初始化 Playwright 浏览器 playwright install chromiumPlaywright 装浏览器这一步容易卡住如果网络环境不好可以用镜像源或者在官网下载浏览器包后手动指定路径。这个坑后面第 5 节我再细说。4. 完整实操登录、取数、写回一条链路走到底讲完了思路和工具现在进入最核心的部分怎么把这条链路真正跑通。我会按实际操作的顺序把每一段关键代码和它背后的原理都讲清楚你可以直接照着改。4.1 第一步搞定登录态老系统大多有登录校验。我们要先分析登录过程打开登录页输入用户名密码提交表单服务端返回 Set-Cookie浏览器保存会话。脚本里复刻这个过程核心是 session 对象。import requests from bs4 import BeautifulSoup session requests.Session() # 1. 先访问登录页拿到必要的隐藏字段和初始 Cookie login_page session.get(http://erp.internal/login.jsp) login_page.encoding gbk # 很多老系统用 GBK 编码后面细说 soup BeautifulSoup(login_page.text, lxml) csrf_token soup.find(input, {name: _csrf})[value] # 2. 构造登录表单字段名和页面里的一致 login_data { username: batch_user, password: your_password, _csrf: csrf_token, } # 3. 提交登录 resp session.post(http://erp.internal/login_submit.do, datalogin_data) # 4. 验证登录结果跳转后的页面是否有欢迎语 if 欢迎 in resp.text: print(登录成功) else: print(登录失败检查账号或验证方式)这个流程的关键点有三个一定要复用同一个 session这样才能把登录后的 Cookie 带进后续请求登录页的隐藏字段要完整带上很多老系统登录表单里藏着时间戳或随机数登录后用一个特征词验证是否成功不要盲目往下走。注意登录密码不要硬编码在脚本里。我在实际项目里是放在环境变量中或者读一个权限收紧的配置文件。脚本如果提交到仓库密码泄露就是安全事故这个红线不能碰。4.2 第二步处理每个请求都要带的公共参数登录成功之后并不是说就能直接请求任意页面了。老系统经常在 URL 或请求头里有一些约定俗成的参数。比如我们要请求订单列表页时经过抓包发现每次请求都会带一个menuId用来标记当前是哪个菜单下的操作还有_t是一个时间戳用来防止浏览器缓存。构造请求时我习惯把这些参数写成一个函数统一处理import time def build_params(base_params: dict) - dict: 统一补充系统要求的公共参数 params dict(base_params) params[menuId] order_list params[_t] str(int(time.time() * 1000)) return params请求头也很重要。老系统可能做了简单的 UA 校验和 Referer 校验。UA 至少伪装成一个常见的 ChromeReferer 设置为上一个页面的 URL否则部分系统会拒绝请求或者跳到错误页。session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: http://erp.internal/sale/order/list.jsp, })这种参数和头信息的处理本质上就是模拟浏览器发请求。别看这些细节小少了任何一个可能就会出现登录成功但查询不到数据之类的问题排查起来还特别费劲。4.3 第三步解析 HTML把页面变成数据结构请求到了订单列表页之后我们要从 HTML 里把数据抠出来。BeautifulSoup 的选择器能力足够应付绝大多数场景。先观察目标页面的 HTML 结构老系统的表格通常是table idorderTable classlist_table tr tdSO20240001/td td张三/td td12800.00/td td已确认/td /tr ... /table对应的解析代码from bs4 import BeautifulSoup resp session.get(http://erp.internal/sale/order/list.jsp, paramsbuild_params({ pageNo: 1, pageSize: 50, status: confirmed, })) resp.encoding gbk soup BeautifulSoup(resp.text, lxml) rows [] table soup.find(table, {id: orderTable}) for tr in table.find_all(tr): cells [td.get_text(stripTrue) for td in tr.find_all(td)] if len(cells) 4: rows.append({ order_no: cells[0], customer: cells[1], amount: cells[2], status: cells[3], })这段代码看起来简单但里面有三个实际项目里总结出来的细节。第一get_text(stripTrue)可以去掉单元格里的空白和换行避免拿到带空格的数据。第二有些单元格里还会嵌套span、a标签比如订单号可能是个链接直接取td的文本还是能拿到但如果要拿链接里的参数就得额外写逻辑。第三不要用正则去解析 HTML正则处理不了复杂的嵌套结构BeautifulSoup 这种 DOM 解析器才是正确的工具很多新手在这上面栽过跟头。4.4 第四步分页与列表页的完整抓取老系统的列表几乎都有分页。分页可能有三种形态URL 参数分页、表单 POST 分页、JS 点击分页。前两种可以直接构造请求模拟第三种只能上浏览器自动化。URL 参数分页最简单改pageNo就行。表单 POST 分页稍微麻烦一点需要先用 GET 打开列表页解析出表单里的隐藏字段比如totalPages、pageToken再带着这些字段 POST 下一页。这里贴一个通用循环抓取框架import random import time def fetch_all_orders(): orders [] page 1 while True: soup fetch_order_page(page) page_orders parse_order_table(soup) if not page_orders: break orders.extend(page_orders) if not has_next_page(soup): break page 1 # 控制请求频率避免给老系统造成压力 time.sleep(random.uniform(0.5, 1.2)) return orders这里加随机延迟是有讲究的。老系统的数据库和服务器承受能力有限如果脚本以毫秒级速度连续请求很可能把系统的连接池打爆影响正常用户。加一点随机延迟既保证效率又不会把自己变成系统事故的源头。我当时就被管理员找过一次这个话题后面再展开。4.5 第五步写回操作自动更新订单状态读数据只是第一步实际需求里往往还要写回。我们的业务场景中有一个需求是每天把超过 30 天未确认的订单自动做暂停处理。在页面上这个操作对应的是勾选订单、选择暂停、点击提交。拆解之后这个操作其实就是一个表单提交def pause_order(order_no: str): 对指定订单执行暂停操作 # 先进入订单详情页找到操作表单 detail session.get(http://erp.internal/sale/order/detail.jsp, paramsbuild_params({orderNo: order_no})) detail.encoding gbk soup BeautifulSoup(detail.text, lxml) form soup.find(form, {id: operationForm}) form_action form[action] fields {inp[name]: inp.get(value, ) for inp in form.find_all(input)} fields[operation] pause fields[confirm] 1 result session.post(fhttp://erp.internal{form_action}, datafields) return 操作成功 in result.text这个例子里最重要的动作是从表单里读取action和所有字段再用程序填充修改。这套逻辑等价于你在页面上手动操作但因为直接构造 HTTP 请求速度快得多而且可以做循环、条件判断。30 个订单要暂停人工要操作 30 次脚本几秒钟就完成了。4.6 数据落库与结果校验数据抓下来之后当然要落库。但落库之前一定先做校验数量是否在合理范围昨天还有 1000 个订单今天突然只有 3 个大概率是出了问题金额字段是否正常能不能转换成数值类型有没有-----这种异常值主键是否冲突增量同步时用订单号做去重重复的就跳过或更新。我习惯于把原始抓取数据先落到一个raw_data表再做转换写入正式表。这样一旦哪个环节出错原始数据还在可以重放处理逻辑不用重新抓一遍页面。这个习惯帮我省了很多事强烈建议保留。5. 动态页面专项Playwright 怎么补位5.1 什么时候必须换工具前文说过requests 方案的前提是页面里直接渲染出数据。但如果有这么几种情况requests 就无能为力了页面数据是通过 AJAX 或 fetch 异步加载的初始 HTML 里根本没有页面内容依赖 JS 计算或渲染比如某些 ECharts 报表操作流程需要连续的前端交互比如先选择客户再联动加载订单列表登录过程有滑块验证或者其他需要真实浏览器环境的机制。我遇到的对账页面就是典型打开页面时只有一个空的表格框架数据是页面加载完成后用 jQuery 的$.getJSON去后台拉的返回的确实是 JSON但 URL 里带了一个由前端 JS 动态生成的签名参数。这种情况下与其花大力气逆向前端的签名算法不如直接让浏览器帮我们把 JS 跑完我们只等结果出来。5.2 一个用 Playwright 读取动态报表的完整示例Playwright 比 Selenium 好的地方在于自动等待做得非常聪明你不用写一堆time.sleep而且它对浏览器的控制能力更强速度也更快。from playwright.sync_api import sync_playwright def fetch_reconciliation_data(): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() # 1. 登录也可以复用已有的 cookie page.goto(http://erp.internal/login.jsp) page.fill(input[nameusername], batch_user) page.fill(input[namepassword], your_password) page.click(button[typesubmit]) page.wait_for_load_state(networkidle) # 2. 进入对账报表页面 page.goto(http://erp.internal/report/recon.jsp) page.wait_for_selector(table#reconTable tbody tr) # 3. 等数据加载完之后直接读取表格内容 rows page.locator(table#reconTable tbody tr).all_text_contents() parsed [] for row_text in rows: cells [c.strip() for c in row_text.split(\t)] parsed.append(cells) browser.close() return parsed这个脚本的核心是wait_for_selector它会自动等待指定的表格出现不用手动 sleep。networkidle状态确保页面所有异步请求都结束了。这两个机制就是 Playwright 稳定性的根基实际跑起来比 Selenium 稳太多。5.3 无头模式、运行环境与 Docker 部署Playwright 在服务器上跑有两个实际问题一是依赖浏览器二是需要系统库。如果你是在干净的服务器上部署直接装可能还会缺动态库最省事的方案是直接用 Playwright 官方提供的 Docker 镜像。我把整套自动化脚本打包到了 Docker 里Dockerfile 大体是这样FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD [python, main.py]这样调度起来很方便Jenkins 里配一个定时任务每天凌晨触发容器执行。这里顺便提一句工程化思路老系统的页面自动化脚本本身也是一个项目也需要版本控制、自动化部署、日志监控别把它当一次性脚本用完就丢。后面维护时你会感谢当初的自己。6. 常见问题与排查技巧实录这部分是我最想写的。网上讲怎么抓页面的教程很多但讲抓的过程中会遇到什么坑的少。我把踩过的坑整理成一张速查表建议收藏现象可能原因排查思路解决办法登录后请求列表页跳回登录页会话 Cookie 丢失或过期检查请求是否携带正确的 Cookie复用 session增加会话保活心跳页面提示参数错误缺少隐藏字段或 token对比真实浏览器请求参数解析 form 所有 input补齐字段抓回来全是乱码编码识别错误看响应头 Content-Type手动指定resp.encoding gbk或 utf-8数据量大时被限制访问频率过高触发保护观察 HTTP 状态码加随机延迟、分批处理页面有验证码系统安全策略判断频次与场景内网白名单或对接验证码识别JS 渲染内容抓不到纯 HTTP 拿不到动态数据用浏览器抓包看加载方式换 Playwright6.1 登录态失效的坑我遇到过最诡异的问题脚本在凌晨跑得好好的跑到一半突然所有请求都跳回登录页。查了半天才发现老系统的会话有个活跃时间窗口机制超过一定时间没操作就得重新登录。但我这个脚本一直在操作啊后来看系统配置才知道它的会话有效期不是滑动续期而是固定 30 分钟——不管你中途有没有活动到点就断。解决方案是在脚本里加一个会话保活逻辑每 20 分钟用 session 访问一次首页维持会话在线同时在请求失败检测到跳回登录页时自动重新登录一遍再重试。def ensure_login(): 检查当前会话是否有效失效则重新登录 probe session.get(http://erp.internal/index.jsp) if 欢迎 not in probe.text: login() return session这个函数每次执行任务前调用一次成本极低但能避免跑到一半挂掉这种尴尬。老系统的会话机制五花八门上线前一定先搞清楚它是固定过期还是滑动过期。6.2 页面结构一改就挂这是 HTML 当 API 方案最大的脆弱点页面结构变了脚本就废了。老系统虽然没有 API但它还是会升级、会改版。对策有三个解析逻辑尽量用稳定的锚点比如table的id、name而不是依赖第几行第几列的位置数据解析和业务逻辑分离页面结构变了只改解析层加结构变更检测比如验证解析结果是否为空为空就发告警而不是默默生成一份空报表。我实际的做法是在脚本里加了一个表格列头预期值校验。如果页面的列头不再是订单号、客户、金额、状态就立即推送告警并暂停任务等人工确认是改版还是故障。这套机制上线之后系统改版过一次告警提前发现没有造成数据缺失。6.3 验证码和频率限制老系统加验证码的情况不多但也不是没有。如果登录页有验证码优先争取从网络层解决既然是内网系统看看能否申请 IP 白名单或者让管理员关掉针对服务账号的验证码策略。前端自动化识别验证码是最后手段而且成本不低。频率限制这个坑我吃过大亏。刚开始我的脚本是毫秒级请求结果第二天系统管理员来找我说生产库连接数飙升业务同事反馈系统变卡。从那以后所有脚本都严格控制请求频率并且只在工作时间之后跑批任务。记住一句话你是来帮系统减负的不是来制造事故的请求频率一定控制住。6.4 编写脚本时的工程化思考最后提醒一点把 HTML 当 API不等于放任脚本变成一坨临时代码。我的工程化经验是所有的选择器、URL、字段名都放到配置里不散落在代码里每次执行记录日志包括请求耗时、抓取数量、错误信息执行结果推送到企业微信群或邮件出问题第一时间知道脚本纳入 Git 管理改版留痕用 Jenkins 定时执行执行记录可以在平台里看到。我见过很多同事写的页面自动化脚本运行半年之后没人敢动就是因为没有配置化、没有日志、没有测试。你把它当 API 去设计而不是当一次性脚本去写后续维护的幸福感会高很多。这个意识比任何具体技术都重要。这套把页面 HTML 当 API的方案在我手上跑了一年多每天稳定同步上千条订单数据替业务部门省下了至少两个人力的重复劳动。过程中最大的体会是很多老系统的问题不是技术做不到而是思路被必须有 API 才能自动化这个框框限制住了。页面本来就是系统给外界的一扇窗把这扇窗用好了它和 API 没有什么本质区别——都是输入输出、都是请求响应。最后再分享一个小技巧做这种页面自动化前期花十分钟用浏览器开发者工具把关键请求完整看一遍比写代码快得多。别急着写脚本先看清楚每一个参数从哪来、到哪里去后面就能少踩一半的坑。如果你手上也有这种让人头疼的老系统不妨试试这个思路先把页面 HTML 打印出来把它当接口文档读一遍你会发现自动化其实没那么远。
返回列表