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:
parent
622ff789f5
commit
8cd76eda1e
10
README.md
10
README.md
@ -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` 中。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
|
||||
@ -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)
|
||||
|
||||
188
publisher/browser_automation.py
Normal file
188
publisher/browser_automation.py
Normal 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
|
||||
@ -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, ''
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user