自媒体账号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. **占位符设计**:使用纯文本标记避免被编辑器过滤
