网站逆向重构 · 复刻优化网页页面
把一个已上线的 Web 系统逆向出真实契约,重建一套能连真后端、并按需求改过交互的前端。
与已有技能的边界
| 技能 | 目的 | 产物 |
|---|---|---|
website-offline-mirror / any-site-to-offline |
把网站抓下来存本地 | 静态副本,断网可看,源码原样 |
| 本技能 | 把系统逆向出来重做 | 活的前端,连真后端,可改交互 |
判据:用户想「离线看」→ 用 mirror;想「自己维护、能提交数据、能改好用」→ 用本技能。
铁律(违反其中任何一条都会浪费数小时)
1. 不测完不交付,每个结论必须能追溯到出处
「接口返回 200」不等于「契约摸对了」。必须问:返回结构是数组还是 {list,total}?参数走 query 还是 body?哪些字段真必填?
2. 后端报错不给原因时,做单变量对照实验,不要猜
code:202 + info:null 是最恶心的错误形态——它什么都告诉你。不要逐个猜字段,要用 ab_probe.js 固定其他条件、一次只改一个变量,看哪个改动让它从失败变成功。
> 实战:某个 add 接口的 202 查了 3 小时(先怀疑加密、再怀疑权限、又卡在 taskId)。做完对照实验 10 分钟定位 —— 根因是漏传 auditing: 0,导致数据被后端默认判成「已审核」,且已审核记录不可删除,产生了死数据。
3. 抓不到的地方,用 Playwright 抓真实流量
穷举 header / 参数组合猜不出来时,直接让浏览器自己发一次给你看。请求头、Cookie、body 格式一次全拿到。
const { chromium } = require('playwright-core');
const browser = await chromium.launch({
executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', // 免下载浏览器
headless: true,
});
page.on('request', r => console.log(r.method(), decodeURIComponent(r.url())));
page.on('response', async r => console.log(await r.text()));
4. 登录接口有失败次数限制,测试前先算次数预算
反复试密码会锁账户(实测 5 次锁定 5 分钟,且计数持久化不按会话重置)。
- 优先用免登录接口验证大部分逻辑,只在最后用真实登录验一次
- 一次登录把要验的全验完,不要每修一个 bug 就重登一次
- 拿不准密码时,反解后端存储的密文拿权威值(见下方坑 1)
5. 逆向要区分「契约」和「缺陷」
业务契约(接口/字段/加密算法)必须一模一样;前端的校验 bug、UI 缺陷要修正,不要照抄。
> 实战:原站登录正则漏了 . 且缺 $,导致 <姓名拼音>@<公司域名> 这类带点邮箱在原站都登不进。照抄等于把 bug 复制一份。
6. 写接口永远不要盲信 200,必须回读确认落库
code === 200 只说明「请求被接受」,不等于「数据写对了、写得进、写得进你能查到的那张表」。
三个高频陷阱(详见坑 9):
- userId 为 null → 后端照收不误写无归属记录 → 原站按 userId 查永远查不到
- 接口是 upsert → 重复提交不新增而是累加 → 用户以为写了新记录,其实并进了旧的
- 读副本延迟 → 回读校验必须轮询,只查一次会把成功提交误报成失败
// ❌ 前端只信 200 → 假成功
if (res.code === 200) { showSuccess(); }
// ✅ 回读确认才报成功
if (res.code === 200) {
await reload()
if (await waitConfirmed(date, payload)) { clearDraft(); showSuccess() }
else { keepDraft(); showError('后端未确认写入,草稿已保留') }
}
加防御性校验前,先想清楚它会不会误报——我自己第一版只查一次回读,
结果把所有成功提交都判成失败,比原 bug 更糟。
7. 前端状态恢复是独立环节,登录时有的不代表刷新后还在
Pinia/Vuex 里凡是「登录时拉一次」的数据(用户信息、权限、字典),
必须在 App 启动时也拉一次。否则「登录后用着好好的,刷新一下就提交失败」这类玄学问题。
自检:搜一遍 store 的 actions,找出只在 login() 里被调用的方法。
工作流
Step 1 · 批量解包,拿接口清单
node scripts/unpack_spa.js http://目标站点:端口 ./recon
产出 recon/index.html、recon/appConfigs.js(运行时配置,常含 API 基址)、recon/js/*.js(全部懒加载 chunk)。
现代前端主包通常只有几十 KB,所有页面和接口常量都在懒加载 chunk 里,不解包等于什么都看不到。
手工检索入口:
grep -oE '"/api/[^"]+"' recon/js/*.js | sort -u # 全部接口
grep -oE 'const \w+=\{login:"[^"]+"' recon/js/*.js # API 常量表(含 method 线索)
grep -o 'import("./[^"]+\.js")' recon/js/index-*.js # 懒加载入口
Step 2 · 探明认证方式
从常量表里找到登录接口,逆出加密逻辑(密钥常是硬编码常量,藏在登录页 chunk)。
node scripts/login_diag.js --key <密钥> --cipher <后端存储的密文> [--pwd "你手打的密码"]
四层诊断:反解权威密码 → 逐字节对比你手打的 → 跨库加密自检 →(可选)远程验证。默认不联网。
Step 3 · 探测接口契约
node scripts/probe_contract.js --base http://站点 --auth <token> \
[--cookie "TMS_SESSION_ID=xxx; rememberMe=yyy"] [--only "/api/a,/api/b"]
输出每个接口的:HTTP 状态 / 错误语义 / 返回结构 / 是否真需要鉴权(自动做有无 token 对照)。
Step 4 · 重建前端 + 改造交互
按探测结果写 API 层,然后重写页面。改造前先问清楚:用户当前最痛的是什么。
Step 5 · 端到端验证(必须真跑一次)
用 Playwright 登录真实站点,走完关键路径,确认数据真的落库。
高频坑速查
坑 1 · 密码手打错,且肉眼看不出
两个字符串可能只差一个字母顺序:
错误:<拼音A>-<顺序写错> → 6a 69 6e 71 75 69 75 40 31 32 33 (11 字节)
正确:<拼音B> → 6a 69 6e 71 69 75 40 31 32 33 (10 字节)
肉眼完全一样,但登录就是失败。解法:拿后端 user/getById 返回的 password 字段(存储密文),用同一密钥反解出权威明文,别靠肉眼比。
坑 2 · 加密编码:Hex 还是 Base64
// crypto-js:.ciphertext 是 WordArray,toString() 未指定 formatter 时默认输出 Hex
CryptoJS.AES.encrypt(k, key, {mode:ECB, padding:Pkcs7}).ciphertext.toString() // Hex
// 误用 Base64 发送 → 后端解密得到乱码 → 报「密码错误」
⚠️ user/getById 返回的 password 字段是后端二次加密后的存储值,不等于前端加密结果,别拿它当基准。
坑 3 · 后端靠会话(Shiro),不靠 token 头
实测特征:登录响应下发 TMS_SESSION_ID + rememberMe 两个 Cookie;
project/list 只发 Authorization 头一律返回 code:500 请登录,
但 dailyReport/list 却正常——因为后者免登录就能读,不能拿它当鉴权通过的证据。
浏览器同源请求自动带 Cookie,前端代码不需处理;但 Postman / curl / Node fetch 必须自己维护 cookie jar 并回传,否则会误判成"鉴权方式不对"。
坑 4 · info 返回结构不统一(页面空白的元凶)
info 是数组 |
info 是 {list,total} |
|---|---|
department/list、projectTask/listByProjectId |
dailyReport/list、project/list、user/list |
用错的表现是:接口 200 有数据,但页面一片空白。因为 res.info?.list 取到 undefined。
在 API 层统一归一化,页面永远拿 res.info.list:
async function listRequest(config) {
const res = await request(config);
if (res && res.code === 200) {
res.info = Array.isArray(res.info)
? { list: res.info, total: res.info.length }
: res.info;
}
return res;
}
坑 5 · 字段语义反转,漏传就出事
auditing(0=待审核 / 1=已审核)漏传 → 后端按 null 处理 → 新提交直接变已审核 →
而已审核记录不可修改删除 → 产生既不能审也不能删的死数据。
抄字段名容易,抄字段的赋值才关键。去原站代码里看它提交时实际带了什么。
坑 6 · Git Bash 会把 /api/xxx 转成 Windows 路径
$ node probe.js --only "/api/user/list"
→ 实际收到 D:/workbudy/WorkBuddy/resources/vendor/PortableGit/api/user/list
脚本里已内置 unMsysPath() 还原(从最后一个 /api/ 截断)。自己写脚本时注意。
坑 7 · 前端内部状态结构 ≠ 接口接收结构
原站前端内部用 { workDate, projects: [...] } 嵌套,但后端 add 收的是扁平数组:
// ✅ 接口要的
[{ workDate, userId, auditing:0, projectId, taskId, workDuration, workOvertime, workMatters }]
// ❌ 前端内部用的
[{ workDate, projects: [{...}] }] → 202
别照抄前端的数据结构当成接口契约。用 ab_probe.js --shapes array,object,flatten 试。
坑 8 · 外键字段必须真实存在
taskId: ''(空字符串)→ 后端 202;taskId: 234(真实 id)→ 200。
前端没让用户选时,要自动取一个默认值,否则整批提交静默失败。
🔴 坑 9 · 「接口返回 200」不等于「业务成功」—— 假成功三连坑
> 来源:<甲方>工时系统,用户报「弹了提交成功,原站却查不到」,排查出 5 个叠加原因。
这是复刻写接口时最高频、也最难自证的一类 bug。前端永远不能盲信 200。
(a) 前端 store 的状态恢复是独立环节
user.js 之类的 store 里,fetchUserInfo() 常常只在 login() 里被调用。
于是:走登录页登录 → 一切正常;刷新页面 / 直接访问路由 / 从书签进入 → userId 是 null。
后果链条最致命:userId: null 提交 → 后端照收不误写入无归属记录 → 接口返回 200 →
前端弹「提交成功」→ 原站按 userId=X 查询永远查不到。
// ✅ App.vue 启动时恢复(不能只在 login 里做)
onMounted(async () => {
const token = tokenStore.get()
if (!token || userStore.userInfos.id) return
const me = await userStore.fetchUserInfo(token, true)
if (!me) tokenStore.clear() // 会话失效
})
// ✅ 提交前再拦一道:拿不到 userId 宁可不提交
if (!userStore.userInfos.id) return { ok: false, reason: '未获取到登录用户' }
顺带检查 store 里的缓存判断有没有写错维度,典型错误:
String(cached.id) === String(token) —— 拿用户 id 和登录 token 比,两个不同维度的东西。
(b) 写接口可能是 upsert,重复提交不新增而是累加
实测:user + date + 外键A + 外键B 已存在时,后端不插入新行,
而是累加数值字段、拼接文本字段("A"/1h → "A,A"/2h),仍返回 200。
| 提交第几次 | 返回 | 库里实际 |
|---|---|---|
| 第 1 次(1h, "A") | 200 |
id=X, 1h, "A" |
| 第 2 次(1h, "A") | 200 |
仍是 id=X,2h, "A,A" |
用户以为新增了一条 → 原站看还是一条 → 「没提交成功」。
修复:重复提交前弹确认框,明说这是累加更新。
(c) 读副本延迟:回读校验必须轮询,不能只查一次
抓包时间线(实测):add 返回 200 → 紧接着第 1 次 list 返回提交前的旧数据 →
第 2 次才拿到新记录(total: 6 → 7)。
// ✅ 轮询确认:只有回读到记录才清草稿、才弹成功
async function waitConfirmed(date, payload, retries = 4, gap = 1200) {
for (let i = 0; i <= retries; i++) {
if (confirmed(date, payload)) return true
if (i === retries) break
await sleep(gap)
await reload()
}
return confirmed(date, payload)
}
> ⚠️ 自己踩过的坑:第一版只查一次就下结论,结果加上校验后所有提交都误报「后端未确认写入」,
> 比原 bug 更严重。加防御性校验前,先想清楚它会不会误报。
(d) 查询参数不一定稳定生效
同一 token 下,有时 userId 过滤生效(返回我的 7 条),有时被忽略返回全量 51 条(会话刚建立时)。
不自己按 record.userId 再兜一层,就会把同事的记录混进「我的列表」,校验结论完全错乱。
(e) 原站与复刻站的查询口径可能不同
抓原站真实流量发现,原站用单日查询 { workDate, userId },
复刻站用月查询 { yearMonth, userId }。两者应一致,但竞态时会错位成
「原站看得到、复刻站回显不出来」。提交后按两种口径都查一次补漏。
🔴 坑 10 · 自写的「限流 / 并发控制」可能根本没生效
// ❌ 看似限了并发,实际每轮都发全部请求
for (let i = 0; i < targets.length; i += CONCURRENCY) {
await Promise.all(targets.map(...)) // 遍历的是整个 targets,不是当前批次
}
// ✅
const batch = targets.slice(i, i + CONCURRENCY)
await Promise.all(batch.map(...))
症状极其隐蔽:浏览器对同域并发有上限,超出的请求被排队长期不返回
(实测某接口响应体为 null 且挂起),表现是「偶发点了没反应 / 按钮一直转圈」,
极难归因——因为后端单测该接口完全正常(999ms 返回)。
顺带:凡是用 Promise.all 依赖「一定会返回」的接口,都加超时兜底:
const tasks = await Promise.race([
taskApi(pid),
new Promise((r) => setTimeout(() => r(null), 12000)),
])
if (tasks === null) return [] // 超时:不写缓存,让下次还能重试
💡 方法 · 用「定义了却没人调用」挖功能缺口
复刻完不是终点。主动 grep 一下接口定义文件里哪些方法从没被页面调用过,
这些就是「后端有能力、但前端没做」的功能缺口,成本极低:
# 列出 api 定义的所有方法名,逐个查是否在 views/ 里出现过
grep -oP "^\s+\w+:\s*\(" src/api/index.js | tr -d ' :(' \
| while read m; do
n=$(grep -rl "$m" src/views/ 2>/dev/null | wc -l)
[ "$n" -eq 0 ] && echo "⚠️ 定义了但没人用: $m"
done
实战:某工时系统的 dailyReport/update 定义了但 0 处调用,
用户反馈「填错了只能删掉重填」——顺手实测确认接口可用(改 aud0 生效、aud1 拒绝),
就补出了「已提交记录可编辑」这个高价值功能。
关键动作是先实测再实现:接口定义存在 ≠ 接口可用 ≠ 权限允许。
实测只需提交一次 + 回查确认,成本几分钟,但能避免做完才发现被拒。
💡 方法 · 「提示太晚」是独立的一类缺陷
很多 UX 问题不是「没提示」,而是提示的时机错了——用户已经白填了一遍才被告知。
典型:某写接口对重复提交是 upsert(累加而非新增)。
如果在「提交时」才弹警告,用户已经把内容填完了,此时只能取消重来。
正确做法是把警告前移到动作发生的时刻:
// ❌ 只在提交时提示 —— 太晚
async function submitOne(date) { /* 这里才检查重复 */ }
// ✅ 复制来的那一刻就预判并提示
async function copyPrevDay(date) {
const clash = src.filter((r) =>
already.some((a) => String(a.projectId) === String(r.projectId)
&& String(a.taskId) === String(r.taskId)))
if (clash.length) { /* 逐条列出冲突项:项目 X 3h(已提交那条是 2h) */ }
}
自查方法:把每个 ElMessage 调用点问一遍「这个提示应该在更早的动作里出现吗?」
💡 方法 · 页面数字要能和后端汇总接口对账
前后端各算一遍合计时,很容易出现「页面上是 109h,原站只有 105h」,
用户会怀疑数据丢了,而且很难自证清白。
做法:让页面的口径显式拆开,和后端接口逐项对齐。
// 后端 getWorkTimeNum 只统计已提交
const monthSubmitted = computed(() => round2(days.reduce((s, d) => s + d.subTotal, 0)))
const monthDraft = computed(() => round2(days.reduce((s, d) => s + d.draftTotal, 0)))
// 分别显示,而不是只给一个「合计」
同时定期对账:本地求和 vs 后端汇总接口返回值,差值必须是 0。
🔴 坑 11 · 用户说「太素、看不清」时,先查结构而不是先加深颜色
> 来源:工时系统反馈「页面太素了,不容易看清,能不能对比更强烈,但不要太花哨」。
我第一反应是加深颜色和加重边框。做完后才意识到真正的问题不是颜色淡,是缺结构引导——
表格有 6 列内容却没有列头,用户根本不知道哪列是工时、哪列是加班。
「看不清」往往是「不知道该看哪」的另一说法。
优先级排序(工具型界面,从上到下):
| 优先级 | 手段 | 说明 |
|---|---|---|
| 1 | 加列头 / 结构分组 | 表格没列头 = 用户不知道每列是什么,这是根因 |
| 2 | 字重分级 | 标题 700 / 关键数字 700 / 正文 400,靠字重分层最稳且零噪音 |
| 3 | 关键信息色块化 | 「合计 8h」给底色、状态给实心标签,不靠眼睛在白底上找 |
| 4 | 边框加重 | 淡边框在投影屏和笔记本上几乎看不见 |
别做的事(用户说了"这是工具不是展示页"):
大面积渐变、阴影花边、动画、彩色背景、装饰性图标。
<!-- ❌ Element Plus 的 plain 标签几乎是白的,等于没有状态 -->
<el-tag effect="plain">待审核</el-tag>
<!-- ✅ -->
<el-tag effect="dark">待审核</el-tag>
🎯 把「够不够清楚」变成可测量的
「我觉得清楚了」是主观的,对比度是算得出来的。用 Playwright 在浏览器里实测 WCAG 比值,
比反复凭感觉调 CSS 快得多,而且能防止下次改版又淡回去:
// 注入页面直接跑,采样关键文本
const lum = (c) => {
const [r, g, b] = c.match(/\d+/g).map(Number).map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const ratio = (fg, bg) => {
const l1 = lum(fg), l2 = lum(bg)
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
}
// 背景要往上找,直到找到不透明的
let p = el
while (p) {
const b = getComputedStyle(p).backgroundColor
if (b && b !== 'rgba(0, 0, 0, 0)' && b !== 'transparent') { bg = b; break }
p = p.parentElement
}
验收线:正文 ≥ 4.5:1(AA),大字号/次要文字 ≥ 3:1。
实测把 EP 默认的 --el-text-color-regular: #606266、--el-border-color: #dcdfe6
压深到 #374151 / #c9ced6 后,9 处关键文本从 3.x 拉到 4.6~17.7,全数达标。
改完一定要跑一遍原有 E2E——视觉改版容易把选择器可见性搞坏
(例:列表变长后 waitForSelector 等到的是隐藏项,加 :visible 才稳)。
脚本
| 脚本 | 用途 | 联网 |
|---|---|---|
scripts/unpack_spa.js |
批量下载打包产物 + 提取接口清单 | 是 |
scripts/login_diag.js |
登录失败四层诊断(默认不联网) | 默认否 |
scripts/probe_contract.js |
探测接口 method/结构/鉴权要求 | 是 |
scripts/ab_probe.js |
单变量对照实验 | 是 |
ab_probe.js 用法
# 字段开关实验:找出哪个字段一加就成功
node ab_probe.js --base http://x --path /api/xxx --auth 103 \
--template '{"workDate":"2026-10-07","userId":103,"projectId":56}' \
--toggle '{"auditing":[0,1],"taskId":[234,270]}'
# 结构实验:接口到底收数组还是对象
node ab_probe.js --base http://x --path /api/xxx --auth 103 \
--template '{...}' --shapes array,object,flatten
交付检查清单
- [ ] 每个接口的 method / 参数位置(query vs body)/ 返回结构都实测过,不是照抄前端代码
- [ ] 登录能跑通,且没把账户锁了
- [ ] 写接口验证过数据真的落库(再查一次 list 确认),且轮询确认过(不是只查一次)
- [ ] 返回结构差异已归一化,不会出现「接口通但页面空」
- [ ] 布尔/状态字段的语义已确认(0/1 哪个是哪个),且提交时显式传值
- [ ] 自动化测试跑得通(不只是
npm run build过) - [ ] 刷新页面后功能仍正常(store 状态恢复、userId 等关键字段非空)
- [ ] 在原站交叉验证过:复刻站提交的记录,原站能查到;反之亦然
- [ ] 查询接口返回的列表自己再按归属过滤一遍,不假设后端的过滤参数稳定生效
- [ ] 所有
Promise.all依赖的接口都有超时兜底,并发控制验证过真的生效(不是写完就信) - [ ] 表格类界面有列头,关键数字有权重或色块,能一眼定位
- [ ] 关键文本实测过 WCAG 对比度 ≥ 4.5:1(不是凭感觉),工具型界面不堆装饰
- [ ] 视觉改版后原有 E2E 全部重跑(选择器可见性最容易受影响)
- [ ] grep 一遍接口定义:没有「定义了却没人调用」的方法(= 未开发的功能缺口)
- [ ] 每个警告提示的时机正确(在动作发生时就提示,不是事后)
-
[ ] 页面合计数字能与后端汇总接口对账,口径已显式拆开
-
[ ] 改造点是真解决了用户痛点,不是换皮
- [ ] 测试写入生产的数据已清理,且清理不了的已列明(谁、哪条、为什么)
- [ ] 踩过的坑写进交付文档(尤其是不给原因的报错)
反模式
| 别做 | 改做 |
|---|---|
| 照抄原站前端的校验 bug | 契约照抄,缺陷修正 |
| 把免登录接口通当成鉴权 OK | 用真正需要鉴权的接口验证 |
| 报错就猜字段 | 单变量对照实验 |
| 反复试密码 | 反解存储密文拿权威值 |
| 猜不出就手动穷举 header | Playwright 抓真实流量 |
npm run build 通过就交付 |
端到端跑一次,确认落库 |
| 在生产库写测试数据不清理 | 清理,或列明需管理员处理 |
code === 200 就弹「提交成功」 |
回读确认落库才报成功;轮询以避开读副本延迟 |
| 「登录时拉到用户信息」就以为刷新后还在 | App 启动时也拉一次;提交前校验 userId 非空 |
| 自写的并发/限流写完就信 | 验证真的生效;给 Promise.all 加超时兜底 |
| 在外部猜组件内部状态 | 挂临时调试钩子直接读 ref,验完即删 |
| 用户说「看不清」就一味加深颜色 | 先查结构(有没有列头/分组),再调对比度 |
| 「我觉得够清楚了」 | 用 Playwright 算 WCAG 对比度,≥4.5:1 才算过 |
el-tag effect="plain" 当状态用 |
plain 几乎是白的,用 dark 或自定义色块 |
参考资料
references/实战案例-工时填报系统.md—— 完整实战:契约明细、踩坑时间线、
以及「弹了提交成功原站却查不到」的 5 原因叠加排查全过程references/排查决策树.md—— 遇到症状按图索骥