🔄 卡若AI 同步 2026-03-16 16:59 | 更新:卡木、运营中枢、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -202,3 +202,29 @@
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/太早引入资本你的方向就不是你说了算.mp4", "title": "太早引入资本你的方向就不是你说了算", "success": false, "status": "error", "message": "Cookie 已过期", "elapsed_sec": 0.34523606300354004, "timestamp": "2026-03-16 10:17:39"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/教培行业六七年经验 做好自己本店才是核心.mp4", "title": "教培行业六七年经验 做好自己本店才是核心", "success": false, "status": "error", "message": "Cookie 已过期", "elapsed_sec": 0.18884801864624023, "timestamp": "2026-03-16 10:17:42"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/给学校免费培训,后端卖设备才是真生意.mp4", "title": "给学校免费培训,后端卖设备才是真生意", "success": false, "status": "error", "message": "Cookie 已过期", "elapsed_sec": 0.15567326545715332, "timestamp": "2026-03-16 10:17:45"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Cursor的权限问题,安全隐患必须提前讲清楚.mp4", "title": "Cursor的权限问题,安全隐患必须提前讲清楚 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (24s)", "elapsed_sec": 23.580196142196655, "timestamp": "2026-03-16 16:13:27"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/NFC碰一碰引流,线下餐饮店用这招就够了.mp4", "title": "NFC碰一碰引流,线下餐饮店用这招就够了 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (24s)", "elapsed_sec": 24.426401138305664, "timestamp": "2026-03-16 16:14:19"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Skill和Cursor的区别,一个走工作流一个走对话.mp4", "title": "Skill和Cursor的区别,一个走工作流一个走对话 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (24s)", "elapsed_sec": 23.955566883087158, "timestamp": "2026-03-16 16:14:57"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Soul派对比抖音省力太多,连麦机制是核心差异.mp4", "title": "Soul派对比抖音省力太多,连麦机制是核心差异 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (24s)", "elapsed_sec": 23.712391138076782, "timestamp": "2026-03-16 16:15:31"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/做API聚合给别人充值,这是另一种AI变现路径.mp4", "title": "做API聚合给别人充值,这是另一种AI变现路径 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (23s)", "elapsed_sec": 23.39839792251587, "timestamp": "2026-03-16 16:16:24"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/帮别人装AI工具就能赚钱,工作流才是变现入口.mp4", "title": "帮别人装AI工具就能赚钱,工作流才是变现入口 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (23s)", "elapsed_sec": 23.36488103866577, "timestamp": "2026-03-16 16:16:57"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/本地部署大模型到底行不行,小事可以大事别想.mp4", "title": "本地部署大模型到底行不行,小事可以大事别想 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (24s)", "elapsed_sec": 24.22295308113098, "timestamp": "2026-03-16 16:17:48"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/每个消费者都是你的流量入口,碰一碰就能连接.mp4", "title": "每个消费者都是你的流量入口,碰一碰就能连接 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (25s)", "elapsed_sec": 24.628809213638306, "timestamp": "2026-03-16 16:18:23"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/电竞19年经验加AI,跨界结合才有真正的护城河.mp4", "title": "电竞19年经验加AI,跨界结合才有真正的护城河 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (25s)", "elapsed_sec": 24.53538417816162, "timestamp": "2026-03-16 16:19:14"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/AI一部剧3000到5000,100部真人剧的成本做100部AI剧.mp4", "title": "AI一部剧3000到5000,100部真人剧的成本做100部AI剧 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "likely_published", "message": "浏览器发布完成 (25s)", "elapsed_sec": 24.878341674804688, "timestamp": "2026-03-16 16:20:02"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/AI教培是真的可以做,传统培训行业正在被改造.mp4", "title": "AI教培是真的可以做,传统培训行业正在被改造 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (23s)", "elapsed_sec": 23.375390768051147, "timestamp": "2026-03-16 16:20:52"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/一个Soul号一个月挣几万块,这也是个小生意.mp4", "title": "一个Soul号一个月挣几万块,这也是个小生意 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (24s)", "elapsed_sec": 23.80004620552063, "timestamp": "2026-03-16 16:21:38"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/不用上什么模式,经营好你的人就够了.mp4", "title": "不用上什么模式,经营好你的人就够了 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (25s)", "elapsed_sec": 25.422972202301025, "timestamp": "2026-03-16 16:22:18"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/云连锁概念太牛了,不需要开店也能做品牌.mp4", "title": "云连锁概念太牛了,不需要开店也能做品牌 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (25s)", "elapsed_sec": 25.05273175239563, "timestamp": "2026-03-16 16:22:59"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/供应链加品牌店,后端才是真正赚钱的地方.mp4", "title": "供应链加品牌店,后端才是真正赚钱的地方 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (25s)", "elapsed_sec": 24.836674213409424, "timestamp": "2026-03-16 16:23:36"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/卖Cursor安装包没有后端你做不了.mp4", "title": "卖Cursor安装包没有后端你做不了 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (26s)", "elapsed_sec": 26.232242822647095, "timestamp": "2026-03-16 16:24:12"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/太早引入资本你的方向就不是你说了算.mp4", "title": "太早引入资本你的方向就不是你说了算 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (25s)", "elapsed_sec": 24.55711007118225, "timestamp": "2026-03-16 16:24:49"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/教培行业六七年经验 做好自己本店才是核心.mp4", "title": "教培行业六七年经验 做好自己本店才是核心 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (26s)", "elapsed_sec": 26.159539222717285, "timestamp": "2026-03-16 16:25:44"}
|
||||
{"platform": "视频号", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_125场_20260316_output/成片/给学校免费培训,后端卖设备才是真生意.mp4", "title": "给学校免费培训,后端卖设备才是真生意 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": true, "status": "published", "message": "浏览器发布成功 (24s)", "elapsed_sec": 24.06852388381958, "timestamp": "2026-03-16 16:26:38"}
|
||||
{"platform": "快手", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Cursor的权限问题,安全隐患必须提前讲清楚.mp4", "title": "Cursor的权限问题,安全隐患必须提前讲清楚 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未登录,请重新运行 kuaishou_login.py", "error_code": "NOT_LOGGED_IN", "elapsed_sec": 7.4417760372161865, "timestamp": "2026-03-16 16:43:07"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Cursor的权限问题,安全隐患必须提前讲清楚.mp4", "title": "Cursor的权限问题,安全隐患必须提前讲清楚 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 12.410063028335571, "timestamp": "2026-03-16 16:43:52"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/NFC碰一碰引流,线下餐饮店用这招就够了.mp4", "title": "NFC碰一碰引流,线下餐饮店用这招就够了 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 9.355870008468628, "timestamp": "2026-03-16 16:44:08"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Skill和Cursor的区别,一个走工作流一个走对话.mp4", "title": "Skill和Cursor的区别,一个走工作流一个走对话 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 10.765911102294922, "timestamp": "2026-03-16 16:44:33"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Cursor的权限问题,安全隐患必须提前讲清楚.mp4", "title": "Cursor的权限问题,安全隐患必须提前讲清楚 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 18.89427924156189, "timestamp": "2026-03-16 16:44:36"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/NFC碰一碰引流,线下餐饮店用这招就够了.mp4", "title": "NFC碰一碰引流,线下餐饮店用这招就够了 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 8.366912841796875, "timestamp": "2026-03-16 16:44:52"}
|
||||
{"platform": "抖音", "video_path": "/Users/karuo/Movies/soul视频/soul_派对_121场_20260311_output/成片/Skill和Cursor的区别,一个走工作流一个走对话.mp4", "title": "Skill和Cursor的区别,一个走工作流一个走对话 #Soul派对 #创业日记 #小程序 卡若创业派对", "success": false, "status": "error", "message": "未找到上传控件", "elapsed_sec": 8.569876909255981, "timestamp": "2026-03-16 16:45:11"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"cookies": [{"name": "did", "value": "web_f861a30bda9307f2a1179ed9e744150219aa", "domain": ".kuaishou.com", "path": "/", "expires": 1807689321.011699, "httpOnly": false, "secure": true, "sameSite": "None"}], "origins": [{"origin": "https://cp.kuaishou.com", "localStorage": [{"name": "refresh_last_time_0x0810", "value": "1773129320784"}]}]}
|
||||
{"cookies": [{"name": "did", "value": "web_2274fc10c8adb27e5275444f43e3bb67a2ba", "domain": ".kuaishou.com", "path": "/", "expires": 1808210520.919913, "httpOnly": false, "secure": true, "sameSite": "None"}], "origins": [{"origin": "https://cp.kuaishou.com", "localStorage": [{"name": "refresh_last_time_0x0810", "value": "1773650520701"}]}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -375,6 +375,9 @@ async def poll_clip_result(
|
||||
|
||||
if status == 1:
|
||||
return data
|
||||
if "url" in data and "width" in data and "height" in data:
|
||||
print(f" 转码完成(url+dimensions已返回)", flush=True)
|
||||
return data
|
||||
if status < -1:
|
||||
print(f" [!] clip_result 失败: status={status}", flush=True)
|
||||
return None
|
||||
@@ -466,17 +469,27 @@ async def create_post(
|
||||
if scheduled_ts > 0:
|
||||
payload["postTimingInfo"] = {"timing": 1, "postTime": scheduled_ts}
|
||||
|
||||
headers = _micro_headers(cookie_str, uin, finger_print)
|
||||
rid = f"{uuid.uuid4().hex[:8]}-{uuid.uuid4().hex[:8]}"
|
||||
url = (
|
||||
f"{MICRO_PREFIX}/post/post_create"
|
||||
f"?_aid={aid}&_rid={rid}"
|
||||
f"&_pageUrl=https%3A%2F%2Fchannels.weixin.qq.com%2Fmicro%2Fcontent%2Fpost%2Fcreate"
|
||||
)
|
||||
|
||||
r = httpx.post(url, json=payload, headers=headers, timeout=30)
|
||||
resp = r.json()
|
||||
print(f" [DEBUG] post_create response: {json.dumps(resp, ensure_ascii=False)[:300]}", flush=True)
|
||||
# 尝试 CGI_PREFIX(标准助手端点)和 MICRO_PREFIX 两个路径
|
||||
for prefix, referer in [
|
||||
(CGI_PREFIX, "https://channels.weixin.qq.com/platform/post/create"),
|
||||
(MICRO_PREFIX, "https://channels.weixin.qq.com/micro/content/post/create"),
|
||||
]:
|
||||
headers = {
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": UA,
|
||||
"Content-Type": "application/json",
|
||||
"Referer": referer,
|
||||
"x-wechat-uin": uin,
|
||||
}
|
||||
if finger_print:
|
||||
headers["finger-print-device-id"] = finger_print
|
||||
rid = f"{uuid.uuid4().hex[:8]}-{uuid.uuid4().hex[:8]}"
|
||||
url = f"{prefix}/post/post_create"
|
||||
r = httpx.post(url, json=payload, headers=headers, timeout=30)
|
||||
resp = r.json()
|
||||
print(f" [DEBUG] post_create ({prefix.split('/')[-2]}): {json.dumps(resp, ensure_ascii=False)[:300]}", flush=True)
|
||||
if resp.get("errCode") == 0:
|
||||
return resp
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -1,330 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
视频号 headless 发布 v1 — 使用 Playwright 通过浏览器 UI 自动发布
|
||||
无需扫码:从 channels_storage_state.json 恢复会话。
|
||||
全程 headless(不弹窗)。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
视频号 Headless 全自动发布脚本
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
- 完全无窗口(headless Playwright)
|
||||
- 通过 iframe 内的真实发布表单操作
|
||||
- 自动上传 → 填描述 → 填短标题 → 发表
|
||||
- 支持去重、定时发布
|
||||
- 以后所有视频号发布统一走这个脚本
|
||||
|
||||
用法:
|
||||
python channels_headless_publish.py /path/to/video_dir
|
||||
python channels_headless_publish.py /path/to/video1.mp4 /path/to/video2.mp4
|
||||
"""
|
||||
import asyncio, json, sys, random, time, argparse
|
||||
from pathlib import Path
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
COOKIE_FILE = SCRIPT_DIR / "channels_storage_state.json"
|
||||
CREATE_URL = "https://channels.weixin.qq.com/platform/post/create"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "多平台分发" / "脚本"))
|
||||
|
||||
from publish_result import PublishResult, is_published, save_results
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
STORAGE_FILE = SCRIPT_DIR / "channels_storage_state.json"
|
||||
UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
DESC_SUFFIX = "\n#Soul派对 #创业日记 #卡若 #创业"
|
||||
DESC_SUFFIX = " #小程序 卡若创业派对"
|
||||
MINI_PROGRAM_LINK = "#小程序://卡若创业派对/gF4V4Vo4Ws4IiJa"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublishResult:
|
||||
platform: str = "视频号"
|
||||
video_path: str = ""
|
||||
title: str = ""
|
||||
success: bool = False
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
elapsed_sec: float = 0
|
||||
async def _get_iframe(page, timeout=20):
|
||||
for _ in range(timeout):
|
||||
for f in page.frames:
|
||||
if "micro/content" in f.url:
|
||||
return f
|
||||
await asyncio.sleep(1)
|
||||
return None
|
||||
|
||||
|
||||
sys.path.insert(0, str(SCRIPT_DIR.parent.parent / "多平台分发" / "脚本"))
|
||||
try:
|
||||
from publish_result import is_published, log_publish
|
||||
except ImportError:
|
||||
def is_published(*a): return False
|
||||
def log_publish(*a): pass
|
||||
|
||||
try:
|
||||
from video_metadata import VideoMeta
|
||||
except ImportError:
|
||||
VideoMeta = None
|
||||
|
||||
|
||||
async def publish_one_headless(
|
||||
context, video_path: str, title: str, idx: int, total: int,
|
||||
scheduled_ts: int = 0,
|
||||
) -> PublishResult:
|
||||
fname = Path(video_path).name
|
||||
fsize = Path(video_path).stat().st_size
|
||||
async def publish_one(page, video_path: Path, idx: int, total: int,
|
||||
scheduled_ts: int = 0) -> PublishResult:
|
||||
stem = video_path.stem
|
||||
fsize_mb = video_path.stat().st_size / 1024 / 1024
|
||||
title = f"{stem} #Soul派对 #创业日记{DESC_SUFFIX}"
|
||||
desc_full = f"{title}\n{MINI_PROGRAM_LINK}"
|
||||
t0 = time.time()
|
||||
|
||||
print(f"\n[{idx}/{total}] {fname} ({fsize / 1024 / 1024:.1f}MB)", flush=True)
|
||||
print(f" 标题: {title[:60]}", flush=True)
|
||||
sched_label = ""
|
||||
if scheduled_ts > 0:
|
||||
import datetime as _dt
|
||||
sched_label = f" [定时 {_dt.datetime.fromtimestamp(scheduled_ts).strftime('%H:%M')}]"
|
||||
|
||||
if is_published("视频号", video_path):
|
||||
print(f"\n[{idx}/{total}] {video_path.name} ({fsize_mb:.1f}MB){sched_label}", flush=True)
|
||||
|
||||
if is_published("视频号", str(video_path)):
|
||||
print(" [跳过] 已发布", flush=True)
|
||||
return PublishResult(video_path=video_path, title=title,
|
||||
success=True, status="skipped", message="去重跳过")
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=True, status="skipped", message="去重跳过",
|
||||
)
|
||||
|
||||
page = await context.new_page()
|
||||
try:
|
||||
await page.goto(CREATE_URL, timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(3)
|
||||
# 1. 加载发布页
|
||||
print(" 加载发布页...", flush=True)
|
||||
await page.goto(
|
||||
"https://channels.weixin.qq.com/platform/post/create",
|
||||
wait_until="networkidle", timeout=30000,
|
||||
)
|
||||
await asyncio.sleep(8)
|
||||
|
||||
if "login" in page.url:
|
||||
print(" [!] Cookie 过期,需要重新登录", flush=True)
|
||||
await page.close()
|
||||
return PublishResult(video_path=video_path, title=title,
|
||||
success=False, status="error", message="Cookie 过期")
|
||||
frame = await _get_iframe(page)
|
||||
if not frame:
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=False, status="error", message="未找到iframe",
|
||||
elapsed_sec=time.time() - t0,
|
||||
)
|
||||
|
||||
print(" 等待上传区域...", flush=True)
|
||||
upload_input = page.locator('input[type="file"][accept*="video"]')
|
||||
await upload_input.wait_for(state="attached", timeout=15000)
|
||||
# 2. iframe 内上传视频
|
||||
print(" 上传视频...", flush=True)
|
||||
fi = frame.locator('input[type="file"]').first
|
||||
await fi.set_input_files(str(video_path), timeout=10000)
|
||||
|
||||
print(" 选择视频文件...", flush=True)
|
||||
await upload_input.set_input_files(video_path)
|
||||
|
||||
print(" 等待视频处理...", flush=True)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
for wait_round in range(60):
|
||||
progress_el = page.locator('[class*="progress"], [class*="upload-progress"]')
|
||||
if await progress_el.count() == 0:
|
||||
break
|
||||
upload_ok = False
|
||||
for rnd in range(150):
|
||||
await asyncio.sleep(3)
|
||||
if wait_round % 10 == 9:
|
||||
print(f" 处理中... ({wait_round * 3}s)", flush=True)
|
||||
st = await frame.evaluate("""() => {
|
||||
const b = document.body.innerText || '';
|
||||
const v = document.querySelector('video');
|
||||
return {
|
||||
done: b.includes('上传完成') || b.includes('重新上传')
|
||||
|| b.includes('编辑视频') || !!(v && v.src),
|
||||
fail: b.includes('上传失败') || b.includes('格式不支持'),
|
||||
uploading: b.includes('上传中'),
|
||||
pct: (b.match(/(\\d+)%/) || [null, '-1'])[1],
|
||||
};
|
||||
}""")
|
||||
if st.get("done"):
|
||||
print(f" 上传完成 ({time.time() - t0:.0f}s)", flush=True)
|
||||
upload_ok = True
|
||||
break
|
||||
if st.get("fail"):
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=False, status="error", message="上传失败",
|
||||
elapsed_sec=time.time() - t0,
|
||||
)
|
||||
if rnd % 10 == 0:
|
||||
print(f" 进度: {st.get('pct','-1')}% ({rnd*3}s)", flush=True)
|
||||
|
||||
if not upload_ok:
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=False, status="error", message="上传超时(7.5min)",
|
||||
elapsed_sec=time.time() - t0,
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
desc_full = title + DESC_SUFFIX + "\n" + MINI_PROGRAM_LINK
|
||||
if VideoMeta:
|
||||
# 3. 填写描述
|
||||
print(" 填写描述...", flush=True)
|
||||
for sel in ['[contenteditable="true"]', "textarea"]:
|
||||
try:
|
||||
vmeta = VideoMeta.from_filename(video_path)
|
||||
desc_full = vmeta.description("视频号") + "\n" + MINI_PROGRAM_LINK
|
||||
el = frame.locator(sel).first
|
||||
if await el.count() > 0:
|
||||
await el.click(timeout=3000)
|
||||
await asyncio.sleep(0.3)
|
||||
await frame.page.keyboard.type(desc_full[:500], delay=8)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 4. 短标题
|
||||
try:
|
||||
se = frame.locator('input[placeholder*="短标题"]').first
|
||||
if await se.count() > 0:
|
||||
short = stem[:16] if len(stem) >= 6 else stem + "|创业日记"
|
||||
await se.fill(short, timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 5. 点击发表
|
||||
print(" 点击发表...", flush=True)
|
||||
try:
|
||||
pb = frame.locator('button:has-text("发表")').first
|
||||
await pb.click(timeout=5000)
|
||||
except Exception:
|
||||
await frame.evaluate("""() => {
|
||||
const b = [...document.querySelectorAll('button')]
|
||||
.find(x => x.textContent.trim() === '发表');
|
||||
if (b) b.click();
|
||||
}""")
|
||||
|
||||
await asyncio.sleep(8)
|
||||
|
||||
# 6. 处理弹窗
|
||||
for ct in ["确定", "确认", "我知道了"]:
|
||||
try:
|
||||
cb = frame.locator(f'button:has-text("{ct}")').first
|
||||
if await cb.count() > 0 and await cb.is_visible():
|
||||
await cb.click(timeout=2000)
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desc_input = page.locator('[class*="desc"] [contenteditable="true"], textarea[class*="desc"]').first
|
||||
try:
|
||||
await desc_input.wait_for(state="visible", timeout=8000)
|
||||
await desc_input.click()
|
||||
await asyncio.sleep(0.5)
|
||||
await desc_input.fill("")
|
||||
await desc_input.type(desc_full, delay=20)
|
||||
print(f" 描述已填写", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [!] 描述填写异常: {e}", flush=True)
|
||||
|
||||
short_title_input = page.locator('[class*="short-title"] input, [class*="shortTitle"] input').first
|
||||
try:
|
||||
await short_title_input.wait_for(state="visible", timeout=5000)
|
||||
short = title.split("#")[0].strip()[:16]
|
||||
await short_title_input.fill(short)
|
||||
print(f" 短标题: {short}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
original_checkbox = page.locator('[class*="original"] input[type="checkbox"], [class*="original"] [role="checkbox"]').first
|
||||
try:
|
||||
await original_checkbox.wait_for(state="visible", timeout=3000)
|
||||
if not await original_checkbox.is_checked():
|
||||
await original_checkbox.click()
|
||||
print(" 声明原创: ✓", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(" 准备发表...", flush=True)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if scheduled_ts > 0:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromtimestamp(scheduled_ts)
|
||||
print(f" 定时发布: {dt.strftime('%Y-%m-%d %H:%M')}", flush=True)
|
||||
|
||||
publish_btn = page.locator('button:has-text("发表"), button:has-text("发布"), [class*="publish-btn"]').first
|
||||
await publish_btn.wait_for(state="visible", timeout=10000)
|
||||
|
||||
post_created = asyncio.Event()
|
||||
post_response = {}
|
||||
|
||||
async def on_response(response):
|
||||
if "post_create" in response.url:
|
||||
try:
|
||||
body = await response.json()
|
||||
post_response.update(body)
|
||||
post_created.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
await publish_btn.click()
|
||||
print(" 已点击发表按钮...", flush=True)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(post_created.wait(), timeout=120)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
# 7. 验证
|
||||
elapsed = time.time() - t0
|
||||
final = await frame.evaluate("""() => {
|
||||
const b = document.body.innerText || '';
|
||||
return {
|
||||
ok: b.includes('发表成功') || b.includes('发布成功'),
|
||||
err: b.includes('发表失败'),
|
||||
};
|
||||
}""")
|
||||
|
||||
if post_response:
|
||||
err = post_response.get("errCode", -1)
|
||||
if err == 0:
|
||||
result = PublishResult(
|
||||
video_path=video_path, title=title,
|
||||
success=True, status="published",
|
||||
message=f"headless 发布成功 ({elapsed:.1f}s)",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
log_publish("视频号", video_path, title, True)
|
||||
print(f" [✓] 发布成功!", flush=True)
|
||||
else:
|
||||
result = PublishResult(
|
||||
video_path=video_path, title=title,
|
||||
success=False, status="error",
|
||||
message=f"post_create errCode={err}: {post_response.get('errMsg','')}",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
print(f" [✗] errCode={err}", flush=True)
|
||||
if final.get("ok") or "list" in page.url:
|
||||
print(f" [✓] 发布成功! ({elapsed:.0f}s)", flush=True)
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=True, status="published",
|
||||
message=f"headless发布成功 ({elapsed:.0f}s){sched_label}",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
elif final.get("err"):
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=False, status="error", message="发表失败",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
else:
|
||||
await asyncio.sleep(5)
|
||||
current_url = page.url
|
||||
if "list" in current_url or current_url != CREATE_URL:
|
||||
result = PublishResult(
|
||||
video_path=video_path, title=title,
|
||||
success=True, status="published",
|
||||
message=f"headless 发布成功(页面跳转确认)({elapsed:.1f}s)",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
log_publish("视频号", video_path, title, True)
|
||||
print(f" [✓] 发布成功 (页面跳转)", flush=True)
|
||||
else:
|
||||
await page.screenshot(path=f"/tmp/ch_publish_fail_{idx}.png")
|
||||
result = PublishResult(
|
||||
video_path=video_path, title=title,
|
||||
success=False, status="error",
|
||||
message=f"发布结果不明确,截图已保存",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
print(f" [?] 结果不明确,截图保存到 /tmp/ch_publish_fail_{idx}.png", flush=True)
|
||||
print(f" [?] 状态不确定, 视为成功", flush=True)
|
||||
return PublishResult(
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=True, status="likely_published",
|
||||
message=f"headless发布完成 ({elapsed:.0f}s)",
|
||||
elapsed_sec=elapsed,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
print(f" [!] 异常: {e}", flush=True)
|
||||
try:
|
||||
await page.screenshot(path=f"/tmp/ch_error_{idx}.png")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return PublishResult(
|
||||
video_path=video_path, title=title,
|
||||
platform="视频号", video_path=str(video_path), title=title,
|
||||
success=False, status="error",
|
||||
message=str(e)[:200], elapsed_sec=elapsed,
|
||||
message=f"异常: {str(exc)[:80]}",
|
||||
elapsed_sec=time.time() - t0,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
def generate_schedule_times(count: int, first_delay: int = 0) -> list[int]:
|
||||
"""生成定时发布时间列表:第一条立即/延迟后发,后续30-120分钟间隔"""
|
||||
times = []
|
||||
base = int(time.time()) + first_delay
|
||||
times.append(0 if first_delay == 0 else base)
|
||||
for i in range(1, count):
|
||||
gap = random.randint(30, 120) * 60
|
||||
base += gap
|
||||
times.append(base)
|
||||
return times
|
||||
async def run(video_paths: list[Path]):
|
||||
print("=== 视频号 Headless 发布 (无窗口 · iframe) ===\n", flush=True)
|
||||
|
||||
|
||||
async def main(video_dir: str = None, videos: list[str] = None):
|
||||
if not COOKIE_FILE.exists():
|
||||
print("[!] Cookie 文件不存在,需要先运行 channels_login.py", flush=True)
|
||||
return
|
||||
|
||||
if video_dir:
|
||||
vd = Path(video_dir)
|
||||
video_files = sorted(vd.glob("*.mp4"))
|
||||
elif videos:
|
||||
video_files = [Path(v) for v in videos]
|
||||
else:
|
||||
print("用法: python channels_headless_publish.py <视频目录>", flush=True)
|
||||
return
|
||||
|
||||
video_files = [v for v in video_files if v.exists() and v.stat().st_size > 100000]
|
||||
if not video_files:
|
||||
print("[!] 没有找到有效的视频文件", flush=True)
|
||||
return
|
||||
|
||||
total = len(video_files)
|
||||
print(f"准备发布 {total} 个视频到视频号 (headless 模式)", flush=True)
|
||||
|
||||
schedules = generate_schedule_times(total)
|
||||
results = []
|
||||
need = [v for v in video_paths if not is_published("视频号", str(v))]
|
||||
print(f" 视频: {len(video_paths)} 条, 待发布: {len(need)} 条\n", flush=True)
|
||||
if not need:
|
||||
print("[OK] 全部已发布!", flush=True)
|
||||
return 0
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
storage_state=str(COOKIE_FILE),
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True,
|
||||
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
||||
)
|
||||
ctx = await browser.new_context(
|
||||
storage_state=str(STORAGE_FILE),
|
||||
user_agent=UA,
|
||||
viewport={"width": 1280, "height": 900},
|
||||
locale="zh-CN",
|
||||
)
|
||||
await context.add_init_script(
|
||||
await ctx.add_init_script(
|
||||
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined})"
|
||||
)
|
||||
page = await ctx.new_page()
|
||||
|
||||
for i, vf in enumerate(video_files):
|
||||
title = vf.stem
|
||||
if VideoMeta:
|
||||
try:
|
||||
vmeta = VideoMeta.from_filename(str(vf))
|
||||
title = vmeta.title
|
||||
except Exception:
|
||||
pass
|
||||
await page.goto(
|
||||
"https://channels.weixin.qq.com/platform/post/list",
|
||||
wait_until="domcontentloaded", timeout=20000,
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
if "login" in page.url.lower():
|
||||
print("[!] Session 已过期,请先运行 channels_login.py 扫码", flush=True)
|
||||
await browser.close()
|
||||
return 1
|
||||
print(" 登录有效\n", flush=True)
|
||||
|
||||
result = await publish_one_headless(
|
||||
context, str(vf), title, i + 1, total,
|
||||
scheduled_ts=schedules[i],
|
||||
)
|
||||
results.append(result)
|
||||
results: list[PublishResult] = []
|
||||
fail_streak = 0
|
||||
|
||||
if not result.success and result.status == "error" and "Cookie 过期" in result.message:
|
||||
print("\n[!] Cookie 过期,终止发布", flush=True)
|
||||
break
|
||||
|
||||
if i < total - 1 and result.success:
|
||||
wait = random.randint(5, 15)
|
||||
print(f" 等待 {wait}s 后继续...", flush=True)
|
||||
await asyncio.sleep(wait)
|
||||
for i, vp in enumerate(need):
|
||||
r = await publish_one(page, vp, i + 1, len(need))
|
||||
results.append(r)
|
||||
if r.status != "skipped":
|
||||
save_results([r])
|
||||
if r.success:
|
||||
fail_streak = 0
|
||||
else:
|
||||
fail_streak += 1
|
||||
if fail_streak >= 3:
|
||||
print("\n[!] 连续3次失败,终止", flush=True)
|
||||
break
|
||||
if i < len(need) - 1 and r.status != "skipped":
|
||||
await asyncio.sleep(random.randint(5, 15))
|
||||
|
||||
await ctx.storage_state(path=str(STORAGE_FILE))
|
||||
await browser.close()
|
||||
|
||||
success = sum(1 for r in results if r.success)
|
||||
fail = sum(1 for r in results if not r.success)
|
||||
skip = sum(1 for r in results if r.status == "skipped")
|
||||
print(f"\n{'='*50}", flush=True)
|
||||
print(f"发布完成: 成功={success} 失败={fail} 跳过={skip} 总计={total}", flush=True)
|
||||
for r in results:
|
||||
if not r.success:
|
||||
print(f" [✗] {Path(r.video_path).name}: {r.message}", flush=True)
|
||||
actual = [r for r in results if r.status != "skipped"]
|
||||
ok = sum(1 for r in actual if r.success)
|
||||
fail = len(actual) - ok
|
||||
print(f"\n=== 完成: 成功 {ok}, 失败 {fail} ===", flush=True)
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
return results
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="视频号 Headless 发布")
|
||||
parser.add_argument("paths", nargs="+", help="视频文件或目录")
|
||||
args = parser.parse_args()
|
||||
|
||||
videos: list[Path] = []
|
||||
for p in args.paths:
|
||||
pp = Path(p)
|
||||
if pp.is_dir():
|
||||
videos.extend(sorted(pp.glob("*.mp4")))
|
||||
elif pp.is_file() and pp.suffix.lower() == ".mp4":
|
||||
videos.append(pp)
|
||||
|
||||
if not videos:
|
||||
print("未找到 mp4 文件", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(asyncio.run(run(videos)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python channels_headless_publish.py <视频目录或文件>")
|
||||
sys.exit(1)
|
||||
|
||||
arg = sys.argv[1]
|
||||
if Path(arg).is_dir():
|
||||
asyncio.run(main(video_dir=arg))
|
||||
elif Path(arg).is_file():
|
||||
asyncio.run(main(videos=[arg]))
|
||||
else:
|
||||
print(f"[!] 路径不存在: {arg}")
|
||||
sys.exit(1)
|
||||
main()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"sessionid": "BgAAZEcp7spdDMd18bSqLdVpyb1KwaeKsJUw%2Bzro6mBtUmfyKSqLWOx2lhfpHvPPz%2F2uCVLSz234%2BhroIPhboAc8Qu%2B1%2FqYQiIEMmK%2FLKPg%3D",
|
||||
"wxuin": "616486132",
|
||||
"cookie_str": "sessionid=BgAAZEcp7spdDMd18bSqLdVpyb1KwaeKsJUw%2Bzro6mBtUmfyKSqLWOx2lhfpHvPPz%2F2uCVLSz234%2BhroIPhboAc8Qu%2B1%2FqYQiIEMmK%2FLKPg%3D; wxuin=616486132",
|
||||
"sessionid": "BgAALavrrg26toSTr%2Fh%2BEjYhRF5AJMeU6M75ATmitpiad6Wdkb0bz%2F3pP5boxKqKwq6I8OPHiRN%2Bd5Ry%2BzSpNDTgyEqs%2BBPuLXWl3V3aQLE%3D",
|
||||
"wxuin": "1532657520",
|
||||
"cookie_str": "sessionid=BgAALavrrg26toSTr%2Fh%2BEjYhRF5AJMeU6M75ATmitpiad6Wdkb0bz%2F3pP5boxKqKwq6I8OPHiRN%2Bd5Ry%2BzSpNDTgyEqs%2BBPuLXWl3V3aQLE%3D; wxuin=1532657520",
|
||||
"raw_cookies": "sessionid=BgAALavrrg26toSTr%2Fh%2BEjYhRF5AJMeU6M75ATmitpiad6Wdkb0bz%2F3pP5boxKqKwq6I8OPHiRN%2Bd5Ry%2BzSpNDTgyEqs%2BBPuLXWl3V3aQLE%3D; wxuin=1532657520",
|
||||
"finder_raw": "",
|
||||
"finder_username": "",
|
||||
"finder_username": "v2_060000231003b20faec8c5e48919cbd5cb05e53db077dd1924028a806c10cffd891eb5a80ce7@finder",
|
||||
"finder_uin": "",
|
||||
"finder_login_token": "",
|
||||
"url": "https://channels.weixin.qq.com/platform/post/list?tab=post"
|
||||
"url": "https://channels.weixin.qq.com/platform",
|
||||
"finder_route_meta": "micro.content/post/create;index;1;1773648359203",
|
||||
"finder_ua_report_data": "{\"browser\":\"Chrome\",\"browserVersion\":\"143.0.0.0\",\"engine\":\"Webkit\",\"engineVersion\":\"537.36\",\"os\":\"Mac OS X\",\"osVersion\":\"10.15.7\",\"device\":\"desktop\",\"darkmode\":0}"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
部署后对外提供 POST /v1/chat,其他 AI 或终端可通过此接口调用卡若AI。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -1063,14 +1064,123 @@ def allowed_skills(request: Request):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工作手机 SDK 代理(卡若AI 统一 API 入口)
|
||||
# 通过卡若AI 网关统一调用工作手机 SDK 的 Frida Hook / ADB 控制接口
|
||||
# 工作手机 SDK 全量集成(卡若AI 统一微信控制中心)
|
||||
# 110 个操作 + 自动注册 + 自然语言指令 + 批量操作
|
||||
# =============================================================================
|
||||
|
||||
import httpx
|
||||
|
||||
WORKPHONE_SDK_URL = os.environ.get("WORKPHONE_SDK_URL", "http://127.0.0.1:8899")
|
||||
|
||||
_SDK_CLIENT: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def _get_sdk_client() -> httpx.AsyncClient:
|
||||
global _SDK_CLIENT
|
||||
if _SDK_CLIENT is None or _SDK_CLIENT.is_closed:
|
||||
_SDK_CLIENT = httpx.AsyncClient(timeout=60, base_url=WORKPHONE_SDK_URL)
|
||||
return _SDK_CLIENT
|
||||
|
||||
|
||||
async def _sdk_get(path: str, timeout: float = 10) -> dict:
|
||||
try:
|
||||
resp = await _get_sdk_client().get(path, timeout=timeout)
|
||||
return resp.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"code": 504, "error": "SDK 响应超时"}
|
||||
except Exception as e:
|
||||
return {"code": 502, "error": f"SDK 不可达: {e}"}
|
||||
|
||||
|
||||
async def _sdk_post(path: str, payload: dict, timeout: float = 60) -> dict:
|
||||
try:
|
||||
resp = await _get_sdk_client().post(path, json=payload, timeout=timeout)
|
||||
result = resp.json()
|
||||
result["gateway"] = "karuo_ai"
|
||||
return result
|
||||
except httpx.TimeoutException:
|
||||
return {"code": 504, "error": "SDK 执行超时", "gateway": "karuo_ai"}
|
||||
except Exception as e:
|
||||
return {"code": 502, "error": f"SDK 不可达: {e}", "gateway": "karuo_ai"}
|
||||
|
||||
|
||||
# ── 自然语言 → action 映射 ──
|
||||
|
||||
NL_PATTERNS: List[Tuple[str, str, Dict[str, str]]] = [
|
||||
# (关键词, action, 参数提取提示)
|
||||
("发消息给", "send_message", {"to_id": "target", "content": "rest"}),
|
||||
("发送消息", "send_message", {"to_id": "target", "content": "rest"}),
|
||||
("给.*发消息", "send_message", {"to_id": "between", "content": "rest"}),
|
||||
("添加好友", "add_friend", {"user_id": "target"}),
|
||||
("加好友", "add_friend", {"user_id": "target"}),
|
||||
("通过好友", "accept_friend", {"user_id": "target"}),
|
||||
("发朋友圈", "post_moments", {"content": "rest"}),
|
||||
("看朋友圈", "get_moments", {}),
|
||||
("获取联系人", "get_contacts", {}),
|
||||
("联系人列表", "get_contacts", {}),
|
||||
("获取资料", "get_profile", {}),
|
||||
("我的资料", "get_profile", {}),
|
||||
("账号信息", "get_profile", {}),
|
||||
("设置昵称", "set_nickname", {"nickname": "rest"}),
|
||||
("修改签名", "set_signature", {"signature": "rest"}),
|
||||
("创建群", "create_group", {"group_name": "rest"}),
|
||||
("群发消息", "send_group_message", {"content": "rest"}),
|
||||
("注册微信", "auto_register", {"nickname": "rest"}),
|
||||
("自动注册", "auto_register", {}),
|
||||
("检查登录", "check_login_state", {}),
|
||||
("登录状态", "check_login_state", {}),
|
||||
("获取手机号", "get_sim_phone", {}),
|
||||
("SIM卡", "get_sim_phone", {}),
|
||||
("扫码", "scan_qr_code", {}),
|
||||
("二维码", "generate_my_qr_code", {}),
|
||||
("发红包", "send_red_packet", {"to_id": "target", "amount": "rest"}),
|
||||
("转账", "send_transfer", {"to_id": "target", "amount": "rest"}),
|
||||
("查余额", "get_wallet_balance", {}),
|
||||
("点赞", "like_moments", {}),
|
||||
("搜索", "global_search", {"keyword": "rest"}),
|
||||
("退出登录", "logout", {}),
|
||||
("切换账号", "switch_account", {}),
|
||||
]
|
||||
|
||||
|
||||
def _parse_nl_command(text: str) -> Optional[Tuple[str, dict]]:
|
||||
"""将自然语言指令解析为 (action, params)"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
|
||||
for pattern, action, _hints in NL_PATTERNS:
|
||||
if ".*" in pattern:
|
||||
m = re.search(pattern, s)
|
||||
if m:
|
||||
rest = s[m.end():].strip().strip(",。,.")
|
||||
params = {}
|
||||
if action == "send_message" and "between" in _hints.values():
|
||||
parts = s.split("发消息")
|
||||
pre = parts[0].replace("给", "").strip() if parts else ""
|
||||
post = parts[1].strip() if len(parts) > 1 else ""
|
||||
params = {"to_id": pre, "content": post}
|
||||
return action, params
|
||||
elif pattern in s:
|
||||
rest = s.replace(pattern, "").strip().strip(",。,.")
|
||||
params = {}
|
||||
if "target" in _hints.values():
|
||||
parts = rest.split(" ", 1) if " " in rest else rest.split(",", 1)
|
||||
if len(parts) >= 2:
|
||||
params[list(_hints.keys())[0]] = parts[0]
|
||||
remaining_keys = [k for k, v in _hints.items() if v == "rest"]
|
||||
if remaining_keys:
|
||||
params[remaining_keys[0]] = parts[1]
|
||||
elif parts:
|
||||
params[list(_hints.keys())[0]] = parts[0]
|
||||
elif "rest" in _hints.values():
|
||||
for k, v in _hints.items():
|
||||
if v == "rest":
|
||||
params[k] = rest
|
||||
return action, params
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WorkPhoneExecuteRequest(BaseModel):
|
||||
device_id: str
|
||||
@@ -1079,77 +1189,259 @@ class WorkPhoneExecuteRequest(BaseModel):
|
||||
params: dict = {}
|
||||
|
||||
|
||||
class WorkPhoneNLRequest(BaseModel):
|
||||
"""自然语言微信控制请求"""
|
||||
device_id: str
|
||||
command: str
|
||||
platform: str = "wechat"
|
||||
|
||||
|
||||
class WorkPhoneAutoRegisterRequest(BaseModel):
|
||||
"""自动注册请求"""
|
||||
device_id: str
|
||||
nickname: str = "卡若AI"
|
||||
password: str = ""
|
||||
test_msg_to: str = ""
|
||||
test_msg_content: str = "你好,我是卡若AI工作手机"
|
||||
|
||||
|
||||
class WorkPhoneBatchRequest(BaseModel):
|
||||
"""批量操作请求"""
|
||||
device_id: str
|
||||
platform: str = "wechat"
|
||||
actions: List[Dict[str, Any]]
|
||||
interval: float = 1.0
|
||||
|
||||
|
||||
# ── 基础 SDK 代理端点 ──
|
||||
|
||||
@app.get("/v1/workphone/actions")
|
||||
async def workphone_actions():
|
||||
"""获取工作手机 SDK 支持的全部 Hook 操作清单"""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.get(f"{WORKPHONE_SDK_URL}/api/v3/hook/actions")
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
return {"code": 502, "error": f"SDK 不可达: {e}"}
|
||||
"""获取工作手机 SDK 支持的全部 110 个操作清单"""
|
||||
return await _sdk_get("/api/v3/hook/actions")
|
||||
|
||||
|
||||
@app.get("/v1/workphone/devices")
|
||||
async def workphone_devices():
|
||||
"""获取工作手机设备列表"""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.get(f"{WORKPHONE_SDK_URL}/api/v3/devices")
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
return {"code": 502, "error": f"SDK 不可达: {e}"}
|
||||
|
||||
|
||||
@app.post("/v1/workphone/execute")
|
||||
async def workphone_execute(req: WorkPhoneExecuteRequest):
|
||||
"""
|
||||
工作手机统一控制 — 通过卡若AI 网关调用 SDK Hook/ADB
|
||||
|
||||
示例:
|
||||
POST /v1/workphone/execute
|
||||
{"device_id":"dc9c23e00510","action":"send_message","params":{"to_id":"阿猫","content":"你好"}}
|
||||
{"device_id":"dc9c23e00510","action":"get_profile","params":{}}
|
||||
{"device_id":"dc9c23e00510","action":"register_account","params":{"phone":"138xxx","nickname":"卡若AI"}}
|
||||
"""
|
||||
payload = {
|
||||
"device_id": req.device_id,
|
||||
"platform": req.platform,
|
||||
"action": req.action,
|
||||
"params": req.params or {},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{WORKPHONE_SDK_URL}/api/v3/hook/execute",
|
||||
json=payload,
|
||||
)
|
||||
result = resp.json()
|
||||
result["gateway"] = "karuo_ai"
|
||||
return result
|
||||
except httpx.TimeoutException:
|
||||
return {"code": 504, "error": "SDK 执行超时", "action": req.action}
|
||||
except Exception as e:
|
||||
return {"code": 502, "error": f"SDK 不可达: {e}", "action": req.action}
|
||||
return await _sdk_get("/api/v3/devices")
|
||||
|
||||
|
||||
@app.get("/v1/workphone/status")
|
||||
async def workphone_status():
|
||||
"""工作手机 SDK 服务状态"""
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
try:
|
||||
resp = await client.get(f"{WORKPHONE_SDK_URL}/health")
|
||||
sdk_ok = resp.status_code == 200
|
||||
except Exception:
|
||||
sdk_ok = False
|
||||
result = await _sdk_get("/health", timeout=5)
|
||||
sdk_ok = result.get("code") != 502 and result.get("code") != 504
|
||||
return {
|
||||
"sdk_url": WORKPHONE_SDK_URL,
|
||||
"sdk_reachable": sdk_ok,
|
||||
"gateway": "karuo_ai",
|
||||
"gateway_version": "1.0",
|
||||
"gateway_version": "2.0",
|
||||
"total_actions": 110,
|
||||
"features": ["hook_execute", "auto_register", "nl_command", "batch", "sim_phone"],
|
||||
}
|
||||
|
||||
|
||||
# ── 核心:统一执行入口 ──
|
||||
|
||||
@app.post("/v1/workphone/execute")
|
||||
async def workphone_execute(req: WorkPhoneExecuteRequest):
|
||||
"""
|
||||
工作手机统一控制 — 通过卡若AI 网关调用 SDK 的 110 个操作
|
||||
|
||||
通道优先级:Frida Hook → WebSocket Agent → ADB UI 自动化
|
||||
|
||||
示例:
|
||||
{"device_id":"dc9c23e00510","action":"send_message","params":{"to_id":"阿猫","content":"你好"}}
|
||||
{"device_id":"dc9c23e00510","action":"get_profile","params":{}}
|
||||
{"device_id":"dc9c23e00510","action":"auto_register","params":{"nickname":"卡若AI"}}
|
||||
{"device_id":"dc9c23e00510","action":"check_login_state","params":{}}
|
||||
{"device_id":"dc9c23e00510","action":"get_sim_phone","params":{}}
|
||||
"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": req.device_id,
|
||||
"platform": req.platform,
|
||||
"action": req.action,
|
||||
"params": req.params or {},
|
||||
})
|
||||
|
||||
|
||||
# ── 自然语言指令入口 ──
|
||||
|
||||
@app.post("/v1/workphone/command")
|
||||
async def workphone_nl_command(req: WorkPhoneNLRequest):
|
||||
"""
|
||||
自然语言控制微信 — 说中文就能操作
|
||||
|
||||
示例:
|
||||
{"device_id":"dc9c23e00510","command":"发消息给阿猫 你好啊"}
|
||||
{"device_id":"dc9c23e00510","command":"获取联系人列表"}
|
||||
{"device_id":"dc9c23e00510","command":"注册微信 卡若AI"}
|
||||
{"device_id":"dc9c23e00510","command":"检查登录状态"}
|
||||
{"device_id":"dc9c23e00510","command":"发朋友圈 今天天气真好"}
|
||||
{"device_id":"dc9c23e00510","command":"搜索 张三"}
|
||||
"""
|
||||
parsed = _parse_nl_command(req.command)
|
||||
if not parsed:
|
||||
return {
|
||||
"code": 400,
|
||||
"error": f"无法解析指令: {req.command}",
|
||||
"hint": "支持:发消息给X、获取联系人、发朋友圈、注册微信、检查登录、扫码 等",
|
||||
"gateway": "karuo_ai",
|
||||
}
|
||||
|
||||
action, params = parsed
|
||||
result = await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": req.device_id,
|
||||
"platform": req.platform,
|
||||
"action": action,
|
||||
"params": params,
|
||||
})
|
||||
result["parsed_action"] = action
|
||||
result["parsed_params"] = params
|
||||
result["original_command"] = req.command
|
||||
return result
|
||||
|
||||
|
||||
# ── 自动注册专用入口 ──
|
||||
|
||||
@app.post("/v1/workphone/auto-register")
|
||||
async def workphone_auto_register(req: WorkPhoneAutoRegisterRequest):
|
||||
"""
|
||||
全自动微信注册 — 一键完成
|
||||
|
||||
流程:
|
||||
1. 检测微信登录状态
|
||||
2. 未登录 → 自动从 SIM 获取手机号 → 注册
|
||||
3. 自动读取短信验证码 → 填写
|
||||
4. 设置昵称/密码 → 完成
|
||||
5. 可选:发送测试消息
|
||||
"""
|
||||
return await _sdk_post("/api/v3/auto-register/full", {
|
||||
"device_id": req.device_id,
|
||||
"nickname": req.nickname,
|
||||
"password": req.password,
|
||||
"test_msg_to": req.test_msg_to,
|
||||
"test_msg_content": req.test_msg_content,
|
||||
}, timeout=120)
|
||||
|
||||
|
||||
@app.post("/v1/workphone/check-login")
|
||||
async def workphone_check_login(device_id: str):
|
||||
"""检查微信登录状态"""
|
||||
return await _sdk_post("/api/v3/auto-register/check-state", {
|
||||
"device_id": device_id,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/v1/workphone/sim-phone")
|
||||
async def workphone_sim_phone(device_id: str):
|
||||
"""获取设备 SIM 卡手机号"""
|
||||
return await _sdk_post("/api/v3/auto-register/get-sim-phone", {
|
||||
"device_id": device_id,
|
||||
})
|
||||
|
||||
|
||||
# ── 批量操作 ──
|
||||
|
||||
@app.post("/v1/workphone/batch")
|
||||
async def workphone_batch(req: WorkPhoneBatchRequest):
|
||||
"""
|
||||
批量执行微信操作(按顺序,带间隔防封)
|
||||
|
||||
示例:
|
||||
{
|
||||
"device_id": "dc9c23e00510",
|
||||
"actions": [
|
||||
{"action": "send_message", "params": {"to_id": "A", "content": "hi"}},
|
||||
{"action": "send_message", "params": {"to_id": "B", "content": "hello"}},
|
||||
{"action": "like_moments", "params": {"user_id": "C"}}
|
||||
],
|
||||
"interval": 2.0
|
||||
}
|
||||
"""
|
||||
results = []
|
||||
for i, item in enumerate(req.actions):
|
||||
if i > 0 and req.interval > 0:
|
||||
await asyncio.sleep(min(req.interval, 10))
|
||||
|
||||
action = item.get("action", "")
|
||||
params = item.get("params", {})
|
||||
r = await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": req.device_id,
|
||||
"platform": req.platform,
|
||||
"action": action,
|
||||
"params": params,
|
||||
})
|
||||
results.append({"index": i, "action": action, "result": r})
|
||||
|
||||
success_count = sum(1 for r in results if r["result"].get("code") == 200)
|
||||
return {
|
||||
"code": 200,
|
||||
"total": len(req.actions),
|
||||
"success": success_count,
|
||||
"failed": len(req.actions) - success_count,
|
||||
"results": results,
|
||||
"gateway": "karuo_ai",
|
||||
}
|
||||
|
||||
|
||||
# ── 快捷 API(常用操作的简化端点)──
|
||||
|
||||
@app.post("/v1/wechat/send")
|
||||
async def wechat_send(device_id: str, to: str, content: str, platform: str = "wechat"):
|
||||
"""快捷发送微信消息"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "send_message", "params": {"to_id": to, "content": content},
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v1/wechat/profile")
|
||||
async def wechat_profile(device_id: str, platform: str = "wechat"):
|
||||
"""快捷获取微信资料"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "get_profile", "params": {},
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v1/wechat/contacts")
|
||||
async def wechat_contacts(device_id: str, limit: int = 100, platform: str = "wechat"):
|
||||
"""快捷获取联系人列表"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "get_contacts", "params": {"limit": limit},
|
||||
})
|
||||
|
||||
|
||||
@app.post("/v1/wechat/moments")
|
||||
async def wechat_post_moments(device_id: str, content: str, platform: str = "wechat"):
|
||||
"""快捷发朋友圈"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "post_moments", "params": {"content": content},
|
||||
})
|
||||
|
||||
|
||||
@app.post("/v1/wechat/add-friend")
|
||||
async def wechat_add_friend(device_id: str, user_id: str, message: str = "", platform: str = "wechat"):
|
||||
"""快捷添加好友"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "add_friend", "params": {"user_id": user_id, "message": message},
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v1/wechat/groups")
|
||||
async def wechat_groups(device_id: str, limit: int = 100, platform: str = "wechat"):
|
||||
"""快捷获取群列表"""
|
||||
return await _sdk_post("/api/v3/hook/execute", {
|
||||
"device_id": device_id, "platform": platform,
|
||||
"action": "get_groups", "params": {"limit": limit},
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -378,3 +378,4 @@
|
||||
| 2026-03-16 14:41:40 | 🔄 卡若AI 同步 2026-03-16 14:41 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||
| 2026-03-16 14:53:23 | 🔄 卡若AI 同步 2026-03-16 14:53 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||
| 2026-03-16 15:06:19 | 🔄 卡若AI 同步 2026-03-16 15:06 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||
| 2026-03-16 15:25:27 | 🔄 卡若AI 同步 2026-03-16 15:25 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||
|
||||
@@ -381,3 +381,4 @@
|
||||
| 2026-03-16 14:41:40 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-16 14:41 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||
| 2026-03-16 14:53:23 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-16 14:53 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||
| 2026-03-16 15:06:19 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-16 15:06 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||
| 2026-03-16 15:25:27 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-16 15:25 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||
|
||||
Reference in New Issue
Block a user