当前位置: 首页 > news >正文

自媒体账号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-features=AutomationControlled',

'--window-size=1280,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 发布主流程

```javascript

async 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?contentStatus=1', {

waitUntil: 'networkidle',

timeout: 30000

})

}

// 步骤4: 填写标题

await this._fillTitle(articleData.title)

// 步骤5: 解析HTML提取配图,注入正文(带占位符)

const baseUrl = 'https://geo.huikefu.cn'

const { 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` 作为标题输入框。

```javascript

async _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 = 0

const textHtml = html.replace(/<img[^>]+src=["']([^"']+)["'][^>]*\/?>/gi, (match, src) => {

index++

// 补全相对路径

let fullUrl = src

if (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` 注入会丢失部分内容(如列表)。采用模拟原生粘贴事件的方式,与手动 Ctrl+V 效果一致。

```javascript

async _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 + DataTransfer

const 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 封面图上传

封面图上传是多步骤操作:打开弹窗 → 选择本地上传 → 选择文件 → 确认。

```javascript

async _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[for="new-file"]').first()

await uploadArea.click({ force: true })

// 5. 设置文件

const fileChooser = await fileChooserPromise

await 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) break

await 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')) continue

const 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[for="new-file"]` = 弹窗内"上传图片"区域

- `.board[contentpictures] .positive-button` = 弹窗内"确定"按钮

### 3.8 配图位置保留上传

这是最复杂的部分,需要确保配图插入到文章正确位置。

```javascript

async _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. 点击弹窗内上传区域触发 filechooser

const fileChooserPromise = this.page.waitForEvent('filechooser', { timeout: 15000 })

const uploadArea = this.page.locator('label[for="new-file"]').first()

await uploadArea.click({ force: true })

// 6. 设置文件

const fileChooser = await fileChooserPromise

await 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图片下载工具方法

```javascript

async _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 : http

const 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[for="new-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() // 点击触发 filechooser

const fileChooser = await fileChooserPromise

await fileChooser.setFiles(localPath) // 设置文件

```

### 5.2 编辑器内容注入

有些编辑器直接设置 `innerHTML` 会丢失列表等格式,使用模拟粘贴事件:

```javascript

const 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 精确控制光标:

```javascript

const range = document.createRange()

range.selectNodeContents(targetElement) // 选中元素内容

const sel = window.getSelection()

sel.removeAllRanges()

sel.addRange(range)

```

### 5.4 元素可见性判断

通过 `offsetParent` 和 `offsetHeight` 判断元素是否可见:

```javascript

if (btn.offsetParent !== null && btn.offsetHeight > 0) {

// 元素可见,可以点击

}

```

## 六、发布流程时序图

```

用户触发发布

启动浏览器 + 注入Cookie

访问后台 → URL验证登录状态

点击"发布内容"菜单

导航到文章发布页 → URL验证

填写标题 (.publish-title input)

解析HTML → 提取图片 + 生成占位符

模拟粘贴注入正文(ClipboardEvent)

上传封面图(弹窗多步骤操作)

逐张上传配图到占位符位置

点击发布按钮

```

## 七、注意事项

1. **反检测**:注入脚本隐藏 `navigator.webdriver` 等自动化特征

2. **等待策略**:使用 `waitForTimeout` + 文本检测双重等待

3. **URL验证**:导航操作后通过 URL 验证目标页面

4. **文件清理**:上传完成后删除本地临时文件

5. **占位符设计**:使用纯文本标记避免被编辑器过滤

http://www.jsqmd.com/news/1004265/

相关文章:

  • 2026年合肥注册公司服务商怎么选?本地化财税机构能力解析与真实案例参考 - 优质品牌商家
  • 2026年安庆市黄金回收白银回收铂金回收彩金回收 地址联系大全+支持现场结算无套路 - 前途无量YY
  • 封神榜风格横版游戏源码:含角色选择、登录界面与基础场景管理(Cocos2d-x 2.x/3.x)
  • SpringBoot项目里调用老旧C# WebService接口,我是怎么用HttpClientBuilder一步步搞定的?
  • 自适应系统中的运行时伦理挑战与技术应对
  • 鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
  • 基于Osip的Windows SIP通信双工程示例:发送INVITE/REGISTER与接收响应一体化封装
  • 2026番禺区新造下水道疏通技术办案逻辑解析:居顺联疏通服务深耕本地厨卫下水疏通 - 居顺联家政疏通
  • Vue 3 中的事件监听问题及解决方案
  • 2026年杭州软考中级系统集成报名费用资料怎么确认?众智商学院官网400冯老师 - 众智商学院官方
  • HLS性能翻倍的秘密:深入解读`array_partition`、`pipeline`与`dataflow`三大优化指令(附Vitis HLS 2023.2实测数据)
  • 微信小程序蓝牙开发避坑实录:从连接失败到数据收发,我踩过的那些坑
  • ArcGIS地统计向导实战:用普通克里金法预测石家庄房价(附趋势剔除与Log变换技巧)
  • 【郴州同城黄金回收服务 | 鑫诚黄金回收】 - 润富黄金回收
  • 2026年射洪装修公司怎么选?从本地经验、材料体系到售后保障的多维度分析 - 优质品牌商家
  • 读UNIX传奇:历史与回忆01贝尔实验室
  • LLM工程落地五大关键技术闭环解析
  • 大功率工业吸尘器十大品牌2026排名,第一名实至名归 - 工业清洁测评社
  • 【郴州同城黄金回收服务 | 鑫盛鑫诚万金汇联合回收指南】 - 润富黄金回收
  • 科研绘图效率翻倍:用ArcGIS+AI组合拳,5分钟搞定论文地图的精修与排版
  • 告别版本兼容烦恼:用Python mikeio 1.x新版搞定ERA5风场转MIKE21 dfs2文件
  • 别再死记硬背了!用这个可视化工具,5分钟搞懂‘图序列’判定定理
  • 2026年安丘市黄金回收白银回收铂金回收彩金回收 地址联系大全+支持现场结算无套路 - 前途无量YY
  • 2026济南历下蒂芙尼回收|弄懂估价逻辑,出手首饰少花冤枉钱 - 逸程
  • 别再让3D模型拖慢你的网页了!Three.js + Blender纹理烘焙实战避坑指南
  • 新服务器买完 24 小时内要做什么?安全加固清单
  • 保姆级教程:从零搭建Scrcpy Server端调试环境(基于Android Studio与ADB)
  • 3步解锁NVIDIA显卡隐藏性能:Profile Inspector完全指南
  • 2026年安顺市黄金回收白银回收铂金回收彩金回收 地址联系大全+支持现场结算无套路 - 前途无量YY
  • 2026年洛阳SCMP供应链管理专家课程咨询怎么确认?众智商学院官网400和冯老师 - 众智商学院官方