自媒体账号RPA 自动发布技术实现,本文主要针对平台方使用Quill 编辑器,其他编辑器也可以使用类似方案处理!
## 一、技术概述本文档介绍使用 **Playwright** 实现自媒体文章自动发布的技术方案。核心技术栈- **Playwright**: 浏览器自动化框架支持 Chromium- **Node.js**: 运行环境- **Cookie 注入**: 实现免登录自动化## 二、整体架构┌─────────────────────────────────────────────────────────────┐│ 自媒体账号 RPA 发布器 │├─────────────────────────────────────────────────────────────┤│ 1. login() - 浏览器启动 Cookie 注入登录 ││ 2. publish() - 发布主流程编排 ││ ├─ _handleVerificationModal() - 处理弹窗 ││ ├─ _fillTitle() - 填写标题 ││ ├─ _parseHtmlForImages() - 解析HTML提取配图 ││ ├─ _fillContent() - 模拟粘贴注入正文 ││ ├─ _uploadCover() - 上传封面图 ││ └─ _uploadImagesAtPlaceholders() - 逐张上传配图到原位 ││ 3. _clickPublishButton() - 点击发布 │└─────────────────────────────────────────────────────────────┘## 三、核心实现### 3.1 浏览器启动与反检测javascript// 启动浏览器非无头模式便于调试this.browser await chromium.launch({headless: false,args: [--no-sandbox,--disable-blink-featuresAutomationControlled,--window-size1280,800]})// 创建浏览器上下文this.context await this.browser.newContext({userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36,viewport: { width: 1280, height: 800 },locale: zh-CN,timezoneId: Asia/Shanghai})// 反检测脚本隐藏自动化特征await this.context.addInitScript(() {Object.defineProperty(navigator, webdriver, { get: () false })Object.defineProperty(navigator, __playwright, { get: () undefined })Object.defineProperty(navigator, plugins, {get: () [{ name: Chrome PDF Plugin, filename: internal-pdf-viewer },{ name: Native Client, filename: native-client }]})})### 3.2 Cookie 注入登录javascript// 注入 Cookies 实现免登录const validCookies cookies.map(cookie ({name: cookie.name,value: cookie.value,domain: cookie.domain || .sohu.com,path: cookie.path || /,secure: cookie.secure || false,httpOnly: cookie.httpOnly || false})).filter(c c.name c.value)await this.context.addCookies(validCookies)// 访问后台验证登录状态await this.page.goto(https://mp.sohu.com/mpfe/v3/main/content/list, {waitUntil: networkidle,timeout: 30000})// URL 验证不包含 login/passport 即为登录成功const currentUrl this.page.url()if (currentUrl.includes(login) || currentUrl.includes(passport)) {return { success: false, errorMsg: Cookie已过期 }}### 3.3 发布主流程javascriptasync publish(articleData) {// 步骤1: 点击左侧发布内容菜单const publishMenu this.page.locator(#menu-ic_publish).first()await publishMenu.click({ force: true })await this.page.waitForTimeout(3000)// 步骤2: 处理实名认证弹窗await this._handleVerificationModal()// 步骤3: 导航到文章发布页URL验证const currentUrl this.page.url()if (!currentUrl.includes(addarticle)) {await this.page.goto(https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?contentStatus1, {waitUntil: networkidle,timeout: 30000})}// 步骤4: 填写标题await this._fillTitle(articleData.title)// 步骤5: 解析HTML提取配图注入正文带占位符const baseUrl https://geo.huikefu.cnconst { textHtml, images: contentImages } this._parseHtmlForImages(articleData.content, baseUrl)await this._fillContent(textHtml)// 步骤6: 上传封面图if (articleData.coverImage) {await this._uploadCover(articleData.coverImage)}// 步骤7: 逐张上传配图到占位符位置if (contentImages.length 0) {await this._uploadImagesAtPlaceholders(contentImages)}// ... 其他可选项设置确认后点击发布按钮即可}### 3.4标题填写如搜狐号使用 .publish-title input 作为标题输入框。javascriptasync _fillTitle(title) {const input this.page.locator(.publish-title input).first()await input.click()await this.page.waitForTimeout(300)await input.fill(title)await this.page.waitForTimeout(500)}### 3.5 HTML 解析与配图提取将文章 HTML 中的 img 标签提取为独立图片替换为占位符文本。javascript_parseHtmlForImages(html, baseUrl) {const images []let index 0const textHtml html.replace(/img[^]src[]([^])[][^]*\/?/gi, (match, src) {index// 补全相对路径let fullUrl srcif (src.startsWith(/api/)) {fullUrl ${baseUrl}${src}}images.push({ url: fullUrl, index })// 使用唯一文本标记作为占位符return p[IMG_PH_${index}]/p})return { textHtml, images }}**设计要点**- 使用文本标记 [IMG_PH_N] 而非 data-* 属性因为 Quill 编辑器会过滤自定义属性- 自动补全相对路径为完整 URL### 3.6 正文注入模拟粘贴有些自媒体使用 **Quill 编辑器**直接用 innerHTML 注入会丢失部分内容如列表。采用模拟原生粘贴事件的方式与手动 CtrlV 效果一致。javascriptasync _fillContent(content) {const editor this.page.locator(#editor .ql-editor).first()await editor.click()await this.page.waitForTimeout(500)// 模拟原生粘贴事件const result await this.page.evaluate((htmlContent) {const editorEl document.querySelector(#editor .ql-editor)editorEl.focus()// 创建 ClipboardEvent DataTransferconst dt new DataTransfer()dt.setData(text/html, htmlContent)dt.setData(text/plain, )const pasteEvent new ClipboardEvent(paste, {bubbles: true,cancelable: true,clipboardData: dt})editorEl.dispatchEvent(pasteEvent)return { success: true, method: simulated paste event }}, content)}**技术原理**- DataTransfer 对象模拟剪贴板数据- ClipboardEvent 触发 Quill 的原生粘贴处理器- 如果是Quill编辑器 正确解析 HTML 并保留格式包括列表、标题等### 3.7 封面图上传封面图上传是多步骤操作打开弹窗 → 选择本地上传 → 选择文件 → 确认。javascriptasync _uploadCover(coverImageUrl) {// 1. 下载图片到本地const localPath await this._downloadImage(coverImageUrl)// 2. 点击封面上传按钮打开弹窗const btn this.page.locator(.upload-file.mp-upload).first()await btn.click({ force: true })await this.page.waitForTimeout(2000)// 3. 点击本地上传标签const localUploadTab this.page.locator(text本地上传).first()await localUploadTab.click({ force: true })await this.page.waitForTimeout(1500)// 4. 监听 filechooser 事件点击上传区域触发const fileChooserPromise this.page.waitForEvent(filechooser, { timeout: 15000 })const uploadArea this.page.locator(label[fornew-file]).first()await uploadArea.click({ force: true })// 5. 设置文件const fileChooser await fileChooserPromiseawait fileChooser.setFiles(localPath)// 6. 等待上传完成检测已选择 1 张文本for (let i 0; i 10; i) {const hasImage await this.page.evaluate(() {return document.body.innerText.includes(已选择 1 张)})if (hasImage) breakawait this.page.waitForTimeout(1000)}// 7. 点击确定按钮const confirmClicked await this.page.evaluate(() {const boards document.querySelectorAll(.board[contentpictures])for (const board of boards) {const style board.getAttribute(style) || if (style.includes(display: none)) continueconst btn board.querySelector(.positive-button)if (btn !btn.classList.contains(disable-button)) {btn.click()return true}}return null})// 清理临时文件fs.unlinkSync(localPath)}**DOM 选择器说明**- .upload-file.mp-upload 封面上传入口按钮- text本地上传 弹窗内本地上传标签- label[fornew-file] 弹窗内上传图片区域- .board[contentpictures] .positive-button 弹窗内确定按钮### 3.8 配图位置保留上传这是最复杂的部分需要确保配图插入到文章正确位置。javascriptasync _uploadImagesAtPlaceholders(images) {for (const img of images) {// 1. 下载图片到本地const localPath await this._downloadImage(img.url)const placeholderText [IMG_PH_${img.index}]// 2. 在编辑器中定位占位符将光标放到占位符位置const positioned await this.page.evaluate((phText) {const editor document.querySelector(#editor .ql-editor)const paragraphs editor.querySelectorAll(p)for (const p of paragraphs) {if (p.textContent.includes(phText)) {editor.focus()const range document.createRange()range.selectNodeContents(p)const sel window.getSelection()sel.removeAllRanges()sel.addRange(range)return true}}return false}, placeholderText)if (!positioned) continue// 3. 再次定位光标点击工具栏后会失焦await this.page.evaluate((phText) {const editor document.querySelector(#editor .ql-editor)const paragraphs editor.querySelectorAll(p)for (const p of paragraphs) {if (p.textContent.includes(phText)) {const range document.createRange()range.selectNodeContents(p)const sel window.getSelection()sel.removeAllRanges()sel.addRange(range)break}}}, placeholderText)// 4. 点击 点击编辑如Quill 工具栏图片按钮const imgBtn this.page.locator(.ql-image).first()await imgBtn.click({ force: true })await this.page.waitForTimeout(2000)// 5. 点击弹窗内上传区域触发 filechooserconst fileChooserPromise this.page.waitForEvent(filechooser, { timeout: 15000 })const uploadArea this.page.locator(label[fornew-file]).first()await uploadArea.click({ force: true })// 6. 设置文件const fileChooser await fileChooserPromiseawait fileChooser.setFiles(localPath)await this.page.waitForTimeout(3000)// 7. 点击弹窗确定按钮const confirmBtn this.page.locator(p.positive-button:not(.disable-button):visible).first()await confirmBtn.click({ force: true })await this.page.waitForTimeout(2000)// 8. 删除占位符文本await this.page.evaluate((phText) {const editor document.querySelector(#editor .ql-editor)const paragraphs editor.querySelectorAll(p)for (const p of paragraphs) {if (p.textContent.includes(phText)) {p.remove()editor.dispatchEvent(new Event(input, { bubbles: true }))break}}}, placeholderText)// 清理临时文件fs.unlinkSync(localPath)}}**技术要点**- 使用 Range Selection API 精确控制光标位置- 点击工具栏按钮前需**重新定位光标**点击会导致 iframe 失焦- Quill 的 .ql-image 按钮点击后弹出上传窗口需二次点击触发 filechooser### 3.9图片下载工具方法javascriptasync _downloadImage(url) {const https require(https)const http require(http)const os require(os)return new Promise((resolve, reject) {const tempDir os.tmpdir()const tempFile path.join(tempDir, sohu_image_${Date.now()}.jpg)const protocol url.startsWith(https) ? https : httpconst file fs.createWriteStream(tempFile)protocol.get(url, (response) {if (response.statusCode ! 200) {reject(new Error(下载失败: HTTP ${response.statusCode}))return}response.pipe(file)file.on(finish, () { file.close(() resolve(tempFile)) })}).on(error, (err) {fs.unlink(tempFile, () {})reject(err)})})}## 四、关键 DOM 选择器速查表| 业务语义 | 选择器 | 说明 ||---------|--------|------|| 发布内容菜单 | #menu-ic_publish | 左侧导航栏入口 || 标题输入框 | .publish-title input | placeholder: 5-72字 || 正文编辑器 | #editor .ql-editor | Quill 编辑器 || 封面上传入口 | .upload-file.mp-upload | 封面区域上传按钮 || 本地上传标签 | text本地上传 | 弹窗内 Tab || 上传图片区域 | label[fornew-file] | 触发 filechooser || 确定按钮 | .board[contentpictures] .positive-button | 封面图确认 || 工具栏图片按钮 | .ql-image | Quill 插入图片 |## 五、核心技术要点### 5.1 filechooser 事件处理Playwright 通过 waitForEvent(filechooser) 监听文件选择对话框javascript// 先注册监听再触发点击const fileChooserPromise this.page.waitForEvent(filechooser, { timeout: 15000 })await uploadButton.click() // 点击触发 filechooserconst fileChooser await fileChooserPromiseawait fileChooser.setFiles(localPath) // 设置文件### 5.2 编辑器内容注入有些编辑器直接设置 innerHTML 会丢失列表等格式使用模拟粘贴事件javascriptconst dt new DataTransfer()dt.setData(text/html, htmlContent)const pasteEvent new ClipboardEvent(paste, {bubbles: true,cancelable: true,clipboardData: dt})editorEl.dispatchEvent(pasteEvent)### 5.3 光标位置控制使用 Range Selection API 精确控制光标javascriptconst range document.createRange()range.selectNodeContents(targetElement) // 选中元素内容const sel window.getSelection()sel.removeAllRanges()sel.addRange(range)### 5.4 元素可见性判断通过 offsetParent 和 offsetHeight 判断元素是否可见javascriptif (btn.offsetParent ! null btn.offsetHeight 0) {// 元素可见可以点击}## 六、发布流程时序图用户触发发布│▼启动浏览器 注入Cookie│▼访问后台 → URL验证登录状态│▼点击发布内容菜单│▼导航到文章发布页 → URL验证│▼填写标题 (.publish-title input)│▼解析HTML → 提取图片 生成占位符│▼模拟粘贴注入正文ClipboardEvent│▼上传封面图弹窗多步骤操作│▼逐张上传配图到占位符位置│▼点击发布按钮## 七、注意事项1. **反检测**注入脚本隐藏 navigator.webdriver 等自动化特征2. **等待策略**使用 waitForTimeout 文本检测双重等待3. **URL验证**导航操作后通过 URL 验证目标页面4. **文件清理**上传完成后删除本地临时文件5. **占位符设计**使用纯文本标记避免被编辑器过滤