🔄 卡若AI 同步 2026-03-16 16:59 | 更新:卡木、运营中枢、运营中枢工作台 | 排除 >20MB: 11 个

This commit is contained in:
2026-03-16 16:59:11 +08:00
parent 1b1ce77b95
commit 0d7381bbaf
10 changed files with 650 additions and 346 deletions

View File

@@ -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到5000100部真人剧的成本做100部AI剧.mp4", "title": "AI一部剧3000到5000100部真人剧的成本做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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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}"
}

View File

@@ -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)

View File

@@ -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 个 |

View File

@@ -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) |