diff --git a/README.md b/README.md index 29c4822..e3ca5f8 100644 --- a/README.md +++ b/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` 中。 + ## 目录 ``` diff --git a/ddl/mysql.sql b/ddl/mysql.sql index 18006aa..353ab2d 100644 --- a/ddl/mysql.sql +++ b/ddl/mysql.sql @@ -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) diff --git a/publisher/browser_automation.py b/publisher/browser_automation.py new file mode 100644 index 0000000..1b5f876 --- /dev/null +++ b/publisher/browser_automation.py @@ -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 diff --git a/publisher/engine.py b/publisher/engine.py index 71705c2..d5adbd1 100644 --- a/publisher/engine.py +++ b/publisher/engine.py @@ -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, ''