半年前,我们内部有一个很朴素的想法:既然 Turnitin 官方 Similarity Report API 只对签约机构开放,那就用 Playwright 写个无头浏览器脚本,登录、提交、下载报告,做成一个内部工具。
那时候团队觉得这个东西大概一周能搞定。事实证明,它花了我们整整三个月,最后还不得不推倒重写。本文复盘我们在这期间踩过的三类大坑。
阶段一:朴素爬虫(一周写完,一周挂掉)
最早的实现非常”直男”:
await page.goto('https://www.turnitin.com/login');
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
第一次运行时它工作得很好。上线后的第三天,所有请求开始返回一个奇怪的 Cloudflare Challenge 页。我们加了常见的 stealth 插件,熬了两天把登录路径走通了。然后第五天,登录之后的每个页面都开始需要重新过 JS challenge。
结论:光是伪装成人类还不够——我们需要真实的浏览器指纹。
阶段二:真实指纹 + 代理池(两个月的复杂度爆炸)
我们切到了 Playwright 的 persistent context,给每个账号一个独立的 user_data_dir。这意味着:
- Chrome 会记住 localStorage、IndexedDB、Cookie、插件状态
- Canvas/WebGL 指纹在同一 profile 里是一致的
- 登录一次之后的 session 能保持好几天
这部分效果很好。但同时带来了新问题:
问题 1:Profile 锁
Chrome 同一个 user_data_dir 只能一个进程打开。我们原本设计的并发 pool 一下子退化成了串行,吞吐量掉到了 1/N。
修复:把 profile 目录做成 租约制 —— 每个 job 启动前从 pool 里”借”一个 profile,执行完归还。如果 crash 了 TTL 过期自动释放。
问题 2:代理粘性
每个 profile 必须绑定一个稳定出口 IP。如果这次从美国东岸登录、下次从新加坡出口请求,Turnitin 的风控会立刻要求重新验证。
修复:代理池做成了 profile × proxy 的绑定关系,而不是独立池。admin-web 上每个账号可以手动指定代理,也可以进默认 resolver。
问题 3:上游限流
忙的时候 Turnitin 会返回 429 或一个带验证码的页面。我们最初简单地重试,结果两个小时之内整个账号池被标记冷却。
修复:做了一个 FailureClassifier,把错误归到 turnitin_blocked / login_failed / upload_failed / report_timeout 等结构化码。
turnitin_blocked→ 账号立即进冷却池,N 小时后再出来login_failed→ 尝试一次密码刷新,还不行就 disable 账号report_timeout→ 从 checkpoint 恢复,不重新上传
失败分类器是整个项目里最值得的一个设计投资。它把”我们系统哪里病了”从模糊的体感变成了可查询的数据。
阶段三:重新架构成”准 SaaS”
当我们发现 3-4 家同行客户都在问”能不能给我也来一份”之后,我们意识到这东西可能值得做成产品。为此把系统重构了一遍:
- 多租户:从单机单账号改成多客户共享账号池,加上 API Key 限流
- 两阶段提交:登录+提交在 browser phase,报告轮询在 session phase。这两阶段的 checkpoint 允许容器重启不丢状态
- 结算系统:引入
quota_grants作为配额的唯一来源,兑换码/订阅/支付都往同一张表写 - 失败退款:所有平台侧失败都自动调用 QuotaService.refund,客户无感知
重构完成上线后,我们的失败率稳定在 2% 左右,大部分是上游真 down。
给后来者的几个建议
- 不要假设你能骗过 Cloudflare。你会败得很惨。带上真实浏览器 profile,把钱花在好代理上。
- 尽早做失败分类器。在你还只有 3 种失败的时候做好它,到 30 种的时候你会感谢自己。
- 两阶段提交是救命稻草。容器 OOM、网络抖动、工作节点升级,只要 checkpoint 还在,就不用重跑几十秒钟的登录。
- 账号池是 profile 池,不是账号池。profile 状态变质是最常见的隐性故障,监控它。
如果你也在做类似的事情,欢迎发邮件到 support@njbejm.cn 聊聊。