feat: browser automation for 6 no-API platforms

- Add browser_automation.py: HTTP→Headless Browser adapter
  - 6 platform default browser scripts (douyin/bilibili/kuaishou/xiaohongshu/toutiao/channels)
  - {{variable}} substitution for content fields
  - configurable browser_endpoint in extra_config
- engine.py: route browser-only platforms to browser_execute()
- platfoms.py: remove NotImplementedError stubs
This commit is contained in:
yumoqing 2026-08-04 08:39:34 +08:00
parent 622ff789f5
commit 8cd76eda1e
4 changed files with 210 additions and 5 deletions

View File

@ -50,6 +50,16 @@ Sage 框架模块 — 多平台内容一键发布。支持文章/视频/音频/
{"uid": "1234567890", "open_id": "abc123", "channel_id": "UCxxx"}
```
## 浏览器自动化
6 个无 API 平台(抖音/B站/快手/视频号/小红书/头条)通过 Headless Browser 服务实现。
配置 `publisher_platform.extra_config`:
```json
{"browser_endpoint": "http://localhost:3000/execute"}
```
协议: POST `{browser_endpoint}` body=`{actions: [...], variables: {...}}`
各平台默认操作脚本已内置在 `browser_automation.py` 中。
## 目录
```

View File

@ -15,7 +15,7 @@ CREATE TABLE publisher_platform
`access_token` VARCHAR(2000) comment 'Access Token',
`refresh_token` VARCHAR(500) comment 'Refresh Token',
`token_expires` VARCHAR(50) comment 'Token 过期时间',
`extra_config` TEXT comment '额外配置 JSON (内容格式限制/审核规则等)',
`extra_config` TEXT comment '额外配置 JSON: browser_endpoint, browser_script, uid, open_id 等',
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
`created_at` VARCHAR(50) comment '创建时间'
,primary key(id)

View File

@ -0,0 +1,188 @@
"""
浏览器自动化适配层 通过 HTTP 调用 Headless Browser 服务
支持的浏览器服务:
- browserless/chrome (self-hosted): https://docs.browserless.io/
- Playwright HTTP server: 自定义的 playwright 服务
- 任何兼容 {actions: [...], variables: {...}} 协议的 HTTP endpoint
协议:
POST {browser_endpoint} body: {actions: [...], variables: {...}}
返回: {status: "ok", result: {url: "...", id: "..."}} | {status: "error", message: "..."}
配置:
publisher_platform.extra_config:
{
"browser_endpoint": "http://localhost:3000/execute", // 浏览器服务地址
"browser_script": [...] // 操作步骤 (可选用平台默认脚本)
}
"""
import json, re
import aiohttp
# ═══════════════════════════════════════════
# 各平台默认浏览器操作脚本
# ═══════════════════════════════════════════
DEFAULT_SCRIPTS = {
'douyin': [
{'type': 'navigate', 'url': 'https://creator.douyin.com/'},
{'type': 'wait', 'selector': '.login-container', 'timeout': 15000},
{'type': 'click', 'selector': '[data-type="qrcode"]'},
{'type': 'wait', 'selector': '.upload-card', 'timeout': 60000},
{'type': 'click', 'selector': '.upload-card'},
{'type': 'upload', 'selector': 'input[type="file"]', 'value': '{{media_url}}'},
{'type': 'wait', 'selector': '.title-input input', 'timeout': 30000},
{'type': 'type', 'selector': '.title-input input', 'value': '{{title}}'},
{'type': 'click', 'selector': '.publish-btn'},
{'type': 'wait', 'selector': '.success-tip', 'timeout': 60000},
],
'bilibili': [
{'type': 'navigate', 'url': 'https://member.bilibili.com/platform/upload/video/frame'},
{'type': 'wait', 'selector': '.upload-area', 'timeout': 15000},
{'type': 'upload', 'selector': 'input[type="file"]', 'value': '{{media_url}}'},
{'type': 'wait', 'selector': '.title-input', 'timeout': 60000},
{'type': 'type', 'selector': '.title-input', 'value': '{{title}}'},
{'type': 'type', 'selector': '.desc-input', 'value': '{{body}}'},
{'type': 'type', 'selector': '.tag-input', 'value': '{{tags}}'},
{'type': 'click', 'selector': '.submit-btn'},
{'type': 'wait', 'selector': '.success-modal', 'timeout': 120000},
],
'kuaishou': [
{'type': 'navigate', 'url': 'https://cp.kuaishou.com/article/publish/video'},
{'type': 'wait', 'selector': '.upload-area', 'timeout': 15000},
{'type': 'upload', 'selector': 'input[type="file"]', 'value': '{{media_url}}'},
{'type': 'wait', 'selector': '.title-area textarea', 'timeout': 60000},
{'type': 'type', 'selector': '.title-area textarea', 'value': '{{title}}'},
{'type': 'click', 'selector': '.publish-btn'},
{'type': 'wait', 'selector': '.success-msg', 'timeout': 60000},
],
'xiaohongshu': [
{'type': 'navigate', 'url': 'https://creator.xiaohongshu.com/publish/publish'},
{'type': 'wait', 'selector': '.upload-btn', 'timeout': 15000},
{'type': 'click', 'selector': '.upload-btn'},
{'type': 'upload', 'selector': 'input[type="file"]', 'value': '{{media_url}}'},
{'type': 'wait', 'selector': '.title-input', 'timeout': 30000},
{'type': 'type', 'selector': '.title-input', 'value': '{{title}}'},
{'type': 'type', 'selector': '.content-editor', 'value': '{{body}}'},
{'type': 'click', 'selector': '.publish-btn'},
{'type': 'wait', 'selector': '.result-success', 'timeout': 60000},
],
'toutiao': [
{'type': 'navigate', 'url': 'https://mp.toutiao.com/profile_v4/graphic/publish'},
{'type': 'wait', 'selector': '.editor-title input', 'timeout': 15000},
{'type': 'type', 'selector': '.editor-title input', 'value': '{{title}}'},
{'type': 'type', 'selector': '.editor-content', 'value': '{{body}}'},
{'type': 'click', 'selector': '.publish-btn'},
{'type': 'wait', 'selector': '.success-tip', 'timeout': 30000},
],
'channels': [
{'type': 'navigate', 'url': 'https://channels.weixin.qq.com/platform/post/create'},
{'type': 'wait', 'selector': '.upload-area', 'timeout': 15000},
{'type': 'upload', 'selector': 'input[type="file"]', 'value': '{{media_url}}'},
{'type': 'wait', 'selector': '.title-editor', 'timeout': 60000},
{'type': 'type', 'selector': '.title-editor', 'value': '{{title}}'},
{'type': 'type', 'selector': '.desc-editor', 'value': '{{body}}'},
{'type': 'click', 'selector': '.post-btn'},
{'type': 'wait', 'selector': '.result-success', 'timeout': 120000},
],
}
async def execute(platform_config, content, sor=None):
"""
执行浏览器自动化发布
platform_config: publisher_platform 记录 (需含 extra_config.browser_endpoint)
content: publisher_content 记录 (title, body, media_urls, tags)
返回: (ok: bool, error: str, response_data: str, external_url: str)
"""
platform_code = platform_config.get('platform_code', '')
extra = {}
if platform_config.get('extra_config'):
try:
extra = json.loads(platform_config['extra_config'])
except json.JSONDecodeError:
pass
browser_endpoint = extra.get('browser_endpoint', '')
if not browser_endpoint:
return False, 'browser_endpoint not configured in extra_config', '', ''
# 获取操作脚本: extra_config 优先,否则用平台默认
script = extra.get('browser_script') or DEFAULT_SCRIPTS.get(platform_code, [])
if not script:
return False, f'no browser script for {platform_code}', '', ''
# 构建变量
media_urls = []
if content.get('media_urls'):
try:
media_urls = json.loads(content['media_urls'])
except json.JSONDecodeError:
pass
variables = {
'title': content.get('title', ''),
'body': content.get('body', ''),
'media_url': media_urls[0] if media_urls else '',
'media_urls': json.dumps(media_urls) if media_urls else '[]',
'tags': content.get('tags', ''),
'cover_url': content.get('cover_url', ''),
'username': extra.get('username', extra.get('account', '')),
'password': extra.get('password', ''),
}
# 替换脚本中的 {{variable}}
script_json = _substitute(script, variables)
payload = {
'actions': script_json,
'variables': variables,
'platform': platform_code,
'content_type': content.get('content_type', 'video'),
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
browser_endpoint,
json=payload,
headers={'Content-Type': 'application/json'},
timeout=aiohttp.ClientTimeout(total=300) # 浏览器操作可能很长
) as resp:
resp_text = await resp.text()
try:
data = json.loads(resp_text)
except json.JSONDecodeError:
data = {'raw': resp_text[:1000]}
if resp.status == 200 and data.get('status') == 'ok':
result = data.get('result', {})
ext_url = result.get('url', '')
return True, '', json.dumps(data, ensure_ascii=False)[:2000], ext_url
else:
err = data.get('message', data.get('error', f'HTTP {resp.status}'))
return False, err, resp_text[:2000], ''
except aiohttp.ClientError as e:
return False, f'browser service unreachable: {e}', '', ''
except Exception as e:
return False, f'browser automation error: {e}', '', ''
def _substitute(script, variables):
"""替换脚本中的 {{variable}} 占位符"""
result = []
for step in script:
new_step = {}
for k, v in step.items():
if isinstance(v, str):
new_step[k] = re.sub(
r'\{\{(\w+)\}\}',
lambda m: str(variables.get(m.group(1), '')),
v
)
else:
new_step[k] = v
result.append(new_step)
return result

View File

@ -6,6 +6,7 @@ import aiohttp
from datetime import datetime
from .platforms import PUBLISHERS, BROWSER_ONLY
from .browser_automation import execute as browser_execute
from .db import (get_pending_tasks, get_content, get_platform,
update_task_status, update_content_status, write_publish_log)
@ -16,12 +17,18 @@ def now_str():
async def publish_one(session, task, content, platform, sor):
"""发布单条任务到指定平台"""
platform_code = platform.get('platform_code', '')
# --- 浏览器自动化路径 ---
if platform_code in BROWSER_ONLY:
t0 = time.time()
ok, err, resp, ext_url = await browser_execute(platform, content, sor)
elapsed = int((time.time() - t0) * 1000)
return ok, err, resp, ext_url, '', elapsed
# --- API 路径 ---
publisher = PUBLISHERS.get(platform_code)
if not publisher:
return False, 'unsupported platform: ' + platform_code, ''
if platform_code in BROWSER_ONLY:
return False, 'browser-only: ' + platform_code + ' has no public API, needs Playwright/Selenium', ''
return False, 'unsupported platform: ' + platform_code, '', '', '', 0
if content['content_type'] not in publisher['content_types']:
return False, 'content type ' + content['content_type'] + ' not supported by ' + platform_code, ''