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"}
|
{"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',
|
`access_token` VARCHAR(2000) comment 'Access Token',
|
||||||
`refresh_token` VARCHAR(500) comment 'Refresh Token',
|
`refresh_token` VARCHAR(500) comment 'Refresh Token',
|
||||||
`token_expires` VARCHAR(50) comment '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 '是否启用',
|
`enabled` VARCHAR(1) DEFAULT '1' comment '是否启用',
|
||||||
`created_at` VARCHAR(50) comment '创建时间'
|
`created_at` VARCHAR(50) comment '创建时间'
|
||||||
,primary key(id)
|
,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 datetime import datetime
|
||||||
|
|
||||||
from .platforms import PUBLISHERS, BROWSER_ONLY
|
from .platforms import PUBLISHERS, BROWSER_ONLY
|
||||||
|
from .browser_automation import execute as browser_execute
|
||||||
from .db import (get_pending_tasks, get_content, get_platform,
|
from .db import (get_pending_tasks, get_content, get_platform,
|
||||||
update_task_status, update_content_status, write_publish_log)
|
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):
|
async def publish_one(session, task, content, platform, sor):
|
||||||
"""发布单条任务到指定平台"""
|
"""发布单条任务到指定平台"""
|
||||||
platform_code = platform.get('platform_code', '')
|
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)
|
publisher = PUBLISHERS.get(platform_code)
|
||||||
if not publisher:
|
if not publisher:
|
||||||
return False, 'unsupported platform: ' + platform_code, ''
|
return False, 'unsupported platform: ' + platform_code, '', '', '', 0
|
||||||
|
|
||||||
if platform_code in BROWSER_ONLY:
|
|
||||||
return False, 'browser-only: ' + platform_code + ' has no public API, needs Playwright/Selenium', ''
|
|
||||||
|
|
||||||
if content['content_type'] not in publisher['content_types']:
|
if content['content_type'] not in publisher['content_types']:
|
||||||
return False, 'content type ' + content['content_type'] + ' not supported by ' + platform_code, ''
|
return False, 'content type ' + content['content_type'] + ' not supported by ' + platform_code, ''
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user