浏览器插件+Playwright绕过大模型风控限流实战

发布时间:2026/7/16 17:30:22
浏览器插件+Playwright绕过大模型风控限流实战 1. 项目概述这不是一次“黑产操作”而是一次面向AI产品接口边界的压力测绘凌晨三点电脑屏幕幽幽泛着蓝光我盯着豆包网页版控制台里不断跳动的429状态码手边咖啡凉透。这不是在搞什么灰产攻击而是我为一个内部知识库自动化摘要项目写的浏览器插件在真实用户场景下第一次撞上了大厂风控系统的实体墙。标题里那个“干限流了”的“干”字是北方工程师圈里带点自嘲的实话——不是主动去“干”是被系统精准识别、果断拦截、温柔但不容置疑地“请离场”。整个过程没有暴力请求没有IP池轮换只有一段基于Playwright封装的、逻辑清晰的自动化脚本配合一个轻量级Chrome插件做前端触发与上下文注入。它跑得非常稳直到第173次请求后页面开始卡顿Network面板里所有发往/api/v1/chat/completions的请求统一返回{code:429,message:Too Many Requests}。那一刻我意识到自己亲手把一个本该服务小团队的工具送进了豆包开放平台风控模型的“观察名单”。这个项目的核心关键词——豆包、浏览器插件、自动化、限流、风控——每一个都不是孤立存在的。它们共同指向一个正在快速成型的新战场当大模型能力以API和网页形态向公众开放普通开发者如何在“合法调用”与“系统防御”之间那条极窄的灰色地带找到一条可持续、可复现、不伤筋动骨的自动化路径它不涉及任何破解或绕过而是对标准HTTP协议、浏览器渲染机制、前端事件循环、以及大厂后台风控策略的一次系统性解构与适配。适合三类人直接抄作业一是想用豆包API但被配额卡住的独立开发者二是正为内部知识库、客服话术生成等场景写自动化流程的技术负责人三是刚学完Playwright或Selenium苦于找不到真实、有挑战性练手项目的前端/测试工程师。你不需要懂豆包源码但必须理解现代Web应用是如何被“看”、被“判”、被“管”的。2. 核心思路拆解为什么选“插件Playwright”组合而不是纯后端API调用2.1 纯后端API调用的死胡同Token、Session与风控指纹的三重绞杀最直觉的方案当然是直接调用豆包开放平台提供的API。但实际踩坑后你会发现这条路在非企业级合作场景下几乎走不通。原因不在技术而在设计哲学。首先Token不是万能钥匙。豆包开放平台确实提供API Key但它默认绑定的是“开发者账号”而非“终端用户”。当你用这个Key批量请求时后台风控系统看到的不是一个“用户A在用豆包写周报”而是一个“未知来源的服务器IP在高频、模式化地消费文本生成服务”。这就像你拿着一张银行VIP卡却天天在ATM机前连续取款100次——卡本身没问题但行为模式触发了反洗钱模型。我试过用curl直接调用哪怕把QPS压到0.5即每2秒1次持续跑4小时后Key依然被临时冻结。后台没有明确提示只是返回401 Unauthorized且Key在控制台里显示为“已禁用”。其次Session ID是更隐蔽的枷锁。网页版豆包的每一次对话都依赖一个由前端JavaScript动态生成并维护的session_id。这个ID不是简单的cookie它与当前浏览器实例的localStorage、indexedDB中的会话状态强耦合。纯后端请求无法模拟这个完整链路。我曾尝试用Pythonrequests库抓包获取session_id再带着它去调API结果前5次成功第6次就失败。日志显示后台校验了session_id与发起请求的User-Agent、Referer、甚至TLS握手指纹的匹配度。一个Python脚本发出的请求其TLS指纹与Chrome浏览器相差甚远风控系统一眼就能识别出“这不是人也不是浏览器”。最后也是最关键的风控指纹Fingerprint是终极审判者。现代大厂的风控系统早已超越了简单的IP频率统计。它会综合采集Canvas渲染噪声、WebGL参数、音频上下文采样、字体枚举列表、设备内存、硬件并发数、甚至鼠标移动轨迹的贝叶斯熵值。这些数据在纯后端请求中全部缺失或为默认值构成一个“完美但虚假”的数字画像。我的curl脚本其Canvas指纹在风控系统里得分是0.02满分1而一个真实人类操作的Chrome浏览器平均分是0.87。这个差距就是一道无法逾越的墙。提示不要迷信“更换User-Agent就能绕过”。风控系统早已将UA视为最低阶的特征它只是拼图的第一块。真正起决定作用的是那些你根本看不到、也很难伪造的底层浏览器环境信号。2.2 “插件Playwright”组合的底层逻辑用“真身”做“真事”既然纯后端行不通那就让自动化发生在它本该发生的地方——浏览器内部。这就是我们选择“浏览器插件 Playwright”双引擎架构的根本原因用真实的浏览器环境执行真实的用户操作从而获得真实的风控信任。浏览器插件Chrome Extension的角色它不是用来“劫持”或“注入恶意代码”的而是作为一个“可信信使”。它的核心任务只有两个第一在用户点击插件图标时向当前活动的豆包网页发送一个标准化的postMessage指令内容是本次需要处理的原始文本比如一段会议纪要第二监听网页内豆包React组件发出的customEvent捕获生成完成后的漫画解说文案并将其回传给插件弹窗供用户一键复制。整个过程插件不触碰豆包的任何API密钥不构造任何HTTP请求它只是在同一个Origin域下与网页进行安全的、受浏览器同源策略保护的通信。这使得它天然具备“白名单”属性——它是用户主动安装、主动授权的行为完全符合Chrome Web Store的审核规范。Playwright的角色它负责“代劳”那些用户懒得重复的手动操作。比如用户把一段长文本粘贴进豆包输入框然后手动点击“发送”再等待几秒最后手动复制输出。Playwright把这个流程自动化它启动一个干净的、无痕模式的Chromium实例打开豆包网页等待React应用完全加载通过监听window.__BEFORE_RENDERED__全局变量确认然后模拟真实的KeyboardEvent和MouseEvent将文本填入输入框模拟鼠标悬停、点击发送按钮。最关键的是Playwright可以精确控制请求的节奏它不会在click()之后立刻page.waitForResponse()而是先page.waitForSelector(.response-content)等待DOM中出现代表回复完成的CSS类名。这模仿了人类的视觉反馈习惯而非机器的毫秒级响应极大降低了行为模式的“机械感”。这个组合的精妙之处在于分工明确、各司其职插件负责“可信通信”Playwright负责“可信操作”。两者都运行在真实的、未被篡改的Chromium环境中共享同一套Canvas、WebGL、AudioContext等指纹源。风控系统看到的就是一个“有点慢、但很像人”的Chrome浏览器在按部就班地使用豆包。它不会因为你的QPS是0.3而怀疑你因为它看到的不是“请求频率”而是“用户操作频率”。2.3 为什么不是Selenium或PuppeteerPlaywright的不可替代性市面上有太多自动化框架为什么最终锁定Playwright这背后是三次失败的实操教训。第一次我用Selenium ChromeDriver。问题出在隔离性上。Selenium启动的浏览器实例其localStorage和cookies是全局共享的。这意味着如果我昨天用这个浏览器登录过自己的豆包账号今天自动化脚本一启动它就会自动带上那个登录态。这看似方便实则致命。因为风控系统会将“长期活跃的登录态”与“高频自动化操作”关联起来判定为“账号被盗用”。我花了两天时间研究如何彻底清空Profile最终发现Selenium的--user-data-dir参数在不同版本间行为不一致极难稳定。第二次我换用Puppeteer。问题出在渲染保真度上。Puppeteer的page.screenshot()在截取Canvas时会丢失抗锯齿和子像素渲染信息导致Canvas指纹与真实浏览器偏差超过15%。我用Puppeteer跑了一下午成功了127次第128次被限流。日志分析显示那次失败前Puppeteer的Canvas噪声熵值突然从4.2降到了3.1恰好低于风控模型设定的“人类操作下限阈值”。第三次我转向Playwright。它解决了前两者的痛点其chromium.launch({ headless: new })模式每次启动都是一个全新的、沙盒化的浏览器上下文localStorage、cookies、indexedDB全部清零确保了每次操作的“纯净性”同时Playwright对Chromium内核的封装更底层它能直接访问V8引擎的performance.memory和navigator.hardwareConcurrency并能精确模拟requestIdleCallback的调度这让它的行为模式熵值长期稳定在0.85±0.03的区间内与真实人类操作高度吻合。这不是玄学是我在playwright.config.ts里反复调整viewport、deviceScaleFactor、hasTouch等23个参数后用A/B测试跑出来的数据结论。3. 核心细节解析从插件开发到Playwright脚本每一个环节的“为什么”3.1 浏览器插件最小化、零权限、纯前端的设计哲学很多人一听到“浏览器插件”脑子里就浮现出各种需要all_urls权限、能读取全站Cookie的“全能型”扩展。我们的插件恰恰相反它奉行的是“最小权限原则”整个manifest.json文件只有37行核心配置如下{ manifest_version: 3, name: 豆包漫画解说助手, version: 1.0.0, description: 为豆包网页版添加一键漫画解说功能, permissions: [activeTab], host_permissions: [https://www.doubao.com/*], content_scripts: [{ matches: [https://www.doubao.com/*], js: [content.js], run_at: document_idle }], background: { service_worker: background.js }, action: { default_popup: popup.html, default_title: 豆包漫画解说 } }permissions: [activeTab]这是最关键的权限声明。它意味着插件只能读取和操作当前用户正在查看的那个标签页且仅限于该标签页的DOM和事件。它无法窥探其他标签页无法读取历史记录也无法访问任何网站的存储数据。这不仅是为了过审更是为了安全——一个没有权限的插件即使被恶意利用危害也极其有限。host_permissions明确声明只允许与豆包官网域名通信。这堵死了插件向第三方服务器发送数据的通道所有数据处理都在用户本地浏览器内存中完成。content.js的唯一任务监听用户点击插件图标的动作然后向当前页面注入一个轻量级的injected.js。这个injected.js才是真正干活的但它不是从远程加载的而是作为字符串由content.js在运行时动态eval()进去的。这样做的好处是injected.js可以完全访问页面的React上下文window.__REDUX_DEVTOOLS_EXTENSION__能精准找到textarea元素并监听其input事件还能在div classresponse-content出现时用MutationObserver实时捕获新生成的文本。整个过程没有网络请求没有外部依赖纯粹是前端DOM操作。注意绝对不要在content.js里写fetch()去调用你的后端API。这会让插件瞬间失去“可信”属性。所有“智能”都必须发生在前端或者通过Playwright在本地完成。3.2 Playwright脚本节奏控制、状态等待与异常熔断的黄金三角Playwright脚本的主体是一个名为auto-doubao.ts的TypeScript文件。它的核心不在于“做了什么”而在于“怎么做”和“做多久”。我把整个流程抽象为三个相互咬合的齿轮节奏控制、状态等待、异常熔断。节奏控制Pacing Control这是对抗风控的第一道防线。脚本里没有await page.click()之后立刻await page.waitForTimeout(2000)这种粗暴的硬等待。取而代之的是一套基于“人类操作生理学”的动态节奏// 模拟人类打字速度每字符间隔80-150ms带随机抖动 const typeText async (page: Page, selector: string, text: string) { const input await page.$(selector); for (let i 0; i text.length; i) { await input?.press(text[i]); // 随机停顿模拟思考、纠错 if (i % 5 0) await page.waitForTimeout(120 Math.random() * 80); else await page.waitForTimeout(80 Math.random() * 40); } };这段代码让100字的输入耗时在12-18秒之间而不是毫秒级完成。风控系统会将这种“非匀速”的输入模式归类为“高置信度人类行为”。状态等待State-based Waiting这是第二道防线确保脚本永远“看”而不是“猜”。Playwright提供了page.waitForSelector()、page.waitForFunction()等强大API但关键在于等待什么。我不等待page.waitForResponse(/completions/)因为这个请求可能被CDN缓存也可能被后台异步处理响应时间波动极大。我等待page.waitForSelector(.response-content, { state: visible, timeout: 30000 })即等待DOM中出现一个可见的、代表回复完成的容器。这与用户的真实体验完全一致——用户也是看着屏幕等文字出来。更进一步我还会用page.waitForFunction()来检查React组件的内部状态await page.waitForFunction(() { // 检查豆包的Redux store中当前会话是否处于IDLE状态 return (window as any).__REDUX_DEVTOOLS_EXTENSION__?.store?.getState?.()?.chat?.status IDLE; }, {}, { timeout: 30000 });这确保了脚本只在豆包前端真正“准备好”接收下一条指令时才继续执行。异常熔断Circuit Breaking这是第三道也是最后一道防线。脚本不能在失败时无限重试。我设计了一个三级熔断机制单次失败如果一次请求超时30秒或返回429脚本会暂停5分钟然后重试一次。连续失败如果连续两次失败脚本会自动切换到一个备用的、预设的“低频模式”将QPS从0.3降到0.1并在每次操作后强制waitForTimeout(10000)。熔断触发如果在低频模式下1小时内仍失败3次脚本会主动退出并在控制台打印一条清晰的错误信息“检测到高强度风控建议暂停24小时后重试。当前会话已保存至./session-backup.json”。这个备份文件包含了最后一次成功的session_id和conversation_id下次启动时可直接恢复避免从头开始。这套机制让脚本不再是“一根筋”的机器人而是一个有感知、有判断、有退路的“数字同事”。3.3 豆包网页版的隐藏交互点那些官方文档不会告诉你的DOM线索要让自动化脚本稳定运行光靠公开的UI元素是不够的。你必须深入到豆包网页版的React组件树里找到那些被官方刻意隐藏、但却是自动化命脉的“暗线”。textarea的>npm init playwrightlatest # 选择TypeScript, 选择Yes to install dependencies, 选择Chromium only npx playwright install-deps chromium npx playwright install chromium这会下载一个独立的、与系统Chrome完全隔离的Chromium版本目前是119.x。它的好处是无论你的同事用的是Mac还是Windows只要运行npx playwright test启动的都是同一个Chromium内核消除了“在我电脑上好使在你电脑上不行”的经典魔咒。配置Playwright创建playwright.config.ts核心配置如下import { defineConfig } from playwright/test; export default defineConfig({ // 每次测试都启动一个全新的、无痕的浏览器上下文 use: { headless: true, // 生产环境用true调试时可设为false viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false, javaScriptEnabled: true, // 关键禁用所有可能暴露自动化痕迹的特性 ignoreHTTPSErrors: true, bypassCSP: true, userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, // 启用详细的trace便于事后分析 trace: on-first-retry, }, // 全局超时设置避免脚本挂起 timeout: 60000, expect: { timeout: 5000, }, });创建专用的User Data Dir这是保证纯净性的最后一步。在脚本启动时显式指定一个全新的、空的用户数据目录const browser await chromium.launch({ headless: new, // 每次都创建一个全新的、随机命名的目录 userDataDir: /tmp/playwright-${Date.now()}, });这个目录里Default/子目录下是空的没有任何历史记录、缓存或登录态。它就是一个出厂设置的、崭新的Chrome浏览器。4.2 插件开发30分钟搞定一个合规、可用、不被封的Chrome扩展现在让我们把注意力转向浏览器插件。整个开发流程可以在30分钟内完成无需任何后端。第一步创建项目结构在你的项目根目录下新建一个extension/文件夹结构如下extension/ ├── manifest.json ├── popup.html ├── popup.css ├── popup.js ├── content.js └── injected.js第二步编写popup.html与popup.jspopup.html是一个极简的弹窗界面只有一个textarea和一个“发送”按钮!DOCTYPE html html headmeta charsetutf-8link relstylesheet hrefpopup.css/head body textarea idinput-text placeholder粘贴你的会议纪要、产品需求.../textarea button idsend-btn一键生成漫画解说/button div idstatus就绪/div /body script srcpopup.js/script /htmlpopup.js的核心逻辑是监听按钮点击获取文本然后通过chrome.tabs.query找到当前激活的豆包标签页并向其发送消息document.getElementById(send-btn).addEventListener(click, async () { const text document.getElementById(input-text).value.trim(); if (!text) return; const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); if (tab.url tab.url.includes(doubao.com)) { try { await chrome.tabs.sendMessage(tab.id!, { action: GENERATE, payload: text }); document.getElementById(status).textContent 已发送等待豆包回复...; } catch (e) { document.getElementById(status).textContent 发送失败请确保豆包网页已打开; } } });第三步编写content.js与injected.jscontent.js的任务是“搭桥”// content.js chrome.runtime.onMessage.addListener((request, sender, sendResponse) { if (request.action GENERATE) { // 将injected.js作为字符串注入 const script document.createElement(script); script.textContent // injected.js 的全部代码这里省略见上文 ; (document.head || document.documentElement).appendChild(script); // 注入后立即向页面发送消息 window.postMessage({ type: DOUBAO_ASSISTANT_GENERATE, payload: request.payload }, *); sendResponse({ status: success }); } });injected.js则是真正的“大脑”它会监听postMessage找到输入框填充文本并监听回复。它的全部逻辑都运行在豆包网页的上下文中与豆包的React应用无缝集成。第四步加载与测试打开Chrome进入chrome://extensions/开启右上角的“开发者模式”点击“加载已解压的扩展程序”选择你的extension/文件夹。然后打开https://www.doubao.com/点击插件图标填写文本点击发送。你会看到豆包网页会自动将文本填入输入框并点击发送。整个过程流畅、自然、毫无破绽。4.3 Playwright主脚本auto-doubao.ts的完整实现与参数详解现在我们把所有线索串起来写出最终的auto-doubao.ts。这是一个完整的、可直接运行的脚本。// auto-doubao.ts import { chromium, Page, Browser } from playwright/test; // 配置常量 const DOUBAO_URL https://www.doubao.com/; const INPUT_SELECTOR div[contenteditabletrue]; const SEND_BUTTON_SELECTOR button[data-testiddoubao-send-button]; const RESPONSE_SELECTOR div.response-content; const MAX_RETRIES 3; const BASE_DELAY_MS 30000; // 基础延迟30秒 // 主函数 async function runAutomation(text: string) { let browser: Browser | null null; let page: Page | null null; let retryCount 0; while (retryCount MAX_RETRIES) { try { // 1. 启动干净的浏览器 browser await chromium.launch({ headless: new, userDataDir: /tmp/playwright-${Date.now()}-${retryCount}, }); page await browser.newPage(); // 2. 导航到豆包等待React应用就绪 await page.goto(DOUBAO_URL, { waitUntil: networkidle }); await page.waitForFunction(() (window as any).__REDUX_DEVTOOLS_EXTENSION__ ! undefined); // 3. 模拟用户操作输入文本 await page.waitForSelector(INPUT_SELECTOR, { state: visible }); await typeText(page, INPUT_SELECTOR, text); // 4. 点击发送 await page.waitForSelector(SEND_BUTTON_SELECTOR, { state: enabled }); await page.click(SEND_BUTTON_SELECTOR); // 5. 等待回复完成 await page.waitForSelector(RESPONSE_SELECTOR, { state: visible, timeout: 30000 }); // 6. 提取回复文本 const responseText await page.$eval(RESPONSE_SELECTOR, el el.textContent || ); console.log(✅ 成功获取回复:, responseText.substring(0, 100) ...); return responseText; } catch (error: any) { retryCount; console.error(❌ 第${retryCount}次尝试失败:, error.message); if (page) await page.close(); if (browser) await browser.close(); if (retryCount MAX_RETRIES) { // 指数退避第一次等30秒第二次等60秒第三次等120秒 const delay BASE_DELAY_MS * Math.pow(2, retryCount - 1); console.log(⏳ 正在等待${delay / 1000}秒后重试...); await new Promise(resolve setTimeout(resolve, delay)); } } } throw new Error(自动化失败已重试${MAX_RETRIES}次); } // 模拟打字的辅助函数 async function typeText(page: Page, selector: string, text: string) { const input await page.$(selector); if (!input) throw new Error(未找到输入框); for (let i 0; i text.length; i) { await input.press(text[i]); // 每5个字符加一个稍长的停顿 if (i % 5 0) { await page.waitForTimeout(120 Math.random() * 80); } else { await page.waitForTimeout(80 Math.random() * 40); } } } // 导出供其他模块调用 export { runAutomation }; // 如果直接运行此文件则执行一次 if (require.main module) { const inputText process.argv[2] || 请为以下会议纪要生成一个幽默风趣的漫画解说项目启动会讨论了Q3 OKR...; runAutomation(inputText) .then(console.log) .catch(console.error); }参数详解MAX_RETRIES 3不是越多越好。重试次数过多本身就是一种“异常行为”。3次是经验平衡点。BASE_DELAY_MS 30000这是核心的“节拍器”。30秒的延迟是基于对豆包用户行为的观察一个真实用户在发送一条消息后平均会花20-40秒去阅读、思考、再发送下一条。我们的脚本就卡在这个区间内。typeText函数它不只是“输入”而是“表达”。随机的停顿、不规则的节奏是让脚本摆脱“机器感”的灵魂。如何运行# 编译TypeScript npx tsc auto-doubao.ts # 运行脚本传入要处理的文本 node auto-doubao.js 请为这份产品需求文档生成一个适合给老板看的漫画版摘要...这个脚本就是你整个自动化流水线的“心脏”。它不依赖任何外部服务不调用任何API所有的智能都来自于对浏览器环境的深刻理解和对人类行为的精准模拟。5. 常见问题与排查技巧实录那些凌晨三点教会我的血泪教训5.1 限流不是故障而是一种“健康提示”如何读懂429背后的信号被限流是这个项目里最常发生的“事件”但它绝不是失败而是一个极其宝贵的诊断信号。我整理了一份“429状态码解读速查表”它比任何错误日志都更有价值。429出现的时机可能原因排查方法应对策略首次运行第1次请求就429IP被全局拉黑如你所在公司出口IP曾被滥用用手机热点连接重新运行脚本切换网络或联系网络管理员运行10-20次后出现行为模式过于“完美”如每次输入都精确100字符每次等待都精确30秒查看trace.zip中的network面板对比两次请求的User-Agent、Accept-Language、Sec-Fetch-*头在typeText中加入更多随机性或在page.goto()后随机滚动页面几次运行100次以上突然集中爆发session_id或conversation_id泄露被风控系统标记为“高风险会话”检查userDataDir是否真的每次都是全新的检查injected.js是否意外将session_id发到了外部严格使用/tmp/下的随机目录禁用所有console.log中可能包含敏感ID的日志在低频模式QPS0.1下仍持续429设备指纹被标记Canvas/WebGL指纹长期不变用page.evaluate()执行canvas.toDataURL()对比两次的Base64字符串差异升级Playwright到最新版或在launch()时添加args: [--disable-gpu]强制使用CPU渲染注意不要试图“对抗”429。把它当作一个友好的提醒“嘿朋友你现在的节奏让我有点紧张。” 你的任务是调整节奏让它更像一个真实的人。5.2 页面元素找不到TimeoutError: element not foundDOM的“捉迷藏”游戏这是新手最容易卡住的地方。你以为textarea就在那里但Playwright就是找不到。真相是豆包的DOM在不停地“变形”。问题根源React的懒加载Lazy Loading和条件渲染Conditional Rendering。豆包的输入框在页面刚加载时可能只是一个占位符div只有当用户第一次点击它或者页面完全渲染完毕后真正的div contenteditabletrue才会被挂载到DOM树上。终极解决方案永远不要用page.waitForSelector(textarea)而要用page.waitForSelector(div[contenteditabletrue], { state: attached })。state: attached意味着只要这个元素被创建哪怕还不可见、不可交互就认为它存在了。然后再用page.waitForSelector(div[contenteditabletrue], { state: visible })来等待它变得可见。这是一个两阶段的等待完美覆盖了React的渲染生命周期。一个实用的调试技巧当脚本卡在某个waitForSelector时不要急着改代码。先在playwright.config.ts里把headless设为false然后运行脚本。你会看到一个真实的Chrome窗口。在它卡住时立刻按F12打开DevTools手动在Console里输入document.querySelector(div[contenteditabletrue])看看它是否存在。如果存在说明你的选择器是对的问题在等待条件如果不存在说明你需要等待更长的时间或者等待另一个触发事件比如先click()一下某个按钮。5.3 回复文本为空或不完整流式输出的“截断”陷阱豆包的回复是流式的它会一个字一个字地“打”出来。如果你在page.$eval(RESPONSE_SELECTOR, el el.textContent)很可能只拿到前10个字因为脚本执行得太快了。正确做法使用MutationObserver监听RESPONSE_SELECTOR的childList和characterData变化。我封装了一个可靠的waitForFullResponse函数async function waitForFullResponse(page: Page, selector: string): Promisestring { return await page.waitForFunction( (sel)