ARTICLE DETAIL

资讯详情

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

基于CDP与JSON配置的多平台自动化发布实战:穿透Shadow DOM与iframe

基于CDP与JSON配置的多平台自动化发布实战:穿透Shadow DOM与iframe 1. 为什么我要折腾多平台发布这件事做内容的人都有一个共同的痛点同一篇文章写完只是第一步接下来要把它搬到公众号、知乎、掘金、CSDN、小红书、B站专栏……每个平台的编辑器都不一样图片要重新传代码块要重新调格式稍不留神就乱版。我最早是手动复制粘贴一篇稿子发五个平台光排版就得花一个多小时发完眼睛都花了。后来我开始琢磨能不能自动化。市面上确实有一些多平台发布工具但要么是SaaS服务、数据要过别人的服务器要么是浏览器插件、功能残缺还经常失效。我需要的是一套自己能掌控、能调试、能扩展的方案。于是就有了这个WorkBuddy 多平台发布实战项目——用Chrome DevTools ProtocolCDP直接驱动浏览器把内容从一份JSON配置里读出来自动填进各个平台的编辑器包括那些用了Shadow DOM的顽固页面。这套东西解决的核心问题就一个把人肉搬运变成配置驱动。你只需要维护一份 JSON里面写清楚每个平台的登录状态、编辑器选择器、发布按钮位置剩下的交给脚本。适合谁参考有一定 JavaScript 基础、愿意折腾浏览器自动化的内容创作者或者想入门 CDP 但不知道拿什么练手的前端同学。哪怕你只是想搞清楚 Shadow DOM 里的元素怎么抓这篇也能给你省下几个小时的搜索时间。我踩过的坑不少CDP 连接断连、Shadow DOM 选择器穿透失败、JSON 配置里中文转义出错、平台风控识别自动化行为……这些后面都会一个个讲清楚。先把整体思路捋一遍。2. 整体方案设计与技术选型拆解2.1 为什么选 CDP 而不是 Selenium 或 Puppeteer一开始我用的 Puppeteer写起来确实舒服但它自带了一个 Chromium 版本跟我日常用的 Chrome 是两套环境。登录态、插件、书签全都不共享每次都要重新扫码登录烦得很。Selenium 更重还要配 driver 版本Chrome 一升级就得跟着换。CDP 的好处是它直接连你已经开着的 Chrome。你平时怎么用浏览器脚本就怎么用。登录态是现成的插件是现成的甚至你手动打开某个页面脚本可以接着在那个标签页上操作。这对多平台发布太重要了——大部分平台都要登录而登录往往有验证码、扫码、短信自动化很难搞定但复用人工登录的会话就完全绕开了这个问题。具体做法是启动 Chrome 时加一个远程调试端口# Windows chrome.exe --remote-debugging-port9222 --user-data-dirC:\chrome-debug # macOS /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port9222 --user-data-dir/tmp/chrome-debug注意--user-data-dir一定要单独指定一个目录不要用默认目录。否则你日常用的 Chrome 如果已经开着新实例起不来端口也占不上。我一开始就是没加这个参数折腾了半小时才发现问题。启动之后访问http://localhost:9222/json/version能看到浏览器版本信息就说明通了。然后脚本里用chrome-remote-interface这个库连上去const CDP require(chrome-remote-interface); async function connect() { const client await CDP({ port: 9222 }); const { Page, Runtime, DOM } client; await Page.enable(); await DOM.enable(); await Runtime.enable(); return client; }这套组合的好处是轻——没有额外的浏览器二进制没有 driver就是一个 WebSocket 连接。坏处是 API 比较底层什么都要自己写比如等待元素出现、模拟输入、处理 iframe这些 Puppeteer 帮你封装好的东西CDP 里得手动实现。但反过来底层意味着可控遇到奇怪的问题能直接看协议层发生了什么。2.2 JSON 配置驱动的设计思路多平台发布最怕什么最怕平台改版。今天按钮在这个 class明天换成那个 id脚本就废了。如果每个平台的选择器都硬编码在代码里改一次就要动代码、重新测试、重新部署成本太高。所以我把所有平台相关的信息抽到一份 JSON 里{ platforms: [ { name: example-blog, url: https://example.com/editor, steps: [ { action: wait, selector: #title-input, timeout: 10000 }, { action: type, selector: #title-input, value: {{title}} }, { action: type, selector: .editor-body, value: {{content}}, shadow: true }, { action: click, selector: button.publish-btn } ] } ] }这样平台改版了我只需要改 JSON 里的选择器代码一行不动。{{title}}和{{content}}是占位符运行时从文章数据里替换。shadow: true表示这个元素在 Shadow DOM 里面需要特殊处理。为什么用 JSON 而不是 YAML因为 JSON 是浏览器和 Node 都原生支持的不需要额外解析库而且我后面要把配置传给页面里的注入脚本JSON 序列化最省事。YAML 虽然写起来舒服但缩进一错就报错而且注释在传输过程中会丢。2.3 Shadow DOM 带来的挑战现在越来越多的编辑器用 Web Components 构建比如某些富文本编辑器、代码编辑器它们的内部结构藏在 Shadow DOM 里。你在控制台用document.querySelector是找不到的因为 Shadow DOM 形成了一个隔离的 DOM 树。举个例子假设页面结构是这样my-editor #shadow-root (open) div classtoolbar.../div div classcontent contenteditabletrue/div /my-editor你想操作.content直接document.querySelector(.content)返回 null。必须先拿到宿主元素my-editor再访问它的shadowRootconst host document.querySelector(my-editor); const content host.shadowRoot.querySelector(.content);如果 Shadow DOM 是嵌套的就要一层层往下钻。我在配置里用shadow: true标记脚本里递归查找function queryDeep(selector, root document) { const el root.querySelector(selector); if (el) return el; const hosts root.querySelectorAll(*); for (const host of hosts) { if (host.shadowRoot) { const found queryDeep(selector, host.shadowRoot); if (found) return found; } } return null; }注意只有open模式的 Shadow DOM 才能这样访问closed模式的shadowRoot返回 null目前没有标准方法穿透。遇到 closed 的只能退回到坐标点击或者键盘操作。3. 核心细节解析与实操要点3.1 CDP 连接的生命周期管理CDP 连接不是一劳永逸的。Chrome 标签页关闭、页面跳转、浏览器崩溃都会导致连接断开。我最早写的脚本跑一半就报WebSocket is not open查了半天才发现是页面跳转后 target 变了。正确的做法是监听Target.targetCreated和Target.targetDestroyed事件动态维护连接。或者更简单一点每次操作前检查连接状态断了就重连async function ensureConnected(client) { try { await client.Runtime.evaluate({ expression: 11 }); return client; } catch (e) { console.log(连接断开重新连接...); return await connect(); } }还有一个坑CDP 默认连接的是第一个 target但 Chrome 可能有很多标签页。你要先列出所有 target找到你想要的那个const targets await CDP.List({ port: 9222 }); const page targets.find(t t.type page t.url.includes(example.com)); const client await CDP({ target: page, port: 9222 });我一般会在配置里写清楚平台的 URL 特征脚本根据特征找对应的标签页。这样即使开了十几个标签也能准确定位。3.2 模拟输入的正确姿势直接设置element.value xxx在很多编辑器里是不生效的因为框架监听的是input事件而不是 value 变化。你必须手动派发事件async function typeText(client, selector, text) { const expression (function() { const el ${queryDeep.toString()}.call(null, ${JSON.stringify(selector)}); if (!el) return not found; el.focus(); el.value ${JSON.stringify(text)}; el.dispatchEvent(new Event(input, { bubbles: true })); el.dispatchEvent(new Event(change, { bubbles: true })); return ok; })() ; const result await client.Runtime.evaluate({ expression, returnByValue: true }); return result.result.value; }对于contenteditable的富文本编辑器value属性根本不存在要用innerHTML或者textContent而且很多编辑器比如基于 ProseMirror 的会拦截粘贴事件直接改 DOM 会被它重置。这种情况我一般用 CDP 的Input.insertTextawait client.Input.insertText({ text: 要插入的内容 });这个方法模拟的是真实的文本输入编辑器会当成用户打字处理兼容性最好。但前提是光标已经在编辑器里了所以要先el.focus()。实操心得如果insertText也不行试试Input.dispatchKeyEvent逐字符发送。虽然慢但几乎能骗过所有编辑器。我遇到过一个平台只有逐字符输入才能触发它的自动保存批量插入会被判定为异常。3.3 JSON 配置里的中文与特殊字符处理JSON 标准要求字符串里的中文可以直接写但有些平台的后端会对请求体做编码检查中文如果不转义可能乱码。我在配置里统一用JSON.stringify处理它会自动把中文转成\uXXXX形式虽然可读性差一点但兼容性最好。另外文章内容里经常有引号、换行、反斜杠这些在 JSON 里都要转义。我写了一个小工具函数专门处理内容字段function escapeForJSON(str) { return str .replace(/\\/g, \\\\) .replace(//g, \\) .replace(/\n/g, \\n) .replace(/\r/g, \\r) .replace(/\t/g, \\t); }但更推荐的做法是不要把文章内容直接塞进 JSON 配置。配置里只放平台信息文章内容单独用一个文件运行时读取。这样配置可以复用内容可以随时换。我现在的结构是project/ config/ platforms.json # 平台配置 content/ article.md # 文章内容 scripts/ publish.js # 主脚本article.md用 Markdown 写脚本里用marked转成 HTML再根据平台需要决定是填 HTML 还是纯文本。3.4 等待策略别再用 sleep 了新手最容易犯的错就是await sleep(3000)觉得等三秒页面肯定加载完了。实际网络一慢三秒不够网络一快三秒浪费。而且平台越多累计的等待时间越长。正确的做法是轮询检查元素是否存在async function waitForElement(client, selector, timeout 10000, shadow false) { const start Date.now(); while (Date.now() - start timeout) { const expression shadow ? ${queryDeep.toString()}.call(null, ${JSON.stringify(selector)}) ! null : document.querySelector(${JSON.stringify(selector)}) ! null; const result await client.Runtime.evaluate({ expression, returnByValue: true }); if (result.result.value) return true; await new Promise(r setTimeout(r, 200)); } throw new Error(等待元素超时: ${selector}); }轮询间隔 200ms 是个经验值。太短了频繁发 CDP 请求浏览器压力大太长了响应慢。200ms 在大多数场景下平衡得不错。对于页面跳转要监听Page.frameNavigated事件而不是傻等client.Page.frameNavigated(() { console.log(页面跳转完成); });4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把 Node 环境准备好建议 18 以上因为要用到原生的 fetch 和顶层 await。然后初始化项目mkdir workbuddy-publish cd workbuddy-publish npm init -y npm install chrome-remote-interface markedchrome-remote-interface是 CDP 的 Node 封装marked用来把 Markdown 转 HTML。不需要装 Puppeteer不需要装 Selenium依赖非常干净。然后启动带调试端口的 Chrome。我写了一个启动脚本Windows 和 macOS 各一份# start-chrome.sh (macOS) #!/bin/bash /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port9222 \ --user-data-dir/tmp/workbuddy-chrome \ --no-first-run \ --no-default-browser-check:: start-chrome.bat (Windows) echo off start C:\Program Files\Google\Chrome\Application\chrome.exe ^ --remote-debugging-port9222 ^ --user-data-dirC:\workbuddy-chrome ^ --no-first-run ^ --no-default-browser-check注意--user-data-dir指向的目录如果不存在Chrome 会自动创建。但如果你之前用这个目录登录过登录态会保留。所以第一次启动后手动把所有平台登录一遍之后就不用再登了。4.2 平台配置的编写方法打开目标平台的编辑器页面按 F12 打开开发者工具用元素选择器点一下标题输入框看它的选择器是什么。优先用 id其次用稳定的 class最后才用层级选择器。我以三个典型平台为例展示配置怎么写{ platforms: [ { name: platform-a, urlPattern: editor.platform-a.com, steps: [ { action: wait, selector: #article-title }, { action: type, selector: #article-title, value: {{title}} }, { action: wait, selector: .ql-editor }, { action: type, selector: .ql-editor, value: {{content}}, rich: true }, { action: click, selector: #submit-btn }, { action: wait, selector: .success-toast, timeout: 15000 } ] }, { name: platform-b, urlPattern: write.platform-b.io, steps: [ { action: wait, selector: input.title, shadow: true }, { action: type, selector: input.title, value: {{title}}, shadow: true }, { action: wait, selector: div.editor, shadow: true }, { action: type, selector: div.editor, value: {{content}}, shadow: true, rich: true }, { action: click, selector: button.publish, shadow: true } ] }, { name: platform-c, urlPattern: mp.platform-c.cn, steps: [ { action: wait, selector: #js_editor_insertimg }, { action: type, selector: #title, value: {{title}} }, { action: type, selector: #ueditor_0, value: {{content}}, iframe: true }, { action: click, selector: #js_send } ] } ] }注意 platform-c 用了iframe: true因为它的编辑器在 iframe 里。iframe 的处理要单独说。4.3 iframe 与 Shadow DOM 的穿透处理iframe 里的元素document.querySelector也找不到。要先拿到 iframe 的 contentDocumentfunction queryInFrame(selector, frameSelector) { const frame document.querySelector(frameSelector); if (!frame || !frame.contentDocument) return null; return frame.contentDocument.querySelector(selector); }但 CDP 有个更优雅的方式直接获取 iframe 的 execution context然后在那个上下文里执行表达式。不过实现起来复杂我一般用上面的 DOM 方式就够了前提是 iframe 同源。跨域的 iframe 就没办法了只能靠坐标点击。Shadow DOM 和 iframe 可能同时出现比如一个 Web Component 里面套了个 iframe。我的queryDeep函数要同时处理两种情况function queryDeep(selector, root document) { // 先查普通元素 const el root.querySelector(selector); if (el) return el; // 查 iframe const frames root.querySelectorAll(iframe); for (const frame of frames) { try { const found queryDeep(selector, frame.contentDocument); if (found) return found; } catch (e) { // 跨域 iframe跳过 } } // 查 Shadow DOM const hosts root.querySelectorAll(*); for (const host of hosts) { if (host.shadowRoot) { const found queryDeep(selector, host.shadowRoot); if (found) return found; } } return null; }这个函数是递归的能处理任意深度的嵌套。但要注意性能querySelectorAll(*)在大型页面上可能返回几千个元素每次都遍历一遍会很慢。优化方法是缓存已经查过的 root或者限制递归深度。实操心得我在实际使用中发现把queryDeep注入到页面里执行比在 Node 端通过 CDP 反复查询要快得多。因为 CDP 每次 evaluate 都有网络往返开销而注入后所有查询都在浏览器内部完成。所以我的做法是一次性把工具函数注入页面然后后续操作都调用页面里的函数。4.4 内容注入与格式适配不同平台对内容的格式要求不一样。有的接受 HTML有的只接受纯文本有的对图片有特殊要求。我在配置里加一个format字段{ name: platform-a, format: html, steps: [...] }然后脚本里根据 format 决定怎么处理内容function prepareContent(markdown, format) { const html marked.parse(markdown); if (format html) return html; if (format text) return markdown.replace(/[#*]/g, ); if (format rich) return html; // 富文本编辑器插入 HTML return markdown; }图片是最麻烦的。Markdown 里的图片是本地路径或者外链但平台通常要求上传到自己的图床。我的做法是先把图片上传到平台拿到平台返回的 URL再替换内容里的图片地址。这一步每个平台都不一样有的有上传接口有的只能模拟点击上传按钮。模拟上传用 CDP 的DOM.setFileInputFilesasync function uploadFile(client, selector, filePath) { const { root } await client.DOM.getDocument(); const { nodeId } await client.DOM.querySelector({ nodeId: root.nodeId, selector: selector }); await client.DOM.setFileInputFiles({ nodeId: nodeId, files: [filePath] }); }这个方法直接给input typefile设置文件不弹文件选择框非常适合自动化。但前提是页面上有 file input有些平台是点击按钮后才动态创建 input那就要先点按钮等 input 出现再设置文件。4.5 发布后的验证与日志记录发布不是点完按钮就完事了要验证是否真的成功。我的做法是等待成功提示元素出现或者检查 URL 是否跳转到了文章详情页async function verifyPublish(client, platform) { const successSelectors [.success-toast, .publish-success, .article-published]; for (const sel of successSelectors) { const found await waitForElement(client, sel, 5000).catch(() false); if (found) return true; } // 检查 URL 变化 const { result } await client.Runtime.evaluate({ expression: location.href, returnByValue: true }); return result.value.includes(/article/) || result.value.includes(/post/); }每次发布都记日志写到一个 JSON 文件里const log { platform: platform.name, title: article.title, timestamp: new Date().toISOString(), success: true, url: currentUrl }; fs.appendFileSync(publish-log.json, JSON.stringify(log) \n);这样出问题了可以回溯也方便统计哪些平台发成功了、哪些失败了。5. 常见问题与排查技巧实录5.1 连接类问题速查问题现象可能原因解决方法ECONNREFUSED 9222Chrome 没启动或端口没开检查启动参数访问localhost:9222/json/version验证WebSocket is not open页面跳转导致 target 失效监听 target 变化重新连接连接上了但操作无反应连到了错误的 target用CDP.List列出所有 target按 URL 筛选频繁断连Chrome 版本与库不兼容升级chrome-remote-interface或锁定 Chrome 版本连接问题占了新手遇到问题的一半以上。我的建议是每次操作前都验证连接不要假设连接一直有效。写一个safeEvaluate包装函数内部自动重连async function safeEvaluate(client, expression) { try { return await client.Runtime.evaluate({ expression, returnByValue: true }); } catch (e) { if (e.message.includes(WebSocket)) { client await reconnect(client); return await client.Runtime.evaluate({ expression, returnByValue: true }); } throw e; } }5.2 元素定位失败排查元素找不到是最常见的问题。排查顺序确认页面加载完成在控制台手动执行document.querySelector(你的选择器)看能不能找到。确认是否在 iframe 里在控制台顶部有个下拉框切换 frame 试试。确认是否在 Shadow DOM 里在 Elements 面板看有没有#shadow-root标记。确认选择器是否唯一document.querySelectorAll(你的选择器).length如果大于 1可能选错了。确认元素是否动态生成有些元素要滚动到可视区域才加载先scrollIntoView。我遇到过一个平台编辑器在页面加载后 5 秒才初始化而且初始化过程中会替换整个 DOM 树。这种情况下等待元素出现还不够还要等它稳定。我的做法是等元素出现后再等 500ms确认元素没有被替换async function waitForStable(client, selector, timeout 15000) { await waitForElement(client, selector, timeout); let lastHTML ; for (let i 0; i 5; i) { const { result } await client.Runtime.evaluate({ expression: document.querySelector(${JSON.stringify(selector)}).outerHTML, returnByValue: true }); if (result.value lastHTML) return true; lastHTML result.value; await new Promise(r setTimeout(r, 200)); } return true; }5.3 内容注入异常处理内容注入的异常主要有三类第一类内容被截断。原因是编辑器有字数限制或者输入速度太快触发了防抖。解决方法是分段注入每段之间加延迟async function typeInChunks(client, selector, text, chunkSize 500) { for (let i 0; i text.length; i chunkSize) { const chunk text.slice(i, i chunkSize); await client.Input.insertText({ text: chunk }); await new Promise(r setTimeout(r, 100)); } }第二类格式丢失。富文本编辑器对 HTML 有白名单不支持的标签会被过滤。解决方法是先了解平台支持哪些标签在prepareContent里做适配。比如有的平台不支持h1那就降级成h2。第三类特殊字符乱码。中文、emoji、数学符号在不同编码下表现不一样。统一用 UTF-8并且在 JSON 配置里对特殊字符做转义。实操心得我习惯在正式发布前先用一个测试账号跑一遍确认内容完整、格式正确。测试账号的文章设为私密发完检查没问题再删掉。这样不会污染正式账号也不会因为格式问题被平台限流。5.4 平台风控与自动化识别规避平台对自动化行为是有检测的。常见的检测点包括鼠标移动轨迹、输入速度、点击间隔、User-Agent、WebDriver 标志。CDP 驱动的浏览器navigator.webdriver是false这一点比 Selenium 好。但输入速度如果太快、太规律还是会被识别。我的应对策略输入加随机延迟每个字符之间 30-80ms 随机不要固定值。点击前先移动鼠标用Input.dispatchMouseEvent模拟鼠标移动轨迹不要直接点击。避免高频操作同一平台两次发布之间至少间隔几分钟。使用真实用户目录--user-data-dir用日常使用的目录有历史记录、Cookie、插件更像真人。async function humanType(client, text) { for (const char of text) { await client.Input.dispatchKeyEvent({ type: keyDown, text: char }); await client.Input.dispatchKeyEvent({ type: keyUp, text: char }); await new Promise(r setTimeout(r, 30 Math.random() * 50)); } }这套方法不能保证 100% 不被识别但能大幅降低风险。我的原则是自动化只用来提高效率不要用来刷量。正常发布内容平台一般不会为难你。5.5 多平台并发发布的注意事项多个平台同时发布能省时间但要注意不要共用同一个 CDP 连接每个平台一个连接互不干扰。控制并发数同时开 3-5 个就够了太多会拖慢浏览器。错误隔离一个平台失败不要影响其他平台用Promise.allSettled而不是Promise.all。const results await Promise.allSettled( platforms.map(p publishToPlatform(p, article)) ); results.forEach((r, i) { if (r.status rejected) { console.error(${platforms[i].name} 发布失败:, r.reason); } });6. 我在这套方案上的一些个人体会这套 WorkBuddy 多平台发布方案我从最初的手动复制粘贴到现在基本一键发布前后迭代了大概十几个版本。最大的感受是自动化不是一劳永逸的而是一个持续维护的过程。平台会改版Chrome 会升级CDP 协议也会变。但只要你的配置和代码分离得好维护成本就能控制在可接受的范围内。JSON 配置驱动这个设计是我觉得最值得坚持的一点。它让适配新平台变成了一件不需要写代码的事——打开开发者工具找到选择器填进 JSON跑一遍完事。Shadow DOM 和 iframe 的穿透函数我封装成了一个独立的工具模块任何项目都能直接拿去用。如果你也想搭一套类似的系统我的建议是先从单个平台开始跑通了再扩展。不要一上来就想着支持十个平台那样只会让你在调试中崩溃。一个平台跑通你就掌握了 CDP 的核心用法两个平台跑通你就理解了配置驱动的价值三个平台跑通你就能总结出通用的模式了。最后分享一个小技巧把常用的 CDP 操作封装成actions比如wait、type、click、upload、verify然后在 JSON 里用action字段引用。这样配置写起来就像写剧本一样一步一步清晰明了。后面想加新动作只需要在代码里注册一个新的 action 处理器配置里就能直接用。这套模式我用了大半年至今没遇到解决不了的问题。
返回列表