feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# agentos - AgentOS 系统管理 CLI
|
||||
# 统一入口:换机初始化 / 双机同步 / 技能管理 / 备份还原 / 健康检查
|
||||
@@ -0,0 +1,3 @@
|
||||
"""agentos CLI 入口 — python -m agentos"""
|
||||
from .main import main
|
||||
main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
agentos backup / restore — 备份与还原 agent-local/
|
||||
|
||||
备份内容:
|
||||
- materials/ (采集的原始素材)
|
||||
- memory/raw/ (L3 记忆原文)
|
||||
+ 备份清单 manifest.json
|
||||
|
||||
不备份:
|
||||
- vector_db/ (可重建)
|
||||
- runtime/cache/ (临时缓存)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import tarfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from .utils import get_sync_root, get_local_root, info, ok, warn, err, banner
|
||||
|
||||
|
||||
def build_manifest(local_root: Path, backup_dirs: list) -> dict:
|
||||
"""生成备份清单"""
|
||||
manifest = {
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"hostname": __import__("platform").node(),
|
||||
"agentos_version": "1.0.0",
|
||||
"items": [],
|
||||
}
|
||||
total_size = 0
|
||||
entry_count = 0
|
||||
for rel in backup_dirs:
|
||||
path = local_root / rel
|
||||
if not path.exists():
|
||||
continue
|
||||
if path.is_dir():
|
||||
for f in path.rglob("*"):
|
||||
if f.is_file() and f.name != ".DS_Store":
|
||||
stat = f.stat()
|
||||
total_size += stat.st_size
|
||||
entry_count += 1
|
||||
manifest["items"].append({
|
||||
"path": str(f.relative_to(local_root)),
|
||||
"size": stat.st_size,
|
||||
"mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
})
|
||||
else:
|
||||
stat = path.stat()
|
||||
total_size += stat.st_size
|
||||
entry_count += 1
|
||||
manifest["items"].append({
|
||||
"path": str(path.relative_to(local_root)),
|
||||
"size": stat.st_size,
|
||||
"mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
})
|
||||
|
||||
manifest["total_size"] = total_size
|
||||
manifest["entry_count"] = entry_count
|
||||
return manifest
|
||||
|
||||
|
||||
def cmd_backup(output_path: str = None, full: bool = False):
|
||||
"""执行备份"""
|
||||
banner()
|
||||
local_root = get_local_root()
|
||||
hostname = __import__("platform").node()
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if not local_root.exists():
|
||||
err(f"agent-local 目录不存在: {local_root}")
|
||||
sys.exit(1)
|
||||
|
||||
# 确定备份目录
|
||||
if full:
|
||||
sync_root = get_sync_root()
|
||||
backup_dirs = ["."] # 打包整个 agent-local/
|
||||
archive_name = f"agentos_full_{hostname}_{timestamp}.tar.gz"
|
||||
else:
|
||||
backup_dirs = ["materials", "memory/raw"]
|
||||
archive_name = f"agentos_local_{hostname}_{timestamp}.tar.gz"
|
||||
|
||||
# 输出路径
|
||||
if output_path:
|
||||
out_dir = Path(output_path)
|
||||
else:
|
||||
out_dir = Path.cwd()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = out_dir / archive_name
|
||||
|
||||
# 构建 manifest
|
||||
info("扫描备份内容...")
|
||||
manifest = build_manifest(local_root, backup_dirs)
|
||||
|
||||
if manifest["entry_count"] == 0:
|
||||
warn("没有需要备份的文件")
|
||||
return
|
||||
|
||||
print(f" 📊 待备份: {manifest['entry_count']} 个文件, {manifest['total_size'] / 1024 / 1024:.1f} MB")
|
||||
print(f" 📁 来源: {local_root}")
|
||||
|
||||
# 创建 tar.gz
|
||||
info("打包中...")
|
||||
with tarfile.open(str(archive_path), "w:gz") as tar:
|
||||
# 添加 manifest
|
||||
manifest_bytes = json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8")
|
||||
import io
|
||||
manifest_file = io.BytesIO(manifest_bytes)
|
||||
tarinfo = tarfile.TarInfo(name="manifest.json")
|
||||
tarinfo.size = len(manifest_bytes)
|
||||
tar.addfile(tarinfo, manifest_file)
|
||||
|
||||
# 添加备份目录
|
||||
for rel in backup_dirs:
|
||||
path = local_root / rel
|
||||
if path.exists():
|
||||
tar.add(str(path), arcname=rel)
|
||||
|
||||
file_size_mb = archive_path.stat().st_size / 1024 / 1024
|
||||
ok(f"备份完成: {archive_path} ({file_size_mb:.1f} MB)")
|
||||
info(f"还原命令: agentos restore {archive_path}")
|
||||
print()
|
||||
|
||||
|
||||
def run_restore(args):
|
||||
"""执行还原"""
|
||||
banner()
|
||||
backup_path = Path(args.backup_path)
|
||||
if not backup_path.exists():
|
||||
err(f"备份文件不存在: {backup_path}")
|
||||
sys.exit(1)
|
||||
|
||||
local_root = get_local_root()
|
||||
force = args.force
|
||||
|
||||
info(f"从备份还原: {backup_path}")
|
||||
info(f"目标目录: {local_root}")
|
||||
|
||||
# 读取 manifest
|
||||
manifest = None
|
||||
with tarfile.open(str(backup_path), "r:gz") as tar:
|
||||
try:
|
||||
mf = tar.extractfile("manifest.json")
|
||||
if mf:
|
||||
manifest = json.loads(mf.read().decode("utf-8"))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if manifest:
|
||||
print(f" 📊 备份信息: {manifest['entry_count']} 个文件, {manifest['total_size'] / 1024 / 1024:.1f} MB")
|
||||
print(f" 🖥️ 来源机器: {manifest.get('hostname', '?')}")
|
||||
print(f" 🕐 备份时间: {manifest.get('created_at', '?')}")
|
||||
else:
|
||||
info(" 备份文件无 manifest.json(旧版本备份)")
|
||||
|
||||
# 冲突检查
|
||||
conflicts = []
|
||||
if manifest and not force:
|
||||
for item in manifest["items"]:
|
||||
dest = local_root / item["path"]
|
||||
if dest.exists():
|
||||
conflicts.append(item["path"])
|
||||
|
||||
if conflicts:
|
||||
print(f"\n⚠️ 发现 {len(conflicts)} 个已存在的文件: (使用 --force 覆盖)")
|
||||
for c in conflicts[:10]:
|
||||
print(f" {c}")
|
||||
if len(conflicts) > 10:
|
||||
print(f" ... 还有 {len(conflicts) - 10} 个")
|
||||
print()
|
||||
|
||||
# 确认
|
||||
if not force:
|
||||
reply = input("是否继续还原?(y/N): ").strip().lower()
|
||||
if reply != "y":
|
||||
info("已取消")
|
||||
return
|
||||
|
||||
# 执行还原
|
||||
info("还原中...")
|
||||
with tarfile.open(str(backup_path), "r:gz") as tar:
|
||||
tar.extractall(path=str(local_root), filter="data")
|
||||
|
||||
ok("还原完成")
|
||||
info("后续操作:")
|
||||
info(" 1. 运行 agentos check 验证状态")
|
||||
info(" 2. 如果向量库未一起还原,运行 agentos init 重建")
|
||||
print()
|
||||
|
||||
|
||||
def run(args):
|
||||
cmd_backup(output_path=args.output, full=args.full)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
agentos check — 全系统健康检查
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import (
|
||||
get_sync_root, get_local_root, get_python, get_npx,
|
||||
info, ok, warn, err, run, banner
|
||||
)
|
||||
|
||||
|
||||
def check_item(name: str, status: str, detail: str = ""):
|
||||
"""输出检查项"""
|
||||
icon = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
|
||||
label = {"ok": "通过", "warn": "警告", "fail": "失败"}
|
||||
print(f" {icon.get(status, '❓')} [{label.get(status, '未知')}] {name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
|
||||
|
||||
def cmd_check(quick: bool = False):
|
||||
"""执行全系统健康检查"""
|
||||
banner()
|
||||
sync_root = get_sync_root()
|
||||
local_root = get_local_root()
|
||||
|
||||
print("=" * 60)
|
||||
print(" 全系统健康检查")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 1. 目录结构
|
||||
print("📁 目录结构:")
|
||||
check_item("agent-sync 根目录", "ok" if sync_root.exists() else "fail", str(sync_root))
|
||||
check_item("agent-local 根目录", "ok" if local_root.exists() else "fail", str(local_root))
|
||||
|
||||
sync_skills = sync_root / "02_skills"
|
||||
sync_skills_count = len([d for d in sync_skills.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]) if sync_skills.exists() else 0
|
||||
check_item(f"技能目录 ({sync_skills_count} 个)", "ok" if sync_skills_count > 0 else "warn")
|
||||
|
||||
local_materials = local_root / "materials" if local_root.exists() else None
|
||||
local_raw = local_root / "memory/raw" if local_root.exists() else None
|
||||
materials_exist = local_materials and local_materials.exists()
|
||||
raw_exist = local_raw and local_raw.exists()
|
||||
check_item("materials/", "ok" if materials_exist else "warn", "采集素材目录")
|
||||
check_item("memory/raw/", "ok" if raw_exist else "warn", "L3 记忆原文目录")
|
||||
|
||||
# 2. 软链接
|
||||
print("\n🔗 软链接:")
|
||||
symlinks_to_check = [
|
||||
("04_memory/long_term/raw", local_root / "memory/raw"),
|
||||
("04_memory/vector_db", local_root / "memory/vector_db"),
|
||||
("06_runtime/cache", local_root / "runtime/cache"),
|
||||
]
|
||||
for link_rel, target in symlinks_to_check:
|
||||
link_path = sync_root / link_rel
|
||||
if link_path.is_symlink():
|
||||
actual = link_path.resolve()
|
||||
expected = target.resolve()
|
||||
if actual == expected:
|
||||
check_item(f"{link_rel} → OK", "ok")
|
||||
else:
|
||||
check_item(f"{link_rel} → 指向错误", "warn", f"实际: {actual}, 期望: {expected}")
|
||||
elif link_path.exists():
|
||||
check_item(f"{link_rel} → 真实目录", "ok")
|
||||
else:
|
||||
check_item(f"{link_rel} → 不存在", "fail")
|
||||
|
||||
# 3. Python 环境
|
||||
print("\n🐍 Python 环境:")
|
||||
py = get_python()
|
||||
if Path(py).exists():
|
||||
result = run([py, "--version"])
|
||||
check_item("Python 版本", "ok" if result.returncode == 0 else "fail", result.stdout.strip() if result.returncode == 0 else "")
|
||||
else:
|
||||
check_item("Python 可执行文件", "fail", py)
|
||||
|
||||
# 关键依赖检查
|
||||
deps = ["chromadb", "trafilatura", "feedparser", "sqlite_utils"]
|
||||
for dep in deps:
|
||||
result = run([py, "-c", f"import {dep}"])
|
||||
check_item(f"依赖: {dep}", "ok" if result.returncode == 0 else "fail")
|
||||
|
||||
if quick:
|
||||
print("\n" + "=" * 60)
|
||||
ok("快速检查完成")
|
||||
return
|
||||
|
||||
# 4. oMLX
|
||||
print("\n🤖 本地 LLM 引擎:")
|
||||
try:
|
||||
import json, urllib.request
|
||||
data = json.dumps({"model": "Qwen3-Embedding-0.6B-4bit-DWQ", "input": ["test"]}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8000/v1/embeddings",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer omlx", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
check_item("oMLX Embedding API", "ok" if resp.status == 200 else "fail", f"状态码 {resp.status}")
|
||||
except Exception as e:
|
||||
check_item("oMLX Embedding API", "fail", str(e))
|
||||
|
||||
# 音频转文字能力检查
|
||||
print("\n🎙️ 音频转文字:")
|
||||
ffmpeg = run(["which", "ffmpeg"])
|
||||
if ffmpeg.returncode == 0:
|
||||
check_item("ffmpeg(系统工具)", "ok", "已安装")
|
||||
else:
|
||||
check_item("ffmpeg(系统工具)", "warn", "未安装,音频转文字不可用。运行: brew install ffmpeg")
|
||||
|
||||
whisper_check = run([py, "-m", "pip", "show", "openai-whisper"])
|
||||
if whisper_check.returncode == 0:
|
||||
import re
|
||||
ver_match = re.search(r"Version:\s*(\S+)", whisper_check.stdout)
|
||||
ver = ver_match.group(1) if ver_match else "?"
|
||||
check_item("openai-whisper(Python包)", "ok", f"v{ver}")
|
||||
# 检查模型是否已下载
|
||||
import os as _os
|
||||
whisper_cache = _os.path.expanduser("~/.cache/whisper")
|
||||
if _os.path.exists(whisper_cache):
|
||||
models = [f for f in _os.listdir(whisper_cache) if f.endswith(('.pt', '.bin'))]
|
||||
if models:
|
||||
model_sizes = [_os.path.getsize(_os.path.join(whisper_cache, m)) / 1024 / 1024 for m in models]
|
||||
detail = ", ".join([f"{m} ({s:.0f}MB)" for m, s in zip(models, model_sizes)])
|
||||
check_item("Whisper 模型权重", "ok", f"已缓存: {detail}")
|
||||
else:
|
||||
check_item("Whisper 模型权重", "warn", "首次使用时会自动下载(~1.5GB)")
|
||||
else:
|
||||
check_item("Whisper 模型权重", "warn", "未下载,首次运行自动下载(~1.5GB)")
|
||||
else:
|
||||
check_item("openai-whisper(Python包)", "fail", "未安装,运行: pip install openai-whisper")
|
||||
|
||||
# 5. facts.db
|
||||
print("\n🗄️ 记忆层:")
|
||||
facts_db = sync_root / "04_memory" / "long_term" / "facts.db"
|
||||
if facts_db.exists():
|
||||
size_kb = facts_db.stat().st_size / 1024
|
||||
result = run([py, "-c", f"""
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('{facts_db}')
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM facts")
|
||||
count = c.fetchone()[0]
|
||||
c.execute("SELECT COUNT(DISTINCT subject) FROM facts")
|
||||
subjects = c.fetchone()[0]
|
||||
conn.close()
|
||||
print(f"条目: {{count}}, 主题: {{subjects}}")
|
||||
"""])
|
||||
check_item(f"facts.db ({size_kb:.1f} KB)", "ok", result.stdout.strip() if result.returncode == 0 else "读取异常")
|
||||
else:
|
||||
check_item("facts.db", "warn", "不存在(首次初始化正常)")
|
||||
|
||||
# 6. 向量库
|
||||
vector_db = local_root / "vector_db" / "global" / "chroma"
|
||||
if vector_db.exists():
|
||||
chroma_files = list(vector_db.glob("*.sqlite3"))
|
||||
check_item("global 向量库", "ok" if chroma_files else "warn", f"目录存在但无 .sqlite3 文件" if not chroma_files else "")
|
||||
else:
|
||||
check_item("global 向量库", "warn", "未建,运行 agentos init 或 vector_db_rebuild.py 重建")
|
||||
|
||||
# 7. WorkBuddy 技能注册
|
||||
print("\n📦 技能注册状态:")
|
||||
wb_skills = Path.home() / ".workbuddy" / "skills"
|
||||
sync_skills_dir = sync_root / "02_skills"
|
||||
if wb_skills.exists() and sync_skills_dir.exists():
|
||||
wb_names = {d.name for d in wb_skills.iterdir() if d.is_dir()}
|
||||
src_names = {d.name for d in sync_skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()}
|
||||
missing = src_names - wb_names
|
||||
extra = wb_names - src_names
|
||||
if missing:
|
||||
check_item(f"未注册到 WorkBuddy ({len(missing)} 个)", "warn", ", ".join(sorted(missing)))
|
||||
if extra:
|
||||
check_item(f"WB 中多余的 ({len(extra)} 个)", "warn", ", ".join(sorted(extra)))
|
||||
if not missing and not extra:
|
||||
check_item(f"技能一致性 ({len(src_names)} 个)", "ok", "源目录与 WB 注册一致")
|
||||
|
||||
# 8. 磁盘
|
||||
print("\n💾 磁盘空间:")
|
||||
import shutil
|
||||
usage = shutil.disk_usage(str(Path.home()))
|
||||
free_gb = usage.free / (1024 ** 3)
|
||||
total_gb = usage.total / (1024 ** 3)
|
||||
pct = usage.free / usage.total * 100
|
||||
status = "ok" if pct > 10 else "warn"
|
||||
check_item(f"磁盘剩余", status, f"{free_gb:.0f} GB / {total_gb:.0f} GB ({pct:.0f}%)")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
ok("健康检查完成")
|
||||
print()
|
||||
|
||||
|
||||
def install_deps(dry_run: bool = False):
|
||||
"""安装 Python 依赖(被 init 调用)"""
|
||||
py = get_python()
|
||||
req_file = get_sync_root() / "requirements.txt"
|
||||
if not req_file.exists():
|
||||
warn(f"requirements.txt 不存在: {req_file}")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] pip install -r {req_file}")
|
||||
return
|
||||
|
||||
info("安装 Python 依赖...")
|
||||
result = run([py, "-m", "pip", "install", "-r", str(req_file), "--quiet"])
|
||||
if result.returncode == 0:
|
||||
ok("Python 依赖安装完成")
|
||||
else:
|
||||
warn(f"部分依赖安装失败:\n{result.stderr[:200]}")
|
||||
|
||||
# 检查并提示安装 ffmpeg(音频转文字需要)
|
||||
ffmpeg = run(["which", "ffmpeg"])
|
||||
if ffmpeg.returncode != 0:
|
||||
print()
|
||||
warn("音频转文字功能需要 ffmpeg 系统工具")
|
||||
info(" 运行以下命令安装:")
|
||||
info(" brew install ffmpeg")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_run(args):
|
||||
cmd_check(quick=args.quick)
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
agentos config — 配置管理模块 (v1.0.0)
|
||||
|
||||
管理 01_core/ ←→ ~/.workbuddy/ 的配置同步。
|
||||
三类配置:
|
||||
A: 自动覆盖(内核身份文件)
|
||||
B: 对比选择(服务配置,diff 后用户选择)
|
||||
C: 仅报告(本机个性化,不同步)
|
||||
|
||||
子命令:
|
||||
agentos config status 查看配置状态
|
||||
agentos config diff 查看差异详情
|
||||
agentos config apply 应用配置更新
|
||||
agentos config rollback 回滚到备份
|
||||
|
||||
最后更新: 2026-05-02
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .const import __agent_sync_root__, __agent_local_root__, __version__
|
||||
|
||||
SYNC_ROOT = Path(__agent_sync_root__)
|
||||
MANIFEST_PATH = SYNC_ROOT / "01_core" / "CONFIG_MANIFEST.yaml"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 配置清单解析
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def load_manifest() -> list:
|
||||
"""解析 CONFIG_MANIFEST.yaml,返回配置项列表"""
|
||||
import yaml
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"❌ 未找到配置清单: {MANIFEST_PATH}")
|
||||
return []
|
||||
with open(MANIFEST_PATH) as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data.get("configs", [])
|
||||
|
||||
|
||||
def resolve_path(path_str: str) -> Optional[Path]:
|
||||
"""将 ~/ 开头的路径解析为绝对路径"""
|
||||
if not path_str:
|
||||
return None
|
||||
path_str = path_str.replace("~", str(Path.home()))
|
||||
p = Path(path_str)
|
||||
return p if p.exists() else p # 即使不存在也返回路径对象
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 状态检查
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def check_status() -> list:
|
||||
"""检查所有配置项状态,返回状态列表"""
|
||||
items = load_manifest()
|
||||
if not items:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
cat = item["category"]
|
||||
name = item["file"]
|
||||
source = resolve_path(item.get("source"))
|
||||
target = resolve_path(item.get("target"))
|
||||
|
||||
status = {
|
||||
"name": name,
|
||||
"category": cat,
|
||||
"source": str(source) if source else "—",
|
||||
"target": str(target) if target else "—",
|
||||
"exists_source": source and source.exists(),
|
||||
"exists_target": target and target.exists(),
|
||||
"identical": False,
|
||||
"diff_lines": 0,
|
||||
}
|
||||
|
||||
# 比较内容
|
||||
if status["exists_source"] and status["exists_target"]:
|
||||
try:
|
||||
s_content = source.read_bytes()
|
||||
t_content = target.read_bytes()
|
||||
status["identical"] = s_content == t_content
|
||||
if not status["identical"]:
|
||||
# 粗略统计差异行数
|
||||
s_lines = s_content.decode().splitlines()
|
||||
t_lines = t_content.decode().splitlines()
|
||||
status["diff_lines"] = abs(len(s_lines) - len(t_lines))
|
||||
except Exception:
|
||||
status["identical"] = False
|
||||
|
||||
results.append(status)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 差异显示
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def show_diff(item: dict):
|
||||
"""显示指定配置项的 diff"""
|
||||
source = resolve_path(item.get("source"))
|
||||
target = resolve_path(item.get("target"))
|
||||
|
||||
if not source or not source.exists():
|
||||
print(f" ⚠️ 源文件不存在: {source}")
|
||||
return
|
||||
if not target or not target.exists():
|
||||
print(f" ⚠️ 目标文件不存在: {target}")
|
||||
print(f" → 将创建: {target}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["diff", "-u", str(source), str(target)],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✅ 文件一致,无差异")
|
||||
else:
|
||||
diff_out = result.stdout
|
||||
lines = diff_out.splitlines()
|
||||
# 只显示前 30 行
|
||||
display = lines[:30]
|
||||
if len(lines) > 30:
|
||||
display.append(f" ... (还有 {len(lines)-30} 行差异)")
|
||||
for line in display:
|
||||
marker = " "
|
||||
if line.startswith("+"):
|
||||
marker = "+"
|
||||
elif line.startswith("-"):
|
||||
marker = "-"
|
||||
elif line.startswith("@@"):
|
||||
marker = "@"
|
||||
print(f" {marker} {line}")
|
||||
except Exception as e:
|
||||
print(f" ❌ diff 失败: {e}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 备份
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def backup_targets(items: list) -> Optional[Path]:
|
||||
"""备份所有目标文件到时间戳目录"""
|
||||
backup_dir = Path.home() / ".workbuddy" / f"config_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
count = 0
|
||||
for item in items:
|
||||
if item["category"] == "C":
|
||||
continue
|
||||
target = resolve_path(item.get("target"))
|
||||
if target and target.exists():
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(target), str(backup_dir / target.name))
|
||||
count += 1
|
||||
if count > 0:
|
||||
print(f" 💾 已备份 {count} 个文件到: {backup_dir}")
|
||||
return backup_dir
|
||||
return None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 应用配置
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def apply_config(item: dict) -> bool:
|
||||
"""应用单个配置项"""
|
||||
cat = item["category"]
|
||||
name = item["file"]
|
||||
source = resolve_path(item.get("source"))
|
||||
target = resolve_path(item.get("target"))
|
||||
|
||||
if not target:
|
||||
print(f" ⚠️ {name}: 目标路径为空,跳过")
|
||||
return False
|
||||
|
||||
if cat == "A":
|
||||
# A类: 自动覆盖
|
||||
if not source or not source.exists():
|
||||
print(f" ⚠️ {name}: 源文件不存在 ({source}),跳过")
|
||||
return False
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source), str(target))
|
||||
print(f" ✅ {name}: 已部署(A类自动覆盖)")
|
||||
return True
|
||||
|
||||
elif cat == "B":
|
||||
# B类: 对比后选择
|
||||
if not source or not source.exists():
|
||||
print(f" ⚠️ {name}: 源文件不存在 ({source}),跳过")
|
||||
return False
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target.exists():
|
||||
# 检查是否一致
|
||||
try:
|
||||
s_content = source.read_bytes()
|
||||
t_content = target.read_bytes()
|
||||
if s_content == t_content:
|
||||
print(f" ✅ {name}: 一致,无需更新")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 有差异,尝试交互(非交互环境自动跳过)
|
||||
print(f"\n 📋 {name}: 源与目标存在差异")
|
||||
print(f" 源: {source}")
|
||||
print(f" 目标: {target}")
|
||||
show_diff(item)
|
||||
print()
|
||||
try:
|
||||
choice = input(f" 选择 [K]保留本地 [N]用新版 [D]查看差异 [S]跳过: ").strip().upper()
|
||||
except (EOFError, OSError):
|
||||
print(f" ⏭️ {name}: 非交互环境,跳过(运行 agentos config diff {name} 手动处理)")
|
||||
return False
|
||||
while True:
|
||||
if choice == "K":
|
||||
print(f" ⏭️ {name}: 保留本地版本")
|
||||
return False
|
||||
elif choice == "N":
|
||||
shutil.copy2(str(source), str(target))
|
||||
print(f" ✅ {name}: 已更新为新版")
|
||||
return True
|
||||
elif choice == "D":
|
||||
show_diff(item)
|
||||
break
|
||||
elif choice == "S":
|
||||
print(f" ⏭️ {name}: 跳过")
|
||||
return False
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# 目标不存在,直接部署
|
||||
shutil.copy2(str(source), str(target))
|
||||
print(f" ✅ {name}: 已创建(首次部署)")
|
||||
return True
|
||||
|
||||
elif cat == "C":
|
||||
print(f" ⏭️ {name}: C类本机配置,不同步")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# CLI 命令
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def cmd_status(args):
|
||||
"""agentos config status — 查看配置状态"""
|
||||
results = check_status()
|
||||
if not results:
|
||||
print("❌ 无法读取配置清单")
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 📋 AgentOS 配置状态")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
for r in results:
|
||||
cat = r["category"]
|
||||
name = r["name"]
|
||||
tag = {"A": "🔵 A-自动", "B": "🟡 B-选择", "C": "⚪ C-本地"}.get(cat, "?")
|
||||
|
||||
if cat == "C":
|
||||
exists = r["exists_target"]
|
||||
print(f" {tag} {name:20s} {'✅ 存在' if exists else '⏹️ 不存在'}")
|
||||
elif r["identical"]:
|
||||
print(f" {tag} {name:20s} ✅ 一致")
|
||||
elif r["exists_source"] and r["exists_target"]:
|
||||
print(f" {tag} {name:20s} ⚠️ 有差异(~{r['diff_lines']}行不同)")
|
||||
elif r["exists_source"] and not r["exists_target"]:
|
||||
print(f" {tag} {name:20s} 📋 待部署(目标不存在)")
|
||||
elif not r["exists_source"]:
|
||||
print(f" {tag} {name:20s} ❌ 源文件不存在")
|
||||
else:
|
||||
print(f" {tag} {name:20s} ❓ 未知")
|
||||
|
||||
print(f"\n{'─'*60}")
|
||||
a_count = sum(1 for r in results if r["category"] == "A" and not r["identical"])
|
||||
b_count = sum(1 for r in results if r["category"] == "B" and not r["identical"])
|
||||
if a_count + b_count > 0:
|
||||
print(f" ⚠️ {a_count} 个 A类 + {b_count} 个 B类 待处理")
|
||||
print(f" 运行: agentos config apply")
|
||||
else:
|
||||
print(f" ✅ 所有配置一致")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_diff(args):
|
||||
"""agentos config diff — 查看所有差异"""
|
||||
results = check_status()
|
||||
if not results:
|
||||
return
|
||||
|
||||
items = load_manifest()
|
||||
for i, r in enumerate(results):
|
||||
if r["identical"] or r["category"] == "C":
|
||||
continue
|
||||
if r["exists_source"] and r["exists_target"]:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 📄 {r['name']}")
|
||||
print(f"{'='*60}")
|
||||
show_diff(items[i])
|
||||
print()
|
||||
|
||||
|
||||
def cmd_apply(args):
|
||||
"""agentos config apply — 应用配置更新"""
|
||||
items = load_manifest()
|
||||
if not items:
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 🔄 AgentOS 配置更新")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# 先备份
|
||||
print(f"▶ 备份现有配置...")
|
||||
backup_dir = backup_targets(items)
|
||||
if backup_dir:
|
||||
print(f" 回滚命令: agentos config rollback {backup_dir.name}")
|
||||
print()
|
||||
|
||||
# 逐项应用
|
||||
changed = 0
|
||||
for item in items:
|
||||
if item["category"] == "C":
|
||||
continue
|
||||
ok = apply_config(item)
|
||||
if ok:
|
||||
changed += 1
|
||||
|
||||
print(f"\n{'─'*60}")
|
||||
if changed > 0:
|
||||
print(f" ✅ 已更新 {changed} 个配置项")
|
||||
print(f" 提示: 如果 WorkBuddy 正在运行,可能需要重启生效")
|
||||
else:
|
||||
print(f" ⏭️ 无变更")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_rollback(args):
|
||||
"""agentos config rollback — 回滚配置"""
|
||||
backup_name = getattr(args, "backup_name", None)
|
||||
|
||||
backup_base = Path.home() / ".workbuddy"
|
||||
if backup_name:
|
||||
backup_dir = backup_base / backup_name
|
||||
else:
|
||||
# 找最近的备份
|
||||
backups = sorted(backup_base.glob("config_backup_*"))
|
||||
if not backups:
|
||||
print("❌ 未找到备份")
|
||||
return
|
||||
backup_dir = backups[-1]
|
||||
print(f" 使用最近备份: {backup_dir.name}")
|
||||
|
||||
if not backup_dir.exists():
|
||||
print(f"❌ 备份目录不存在: {backup_dir}")
|
||||
return
|
||||
|
||||
files = list(backup_dir.iterdir())
|
||||
if not files:
|
||||
print(f"❌ 备份目录为空: {backup_dir}")
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ↩️ 回滚配置")
|
||||
print(f"{'='*60}\n")
|
||||
print(f" 从: {backup_dir}")
|
||||
print()
|
||||
|
||||
for f in files:
|
||||
target = Path.home() / ".workbuddy" / f.name
|
||||
shutil.copy2(str(f), str(target))
|
||||
print(f" ✅ {f.name}: 已恢复")
|
||||
|
||||
print(f"\n 共恢复 {len(files)} 个文件")
|
||||
print()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 子命令注册
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def setup_parser(subparsers):
|
||||
"""向主 CLI 注册 config 子命令"""
|
||||
p = subparsers.add_parser("config", help="配置管理:状态查看 / 差异对比 / 部署 / 回滚")
|
||||
|
||||
config_sub = p.add_subparsers(dest="config_cmd", help="配置子命令")
|
||||
|
||||
config_sub.add_parser("status", help="查看配置状态(哪些文件有差异)")
|
||||
config_sub.add_parser("diff", help="查看配置差异详情")
|
||||
config_sub.add_parser("apply", help="应用配置更新(A类自动 / B类选择)")
|
||||
|
||||
p_rollback = config_sub.add_parser("rollback", help="回滚到上次备份")
|
||||
p_rollback.add_argument("backup_name", nargs="?", default=None,
|
||||
help="备份目录名(可选,默认使用最近备份)")
|
||||
|
||||
|
||||
def run(args):
|
||||
cmd = args.config_cmd
|
||||
if not cmd:
|
||||
print("agentos config 子命令: status | diff | apply | rollback")
|
||||
return
|
||||
|
||||
if cmd == "status":
|
||||
cmd_status(args)
|
||||
elif cmd == "diff":
|
||||
cmd_diff(args)
|
||||
elif cmd == "apply":
|
||||
cmd_apply(args)
|
||||
elif cmd == "rollback":
|
||||
cmd_rollback(args)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
agentos - AgentOS 系统管理 CLI
|
||||
=================================
|
||||
|
||||
统一入口,管理 AgentOS 的全部系统级操作。
|
||||
|
||||
用法:
|
||||
agentos 显示帮助
|
||||
agentos init 换机一键初始化
|
||||
agentos sync 双机增量同步
|
||||
agentos skill list 列出所有技能
|
||||
agentos skill install 安装技能到 WorkBuddy
|
||||
agentos skill build 打包技能为 .skill 包
|
||||
agentos skill search 搜索技能
|
||||
agentos tool list 列出所有工具
|
||||
agentos check 全系统健康检查
|
||||
agentos upgrade [--module <name>] 统一模块升级(拉取代码 + 安装依赖 + 环境检查)
|
||||
agentos config status 查看配置状态
|
||||
agentos config diff 查看配置差异
|
||||
agentos config apply 部署配置(A类自动 / B类选择)
|
||||
agentos config rollback 回滚至备份
|
||||
agentos backup 备份 agent-local/(materials + memory/raw)
|
||||
agentos restore <路径> 从备份还原 agent-local/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__agent_sync_root__ = str(Path.home() / "workbuddy-agent-os" / "agent-sync")
|
||||
__agent_local_root__ = str(Path.home() / "workbuddy-agent-os" / "agent-local")
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
agentos init — 换机一键初始化
|
||||
|
||||
流程:
|
||||
1. 创建 agent-local/ 目录结构
|
||||
2. 建立软链接
|
||||
3. 安装 Python/Node 依赖
|
||||
4. 注册技能到 WorkBuddy
|
||||
5. 部署 MCP 配置
|
||||
6. 创建自动化任务
|
||||
7. 重建向量库
|
||||
8. 环境验证
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import (
|
||||
get_sync_root, get_local_root, get_python, get_npx,
|
||||
info, ok, warn, err, run, banner
|
||||
)
|
||||
from . import skill_mgr
|
||||
|
||||
# 本机专属目录结构
|
||||
LOCAL_DIRS = [
|
||||
"memory/raw",
|
||||
"runtime/cache",
|
||||
"materials/web",
|
||||
"materials/video",
|
||||
"materials/audio",
|
||||
"materials/screenshots",
|
||||
"materials/refined_for_inbox/clipping",
|
||||
"materials/refined_for_inbox/feed",
|
||||
"materials/refined_for_inbox/personal",
|
||||
"materials/refined_for_inbox/memory",
|
||||
]
|
||||
|
||||
# 软链接映射 {相对路径: 目标路径}
|
||||
SYMLINKS = {
|
||||
"04_memory/long_term/raw": "memory/raw",
|
||||
"04_memory/vector_db": "memory/vector_db",
|
||||
"06_runtime/cache": "runtime/cache",
|
||||
}
|
||||
|
||||
# 自动化任务定义 (name, prompt, rrule, schedule_type)
|
||||
AUTOMATIONS = [
|
||||
{
|
||||
"name": "AgentOS 每日记忆提炼",
|
||||
"schedule_type": "recurring",
|
||||
"rrule": "FREQ=DAILY;BYHOUR=2;BYMINUTE=0",
|
||||
"prompt": f"""执行 AgentOS 每日记忆提炼任务。
|
||||
先读取自动化记忆文件(如存在)了解历史。
|
||||
执行命令:
|
||||
{get_python()} {get_sync_root() / '02_skills/memory_manager/daily_digest.py'} --root {get_sync_root()}
|
||||
汇报结果:新增了几条事实、去重了几条、冲突了几条。
|
||||
写执行摘要到自动化记忆文件。""",
|
||||
},
|
||||
{
|
||||
"name": "AgentOS 收件箱汇聚",
|
||||
"schedule_type": "recurring",
|
||||
"rrule": "FREQ=DAILY;BYHOUR=2;BYMINUTE=30",
|
||||
"prompt": f"""执行 AgentOS 收件箱汇聚任务:扫描知识库各分类目录中的文件,提取主要内容生成标准化 MD 文件放入 00_inbox/。
|
||||
执行命令:
|
||||
{get_python()} {get_sync_root() / '02_skills/collect_to_inbox/collect_to_inbox.py'} --root {get_sync_root()}
|
||||
汇报结果:扫描了几个目录、处理了几个文件、新增了几个收件箱文件。""",
|
||||
},
|
||||
{
|
||||
"name": "AgentOS 收件箱提纯",
|
||||
"schedule_type": "recurring",
|
||||
"rrule": "FREQ=DAILY;BYHOUR=3;BYMINUTE=0",
|
||||
"prompt": f"""执行 AgentOS 收件箱提纯任务:每日将 00_inbox/ 中的内容分类归档到知识库,更新首页统计和变更日志。
|
||||
执行命令:
|
||||
{get_python()} {get_sync_root() / '02_skills/inbox_refine/inbox_refine.py'} --root {get_sync_root()}
|
||||
汇报结果:处理了几个收件箱文件、归档到了哪个分类目录。""",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def step_local_dirs(dry_run: bool):
|
||||
"""步骤1: 创建 agent-local/ 目录结构"""
|
||||
local_root = get_local_root()
|
||||
info(f"创建本地目录结构: {local_root}")
|
||||
for subdir in LOCAL_DIRS:
|
||||
target = local_root / subdir
|
||||
if not target.exists():
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] 创建: {target}")
|
||||
else:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
ok(f" 创建: {subdir}")
|
||||
else:
|
||||
ok(f" 已存在: {subdir}")
|
||||
|
||||
|
||||
def step_symlinks(dry_run: bool):
|
||||
"""步骤2: 重建软链接"""
|
||||
sync_root = get_sync_root()
|
||||
local_root = get_local_root()
|
||||
info("重建软链接...")
|
||||
for link_rel, target_rel in SYMLINKS.items():
|
||||
link_path = sync_root / link_rel
|
||||
target_path = local_root / target_rel
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] 软链: {link_rel} → {target_path}")
|
||||
continue
|
||||
if link_path.is_symlink():
|
||||
current_target = os.readlink(str(link_path))
|
||||
if current_target == str(target_path):
|
||||
ok(f" 软链正确: {link_rel}")
|
||||
continue
|
||||
info(f" 更新软链: {link_rel} → {target_path}")
|
||||
link_path.unlink()
|
||||
elif link_path.exists():
|
||||
warn(f" {link_rel} 是真实目录,保留不动")
|
||||
continue
|
||||
link_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
link_path.symlink_to(target_path)
|
||||
ok(f" 创建软链: {link_rel} → {target_path}")
|
||||
|
||||
|
||||
def step_install_skills(dry_run: bool):
|
||||
"""步骤3: 注册技能到 WorkBuddy"""
|
||||
if dry_run:
|
||||
info(" [DRY-RUN] 安装所有技能到 WorkBuddy")
|
||||
return
|
||||
info("安装技能到 WorkBuddy...")
|
||||
skill_mgr.install_all(get_sync_root() / "02_skills")
|
||||
|
||||
|
||||
def step_deploy_mcp(dry_run: bool):
|
||||
"""步骤4: 部署 MCP 配置"""
|
||||
sync_root = get_sync_root()
|
||||
mcp_src = sync_root / "01_core" / "mcp.json"
|
||||
mcp_dst = Path.home() / ".workbuddy" / "mcp.json"
|
||||
|
||||
if not mcp_src.exists():
|
||||
warn(f"MCP 配置源文件不存在: {mcp_src}")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] 部署 MCP: {mcp_src} → {mcp_dst}")
|
||||
return
|
||||
|
||||
# 备份现有配置
|
||||
if mcp_dst.exists():
|
||||
backup_dst = mcp_dst.with_suffix(".json.bak")
|
||||
shutil.copy2(str(mcp_dst), str(backup_dst))
|
||||
info(f" 备份旧 MCP 配置: {backup_dst}")
|
||||
|
||||
shutil.copy2(str(mcp_src), str(mcp_dst))
|
||||
ok(f"MCP 配置已部署: {mcp_dst}")
|
||||
|
||||
|
||||
def step_create_automations(dry_run: bool):
|
||||
"""步骤5: 创建自动化任务"""
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] 创建 {len(AUTOMATIONS)} 个自动化任务")
|
||||
return
|
||||
# 自动化任务通过 WorkBuddy 自动化工具创建
|
||||
# 当前方案:在 init 完成后提示用户手动在 WorkBuddy 中配置
|
||||
info("自动化任务定义:")
|
||||
for a in AUTOMATIONS:
|
||||
info(f" 📋 {a['name']} — {a['rrule']}")
|
||||
info("请在新机 WorkBuddy 中配置自动化任务,或运行 agentos sync 同步")
|
||||
|
||||
|
||||
def step_rebuild_vector_db(dry_run: bool):
|
||||
"""步骤6: 重建向量库"""
|
||||
py = get_python()
|
||||
script = get_sync_root() / "02_skills/kb_manager/vector_db_rebuild.py"
|
||||
if not script.exists():
|
||||
warn(f"向量库重建脚本不存在: {script},跳过")
|
||||
return
|
||||
if dry_run:
|
||||
info(f" [DRY-RUN] 重建向量库: {py} {script}")
|
||||
return
|
||||
info("重建向量库...")
|
||||
result = run([
|
||||
py, str(script),
|
||||
"--root", str(get_sync_root()),
|
||||
"--local", str(get_local_root()),
|
||||
"--track", "both",
|
||||
])
|
||||
if result.returncode == 0:
|
||||
ok("向量库重建完成")
|
||||
else:
|
||||
warn(f"向量库重建失败:\n{result.stderr}")
|
||||
|
||||
|
||||
def step_verify(dry_run: bool):
|
||||
"""步骤7: 环境验证"""
|
||||
if dry_run:
|
||||
info(" [DRY-RUN] 环境验证跳过")
|
||||
return
|
||||
info("验证环境...")
|
||||
|
||||
# 验证 Python
|
||||
py = get_python()
|
||||
result = run([py, "--version"])
|
||||
ok(f"Python: {result.stdout.strip()}" if result.returncode == 0 else "Python: 异常")
|
||||
|
||||
# 验证 oMLX
|
||||
import json, urllib.request
|
||||
try:
|
||||
data = json.dumps({"model": "mlx-community--Qwen3-Embedding-0.6B-4bit-DWQ", "input": ["test"]}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8000/v1/embeddings",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer 5omlx", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
if resp.status == 200:
|
||||
ok("oMLX Embedding API: 正常")
|
||||
else:
|
||||
warn(f"oMLX Embedding API: 状态码 {resp.status}")
|
||||
except Exception as e:
|
||||
warn(f"oMLX Embedding API: 连接失败 ({type(e).__name__})")
|
||||
|
||||
# 验证 facts.db
|
||||
facts_db = get_sync_root() / "04_memory/long_term/facts.db"
|
||||
if facts_db.exists():
|
||||
size_kb = facts_db.stat().st_size / 1024
|
||||
ok(f"facts.db: {size_kb:.1f} KB")
|
||||
else:
|
||||
warn("facts.db 不存在(首次初始化正常)")
|
||||
|
||||
|
||||
def step_check_path(dry_run: bool):
|
||||
"""检查 agentos CLI 是否在 PATH 中,不在则添加"""
|
||||
local_bin = Path.home() / ".local" / "bin"
|
||||
if not local_bin.exists():
|
||||
return
|
||||
if not (local_bin / "agentos").exists():
|
||||
return
|
||||
|
||||
path_str = os.environ.get("PATH", "")
|
||||
local_bin_str = str(local_bin)
|
||||
if local_bin_str in path_str:
|
||||
info(f"agentos CLI 已在 PATH 中: {local_bin}/agentos")
|
||||
return
|
||||
|
||||
shell_rc = None
|
||||
shell = os.environ.get("SHELL", "")
|
||||
if "zsh" in shell:
|
||||
shell_rc = Path.home() / ".zshrc"
|
||||
elif "bash" in shell:
|
||||
shell_rc = Path.home() / ".bash_profile"
|
||||
|
||||
if shell_rc and shell_rc.exists():
|
||||
export_line = f'\nexport PATH="$HOME/.local/bin:$PATH"\n'
|
||||
if dry_run:
|
||||
info(f"[DRY-RUN] 将添加 PATH 到 {shell_rc}")
|
||||
return
|
||||
with open(shell_rc, "a") as f:
|
||||
f.write(export_line)
|
||||
ok(f"已添加 ~/.local/bin 到 PATH ({shell_rc})")
|
||||
info(f"执行以下命令生效: source {shell_rc.name}")
|
||||
else:
|
||||
warn(f"~/.local/bin 未在 PATH 中")
|
||||
info(f"手动添加: echo 'export PATH=\"$HOME/.local/bin:\$PATH\"' >> ~/.zshrc")
|
||||
|
||||
|
||||
def do_init(args):
|
||||
"""执行 init 流程"""
|
||||
dry_run = args.dry_run
|
||||
skip_deps = args.skip_deps
|
||||
|
||||
banner()
|
||||
info("开始换机一键初始化" + (" [DRY-RUN]" if dry_run else ""))
|
||||
print()
|
||||
|
||||
# 依次执行各步骤
|
||||
step_local_dirs(dry_run)
|
||||
print()
|
||||
step_symlinks(dry_run)
|
||||
print()
|
||||
if not skip_deps:
|
||||
from . import check as check_mod
|
||||
check_mod.install_deps(dry_run)
|
||||
print()
|
||||
step_install_skills(dry_run)
|
||||
print()
|
||||
step_deploy_mcp(dry_run)
|
||||
print()
|
||||
step_create_automations(dry_run)
|
||||
print()
|
||||
step_rebuild_vector_db(dry_run)
|
||||
print()
|
||||
step_verify(dry_run)
|
||||
|
||||
# 检查 agentos CLI 是否在 PATH 中
|
||||
step_check_path(dry_run)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
if dry_run:
|
||||
info("DRY-RUN 完成,以上为即将执行的操作")
|
||||
else:
|
||||
ok("初始化完成!请重启 WorkBuddy 使更改生效。")
|
||||
info("后续操作:")
|
||||
info(" 1. 重启 WorkBuddy")
|
||||
info(" 2. 如有旧机备份,运行: agentos restore <备份文件>")
|
||||
info(" 3. 运行: agentos upgrade 统一升级所有模块")
|
||||
info(" 4. 运行: agentos check 验证系统状态")
|
||||
info(" 5. 运行: agentos localize 生成本机身份配置")
|
||||
print()
|
||||
|
||||
# 自动注册到集群
|
||||
if not dry_run:
|
||||
info("自动注册本机到集群...")
|
||||
try:
|
||||
import subprocess
|
||||
cluster_script = str(get_sync_root() / "05_tools" / "01_system" / "cluster_registry.py")
|
||||
result = subprocess.run(
|
||||
[sys.executable, cluster_script, "register"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ok("集群注册成功")
|
||||
info("查看集群状态: agentos cluster-status")
|
||||
else:
|
||||
warn("集群注册失败(不影响初始化)")
|
||||
except Exception as e:
|
||||
warn(f"集群注册异常: {e}")
|
||||
|
||||
|
||||
def do_localize(args):
|
||||
"""从模板生成本机身份配置"""
|
||||
from pathlib import Path
|
||||
from .utils import get_sync_root, get_local_root, info, ok, warn
|
||||
|
||||
sync_root = get_sync_root()
|
||||
local_root = get_local_root()
|
||||
identity_dir = local_root / "identity"
|
||||
identity_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
templates = {
|
||||
sync_root / "01_core" / "IDENTITY.tpl.md": identity_dir / "IDENTITY.md",
|
||||
sync_root / "01_core" / "USER.tpl.md": identity_dir / "USER.md",
|
||||
sync_root / "01_core" / "HOST_ID.tpl.md": identity_dir / "HOST_ID.md",
|
||||
}
|
||||
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" 🆔 AgentOS 本机身份生成")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
for src, dst in templates.items():
|
||||
if not src.exists():
|
||||
warn(f"模板文件不存在: {src}")
|
||||
continue
|
||||
if dry_run:
|
||||
info(f"[DRY-RUN] 生成: {dst}")
|
||||
continue
|
||||
if dst.exists():
|
||||
ok(f" ✓ {dst.name} (跳过,已存在)")
|
||||
continue
|
||||
import shutil
|
||||
shutil.copy2(str(src), str(dst))
|
||||
ok(f" ✓ {dst.name}")
|
||||
|
||||
host_id = identity_dir / "HOST_ID.md"
|
||||
if host_id.exists() and not dry_run:
|
||||
print()
|
||||
info("HOST_ID.md 已生成,默认角色为 node(最低权限)。")
|
||||
info("如需提升权限,编辑角色字段:")
|
||||
print(f" 文件路径: {host_id}")
|
||||
print()
|
||||
print(" 可选角色: master, maintainer, node")
|
||||
print(" master → 知识提纯/记忆汇总/核心维护")
|
||||
print(" maintainer → 内容采集/本地记忆/提交有价值内容")
|
||||
print(" node → 信息采集/素材上传(默认)")
|
||||
print()
|
||||
info("安全建议:先以 node 运行,确认无误后再提升权限。")
|
||||
print()
|
||||
|
||||
ok("本机身份文件生成完成")
|
||||
info(f"身份目录: {identity_dir}")
|
||||
info("可手动编辑 HOST_ID.md 修改角色和能力开关")
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# agentos CLI 安装脚本
|
||||
# 将 agentos 安装到 ~/.local/bin/ 并将其添加到 PATH
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AGENTOS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
|
||||
echo "========================================="
|
||||
echo " AgentOS CLI 安装"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "来源: $AGENTOS_DIR"
|
||||
echo "目标: $INSTALL_DIR/agentos"
|
||||
echo ""
|
||||
|
||||
# 创建 ~/.local/bin/
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# 创建入口脚本
|
||||
cat > "$INSTALL_DIR/agentos" << 'ENTRYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""agentos CLI 入口:自动定位到 agent-sync 并启动主程序"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 尝试定位 agent-sync 根目录
|
||||
CANDIDATES = [
|
||||
os.environ.get("AGENT_SYNC_ROOT"),
|
||||
str(Path.home() / "workbuddy-agent-os" / "agent-sync"),
|
||||
str(Path.home() / "agent-os"),
|
||||
]
|
||||
|
||||
AGENTOS_MODULE = None
|
||||
for root in CANDIDATES:
|
||||
if root:
|
||||
module_path = Path(root) / "05_tools" / "00_setup" / "agentos"
|
||||
if module_path.exists() and (module_path / "main.py").exists():
|
||||
AGENTOS_MODULE = str(module_path)
|
||||
break
|
||||
|
||||
if not AGENTOS_MODULE:
|
||||
print("❌ 无法定位 AgentOS 模块。请设置环境变量 AGENT_SYNC_ROOT")
|
||||
sys.exit(1)
|
||||
|
||||
sys.path.insert(0, str(Path(AGENTOS_MODULE).parent))
|
||||
from agentos.main import main
|
||||
main()
|
||||
ENTRYEOF
|
||||
|
||||
chmod +x "$INSTALL_DIR/agentos"
|
||||
|
||||
# 检查 PATH 是否包含 ~/.local/bin
|
||||
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
echo "⚠️ 请将 ~/.local/bin 添加到 PATH:"
|
||||
echo ""
|
||||
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
|
||||
echo " source ~/.zshrc"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "✅ 安装完成!"
|
||||
echo ""
|
||||
echo "运行测试: agentos --version"
|
||||
echo "帮助: agentos --help"
|
||||
echo ""
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
agentos — AgentOS 统一命令入口
|
||||
|
||||
系统管理:
|
||||
agentos init 换机一键初始化
|
||||
agentos sync 双机增量同步
|
||||
agentos skill <子命令> 技能管理
|
||||
agentos tool <子命令> 工具管理
|
||||
agentos config <子命令> 配置管理
|
||||
agentos check 全系统健康检查
|
||||
agentos backup 备份 agent-local/
|
||||
agentos upgrade 模块升级
|
||||
agentos restore <路径> 从备份还原
|
||||
agentos rebuild-vector 重建向量数据库
|
||||
agentos localize 生成本机身份
|
||||
agentos register 注册到集群
|
||||
agentos cluster-status 集群状态
|
||||
agentos cluster-cleanup 清理集群
|
||||
|
||||
联邦管理(来自 07_matrix/scripts/agentos/plugins/):
|
||||
agentos matrix 社交矩阵 — 抖音/小红书运营
|
||||
agentos ave 视频工厂 — 视频制作与编辑
|
||||
agentos crawl 内容采集 — 互联网内容抓取
|
||||
agentos fleet 联邦管理 — 多机协同
|
||||
agentos serve 服务管理 — MCP/Dashboard/调度
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from .const import __version__
|
||||
from .utils import banner, get_sync_root
|
||||
|
||||
|
||||
def _load_federation_plugins():
|
||||
"""从 07_matrix/scripts/agentos/ 加载联邦插件"""
|
||||
sync_root = get_sync_root()
|
||||
plugin_base = sync_root / "05_tools" / "07_matrix" / "scripts" / "agentos" / "plugins"
|
||||
if not plugin_base.exists():
|
||||
return []
|
||||
|
||||
# 动态加载 agentos.base 模块
|
||||
agentos_pkg = plugin_base.parent
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"agentos.base",
|
||||
agentos_pkg / "base.py",
|
||||
submodule_search_locations=[str(agentos_pkg)]
|
||||
)
|
||||
if not spec or not spec.loader:
|
||||
return []
|
||||
|
||||
base_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["agentos.base"] = base_mod
|
||||
spec.loader.exec_module(base_mod)
|
||||
|
||||
# 扫描 plugins/ 下的每个 .py 文件
|
||||
plugins = []
|
||||
for f in sorted(plugin_base.iterdir()):
|
||||
if f.name.startswith("_") or f.suffix != ".py":
|
||||
continue
|
||||
# 动态加载插件模块
|
||||
mod_name = f"agentos.plugins.{f.stem}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
mod_name, f,
|
||||
submodule_search_locations=[str(plugin_base)]
|
||||
)
|
||||
if not spec or not spec.loader:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# 让模块中的 `from agentos.base import ...` 能找到 base
|
||||
sys.modules["agentos"] = type(sys)("agentos")
|
||||
sys.modules["agentos"].plugins = type(sys)("plugins")
|
||||
sys.modules[mod_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# 查找 AgentOSPlugin 子类
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name)
|
||||
if isinstance(attr, type) and issubclass(attr, base_mod.AgentOSPlugin) and attr is not base_mod.AgentOSPlugin:
|
||||
plugins.append(attr())
|
||||
|
||||
return plugins, base_mod
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agentos",
|
||||
description="AgentOS 统一命令入口 — 系统管理 + 联邦管理",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
系统管理:
|
||||
agentos init / sync / skill / tool / config / check
|
||||
agentos backup / restore / upgrade / rebuild-vector
|
||||
agentos register / cluster-status / cluster-cleanup
|
||||
|
||||
联邦管理:
|
||||
agentos matrix / ave / crawl / fleet / serve
|
||||
|
||||
参见:
|
||||
agentos <command> --help
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"agentos v{__version__}"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
||||
|
||||
# ---- 系统管理命令 ----
|
||||
p_init = subparsers.add_parser(
|
||||
"init", help="换机一键初始化:目录/依赖/技能/MCP/自动化"
|
||||
)
|
||||
p_init.add_argument("--dry-run", action="store_true", help="仅预览,不执行")
|
||||
p_init.add_argument("--skip-deps", action="store_true", help="跳过依赖安装")
|
||||
|
||||
p_sync = subparsers.add_parser(
|
||||
"sync", help="双机增量同步:检测差异并更新本地 WorkBuddy"
|
||||
)
|
||||
p_sync.add_argument("--dry-run", action="store_true", help="仅报告差异,不执行")
|
||||
p_sync.add_argument("--force", action="store_true", help="强制全量覆盖")
|
||||
|
||||
p_skill = subparsers.add_parser("skill", help="技能管理")
|
||||
skill_sub = p_skill.add_subparsers(dest="skill_cmd", help="技能子命令")
|
||||
p_skill_list = skill_sub.add_parser("list", help="列出所有可用技能")
|
||||
p_skill_list.add_argument("--verbose", "-v", action="store_true", help="显示详细信息")
|
||||
skill_sub.add_parser("install", help="安装/更新所有技能到 WorkBuddy")
|
||||
skill_sub.add_parser("check", help="检查技能一致性")
|
||||
p_skill_build = skill_sub.add_parser("build", help="打包技能为 .skill 压缩包")
|
||||
p_skill_build.add_argument("skill_name", help="技能名称(对应 02_skills/ 下的目录名)")
|
||||
p_skill_build.add_argument("--output", "-o", default=None, help="输出路径(默认: 07_migration/exports/)")
|
||||
p_skill_search = skill_sub.add_parser("search", help="搜索技能")
|
||||
p_skill_search.add_argument("keyword", help="搜索关键词")
|
||||
p_skill_rm = skill_sub.add_parser("uninstall", help="从 WorkBuddy 卸载技能")
|
||||
p_skill_rm.add_argument("skill_name", help="技能名称")
|
||||
|
||||
p_tool = subparsers.add_parser("tool", help="工具管理")
|
||||
tool_sub = p_tool.add_subparsers(dest="tool_cmd", help="工具子命令")
|
||||
tool_sub.add_parser("list", help="列出所有可用工具")
|
||||
p_tool_trae = tool_sub.add_parser("trae", help="Trae Agent AI 编程助手")
|
||||
trae_sub = p_tool_trae.add_subparsers(dest="trae_cmd", help="trae 子命令")
|
||||
p_trae_run = trae_sub.add_parser("run", help="执行任务")
|
||||
p_trae_run.add_argument("task", nargs="*", help="任务描述")
|
||||
p_trae_run.add_argument("-p", "--provider", help="LLM 提供商")
|
||||
p_trae_run.add_argument("-m", "--model", help="模型名称")
|
||||
p_trae_run.add_argument("-f", "--file", help="从文件读取任务")
|
||||
trae_sub.add_parser("interactive", help="交互式模式")
|
||||
trae_sub.add_parser("config", help="查看配置")
|
||||
trae_sub.add_parser("install", help="安装 trae-agent")
|
||||
|
||||
from . import config_mgr as config_mod
|
||||
config_mod.setup_parser(subparsers)
|
||||
|
||||
p_check = subparsers.add_parser("check", help="全系统健康检查")
|
||||
p_check.add_argument("--quick", action="store_true", help="快速检查(仅核心项)")
|
||||
|
||||
p_backup = subparsers.add_parser("backup", help="备份 agent-local/(materials + memory/raw)")
|
||||
p_backup.add_argument("--output", "-o", default=None, help="输出目录(默认: 当前目录)")
|
||||
p_backup.add_argument("--full", action="store_true", help="打包 agent-sync/ + agent-local/ 全量")
|
||||
|
||||
from . import upgrade as upgrade_mod
|
||||
upgrade_mod.setup_parser(subparsers)
|
||||
|
||||
p_restore = subparsers.add_parser("restore", help="从备份文件还原 agent-local/")
|
||||
p_restore.add_argument("backup_path", help="备份文件路径(.tar.gz)")
|
||||
p_restore.add_argument("--force", action="store_true", help="覆盖现有文件")
|
||||
|
||||
p_vector = subparsers.add_parser("rebuild-vector", help="本地重建向量数据库(升级后执行)")
|
||||
p_vector.add_argument("--track", choices=["local", "global", "both"], default="both", help="重建范围 (默认 both)")
|
||||
p_vector.add_argument("--incremental", action="store_true", help="增量模式")
|
||||
p_vector.add_argument("--dry-run", action="store_true", help="仅预览,不执行")
|
||||
|
||||
subparsers.add_parser("localize", help="从模板生成本机身份配置")
|
||||
subparsers.add_parser("register", help="注册本机到集群注册表")
|
||||
subparsers.add_parser("cluster-status", help="查看集群所有机器状态")
|
||||
subparsers.add_parser("cluster-cleanup", help="清理过期注册条目")
|
||||
|
||||
# ---- 联邦管理命令(从 07_matrix/ 插件自动加载)----
|
||||
fed_plugins = []
|
||||
fed_base_mod = None
|
||||
try:
|
||||
fed_plugins, fed_base_mod = _load_federation_plugins()
|
||||
for plugin in fed_plugins:
|
||||
plugin.register(subparsers)
|
||||
except Exception:
|
||||
pass # 插件加载失败不影响系统命令
|
||||
|
||||
# ---- 解析 ----
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
banner()
|
||||
parser.print_help()
|
||||
print()
|
||||
print(" 输入 agentos <command> --help 查看子命令详情")
|
||||
print()
|
||||
return
|
||||
|
||||
# ---- 路由到系统管理模块 ----
|
||||
sys_commands = {
|
||||
"init": (".init", "do_init"),
|
||||
"sync": (".sync", "run"),
|
||||
"skill": (".skill_mgr", "run"),
|
||||
"tool": (".tool_mgr", "run"),
|
||||
"config": (".config_mgr", "run"),
|
||||
"check": (".check", "cmd_run"),
|
||||
"backup": (".backup", "run"),
|
||||
"upgrade": (".upgrade", "do_upgrade"),
|
||||
"restore": (".backup", "run_restore"),
|
||||
"rebuild-vector": (".upgrade", "do_rebuild_vector"),
|
||||
"localize": (".init", "do_localize"),
|
||||
"register": (".upgrade", "do_cluster_register"),
|
||||
"cluster-status": (".upgrade", "do_cluster_status"),
|
||||
"cluster-cleanup": (".upgrade", "do_cluster_cleanup"),
|
||||
}
|
||||
|
||||
if args.command in sys_commands:
|
||||
mod_path, func_name = sys_commands[args.command]
|
||||
import importlib
|
||||
mod = importlib.import_module(mod_path, "agentos")
|
||||
getattr(mod, func_name)(args)
|
||||
return
|
||||
|
||||
# ---- 路由到联邦插件 ----
|
||||
for plugin in fed_plugins:
|
||||
if args.command == plugin.name:
|
||||
sys.exit(plugin.dispatch(args))
|
||||
|
||||
# 未识别的命令
|
||||
print(f"未知命令: {args.command}")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
agentos skill — 技能管理
|
||||
|
||||
子命令: list / install / check / build / search / uninstall
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from .utils import (
|
||||
get_sync_root, get_local_root, get_python,
|
||||
info, ok, warn, err, run, banner
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 内部函数
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def get_skill_dirs(skills_root: Path) -> list:
|
||||
"""从 skills_root 扫描所有技能目录"""
|
||||
if not skills_root.exists():
|
||||
return []
|
||||
result = []
|
||||
for d in sorted(skills_root.iterdir()):
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
def read_skill_card(skill_dir: Path) -> dict:
|
||||
"""读取 SKILL_CARD.yaml 或 SKILL.md 中的元数据"""
|
||||
card = {"name": skill_dir.name, "version": "?", "status": "?", "description": ""}
|
||||
|
||||
# 优先读 SKILL_CARD.yaml
|
||||
yaml_path = skill_dir / "SKILL_CARD.yaml"
|
||||
if yaml_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
card["version"] = data.get("version", "?")
|
||||
card["status"] = data.get("status", "?")
|
||||
card["description"] = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 从 SKILL.md 提取触发词
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
content = skill_md.read_text()
|
||||
triggers = []
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("- "):
|
||||
triggers.append(line[2:])
|
||||
card["triggers"] = triggers[:5] # 最多取前5个
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def get_workbuddy_skills_dir() -> Path:
|
||||
"""获取 WorkBuddy 技能目录"""
|
||||
return Path.home() / ".workbuddy" / "skills"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 子命令
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def cmd_list(verbose: bool):
|
||||
"""列出所有可用技能"""
|
||||
banner()
|
||||
sync_root = get_sync_root()
|
||||
skills_root = sync_root / "02_skills"
|
||||
|
||||
dirs = get_skill_dirs(skills_root)
|
||||
if not dirs:
|
||||
info(f"技能目录为空: {skills_root}")
|
||||
return
|
||||
|
||||
print(f"{'技能名':<22} {'版本':<8} {'状态':<10} {'描述'}")
|
||||
print("-" * 70)
|
||||
for d in dirs:
|
||||
card = read_skill_card(d)
|
||||
desc = card.get("description") or ""
|
||||
if isinstance(desc, dict):
|
||||
desc = desc.get("short", "")
|
||||
desc = desc[:40] if isinstance(desc, str) else ""
|
||||
print(f"{d.name:<22} {card['version']:<8} {card['status']:<10} {desc}")
|
||||
|
||||
print(f"\n共 {len(dirs)} 个技能 ({skills_root})")
|
||||
print()
|
||||
|
||||
if verbose:
|
||||
print("触发词详情:")
|
||||
for d in dirs:
|
||||
card = read_skill_card(d)
|
||||
triggers = card.get("triggers", [])
|
||||
if triggers:
|
||||
print(f" {d.name}: {' | '.join(triggers)}")
|
||||
|
||||
|
||||
def cmd_install():
|
||||
"""安装所有技能到 WorkBuddy"""
|
||||
banner()
|
||||
sync_root = get_sync_root()
|
||||
skills_root = sync_root / "02_skills"
|
||||
wb_skills = get_workbuddy_skills_dir()
|
||||
wb_skills.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dirs = get_skill_dirs(skills_root)
|
||||
if not dirs:
|
||||
warn(f"没有可安装的技能: {skills_root}")
|
||||
return
|
||||
|
||||
installed = 0
|
||||
updated = 0
|
||||
for d in dirs:
|
||||
dest = wb_skills / d.name
|
||||
if dest.exists():
|
||||
shutil.rmtree(str(dest))
|
||||
updated += 1
|
||||
shutil.copytree(str(d), str(dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
installed += 1
|
||||
|
||||
ok(f"技能安装完成: 新增 {installed},更新 {updated}")
|
||||
info(f"目标目录: {wb_skills}")
|
||||
info("重启 WorkBuddy 后新技能将生效")
|
||||
|
||||
|
||||
def install_all(skills_root: Path):
|
||||
"""(被 init 调用) 安装所有技能"""
|
||||
wb_skills = get_workbuddy_skills_dir()
|
||||
wb_skills.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dirs = get_skill_dirs(skills_root)
|
||||
for d in dirs:
|
||||
dest = wb_skills / d.name
|
||||
if dest.exists():
|
||||
shutil.rmtree(str(dest))
|
||||
shutil.copytree(str(d), str(dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
ok(f" 技能已安装: {d.name}")
|
||||
|
||||
|
||||
def cmd_check():
|
||||
"""检查技能一致性"""
|
||||
banner()
|
||||
sync_root = get_sync_root()
|
||||
wb_skills = get_workbuddy_skills_dir()
|
||||
|
||||
source_dirs = {d.name: d for d in get_skill_dirs(sync_root / "02_skills")}
|
||||
wb_dirs = {d.name: d for d in get_skill_dirs(wb_skills)}
|
||||
|
||||
# 检查源有但 WB 没有的
|
||||
missing = set(source_dirs.keys()) - set(wb_dirs.keys())
|
||||
if missing:
|
||||
for name in sorted(missing):
|
||||
warn(f"技能未安装到 WorkBuddy: {name}")
|
||||
|
||||
# 检查版本差异
|
||||
diffs = []
|
||||
for name in set(source_dirs.keys()) & set(wb_dirs.keys()):
|
||||
src_card = read_skill_card(source_dirs[name])
|
||||
wb_card = read_skill_card(wb_dirs[name])
|
||||
if src_card["version"] != wb_card["version"]:
|
||||
diffs.append((name, src_card["version"], wb_card["version"]))
|
||||
|
||||
if diffs:
|
||||
for name, sv, wv in diffs:
|
||||
info(f"版本差异: {name} (源: {sv} vs WB: {wv})")
|
||||
|
||||
if not missing and not diffs:
|
||||
ok("所有技能一致,无差异")
|
||||
else:
|
||||
info("运行 agentos skill install 以同步")
|
||||
|
||||
|
||||
def cmd_build(skill_name: str, output_path: str = None):
|
||||
"""打包技能为 .skill 压缩包"""
|
||||
banner()
|
||||
skills_root = get_sync_root() / "02_skills"
|
||||
skill_dir = skills_root / skill_name
|
||||
|
||||
if not skill_dir.exists() or not (skill_dir / "SKILL.md").exists():
|
||||
err(f"技能不存在: {skill_name} ({skill_dir})")
|
||||
sys.exit(1)
|
||||
|
||||
# 读取版本号
|
||||
card = read_skill_card(skill_dir)
|
||||
version = card["version"]
|
||||
|
||||
# 确定输出路径
|
||||
if output_path:
|
||||
out_dir = Path(output_path)
|
||||
else:
|
||||
out_dir = get_sync_root() / "07_migration" / "exports"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
archive_name = f"{skill_name}_v{version}.tar.gz"
|
||||
archive_path = out_dir / archive_name
|
||||
|
||||
with tarfile.open(str(archive_path), "w:gz") as tar:
|
||||
tar.add(str(skill_dir), arcname=skill_name)
|
||||
|
||||
file_size = archive_path.stat().st_size / 1024
|
||||
ok(f"技能包已创建: {archive_path} ({file_size:.1f} KB)")
|
||||
info(f"安装命令: agentos skill install (放到新机后)")
|
||||
info(f"手动安装: 解压到 ~/.workbuddy/skills/{skill_name}/")
|
||||
|
||||
|
||||
def cmd_search(keyword: str):
|
||||
"""搜索技能"""
|
||||
banner()
|
||||
skills_root = get_sync_root() / "02_skills"
|
||||
keyword_lower = keyword.lower()
|
||||
|
||||
results = []
|
||||
for d in get_skill_dirs(skills_root):
|
||||
card = read_skill_card(d)
|
||||
desc = card.get("description", "").lower()
|
||||
name = d.name.lower()
|
||||
triggers = " ".join(card.get("triggers", [])).lower()
|
||||
|
||||
if keyword_lower in name or keyword_lower in desc or keyword_lower in triggers:
|
||||
results.append((d.name, card["version"], card.get("description", "")))
|
||||
|
||||
if results:
|
||||
print(f"找到 {len(results)} 个匹配技能:")
|
||||
for name, ver, desc in results:
|
||||
print(f" {name:<20} v{ver:<6} {desc[:50]}")
|
||||
else:
|
||||
info(f"未找到匹配 '{keyword}' 的技能")
|
||||
|
||||
|
||||
def cmd_uninstall(skill_name: str):
|
||||
"""从 WorkBuddy 卸载技能"""
|
||||
wb_dir = get_workbuddy_skills_dir() / skill_name
|
||||
if not wb_dir.exists():
|
||||
err(f"技能未安装: {skill_name}")
|
||||
sys.exit(1)
|
||||
|
||||
shutil.rmtree(str(wb_dir))
|
||||
ok(f"技能已卸载: {skill_name}")
|
||||
info("重启 WorkBuddy 后生效")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 路由
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def run(args):
|
||||
cmd = args.skill_cmd
|
||||
if not cmd:
|
||||
banner()
|
||||
print("agentos skill 子命令: list | install | check | build | search | uninstall")
|
||||
print()
|
||||
return
|
||||
|
||||
if cmd == "list":
|
||||
cmd_list(verbose=args.verbose)
|
||||
elif cmd == "install":
|
||||
cmd_install()
|
||||
elif cmd == "check":
|
||||
cmd_check()
|
||||
elif cmd == "build":
|
||||
cmd_build(args.skill_name, args.output)
|
||||
elif cmd == "search":
|
||||
cmd_search(args.keyword)
|
||||
elif cmd == "uninstall":
|
||||
cmd_uninstall(args.skill_name)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
agentos sync — 双机增量同步
|
||||
|
||||
检查 agent-sync/ 与本地 WorkBuddy 的差异并增量更新:
|
||||
1. 技能差异检测(对比版本号和文件变化)
|
||||
2. MCP 配置差异检测
|
||||
3. 自动化任务差异检测
|
||||
4. 增量修复差异项
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import (
|
||||
get_sync_root, get_local_root, get_python,
|
||||
info, ok, warn, err, run, banner
|
||||
)
|
||||
from . import skill_mgr
|
||||
|
||||
|
||||
def file_hash(path: Path) -> str:
|
||||
"""计算文件 SHA256"""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def diff_skills(dry_run: bool) -> list:
|
||||
"""检测技能差异"""
|
||||
sync_root = get_sync_root()
|
||||
wb_skills = Path.home() / ".workbuddy" / "skills"
|
||||
source_dir = sync_root / "02_skills"
|
||||
|
||||
changes = []
|
||||
source_dirs = {d.name: d for d in skill_mgr.get_skill_dirs(source_dir)}
|
||||
wb_dirs = {d.name: d for d in skill_mgr.get_skill_dirs(wb_skills)} if wb_skills.exists() else {}
|
||||
|
||||
for name, src_dir in source_dirs.items():
|
||||
if name not in wb_dirs:
|
||||
changes.append({"type": "missing", "name": name, "action": "install"})
|
||||
continue
|
||||
|
||||
src_card = skill_mgr.read_skill_card(src_dir)
|
||||
wb_card = skill_mgr.read_skill_card(wb_dirs[name])
|
||||
|
||||
if src_card["version"] != wb_card["version"]:
|
||||
changes.append({
|
||||
"type": "version_diff",
|
||||
"name": name,
|
||||
"action": "update",
|
||||
"src_version": src_card["version"],
|
||||
"wb_version": wb_card["version"],
|
||||
})
|
||||
|
||||
for name in wb_dirs:
|
||||
if name not in source_dirs:
|
||||
changes.append({"type": "extra", "name": name, "action": "uninstall"})
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def diff_mcp(dry_run: bool) -> list:
|
||||
"""检测 MCP 配置差异"""
|
||||
sync_root = get_sync_root()
|
||||
src = sync_root / "01_core" / "mcp.json"
|
||||
dst = Path.home() / ".workbuddy" / "mcp.json"
|
||||
|
||||
changes = []
|
||||
if not src.exists():
|
||||
return changes
|
||||
|
||||
if not dst.exists():
|
||||
changes.append({"type": "missing", "name": "mcp.json", "action": "deploy"})
|
||||
else:
|
||||
src_hash = file_hash(src)
|
||||
dst_hash = file_hash(dst)
|
||||
if src_hash != dst_hash:
|
||||
changes.append({"type": "content_diff", "name": "mcp.json", "action": "update"})
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def diff_automations(dry_run: bool) -> list:
|
||||
"""检测自动化任务差异"""
|
||||
sync_root = get_sync_root()
|
||||
automations_file = sync_root / "01_core" / "automations.yaml"
|
||||
if not automations_file.exists():
|
||||
return []
|
||||
return [{"type": "info", "name": "automations.yaml", "action": "需手动在 WorkBuddy 中比对"}]
|
||||
|
||||
|
||||
def apply_changes(changes: list, dry_run: bool, force: bool):
|
||||
"""执行差异修复"""
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
for ch in changes:
|
||||
action = ch["action"]
|
||||
|
||||
if action == "install":
|
||||
info(f" 安装技能: {ch['name']}")
|
||||
src_dir = get_sync_root() / "02_skills" / ch["name"]
|
||||
skill_mgr.install_all(get_sync_root() / "02_skills")
|
||||
|
||||
elif action == "update":
|
||||
info(f" 更新技能: {ch['name']} ({ch.get('src_version', '?')})")
|
||||
skill_mgr.install_all(get_sync_root() / "02_skills")
|
||||
|
||||
elif action == "uninstall":
|
||||
warn(f" agent-sync 中已移除: {ch['name']}(建议手动清理 WorkBuddy)")
|
||||
|
||||
elif action == "deploy":
|
||||
info(f" 部署 MCP 配置")
|
||||
src = get_sync_root() / "01_core" / "mcp.json"
|
||||
dst = Path.home() / ".workbuddy" / "mcp.json"
|
||||
if dst.exists():
|
||||
dst.rename(dst.with_suffix(".json.syncbak"))
|
||||
shutil.copy2(str(src), str(dst))
|
||||
ok(" MCP 配置已更新")
|
||||
|
||||
elif action == "update" and ch["name"] == "mcp.json":
|
||||
info(f" 更新 MCP 配置")
|
||||
src = get_sync_root() / "01_core" / "mcp.json"
|
||||
dst = Path.home() / ".workbuddy" / "mcp.json"
|
||||
dst.rename(dst.with_suffix(".json.syncbak"))
|
||||
shutil.copy2(str(src), str(dst))
|
||||
ok(" MCP 配置已更新")
|
||||
|
||||
|
||||
def run(args):
|
||||
dry_run = args.dry_run
|
||||
force = args.force
|
||||
|
||||
banner()
|
||||
info("双机增量同步" + (" [DRY-RUN]" if dry_run else ""))
|
||||
print()
|
||||
|
||||
# 1. 检测技能差异
|
||||
print("📦 技能差异:")
|
||||
skill_changes = diff_skills(dry_run)
|
||||
if not skill_changes:
|
||||
ok(" 无差异")
|
||||
else:
|
||||
for ch in skill_changes:
|
||||
action_icon = {"install": "➕", "update": "🔄", "uninstall": "🗑️"}.get(ch["action"], "?")
|
||||
if ch["type"] == "missing":
|
||||
warn(f" {action_icon} 新增技能: {ch['name']}")
|
||||
elif ch["type"] == "version_diff":
|
||||
info(f" {action_icon} 版本变更: {ch['name']} ({ch['wb_version']} → {ch['src_version']})")
|
||||
elif ch["type"] == "extra":
|
||||
warn(f" {action_icon} 多余的: {ch['name']}(源目录已移除)")
|
||||
|
||||
print()
|
||||
|
||||
# 2. MCP 差异
|
||||
print("🔧 MCP 配置差异:")
|
||||
mcp_changes = diff_mcp(dry_run)
|
||||
if not mcp_changes:
|
||||
ok(" 无差异")
|
||||
else:
|
||||
for ch in mcp_changes:
|
||||
if ch["action"] == "deploy":
|
||||
warn(f" 缺失: {ch['name']}(待部署)")
|
||||
elif ch["action"] == "update":
|
||||
info(f" 内容变更: {ch['name']}(待更新)")
|
||||
|
||||
print()
|
||||
|
||||
# 3. 自动化差异
|
||||
print("⏰ 自动化任务差异:")
|
||||
auto_changes = diff_automations(dry_run)
|
||||
if not auto_changes:
|
||||
ok(" 无自动化配置文件(无差异检测)")
|
||||
else:
|
||||
for ch in auto_changes:
|
||||
info(f" {ch['name']}: {ch['action']}")
|
||||
|
||||
print()
|
||||
|
||||
# 汇总
|
||||
all_changes = skill_changes + mcp_changes + [c for c in auto_changes if c["action"] != "需手动在 WorkBuddy 中比对"]
|
||||
if not all_changes:
|
||||
ok("完全一致,无需同步")
|
||||
return
|
||||
|
||||
print(f"共 {len(all_changes)} 项差异")
|
||||
|
||||
if dry_run:
|
||||
info("添加 --force 参数执行同步")
|
||||
else:
|
||||
# 执行同步
|
||||
info("执行同步...")
|
||||
apply_changes(all_changes, dry_run, force)
|
||||
ok("同步完成,请重启 WorkBuddy 使更改生效")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
agentos tool — 工具管理
|
||||
|
||||
子命令: list, trae
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import get_sync_root, info, ok, warn, err, banner
|
||||
|
||||
|
||||
TOOL_CATEGORIES = {
|
||||
"00_setup": "安装部署",
|
||||
"01_system": "系统诊断与健康检查",
|
||||
"02_browser": "浏览器自动化",
|
||||
"03_ocr": "OCR 识别",
|
||||
"04_media": "媒体处理",
|
||||
"05_crawl": "网页抓取与爬虫",
|
||||
"06_mobile": "移动端工具",
|
||||
"07_matrix": "矩阵养号",
|
||||
"08_trae_agent": "Trae AI 编程助手",
|
||||
}
|
||||
|
||||
|
||||
def _get_trae_script() -> Path:
|
||||
"""查找 trae_agent.sh 脚本路径"""
|
||||
sync_root = get_sync_root()
|
||||
candidates = [
|
||||
sync_root / "05_tools" / "08_trae_agent" / "trae_agent.sh",
|
||||
sync_root / "05_tools" / "08_trae_agent" / "trae_agent.sh",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p
|
||||
# Fallback: 直接使用 agent-local 路径
|
||||
fallback = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "trae-agent"
|
||||
script = fallback.parent.parent / "agent-sync" / "05_tools" / "08_trae_agent" / "trae_agent.sh"
|
||||
if script.exists():
|
||||
return script
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def cmd_list():
|
||||
"""列出所有可用工具"""
|
||||
banner()
|
||||
tools_root = get_sync_root() / "05_tools"
|
||||
|
||||
if not tools_root.exists():
|
||||
warn(f"工具目录不存在: {tools_root}")
|
||||
return
|
||||
|
||||
total = 0
|
||||
for category_dir in sorted(tools_root.iterdir()):
|
||||
if not category_dir.is_dir() or category_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
cat_name = TOOL_CATEGORIES.get(category_dir.name, category_dir.name)
|
||||
scripts = sorted(category_dir.rglob("*.py")) + sorted(category_dir.rglob("*.sh"))
|
||||
|
||||
# 过滤掉 __pycache__ 和 __init__.py
|
||||
scripts = [s for s in scripts if "__pycache__" not in s.parts and s.name != "__init__.py"]
|
||||
|
||||
if not scripts:
|
||||
continue
|
||||
|
||||
print(f"\n{cat_name} ({category_dir.name}/):")
|
||||
for s in scripts:
|
||||
rel = s.relative_to(tools_root)
|
||||
# 显示简要描述(读取文件第一行注释)
|
||||
desc = ""
|
||||
try:
|
||||
with open(s) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("#"):
|
||||
desc = first_line.lstrip("# ").strip()
|
||||
elif first_line.startswith('"""'):
|
||||
desc = s.read_text().split('"""')[1].strip().split("\n")[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
desc = desc[:50] if desc else ""
|
||||
print(f" 📄 {rel}")
|
||||
if desc:
|
||||
print(f" {desc}")
|
||||
total += 1
|
||||
|
||||
print(f"\n共 {total} 个工具脚本")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_trae_install():
|
||||
"""安装 trae-agent"""
|
||||
sync_root = get_sync_root()
|
||||
install_script = sync_root / "05_tools" / "08_trae_agent" / "install_trae_agent.sh"
|
||||
if not install_script.exists():
|
||||
err(f"安装脚本不存在: {install_script}")
|
||||
return
|
||||
info("正在安装 trae-agent...")
|
||||
result = subprocess.run(["bash", str(install_script)], cwd=str(install_script.parent))
|
||||
if result.returncode == 0:
|
||||
ok("trae-agent 安装完成")
|
||||
else:
|
||||
err("安装失败")
|
||||
|
||||
|
||||
def cmd_trae_run(args):
|
||||
"""执行 trae-agent 任务"""
|
||||
script = _get_trae_script()
|
||||
if not script.exists():
|
||||
err(f"trae_agent.sh 不存在: {script}")
|
||||
err("请先运行: agentos tool trae install")
|
||||
return
|
||||
|
||||
# 绕过系统代理(macOS 系统代理端口 6478 会拦截本地 LLM 请求)
|
||||
env = {**subprocess._clean_environ() if hasattr(subprocess, '_clean_environ') else {}, **os.environ}
|
||||
env["NO_PROXY"] = "localhost,127.0.0.1,::1"
|
||||
env["no_proxy"] = "localhost,127.0.0.1,::1"
|
||||
|
||||
cmd = ["bash", str(script), "run"]
|
||||
if args.provider:
|
||||
cmd.extend(["-p", args.provider])
|
||||
if args.model:
|
||||
cmd.extend(["-m", args.model])
|
||||
if args.file:
|
||||
cmd.extend(["-f", args.file])
|
||||
if args.task:
|
||||
cmd.append(" ".join(args.task))
|
||||
|
||||
result = subprocess.run(cmd, env=env)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def cmd_trae_interactive():
|
||||
"""启动 trae-agent 交互模式"""
|
||||
script = _get_trae_script()
|
||||
if not script.exists():
|
||||
err(f"trae_agent.sh 不存在: {script}")
|
||||
err("请先运行: agentos tool trae install")
|
||||
return
|
||||
subprocess.run(["bash", str(script), "interactive"])
|
||||
|
||||
|
||||
def cmd_trae_config():
|
||||
"""查看 trae-agent 配置"""
|
||||
script = _get_trae_script()
|
||||
if not script.exists():
|
||||
err("trae-agent 未安装")
|
||||
return
|
||||
subprocess.run(["bash", str(script), "show-config"])
|
||||
|
||||
|
||||
def run(args):
|
||||
cmd = args.tool_cmd
|
||||
if not cmd:
|
||||
banner()
|
||||
print("agentos tool 子命令: list, trae")
|
||||
print()
|
||||
return
|
||||
if cmd == "list":
|
||||
cmd_list()
|
||||
elif cmd == "trae":
|
||||
trae_cmd = getattr(args, "trae_cmd", None)
|
||||
if not trae_cmd:
|
||||
banner()
|
||||
print("agentos tool trae 子命令: run, interactive, config, install")
|
||||
print()
|
||||
return
|
||||
if trae_cmd == "run":
|
||||
cmd_trae_run(args)
|
||||
elif trae_cmd == "interactive":
|
||||
cmd_trae_interactive()
|
||||
elif trae_cmd == "config":
|
||||
cmd_trae_config()
|
||||
elif trae_cmd == "install":
|
||||
cmd_trae_install()
|
||||
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
agentos upgrade — 统一模块升级引擎 (v1.0.0)
|
||||
|
||||
扫描 agent-sync/ 下所有模块,对每个模块执行标准化 4 步升级:
|
||||
1. 检测模块状态(有 MODULE.md?install.sh?)
|
||||
2. 运行 install.sh(建目录 + 写 local.yaml)
|
||||
3. 安装 Python 依赖(pip install -r requirements.txt)
|
||||
4. 运行 check 脚本(验证配置 + 环境完整性)
|
||||
|
||||
用法:
|
||||
agentos upgrade 全量升级所有模块
|
||||
agentos upgrade --module matrix 只升级指定模块
|
||||
|
||||
最后更新: 2026-05-01
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .const import __agent_sync_root__, __agent_local_root__
|
||||
|
||||
# 模块类型
|
||||
MODULE_TYPES = {
|
||||
"02_skills": "skill",
|
||||
"05_tools": "tool",
|
||||
"03_knowledge": "knowledge",
|
||||
"04_memory": "memory",
|
||||
}
|
||||
|
||||
# 模块扫描目录(按优先级排序)
|
||||
SCAN_DIRS = ["05_tools", "02_skills"]
|
||||
|
||||
|
||||
class ModuleInfo:
|
||||
"""解析后的模块信息"""
|
||||
|
||||
def __init__(self, path: Path, rel_path: str):
|
||||
self.path = path # 模块目录绝对路径
|
||||
self.rel_path = rel_path # 相对 agent-sync/ 的路径
|
||||
self.name = path.name
|
||||
self.type = self._detect_type()
|
||||
self.has_module_md = (path / "MODULE.md").exists()
|
||||
self.has_install_sh = (path / "install.sh").exists()
|
||||
self.has_requirements = (path / "requirements.txt").exists()
|
||||
self.has_check_sh = (path / "check.sh").exists()
|
||||
self.has_config_template = (path / "config_template").exists()
|
||||
self.local_dir = self._calc_local_dir()
|
||||
|
||||
def _detect_type(self) -> str:
|
||||
for prefix, mtype in MODULE_TYPES.items():
|
||||
if self.rel_path.startswith(prefix):
|
||||
return mtype
|
||||
return "unknown"
|
||||
|
||||
def _calc_local_dir(self) -> Optional[Path]:
|
||||
"""计算对应的 agent-local 目录路径"""
|
||||
if self.type == "tool":
|
||||
# 05_tools/07_matrix → agent-local/tools/matrix
|
||||
parts = self.rel_path.split("/")
|
||||
if len(parts) >= 2:
|
||||
return Path(__agent_local_root__) / "tools" / "_".join(parts[1:])
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.type}:{self.name}>"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 模块扫描
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def scan_modules(target: str = None) -> list:
|
||||
"""扫描 agent-sync/ 下所有可用模块
|
||||
|
||||
参数:
|
||||
target: 指定模块名(可选)
|
||||
|
||||
返回:
|
||||
list[ModuleInfo]: 模块列表
|
||||
"""
|
||||
sync_root = Path(__agent_sync_root__)
|
||||
modules = []
|
||||
|
||||
for scan_dir in SCAN_DIRS:
|
||||
scan_path = sync_root / scan_dir
|
||||
if not scan_path.exists():
|
||||
continue
|
||||
for item in sorted(scan_path.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
# 跳过隐藏目录和 __pycache__
|
||||
if item.name.startswith(".") or item.name == "__pycache__":
|
||||
continue
|
||||
# 如果是 05_tools,跳过 00_setup(agentos 自身)和单文件目录
|
||||
if scan_dir == "05_tools":
|
||||
parts = item.name.split("_", 1)
|
||||
if len(parts) < 2 or not parts[0].isdigit():
|
||||
continue
|
||||
|
||||
rel = f"{scan_dir}/{item.name}"
|
||||
module = ModuleInfo(item, rel)
|
||||
|
||||
if target and target != module.name:
|
||||
continue
|
||||
|
||||
modules.append(module)
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 升级步骤
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run(cmd: list, cwd: Path = None, timeout: int = 120) -> tuple:
|
||||
"""运行命令并返回 (ok, stdout_preview)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, cwd=cwd or Path.cwd(),
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
out = (result.stdout or result.stderr or "").strip()[:200]
|
||||
return ok, out
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def step_git_pull() -> tuple:
|
||||
"""Step 0: 拉取最新代码"""
|
||||
sync_root = Path(__agent_sync_root__)
|
||||
if not (sync_root / ".git").exists():
|
||||
return "skipped", "非 Git 仓库,跳过"
|
||||
ok, out = _run(["git", "pull", "origin", "main"], cwd=sync_root)
|
||||
return ("ok" if ok else "fail"), out
|
||||
|
||||
|
||||
def step_install(module: ModuleInfo) -> tuple:
|
||||
"""Step 1: 运行 install.sh(如果有)"""
|
||||
install_path = module.path / "install.sh"
|
||||
if not install_path.exists():
|
||||
return "skipped", "无 install.sh"
|
||||
|
||||
# 传入 managed Python 路径(所有机器统一版本,避免差异)
|
||||
from .utils import get_python
|
||||
env = os.environ.copy()
|
||||
env["WORKBUDDY_PYTHON"] = get_python()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", str(install_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=env
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
out = (result.stdout or result.stderr or "").strip()[:200]
|
||||
status = "ok" if ok else "fail"
|
||||
return status, out
|
||||
except subprocess.TimeoutExpired:
|
||||
return "fail", "安装超时(网络慢?可稍后手动安装)"
|
||||
except Exception as e:
|
||||
return "fail", str(e)
|
||||
|
||||
|
||||
def step_pip(module: ModuleInfo) -> tuple:
|
||||
"""Step 2: 安装 Python 依赖"""
|
||||
req_path = module.path / "requirements.txt"
|
||||
if not req_path.exists():
|
||||
return "skipped", "无 requirements.txt"
|
||||
|
||||
# 使用统一 managed Python(所有机器保持同一版本,避免差异)
|
||||
from .utils import get_python
|
||||
python = get_python()
|
||||
ok, out = _run([python, "-m", "pip", "install", "-r", str(req_path), "-q"])
|
||||
status = "ok" if ok else "fail"
|
||||
return status, out
|
||||
|
||||
|
||||
def step_check(module: ModuleInfo) -> tuple:
|
||||
"""Step 3: 运行 check 脚本(如果有)"""
|
||||
check_path = module.path / "check.sh"
|
||||
if not check_path.exists():
|
||||
return "skipped", "无 check.sh"
|
||||
|
||||
ok, out = _run(["bash", str(check_path)])
|
||||
status = "ok" if ok else "fail"
|
||||
return status, out
|
||||
|
||||
|
||||
def step_environ(module: ModuleInfo) -> list:
|
||||
"""Step 4: 环境完整性检查(不执行,只报告)"""
|
||||
notes = []
|
||||
|
||||
# 检查 local 目录
|
||||
if module.local_dir:
|
||||
if not module.local_dir.exists():
|
||||
notes.append("⚠️ agent-local 目录未创建,需要 install.sh")
|
||||
else:
|
||||
subdirs = ["config", "data", "logs", "profiles"]
|
||||
missing = [d for d in subdirs if not (module.local_dir / d).exists()]
|
||||
if missing:
|
||||
notes.append(f"⚠️ 缺少子目录: {', '.join(missing)}")
|
||||
|
||||
# 检查 config_template
|
||||
if module.has_config_template:
|
||||
target_conf = module.local_dir / "config" if module.local_dir else None
|
||||
if target_conf:
|
||||
has_config = any(target_conf.glob("*.yaml")) or any(target_conf.glob("*.yml"))
|
||||
if not has_config:
|
||||
notes.append("📋 需配置: cp config_template/*.yaml → agent-local/config/")
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 主升级流程
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def do_upgrade(args):
|
||||
"""执行升级流程"""
|
||||
target = getattr(args, "module", None)
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 🔄 AgentOS 统一升级")
|
||||
print(f" {'(dry-run)' if dry_run else '(执行模式)'}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Step 0: 拉取代码
|
||||
print(f"▶ git pull")
|
||||
status, detail = step_git_pull()
|
||||
print(f" {_icon(status)} {detail}")
|
||||
|
||||
# 扫描模块
|
||||
modules = scan_modules(target)
|
||||
if not modules:
|
||||
print(f"\n ❌ 未找到模块" + (f" (匹配: {target})" if target else ""))
|
||||
return
|
||||
|
||||
print(f"\n 扫描到 {len(modules)} 个模块:")
|
||||
for m in modules:
|
||||
print(f" {m.type:6s} {m.name:25s} {m.rel_path}")
|
||||
print()
|
||||
|
||||
# 逐模块升级
|
||||
results = {}
|
||||
for module in modules:
|
||||
# 判断模块层级
|
||||
has_local_needs = module.has_install_sh or module.has_requirements or module.has_config_template
|
||||
if not has_local_needs:
|
||||
# L1 纯代码模块
|
||||
print(f" 📦 {module.name} ({module.type}) — 纯代码模块,无需本地部署")
|
||||
module_results = {"install": ("skipped", ""), "pip": ("skipped", ""), "check": ("skipped", "")}
|
||||
print(f" install: ⏭️ 无本地依赖")
|
||||
print(f" pip: ⏭️ 无 Python 依赖")
|
||||
print(f" check: ⏭️ 无检查脚本")
|
||||
else:
|
||||
print(f"{'─'*60}")
|
||||
print(f" 📦 {module.name} ({module.type})")
|
||||
print(f"{'─'*60}")
|
||||
|
||||
module_results = {}
|
||||
|
||||
# Step 1: install.sh
|
||||
s, d = step_install(module)
|
||||
module_results["install"] = (s, d)
|
||||
print(f" install: {_icon(s)} {d[:80]}")
|
||||
|
||||
# Step 2: pip install
|
||||
s, d = step_pip(module)
|
||||
module_results["pip"] = (s, d)
|
||||
print(f" pip: {_icon(s)} {d[:80]}")
|
||||
|
||||
# Step 3: check.sh
|
||||
s, d = step_check(module)
|
||||
module_results["check"] = (s, d)
|
||||
print(f" check: {_icon(s)} {d[:80]}")
|
||||
|
||||
# Step 4: 环境检查
|
||||
notes = step_environ(module)
|
||||
module_results["environ"] = notes
|
||||
for n in notes:
|
||||
print(f" {n}")
|
||||
|
||||
results[module.name] = module_results
|
||||
print()
|
||||
|
||||
# 如果有 Skill 模块,自动注册到 WorkBuddy
|
||||
has_skills = any(m.type == "skill" for m in modules)
|
||||
if has_skills and not dry_run:
|
||||
print(f"{'─'*60}")
|
||||
print(f" 🔗 注册技能到 WorkBuddy...")
|
||||
from . import skill_mgr
|
||||
try:
|
||||
skill_mgr.run(argparse.Namespace(skill_cmd="install"))
|
||||
print(f" ✅ 技能注册完成")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 技能注册失败: {e}")
|
||||
print(f" 可稍后手动执行: agentos skill install")
|
||||
print()
|
||||
|
||||
# 自动注册到集群
|
||||
if not dry_run:
|
||||
print(f"{'─'*60}")
|
||||
print(f" 📡 自动注册到集群...")
|
||||
try:
|
||||
import subprocess
|
||||
sync_root = get_sync_root()
|
||||
script = str(sync_root / "05_tools" / "01_system" / "cluster_registry.py")
|
||||
result = subprocess.run(
|
||||
[sys.executable, script, "register"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✅ 集群注册完成")
|
||||
else:
|
||||
print(f" ⚠️ 集群注册失败: {result.stderr.strip()}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 集群注册异常: {e}")
|
||||
print(f" 可稍后手动执行: agentos register")
|
||||
print()
|
||||
|
||||
# 汇总报告
|
||||
_print_report(results, modules)
|
||||
|
||||
|
||||
def _icon(status: str) -> str:
|
||||
return {"ok": "✅", "fail": "❌", "skipped": "⏭️"}.get(status, "❓")
|
||||
|
||||
|
||||
def _print_report(results: dict, modules: list):
|
||||
"""打印升级报告"""
|
||||
print(f"{'='*60}")
|
||||
print(f" 📋 升级报告")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# 按层级分组统计
|
||||
l1_count = sum(1 for m in modules if not m.has_install_sh and not m.has_requirements)
|
||||
l2_count = sum(1 for m in modules if m.has_install_sh or m.has_requirements)
|
||||
ok_count = 0
|
||||
warn_count = 0
|
||||
|
||||
for module in modules:
|
||||
r = results.get(module.name, {})
|
||||
install_s = r.get("install", ("skipped", ""))[0]
|
||||
pip_s = r.get("pip", ("skipped", ""))[0]
|
||||
check_s = r.get("check", ("skipped", ""))[0]
|
||||
notes = r.get("environ", [])
|
||||
|
||||
has_local = module.has_install_sh or module.has_requirements
|
||||
module_ok = all(s == "ok" or s == "skipped" for s in [install_s, pip_s, check_s])
|
||||
|
||||
if module_ok:
|
||||
ok_count += 1
|
||||
else:
|
||||
warn_count += 1
|
||||
|
||||
if has_local:
|
||||
print(f" 📦 {module.name:25s} {_icon(install_s)} install {_icon(pip_s)} pip {_icon(check_s)} check")
|
||||
else:
|
||||
print(f" 📦 {module.name:25s} ⏭️ 纯代码模块(无本地依赖)")
|
||||
for n in notes:
|
||||
print(f" {'':25s} {n}")
|
||||
|
||||
print(f"\n L1 纯代码模块: {l1_count} 个(自动跳过,无需部署)")
|
||||
print(f" L2 本地数据模块: {l2_count} 个(已执行 install/pip/check)")
|
||||
print()
|
||||
print(f"{'-'*60}")
|
||||
if warn_count == 0:
|
||||
print(f" ✅ 全部 {ok_count} 个模块检查完成")
|
||||
else:
|
||||
print(f" ⚠️ {ok_count} 个正常, {warn_count} 个需关注")
|
||||
print(f"{'-'*60}")
|
||||
print()
|
||||
|
||||
# 手动操作提示
|
||||
manual_steps = []
|
||||
for module in modules:
|
||||
if module.name == "matrix":
|
||||
manual_steps.append(f" ☐ Matrix: 配置 {__agent_local_root__}/tools/matrix/config/accounts.yaml")
|
||||
manual_steps.append(f" ☐ Matrix: 运行 switch_account.py --method profile 重新登录")
|
||||
if manual_steps:
|
||||
print(f" 需手动操作:")
|
||||
for s in manual_steps:
|
||||
print(s)
|
||||
print()
|
||||
|
||||
|
||||
def setup_parser(subparsers):
|
||||
"""向主 CLI 注册 upgrade 子命令"""
|
||||
p = subparsers.add_parser("upgrade", help="统一升级:拉取代码 + 安装依赖 + 环境检查")
|
||||
p.add_argument("--module", "-m", type=str, default=None,
|
||||
help="只升级指定模块(如 matrix)")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="仅预览,不执行实际操作")
|
||||
p.set_defaults(func=do_upgrade)
|
||||
|
||||
|
||||
def do_rebuild_vector(args):
|
||||
"""重建本地向量数据库"""
|
||||
import subprocess as sp
|
||||
from pathlib import Path
|
||||
from .utils import get_sync_root, get_local_root, get_python
|
||||
|
||||
sync_root = get_sync_root()
|
||||
local_root = get_local_root()
|
||||
python = get_python()
|
||||
|
||||
track = getattr(args, "track", "both")
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
incremental = getattr(args, "incremental", False)
|
||||
|
||||
script = sync_root / "02_skills" / "kb_manager" / "vector_db_rebuild.py"
|
||||
if not script.exists():
|
||||
print(f"❌ 向量重建脚本不存在: {script}")
|
||||
return
|
||||
|
||||
cmd = [
|
||||
str(python),
|
||||
str(script),
|
||||
"--root", str(sync_root),
|
||||
"--local", str(local_root),
|
||||
"--track", track,
|
||||
]
|
||||
if incremental:
|
||||
cmd.append("--incremental")
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
print(f"🔄 重建向量数据库 ({track})")
|
||||
result = sp.run(cmd, cwd=str(sync_root))
|
||||
if result.returncode == 0:
|
||||
print("✅ 向量数据库重建完成")
|
||||
else:
|
||||
print(f"❌ 向量数据库重建失败 (rc={result.returncode})")
|
||||
|
||||
|
||||
def _run_cluster_script(args, subcommand: str):
|
||||
"""运行集群注册表脚本"""
|
||||
import subprocess as sp
|
||||
from pathlib import Path
|
||||
from .utils import get_sync_root
|
||||
|
||||
sync_root = get_sync_root()
|
||||
script = sync_root / "05_tools" / "01_system" / "cluster_registry.py"
|
||||
if not script.exists():
|
||||
print(f"❌ 集群脚本不存在: {script}")
|
||||
return
|
||||
|
||||
result = sp.run(
|
||||
[sys.executable, str(script), subcommand],
|
||||
cwd=str(sync_root)
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def do_cluster_register(args):
|
||||
"""注册本机到集群"""
|
||||
_run_cluster_script(args, "register")
|
||||
|
||||
|
||||
def do_cluster_status(args):
|
||||
"""查看集群状态"""
|
||||
_run_cluster_script(args, "status")
|
||||
|
||||
|
||||
def do_cluster_cleanup(args):
|
||||
"""清理过期注册条目"""
|
||||
_run_cluster_script(args, "cleanup")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
工具函数:路径解析、shell 执行、颜色输出
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .const import __agent_sync_root__, __agent_local_root__
|
||||
|
||||
|
||||
def get_sync_root() -> Path:
|
||||
"""获取 agent-sync 根目录"""
|
||||
env_root = os.environ.get("AGENT_SYNC_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
return Path(__agent_sync_root__).expanduser()
|
||||
|
||||
|
||||
def get_local_root() -> Path:
|
||||
"""获取 agent-local 根目录"""
|
||||
env_root = os.environ.get("AGENT_LOCAL_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
return Path(__agent_local_root__).expanduser()
|
||||
|
||||
|
||||
def get_python() -> str:
|
||||
"""获取 WorkBuddy 管理的 Python 路径"""
|
||||
candidates = [
|
||||
os.environ.get("AGENTOS_PYTHON"),
|
||||
str(Path.home() / ".workbuddy/binaries/python/envs/agent-os/bin/python3"),
|
||||
str(Path.home() / ".workbuddy/binaries/python/versions/3.13.12/bin/python3"),
|
||||
"python3",
|
||||
]
|
||||
for c in candidates:
|
||||
if c and Path(c).exists():
|
||||
return c
|
||||
return "python3"
|
||||
|
||||
|
||||
def get_node() -> str:
|
||||
"""获取 WorkBuddy 管理的 node 路径"""
|
||||
candidates = [
|
||||
str(Path.home() / ".workbuddy/binaries/node/versions/22.12.0/bin/node"),
|
||||
"node",
|
||||
]
|
||||
for c in candidates:
|
||||
if c and Path(c).exists():
|
||||
return c
|
||||
return "node"
|
||||
|
||||
|
||||
def get_npx() -> str:
|
||||
"""获取 WorkBuddy 管理的 npx 路径"""
|
||||
node_dir = Path.home() / ".workbuddy/binaries/node/versions/22.12.0/bin"
|
||||
npx_path = node_dir / "npx"
|
||||
if npx_path.exists():
|
||||
return str(npx_path)
|
||||
return "npx"
|
||||
|
||||
|
||||
def run(cmd: list, **kwargs) -> subprocess.CompletedProcess:
|
||||
"""运行命令并返回结果"""
|
||||
default = {"capture_output": True, "text": True, "timeout": 120}
|
||||
default.update(kwargs)
|
||||
return subprocess.run(cmd, **default)
|
||||
|
||||
|
||||
def color(text: str, color_name: str = "blue") -> str:
|
||||
"""终端颜色输出"""
|
||||
colors = {
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"cyan": "\033[96m",
|
||||
"reset": "\033[0m",
|
||||
}
|
||||
return f"{colors.get(color_name, '')}{text}{colors['reset']}"
|
||||
|
||||
|
||||
def info(msg: str):
|
||||
print(f"{color('[INFO]', 'blue')} {msg}")
|
||||
|
||||
|
||||
def ok(msg: str):
|
||||
print(f"{color('[OK]', 'green')} {msg}")
|
||||
|
||||
|
||||
def warn(msg: str):
|
||||
print(f"{color('[WARN]', 'yellow')} {msg}")
|
||||
|
||||
|
||||
def err(msg: str):
|
||||
print(f"{color('[ERROR]', 'red')} {msg}")
|
||||
|
||||
|
||||
def banner():
|
||||
"""显示欢迎横幅"""
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" AgentOS 系统管理 CLI v1.0.0")
|
||||
print(f" Synced: {get_sync_root()}")
|
||||
print(f" Local: {get_local_root()}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
@@ -0,0 +1,185 @@
|
||||
# guardd v2.3.0 — 联邦守护进程 (WPRA v2.0)
|
||||
|
||||
> **架构**: WPRA v2.0 — 每台机器只写自己的命名空间,永不冲突
|
||||
> **版本**: 2.3.0 | **更新**: 2026-05-31
|
||||
> 适用: 所有 AgentOS 联邦机器
|
||||
|
||||
---
|
||||
|
||||
## 架构变化 (v2.3.0 关键升级)
|
||||
|
||||
v2.3.0 从 `git add -A` 切换到 **WPRA (Write Partitioned, Read Aggregated)** 模型:
|
||||
|
||||
| 旧架构 (≤2.2.x) | 新架构 (≥2.3.0) |
|
||||
|:-----------------|:-----------------|
|
||||
| `git add -A` 全量提交 → 冲突 | `git add <本机文件>` → 精确提交 |
|
||||
| 全员写 `_registry.json` → 覆盖冲突 | guardd 不写聚合文件,只写自己的命名空间 |
|
||||
| 单文件 `accounts_registry.yaml` | 每台机器 `machines/{uid}/accounts.yaml` |
|
||||
| 心跳写到 `status/live/` | 同时写到 `machines/{uid}/heartbeat.json` |
|
||||
|
||||
**每台机器只操作自己的命名空间:**
|
||||
```
|
||||
machines/{MACHINE_UID}/
|
||||
├── MACHINE.yaml ← 身份声明(首次写入,终身只读)
|
||||
├── heartbeat.json ← 心跳(含 file_version + updated_at)
|
||||
└── events/{date}.jsonl ← 事件日志(append-only)
|
||||
```
|
||||
|
||||
**不再由 guardd 写入的文件**(已移交给 Dashboard 单实例管理):
|
||||
- `status/live/_registry.json` — 安全删除,无冲突风险
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 本机能访问 Gitee (git@gitee.com:babycalf/mac-agent-os.git)
|
||||
- 已配置 SSH 密钥 (有推送权限)
|
||||
- Python 3 可用
|
||||
|
||||
---
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 克隆/拉取仓库
|
||||
|
||||
```bash
|
||||
# 首次 (如果还没有仓库)
|
||||
git clone [email protected]:babycalf/mac-agent-os.git ~/workbuddy-agent-os/agent-sync
|
||||
|
||||
# 已有仓库
|
||||
cd ~/workbuddy-agent-os/agent-sync && git pull
|
||||
```
|
||||
|
||||
### 2. 创建本地目录
|
||||
|
||||
```bash
|
||||
mkdir -p ~/workbuddy-agent-os/agent-local/identity
|
||||
mkdir -p ~/workbuddy-agent-os/agent-local/runtime/guardd
|
||||
```
|
||||
|
||||
### 3. 首次运行测试
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd
|
||||
python3 guardd.py --once
|
||||
```
|
||||
|
||||
如果输出 `心跳已上报` 表示成功。此时:
|
||||
- 本机 UID 已生成 → `agent-local/identity/machine_uid`
|
||||
- 本机 hostname 已缓存 → `agent-local/identity/cached_hostname`
|
||||
- 心跳已写入 (旧) → `cross_machine/status/live/{UID}.json`
|
||||
- 心跳已写入 (新) → `cross_machine/machines/{UID}/heartbeat.json` ✅ WPRA v2.0
|
||||
- 身份声明已创建 → `cross_machine/machines/{UID}/MACHINE.yaml`
|
||||
- 数据已推送到 Git (精确 add,非 `-A`)
|
||||
|
||||
### 4. 验证 Dashboard
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
python3 run.py 9988
|
||||
```
|
||||
|
||||
浏览器打开 `http://本机IP:9988` → 看到所有机器状态
|
||||
|
||||
### 5. 配置自动启动 (macOS launchd)
|
||||
|
||||
创建 plist 文件:
|
||||
|
||||
```bash
|
||||
cat > ~/Library/LaunchAgents/com.agentos.guardd.plist << 'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.guardd</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/guardd.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/stderr.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# 加载启动
|
||||
launchctl load ~/Library/LaunchAgents/com.agentos.guardd.plist
|
||||
```
|
||||
|
||||
### 6. 配置 Dashboard 自动启动
|
||||
|
||||
```bash
|
||||
cat > ~/Library/LaunchAgents/com.agentos.dashboard.plist << 'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.dashboard</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/10_dashboard/run.py</string>
|
||||
<string>9988</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/10_dashboard</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/dashboard_stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/dashboard_stderr.log</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl load ~/Library/LaunchAgents/com.agentos.dashboard.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本管理
|
||||
|
||||
**版本文件**: `cross_machine/guardd-required-version.txt`
|
||||
|
||||
guardd 每次启动时会检查版本号。如果当前版本低于要求版本:
|
||||
1. 自动执行 `git pull` 拉取最新代码
|
||||
2. 提示重启
|
||||
|
||||
**如果版本不匹配, 手动更新**:
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync && git pull
|
||||
# 然后重启 guardd
|
||||
launchctl unload ~/Library/LaunchAgents/com.agentos.guardd.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.agentos.guardd.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 问题 | 解决方法 |
|
||||
|------|---------|
|
||||
| Git push 权限不足 | 检查 SSH key 是否添加到 Gitee |
|
||||
| 启动报错 | 检查 stdout.log/stderr.log |
|
||||
| Dashboard 看不到其他机器 | 点击左侧 **[🔄 同步]** 执行 git pull |
|
||||
| 磁盘占用 | `agent-local/runtime/guardd/` 下的日志会自动轮转 |
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.chrome-debug</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>/Users/chengzige/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/scripts/chrome_debug.sh</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/chengzige/workbuddy-agent-os/agent-local/runtime/chrome-debug.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/chengzige/workbuddy-agent-os/agent-local/runtime/chrome-debug.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.guardd</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>__HOME__/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/launch.sh</string>
|
||||
<string>05_tools/00_setup/guardd/guardd.py</string>
|
||||
</array>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>__HOME__/workbuddy-agent-os/agent-local/runtime/guardd/stdout.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>__HOME__/workbuddy-agent-os/agent-local/runtime/guardd/stderr.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:__HOME__/.workbuddy/binaries/python/versions/3.13.12/bin</string>
|
||||
<key>GUARDD_DASHBOARD_URL</key>
|
||||
<string>http://localhost:9988</string>
|
||||
</dict>
|
||||
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>30</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# guardd — 自动安装/修复脚本
|
||||
# 适用: 任何联邦机器(自动适配当前用户)
|
||||
# 用法: bash install_guardd.sh
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
USER_HOME="$HOME"
|
||||
USER_NAME="$(whoami)"
|
||||
AGENT_SYNC="$HOME/workbuddy-agent-os/agent-sync"
|
||||
AGENT_LOCAL="$HOME/workbuddy-agent-os/agent-local"
|
||||
GUARDD_DIR="$AGENT_SYNC/05_tools/00_setup/guardd"
|
||||
GUARDD_PY="$GUARDD_DIR/guardd.py"
|
||||
|
||||
# 检测 WorkBuddy 管理的 Python
|
||||
PYTHON_BIN=""
|
||||
for p in "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3" \
|
||||
"$HOME/.workbuddy/binaries/python/versions/3.13.12/bin/python3" \
|
||||
"/usr/bin/python3"; do
|
||||
if [ -x "$p" ]; then
|
||||
PYTHON_BIN="$p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
echo "❌ 未找到可用的 Python 3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════════"
|
||||
echo " guardd 安装脚本"
|
||||
echo " 用户: $USER_NAME"
|
||||
echo " 路径: $AGENT_SYNC"
|
||||
echo " Python: $PYTHON_BIN"
|
||||
echo "════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. 创建必要目录 ──
|
||||
echo "[1/5] 创建运行时目录..."
|
||||
mkdir -p "$AGENT_LOCAL/identity"
|
||||
mkdir -p "$AGENT_LOCAL/runtime/guardd"
|
||||
|
||||
# ── 2. 修复 guardd.py 的 logger bug(已固化的版本会从仓库同步) ──
|
||||
echo "[2/5] 检查 guardd.py..."
|
||||
if [ ! -f "$GUARDD_PY" ]; then
|
||||
echo "❌ guardd.py 不存在 — 请先 git pull"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 3. 生成 plist ──
|
||||
echo "[3/5] 生成 launchd plist..."
|
||||
PLIST_PATH="$HOME/Library/LaunchAgents/com.agentos.guardd.plist"
|
||||
|
||||
cat > "$PLIST_PATH" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.guardd</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${PYTHON_BIN}</string>
|
||||
<string>${GUARDD_PY}</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${AGENT_LOCAL}/runtime/guardd/stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${AGENT_LOCAL}/runtime/guardd/stderr.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:${HOME}/.workbuddy/binaries/python/versions/3.13.12/bin</string>
|
||||
<!-- GUARDD_DASHBOARD_URL 已废弃: 改为读 agent-local/config.yaml 中的 dashboard_url
|
||||
<key>GUARDD_DASHBOARD_URL</key>
|
||||
<string>http://localhost:9988</string>
|
||||
-->
|
||||
</dict>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>300</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
echo " ✅ plist 已生成: $PLIST_PATH"
|
||||
|
||||
# ── 4. 卸载旧服务 ──
|
||||
echo "[4/5] 卸载旧 guardd 服务..."
|
||||
launchctl bootout gui/$(id -u)/com.agentos.guardd 2>/dev/null || true
|
||||
# 兼容旧版 launchctl
|
||||
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
||||
echo " ✅ 已卸载"
|
||||
|
||||
# ── 5. 加载新服务 ──
|
||||
echo "[5/5] 加载 guardd 服务..."
|
||||
launchctl bootstrap gui/$(id -u) "$PLIST_PATH" 2>/dev/null || launchctl load "$PLIST_PATH"
|
||||
echo " ✅ 已加载"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════"
|
||||
echo " ✅ 安装完成!"
|
||||
echo "════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "查看状态: launchctl print gui/$(id -u)/com.agentos.guardd | grep state"
|
||||
echo "查看日志: tail -f $AGENT_LOCAL/runtime/guardd/stdout.log"
|
||||
echo ""
|
||||
echo "如需卸载:"
|
||||
echo " launchctl bootout gui/$(id -u)/com.agentos.guardd"
|
||||
echo " rm ~/Library/LaunchAgents/com.agentos.guardd.plist"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# AgentOS launchd wrapper — 自动解析 $HOME 路径,适配多机
|
||||
# 用法: launch.sh <script_relative_path> [args...]
|
||||
# 示例: launch.sh 05_tools/00_setup/guardd/guardd.py
|
||||
|
||||
SCRIPT_REL="$1"
|
||||
shift
|
||||
|
||||
PYTHON="$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
SCRIPT="$HOME/workbuddy-agent-os/agent-sync/$SCRIPT_REL"
|
||||
|
||||
exec "$PYTHON" "$SCRIPT" "$@"
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
account_monitor.py — 本机账号登录状态采集 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- 读取本机 identities/ 目录下所有账号的 cookies.sqlite
|
||||
- 返回 {account_id: status} 映射
|
||||
- 每次调用实时检测(不缓存)
|
||||
|
||||
状态定义:
|
||||
logged_in — cookite 文件存在且含 session cookie
|
||||
no_cookie — 身份目录存在但无 cookies.sqlite 文件
|
||||
empty_cookie — cookies.sqlite 文件小于 100 字节
|
||||
no_session — cookies.sqlite 存在但无 session 类型 cookie
|
||||
no_identity — 身份目录不存在
|
||||
error — 读取异常
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("guardd.account_monitor")
|
||||
|
||||
# ── 路径 ──────────────────────────────────────────────
|
||||
AGENT_LOCAL = Path(os.environ.get(
|
||||
"AGENT_LOCAL",
|
||||
str(Path.home() / "workbuddy-agent-os" / "agent-local")
|
||||
))
|
||||
MATRIX_IDENTITIES = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||||
OVERRIDE_PATH = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.override.yaml"
|
||||
|
||||
|
||||
class AccountMonitor:
|
||||
"""本机账号登录状态实时采集器(60 秒缓存)"""
|
||||
|
||||
def __init__(self):
|
||||
self._identities_root = MATRIX_IDENTITIES
|
||||
self._override_path = OVERRIDE_PATH
|
||||
self._cache = {} # {account_id: status}
|
||||
self._cache_time = 0 # 上次缓存时间
|
||||
self._cache_ttl = 60 # 秒
|
||||
|
||||
def collect_status(self) -> dict:
|
||||
"""采集本机所有账号的登录状态(带 60 秒缓存)"""
|
||||
import time as _t
|
||||
now = _t.time()
|
||||
if self._cache and now - self._cache_time < self._cache_ttl:
|
||||
return dict(self._cache)
|
||||
accounts = self._get_local_accounts()
|
||||
result = {}
|
||||
for account_id, identity_hint in accounts.items():
|
||||
status = self._check_login_status(identity_hint)
|
||||
result[account_id] = status
|
||||
self._cache = result
|
||||
self._cache_time = now
|
||||
return result
|
||||
|
||||
def get_cached_status(self, account_id: str) -> str:
|
||||
"""快速查某个账号的登录状态(只走缓存,不读盘)"""
|
||||
if self._cache and account_id in self._cache:
|
||||
return self._cache[account_id]
|
||||
return "unknown"
|
||||
|
||||
def _get_local_accounts(self) -> dict:
|
||||
"""获取本机账号列表 {account_id: identity_hint}"""
|
||||
# 优先级 1: override.yaml
|
||||
if self._override_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(self._override_path.read_text()) or {}
|
||||
accts = {}
|
||||
for a in data.get("accounts", []):
|
||||
aid = a.get("id", "")
|
||||
if not aid:
|
||||
continue
|
||||
hint = a.get("identity_dir") or a.get("identity_hint") or aid
|
||||
hint = hint.replace("identities/", "")
|
||||
accts[aid] = hint
|
||||
if accts:
|
||||
return accts
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 override.yaml 失败: {e}")
|
||||
|
||||
# 优先级 2: 扫描 identities/ 目录(降级)
|
||||
if self._identities_root.exists():
|
||||
accts = {}
|
||||
for entry in sorted(self._identities_root.iterdir()):
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
accts[entry.name] = entry.name
|
||||
return accts
|
||||
|
||||
return {}
|
||||
|
||||
def _check_login_status(self, identity_hint: str) -> str:
|
||||
"""检查单个身份目录的登录状态(逻辑同 matrix_mgmt._check_login_status_by_hint)"""
|
||||
if not identity_hint:
|
||||
return "no_identity"
|
||||
identity_path = self._identities_root / identity_hint
|
||||
if not identity_path.exists():
|
||||
return "no_identity"
|
||||
cookie_path = identity_path / "user_data" / "cookies.sqlite"
|
||||
if not cookie_path.exists():
|
||||
return "no_cookie"
|
||||
if cookie_path.stat().st_size < 100:
|
||||
return "empty_cookie"
|
||||
try:
|
||||
conn = sqlite3.connect(str(cookie_path), timeout=2)
|
||||
cur = conn.cursor()
|
||||
total = cur.execute("SELECT count(*) FROM moz_cookies").fetchone()[0]
|
||||
session = cur.execute(
|
||||
"SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return "logged_in" if session > 0 else "no_session"
|
||||
except Exception as e:
|
||||
logger.debug(f"cookie 检查异常 {identity_hint}: {e}")
|
||||
return "error"
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
executor.py — 任务执行器 (guardd 模块 同步版)
|
||||
|
||||
职责:
|
||||
- 调 mc run / mc interact 等命令
|
||||
- 实时解析 stdout 提取进度
|
||||
- 超时控制(含 60 秒无输出检测)
|
||||
- 执行结果写入 task_store
|
||||
- shell=False(不再通过 /bin/sh 启动,避免僵尸 shell PID)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
from modules.task_store import TaskStore, STATUS_RUNNING, STATUS_COMPLETED, STATUS_FAILED
|
||||
|
||||
logger = logging.getLogger("guardd.executor")
|
||||
|
||||
HOME = Path.home()
|
||||
SCRIPTS_DIR = HOME / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "07_matrix" / "scripts"
|
||||
PYTHON_PATH = HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3"
|
||||
|
||||
|
||||
class Executor:
|
||||
"""任务执行器(同步版本,shell=False)"""
|
||||
|
||||
def __init__(self, task_store: TaskStore, slot_manager=None):
|
||||
self.task_store = task_store
|
||||
self.slot_manager = slot_manager
|
||||
self._running_procs = {} # task_id → proc
|
||||
|
||||
def execute(self, task: dict) -> dict:
|
||||
"""执行一个任务,实时解析输出,返回结果"""
|
||||
task_id = task["task_id"]
|
||||
account_id = task.get("accounts", [""])[0] if task.get("accounts") else ""
|
||||
identity_dir = task.get("identity_dir", account_id)
|
||||
|
||||
cmd_info = self._build_cmd(task)
|
||||
if not cmd_info:
|
||||
task["status"] = STATUS_FAILED
|
||||
task["error"] = "无法构建执行命令"
|
||||
self.task_store.save(task)
|
||||
return {"success": False, "error": "无法构建执行命令"}
|
||||
|
||||
logger.info(f" ▶️ executor: task_id={task.get('task_id','')} accounts={task.get('accounts',[])} cmd_type={task.get('cmd_type','')}")
|
||||
logger.info(f" ▶️ cmd: {' '.join(cmd_info['args'])[:200] if cmd_info.get('args') else 'NONE'}")
|
||||
|
||||
task["status"] = STATUS_RUNNING
|
||||
task["started_at"] = time.time()
|
||||
self.task_store.save(task)
|
||||
|
||||
# 获取槽位
|
||||
slot = None
|
||||
if self.slot_manager:
|
||||
try:
|
||||
nickname = task.get("nickname", "")
|
||||
platform = task.get("platform", "")
|
||||
if not platform and account_id:
|
||||
platform = "xiaohongshu" if account_id.startswith("xhs_") else "douyin"
|
||||
if not nickname:
|
||||
try:
|
||||
import json
|
||||
pf = HOME / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
|
||||
if pf.exists():
|
||||
profiles = json.loads(pf.read_text())
|
||||
if account_id in profiles:
|
||||
nickname = profiles[account_id].get("nickname", "")
|
||||
except Exception:
|
||||
pass
|
||||
slot = self.slot_manager.acquire(account_id, identity_dir, nickname, platform)
|
||||
except Exception as e:
|
||||
task["status"] = STATUS_FAILED
|
||||
task["error"] = str(e)
|
||||
self.task_store.save(task)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
log_lines = []
|
||||
# 任务日志文件
|
||||
log_path = HOME / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "tasks" / f"{task_id}.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(log_path, "w", encoding="utf-8")
|
||||
try:
|
||||
# shell=False:直接启动 python3 -m ...,不经过 /bin/sh
|
||||
proc = subprocess.Popen(
|
||||
cmd_info["args"],
|
||||
cwd=cmd_info.get("cwd"),
|
||||
env=cmd_info.get("env"),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
preexec_fn=os.setsid
|
||||
)
|
||||
if slot:
|
||||
self.slot_manager.set_pid(slot["browser_id"], proc.pid)
|
||||
self._running_procs[task_id] = proc
|
||||
|
||||
max_execution_sec = task.get("max_execution_sec", 7200)
|
||||
cmd_type = task.get("cmd_type", "")
|
||||
# 仅登录任务给 5 分钟静默等待(首次登录需手动操作),其余保持 60 秒
|
||||
idle_timeout = 300 if cmd_type in ("login", "smart-login") else 60
|
||||
start_time = time.time()
|
||||
last_output_time = time.time()
|
||||
|
||||
import select as _select
|
||||
|
||||
# 实时读取输出(非阻塞版)
|
||||
while True:
|
||||
now = time.time()
|
||||
elapsed = now - start_time
|
||||
|
||||
# 浏览器启动超时:登录任务 5 分钟无输出,其他任务 60 秒无输出
|
||||
if elapsed > idle_timeout and (now - last_output_time) > idle_timeout:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
task["status"] = STATUS_FAILED
|
||||
task["error"] = "浏览器启动超时(60秒无输出)"
|
||||
task["completed_at"] = time.time()
|
||||
self.task_store.save(task)
|
||||
logger.warning(f" ⏰ [{task_id}] 浏览器启动超时(60秒无输出)")
|
||||
return {"success": False, "error": "浏览器启动超时"}
|
||||
|
||||
if elapsed > max_execution_sec:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
task["status"] = STATUS_FAILED
|
||||
task["error"] = f"超时 ({max_execution_sec:.0f}s)"
|
||||
task["completed_at"] = time.time()
|
||||
self.task_store.save(task)
|
||||
logger.warning(f" ⏰ [{task_id}] 超时 ({elapsed:.0f}s > {max_execution_sec}s)")
|
||||
return {"success": False, "error": task["error"]}
|
||||
|
||||
r, _, _ = _select.select([proc.stdout], [], [], 2.0)
|
||||
if r:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
text = line.decode("utf-8", errors="replace").strip()
|
||||
log_lines.append(text)
|
||||
log_fh.write(text + "\n")
|
||||
log_fh.flush()
|
||||
last_output_time = time.time()
|
||||
self._parse_and_update(task_id, account_id, identity_dir, text)
|
||||
else:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
exit_code = proc.wait()
|
||||
if exit_code != 0 and task.get("cmd_type") in ("collect", "nurture"):
|
||||
try:
|
||||
import json
|
||||
pf = HOME / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
|
||||
if pf.exists():
|
||||
mtime = pf.stat().st_mtime
|
||||
started = task.get("started_at", 0)
|
||||
if mtime >= started - 5:
|
||||
logger.info(f" ✅ [{task_id}] profiles.json 已更新 (mtime={mtime:.0f} >= start={started:.0f})")
|
||||
exit_code = 0
|
||||
except Exception:
|
||||
pass
|
||||
success = exit_code == 0
|
||||
task["status"] = STATUS_COMPLETED if success else STATUS_FAILED
|
||||
task["completed_at"] = time.time()
|
||||
task["exit_code"] = exit_code
|
||||
self.task_store.save(task)
|
||||
return {"success": success, "exit_code": exit_code, "task_id": task_id}
|
||||
|
||||
except Exception as e:
|
||||
task["status"] = STATUS_FAILED
|
||||
task["error"] = str(e)
|
||||
task["completed_at"] = time.time()
|
||||
self.task_store.save(task)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
finally:
|
||||
if slot:
|
||||
self.slot_manager.release(slot["browser_id"])
|
||||
self._running_procs.pop(task_id, None)
|
||||
try:
|
||||
log_fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_cmd(self, task: dict) -> Optional[dict]:
|
||||
"""构建执行命令(返回 args/cwd/env 字典,shell=False)"""
|
||||
scripts_dir = str(SCRIPTS_DIR)
|
||||
python_path = str(PYTHON_PATH)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = f"{scripts_dir}:{env.get('PYTHONPATH', '')}"
|
||||
|
||||
cmd_line = task.get("command_line", "")
|
||||
if cmd_line:
|
||||
# command_line 形如: mc task comment --account=X --url=Y -y
|
||||
# 也有旧格式: mc run --accounts=X --blueprints=Y ...
|
||||
args = [python_path, "-m"] + shlex.split(cmd_line)
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
|
||||
cmd_type = task.get("cmd_type", "")
|
||||
accounts = ",".join(task.get("accounts", []))
|
||||
blueprint = task.get("blueprint", "")
|
||||
rounds = task.get("rounds", 1)
|
||||
|
||||
if cmd_type == "nurture":
|
||||
args = [python_path, "-m", "mc", "run",
|
||||
f"--accounts={accounts}", f"--blueprints={blueprint}",
|
||||
f"--rounds={rounds}", "--mix", "--interval=45-90"]
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
elif cmd_type in ("interact", "comment"):
|
||||
url = task.get("params", {}).get("url", "")
|
||||
direction = task.get("params", {}).get("direction", "")
|
||||
args = [python_path, "-m", "mc", "run",
|
||||
f"--accounts={accounts}", f"--blueprints={blueprint}",
|
||||
"--rounds=1", f"--url={url}", f"--direction={direction}"]
|
||||
interval = task.get("interval", 0)
|
||||
if interval:
|
||||
args.append(f"--interval={interval}")
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
elif cmd_type == "collect":
|
||||
args = [python_path, "-m", "mc", "run",
|
||||
f"--accounts={accounts}", f"--blueprints={blueprint}", "--rounds=1"]
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
elif cmd_type == "login":
|
||||
args = [python_path, "-m", "mc", "smart-login", accounts]
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
elif cmd_type == "record":
|
||||
platform = task.get("params", {}).get("platform", "douyin")
|
||||
args = [python_path, "-m", "mc", "record", "start",
|
||||
f"--accounts={accounts}", f"--platform={platform}"]
|
||||
return {"args": args, "cwd": scripts_dir, "env": env}
|
||||
return None
|
||||
|
||||
def _parse_and_update(self, task_id: str, account_id: str, browser_id: str, line: str):
|
||||
"""解析一行日志,更新任务/槽位进度"""
|
||||
if not self.slot_manager:
|
||||
return
|
||||
# 更新 step
|
||||
for pattern, step_name in [
|
||||
(r"(?:正在|开始|执行)\s*([\u4e00-\u9fa5_]+)", None),
|
||||
(r"step:\s*(\S+)", None),
|
||||
]:
|
||||
m = re.search(pattern, line)
|
||||
if m:
|
||||
self.slot_manager.update_step(browser_id, m.group(1))
|
||||
break
|
||||
# 更新 step_index(从 blueprint progress 提取)
|
||||
m = re.search(r"(?:步骤|step)\s*(\d+)\s*[/|]\s*(\d+)", line)
|
||||
if m:
|
||||
self.slot_manager.update_step(browser_id, line[:40],
|
||||
step_index=int(m.group(1)), total_steps=int(m.group(2)))
|
||||
|
||||
def kill(self, task_id: str) -> bool:
|
||||
proc = self._running_procs.get(task_id)
|
||||
if proc:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
heartbeat.py — 增强心跳上报 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- 每15秒采集系统状态 + 任务状态 + 槽位状态
|
||||
- 上报到 Dashboard (POST /api/push/heartbeat)
|
||||
- 本地写入 status/{hostname}/heartbeat.json
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from modules.task_store import TaskStore
|
||||
from modules.slot_manager import BrowserSlotManager
|
||||
from modules.scheduler import Scheduler
|
||||
from modules.account_monitor import AccountMonitor
|
||||
|
||||
logger = logging.getLogger("guardd.heartbeat")
|
||||
|
||||
|
||||
class HeartbeatReporter:
|
||||
"""增强心跳上报器"""
|
||||
|
||||
def __init__(self, task_store: TaskStore, slot_manager: BrowserSlotManager,
|
||||
scheduler: Scheduler, hostname: str, machine_uid: str,
|
||||
dashboard_url: str = None,
|
||||
account_monitor: AccountMonitor = None):
|
||||
self.task_store = task_store
|
||||
self.slot_manager = slot_manager
|
||||
self.scheduler = scheduler
|
||||
self.hostname = hostname
|
||||
self.machine_uid = machine_uid
|
||||
self.dashboard_url = dashboard_url or "http://127.0.0.1:9988"
|
||||
self.account_monitor = account_monitor
|
||||
|
||||
def collect(self) -> dict:
|
||||
"""采集完整状态(v2: 含 per-slot 详细进度)"""
|
||||
slot_usage = self.slot_manager.get_usage() if self.slot_manager else {"max": 0, "used": 0, "slots": []}
|
||||
|
||||
# 收集任务状态(v2: active_tasks 字典,支持多 slot 并行)
|
||||
active_tasks = list(self.scheduler.active_tasks.values()) if hasattr(self.scheduler, 'active_tasks') and self.scheduler.active_tasks else []
|
||||
active_list = []
|
||||
for t in active_tasks:
|
||||
at = self._task_to_heartbeat(t)
|
||||
if at:
|
||||
active_list.append(at)
|
||||
queued = self.scheduler.get_all_queued() if hasattr(self.scheduler, 'get_all_queued') else []
|
||||
task_counts = self.task_store.count()
|
||||
|
||||
# 增强 slot 信息:从 slot_manager 获取每个浏览器的当前步骤/时间
|
||||
slot_details = []
|
||||
for s in slot_usage.get("slots", []):
|
||||
if s.get("account_id"):
|
||||
slot_details.append({
|
||||
"slot_id": s.get("slot_id"),
|
||||
"account_id": s.get("account_id"),
|
||||
"nickname": s.get("nickname", ""),
|
||||
"platform": s.get("platform", ""),
|
||||
"current_step": s.get("current_step", ""),
|
||||
"step_index": s.get("step_index", 0),
|
||||
"total_steps": s.get("total_steps", 0),
|
||||
"elapsed_sec": s.get("elapsed_sec", 0),
|
||||
"health": s.get("health", "healthy"),
|
||||
"pid": s.get("pid"),
|
||||
})
|
||||
|
||||
# 系统状态
|
||||
cpu_load = -1
|
||||
try:
|
||||
cpu_load = round(os.getloadavg()[0], 2)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 采集每个账号的登录状态
|
||||
account_status = {}
|
||||
if self.account_monitor:
|
||||
try:
|
||||
account_status = self.account_monitor.collect_status()
|
||||
except Exception as e:
|
||||
logger.debug(f"账号状态采集失败: {e}")
|
||||
|
||||
heartbeat = {
|
||||
"hostname": self.hostname,
|
||||
"machine_uid": self.machine_uid,
|
||||
"status": "online",
|
||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
||||
"version": "4.3.0",
|
||||
"account_status": account_status,
|
||||
"slots": {
|
||||
"max": slot_usage.get("max", 3),
|
||||
"used": slot_usage.get("used", 0),
|
||||
"list": slot_details,
|
||||
},
|
||||
"tasks": {
|
||||
"active": active_list,
|
||||
"queued": [
|
||||
{"task_id": q["task_id"], "priority": q.get("priority", 1),
|
||||
"estimated_at": q.get("scheduled_at", 0)}
|
||||
for q in queued[:20]
|
||||
],
|
||||
"counts": task_counts,
|
||||
},
|
||||
"system": {
|
||||
"cpu_load_1m": cpu_load,
|
||||
"browsers_open": slot_usage.get("used", 0),
|
||||
},
|
||||
}
|
||||
return heartbeat
|
||||
|
||||
def _task_to_heartbeat(self, task: dict) -> dict:
|
||||
"""把 task dict 转成心跳用的紧凑格式"""
|
||||
if not task:
|
||||
return None
|
||||
return {
|
||||
"task_id": task.get("task_id", ""),
|
||||
"cmd_type": task.get("cmd_type", ""),
|
||||
"type": task.get("task_type", task.get("cmd_type", "")),
|
||||
"account": (task.get("accounts") or [""])[0],
|
||||
"blueprint": task.get("blueprint", ""),
|
||||
"status": task.get("status", ""),
|
||||
"started_at": task.get("started_at", 0),
|
||||
"elapsed_sec": int(time.time() - task.get("started_at", time.time())),
|
||||
"progress": {
|
||||
"current_step": task.get("current_step", ""),
|
||||
"step_index": task.get("step_index", 0),
|
||||
"total_steps": task.get("total_steps", 0),
|
||||
},
|
||||
}
|
||||
|
||||
def send_to_dashboard(self, heartbeat: dict = None):
|
||||
"""发送心跳到 Dashboard"""
|
||||
if heartbeat is None:
|
||||
heartbeat = self.collect()
|
||||
|
||||
try:
|
||||
payload = json.dumps(heartbeat, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{self.dashboard_url}/api/push/heartbeat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(f"心跳推送失败: {e}")
|
||||
|
||||
def write_local(self, heartbeat: dict = None):
|
||||
"""写入本地 heartbeat.json"""
|
||||
if heartbeat is None:
|
||||
heartbeat = self.collect()
|
||||
try:
|
||||
home = Path.home()
|
||||
status_dir = home / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / self.hostname
|
||||
status_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = status_dir / "heartbeat.json"
|
||||
path.write_text(json.dumps(heartbeat, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# also write live file
|
||||
live_dir = status_dir.parent / "live"
|
||||
live_dir.mkdir(parents=True, exist_ok=True)
|
||||
(live_dir / f"{self.machine_uid}.json").write_text(
|
||||
json.dumps(heartbeat, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"本地心跳写入失败: {e}")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
oracle_sync.py — ORACLE 定时任务同步 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- guardd 启动时读取 ORACLE.yaml 的 schedules 节
|
||||
- 将定时任务导入到 task_store
|
||||
- 每6小时增量同步
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from modules.task_store import TaskStore, STATUS_SCHEDULED
|
||||
|
||||
logger = logging.getLogger("guardd.oracle_sync")
|
||||
SYNC_INTERVAL = 6 * 3600 # 6小时
|
||||
|
||||
|
||||
class OracleSync:
|
||||
"""ORACLE.yaml 定时任务同步器"""
|
||||
|
||||
def __init__(self, task_store: TaskStore):
|
||||
self.task_store = task_store
|
||||
self.last_sync = 0
|
||||
self.home = Path.home()
|
||||
self.oracle_path = self.home / "workbuddy-agent-os" / "agent-sync" / "ORACLE.yaml"
|
||||
|
||||
def sync(self):
|
||||
"""执行同步(启动时 + 周期调用)"""
|
||||
now = time.time()
|
||||
if now - self.last_sync < SYNC_INTERVAL:
|
||||
return
|
||||
self.last_sync = now
|
||||
|
||||
if not self.oracle_path.exists():
|
||||
logger.info(" ORACLE.yaml 不存在,跳过定时任务同步")
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml
|
||||
oracle = yaml.safe_load(self.oracle_path.read_text())
|
||||
schedules = oracle.get("schedules", [])
|
||||
count = 0
|
||||
for s in schedules:
|
||||
task_id = f"oracle_{s['name']}_{int(now)}"
|
||||
# 检查是否已存在
|
||||
existing = self.task_store.get(task_id)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
blueprint = s.get("action", "").replace("mc run", "").strip()
|
||||
if not blueprint:
|
||||
blueprint = s.get("params", {}).get("blueprint", "douyin_daily")
|
||||
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"cmd_type": "nurture",
|
||||
"source": "oracle",
|
||||
"accounts": s.get("accounts", "all"),
|
||||
"machine": s.get("on_machines", "*"),
|
||||
"priority": 1,
|
||||
"schedule_type": "cron",
|
||||
"cron_expr": s["schedule"],
|
||||
"blueprint": blueprint,
|
||||
"rounds": s.get("params", {}).get("rounds", 10),
|
||||
"status": STATUS_SCHEDULED,
|
||||
"created_at": now,
|
||||
}
|
||||
self.task_store.save(task)
|
||||
count += 1
|
||||
logger.info(f" 📅 [{task_id}] 导入定时任务: {s['schedule']} {blueprint}")
|
||||
|
||||
if count:
|
||||
logger.info(f" ✅ 导入 {count} 条定时任务")
|
||||
else:
|
||||
logger.debug(" 没有新的定时任务需要导入")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ORACLE 同步异常: {e}")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
priority_queue.py — 优先级队列 (guardd 模块)
|
||||
|
||||
基于 heapq 实现,支持:
|
||||
- 按优先级排序(0=最高)
|
||||
- 按时间调度(scheduled_at)
|
||||
- 取消/重排
|
||||
"""
|
||||
import heapq
|
||||
import time
|
||||
from typing import Optional, List
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class PriorityQueue:
|
||||
"""优先级队列 — 支持时间调度"""
|
||||
|
||||
def __init__(self):
|
||||
self._heap = []
|
||||
self._task_map = {} # task_id → (priority, scheduled_at, task_id)
|
||||
self._lock = Lock()
|
||||
|
||||
def push(self, task: dict) -> None:
|
||||
"""推入任务,保留 accounts/cmd_type 等关键字段"""
|
||||
task_id = task["task_id"]
|
||||
priority = task.get("priority", 1)
|
||||
scheduled_at = task.get("scheduled_at", 0) or 0
|
||||
if isinstance(scheduled_at, str):
|
||||
try:
|
||||
scheduled_at = float(scheduled_at)
|
||||
except (ValueError, TypeError):
|
||||
scheduled_at = 0
|
||||
item = (priority, scheduled_at, task_id)
|
||||
with self._lock:
|
||||
heapq.heappush(self._heap, item)
|
||||
self._task_map[task_id] = {
|
||||
"priority": priority,
|
||||
"scheduled_at": scheduled_at,
|
||||
"accounts": task.get("accounts", []),
|
||||
"cmd_type": task.get("cmd_type", ""),
|
||||
"queued_at": task.get("queued_at", 0),
|
||||
"command_line": task.get("command_line", ""),
|
||||
"blueprint": task.get("blueprint", ""),
|
||||
}
|
||||
|
||||
def pop_ready(self, now: float = None) -> Optional[str]:
|
||||
"""取出到时间且优先级最高的任务,返回 task_id"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
while self._heap:
|
||||
priority, scheduled_at, task_id = self._heap[0]
|
||||
if scheduled_at <= now:
|
||||
heapq.heappop(self._heap)
|
||||
self._task_map.pop(task_id, None)
|
||||
return task_id
|
||||
else:
|
||||
return None # 堆顶还没到时间
|
||||
return None
|
||||
|
||||
def peek(self) -> Optional[dict]:
|
||||
"""查看堆顶但不取出"""
|
||||
with self._lock:
|
||||
if not self._heap:
|
||||
return None
|
||||
priority, scheduled_at, task_id = self._heap[0]
|
||||
return {"task_id": task_id, "priority": priority, "scheduled_at": scheduled_at}
|
||||
|
||||
def remove(self, task_id: str) -> bool:
|
||||
"""从队列中移除任务"""
|
||||
with self._lock:
|
||||
if task_id in self._task_map:
|
||||
# 标记移除(实际在 pop 时清理)
|
||||
self._task_map[task_id] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
with self._lock:
|
||||
return len(self._heap) == 0
|
||||
|
||||
def size(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._heap)
|
||||
|
||||
def clear(self):
|
||||
"""清空队列"""
|
||||
with self._lock:
|
||||
self._heap.clear()
|
||||
self._task_map.clear()
|
||||
|
||||
def get_all(self) -> List[dict]:
|
||||
"""返回所有队列中的任务信息(含 accounts/cmd_type 等字段)"""
|
||||
with self._lock:
|
||||
result = []
|
||||
for p, s, tid in self._heap:
|
||||
info = self._task_map.get(tid)
|
||||
if info is None:
|
||||
continue
|
||||
result.append({
|
||||
"task_id": tid,
|
||||
"priority": info.get("priority", p),
|
||||
"scheduled_at": info.get("scheduled_at", s),
|
||||
"accounts": info.get("accounts", []),
|
||||
"cmd_type": info.get("cmd_type", ""),
|
||||
"queued_at": info.get("queued_at", 0),
|
||||
"command_line": info.get("command_line", ""),
|
||||
"blueprint": info.get("blueprint", ""),
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
schedule_bridge.py — 定时任务桥接 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- 读取 mc/scheduler 的 schedule.yaml
|
||||
- 检查到期的定时任务
|
||||
- 提交到 guardd scheduler 执行(P1 优先级)
|
||||
- 记录最后执行时间,防止重复触发
|
||||
|
||||
配置路径: 05_tools/07_matrix/scripts/config/schedule.yaml
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from modules.task_store import TaskStore
|
||||
|
||||
logger = logging.getLogger("guardd.schedule_bridge")
|
||||
|
||||
HOME = Path.home()
|
||||
SCRIPTS_DIR = HOME / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "07_matrix" / "scripts"
|
||||
SCHEDULE_YAML = SCRIPTS_DIR / "config" / "schedule.yaml"
|
||||
LAST_RUN_FILE = HOME / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "schedule_last_run.json"
|
||||
|
||||
CHECK_INTERVAL = 60 # 每 60 秒检查一次
|
||||
|
||||
|
||||
class ScheduleBridge:
|
||||
"""定时任务 → guardd 调度器桥接"""
|
||||
|
||||
def __init__(self, task_store: TaskStore, scheduler=None, hostname: str = ""):
|
||||
self.task_store = task_store
|
||||
self.scheduler = scheduler
|
||||
self.hostname = hostname
|
||||
self.last_check = 0
|
||||
self._last_run_cache = {}
|
||||
|
||||
def load_last_runs(self):
|
||||
"""加载上次执行时间记录"""
|
||||
if LAST_RUN_FILE.exists():
|
||||
try:
|
||||
self._last_run_cache = json.loads(LAST_RUN_FILE.read_text())
|
||||
except Exception:
|
||||
self._last_run_cache = {}
|
||||
|
||||
def save_last_runs(self):
|
||||
"""保存上次执行时间"""
|
||||
LAST_RUN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
LAST_RUN_FILE.write_text(json.dumps(self._last_run_cache, indent=2))
|
||||
|
||||
def check_schedules(self):
|
||||
"""检查是否有定时任务到期"""
|
||||
now = time.time()
|
||||
if now - self.last_check < CHECK_INTERVAL:
|
||||
return 0
|
||||
self.last_check = now
|
||||
|
||||
if not SCHEDULE_YAML.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(SCHEDULE_YAML.read_text())
|
||||
schedules = data.get("schedules", {}) if data else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"schedule.yaml 读取失败: {e}")
|
||||
return 0
|
||||
|
||||
self.load_last_runs()
|
||||
triggered = 0
|
||||
now_dt = datetime.now()
|
||||
today_minutes = now_dt.hour * 60 + now_dt.minute
|
||||
weekday = now_dt.isoweekday() # 1=周一..7=周日
|
||||
|
||||
for sid, s in schedules.items():
|
||||
if not s.get("enabled", False):
|
||||
continue
|
||||
|
||||
# 检查星期匹配
|
||||
days_str = s.get("days", "1,2,3,4,5,6,7")
|
||||
days = [int(d.strip()) for d in days_str.split(",") if d.strip()]
|
||||
if weekday not in days:
|
||||
continue
|
||||
|
||||
# 检查时间匹配
|
||||
time_str = s.get("time", "")
|
||||
if not time_str:
|
||||
continue
|
||||
try:
|
||||
h, m = time_str.split(":")
|
||||
schedule_minutes = int(h) * 60 + int(m)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# 允许 1 分钟误差(防止跳过)
|
||||
if abs(today_minutes - schedule_minutes) > 1:
|
||||
continue
|
||||
|
||||
# 检查是否已触发过(防止重复)
|
||||
last_run = self._last_run_cache.get(sid, 0)
|
||||
if now - last_run < CHECK_INTERVAL * 2:
|
||||
continue
|
||||
|
||||
# 提交到 guardd scheduler
|
||||
account = s.get("account", "")
|
||||
blueprint = s.get("blueprint", "douyin_daily")
|
||||
rounds = s.get("rounds", 3)
|
||||
|
||||
task_id = f"schedule_{sid}_{int(now)}"
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"cmd_type": "nurture",
|
||||
"accounts": [account] if account else [],
|
||||
"blueprint": blueprint,
|
||||
"rounds": rounds,
|
||||
"priority": 1, # P1 日常
|
||||
"source": "schedule",
|
||||
"schedule_id": sid,
|
||||
"params": {},
|
||||
}
|
||||
|
||||
if self.scheduler and hasattr(self.scheduler, 'submit_task'):
|
||||
self.scheduler.submit_task(task)
|
||||
logger.info(f" 📅 [{task_id}] 定时任务触发: {sid} → {blueprint} @ {account}")
|
||||
else:
|
||||
logger.warning(f" ⚠️ scheduler 不可用,定时任务 {sid} 无法提交")
|
||||
|
||||
self._last_run_cache[sid] = now
|
||||
triggered += 1
|
||||
|
||||
if triggered:
|
||||
self.save_last_runs()
|
||||
|
||||
return triggered
|
||||
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
scheduler.py — 调度引擎 v4(简化版)
|
||||
|
||||
核心变化(vs v3):
|
||||
1. 一条队列全局排序,3 slot 从队首取任务
|
||||
2. P0 插入规则: 找第一个"后面没有 P0"的 P1 → 插它后面(自动交替)
|
||||
3. 去掉 interval、decomposed_from、paused_slots 全部复杂逻辑
|
||||
4. executor 读输出改为非阻塞(select)
|
||||
5. _pop_next 增加 task_store 状态校验,过期自动清除
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
from threading import RLock
|
||||
import time
|
||||
from typing import Optional
|
||||
from modules.task_store import (
|
||||
TaskStore, STATUS_QUEUED, STATUS_RUNNING,
|
||||
STATUS_COMPLETED, STATUS_FAILED, TERMINAL_STATUSES
|
||||
)
|
||||
from modules.priority_queue import PriorityQueue
|
||||
from modules.slot_manager import BrowserSlotManager
|
||||
from modules.executor import Executor
|
||||
|
||||
logger = logging.getLogger("guardd.scheduler")
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""调度引擎 v4 — 单队列 + 交替规则 + 事件驱动编排"""
|
||||
|
||||
def __init__(self, task_store: TaskStore, priority_queue: PriorityQueue,
|
||||
slot_manager: BrowserSlotManager, executor: Executor,
|
||||
on_task_event=None):
|
||||
self.task_store = task_store
|
||||
self.queue_priority = PriorityQueue() # P0
|
||||
self.queue_normal = PriorityQueue() # P1
|
||||
self.queue_filler = PriorityQueue() # P2
|
||||
|
||||
self.slot_manager = slot_manager
|
||||
self.executor = executor
|
||||
self.active_tasks: dict[int, dict] = {}
|
||||
self.account_slots: dict[str, int] = {}
|
||||
self.on_task_event = on_task_event
|
||||
|
||||
self.loop_interval = 15
|
||||
self.max_slots = slot_manager.max_slots if slot_manager else 3
|
||||
self._schedule_lock = RLock()
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 主循环
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def run_cycle(self):
|
||||
while True:
|
||||
try:
|
||||
self._check_all_active_tasks()
|
||||
self._schedule_all_slots()
|
||||
self.slot_manager.check_health()
|
||||
except Exception as e:
|
||||
logger.error(f"调度循环异常: {e}")
|
||||
time.sleep(self.loop_interval)
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 任务提交
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def submit_task(self, task: dict):
|
||||
with self._schedule_lock:
|
||||
self._submit_task_inner(task)
|
||||
|
||||
def _submit_task_inner(self, task: dict):
|
||||
task_id = task["task_id"]
|
||||
task["status"] = STATUS_QUEUED
|
||||
task["queued_at"] = time.time()
|
||||
self.task_store.save(task)
|
||||
|
||||
priority = task.get("priority", 1)
|
||||
if priority == 0:
|
||||
self._insert_p0(task)
|
||||
elif priority == 2:
|
||||
self.queue_filler.push(task)
|
||||
logger.info(f" 📥 [{task_id[:30]}] 入队 P2 (队尾)")
|
||||
else:
|
||||
self.queue_normal.push(task)
|
||||
logger.info(f" 📥 [{task_id[:30]}] 入队 P1 (日常)")
|
||||
|
||||
if self._has_free_slot():
|
||||
self._schedule_all_slots()
|
||||
|
||||
def _insert_p0(self, task: dict):
|
||||
"""P0 插入: 直接入 priority 队列"""
|
||||
task_id = task["task_id"]
|
||||
self.queue_priority.push(task)
|
||||
logger.info(f" 📥 [{task_id[:30]}] 入队 P0")
|
||||
|
||||
def _has_free_slot(self) -> bool:
|
||||
"""检查是否有空闲 slot。使用 active_tasks 长度作为主要依据,
|
||||
避免 executor 线程尚未调用 acquire() 时的竞态条件。"""
|
||||
# active_tasks 是同步设置的(在 _assign_task 中),比 slot_manager 更准确
|
||||
active_count = len(self.active_tasks)
|
||||
max_s = self.max_slots
|
||||
free = active_count < max_s
|
||||
return free
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 活跃任务检查
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def _check_all_active_tasks(self):
|
||||
if not self.active_tasks:
|
||||
return
|
||||
|
||||
for slot_id, task in list(self.active_tasks.items()):
|
||||
status = task.get("status", "")
|
||||
|
||||
if status not in TERMINAL_STATUSES:
|
||||
continue
|
||||
|
||||
logger.info(f" ✅ [{task['task_id'][:30]}] 完成 (status={status})")
|
||||
if self.on_task_event:
|
||||
try:
|
||||
self.on_task_event("completed" if status == STATUS_COMPLETED else "failed", task)
|
||||
except Exception:
|
||||
pass
|
||||
self._release_slot(slot_id)
|
||||
|
||||
def _release_slot(self, slot_id: int):
|
||||
task = self.active_tasks.pop(slot_id, None)
|
||||
if task:
|
||||
for acct in (task.get("accounts") or []):
|
||||
if acct and acct in self.account_slots:
|
||||
del self.account_slots[acct]
|
||||
logger.info(f" 🔓 slot {slot_id} 释放账号 {acct}")
|
||||
# 同步释放 slot_manager
|
||||
if self.slot_manager:
|
||||
try:
|
||||
self.slot_manager.release_by_account(acct)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Slot 分配
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def _schedule_all_slots(self):
|
||||
with self._schedule_lock:
|
||||
self._schedule_all_inner()
|
||||
|
||||
def _schedule_all_inner(self):
|
||||
usage = self.slot_manager.get_usage() if self.slot_manager else {"max": self.max_slots, "used": 0, "slots": []}
|
||||
max_s = usage.get("max", self.max_slots)
|
||||
|
||||
for slot_id in range(max_s):
|
||||
if slot_id in self.active_tasks:
|
||||
continue
|
||||
|
||||
if self.slot_manager:
|
||||
slots_info = usage.get("slots", [])
|
||||
slot_info = next((s for s in slots_info if s.get("slot_id") == slot_id), None)
|
||||
if slot_info and slot_info.get("account_id"):
|
||||
continue
|
||||
|
||||
task = self._pop_next(slot_id)
|
||||
if not task:
|
||||
continue
|
||||
|
||||
self._assign_task(slot_id, task)
|
||||
|
||||
def _pop_next(self, slot_id: int) -> Optional[dict]:
|
||||
"""取下一个可用任务,按 P0/P1 交替规则,跳过 busy 账号和过期条目。
|
||||
|
||||
交替规则: 优先取 P0,但已分配的 P0 数量>=P1 时取 P1,
|
||||
确保 P0 和 P1 交替执行,不会 3 个 slot 全跑 P0。
|
||||
"""
|
||||
# 统计本轮已分配给前几个 slot 的 P0/P1 数量
|
||||
p0_assigned = 0
|
||||
p1_assigned = 0
|
||||
for sid in range(slot_id):
|
||||
_t = self.active_tasks.get(sid)
|
||||
if _t:
|
||||
_p = _t.get("priority")
|
||||
if _p == 0:
|
||||
p0_assigned += 1
|
||||
elif _p == 1:
|
||||
p1_assigned += 1
|
||||
|
||||
# 交替策略:P0 >= P1 时优先取 P1(P0 永远跟在 P1 后面)
|
||||
prefer_p1 = (p0_assigned >= p1_assigned) and self.queue_normal.size() > 0
|
||||
|
||||
if prefer_p1:
|
||||
queue_order = [
|
||||
(self.queue_normal, "P1"),
|
||||
(self.queue_priority, "P0"),
|
||||
(self.queue_filler, "P2"),
|
||||
]
|
||||
else:
|
||||
queue_order = [
|
||||
(self.queue_priority, "P0"),
|
||||
(self.queue_normal, "P1"),
|
||||
(self.queue_filler, "P2"),
|
||||
]
|
||||
|
||||
for queue, _ in queue_order:
|
||||
candidates = queue.get_all()
|
||||
candidates.sort(key=lambda c: c.get("queued_at", 0))
|
||||
|
||||
for candidate in candidates:
|
||||
accounts = candidate.get("accounts", [])
|
||||
if any(acct in self.account_slots for acct in accounts):
|
||||
continue
|
||||
|
||||
task_id = candidate["task_id"]
|
||||
|
||||
# 校验 task_store 状态
|
||||
stored = self.task_store.get(task_id)
|
||||
if not stored:
|
||||
queue.remove(task_id)
|
||||
logger.warning(f" ⏭ [{task_id[:30]}] 过期清除(无记录)")
|
||||
continue
|
||||
if stored.get("status") != STATUS_QUEUED:
|
||||
queue.remove(task_id)
|
||||
logger.info(f" ⏭ [{task_id[:30]}] 过期清除(status={stored.get('status')})")
|
||||
continue
|
||||
|
||||
removed = queue.remove(task_id)
|
||||
if not removed:
|
||||
continue
|
||||
|
||||
candidate["status"] = STATUS_RUNNING
|
||||
self.task_store.save(candidate)
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def _assign_task(self, slot_id: int, task: dict):
|
||||
self.active_tasks[slot_id] = task
|
||||
task["started_at"] = time.time()
|
||||
task["slot_id"] = slot_id
|
||||
|
||||
for acct in (task.get("accounts") or []):
|
||||
if acct:
|
||||
self.account_slots[acct] = slot_id
|
||||
|
||||
self.task_store.save(task)
|
||||
accts_str = ",".join(task.get("accounts", []))
|
||||
logger.info(f" ▶️ [{task['task_id'][:30]}] slot {slot_id} 开始 (accounts={accts_str})")
|
||||
|
||||
if self.on_task_event:
|
||||
try:
|
||||
self.on_task_event("started", task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import threading
|
||||
threading.Thread(target=self.executor.execute, args=(task,), daemon=True).start()
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ [{task['task_id'][:30]}] 执行失败: {e}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 辅助
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def kill_active(self):
|
||||
for slot_id, task in list(self.active_tasks.items()):
|
||||
try:
|
||||
self.executor.kill(task["task_id"])
|
||||
except Exception:
|
||||
pass
|
||||
self.active_tasks.clear()
|
||||
self.account_slots.clear()
|
||||
|
||||
def clear_all(self):
|
||||
"""清空所有队列 + 杀死活跃任务,完全还原"""
|
||||
self.kill_active()
|
||||
# 清空内存队列
|
||||
for q in [self.queue_priority, self.queue_normal, self.queue_filler]:
|
||||
q.clear()
|
||||
# 将 task_store 中所有 queued/pending 状态的任务标记为 cancelled
|
||||
for status in ("queued", "pending"):
|
||||
for t in self.task_store.get_by_status(status):
|
||||
t["status"] = "cancelled"
|
||||
self.task_store.save(t)
|
||||
if self.slot_manager:
|
||||
self.slot_manager.cleanup_orphans()
|
||||
|
||||
def queue_sizes(self) -> dict:
|
||||
return {
|
||||
"priority": len(self.queue_priority.get_all()),
|
||||
"normal": len(self.queue_normal.get_all()),
|
||||
"filler": len(self.queue_filler.get_all()),
|
||||
}
|
||||
|
||||
def get_all_queued(self) -> list:
|
||||
"""返回排队队列(P0/P1 交替排序,P2 附加末尾)"""
|
||||
p0_list = self.queue_priority.get_all()
|
||||
p1_list = self.queue_normal.get_all()
|
||||
p2_list = self.queue_filler.get_all()
|
||||
|
||||
# 按 queued_at 排序后交替合并
|
||||
p0_list.sort(key=lambda x: x.get("queued_at", 0))
|
||||
p1_list.sort(key=lambda x: x.get("queued_at", 0))
|
||||
|
||||
result = []
|
||||
p0_i, p1_i, p2_i = 0, 0, 0
|
||||
last_p0 = True # 从 P1 开始交替(P0 永远跟在 P1 后面)
|
||||
|
||||
while p0_i < len(p0_list) or p1_i < len(p1_list):
|
||||
if p0_i >= len(p0_list):
|
||||
# 只剩 P1
|
||||
p1_list[p1_i]["queue"] = "P1"
|
||||
result.append(p1_list[p1_i])
|
||||
p1_i += 1
|
||||
last_p0 = False
|
||||
elif p1_i >= len(p1_list):
|
||||
# 只剩 P0 → 用 P2 作间隔
|
||||
if last_p0 and p2_i < len(p2_list):
|
||||
p2_list[p2_i]["queue"] = "P2"
|
||||
result.append(p2_list[p2_i])
|
||||
p2_i += 1
|
||||
last_p0 = False
|
||||
else:
|
||||
p0_list[p0_i]["queue"] = "P0"
|
||||
result.append(p0_list[p0_i])
|
||||
p0_i += 1
|
||||
last_p0 = True
|
||||
# P0 后面没 P1 → 补一个 P2 作间隔
|
||||
if p2_i < len(p2_list):
|
||||
p2_list[p2_i]["queue"] = "P2"
|
||||
result.append(p2_list[p2_i])
|
||||
p2_i += 1
|
||||
last_p0 = False
|
||||
elif last_p0:
|
||||
# 上次取了 P0,这次取 P1(交替)
|
||||
p1_list[p1_i]["queue"] = "P1"
|
||||
result.append(p1_list[p1_i])
|
||||
p1_i += 1
|
||||
last_p0 = False
|
||||
else:
|
||||
# 上次取了 P1 或刚开始,取 P0
|
||||
p0_list[p0_i]["queue"] = "P0"
|
||||
result.append(p0_list[p0_i])
|
||||
p0_i += 1
|
||||
last_p0 = True
|
||||
|
||||
# 剩余的 P2 附加末尾
|
||||
for i in range(p2_i, len(p2_list)):
|
||||
p2_list[i]["queue"] = "P2"
|
||||
result.append(p2_list[i])
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
slot_manager.py — 浏览器槽位管理器 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- 管理本机 Camoufox 浏览器实例(槽位)
|
||||
- 按 browser_id 识别,不依赖槽位编号(用户拖拽窗口不影响)
|
||||
- 同一账号不能同时在两个浏览器运行
|
||||
- 启动时扫描孤儿浏览器进程
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class AccountBusyError(Exception):
|
||||
"""账号已在其他槽位运行"""
|
||||
pass
|
||||
|
||||
|
||||
class BrowserSlotManager:
|
||||
"""浏览器槽位管理器"""
|
||||
|
||||
def __init__(self, max_slots: int = 3):
|
||||
self.max_slots = max_slots
|
||||
self.slots = [None] * max_slots # list of dict or None
|
||||
self._lock = Lock()
|
||||
|
||||
def acquire(self, account_id: str, identity_dir: str, nickname: str = "",
|
||||
platform: str = "douyin") -> Optional[dict]:
|
||||
"""获取一个槽位,返回 slot_info dict
|
||||
Args:
|
||||
account_id: 账号ID (douyin_136)
|
||||
identity_dir: 身份目录名
|
||||
nickname: 账号昵称 (显示用)
|
||||
platform: 平台 (douyin / xiaohongshu)
|
||||
Raises: AccountBusyError 如果账号已在其他槽位运行
|
||||
"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if s and s["account_id"] == account_id:
|
||||
raise AccountBusyError(f"账号 {account_id} 已在 slot {s['slot_id']} 运行")
|
||||
|
||||
for i in range(self.max_slots):
|
||||
if self.slots[i] is None:
|
||||
info = {
|
||||
"slot_id": i,
|
||||
"account_id": account_id,
|
||||
"nickname": nickname,
|
||||
"platform": platform,
|
||||
"browser_id": identity_dir,
|
||||
"pid": None,
|
||||
"blueprint": None,
|
||||
"current_step": None,
|
||||
"step_index": 0,
|
||||
"total_steps": 0,
|
||||
"started_at": time.time(),
|
||||
"elapsed_sec": 0,
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 0.0,
|
||||
"health": "healthy",
|
||||
"last_heartbeat": time.time(),
|
||||
}
|
||||
self.slots[i] = info
|
||||
return info
|
||||
return None
|
||||
|
||||
def release(self, browser_id: str) -> bool:
|
||||
"""按 browser_id 释放槽位(不依赖槽位编号)"""
|
||||
with self._lock:
|
||||
for i in range(self.max_slots):
|
||||
if self.slots[i] and self.slots[i]["browser_id"] == browser_id:
|
||||
pid = self.slots[i].get("pid")
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
self.slots[i] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def release_by_account(self, account_id: str) -> bool:
|
||||
"""按 account_id 释放槽位"""
|
||||
with self._lock:
|
||||
for i in range(self.max_slots):
|
||||
if self.slots[i] and self.slots[i]["account_id"] == account_id:
|
||||
self.slots[i] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_step(self, browser_id: str, step_name: str, step_index: int = None, total_steps: int = None):
|
||||
"""更新槽位中正在执行的步骤"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if s and s["browser_id"] == browser_id:
|
||||
s["current_step"] = step_name
|
||||
s["elapsed_sec"] = int(time.time() - s["started_at"])
|
||||
s["last_heartbeat"] = time.time()
|
||||
if step_index is not None:
|
||||
s["step_index"] = step_index
|
||||
if total_steps is not None:
|
||||
s["total_steps"] = total_steps
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_pid(self, browser_id: str, pid: int):
|
||||
"""设置浏览器进程 PID"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if s and s["browser_id"] == browser_id:
|
||||
s["pid"] = pid
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_account(self, account_id: str) -> Optional[dict]:
|
||||
"""查找账号在当前哪个槽位运行"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if s and s["account_id"] == account_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def find_by_browser(self, browser_id: str) -> Optional[dict]:
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if s and s["browser_id"] == browser_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def check_health(self):
|
||||
"""每轮 cycle 检查所有浏览器进程健康状态"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if not s or not s.get("pid"):
|
||||
continue
|
||||
try:
|
||||
os.kill(s["pid"], 0) # 空信号测存活
|
||||
except OSError:
|
||||
s["health"] = "crashed"
|
||||
s["account_id"] = None
|
||||
continue
|
||||
# 采集 CPU/内存
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ps", "-p", str(s["pid"]), "-o", "%cpu=,%mem=,rss="],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
parts = r.stdout.strip().split()
|
||||
if len(parts) >= 3:
|
||||
s["cpu_percent"] = float(parts[0])
|
||||
s["memory_mb"] = float(parts[2]) / 1024
|
||||
s["health"] = "warning" if float(parts[0]) > 80 else "healthy"
|
||||
except Exception:
|
||||
pass
|
||||
s["elapsed_sec"] = int(time.time() - s["started_at"])
|
||||
s["last_heartbeat"] = time.time()
|
||||
|
||||
def cleanup_orphans(self):
|
||||
"""启动时扫描孤儿浏览器进程"""
|
||||
print(" [SlotManager] 扫描孤儿浏览器进程...")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ps", "aux"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
orphan_count = 0
|
||||
for line in r.stdout.split("\n"):
|
||||
if "camoufox" in line.lower() or "firefox" in line.lower():
|
||||
parts = line.split()
|
||||
if len(parts) > 1:
|
||||
pid = parts[1]
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
orphan_count += 1
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if orphan_count:
|
||||
print(f" [SlotManager] 清理 {orphan_count} 个孤儿浏览器进程")
|
||||
except Exception as e:
|
||||
print(f" [SlotManager] 清理异常: {e}")
|
||||
|
||||
def check_processes(self):
|
||||
"""逐 slot 检查 PID 存活状态(5 秒线程调用)"""
|
||||
with self._lock:
|
||||
for s in self.slots:
|
||||
if not s or not s.get("pid"):
|
||||
continue
|
||||
pid = s["pid"]
|
||||
alive = True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
alive = False
|
||||
if alive:
|
||||
s["health"] = "healthy"
|
||||
s["last_heartbeat"] = time.time()
|
||||
else:
|
||||
s["health"] = "crashed"
|
||||
s["pid"] = None
|
||||
s["account_id"] = None
|
||||
|
||||
def track_loop(self, interval: float = 5.0):
|
||||
"""后台线程:每 5 秒检查一次所有 slot 的进程存活"""
|
||||
logger = __import__("logging").getLogger("guardd.slot_manager")
|
||||
while True:
|
||||
try:
|
||||
self.check_processes()
|
||||
except Exception as e:
|
||||
logger.error(f"track_loop 异常: {e}")
|
||||
__import__("time").sleep(interval)
|
||||
|
||||
def get_usage(self) -> dict:
|
||||
with self._lock:
|
||||
slots_info = []
|
||||
for i, s in enumerate(self.slots):
|
||||
if s:
|
||||
slots_info.append({
|
||||
"slot_id": i,
|
||||
"account_id": s["account_id"],
|
||||
"nickname": s.get("nickname", ""),
|
||||
"platform": s.get("platform", ""),
|
||||
"browser_id": s["browser_id"],
|
||||
"pid": s["pid"],
|
||||
"blueprint": s.get("blueprint"),
|
||||
"current_step": s.get("current_step"),
|
||||
"step_index": s.get("step_index", 0),
|
||||
"total_steps": s.get("total_steps", 0),
|
||||
"elapsed_sec": s.get("elapsed_sec", 0),
|
||||
"health": s.get("health", "unknown"),
|
||||
})
|
||||
return {
|
||||
"max": self.max_slots,
|
||||
"used": sum(1 for s in self.slots if s is not None),
|
||||
"slots": slots_info,
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
task_store.py — 任务持久化存储 (guardd 模块)
|
||||
|
||||
职责:
|
||||
- 内存 dict + SQLite 双写
|
||||
- 内存保性能,SQLite 持久化
|
||||
- 启动时从未完成的任务恢复
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Task status constants
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_SCHEDULED = "scheduled"
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_PREFLIGHT = "preflight"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_PAUSED = "paused"
|
||||
STATUS_WAITING_DEP = "waiting_dep"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
STATUS_SKIPPED = "skipped"
|
||||
|
||||
TERMINAL_STATUSES = {STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED, STATUS_SKIPPED}
|
||||
ACTIVE_STATUSES = {STATUS_RUNNING, STATUS_QUEUED, STATUS_PREFLIGHT, STATUS_PAUSED, STATUS_WAITING_DEP}
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""任务持久化存储 — 内存 dict + SQLite 双写"""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
if db_path is None:
|
||||
home = Path.home()
|
||||
db_path = str(home / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "tasks.db")
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._mem: dict = {} # task_id → task dict
|
||||
self._init_db()
|
||||
self._load_from_db()
|
||||
|
||||
def _init_db(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS account_health (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def _load_from_db(self):
|
||||
"""启动时从 SQLite 加载所有任务到内存"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
rows = conn.execute("SELECT task_id, data, status FROM tasks").fetchall()
|
||||
for task_id, data_json, status in rows:
|
||||
task = json.loads(data_json)
|
||||
task["status"] = status
|
||||
self._mem[task_id] = task
|
||||
|
||||
def save(self, task: dict) -> None:
|
||||
"""保存/更新任务(内存 + SQLite)"""
|
||||
task_id = task["task_id"]
|
||||
status = task.get("status", STATUS_PENDING)
|
||||
now = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._mem[task_id] = task
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO tasks (task_id, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(task_id, json.dumps(task, ensure_ascii=False), status, task.get("created_at", now), now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get(self, task_id: str) -> Optional[dict]:
|
||||
with self._lock:
|
||||
return self._mem.get(task_id)
|
||||
|
||||
def get_by_status(self, *statuses) -> List[dict]:
|
||||
with self._lock:
|
||||
return [t for t in self._mem.values() if t.get("status") in statuses]
|
||||
|
||||
def get_active(self) -> List[dict]:
|
||||
return self.get_by_status(*ACTIVE_STATUSES)
|
||||
|
||||
def find_dependents(self, task_id: str) -> List[str]:
|
||||
"""查找依赖指定 task_id 的所有任务"""
|
||||
result = []
|
||||
with self._lock:
|
||||
for tid, task in self._mem.items():
|
||||
deps = task.get("depends_on", [])
|
||||
if task_id in deps:
|
||||
result.append(tid)
|
||||
return result
|
||||
|
||||
def save_account_health(self, account_id: str, data: dict) -> None:
|
||||
data["updated_at"] = time.time()
|
||||
with self._lock:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO account_health (account_id, data, updated_at) VALUES (?, ?, ?)",
|
||||
(account_id, json.dumps(data, ensure_ascii=False), time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_account_health(self, account_id: str) -> Optional[dict]:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
row = conn.execute("SELECT data FROM account_health WHERE account_id = ?", (account_id,)).fetchone()
|
||||
if row:
|
||||
return json.loads(row[0])
|
||||
return None
|
||||
|
||||
def get_unfinished(self) -> List[dict]:
|
||||
"""获取所有未完成的任务(启动恢复用)"""
|
||||
return self.get_by_status(*ACTIVE_STATUSES)
|
||||
|
||||
def reset_unfinished(self) -> int:
|
||||
"""启动时将 running 状态重置为 queued"""
|
||||
count = 0
|
||||
for task in self.get_unfinished():
|
||||
if task.get("status") == STATUS_RUNNING:
|
||||
task["status"] = STATUS_QUEUED
|
||||
task["message"] = "guardd 重启恢复"
|
||||
self.save(task)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_group_last_completed(self, group_id: str) -> Optional[float]:
|
||||
"""获取某组任务最后一次完成的时间戳
|
||||
|
||||
Args:
|
||||
group_id: 任务组ID (decomposed_from 或 task_id)
|
||||
Returns:
|
||||
最后完成时间的 Unix 时间戳,没有完成过的返回 None
|
||||
"""
|
||||
with self._lock:
|
||||
last_time = None
|
||||
for tid, task in self._mem.items():
|
||||
if task.get("decomposed_from") == group_id or tid == group_id:
|
||||
last_completed = task.get("completed_at")
|
||||
if last_completed and (last_time is None or last_completed > last_time):
|
||||
last_time = last_completed
|
||||
return last_time
|
||||
|
||||
def all_tasks(self) -> List[dict]:
|
||||
with self._lock:
|
||||
return list(self._mem.values())
|
||||
|
||||
def count(self) -> dict:
|
||||
with self._lock:
|
||||
counts = {}
|
||||
for t in self._mem.values():
|
||||
s = t.get("status", "unknown")
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
return counts
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# chrome_debug.sh — 启动 Chrome 远程调试(供 launchd 调用)
|
||||
# 确保 Chrome 以 --remote-debugging-port=9222 运行
|
||||
#
|
||||
# v2 — 增强版
|
||||
# - 所有日志带时间戳
|
||||
# - 重启次数限制(最多 5 次,超出后退出等待手动处理)
|
||||
# - 重启间隔递增(5s → 15s → 30s)
|
||||
# - 检测到 OOM 时打印内存状态
|
||||
|
||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
USER_DATA_DIR="/tmp/chrome-douyin-profile"
|
||||
PIDFILE="/tmp/chrome-debug.pid"
|
||||
LOG_FILE="/tmp/chrome_debug_launchd.log"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a /tmp/chrome_debug_watch.log
|
||||
}
|
||||
|
||||
is_9222_alive() {
|
||||
curl -s --connect-timeout 3 http://127.0.0.1:9222/json/version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
launch_chrome() {
|
||||
nohup "$CHROME" \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$USER_DATA_DIR" \
|
||||
--no-first-run --no-default-browser-check \
|
||||
--disable-features=ChromeWhatsNewUI \
|
||||
--window-position=9999,9999 \
|
||||
about:blank \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$PIDFILE"
|
||||
log "Chrome 已启动 (PID $!),屏幕外位置 (9999,9999)"
|
||||
}
|
||||
|
||||
# ── 主流程 ──
|
||||
|
||||
log "=== chrome_debug.sh 启动 ==="
|
||||
|
||||
# 先检查 9222 是否已经在线
|
||||
if is_9222_alive; then
|
||||
log "端口 9222 已有 Chrome 在监听,进入健康检查循环"
|
||||
else
|
||||
log "端口 9222 无响应,启动 Chrome..."
|
||||
launch_chrome
|
||||
# 等最多 15 秒
|
||||
for i in $(seq 1 15); do
|
||||
sleep 1
|
||||
if is_9222_alive; then
|
||||
log "Chrome debug 启动成功(第 ${i} 秒)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if ! is_9222_alive; then
|
||||
log "❌ Chrome 启动超时(15 秒),请检查: cat $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 健康检查循环 ──
|
||||
RESTART_COUNT=0
|
||||
MAX_RESTARTS=5
|
||||
|
||||
while true; do
|
||||
sleep 10
|
||||
|
||||
if is_9222_alive; then
|
||||
# 正常:端末还在,继续监控
|
||||
continue
|
||||
fi
|
||||
|
||||
# 端口 9222 挂了
|
||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||
log "⚠️ 端口 9222 无响应(第 ${RESTART_COUNT} 次)"
|
||||
|
||||
if [ "$RESTART_COUNT" -gt "$MAX_RESTARTS" ]; then
|
||||
log "❌ 已连续重启 ${MAX_RESTARTS} 次失败,退出等待手动处理"
|
||||
log " 请检查: 系统内存是否充足(vm_stat),或手动启动 Chrome"
|
||||
log " 手动命令: pkill -f 'remote-debugging-port=9222'"
|
||||
log " $CHROME --remote-debugging-port=9222 --user-data-dir=$USER_DATA_DIR --no-first-run --no-default-browser-check about:blank &"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 递增等待时间:5s → 15s → 30s → 30s → 30s
|
||||
WAIT=$(( RESTART_COUNT <= 1 ? 5 : RESTART_COUNT == 2 ? 15 : 30 ))
|
||||
log "等待 ${WAIT} 秒后重启..."
|
||||
|
||||
# 清理残留 Chrome 进程(防止多实例)
|
||||
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# 打印内存状态帮助排查
|
||||
memory_free=$(vm_stat | awk '/free/ {print $3}' | sed 's/\.//')
|
||||
log "当前空闲内存页: ${memory_free:-?}"
|
||||
|
||||
launch_chrome
|
||||
|
||||
# 等最多 20 秒看有没有起来
|
||||
for i in $(seq 1 20); do
|
||||
sleep 1
|
||||
if is_9222_alive; then
|
||||
log "Chrome 重启成功(第 ${i} 秒)"
|
||||
# 成功一次重置计数
|
||||
RESTART_COUNT=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! is_9222_alive; then
|
||||
log "❌ Chrome 重启失败(20 秒超时),继续下一轮"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chrome_hide.py — 把 9222 采集 Chrome 的窗口移到屏幕外(CDP)
|
||||
|
||||
供 chrome_debug.sh 启动后调用。通过 Chrome DevTools Protocol 操作
|
||||
9222 实例自己的窗口,绝不会影响用户日常 Chrome。
|
||||
|
||||
用法: python3 chrome_hide.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
OFFSCREEN = {"left": 10000, "top": 10000, "width": 900, "height": 600, "windowState": "normal"}
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
print("[chrome_hide] playwright 未安装,跳过窗口隐藏", file=sys.stderr)
|
||||
return 0
|
||||
pw = await async_playwright().start()
|
||||
try:
|
||||
browser = await pw.chromium.connect_over_cdp("http://127.0.0.1:9222")
|
||||
except Exception as e:
|
||||
print(f"[chrome_hide] CDP 连接失败: {e}", file=sys.stderr)
|
||||
await pw.stop()
|
||||
return 1
|
||||
try:
|
||||
cdp = await browser.new_browser_cdp_session()
|
||||
targets = await cdp.send("Target.getTargets")
|
||||
moved = 0
|
||||
for t in targets.get("targetInfos", []):
|
||||
if t.get("type") != "page":
|
||||
continue
|
||||
try:
|
||||
win = await cdp.send("Browser.getWindowForTarget", {"targetId": t["targetId"]})
|
||||
wid = win.get("windowId")
|
||||
if wid:
|
||||
await cdp.send("Browser.setWindowBounds", {"windowId": wid, "bounds": OFFSCREEN})
|
||||
moved += 1
|
||||
except Exception as e:
|
||||
print(f"[chrome_hide] 窗口 {t.get('targetId', '')} 处理失败: {e}", file=sys.stderr)
|
||||
print(f"[chrome_hide] 已将 {moved} 个窗口移到屏幕外")
|
||||
finally:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
await pw.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
# guardd 安装脚本 — 动态生成 plist → 部署到 launchd,设置 300 秒周期
|
||||
# 用法: bash scripts/install.sh
|
||||
# 多机器兼容:自动探测本机路径,无需手动修改 plist 模板
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GUARDD_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PLIST_DEST="$HOME/Library/LaunchAgents/com.agentos.guardd.plist"
|
||||
GUARDD_PY="$GUARDD_DIR/guardd.py"
|
||||
RUNTIME_DIR="$HOME/workbuddy-agent-os/agent-local/runtime/guardd"
|
||||
|
||||
# ── 自动探测 Python ──────────────────────────────────────
|
||||
# 优先使用 agent-os venv,fallback 到当前 python3
|
||||
if [ -x "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3" ]; then
|
||||
PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
elif [ -x "$HOME/.workbuddy/binaries/python/envs/default/bin/python3" ]; then
|
||||
PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/default/bin/python3"
|
||||
else
|
||||
PYTHON_BIN=$(command -v python3)
|
||||
fi
|
||||
PYTHON_LIB_DIR="$(dirname "$(dirname "$PYTHON_BIN")")/versions/$(basename "$(dirname "$(dirname "$PYTHON_BIN")")")/lib" 2>/dev/null || echo ""
|
||||
|
||||
echo "=== guardd 安装脚本 ==="
|
||||
echo "源目录: $GUARDD_DIR"
|
||||
echo "Python: $PYTHON_BIN"
|
||||
|
||||
# 1. 检查 guardd.py 是否存在
|
||||
if [ ! -f "$GUARDD_PY" ]; then
|
||||
echo "❌ 未找到 guardd.py: $GUARDD_PY"
|
||||
echo " 请确保在 guardd 目录下运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 创建运行时目录
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
echo "✅ 运行时目录: $RUNTIME_DIR"
|
||||
|
||||
# 3. 确保 guardd.py 可执行
|
||||
chmod +x "$GUARDD_PY"
|
||||
echo "✅ guardd.py 可执行权限"
|
||||
|
||||
# 4. 动态生成 plist(多机器兼容,不依赖静态模板)
|
||||
cat > "$PLIST_DEST" << PLEOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.agentos.guardd</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$PYTHON_BIN</string>
|
||||
<string>$GUARDD_PY</string>
|
||||
</array>
|
||||
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>${RUNTIME_DIR}/stdout.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${RUNTIME_DIR}/stderr.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
||||
<key>GUARDD_DASHBOARD_URL</key>
|
||||
<string>http://localhost:9988</string>
|
||||
</dict>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>300</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
PLEOF
|
||||
echo "✅ plist 已动态生成: $PLIST_DEST"
|
||||
|
||||
# 5. 卸载旧的 launchd job(如果存在)
|
||||
launchctl bootout gui/$(id -u)/com.agentos.guardd 2>/dev/null || true
|
||||
|
||||
# 6. 加载新 plist
|
||||
launchctl bootstrap gui/$(id -u) "$PLIST_DEST"
|
||||
echo "✅ guardd 已加载到 launchd"
|
||||
|
||||
# 7. 立即启动一次(测试)
|
||||
launchctl kickstart -k gui/$(id -u)/com.agentos.guardd
|
||||
echo "✅ guardd 已启动"
|
||||
|
||||
# 8. 验证
|
||||
sleep 2
|
||||
if [ -f "$RUNTIME_DIR/last_run.json" ]; then
|
||||
echo "✅ guardd 首次运行成功"
|
||||
cat "$RUNTIME_DIR/last_run.json"
|
||||
else
|
||||
echo "⏳ guardd 尚未完成首次运行,等待 10 秒..."
|
||||
sleep 8
|
||||
if [ -f "$RUNTIME_DIR/last_run.json" ]; then
|
||||
echo "✅ guardd 首次运行成功"
|
||||
cat "$RUNTIME_DIR/last_run.json"
|
||||
else
|
||||
echo "⚠️ 未检测到 last_run.json,请检查日志: $RUNTIME_DIR/guardd.log"
|
||||
if [ -f "$RUNTIME_DIR/stderr.log" ]; then
|
||||
echo "--- stderr ---"
|
||||
cat "$RUNTIME_DIR/stderr.log"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== guardd 安装完成 ==="
|
||||
echo "查看状态: cat $RUNTIME_DIR/last_run.json"
|
||||
echo "查看日志: cat $RUNTIME_DIR/guardd.log"
|
||||
echo "手动卸载: launchctl bootout gui/$(id -u) $PLIST_DEST"
|
||||
@@ -0,0 +1,43 @@
|
||||
# 7kecheng → chengzigedeAir SSH 配置
|
||||
|
||||
> 最后更新:2026-06-01 20:15
|
||||
|
||||
## 连接信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 主机 | chengzigedeAir |
|
||||
| IP | 192.168.31.101 |
|
||||
| 端口 | 22 |
|
||||
| 用户 | chengzige |
|
||||
| 密钥 | `id_ed25519_chengzigedeAir` |
|
||||
|
||||
## 7kecheng 上执行
|
||||
|
||||
### 方式一:通过 Git 仓库获取私钥
|
||||
|
||||
本机私钥文件路径: `~/.ssh/id_ed25519_7kecheng`,需要通过安全通道传递给 7kecheng。
|
||||
|
||||
### 方式二:手动复制
|
||||
|
||||
在 7kecheng 上创建一个新的 SSH 密钥对,然后把公钥发过来:
|
||||
|
||||
```bash
|
||||
# 在 7kecheng 上生成密钥
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_chengzigedeAir -N "" -C "7kecheng-to-chengzigedeAir"
|
||||
cat ~/.ssh/id_ed25519_chengzigedeAir.pub
|
||||
# 把输出的公钥发给 chengzigedeAir 加入到 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
### 方式三:在 7kecheng 获取私钥内容
|
||||
|
||||
```
|
||||
# 在 7kecheng 上执行:
|
||||
# 复制以下完整内容到 ~/.ssh/id_ed25519_chengzigedeAir
|
||||
```
|
||||
|
||||
## 公钥(已加入 chengzigedeAir 的 authorized_keys)
|
||||
|
||||
```
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL9bYcGz5IsDKOEpyYKWsJj+CO8qF9fftK/Dtj2ZEZw8 7kecheng@chengzigedeAir
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────
|
||||
# 多机同步脚本 — 拉取最新代码 + 重建依赖
|
||||
# 在 7kecheng / 5kechengdeAir 上各执行一次
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
set -e
|
||||
echo "════════════════════════════════════════"
|
||||
echo " AgentOS 多机同步 $(date '+%Y-%m-%d %H:%M')"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
# ── 1. 拉取最新代码 ──
|
||||
echo ""
|
||||
echo "=== 1. 拉取最新代码 ==="
|
||||
cd ~/workbuddy-agent-os/agent-sync
|
||||
git pull --rebase origin main
|
||||
echo " ✅ 代码已更新 ($(git log --oneline -1))"
|
||||
|
||||
# ── 2. 重建 Dashboard venv ──
|
||||
echo ""
|
||||
echo "=== 2. 重建 Dashboard venv ==="
|
||||
VENV=~/.workbuddy/binaries/python/envs/dashboard
|
||||
rm -rf "$VENV"
|
||||
|
||||
# 使用 managed Python 3.13(如果不可用则用 system Python)
|
||||
PY_BIN=~/.workbuddy/binaries/python/versions/3.13.12/bin/python3
|
||||
if [ ! -f "$PY_BIN" ]; then
|
||||
PY_BIN=/usr/bin/python3
|
||||
fi
|
||||
echo " 使用 Python: $($PY_BIN --version)"
|
||||
|
||||
$PY_BIN -m venv "$VENV"
|
||||
$VENV/bin/pip install fastapi uvicorn pyyaml -q
|
||||
echo " ✅ Dashboard venv 就绪"
|
||||
|
||||
# ── 3. 更新 launchd 配置 ──
|
||||
echo ""
|
||||
echo "=== 3. 更新 launchd 配置 ==="
|
||||
# Dashboard
|
||||
cp ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/com.agentos.dashboard.plist \
|
||||
~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || true
|
||||
|
||||
# SOCKS5 转发器
|
||||
cp ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/com.agentos.socks5-forwarder.plist \
|
||||
~/Library/LaunchAgents/com.agentos.socks5-forwarder.plist 2>/dev/null || true
|
||||
|
||||
echo " ✅ launchd 配置已更新"
|
||||
|
||||
# ── 4. 重启服务 ──
|
||||
echo ""
|
||||
echo "=== 4. 重启服务 ==="
|
||||
# 重启 Dashboard
|
||||
launchctl bootout gui/$(id -u)/com.agentos.dashboard 2>/dev/null || true
|
||||
sleep 1
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || \
|
||||
launchctl load ~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || true
|
||||
|
||||
# guardd 下次心跳会自动更新
|
||||
echo " ✅ 服务已重启"
|
||||
|
||||
# ── 5. 验证 ──
|
||||
echo ""
|
||||
echo "=== 5. 验证 ==="
|
||||
sleep 3
|
||||
curl -s http://localhost:9988/api/machines 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(f' Dashboard: {d[\"total\"]} 台机器在线')
|
||||
for m in d['machines']:
|
||||
print(f' {m[\"hostname\"]:20s} {m[\"status\"]}')
|
||||
" 2>/dev/null || echo " ⚠️ Dashboard 启动中,稍后刷新 http://localhost:9988"
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ 同步完成!"
|
||||
echo "════════════════════════════════════════"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查自动化任务环境配置
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def check_python_env():
|
||||
"""检查 Python 环境"""
|
||||
print("检查 Python 环境:")
|
||||
print("-" * 40)
|
||||
|
||||
# 系统 Python
|
||||
sys_python = "/usr/bin/python3"
|
||||
if Path(sys_python).exists():
|
||||
result = subprocess.run([sys_python, "--version"], capture_output=True, text=True)
|
||||
print(f"系统 Python: {result.stdout.strip() if result.returncode == 0 else '❌ 不可用'}")
|
||||
else:
|
||||
print(f"系统 Python: ❌ 不存在")
|
||||
|
||||
# WorkBuddy 管理的 Python
|
||||
wb_python = "/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
if Path(wb_python).exists():
|
||||
result = subprocess.run([wb_python, "--version"], capture_output=True, text=True)
|
||||
print(f"WorkBuddy Python: {result.stdout.strip() if result.returncode == 0 else '❌ 不可用'}")
|
||||
else:
|
||||
print(f"WorkBuddy Python: ❌ 不存在")
|
||||
|
||||
# 当前脚本使用的 Python
|
||||
print(f"当前 Python: {sys.executable}")
|
||||
result = subprocess.run([sys.executable, "--version"], capture_output=True, text=True)
|
||||
print(f"当前版本: {result.stdout.strip() if result.returncode == 0 else '未知'}")
|
||||
|
||||
print()
|
||||
|
||||
def check_omlx_connection():
|
||||
"""检查 oMLX 连接"""
|
||||
print("检查 oMLX 连接:")
|
||||
print("-" * 40)
|
||||
|
||||
import requests
|
||||
|
||||
# 测试 embedding API(关键依赖)
|
||||
url = "http://localhost:8000/v1/embeddings"
|
||||
headers = {"Authorization": "Bearer omlx", "Content-Type": "application/json"}
|
||||
data = {"model": "Qwen3-Embedding-0.6B", "input": ["测试"]}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ oMLX Embedding API: 正常")
|
||||
else:
|
||||
print(f"❌ oMLX Embedding API: 状态码 {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ oMLX Embedding API: 连接失败 - {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
def check_automation_paths():
|
||||
"""检查自动化脚本路径"""
|
||||
print("检查自动化脚本路径:")
|
||||
print("-" * 40)
|
||||
|
||||
scripts = {
|
||||
"daily_digest.py": "/Users/chengzige/agent-os/02_skills/memory_manager/daily_digest.py",
|
||||
"collect_to_inbox.py": "/Users/chengzige/agent-os/02_skills/collect_to_inbox/collect_to_inbox.py",
|
||||
"inbox_refine.py": "/Users/chengzige/agent-os/02_skills/inbox_refine/inbox_refine.py",
|
||||
"semantic_search.py": "/Users/chengzige/agent-os/02_skills/memory_manager/semantic_search.py"
|
||||
}
|
||||
|
||||
for name, path in scripts.items():
|
||||
if Path(path).exists():
|
||||
print(f"✅ {name}: 存在")
|
||||
else:
|
||||
print(f"❌ {name}: 不存在")
|
||||
|
||||
print()
|
||||
|
||||
def check_python_dependencies():
|
||||
"""检查 Python 依赖"""
|
||||
print("检查 Python 依赖:")
|
||||
print("-" * 40)
|
||||
|
||||
python_path = "/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
if not Path(python_path).exists():
|
||||
print("❌ WorkBuddy Python 环境不存在")
|
||||
return
|
||||
|
||||
dependencies = [
|
||||
"requests",
|
||||
"chromadb",
|
||||
"sqlite-utils",
|
||||
"trafilatura",
|
||||
"feedparser",
|
||||
"schedule"
|
||||
]
|
||||
|
||||
for dep in dependencies:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[python_path, "-c", f"import {dep}; print({dep}.__version__)"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f"✅ {dep}: {result.stdout.strip()}")
|
||||
else:
|
||||
print(f"❌ {dep}: 导入失败")
|
||||
except Exception as e:
|
||||
print(f"❌ {dep}: 检查失败 - {type(e).__name__}")
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("自动化任务环境检查")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
check_python_env()
|
||||
check_omlx_connection()
|
||||
check_automation_paths()
|
||||
check_python_dependencies()
|
||||
|
||||
print("=" * 60)
|
||||
print("总结建议:")
|
||||
print("1. 自动化任务应使用 WorkBuddy Python 环境")
|
||||
print("2. 确保在 automation prompt 中指定完整路径:")
|
||||
print(" /Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3")
|
||||
print("3. oMLX Embedding API 正常,向量化功能可用")
|
||||
print("4. 所有脚本文件都存在,路径正确")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
def check_facts_db():
|
||||
db_path = "04_memory/long_term/facts.db"
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查表结构
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = cursor.fetchall()
|
||||
print("数据库中的表:", tables)
|
||||
|
||||
# 检查facts表结构
|
||||
if ('facts',) in tables:
|
||||
cursor.execute("PRAGMA table_info(facts)")
|
||||
columns = cursor.fetchall()
|
||||
print("\nfacts表结构:")
|
||||
for col in columns:
|
||||
print(f" {col[1]}: {col[2]}")
|
||||
|
||||
# 统计条目数
|
||||
cursor.execute("SELECT COUNT(*) FROM facts")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f"\nL2 事实库条目数: {count}")
|
||||
|
||||
# 查看最新5条
|
||||
cursor.execute("SELECT id, subject, predicate, object, confidence, date_created FROM facts ORDER BY id DESC LIMIT 5")
|
||||
print("\n最新5条事实:")
|
||||
for row in cursor.fetchall():
|
||||
print(f" ID: {row[0]}, 主题: {row[1]}, 谓词: {row[2]}, 置信度: {row[4]}, 日期: {row[5]}")
|
||||
print(f" 内容: {row[3][:100]}...")
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_facts_db()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean up duplicates in registry and live/"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
CROSS = Path("/Users/chengzige/workbuddy-agent-os/agent-sync/04_memory/cross_machine")
|
||||
live_dir = CROSS / "status" / "live"
|
||||
reg_dir = CROSS / "registry"
|
||||
|
||||
# Clean live directory: remove IP-based entries and duplicates
|
||||
for f in list(live_dir.iterdir()):
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
if re.match(r'^\d+\.\d+\.\d+\.\d+', f.name):
|
||||
f.unlink()
|
||||
print(f" removed: {f.name} (IP-based)")
|
||||
|
||||
# We want: 4cf443bc-... (chengzigedeAir), d19759cf-... (Redmi-12C), 5kecheng, 7kechengdeAir
|
||||
want_uids = {"4cf443bc-ff14-4ed9-885b-b04c5326304d", "d19759cf-2159-4fe7-b6ff-db14ccf379f5", "5kecheng", "7kechengdeAir"}
|
||||
for f in list(live_dir.iterdir()):
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
uid = f.name.replace(".json", "")
|
||||
if uid not in want_uids:
|
||||
f.unlink()
|
||||
print(f" removed: {f.name} (duplicate)")
|
||||
|
||||
# Clean registry: remove IP-based entries
|
||||
for f in list(reg_dir.iterdir()):
|
||||
if f.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
hn = d.get("hostname", "")
|
||||
if re.match(r'^\d+\.\d+\.\d+\.\d+$', hn):
|
||||
f.unlink()
|
||||
print(f" removed registry/{f.name} (IP hostname)")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Rebuild _registry.json
|
||||
reg = {}
|
||||
for f in sorted(live_dir.iterdir()):
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
d = json.loads(f.read_text())
|
||||
uid = f.name.replace(".json", "")
|
||||
reg[uid] = {"hostname": d.get("_hostname", uid), "uid": uid,
|
||||
"last_seen": d.get("_received_at", ""), "status": "registered"}
|
||||
|
||||
(live_dir / "_registry.json").write_text(json.dumps(reg, indent=2, ensure_ascii=False))
|
||||
print(f"\n重建后 {len(reg)} 台机器:")
|
||||
for uid in sorted(reg):
|
||||
print(f" {uid[:12]}... → {reg[uid]['hostname']}")
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 多机注册表与冲突检测
|
||||
|
||||
每台机器在 agent-sync/04_memory/cross_machine/registry/ 下有一个注册文件,
|
||||
Git 同步后所有机器可见。
|
||||
|
||||
核心功能:
|
||||
1. agentos register → 更新/创建本机注册(角色、状态、时间戳)
|
||||
2. agentos cluster-status → 查看所有在线机器的角色和状态
|
||||
3. pre_task_check() → 执行任务前检测冲突(入口供 role_check.py 调用)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# 路径
|
||||
SYNC_ROOT = Path.home() / "workbuddy-agent-os" / "agent-sync"
|
||||
LOCAL_ROOT = Path.home() / "workbuddy-agent-os" / "agent-local"
|
||||
REGISTRY_DIR = SYNC_ROOT / "04_memory" / "cross_machine" / "registry"
|
||||
LOCAL_HOST_ID = LOCAL_ROOT / "identity" / "HOST_ID.md"
|
||||
HOSTNAME = os.uname().nodename if hasattr(os, 'uname') else os.environ.get('HOSTNAME', 'unknown')
|
||||
try:
|
||||
HOSTNAME = os.uname().nodename
|
||||
except AttributeError:
|
||||
HOSTNAME = os.environ.get('COMPUTERNAME', 'unknown').lower()
|
||||
|
||||
|
||||
def get_host_display_name() -> str:
|
||||
"""获取可读的主机显示名(优先使用 HOST_ID.md 中的设置)"""
|
||||
# 优先从 HOST_ID.md 读取(用户可自定义)
|
||||
if LOCAL_HOST_ID.exists():
|
||||
try:
|
||||
content = LOCAL_HOST_ID.read_text(encoding="utf-8")
|
||||
for line in content.split("\n"):
|
||||
if "主机名:" in line:
|
||||
name = line.split("主机名:")[-1].strip()
|
||||
if name and name != "__HOSTNAME__":
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# fallback: 从系统获取
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["scutil", "--get", "LocalHostName"],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return HOSTNAME
|
||||
|
||||
|
||||
def get_my_role() -> str:
|
||||
"""从本机 HOST_ID.md 读取角色"""
|
||||
if not LOCAL_HOST_ID.exists():
|
||||
return "unknown"
|
||||
try:
|
||||
content = LOCAL_HOST_ID.read_text(encoding="utf-8")
|
||||
for line in content.split("\n"):
|
||||
if "角色:" in line:
|
||||
role = line.split("角色:")[-1].strip()
|
||||
if role:
|
||||
return role
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_my_status() -> str:
|
||||
"""检测本机状态:online / idle / error"""
|
||||
return "online"
|
||||
|
||||
|
||||
def my_entry() -> dict:
|
||||
"""生成本机注册条目"""
|
||||
return {
|
||||
"hostname": get_host_display_name(),
|
||||
"system_hostname": HOSTNAME,
|
||||
"role": get_my_role(),
|
||||
"status": get_my_status(),
|
||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def read_all_entries() -> list:
|
||||
"""扫描 registry/ 目录,读取所有机器的注册"""
|
||||
if not REGISTRY_DIR.exists():
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for f in sorted(REGISTRY_DIR.glob("*.json")):
|
||||
try:
|
||||
entries.append(json.loads(f.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def write_my_entry():
|
||||
"""将本机注册写入 registry/"""
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
entry = my_entry()
|
||||
filepath = REGISTRY_DIR / f"{get_host_display_name()}.json"
|
||||
filepath.write_text(
|
||||
json.dumps(entry, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def detect_conflicts(entries: list = None) -> list:
|
||||
"""
|
||||
检测集群冲突。
|
||||
返回冲突列表,每条包含 type, severity, description。
|
||||
"""
|
||||
if entries is None:
|
||||
entries = read_all_entries()
|
||||
|
||||
conflicts = []
|
||||
my_host = get_host_display_name()
|
||||
|
||||
# 检查是否注册了自己
|
||||
my_entry_found = any(e.get("hostname") == my_host for e in entries)
|
||||
if not my_entry_found:
|
||||
conflicts.append({
|
||||
"type": "unregistered",
|
||||
"severity": "warn",
|
||||
"description": f"本机 {my_host} 未在集群中注册。运行: agentos register"
|
||||
})
|
||||
|
||||
# 检查双重 master
|
||||
masters = [e for e in entries if e.get("role") == "master"]
|
||||
active_masters = [
|
||||
m for m in masters
|
||||
if not is_stale(m.get("last_seen", ""))
|
||||
]
|
||||
if len(active_masters) > 1:
|
||||
names = [m["hostname"] for m in active_masters]
|
||||
conflicts.append({
|
||||
"type": "dual_master",
|
||||
"severity": "error",
|
||||
"description": f"检测到 {len(active_masters)} 台机器都设为 master: {', '.join(names)}。"
|
||||
f"同一时间只能有一台 master。请修改其中一台的 HOST_ID.md 角色。"
|
||||
})
|
||||
elif len(active_masters) == 0 and len(masters) > 0:
|
||||
# 所有 master 都离线了
|
||||
conflicts.append({
|
||||
"type": "no_active_master",
|
||||
"severity": "warn",
|
||||
"description": f"所有 master 都已离线(最后上线时间超过 24h)。"
|
||||
f"如果你确信没有其他在线 master,可将本机提升为 master。"
|
||||
})
|
||||
|
||||
# 检查 stale 条目
|
||||
for e in entries:
|
||||
if is_stale(e.get("last_seen", "")):
|
||||
conflicts.append({
|
||||
"type": "stale_entry",
|
||||
"severity": "info",
|
||||
"description": f"机器 {e['hostname']} 已超过 24h 未上线(角色: {e['role']})。"
|
||||
f"registry 文件: {REGISTRY_DIR / e['hostname']}.json"
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def is_stale(last_seen_str: str) -> bool:
|
||||
"""判断注册条目是否已过期(超过 24h)"""
|
||||
if not last_seen_str:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(last_seen_str)
|
||||
age = datetime.now(timezone.utc) - last
|
||||
return age > timedelta(hours=24)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def pre_task_check(allowed_roles: list = None) -> bool:
|
||||
"""
|
||||
任务执行前调用。
|
||||
检查角色 + 集群冲突,有冲突则打印警告并返回 False。
|
||||
返回 True = 可以执行,False = 应跳过。
|
||||
"""
|
||||
from role_check import read_host_id, check_role
|
||||
|
||||
# 1. 基础角色检查
|
||||
local_info = read_host_id()
|
||||
my_role = local_info.get("role", "unknown")
|
||||
|
||||
if allowed_roles and my_role not in allowed_roles:
|
||||
print(f"[cluster] ⛔ 本机角色={my_role},需要角色={allowed_roles},跳过")
|
||||
return False
|
||||
|
||||
# 2. 如果我是 master,检查是否有其他 master
|
||||
entries = read_all_entries()
|
||||
conflicts = detect_conflicts(entries)
|
||||
|
||||
error_conflicts = [c for c in conflicts if c["severity"] == "error"]
|
||||
warn_conflicts = [c for c in conflicts if c["severity"] == "warn"]
|
||||
|
||||
if error_conflicts:
|
||||
for c in error_conflicts:
|
||||
print(f"[cluster] ❌ {c['description']}")
|
||||
print("[cluster] ⛔ 存在严重冲突,任务已中止。请手动解决冲突后重试。")
|
||||
return False
|
||||
|
||||
if warn_conflicts:
|
||||
for c in warn_conflicts:
|
||||
print(f"[cluster] ⚠️ {c['description']}")
|
||||
# 警告不阻止执行
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def cmd_register():
|
||||
"""agentos register:注册本机到集群"""
|
||||
entry = write_my_entry()
|
||||
my_host = get_host_display_name()
|
||||
print(f"✅ 已注册: {my_host}")
|
||||
print(f" 角色: {entry['role']}")
|
||||
print(f" 时间: {entry['last_seen']}")
|
||||
|
||||
# 注册后自动检测冲突
|
||||
conflicts = detect_conflicts()
|
||||
if conflicts:
|
||||
print()
|
||||
print("⚠️ 检测到以下问题:")
|
||||
for c in conflicts:
|
||||
icon = {"error": "❌", "warn": "⚠️", "info": "ℹ️"}.get(c["severity"], "?")
|
||||
print(f" {icon} [{c['type']}] {c['description']}")
|
||||
|
||||
print()
|
||||
print("💡 建议: git add -A && git commit -m \"[registry] update\" && git push")
|
||||
|
||||
|
||||
def cmd_status():
|
||||
"""agentos cluster-status:查看集群状态"""
|
||||
entries = read_all_entries()
|
||||
|
||||
# 先注册自己
|
||||
my_entry = write_my_entry()
|
||||
my_host = get_host_display_name()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" 🌐 AgentOS 集群状态")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
if not entries:
|
||||
print(" (无其他注册机器)")
|
||||
else:
|
||||
print(f" {'主机名':24s} {'角色':12s} {'状态':8s} {'上次上线':25s}")
|
||||
print(f" {'-'*24} {'-'*12} {'-'*8} {'-'*25}")
|
||||
for e in entries:
|
||||
host = e.get("hostname", "?")
|
||||
role = e.get("role", "?")
|
||||
status = e.get("status", "?")
|
||||
last = e.get("last_seen", "?")
|
||||
is_me = "← 本机" if host == my_host else ""
|
||||
stale = " [离线]" if is_stale(last) else ""
|
||||
print(f" {host:24s} {role:12s} {status:8s} {last[:19]:19s}{stale} {is_me}")
|
||||
|
||||
print()
|
||||
|
||||
# 冲突检测
|
||||
print("=" * 60)
|
||||
print(" 冲突检测结果")
|
||||
print("=" * 60)
|
||||
print()
|
||||
conflicts = detect_conflicts(entries)
|
||||
if not conflicts:
|
||||
print(" ✅ 无冲突")
|
||||
else:
|
||||
for c in conflicts:
|
||||
icon = {"error": "❌", "warn": "⚠️", "info": "ℹ️"}.get(c["severity"], "?")
|
||||
print(f" {icon} [{c['type']}] {c['description']}")
|
||||
|
||||
print()
|
||||
print(f" 本机: {my_host} (角色: {my_entry['role']})")
|
||||
if my_entry['role'] == 'node':
|
||||
print(" 📌 当前为最低权限角色。如需提升,编辑 HOST_ID.md")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_cleanup():
|
||||
"""清理过期的注册条目(超过 7 天未上线)"""
|
||||
entries = read_all_entries()
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for e in entries:
|
||||
last_str = e.get("last_seen", "")
|
||||
if last_str:
|
||||
try:
|
||||
last = datetime.fromisoformat(last_str)
|
||||
if now - last > timedelta(days=7):
|
||||
fp = REGISTRY_DIR / f"{e['hostname']}.json"
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
print(f" 🗑️ 移除离线机器: {e['hostname']}(最后上线: {last_str[:10]})")
|
||||
removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
if removed == 0:
|
||||
print(" 无过期条目需清理")
|
||||
print()
|
||||
print("💡 建议: git add -A && git commit -m \"[registry] cleanup\" && git push")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "register":
|
||||
cmd_register()
|
||||
elif cmd == "status":
|
||||
cmd_status()
|
||||
elif cmd == "cleanup":
|
||||
cmd_cleanup()
|
||||
else:
|
||||
print(f"未知命令: {cmd}")
|
||||
print("可用: register, status, cleanup")
|
||||
else:
|
||||
cmd_status()
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix the 7kecheng live entry"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
d = {
|
||||
"_uid": "7kecheng",
|
||||
"_hostname": "7kechengdeAir",
|
||||
"_received_at": "2026-05-03T10:35:38+00:00",
|
||||
"os": "macOS",
|
||||
"cpu": {"load_1m": 0},
|
||||
"disk": {"total_gb": 0, "used_gb": 0},
|
||||
}
|
||||
live_dir = Path("/Users/chengzige/workbuddy-agent-os/agent-sync/04_memory/cross_machine/status/live")
|
||||
(live_dir / "7kechengdeAir.json").write_text(json.dumps(d, indent=2))
|
||||
print("done")
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 路径批量修复脚本 v1.0
|
||||
将所有 ~/workbuddy-agent-os/agent-sync / ~/workbuddy-agent-os/agent-local 旧路径引用替换为新的标准路径
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SYNC_ROOT = Path.home() / "workbuddy-agent-os" / "agent-sync"
|
||||
|
||||
# 替换规则(按优先级排序,长匹配优先)
|
||||
REPLACEMENTS = [
|
||||
# 1. 先处理长路径(避免部分替换)
|
||||
("~/workbuddy-agent-os/agent-sync/", "~/workbuddy-agent-os/agent-sync/"),
|
||||
("~/workbuddy-agent-os/agent-local/", "~/workbuddy-agent-os/agent-local/"),
|
||||
("agent-sync/03_knowledge/", "agent-sync/03_knowledge/"),
|
||||
# 2. 处理没有尾部斜杠的情况
|
||||
("~/workbuddy-agent-os/agent-local", "~/workbuddy-agent-os/agent-local"),
|
||||
("~/workbuddy-agent-os/agent-sync", "~/workbuddy-agent-os/agent-sync"),
|
||||
]
|
||||
|
||||
# 需要排除的目录模式
|
||||
EXCLUDE_PATTERNS = [
|
||||
".git/", "node_modules/", "__pycache__/", ".DS_Store",
|
||||
"agent-os/vector_db/", "agent-os/06_runtime/",
|
||||
]
|
||||
|
||||
EXCLUDE_FILES = [".gitignore"] # 跳过这些文件(手动审查)
|
||||
|
||||
def should_skip(filepath: str) -> bool:
|
||||
"""判断是否应该跳过该文件"""
|
||||
fp = filepath.replace(str(SYNC_ROOT), "")
|
||||
for pat in EXCLUDE_PATTERNS:
|
||||
if pat in fp:
|
||||
return True
|
||||
return False
|
||||
|
||||
def fix_file(filepath: str) -> tuple:
|
||||
"""修复单个文件,返回 (修改次数, 原始内容, 新内容)"""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 无法读取: {filepath} ({e})")
|
||||
return (0, "", "")
|
||||
|
||||
original = content
|
||||
total_fixes = 0
|
||||
|
||||
for old, new in REPLACEMENTS:
|
||||
if old in content:
|
||||
count = content.count(old)
|
||||
content = content.replace(old, new)
|
||||
total_fixes += count
|
||||
|
||||
if total_fixes > 0:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return (total_fixes, original, content)
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("AgentOS 路径批量修复工具")
|
||||
print(f"目标目录: {SYNC_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
# 收集所有文本文件
|
||||
extensions = {".md", ".py", ".sh", ".json", ".yaml", ".yml", ".txt", ".cfg", ".ini", ".toml", ".conf"}
|
||||
all_fixes = {}
|
||||
total_fixes = 0
|
||||
total_files = 0
|
||||
|
||||
for root, dirs, files in os.walk(str(SYNC_ROOT)):
|
||||
# 跳过排除目录
|
||||
dirs[:] = [d for d in dirs if not should_skip(os.path.join(root, d))]
|
||||
|
||||
for fname in files:
|
||||
fpath = os.path.join(root, fname)
|
||||
ext = os.path.splitext(fname)[1]
|
||||
|
||||
if ext not in extensions:
|
||||
continue
|
||||
if should_skip(fpath):
|
||||
continue
|
||||
if os.path.basename(fpath) in EXCLUDE_FILES:
|
||||
continue
|
||||
|
||||
count, orig, new = fix_file(fpath)
|
||||
if count > 0:
|
||||
all_fixes[fpath] = count
|
||||
total_fixes += count
|
||||
total_files += 1
|
||||
print(f" [{count}处] {fpath.replace(str(Path.home()), '~')}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✅ 批量修复完成")
|
||||
print(f" 涉及文件: {total_files}")
|
||||
print(f" 修复处数: {total_fixes}")
|
||||
print("=" * 60)
|
||||
|
||||
# 按修改次数排序显示
|
||||
if all_fixes:
|
||||
print("\n按修复数量排名:")
|
||||
sorted_fixes = sorted(all_fixes.items(), key=lambda x: -x[1])
|
||||
for fpath, count in sorted_fixes[:10]:
|
||||
print(f" {count:3d}处 {fpath.replace(str(Path.home()), '~')}")
|
||||
|
||||
return total_fixes > 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""修复注册表: 从插件数据读取真实 hostname, 清理错误条目"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CROSS = Path("/Users/chengzige/workbuddy-agent-os/agent-sync/04_memory/cross_machine")
|
||||
live_dir = CROSS / "status" / "live"
|
||||
|
||||
# 3 台正确机器
|
||||
machines = {
|
||||
"4cf443bc-ff14-4ed9-885b-b04c5326304d": {
|
||||
"_hostname": "chengzigedeAir",
|
||||
"_received_at": "2026-05-19T05:16:39+00:00",
|
||||
"os": "macOS 26.4",
|
||||
"cpu": {"load_1m": 0},
|
||||
"disk": {"total_gb": 245, "used_gb": 225},
|
||||
"guardd_version": "2.0.0",
|
||||
},
|
||||
"d19759cf-2159-4fe7-b6ff-db14ccf379f5": {
|
||||
"_hostname": "7kecheng",
|
||||
"_received_at": "2026-05-19T05:01:36+00:00",
|
||||
"os": "macOS",
|
||||
"cpu": {"load_1m": 0},
|
||||
"disk": {"total_gb": 228, "used_gb": 12},
|
||||
"guardd_version": "1.0.0",
|
||||
},
|
||||
"f13b03d1-731f-453f-accb-8b8edbd6413a": {
|
||||
"_hostname": "5kechengdeAir",
|
||||
"_received_at": "2026-05-19T05:57:58+00:00",
|
||||
"os": "macOS",
|
||||
"cpu": {"load_1m": 0},
|
||||
"disk": {"total_gb": 0, "used_gb": 0},
|
||||
"guardd_version": "1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
# 清理 live 目录
|
||||
for f in list(live_dir.iterdir()):
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
uid = f.name.replace(".json", "")
|
||||
if uid not in machines:
|
||||
f.unlink()
|
||||
print(f" 删除错误条目: {f.name}")
|
||||
|
||||
# 写入正确数据
|
||||
for uid, data in machines.items():
|
||||
path = live_dir / f"{uid}.json"
|
||||
path.write_text(json.dumps({"_uid": uid, **data}, indent=2, ensure_ascii=False))
|
||||
print(f" 写入: {data['_hostname']} ({uid[:12]}...)")
|
||||
|
||||
# 重建注册表
|
||||
reg = {}
|
||||
for uid, data in machines.items():
|
||||
reg[uid] = {"hostname": data["_hostname"], "uid": uid, "status": "registered"}
|
||||
(live_dir / "_registry.json").write_text(json.dumps(reg, indent=2, ensure_ascii=False))
|
||||
|
||||
print(f"\n完成! 共 {len(machines)} 台机器:")
|
||||
for uid, data in machines.items():
|
||||
print(f" {data['_hostname']} ({uid[:12]}...)")
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# fix_ssh_auth.sh — 修复 SSH 公钥认证权限
|
||||
# 在远程机器上执行一次,解决 Permission denied (publickey)
|
||||
# 用法: bash fix_ssh_auth.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 1. 修复目录/文件权限 ==="
|
||||
|
||||
# 家目录不能 group/others 可写
|
||||
chmod go-w "$HOME"
|
||||
echo " ~ ($HOME) → $(stat -f '%A' "$HOME")"
|
||||
|
||||
# .ssh 目录
|
||||
mkdir -p "$HOME/.ssh"
|
||||
chmod 700 "$HOME/.ssh"
|
||||
echo " ~/.ssh → $(stat -f '%A' "$HOME/.ssh")"
|
||||
|
||||
# authorized_keys
|
||||
if [ -f "$HOME/.ssh/authorized_keys" ]; then
|
||||
chmod 600 "$HOME/.ssh/authorized_keys"
|
||||
echo " ~/.ssh/authorized_keys → $(stat -f '%A' "$HOME/.ssh/authorized_keys")"
|
||||
else
|
||||
echo " ⚠️ ~/.ssh/authorized_keys 不存在,请先添加公钥"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 2. 重启 SSH 服务 ==="
|
||||
sudo launchctl unload /System/Library/LaunchDaemons/ssh.plist 2>/dev/null || true
|
||||
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist 2>/dev/null || true
|
||||
echo " SSH 服务已重启"
|
||||
|
||||
echo ""
|
||||
echo "=== 3. 验证 ==="
|
||||
echo " 远程登录: Remote Login $(sudo systemsetup -getremotelogin 2>/dev/null | grep -o 'On\|Off')"
|
||||
echo " 端口 22: $(nc -z -v -G 2 localhost 22 2>&1 && echo '✓ 监听中' || echo '✗ 未监听')"
|
||||
|
||||
echo ""
|
||||
echo "=== 完成 ==="
|
||||
echo "从本机测试: ssh -i ~/.ssh/id_ed25519a [email protected]"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重建 status/live/ 注册表, 从 registry/ + data/ 补齐所有机器"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CROSS = Path("/Users/chengzige/workbuddy-agent-os/agent-sync/04_memory/cross_machine")
|
||||
live_dir = CROSS / "status" / "live"
|
||||
live_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Redmi-12C from cross_machine/data/ (UID: d19759cf-...)
|
||||
rmc = {
|
||||
"_uid": "d19759cf-2159-4fe7-b6ff-db14ccf379f5",
|
||||
"_hostname": "Redmi-12C",
|
||||
"_received_at": "2026-05-19T01:45:07+00:00",
|
||||
"os": "macOS 26.4",
|
||||
"cpu": {"load_1m": 0},
|
||||
"disk": {"total_gb": 228, "used_gb": 12, "available_gb": 161},
|
||||
"guardd_version": "1.0.0",
|
||||
}
|
||||
(live_dir / "d19759cf-2159-4fe7-b6ff-db14ccf379f5.json").write_text(json.dumps(rmc, indent=2, ensure_ascii=False))
|
||||
|
||||
# 5kecheng from registry/5kecheng.json
|
||||
(live_dir / "5kecheng.json").write_text(json.dumps({
|
||||
"_uid": "5kecheng", "_hostname": "5kecheng",
|
||||
"_received_at": "2026-05-16T08:34:43+00:00", "os": "macOS",
|
||||
"cpu": {"load_1m": 0}, "disk": {"total_gb": 0, "used_gb": 0},
|
||||
}, indent=2, ensure_ascii=False))
|
||||
|
||||
# 7kecheng from registry/7kecheng.json
|
||||
(live_dir / "7kecheng.json").write_text(json.dumps({
|
||||
"_uid": "7kecheng", "_hostname": "7kechengdeAir",
|
||||
"_received_at": "2026-05-03T10:35:38+00:00", "os": "macOS",
|
||||
"cpu": {"load_1m": 0}, "disk": {"total_gb": 0, "used_gb": 0},
|
||||
}, indent=2, ensure_ascii=False))
|
||||
|
||||
# Rebuild _registry.json
|
||||
reg = {}
|
||||
for f in sorted(live_dir.iterdir()):
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
d = json.loads(f.read_text())
|
||||
uid = f.name.replace(".json", "")
|
||||
reg[uid] = {"hostname": d.get("_hostname", uid), "uid": uid,
|
||||
"last_seen": d.get("_received_at", ""), "status": "registered"}
|
||||
|
||||
(live_dir / "_registry.json").write_text(json.dumps(reg, indent=2, ensure_ascii=False))
|
||||
print(f"重建完成: {len(reg)} 台机器")
|
||||
for uid in sorted(reg):
|
||||
print(f" {uid[:12]}... → {reg[uid]['hostname']}")
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 角色检查工具
|
||||
所有自动化脚本在执行前调用此工具检查本机角色是否符合要求。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
HOST_ID_PATH = Path.home() / "workbuddy-agent-os" / "agent-local" / "identity" / "HOST_ID.md"
|
||||
LOCAL_IDENTITY_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "identity"
|
||||
|
||||
|
||||
def read_host_id() -> dict:
|
||||
"""读取本机 HOST_ID,返回角色和能力字典"""
|
||||
if not HOST_ID_PATH.exists():
|
||||
print(f"[role_check] ⚠️ HOST_ID.md 不存在: {HOST_ID_PATH}")
|
||||
print("[role_check] 请运行 agentos init --localize 生成")
|
||||
return {"role": "unknown", "capabilities": {}}
|
||||
|
||||
try:
|
||||
with open(HOST_ID_PATH, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 从 yaml code block 中提取能力配置
|
||||
yaml_block = None
|
||||
in_block = False
|
||||
lines = []
|
||||
for line in content.split("\n"):
|
||||
if line.strip().startswith("```yaml"):
|
||||
in_block = True
|
||||
continue
|
||||
elif line.strip().startswith("```"):
|
||||
if in_block:
|
||||
yaml_block = "\n".join(lines)
|
||||
break
|
||||
in_block = False
|
||||
continue
|
||||
if in_block:
|
||||
lines.append(line)
|
||||
|
||||
# 提取角色
|
||||
role = "unknown"
|
||||
for line in content.split("\n"):
|
||||
if "角色:" in line and ":" in line:
|
||||
role_part = line.split("角色:")[-1].strip()
|
||||
if role_part:
|
||||
role = role_part
|
||||
|
||||
# 解析能力
|
||||
capabilities = {}
|
||||
if yaml_block:
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_block)
|
||||
if isinstance(parsed, dict):
|
||||
capabilities = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[role_check] ❌ 读取 HOST_ID.md 失败: {e}")
|
||||
return {"role": "unknown", "capabilities": {}}
|
||||
|
||||
|
||||
def check_role(allowed_roles: list) -> bool:
|
||||
"""
|
||||
检查本机角色是否在允许列表中。
|
||||
同时运行集群冲突检测(如果任务涉及 master 权限)。
|
||||
如果角色不匹配,打印提示并返回 False。
|
||||
"""
|
||||
info = read_host_id()
|
||||
role = info["role"]
|
||||
|
||||
if role in allowed_roles:
|
||||
# 角色通过后,额外检测集群冲突(仅限 master 级别任务)
|
||||
if "master" in allowed_roles:
|
||||
try:
|
||||
from cluster_registry import pre_task_check
|
||||
return pre_task_check(allowed_roles)
|
||||
except ImportError:
|
||||
pass
|
||||
return True
|
||||
|
||||
print(f"[role_check] ⛔ 本机角色={role},需要角色={allowed_roles},跳过执行")
|
||||
print(f"[role_check] 如需修改角色,编辑 {HOST_ID_PATH}")
|
||||
return False
|
||||
|
||||
|
||||
def check_capability(capability: str) -> bool:
|
||||
"""
|
||||
检查本机是否启用指定能力。
|
||||
"""
|
||||
info = read_host_id()
|
||||
caps = info.get("capabilities", {})
|
||||
|
||||
if isinstance(caps.get(capability), bool):
|
||||
return caps[capability]
|
||||
|
||||
# 也兼容字符串 True/False
|
||||
if isinstance(caps.get(capability), str):
|
||||
return caps[capability].lower() in ("true", "yes", "1")
|
||||
|
||||
print(f"[role_check] ⛔ 能力 {capability} 未定义或已禁用,跳过执行")
|
||||
return False
|
||||
|
||||
|
||||
def print_status():
|
||||
"""打印本机角色状态"""
|
||||
info = read_host_id()
|
||||
print(f"[role_check] 本机角色: {info['role']}")
|
||||
print(f"[role_check] 能力配置:")
|
||||
for k, v in info.get("capabilities", {}).items():
|
||||
status = "✅" if v else "❌"
|
||||
print(f" {status} {k}: {v}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "status":
|
||||
print_status()
|
||||
elif cmd == "check-role":
|
||||
allowed = sys.argv[2:] if len(sys.argv) > 2 else ["master"]
|
||||
if check_role(allowed):
|
||||
print(f"[role_check] ✅ 角色确认通过")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
elif cmd == "check-capability":
|
||||
cap = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
if check_capability(cap):
|
||||
print(f"[role_check] ✅ 能力确认通过: {cap}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print_status()
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
skill_scanner.py — 技能状态扫描器
|
||||
|
||||
扫描所有 SKILL_CARD.yaml,报告每个技能的状态:
|
||||
- 是否有身份证(SKILL_CARD.yaml)
|
||||
- 入口脚本是否存在
|
||||
- 核心依赖是否已安装
|
||||
- 主机角色是否匹配(只在 master_editor 机器上写入)
|
||||
|
||||
使用:
|
||||
python3 skill_scanner.py
|
||||
python3 skill_scanner.py --root ~/workbuddy-agent-os/agent-sync
|
||||
python3 skill_scanner.py --check-deps # 同时检查 pip 依赖
|
||||
python3 skill_scanner.py --json # JSON格式输出
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 技能状态扫描器")
|
||||
parser.add_argument("--root", default="~/workbuddy-agent-os/agent-sync", help="agent-os 根目录")
|
||||
parser.add_argument("--local", default="~/workbuddy-agent-os/agent-local", help="~/workbuddy-agent-os/agent-local")
|
||||
parser.add_argument("--check-deps", action="store_true", help="检查 pip 依赖是否已安装")
|
||||
parser.add_argument("--json", action="store_true", dest="json_output", help="JSON 格式输出")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_yaml_simple(file_path: Path) -> dict:
|
||||
"""简单 YAML 解析(不依赖 pyyaml)"""
|
||||
data = {}
|
||||
current_key = None
|
||||
current_list = None
|
||||
current_section = data
|
||||
|
||||
try:
|
||||
lines = file_path.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# 列表项
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(stripped[2:].strip().strip('"'))
|
||||
continue
|
||||
|
||||
if ":" in stripped:
|
||||
key, _, val = stripped.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
|
||||
if val:
|
||||
if indent == 0:
|
||||
data[key] = val
|
||||
current_section = data
|
||||
current_list = None
|
||||
else:
|
||||
current_section[key] = val
|
||||
current_list = None
|
||||
else:
|
||||
# 嵌套块或列表开始
|
||||
if indent == 0:
|
||||
data[key] = {}
|
||||
current_section = data[key]
|
||||
current_list = None
|
||||
elif key:
|
||||
parent = current_section
|
||||
parent[key] = []
|
||||
current_list = parent[key]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_host_id(local_root: Path) -> str:
|
||||
"""读取本机 HOST_ID"""
|
||||
# 从 machine_info.json 读取
|
||||
machine_info = local_root / "machine_info.json"
|
||||
if machine_info.exists():
|
||||
try:
|
||||
info = json.loads(machine_info.read_text(encoding="utf-8"))
|
||||
return info.get("machine", {}).get("id", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
# 从 HOST_ID.md 读取
|
||||
host_id_md = local_root.parent / "agent-os" / "01_core" / "HOST_ID.md"
|
||||
# 已知路径
|
||||
for path in [Path("~/workbuddy-agent-os/agent-sync/01_core/HOST_ID.md").expanduser()]:
|
||||
if path.exists():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
import re
|
||||
m = re.search(r'`([^`]+_(?:master|maintainer|node))`', content)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def check_pip_installed(packages: list) -> dict:
|
||||
"""检查 pip 包是否已安装"""
|
||||
results = {}
|
||||
for pkg in packages:
|
||||
pkg_name = pkg.split(">=")[0].split("==")[0].split("[")[0].strip()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", f"import {pkg_name.replace('-', '_')}"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
results[pkg] = result.returncode == 0
|
||||
except Exception:
|
||||
results[pkg] = False
|
||||
return results
|
||||
|
||||
|
||||
def scan_skill(skill_dir: Path, check_deps: bool, host_id: str) -> dict:
|
||||
"""扫描单个技能目录"""
|
||||
name = skill_dir.name
|
||||
result = {
|
||||
"name": name,
|
||||
"path": str(skill_dir),
|
||||
"has_skill_md": (skill_dir / "SKILL.md").exists(),
|
||||
"has_skill_card": (skill_dir / "SKILL_CARD.yaml").exists(),
|
||||
"has_entry": False,
|
||||
"entry_point": None,
|
||||
"status": "unknown",
|
||||
"version": "?",
|
||||
"category": "?",
|
||||
"master_editor": "?",
|
||||
"is_master": False,
|
||||
"deps_ok": None,
|
||||
"missing_deps": [],
|
||||
"issues": []
|
||||
}
|
||||
|
||||
# 读取 SKILL_CARD.yaml
|
||||
card_path = skill_dir / "SKILL_CARD.yaml"
|
||||
if card_path.exists():
|
||||
card = load_yaml_simple(card_path)
|
||||
skill_info = card.get("skill", {})
|
||||
result["version"] = skill_info.get("version", "?")
|
||||
result["status"] = skill_info.get("status", "?")
|
||||
result["category"] = skill_info.get("category", "?")
|
||||
|
||||
# 读取 ownership
|
||||
ownership = card.get("ownership", {})
|
||||
result["master_editor"] = ownership.get("master_editor", "?")
|
||||
result["is_master"] = host_id != "unknown" and host_id == result["master_editor"]
|
||||
|
||||
# 读取入口脚本
|
||||
usage = card.get("usage", {})
|
||||
entry = usage.get("entry_point", "")
|
||||
if entry:
|
||||
result["entry_point"] = entry
|
||||
result["has_entry"] = (skill_dir / entry).exists()
|
||||
if not result["has_entry"]:
|
||||
result["issues"].append(f"入口脚本不存在: {entry}")
|
||||
|
||||
# 检查依赖
|
||||
if check_deps:
|
||||
env_info = card.get("environment", {})
|
||||
pip_deps = env_info.get("pip_dependencies", [])
|
||||
if isinstance(pip_deps, list) and pip_deps:
|
||||
dep_results = check_pip_installed(pip_deps)
|
||||
missing = [pkg for pkg, ok in dep_results.items() if not ok]
|
||||
result["deps_ok"] = len(missing) == 0
|
||||
result["missing_deps"] = missing
|
||||
if missing:
|
||||
result["issues"].append(f"缺失依赖: {', '.join(missing)}")
|
||||
else:
|
||||
result["issues"].append("缺少 SKILL_CARD.yaml")
|
||||
|
||||
# 没有 SKILL.md 也标记
|
||||
if not result["has_skill_md"]:
|
||||
result["issues"].append("缺少 SKILL.md")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
STATUS_ICON = {
|
||||
"active": "✅",
|
||||
"inactive": "⏸ ",
|
||||
"experimental": "🧪",
|
||||
"unknown": "❓",
|
||||
"?": "❓",
|
||||
}
|
||||
|
||||
|
||||
def print_report(skills: list, host_id: str):
|
||||
"""打印扫描报告"""
|
||||
print("=" * 65)
|
||||
print(f"AgentOS 技能状态报告 (本机: {host_id})")
|
||||
print("=" * 65)
|
||||
|
||||
has_card = [s for s in skills if s["has_skill_card"]]
|
||||
no_card = [s for s in skills if not s["has_skill_card"]]
|
||||
|
||||
if has_card:
|
||||
print("\n📋 已注册技能(有 SKILL_CARD.yaml):\n")
|
||||
for s in has_card:
|
||||
icon = STATUS_ICON.get(s["status"], "❓")
|
||||
master_tag = " 🔑[主机]" if s["is_master"] else ""
|
||||
issues_tag = f" ⚠️ {'; '.join(s['issues'])}" if s["issues"] else ""
|
||||
entry_tag = f" → {s['entry_point']}" if s["entry_point"] else ""
|
||||
print(f" {icon} {s['name']:<22} v{s['version']:<8} [{s['category']}]{master_tag}{entry_tag}{issues_tag}")
|
||||
|
||||
if no_card:
|
||||
print("\n📂 未注册技能(无 SKILL_CARD.yaml):\n")
|
||||
for s in no_card:
|
||||
print(f" ❓ {s['name']}")
|
||||
|
||||
print(f"\n统计: 共 {len(skills)} 个技能,"
|
||||
f"已注册 {len(has_card)},"
|
||||
f"未注册 {len(no_card)},"
|
||||
f"本机主机 {sum(1 for s in skills if s['is_master'])} 个")
|
||||
|
||||
issues_all = [s for s in skills if s["issues"]]
|
||||
if issues_all:
|
||||
print(f"\n⚠️ 需要处理的问题:\n")
|
||||
for s in issues_all:
|
||||
for issue in s["issues"]:
|
||||
print(f" [{s['name']}] {issue}")
|
||||
|
||||
print("\n图例: ✅=活跃 ⏸=暂停 🧪=实验性 🔑=本机主编辑")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = Path(args.root).expanduser()
|
||||
local_root = Path(args.local).expanduser()
|
||||
skills_dir = root / "02_skills"
|
||||
|
||||
if not skills_dir.exists():
|
||||
print(f"[ERROR] 技能目录不存在: {skills_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
host_id = get_host_id(local_root)
|
||||
skills = []
|
||||
|
||||
for skill_dir in sorted(skills_dir.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
if skill_dir.name.startswith(("_", ".")):
|
||||
continue
|
||||
skill = scan_skill(skill_dir, args.check_deps, host_id)
|
||||
skills.append(skill)
|
||||
|
||||
if args.json_output:
|
||||
print(json.dumps(skills, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_report(skills, host_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试 oMLX embedding API 是否在自动化环境中正常工作
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
def test_embedding_api():
|
||||
"""测试 embedding API"""
|
||||
url = "http://localhost:8000/v1/embeddings"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer omlx"
|
||||
}
|
||||
data = {
|
||||
"model": "Qwen3-Embedding-0.6B",
|
||||
"input": ["测试文本:这是用于验证 oMLX embedding API 是否正常工作的测试文本。"]
|
||||
}
|
||||
|
||||
try:
|
||||
print("测试 oMLX embedding API...")
|
||||
print(f"URL: {url}")
|
||||
print(f"Headers: {headers}")
|
||||
print(f"Data: {json.dumps(data, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(url, headers=headers, json=data, timeout=30)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print("✅ API 调用成功!")
|
||||
print(f"返回数据: {json.dumps(result, ensure_ascii=False)[:200]}...")
|
||||
|
||||
# 检查向量维度
|
||||
if "data" in result and len(result["data"]) > 0:
|
||||
embedding = result["data"][0]["embedding"]
|
||||
print(f"向量维度: {len(embedding)} (应为 1024)")
|
||||
if len(embedding) == 1024:
|
||||
print("✅ 向量维度正确!")
|
||||
else:
|
||||
print(f"⚠️ 向量维度异常: {len(embedding)}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print(f"❌ API 调用失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到 oMLX API (ConnectionError)")
|
||||
print("请确保 oMLX 应用正在运行,端口 8000 可访问")
|
||||
return False
|
||||
except requests.exceptions.Timeout:
|
||||
print("❌ API 请求超时")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 未知错误: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
def test_chat_api():
|
||||
"""测试 chat completions API"""
|
||||
url = "http://localhost:8000/v1/chat/completions"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer omlx"
|
||||
}
|
||||
data = {
|
||||
"model": "Qwen3-8B-MLX-4bit",
|
||||
"messages": [{"role": "user", "content": "你好,请回复'测试成功'"}],
|
||||
"stream": False,
|
||||
"temperature": 0.1
|
||||
}
|
||||
|
||||
try:
|
||||
print("\n测试 oMLX chat completions API...")
|
||||
response = requests.post(url, headers=headers, json=data, timeout=60)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print("✅ Chat API 调用成功!")
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
message = result["choices"][0]["message"]["content"]
|
||||
print(f"回复内容: {message}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Chat API 调用失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Chat API 错误: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
def test_models_api():
|
||||
"""测试 models API"""
|
||||
url = "http://localhost:8000/v1/models"
|
||||
headers = {"Authorization": "Bearer omlx"}
|
||||
|
||||
try:
|
||||
print("\n测试 oMLX models API...")
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print("✅ Models API 调用成功!")
|
||||
print(f"可用模型: {json.dumps(result, ensure_ascii=False)}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Models API 调用失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Models API 错误: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("oMLX API 测试脚本")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试 models API
|
||||
models_ok = test_models_api()
|
||||
|
||||
# 测试 embedding API
|
||||
embedding_ok = test_embedding_api()
|
||||
|
||||
# 测试 chat API
|
||||
chat_ok = test_chat_api()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试结果汇总:")
|
||||
print(f" Models API: {'✅ 正常' if models_ok else '❌ 异常'}")
|
||||
print(f" Embedding API: {'✅ 正常' if embedding_ok else '❌ 异常'}")
|
||||
print(f" Chat API: {'✅ 正常' if chat_ok else '❌ 异常'}")
|
||||
print("=" * 60)
|
||||
|
||||
# 关键检查:embedding API 是否正常
|
||||
if embedding_ok:
|
||||
print("\n✅ 关键依赖:embedding API 正常,向量化功能可用")
|
||||
print(" 这意味着 daily_digest.py 的向量化步骤可以正常工作")
|
||||
else:
|
||||
print("\n❌ 关键依赖:embedding API 异常,向量化功能不可用")
|
||||
print(" 这会影响记忆系统的语义检索能力")
|
||||
|
||||
if not chat_ok:
|
||||
print("\n⚠️ Chat API 异常,但这不是关键依赖")
|
||||
print(" inbox_refine.py 的智能分类功能会降级到启发式规则")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
触发词语义匹配检测工具
|
||||
混合模式:先关键词精确匹配,未命中则 Embedding 语义相似度匹配
|
||||
|
||||
用法:
|
||||
python3 trigger_matcher.py "用户说的话"
|
||||
|
||||
返回:
|
||||
matched: true/false
|
||||
category: "meta-thinking" / "cross-domain" / "knowledge-review" / ""
|
||||
match_type: "keyword" / "semantic" / ""
|
||||
similarity: 0.0-1.0
|
||||
"""
|
||||
|
||||
import json, sys, os, urllib.request
|
||||
|
||||
# 触发类别及种子词
|
||||
TRIGGER_CATEGORIES = {
|
||||
"meta-thinking": ["升维思考", "第一性原理", "前提挑战", "深层原因", "本质是什么", "批判地看", "升维", "本质", "前提"],
|
||||
"cross-domain": ["跨界视角", "换个角度", "类比一下", "新视角", "别的领域", "借鉴一下", "跨界", "类比", "新角度", "换个思路"],
|
||||
"knowledge-review": ["入库", "记录这条", "保存知识", "知识审查", "记录", "保存"],
|
||||
}
|
||||
|
||||
EMBEDDING_URL = "http://localhost:8000/v1/embeddings"
|
||||
EMBEDDING_MODEL = "Qwen3-Embedding-0.6B"
|
||||
SIMILARITY_THRESHOLD = 0.8
|
||||
|
||||
|
||||
def keyword_match(text: str) -> tuple:
|
||||
"""关键词精确匹配,返回 (category, trigger_word) 或 ("", "")"""
|
||||
for category, keywords in TRIGGER_CATEGORIES.items():
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
return category, kw
|
||||
return "", ""
|
||||
|
||||
|
||||
def get_embedding(text: str) -> list:
|
||||
"""调用 oMLX Embedding API 获取向量"""
|
||||
data = json.dumps({
|
||||
"model": EMBEDDING_MODEL,
|
||||
"input": text
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
EMBEDDING_URL, data=data,
|
||||
headers={"Authorization": "Bearer omlx", "Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
result = json.loads(resp.read())
|
||||
return result["data"][0]["embedding"]
|
||||
|
||||
|
||||
def cosine_similarity(a: list, b: list) -> float:
|
||||
"""计算余弦相似度"""
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = sum(x * x for x in a) ** 0.5
|
||||
nb = sum(y * y for y in b) ** 0.5
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
def semantic_match(text: str) -> tuple:
|
||||
"""语义相似度匹配,返回 (best_category, best_score)"""
|
||||
try:
|
||||
text_vec = get_embedding(text)
|
||||
except Exception as e:
|
||||
return ("", 0.0)
|
||||
|
||||
best_cat = ""
|
||||
best_score = 0.0
|
||||
for category, keywords in TRIGGER_CATEGORIES.items():
|
||||
# 用类别名 + 第一个种子词作为类别向量
|
||||
cat_text = f"{category}: {', '.join(keywords[:2])}"
|
||||
try:
|
||||
cat_vec = get_embedding(cat_text)
|
||||
score = cosine_similarity(text_vec, cat_vec)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_cat = category
|
||||
except:
|
||||
continue
|
||||
return (best_cat, best_score)
|
||||
|
||||
|
||||
def detect(text: str) -> dict:
|
||||
"""主检测函数:先关键词,再语义"""
|
||||
cat, kw = keyword_match(text)
|
||||
if cat:
|
||||
return {
|
||||
"matched": True,
|
||||
"category": cat,
|
||||
"match_type": "keyword",
|
||||
"trigger": kw,
|
||||
"similarity": 1.0
|
||||
}
|
||||
|
||||
cat, score = semantic_match(text)
|
||||
if cat and score >= SIMILARITY_THRESHOLD:
|
||||
return {
|
||||
"matched": True,
|
||||
"category": cat,
|
||||
"match_type": "semantic",
|
||||
"trigger": "",
|
||||
"similarity": round(score, 4)
|
||||
}
|
||||
|
||||
return {
|
||||
"matched": False,
|
||||
"category": "",
|
||||
"match_type": "",
|
||||
"trigger": "",
|
||||
"similarity": 0.0
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: trigger_matcher.py <文本>")
|
||||
sys.exit(1)
|
||||
text = sys.argv[1]
|
||||
result = detect(text)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,6 @@
|
||||
# 02_browser/
|
||||
|
||||
此目录已清空。浏览器自动化工具的实现分布在:
|
||||
- **Camoufox(养号)** → `05_tools/07_matrix/`
|
||||
- **CloakBrowser(反爬)** → 已归档(`02_skills/_archived/cloakbrowser_controller/`)
|
||||
- **Playwright** → WorkBuddy 内置
|
||||
@@ -0,0 +1,4 @@
|
||||
# 03_ocr/
|
||||
|
||||
此目录已清空。当前无 OCR 工具实现。
|
||||
可参考的替代方案:Peekaboo(MCP 服务)内置 OCR 能力。
|
||||
@@ -0,0 +1,5 @@
|
||||
# 04_media/
|
||||
|
||||
此目录已清空。媒体处理能力分布在:
|
||||
- **FFmpeg** → Homebrew(系统安装)
|
||||
- **yt-dlp(视频下载)** → 05_tools/09_ave/
|
||||
@@ -0,0 +1,451 @@
|
||||
# 🎬 口播素材智能采集与分析系统
|
||||
|
||||
> 完全本地运行的口播灵感工坊——采集、AI 分析、智能下载、统一检索,隐私安全零成本。
|
||||
|
||||
**版本**:v1.0.0
|
||||
**适用环境**:Mac M1 / 16GB RAM / macOS
|
||||
**作者**:ghai
|
||||
**日期**:2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么?
|
||||
|
||||
一个个人口播创作者的**素材灵感管理系统**。解决的核心问题:
|
||||
|
||||
| 痛点 | 解决方案 |
|
||||
|------|----------|
|
||||
| 素材散布在抖音/小红书/B站 | 一键采集元数据到本地 SQLite |
|
||||
| 收藏夹大量 404 | AI 判断价值 → 自动下载到本地 |
|
||||
| 看标题判断不了口播价值 | 本地 LLM 自动提取金句/情绪/结构 |
|
||||
| 不想上云、不想付费 | 全部跑在本机,零网络依赖(采集除外) |
|
||||
|
||||
**一句话**:搜得到、看得懂、存得住、找得快。
|
||||
|
||||
---
|
||||
|
||||
## 二、系统架构
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ collect.py │────→│ analyze.py │────→│ app.py │
|
||||
│ 多平台采集 │ │ AI 分析 │ │ Web 界面 │
|
||||
└──────┬──────┘ └──────┬───────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ JSONL 存档 │ │ oMLX 本地LLM │ │ SQLite 索引 │
|
||||
│ data/raw/ │ │ Qwen2.5-VL │ │ data/db │
|
||||
└──────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌──────▼───────┐
|
||||
│downloader.py │
|
||||
│ yt-dlp 下载 │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
~/workbuddy-agent-os/agent-local/materials/
|
||||
```
|
||||
|
||||
### 技术选型
|
||||
|
||||
| 组件 | 选型 | 为什么 |
|
||||
|------|------|--------|
|
||||
| 采集引擎 | MediaCrawler (Playwright) | 主流平台支持最全 |
|
||||
| AI 大脑 | oMLX + Qwen2.5-VL-3B-8bit | 本地免费,3B 模型足够 JSON 输出 |
|
||||
| 结构化存储 | SQLite (WAL 模式) | 零配置,16GB 无压力 |
|
||||
| 媒体下载 | yt-dlp | 支持平台最多 |
|
||||
| Web 界面 | Gradio | Python 原生,开发最快 |
|
||||
| 配置管理 | YAML | 人类可读,注释友好 |
|
||||
|
||||
---
|
||||
|
||||
## 三、快速开始
|
||||
|
||||
### 3.1 环境准备
|
||||
|
||||
```bash
|
||||
# 确认 Python 环境
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python3 --version
|
||||
# 应输出: Python 3.13.12
|
||||
|
||||
# 确认 oMLX 运行中
|
||||
curl -s http://localhost:8000/v1/models -H "Authorization: Bearer omlx" | python3 -c "import sys,json; [print(m['id']) for m in json.load(sys.stdin)['data']]"
|
||||
# 应输出: Qwen2.5-VL-3B-Instruct-8bit
|
||||
```
|
||||
|
||||
### 3.2 安装依赖
|
||||
|
||||
```bash
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/pip install gradio yt-dlp
|
||||
```
|
||||
|
||||
### 3.3 初始化数据库
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/05_crawl/content-inspiration
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python3 collect.py --init-db
|
||||
# 输出: [OK] 数据库初始化完成
|
||||
```
|
||||
|
||||
### 3.4 开始使用
|
||||
|
||||
```bash
|
||||
# 1. 采集素材(需要先配置 MediaCrawler Cookie)
|
||||
python collect.py -p xiaohongshu -k "普通人逆袭"
|
||||
|
||||
# 2. AI 分析(需要 oMLX 运行中)
|
||||
python analyze.py
|
||||
|
||||
# 3. 启动 Web 界面
|
||||
python app.py
|
||||
# 打开 http://127.0.0.1:7860
|
||||
|
||||
# 4. 下载视频(在 Web 界面标记后)
|
||||
python downloader.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、项目结构
|
||||
|
||||
```
|
||||
content-inspiration/
|
||||
├── SKILL.md # 技能身份证(system/user 能力说明)
|
||||
├── README.md # 本文件(完整项目说明)
|
||||
├── config.yaml # 所有配置(平台/模型/存储/下载/Web)
|
||||
├── schema.sql # 数据库表结构(3 张表)
|
||||
├── requirements.txt # Python 依赖清单
|
||||
│
|
||||
├── collect.py # 采集脚本(MediaCrawler 封装)
|
||||
├── analyze.py # AI 分析脚本(oMLX 调用 + JSON 解析)
|
||||
├── downloader.py # 下载脚本(yt-dlp 封装)
|
||||
├── app.py # Web 界面(Gradio)
|
||||
├── utils.py # 通用工具(配置/数据库/日志/JSONL)
|
||||
│
|
||||
├── data/
|
||||
│ ├── raw/ # JSONL 原始采集存档
|
||||
│ │ └── 2026-04-28_xiaohongshu_普通人逆袭.jsonl
|
||||
│ └── database.db # SQLite 素材库
|
||||
│
|
||||
└── logs/ # 运行日志(按日期分割)
|
||||
├── collect_20260428.log
|
||||
└── analyze_20260428.log
|
||||
```
|
||||
|
||||
### 与 AgentOS 的关系
|
||||
|
||||
```
|
||||
~/workbuddy-agent-os/agent-sync/ ← 坚果云同步 + Git
|
||||
└── 05_tools/05_crawl/
|
||||
└── content-inspiration/ ← 本项目
|
||||
├── 代码 + 配置 + 数据库 (同步)
|
||||
└── data/raw/*.jsonl (同步)
|
||||
|
||||
~/workbuddy-agent-os/agent-local/ ← 本机专属,不同步
|
||||
└── materials/
|
||||
├── video/ ← 下载的视频
|
||||
└── audio/ ← 下载的音频
|
||||
```
|
||||
|
||||
- 项目代码和数据库跟随 agent-os 同步
|
||||
- 下载的视频/音频在本机专属目录,不占用同步空间
|
||||
- AI 分析结果可通过 AgentOS 的 inbox_refine 归档到知识库
|
||||
|
||||
---
|
||||
|
||||
## 五、配置说明(config.yaml)
|
||||
|
||||
```yaml
|
||||
# 平台和关键词
|
||||
platforms:
|
||||
- xiaohongshu # 目前最成熟
|
||||
- douyin # 需要 Cookie
|
||||
- bilibili # 支持
|
||||
|
||||
keywords:
|
||||
- "普通人逆袭"
|
||||
- "情感故事"
|
||||
- "科普冷知识"
|
||||
|
||||
max_count: 20 # 每关键词每次采集上限
|
||||
|
||||
# 本地 LLM(oMLX,非 Ollama)
|
||||
llm:
|
||||
model: "Qwen2.5-VL-3B-Instruct-8bit"
|
||||
base_url: "http://localhost:8000/v1" # oMLX 端口
|
||||
api_key: "omlx" # oMLX 固定 key
|
||||
timeout: 120 # 每条分析超时
|
||||
temperature: 0.3 # 低温度保证稳定
|
||||
|
||||
# 存储路径
|
||||
storage:
|
||||
raw_dir: "data/raw"
|
||||
db_path: "data/database.db"
|
||||
media_dir: "~/workbuddy-agent-os/agent-local/materials/video"
|
||||
audio_dir: "~/workbuddy-agent-os/agent-local/materials/audio"
|
||||
|
||||
# Web 界面
|
||||
web:
|
||||
host: "127.0.0.1"
|
||||
port: 7860
|
||||
title: "口播灵感工坊"
|
||||
|
||||
# 下载配置
|
||||
download:
|
||||
rate_limit: "1M" # 速率限制,防 IP 封禁
|
||||
retries: 3 # 重试次数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、脚本详细说明
|
||||
|
||||
### collect.py — 采集脚本
|
||||
|
||||
**功能**:从主流平台采集内容元数据(标题、描述、封面、互动数据等),不下载视频本体。
|
||||
|
||||
```bash
|
||||
# 初始化数据库
|
||||
python collect.py --init-db
|
||||
|
||||
# 指定平台和关键词采集
|
||||
python collect.py -p xiaohongshu -k "科普冷知识"
|
||||
|
||||
# 指定数量
|
||||
python collect.py -p xiaohongshu -k "情感故事" -n 10
|
||||
|
||||
# 按 config.yaml 批量采集(遍历所有平台×关键词)
|
||||
python collect.py
|
||||
```
|
||||
|
||||
**输出**:
|
||||
- `data/raw/YYYY-MM-DD_平台_关键词.jsonl` — 原始数据存档
|
||||
- `data/database.db` — 写入 materials 表(自动去重)
|
||||
|
||||
**前置条件**:MediaCrawler 已安装并配置好 Cookie。
|
||||
|
||||
### analyze.py — AI 分析脚本
|
||||
|
||||
**功能**:对未分析的素材调用本地 oMLX(Qwen2.5-VL-3B),提取口播可用信息。
|
||||
|
||||
```bash
|
||||
# 分析所有未分析的素材
|
||||
python analyze.py
|
||||
|
||||
# 只分析前 10 条(测试用)
|
||||
python analyze.py -n 10
|
||||
|
||||
# 只看有多少待分析(不执行)
|
||||
python analyze.py --dry-run
|
||||
|
||||
# 重试之前失败的记录
|
||||
python analyze.py --retry-failed
|
||||
```
|
||||
|
||||
**输出**:写入 analysis 表,字段包括:
|
||||
- `tags` — AI 标签(科普、情感、冷知识…)
|
||||
- `golden_quote` — 可直接引用的金句
|
||||
- `core_idea` — 核心立意
|
||||
- `structure` — 口播脚本结构
|
||||
- `emotion` — 整体情绪
|
||||
- `worth_downloading` — 是否推荐下载
|
||||
|
||||
**注意**:串行执行(每次只处理一条),16GB Mac 不会 OOM。每条间隔 1 秒。
|
||||
|
||||
### downloader.py — 下载脚本
|
||||
|
||||
**功能**:下载标记为 pending 的素材视频到本地。
|
||||
|
||||
```bash
|
||||
# 下载所有标记为待下载的
|
||||
python downloader.py
|
||||
|
||||
# 下载所有 AI 推荐的
|
||||
python downloader.py --all
|
||||
|
||||
# 限制数量
|
||||
python downloader.py -n 5
|
||||
|
||||
# 手动指定 URL
|
||||
python downloader.py -u "https://v.douyin.com/xxx"
|
||||
```
|
||||
|
||||
**输出**:文件保存到 `~/workbuddy-agent-os/agent-local/materials/video/`,文件名格式:
|
||||
```
|
||||
xiaohongshu_作者名_标题前20字_日期.mp4
|
||||
```
|
||||
|
||||
### app.py — Web 界面
|
||||
|
||||
**功能**:本地浏览器访问的素材管理界面。
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
# 打开 http://127.0.0.1:7860
|
||||
```
|
||||
|
||||
**功能清单**:
|
||||
- 🔍 关键词搜索(标题/描述/标签/金句)
|
||||
- 📋 平台/情绪/下载状态筛选
|
||||
- 📊 实时统计(总数/已分析/已下载/AI推荐)
|
||||
- 📖 素材详情(AI 分析完整结果)
|
||||
- 📥 一键标记下载
|
||||
- 📝 金句可直接复制
|
||||
|
||||
### utils.py — 工具函数
|
||||
|
||||
| 函数 | 用途 |
|
||||
|------|------|
|
||||
| `load_config()` | 加载 config.yaml |
|
||||
| `get_db()` / `init_db()` | 数据库连接/初始化 |
|
||||
| `setup_logger()` | 日志配置 |
|
||||
| `parse_jsonl()` / `save_jsonl()` | JSONL 读写 |
|
||||
| `check_omlx()` | 检测 oMLX 可用性 |
|
||||
| `truncate_text()` | 文件名安全截断 |
|
||||
|
||||
---
|
||||
|
||||
## 七、数据库设计
|
||||
|
||||
### materials 表(素材元数据)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键 |
|
||||
| platform | TEXT | 平台(xiaohongshu/douyin/bilibili) |
|
||||
| original_id | TEXT | 平台内唯一 ID |
|
||||
| url | TEXT | 原始链接 |
|
||||
| title | TEXT | 标题 |
|
||||
| description | TEXT | 描述/正文 |
|
||||
| author | TEXT | 作者 |
|
||||
| cover_url | TEXT | 封面图 |
|
||||
| music_name | TEXT | BGM |
|
||||
| like_count | INTEGER | 点赞数 |
|
||||
| analyzed | INTEGER | 0未分析/1已分析/2失败 |
|
||||
| download_status | TEXT | none/pending/downloading/done/failed |
|
||||
| local_files | TEXT | JSON(本地文件路径) |
|
||||
| raw_json | TEXT | 完整原始数据备份 |
|
||||
|
||||
### analysis 表(AI 分析结果)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| material_id | INTEGER | 关联素材 |
|
||||
| tags | TEXT | AI 标签(逗号分隔) |
|
||||
| golden_quote | TEXT | 金句 |
|
||||
| core_idea | TEXT | 核心立意 |
|
||||
| structure | TEXT | 脚本结构 |
|
||||
| emotion | TEXT | 情绪 |
|
||||
| worth_downloading | TEXT | yes/no/maybe |
|
||||
| download_reason | TEXT | 下载理由 |
|
||||
| raw_model_output | TEXT | 模型原始输出(调试) |
|
||||
|
||||
### collect_batches 表(采集批次记录)
|
||||
|
||||
记录每次采集的平台、关键词、数量、状态,方便回溯。
|
||||
|
||||
---
|
||||
|
||||
## 八、知识库索引卡片
|
||||
|
||||
采集并分析后的素材,可以在知识库中生成索引卡片(存入 `03_knowledge/50_resources/`):
|
||||
|
||||
**文件名格式**:`{hostname}_{YYYYMMDD}_{slug}.md`
|
||||
**示例**:`Redmi-12C_20260428_xiaohongshu-普通人逆袭.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: 普通人逆袭合集
|
||||
source_url: https://www.xiaohongshu.com/explore/xxx
|
||||
local_path: ~/workbuddy-agent-os/agent-local/materials/video/xiaohongshu_作者_标题.mp4
|
||||
type: video
|
||||
platform: xiaohongshu
|
||||
collected_by: Redmi-12C
|
||||
collected_at: 2026-04-28
|
||||
tags: [口播素材, 励志, 逆袭]
|
||||
---
|
||||
一句话说明:适合逆袭主题口播开头,情绪张力强。
|
||||
```
|
||||
|
||||
`collected_by` 字段防止多机同步时文件名冲突。
|
||||
|
||||
---
|
||||
|
||||
## 九、MediaCrawler 配置(前置条件)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/05_crawl
|
||||
git clone https://github.com/NanmiCoder/MediaCrawler.git
|
||||
cd MediaCrawler
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置 Cookie
|
||||
|
||||
各平台需要在首次使用前扫码登录获取 Cookie:
|
||||
|
||||
1. `cd ~/workbuddy-agent-os/agent-sync/05_tools/05_crawl/MediaCrawler`
|
||||
2. `python main.py --platform xhs --lt qrcode`
|
||||
3. 扫描终端显示的二维码
|
||||
4. Cookie 自动保存,后续无需重复登录
|
||||
|
||||
### 配置本项目
|
||||
|
||||
安装完成后,更新 `config.yaml`:
|
||||
```yaml
|
||||
crawler:
|
||||
project_path: "~/workbuddy-agent-os/agent-sync/05_tools/05_crawl/MediaCrawler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、性能与资源管理(16GB Mac)
|
||||
|
||||
| 场景 | 内存占用 | 建议 |
|
||||
|------|----------|------|
|
||||
| 采集(Playwright) | ~500MB | 关闭其他浏览器 |
|
||||
| AI 分析(Qwen2.5-VL-3B) | ~2GB | 关闭其他大型应用,串行执行 |
|
||||
| Web 界面(Gradio) | ~300MB | 轻量,无压力 |
|
||||
| 下载(yt-dlp) | ~100MB | 速率限制 1MB/s |
|
||||
|
||||
**关键原则**:分析期间不同时运行其他 AI 任务,每次只处理一条记录。
|
||||
|
||||
---
|
||||
|
||||
## 十一、常见问题
|
||||
|
||||
**Q: oMLX 返回 500 错误?**
|
||||
A: Qwen3-8B-MLX-4bit 有已知问题。本系统使用 Qwen2.5-VL-3B-Instruct-8bit,不受影响。
|
||||
|
||||
**Q: 采集没有数据?**
|
||||
A: 检查 MediaCrawler Cookie 是否过期,重新扫码登录。
|
||||
|
||||
**Q: AI 分析结果不准确?**
|
||||
A: 3B 模型有限,可通过调整 `temperature`(降低=更稳定)或更换更强模型改善。
|
||||
|
||||
**Q: 视频下载失败?**
|
||||
A: 抖音/小红书链接有时效性,采集后尽快下载。也可尝试 `--retry-failed`。
|
||||
|
||||
**Q: 如何接入 AgentOS 自动化?**
|
||||
A: 可在 WorkBuddy 中创建定时自动化,例如每天凌晨执行 `python analyze.py`。
|
||||
|
||||
---
|
||||
|
||||
## 十二、合规提醒
|
||||
|
||||
- ⚠️ 仅供**个人学习研究**,禁止大规模抓取或商业化
|
||||
- 下载的视频仅供**分析借鉴**,口播内容须二次原创
|
||||
- 所有数据**本地存储**,注意定期备份
|
||||
|
||||
---
|
||||
|
||||
## 十三、未来扩展
|
||||
|
||||
- [ ] 定时自动采集 + 热点推送
|
||||
- [ ] 向量语义搜索(接入 ChromaDB + Qwen3-Embedding)
|
||||
- [ ] 提示词模板可视化编辑
|
||||
- [ ] 素材标签归一化
|
||||
- [ ] 简易剪辑时间线
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: content_inspiration
|
||||
version: 1.0.0
|
||||
description: 口播素材智能采集与分析系统——多平台采集、AI提炼、智能下载、统一检索
|
||||
category: user
|
||||
triggers:
|
||||
- 口播素材
|
||||
- 素材采集
|
||||
- 灵感采集
|
||||
- 采集素材
|
||||
- 口播灵感
|
||||
- 素材工坊
|
||||
- content inspiration
|
||||
- 启动工坊
|
||||
- 分析素材
|
||||
- 下载素材
|
||||
---
|
||||
|
||||
# Content Inspiration —— 口播素材智能采集与分析系统
|
||||
|
||||
## 概述
|
||||
|
||||
完全运行在本机 Mac 的口播素材管理系统,实现"采集→AI分析→智能下载→检索查阅"的完整闭环。不依赖任何云端服务,隐私安全。
|
||||
|
||||
## 系统能力
|
||||
|
||||
### System 能力(后台自动运行,脚本式任务)
|
||||
|
||||
| 能力 | 脚本 | 说明 |
|
||||
|------|------|------|
|
||||
| 数据库管理 | `schema.sql` | SQLite WAL 模式,3 张表(materials/analysis/collect_batches) |
|
||||
| 配置驱动 | `config.yaml` | 所有参数集中管理,模型/平台/存储/下载均可配置 |
|
||||
| 数据库初始化 | `utils.py: init_db()` | 自动建表、开启 WAL、外键约束 |
|
||||
| JSONL 存档 | `utils.py: save_jsonl()` | 原始采集数据可追溯回溯 |
|
||||
| oMLX 健康检查 | `utils.py: check_omlx()` | 自动检测本地 LLM 可用性 |
|
||||
| 日志系统 | `utils.py: setup_logger()` | 按日期分割,同时输出文件和控制台 |
|
||||
| 去重机制 | `collect.py: save_to_db()` | 基于 platform+original_id 唯一索引 |
|
||||
|
||||
### User 能力(用户直接触发/操作)
|
||||
|
||||
| 能力 | 脚本 | 用法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 按需采集 | `collect.py` | `python collect.py -p xiaohongshu -k "科普冷知识"` | 从指定平台采集素材元数据 |
|
||||
| 全量采集 | `collect.py` | `python collect.py` | 按 config.yaml 配置批量采集 |
|
||||
| AI 分析 | `analyze.py` | `python analyze.py` | 对所有未分析素材调用本地 LLM |
|
||||
| 限量分析 | `analyze.py` | `python analyze.py -n 10` | 只分析前 10 条(测试用) |
|
||||
| 重试失败 | `analyze.py` | `python analyze.py --retry-failed` | 重试之前分析失败的记录 |
|
||||
| 下载视频 | `downloader.py` | `python downloader.py` | 下载所有标记为 pending 的素材 |
|
||||
| 下载推荐 | `downloader.py` | `python downloader.py --all` | 下载所有 AI 推荐的素材 |
|
||||
| 手动下载 | `downloader.py` | `python downloader.py -u "URL"` | 指定 URL 直接下载 |
|
||||
| Web 界面 | `app.py` | `python app.py` | 启动 Gradio 搜索/浏览界面 |
|
||||
| 初始化数据库 | `collect.py` | `python collect.py --init-db` | 仅创建数据库表结构 |
|
||||
|
||||
## 数据流程
|
||||
|
||||
```
|
||||
用户配置关键词
|
||||
↓
|
||||
collect.py → MediaCrawler → JSONL 存档 + SQLite 入库
|
||||
↓
|
||||
analyze.py → oMLX(Qwen2.5-VL-3B) → AI 标签/金句/情绪/推荐
|
||||
↓
|
||||
app.py (Web界面) → 浏览/搜索/标记下载
|
||||
↓
|
||||
downloader.py → yt-dlp → ~/workbuddy-agent-os/agent-local/materials/video/
|
||||
↓
|
||||
知识库索引卡片 → 03_knowledge/50_resources/
|
||||
```
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 必需
|
||||
|
||||
| 依赖 | 版本 | 用途 | 安装状态 |
|
||||
|------|------|------|----------|
|
||||
| Python | 3.10+ | 运行环境 | ✅ 3.13.12 |
|
||||
| oMLX | v0.3.6 | 本地 LLM 推理 | ✅ 运行中 |
|
||||
| Qwen2.5-VL-3B | 8bit | 多模态内容理解 | ✅ 已加载 |
|
||||
| httpx | ≥0.24 | HTTP 客户端(调 oMLX) | ✅ 已安装 |
|
||||
| PyYAML | ≥6.0 | 配置文件解析 | ✅ 已安装 |
|
||||
| Pillow | ≥9.0 | 图片处理 | ✅ 已安装 |
|
||||
|
||||
### 待安装
|
||||
|
||||
| 依赖 | 版本 | 用途 | 安装命令 |
|
||||
|------|------|------|----------|
|
||||
| gradio | ≥4.0 | Web 界面 | `pip install gradio` |
|
||||
| yt-dlp | ≥2024.0 | 视频下载 | `pip install yt-dlp` |
|
||||
|
||||
### 可选
|
||||
|
||||
| 依赖 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| MediaCrawler | 多平台采集 | 需单独 clone 并配置 Cookie |
|
||||
| ffmpeg | 音频提取 | yt-dlp 的音频模式需要 |
|
||||
|
||||
## 存储路径
|
||||
|
||||
| 类型 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 项目代码 | `~/workbuddy-agent-os/agent-sync/05_tools/05_crawl/content-inspiration/` | 坚果云同步 + Git |
|
||||
| 原始 JSONL | `data/raw/` | 跟随项目目录 |
|
||||
| SQLite 数据库 | `data/database.db` | 跟随项目目录 |
|
||||
| 下载的视频 | `~/workbuddy-agent-os/agent-local/materials/video/` | 本机专属,不同步 |
|
||||
| 下载的音频 | `~/workbuddy-agent-os/agent-local/materials/audio/` | 本机专属,不同步 |
|
||||
| 运行日志 | `logs/` | 跟随项目目录 |
|
||||
|
||||
## 限制与注意事项
|
||||
|
||||
1. **MediaCrawler Cookie**:首次使用需在各平台手动扫码登录,获取 Cookie 后填入 MediaCrawler 配置
|
||||
2. **链接时效性**:抖音/小红书链接有时效性,采集后 24 小时内下载成功率最高
|
||||
3. **内存管理**:AI 分析串行执行(每次只处理一条),避免 16GB Mac OOM
|
||||
4. **模型精度**:3B 小模型可能有标签不准确的情况,建议人工校验
|
||||
5. **合规提醒**:仅供个人学习研究,禁止商业化或大规模抓取
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.0.0 | 2026-04-28 | 初始版本,核心闭环(采集→分析→下载→查阅) |
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
口播素材系统 - AI 分析脚本
|
||||
用法:python analyze.py [--limit N] [--retry-failed]
|
||||
|
||||
功能:
|
||||
1. 查询所有未分析的素材记录
|
||||
2. 调用本地 oMLX(Qwen2.5-VL-3B)进行口播价值分析
|
||||
3. 解析结构化 JSON 结果,写入 analysis 表
|
||||
|
||||
本机适配:
|
||||
- 使用 oMLX HTTP API(localhost:8000),非 Ollama
|
||||
- 模型:Qwen2.5-VL-3B-Instruct-8bit
|
||||
- 串行执行,防止 16GB 内存 OOM
|
||||
- 异常自动跳过,不中断整体流程
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import argparse
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import httpx
|
||||
from utils import load_config, get_db, init_db, setup_logger
|
||||
|
||||
# 分析提示词模板
|
||||
ANALYSIS_PROMPT = """你是一个口播素材分析助手。请分析以下内容,提取口播创作可用的信息。仅返回合法JSON,不要有任何额外文字。
|
||||
|
||||
内容:
|
||||
标题:{title}
|
||||
描述:{description}
|
||||
作者:{author}
|
||||
平台:{platform}
|
||||
|
||||
要求返回JSON:
|
||||
{{
|
||||
"tags": ["标签1","标签2","标签3"],
|
||||
"golden_quote": "可以直接引用的金句或观点,不超过30字",
|
||||
"core_idea": "一句话核心立意",
|
||||
"structure": "口播脚本结构:开头-中间-结尾的梗概",
|
||||
"emotion": "整体情绪,如 治愈/励志/震惊/沉思/有趣",
|
||||
"worth_downloading": "yes/no/maybe",
|
||||
"download_reason": "建议下载的简短理由,若no则留空"
|
||||
}}"""
|
||||
|
||||
|
||||
def call_omlx(prompt: str, config: dict, logger) -> str | None:
|
||||
"""
|
||||
调用 oMLX API 进行推理
|
||||
|
||||
返回:模型输出的文本,失败返回 None
|
||||
"""
|
||||
import httpx
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{config['llm']['base_url']}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config['llm']['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": config["llm"]["model"],
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": config["llm"].get("temperature", 0.3),
|
||||
"max_tokens": 500,
|
||||
},
|
||||
timeout=config["llm"].get("timeout", 120),
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"oMLX 返回 {resp.status_code}: {resp.text[:200]}")
|
||||
return None
|
||||
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("oMLX 请求超时")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"oMLX 调用异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_analysis_json(raw_text: str, logger) -> dict | None:
|
||||
"""
|
||||
解析模型输出的 JSON,带容错处理
|
||||
"""
|
||||
if not raw_text:
|
||||
return None
|
||||
|
||||
# 尝试直接解析
|
||||
try:
|
||||
result = json.loads(raw_text)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试提取 JSON 块(模型可能输出 ```json ... ```)
|
||||
match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw_text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
result = json.loads(match.group(1))
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试提取第一个 { 到最后一个 }
|
||||
first = raw_text.find("{")
|
||||
last = raw_text.rfind("}")
|
||||
if first != -1 and last != -1 and last > first:
|
||||
try:
|
||||
result = json.loads(raw_text[first:last+1])
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning(f"JSON 解析失败,原始输出: {raw_text[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def analyze_one(material: dict, config: dict, logger) -> dict | None:
|
||||
"""
|
||||
分析单条素材
|
||||
"""
|
||||
title = material["title"] or ""
|
||||
description = (material.get("description") or "")[:500] # 截断避免过长
|
||||
|
||||
if not title and not description:
|
||||
logger.warning(f"素材 {material['id']} 无标题和描述,跳过")
|
||||
return None
|
||||
|
||||
prompt = ANALYSIS_PROMPT.format(
|
||||
title=title,
|
||||
description=description,
|
||||
author=material.get("author", "未知"),
|
||||
platform=material.get("platform", "未知"),
|
||||
)
|
||||
|
||||
raw_output = call_omlx(prompt, config, logger)
|
||||
if not raw_output:
|
||||
return None
|
||||
|
||||
result = parse_analysis_json(raw_output, logger)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# 规范化字段
|
||||
return {
|
||||
"material_id": material["id"],
|
||||
"tags": ",".join(result.get("tags", [])),
|
||||
"golden_quote": str(result.get("golden_quote", ""))[:200],
|
||||
"core_idea": str(result.get("core_idea", ""))[:300],
|
||||
"structure": str(result.get("structure", ""))[:500],
|
||||
"emotion": str(result.get("emotion", ""))[:20],
|
||||
"worth_downloading": str(result.get("worth_downloading", "no"))[:10],
|
||||
"download_reason": str(result.get("download_reason", ""))[:200],
|
||||
"raw_model_output": raw_output,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="口播素材 AI 分析")
|
||||
parser.add_argument("--limit", "-n", type=int, default=0, help="分析数量上限,0=全部")
|
||||
parser.add_argument("--retry-failed", action="store_true", help="重试分析失败的记录")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅显示待分析数量,不执行")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config()
|
||||
logger = setup_logger("analyze")
|
||||
|
||||
# 检查 oMLX
|
||||
from utils import check_omlx
|
||||
if not check_omlx(config):
|
||||
logger.error("oMLX 不可用,请先启动 oMLX 服务")
|
||||
return
|
||||
|
||||
db_path = init_db()
|
||||
db = get_db(db_path)
|
||||
|
||||
# 查询待分析记录
|
||||
if args.retry_failed:
|
||||
query = "SELECT * FROM materials WHERE analyzed = 2"
|
||||
else:
|
||||
query = "SELECT * FROM materials WHERE analyzed = 0"
|
||||
|
||||
if args.limit > 0:
|
||||
query += f" LIMIT {args.limit}"
|
||||
|
||||
materials = db.execute(query).fetchall()
|
||||
materials = [dict(m) for m in materials]
|
||||
|
||||
if not materials:
|
||||
print("[OK] 没有待分析的素材")
|
||||
db.close()
|
||||
return
|
||||
|
||||
print(f"[INFO] 待分析: {len(materials)} 条")
|
||||
|
||||
if args.dry_run:
|
||||
for m in materials[:5]:
|
||||
print(f" - [{m['platform']}] {m['title'][:40]}")
|
||||
if len(materials) > 5:
|
||||
print(f" ... 还有 {len(materials) - 5} 条")
|
||||
db.close()
|
||||
return
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, material in enumerate(materials, 1):
|
||||
print(f"\r[进度] {i}/{len(materials)} 分析中...", end="", flush=True)
|
||||
|
||||
try:
|
||||
result = analyze_one(material, config, logger)
|
||||
|
||||
if result:
|
||||
# 写入 analysis 表
|
||||
db.execute("""
|
||||
INSERT OR REPLACE INTO analysis
|
||||
(material_id, tags, golden_quote, core_idea, structure,
|
||||
emotion, worth_downloading, download_reason, raw_model_output)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
result["material_id"], result["tags"], result["golden_quote"],
|
||||
result["core_idea"], result["structure"], result["emotion"],
|
||||
result["worth_downloading"], result["download_reason"],
|
||||
result["raw_model_output"],
|
||||
))
|
||||
# 更新素材分析状态
|
||||
db.execute("UPDATE materials SET analyzed = 1 WHERE id = ?", (material["id"],))
|
||||
db.commit()
|
||||
success += 1
|
||||
else:
|
||||
db.execute("UPDATE materials SET analyzed = 2 WHERE id = ?", (material["id"],))
|
||||
db.commit()
|
||||
failed += 1
|
||||
|
||||
# 间隔 1 秒,避免过载
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分析异常 [{material['id']}]: {e}")
|
||||
db.execute("UPDATE materials SET analyzed = 2 WHERE id = ?", (material["id"],))
|
||||
db.commit()
|
||||
failed += 1
|
||||
|
||||
db.close()
|
||||
|
||||
print(f"\r[DONE] 分析完成: 成功 {success}, 失败 {failed}, 共 {len(materials)} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,704 @@
|
||||
"""
|
||||
口播素材系统 - Web 界面(Gradio)
|
||||
用法:python app.py
|
||||
|
||||
功能:
|
||||
- 搜索/筛选素材(关键词、平台、标签、情绪、下载状态)
|
||||
- 查看素材详情和 AI 分析结果
|
||||
- 一键复制金句
|
||||
- 标记下载 / 本地播放
|
||||
|
||||
启动后访问:http://127.0.0.1:7860
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils import load_config, get_db, init_db, setup_logger, PROJECT_ROOT
|
||||
|
||||
try:
|
||||
import gradio as gr
|
||||
except ImportError:
|
||||
print("[ERROR] Gradio 未安装,请执行:pip install gradio")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库查询
|
||||
# ============================================================
|
||||
|
||||
def search_materials(
|
||||
keyword: str = "",
|
||||
platform: str = "全部",
|
||||
emotion: str = "全部",
|
||||
has_file: str = "全部",
|
||||
worth_download: str = "全部",
|
||||
sort_by: str = "fetched_at DESC",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""搜索素材,返回(结果列表, 总数)"""
|
||||
config = load_config()
|
||||
db = get_db(init_db())
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if keyword:
|
||||
where_clauses.append("(m.title LIKE ? OR m.description LIKE ? OR a.tags LIKE ? OR a.golden_quote LIKE ?)")
|
||||
kw = f"%{keyword}%"
|
||||
params.extend([kw, kw, kw, kw])
|
||||
|
||||
if platform and platform != "全部":
|
||||
where_clauses.append("m.platform = ?")
|
||||
params.append(platform)
|
||||
|
||||
if emotion and emotion != "全部":
|
||||
where_clauses.append("a.emotion = ?")
|
||||
params.append(emotion)
|
||||
|
||||
if has_file == "已下载":
|
||||
where_clauses.append("m.download_status = 'done'")
|
||||
elif has_file == "未下载":
|
||||
where_clauses.append("m.download_status != 'done'")
|
||||
|
||||
if worth_download == "推荐下载":
|
||||
where_clauses.append("a.worth_downloading = 'yes'")
|
||||
elif worth_download == "不推荐":
|
||||
where_clauses.append("a.worth_downloading = 'no'")
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# 总数
|
||||
count_sql = f"""
|
||||
SELECT COUNT(*) FROM materials m
|
||||
LEFT JOIN analysis a ON m.id = a.material_id
|
||||
WHERE {where_sql}
|
||||
"""
|
||||
total = db.execute(count_sql, params).fetchone()[0]
|
||||
|
||||
# 排序和分页
|
||||
offset = (page - 1) * page_size
|
||||
data_sql = f"""
|
||||
SELECT m.*, a.tags, a.golden_quote, a.core_idea, a.structure,
|
||||
a.emotion, a.worth_downloading, a.download_reason
|
||||
FROM materials m
|
||||
LEFT JOIN analysis a ON m.id = a.material_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY {sort_by}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params.extend([page_size, offset])
|
||||
rows = db.execute(data_sql, params).fetchall()
|
||||
results = [dict(r) for r in rows]
|
||||
|
||||
db.close()
|
||||
return results, total
|
||||
|
||||
|
||||
def get_material_detail(material_id: int) -> dict:
|
||||
"""获取素材详情"""
|
||||
db = get_db(init_db())
|
||||
row = db.execute("""
|
||||
SELECT m.*, a.tags, a.golden_quote, a.core_idea, a.structure,
|
||||
a.emotion, a.worth_downloading, a.download_reason, a.raw_model_output
|
||||
FROM materials m
|
||||
LEFT JOIN analysis a ON m.id = a.material_id
|
||||
WHERE m.id = ?
|
||||
""", (material_id,)).fetchone()
|
||||
db.close()
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
def mark_download(material_id: int) -> str:
|
||||
"""标记素材为待下载"""
|
||||
db = get_db(init_db())
|
||||
db.execute("UPDATE materials SET download_status = 'pending' WHERE id = ?", (material_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return "已加入下载队列"
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""获取统计信息"""
|
||||
db = get_db(init_db())
|
||||
total = db.execute("SELECT COUNT(*) FROM materials").fetchone()[0]
|
||||
analyzed = db.execute("SELECT COUNT(*) FROM materials WHERE analyzed = 1").fetchone()[0]
|
||||
downloaded = db.execute("SELECT COUNT(*) FROM materials WHERE download_status = 'done'").fetchone()[0]
|
||||
pending = db.execute("SELECT COUNT(*) FROM materials WHERE download_status = 'pending'").fetchone()[0]
|
||||
recommended = db.execute("SELECT COUNT(*) FROM analysis WHERE worth_downloading = 'yes'").fetchone()[0]
|
||||
db.close()
|
||||
return {
|
||||
"总素材": total,
|
||||
"已分析": analyzed,
|
||||
"已下载": downloaded,
|
||||
"待下载": pending,
|
||||
"AI推荐": recommended,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Gradio 界面
|
||||
# ============================================================
|
||||
|
||||
def build_app() -> gr.Blocks:
|
||||
config = load_config()
|
||||
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
stats = get_stats()
|
||||
|
||||
with gr.Blocks(
|
||||
title=config.get("web", {}).get("title", "口播灵感工坊"),
|
||||
) as app:
|
||||
gr.Markdown("# 🎬 口播灵感工坊 — 素材采集 → 脚本生成")
|
||||
|
||||
with gr.Tabs():
|
||||
# ====== 标签页1:素材库 ======
|
||||
with gr.Tab("📦 素材库"):
|
||||
stats_text = gr.Markdown(
|
||||
f"📊 **统计** | 总素材: {stats['总素材']} | 已分析: {stats['已分析']} | "
|
||||
f"已下载: {stats['已下载']} | 待下载: {stats['待下载']} | AI推荐: {stats['AI推荐']}"
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
keyword_input = gr.Textbox(label="关键词搜索", placeholder="标题/描述/标签/金句", scale=2)
|
||||
platform_dd = gr.Dropdown(
|
||||
label="平台", choices=["全部", "xiaohongshu", "douyin", "bilibili"],
|
||||
value="全部", scale=1
|
||||
)
|
||||
emotion_dd = gr.Dropdown(
|
||||
label="情绪", choices=["全部", "治愈", "励志", "震惊", "沉思", "有趣", "感动"],
|
||||
value="全部", scale=1
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
has_file_dd = gr.Dropdown(
|
||||
label="下载状态", choices=["全部", "已下载", "未下载"],
|
||||
value="全部", scale=1
|
||||
)
|
||||
worth_dd = gr.Dropdown(
|
||||
label="AI推荐", choices=["全部", "推荐下载", "不推荐"],
|
||||
value="全部", scale=1
|
||||
)
|
||||
sort_dd = gr.Dropdown(
|
||||
label="排序", choices=["fetched_at DESC", "like_count DESC", "collect_count DESC"],
|
||||
value="fetched_at DESC", scale=1
|
||||
)
|
||||
search_btn = gr.Button("🔍 搜索", variant="primary", scale=1)
|
||||
refresh_btn = gr.Button("🔄 刷新统计", scale=1)
|
||||
|
||||
result_table = gr.Dataframe(
|
||||
headers=["ID", "平台", "标题", "作者", "金句", "情绪", "推荐下载", "状态"],
|
||||
label="搜索结果",
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column(scale=1):
|
||||
detail_id = gr.Number(label="素材ID", precision=0)
|
||||
with gr.Row():
|
||||
detail_btn = gr.Button("查看详情", variant="secondary")
|
||||
download_btn = gr.Button("📥 加入下载队列", variant="stop")
|
||||
|
||||
with gr.Column(scale=2):
|
||||
detail_output = gr.Markdown("选择一条素材后点击「查看详情」")
|
||||
|
||||
# ====== 标签页2:脚本工厂 ======
|
||||
with gr.Tab("🎬 脚本工厂"):
|
||||
gr.Markdown("### 从素材或主题生成 AVE v2.0 导演脚本")
|
||||
|
||||
with gr.Row():
|
||||
script_source = gr.Radio(
|
||||
choices=["输入主题", "从素材库ID"],
|
||||
label="脚本来源", value="输入主题"
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
topic_input = gr.Textbox(
|
||||
label="主题", placeholder="输入口播主题,如:普通人如何抓住AI时代的机会",
|
||||
visible=True, scale=3
|
||||
)
|
||||
material_id_input = gr.Number(
|
||||
label="素材ID", precision=0, value=0,
|
||||
visible=False, scale=1
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
style_dd = gr.Dropdown(
|
||||
label="视频风格",
|
||||
choices=["knowledge_lecture", "bedtime_story", "funny_talk", "tech_review"],
|
||||
value="knowledge_lecture", scale=1
|
||||
)
|
||||
gen_btn = gr.Button("🚀 生成脚本", variant="primary", scale=1)
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
gr.Markdown("#### 📄 AVE 导演脚本 (YAML)")
|
||||
yaml_output = gr.Textbox(label="", lines=20, max_lines=30)
|
||||
with gr.Column():
|
||||
gr.Markdown("#### 📝 说明文档 (MD)")
|
||||
md_output = gr.Textbox(label="", lines=20, max_lines=30)
|
||||
|
||||
with gr.Row():
|
||||
project_id_display = gr.Textbox(label="项目ID", visible=False)
|
||||
save_btn = gr.Button("💾 保存到素材库", variant="primary")
|
||||
save_status = gr.Markdown("")
|
||||
|
||||
gr.Markdown("---")
|
||||
gr.Markdown("#### 📁 历史脚本")
|
||||
with gr.Row():
|
||||
refresh_list_btn = gr.Button("🔄 刷新列表", scale=1)
|
||||
script_list_md = gr.Markdown("点击刷新查看已生成的脚本")
|
||||
|
||||
with gr.Row():
|
||||
view_pid = gr.Textbox(label="查看脚本(粘贴项目ID)", placeholder="如 vid_20260504_8817", scale=2)
|
||||
view_btn = gr.Button("📖 查看", scale=1)
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
gr.Markdown("##### YAML")
|
||||
view_yaml = gr.Textbox(label="", lines=8, max_lines=15)
|
||||
with gr.Column():
|
||||
gr.Markdown("##### MD")
|
||||
view_md = gr.Textbox(label="", lines=8, max_lines=15)
|
||||
|
||||
# 历史脚本回调
|
||||
def list_scripts():
|
||||
from pathlib import Path
|
||||
output_dir = Path(__file__).parent / "scripts_output"
|
||||
yamls = sorted(output_dir.glob("*.yaml"), reverse=True)
|
||||
if not yamls:
|
||||
return "(暂无已生成的脚本)"
|
||||
|
||||
lines = ["| 项目ID | 风格 | 段落 | 日期 |",
|
||||
"|--------|------|------|------|"]
|
||||
for y in yamls:
|
||||
pid = y.stem
|
||||
import yaml as pyyaml
|
||||
try:
|
||||
data = pyyaml.safe_load(y.read_text(encoding='utf-8'))
|
||||
style = data.get('meta', {}).get('style', '?')
|
||||
segs = len(data.get('segments', []))
|
||||
except:
|
||||
style = '?'; segs = '?'
|
||||
date = pid.split('_')[1] if '_' in pid else '?'
|
||||
date = f"{date[:4]}-{date[4:6]}-{date[6:8]}" if len(date)>=8 else '?'
|
||||
lines.append(f"| `{pid}` | {style} | {segs}段 | {date} |")
|
||||
lines.append("\n复制项目ID到下方输入框查看具体内容")
|
||||
return "\n".join(lines)
|
||||
|
||||
def view_script(pid):
|
||||
if not pid:
|
||||
return "请输入项目ID", ""
|
||||
from pathlib import Path
|
||||
output_dir = Path(__file__).parent / "scripts_output"
|
||||
yaml_path = output_dir / f"{pid.strip()}.yaml"
|
||||
md_path = output_dir / f"{pid.strip()}.md"
|
||||
yaml_c = yaml_path.read_text(encoding='utf-8') if yaml_path.exists() else "文件不存在"
|
||||
md_c = md_path.read_text(encoding='utf-8') if md_path.exists() else "文件不存在"
|
||||
return yaml_c, md_c
|
||||
|
||||
refresh_list_btn.click(list_scripts, outputs=[script_list_md])
|
||||
view_btn.click(view_script, inputs=[view_pid], outputs=[view_yaml, view_md])
|
||||
|
||||
# 脚本工厂回调
|
||||
def toggle_source(source):
|
||||
show_topic = source == "输入主题"
|
||||
return (
|
||||
gr.update(visible=show_topic),
|
||||
gr.update(visible=not show_topic),
|
||||
)
|
||||
|
||||
def generate_script(topic, material_id, style):
|
||||
import subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
script_path = Path(__file__).parent / "script_factory.py"
|
||||
cmd = [sys.executable, str(script_path), "--topic", topic, "--style", style]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
return "生成失败:\n" + result.stderr[:500], "生成失败", ""
|
||||
|
||||
# 找到最新生成的YAML和MD
|
||||
output_dir = Path(__file__).parent / "scripts_output"
|
||||
yamls = sorted(output_dir.glob("*.yaml"))
|
||||
mds = sorted(output_dir.glob("*.md"))
|
||||
|
||||
if not yamls:
|
||||
return "未找到生成的脚本文件", "未找到", ""
|
||||
|
||||
latest_yaml = yamls[-1]
|
||||
latest_md = mds[-1]
|
||||
|
||||
pid = latest_yaml.stem
|
||||
yaml_content = latest_yaml.read_text(encoding="utf-8")
|
||||
md_content = latest_md.read_text(encoding="utf-8")
|
||||
|
||||
return yaml_content, md_content, pid
|
||||
except subprocess.TimeoutExpired:
|
||||
return "生成超时(>5分钟)", "超时", ""
|
||||
except Exception as e:
|
||||
return f"生成异常: {str(e)}", "异常", ""
|
||||
|
||||
def save_script(pid):
|
||||
if not pid:
|
||||
return "请先生成脚本"
|
||||
# 复制到 materials/
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
output_dir = Path(__file__).parent / "scripts_output"
|
||||
materials_dir = Path.home() / "workbuddy-agent-os/agent-local/materials/videos"
|
||||
materials_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yaml_src = output_dir / f"{pid}.yaml"
|
||||
md_src = output_dir / f"{pid}.md"
|
||||
|
||||
if yaml_src.exists():
|
||||
shutil.copy2(str(yaml_src), str(materials_dir / f"{pid}.yaml"))
|
||||
if md_src.exists():
|
||||
shutil.copy2(str(md_src), str(materials_dir / f"{pid}.md"))
|
||||
|
||||
return f"✅ 已保存到 `{materials_dir}`"
|
||||
|
||||
# 事件绑定
|
||||
script_source.change(
|
||||
toggle_source,
|
||||
inputs=[script_source],
|
||||
outputs=[topic_input, material_id_input]
|
||||
)
|
||||
|
||||
gen_btn.click(
|
||||
generate_script,
|
||||
inputs=[topic_input, material_id_input, style_dd],
|
||||
outputs=[yaml_output, md_output, project_id_display]
|
||||
)
|
||||
|
||||
save_btn.click(
|
||||
save_script,
|
||||
inputs=[project_id_display],
|
||||
outputs=[save_status]
|
||||
)
|
||||
|
||||
# ====== 标签页3:智能搜索 ======
|
||||
with gr.Tab("🔍 智能搜索"):
|
||||
gr.Markdown("### 搜索平台内容 → 预览筛选 → 下载分析 → 生成脚本")
|
||||
gr.Markdown("""> **百度**: 直连搜索,无需扩展 ✅
|
||||
> **抖音/知乎/小红书/B站/微博**: 需安装 OpenCLI Chrome 扩展(详见帮助)
|
||||
>
|
||||
> **使用建议**:
|
||||
> - 选"百度"可直接搜热点/选题
|
||||
> - 其他平台需先装 Chrome 扩展才能用
|
||||
> - 也可直接粘贴链接到脚本工厂
|
||||
""")
|
||||
|
||||
with gr.Row():
|
||||
search_keyword = gr.Textbox(label="关键词", placeholder="输入搜索关键词", scale=3)
|
||||
search_platform = gr.Dropdown(
|
||||
label="平台", choices=["baidu", "douyin", "zhihu", "xiaohongshu", "bilibili", "weibo"],
|
||||
value="baidu", scale=1
|
||||
)
|
||||
do_search_btn = gr.Button("🔍 搜索", variant="primary", scale=1)
|
||||
|
||||
search_status = gr.Markdown("")
|
||||
|
||||
# 搜索结果表格(带序号勾选)
|
||||
search_results = gr.Dataframe(
|
||||
headers=["选", "标题", "平台", "作者", "简介", "链接"],
|
||||
label="搜索结果(勾选后点下方按钮处理)",
|
||||
interactive=True,
|
||||
col_count=(6, "fixed"),
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
max_items = gr.Slider(minimum=3, maximum=30, value=10, step=1, label="最大结果数", scale=1)
|
||||
process_selected_btn = gr.Button("📥 下载并分析选中项", variant="primary", scale=2)
|
||||
|
||||
process_status = gr.Markdown("")
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
gr.Markdown("#### 📄 生成的 YAML")
|
||||
search_yaml_out = gr.Textbox(label="", lines=12, max_lines=20)
|
||||
with gr.Column():
|
||||
gr.Markdown("#### 📝 说明文档")
|
||||
search_md_out = gr.Textbox(label="", lines=12, max_lines=20)
|
||||
|
||||
# 搜索回调
|
||||
def do_web_search(keyword, platform, max_n):
|
||||
if not keyword:
|
||||
return [], "请输入关键词"
|
||||
|
||||
import sys, importlib
|
||||
import collect as collect_mod
|
||||
importlib.reload(collect_mod)
|
||||
|
||||
# 百度直接走 HTTP,其他平台走 OpenCLI
|
||||
results = collect_mod.search_web(keyword, platform, int(max_n))
|
||||
|
||||
if not results:
|
||||
msg = "未找到结果"
|
||||
if platform != "baidu":
|
||||
msg += "。请确认: ① Chrome 扩展已安装 ② opencli daemon 运行中 ③ Chrome 已登录该平台"
|
||||
return [[""] * 6], msg
|
||||
|
||||
table = []
|
||||
for i, r in enumerate(results):
|
||||
title = (r.get("title", "") or "")[:30]
|
||||
brief = (r.get("brief", "") or "")[:50]
|
||||
url = (r.get("url", "") or "")[:60]
|
||||
author = (r.get("author", "") or "")[:15]
|
||||
table.append(["☐", title, r.get("platform", ""), author, brief, url])
|
||||
|
||||
return table, f"✅ 找到 {len(results)} 条结果,勾选感兴趣的,点击下方按钮处理"
|
||||
|
||||
def process_selected(table_data, keyword, platform):
|
||||
"""处理选中项:下载 + 转写 + 生成脚本"""
|
||||
if not table_data:
|
||||
return "", "请先搜索"
|
||||
|
||||
selected = []
|
||||
for row in table_data:
|
||||
if row and row[0] in ("☑", "✓", "✅", True, "True", "true"):
|
||||
selected.append(row)
|
||||
|
||||
if not selected:
|
||||
return "", "请勾选至少一条结果"
|
||||
|
||||
total = len(selected)
|
||||
report = []
|
||||
|
||||
for i, item in enumerate(selected):
|
||||
title = item[1] if len(item) > 1 else ""
|
||||
url = item[5] if len(item) > 5 else ""
|
||||
report.append(f" {i+1}. {title}")
|
||||
|
||||
result = f"✅ 选中 {total} 条:\n" + "\n".join(report)
|
||||
result += "\n\n⚠️ 批量下载转写功能需在命令行执行:"
|
||||
result += f"\n python collect.py --platform {platform} --keyword \"{keyword}\""
|
||||
result += "\n\n当前界面支持:复制选中链接 → 到脚本工厂输入主题生成脚本"
|
||||
return "", result
|
||||
|
||||
do_search_btn.click(
|
||||
do_web_search,
|
||||
inputs=[search_keyword, search_platform, max_items],
|
||||
outputs=[search_results, search_status]
|
||||
)
|
||||
|
||||
process_selected_btn.click(
|
||||
process_selected,
|
||||
inputs=[search_results, search_keyword, search_platform],
|
||||
outputs=[search_yaml_out, process_status]
|
||||
)
|
||||
|
||||
# ====== 标签页4:跨平台情报 ======
|
||||
with gr.Tab("🌐 跨平台情报"):
|
||||
gr.Markdown("### 全平台并行搜索 → 统一对比 → 勾选处理")
|
||||
gr.Markdown("""> 输入主题后系统自动搜索 **百度 + 抖音 + 知乎 + 小红书 + B站 + 微博** 六个平台
|
||||
> 结果按匹配度排序,一眼对比哪个平台的素材最适合你
|
||||
""")
|
||||
|
||||
with gr.Row():
|
||||
xp_keyword = gr.Textbox(label="主题", placeholder="输入要搜索的主题/标题/思路", scale=3)
|
||||
xp_max = gr.Slider(minimum=3, maximum=10, value=5, step=1, label="每平台条数")
|
||||
xp_search_btn = gr.Button("🚀 全平台搜索", variant="primary", scale=1)
|
||||
|
||||
xp_status = gr.Markdown("")
|
||||
|
||||
# 统一结果(Markdown 展示)
|
||||
xp_results_md = gr.Markdown("搜索结果将显示在这里")
|
||||
xp_cache = gr.State([])
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column(scale=2):
|
||||
gr.Markdown("#### 选择要处理的内容")
|
||||
gr.Markdown("从上方结果中复制序号(如 `1,3,5`)到下方输入框")
|
||||
xp_selected = gr.Textbox(
|
||||
label="选择序号",
|
||||
placeholder="输入序号,如 1,3,5",
|
||||
lines=2
|
||||
)
|
||||
xp_process_btn = gr.Button("📥 查看选中项", variant="primary")
|
||||
with gr.Column(scale=1):
|
||||
xp_output = gr.Markdown("")
|
||||
|
||||
# 搜索回调
|
||||
def do_xp_search(keyword, max_n):
|
||||
if not keyword:
|
||||
return "请输入主题", [], ""
|
||||
|
||||
import sys, importlib
|
||||
import collect as cm
|
||||
importlib.reload(cm)
|
||||
|
||||
results = cm.search_all(keyword, int(max_n))
|
||||
|
||||
if not results:
|
||||
return "未找到结果,试试其他关键词", [], ""
|
||||
|
||||
# 按平台分组统计
|
||||
platform_count = {}
|
||||
for r in results:
|
||||
p = r.get("platform", "?")
|
||||
platform_count[p] = platform_count.get(p, 0) + 1
|
||||
|
||||
summary = " | ".join([f"{cm.PLATFORM_LABELS.get(p,p)}: {c}条" for p, c in platform_count.items()])
|
||||
|
||||
# Markdown 表格(紧凑格式)
|
||||
lines = ["| # | 平台 | 标题(点击链接查看原文) | 作者 |",
|
||||
"|---|------|--------------------------|------|"]
|
||||
for i, r in enumerate(results, 1):
|
||||
title = (r.get("title", "") or "")[:40]
|
||||
author = (r.get("author", "") or "")[:10]
|
||||
label = r.get("label", "")
|
||||
url = (r.get("url", "") or "")
|
||||
# 加链接
|
||||
linked_title = f"[{title}]({url})" if url else title
|
||||
lines.append(f"| {i} | {label} | {linked_title} | {author} |")
|
||||
|
||||
md = "\n".join(lines)
|
||||
md += f"\n\n✅ 共 **{len(results)}** 条。{summary}"
|
||||
md += "\n\n> 复制上方序号(如 `1,3,5`)到右侧输入框查看详情。点击标题链接直接打开原文。"
|
||||
|
||||
return md, results
|
||||
|
||||
def do_xp_process(selection_text, cached_results):
|
||||
if not cached_results:
|
||||
return "请先搜索"
|
||||
if not selection_text.strip():
|
||||
return "请输入要查看的序号"
|
||||
|
||||
try:
|
||||
indices = [int(x.strip()) for x in selection_text.split(",") if x.strip().isdigit()]
|
||||
selected = []
|
||||
for idx in indices:
|
||||
if 1 <= idx <= len(cached_results):
|
||||
selected.append(cached_results[idx - 1])
|
||||
|
||||
if not selected:
|
||||
return "未匹配到有效序号"
|
||||
|
||||
report_parts = []
|
||||
for i, r in enumerate(selected):
|
||||
report_parts.append(f"**{i+1}. [{r.get('label','')}] {r.get('title','')}**")
|
||||
report_parts.append(f" - 平台: {r.get('platform','')} | 作者: {r.get('author','')} | 类型: {r.get('type','')}")
|
||||
report_parts.append(f" - 链接: {r.get('url','')}")
|
||||
report_parts.append(f" - 简介: {r.get('brief','')}")
|
||||
report_parts.append("")
|
||||
|
||||
return "\n".join(report_parts)
|
||||
except:
|
||||
return "序号格式错误,请用逗号分隔(如:1,3,5)"
|
||||
|
||||
xp_search_btn.click(
|
||||
do_xp_search,
|
||||
inputs=[xp_keyword, xp_max],
|
||||
outputs=[xp_results_md, xp_cache]
|
||||
)
|
||||
|
||||
xp_process_btn.click(
|
||||
do_xp_process,
|
||||
inputs=[xp_selected, xp_cache],
|
||||
outputs=[xp_output]
|
||||
)
|
||||
|
||||
# 素材库事件绑定(共用)
|
||||
def show_detail(material_id):
|
||||
if not material_id:
|
||||
return "请输入素材ID"
|
||||
detail = get_material_detail(int(material_id))
|
||||
if not detail:
|
||||
return "未找到该素材"
|
||||
|
||||
local_info = ""
|
||||
if detail.get("local_files"):
|
||||
files = json.loads(detail["local_files"])
|
||||
local_info = f"\n\n**本地文件**:\n"
|
||||
for k, v in files.items():
|
||||
local_info += f"- {k}: `{v}`\n"
|
||||
|
||||
tags = detail.get("tags") or "未分析"
|
||||
quote = detail.get("golden_quote") or "—"
|
||||
idea = detail.get("core_idea") or "—"
|
||||
structure = detail.get("structure") or "—"
|
||||
emotion = detail.get("emotion") or "—"
|
||||
worth = detail.get("worth_downloading") or "—"
|
||||
reason = detail.get("download_reason") or "—"
|
||||
status = detail.get("download_status") or "none"
|
||||
|
||||
return f"""### {detail.get('title', '无标题')}
|
||||
**平台**:{detail.get('platform', '')} | **作者**:{detail.get('author', '')}
|
||||
**链接**:{detail.get('url', '')}
|
||||
**状态**:{status}
|
||||
|
||||
---
|
||||
|
||||
**🏷️ 标签**:{tags}
|
||||
**💡 金句**:> {quote}
|
||||
**🎯 核心立意**:{idea}
|
||||
**📐 脚本结构**:{structure}
|
||||
**🎭 情绪**:{emotion}
|
||||
**⭐ 推荐下载**:{worth} — {reason}
|
||||
{local_info}"""
|
||||
|
||||
def do_search(keyword, platform, emotion, has_file, worth, sort_by):
|
||||
results, total = search_materials(keyword, platform, emotion, has_file, worth, sort_by)
|
||||
if not results:
|
||||
return [["—"] * 8]
|
||||
table_data = []
|
||||
for r in results:
|
||||
table_data.append([
|
||||
r["id"],
|
||||
r["platform"],
|
||||
(r["title"] or "")[:30],
|
||||
(r["author"] or "")[:15],
|
||||
(r.get("golden_quote") or "")[:25],
|
||||
r.get("emotion") or "—",
|
||||
r.get("worth_downloading") or "—",
|
||||
r.get("download_status") or "none",
|
||||
])
|
||||
return table_data
|
||||
|
||||
def do_download(material_id):
|
||||
if not material_id:
|
||||
return "请输入素材ID"
|
||||
return mark_download(int(material_id))
|
||||
|
||||
def refresh_stats():
|
||||
s = get_stats()
|
||||
return (
|
||||
f"📊 **统计** | 总素材: {s['总素材']} | 已分析: {s['已分析']} | "
|
||||
f"已下载: {s['已下载']} | 待下载: {s['待下载']} | AI推荐: {s['AI推荐']}"
|
||||
)
|
||||
|
||||
# 素材库事件绑定
|
||||
search_btn.click(do_search, inputs=[keyword_input, platform_dd, emotion_dd, has_file_dd, worth_dd, sort_dd], outputs=[result_table])
|
||||
detail_btn.click(show_detail, inputs=[detail_id], outputs=[detail_output])
|
||||
download_btn.click(do_download, inputs=[detail_id], outputs=[detail_output])
|
||||
refresh_btn.click(refresh_stats, outputs=[stats_text])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
config = load_config()
|
||||
host = config.get("web", {}).get("host", "127.0.0.1")
|
||||
port = config.get("web", {}).get("port", 7860)
|
||||
|
||||
# 清除代理(防止 Gradio 6.0 启动时被代理拦截 localhost)
|
||||
for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY"]:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
app = build_app()
|
||||
print(f"[INFO] 启动 Web 界面: http://{host}:{port}")
|
||||
app.launch(server_name=host, server_port=port, theme=gr.themes.Soft())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
搜索功能 — 支持 OpenCLI(浏览器桥接)与百度直接HTTP两种模式
|
||||
|
||||
OpenCLI 依赖:
|
||||
- npm 全局安装 @jackwener/opencli
|
||||
- Chrome 扩展已加载(opencli-extension)
|
||||
- opencli daemon 运行中
|
||||
"""
|
||||
|
||||
import re, subprocess, json, os, threading, time
|
||||
|
||||
# OpenCLI 路径
|
||||
OPENCLI_BIN = os.path.expanduser("~/.workbuddy/binaries/node/versions/22.12.0/bin/opencli")
|
||||
|
||||
# 平台映射:内部名称 → OpenCLI 子命令
|
||||
PLATFORM_CMDS = {
|
||||
"douyin": ("douyin", "search"),
|
||||
"zhihu": ("zhihu", "search"),
|
||||
"xiaohongshu": ("xiaohongshu", "search"),
|
||||
"bilibili": ("bilibili", "search"),
|
||||
"weibo": ("weibo", "search"),
|
||||
}
|
||||
|
||||
|
||||
def _opencli_available() -> bool:
|
||||
"""检查 OpenCLI 是否可用(扩展已连接)"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[OPENCLI_BIN, "doctor"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
env={**os.environ, "NODE_OPTIONS": ""},
|
||||
)
|
||||
return "[OK]" in r.stdout and "Extension" in r.stdout and "connected" in r.stdout
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def search_web(keyword: str, platform: str = "baidu", max_results: int = 15) -> list[dict]:
|
||||
"""
|
||||
搜索平台内容,只返回元数据(不下载任何文件)
|
||||
|
||||
平台支持(按优先级):
|
||||
- baidu: 直连 httpx,无需扩展
|
||||
- douyin/zhihu/xiaohongshu/bilibili/weibo: 需 OpenCLI + Chrome 扩展
|
||||
|
||||
Returns:
|
||||
[{url, title, author, brief, platform}, ...]
|
||||
"""
|
||||
|
||||
# ========== 百度搜索:直接HTTP ==========
|
||||
if platform == "baidu":
|
||||
return _search_baidu(keyword, max_results)
|
||||
|
||||
# ========== OpenCLI 搜索 ==========
|
||||
cmd_info = PLATFORM_CMDS.get(platform)
|
||||
if not cmd_info:
|
||||
return []
|
||||
|
||||
plat, action = cmd_info
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[OPENCLI_BIN, plat, action, keyword, "-f", "json"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
env={**os.environ.copy(), "NODE_OPTIONS": ""},
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
|
||||
data = json.loads(r.stdout)
|
||||
# OpenCLI 返回格式:列表或对象列表
|
||||
items = data if isinstance(data, list) else data.get("data", data.get("results", [data]))
|
||||
|
||||
results = []
|
||||
for item in items[:max_results]:
|
||||
url = item.get("url") or item.get("link") or ""
|
||||
title = item.get("title") or item.get("name") or ""
|
||||
author = item.get("author") or item.get("author_name") or ""
|
||||
brief = item.get("description") or item.get("desc") or item.get("snippet") or ""
|
||||
if url and title:
|
||||
results.append({
|
||||
"url": url,
|
||||
"title": str(title)[:40],
|
||||
"author": str(author)[:20],
|
||||
"brief": str(brief)[:80],
|
||||
"platform": platform,
|
||||
})
|
||||
return results
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[WARN] OpenCLI {platform} 搜索超时")
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
print(f"[WARN] OpenCLI {platform} 返回非JSON")
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
print(f"[WARN] OpenCLI 未安装,请执行: npm install -g @jackwener/opencli")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"[WARN] OpenCLI {platform} 搜索失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _search_baidu(keyword: str, max_results: int) -> list[dict]:
|
||||
"""百度搜索:直接 HTTP 请求"""
|
||||
try:
|
||||
import httpx
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
}
|
||||
resp = httpx.get(
|
||||
f"https://www.baidu.com/s?wd={keyword}&ie=utf-8",
|
||||
headers=headers, follow_redirects=True, timeout=15,
|
||||
)
|
||||
items = re.findall(r'<h3[^>]*>.*?<a[^>]*href="([^"]+)"[^>]*>(.*?)</a>', resp.text, re.DOTALL)
|
||||
results = []
|
||||
seen = set()
|
||||
for href, title_html in items:
|
||||
title = re.sub(r'<[^>]+>', '', title_html).strip()
|
||||
if title and len(title) > 4 and href not in seen:
|
||||
seen.add(href)
|
||||
full_url = href if href.startswith("http") else f"https://www.baidu.com{href}"
|
||||
results.append({
|
||||
"url": full_url, "title": title[:40],
|
||||
"author": "", "brief": "", "platform": "baidu",
|
||||
})
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[WARN] 百度搜索失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ============ 内容类型映射 ============
|
||||
CONTENT_TYPES = {
|
||||
"baidu": "文章",
|
||||
"douyin": "视频",
|
||||
"zhihu": "问答",
|
||||
"xiaohongshu": "图文",
|
||||
"bilibili": "视频",
|
||||
"weibo": "话题",
|
||||
}
|
||||
|
||||
PLATFORM_LABELS = {
|
||||
"baidu": "🔍 百度",
|
||||
"douyin": "🎬 抖音",
|
||||
"zhihu": "📝 知乎",
|
||||
"xiaohongshu": "📕 小红书",
|
||||
"bilibili": "📺 B站",
|
||||
"weibo": "💬 微博",
|
||||
}
|
||||
|
||||
|
||||
def _search_platform_thread(keyword: str, platform: str, max_n: int, results: dict, index: int):
|
||||
"""线程包装:搜索单个平台,结果存入 results[index]"""
|
||||
try:
|
||||
r = search_web(keyword, platform, max_n)
|
||||
results[index] = r if r else []
|
||||
except Exception as e:
|
||||
results[index] = []
|
||||
|
||||
|
||||
def search_all(keyword: str, max_per_platform: int = 5) -> list[dict]:
|
||||
"""
|
||||
全平台并行搜索,返回统一格式的合并结果列表。
|
||||
|
||||
搜索6个平台:百度、抖音、知乎、小红书、B站、微博
|
||||
每个平台独立超时,失败不影响其他平台。
|
||||
|
||||
Returns:
|
||||
[{platform, label, title, author, url, brief, type, size_hint, score}, ...]
|
||||
"""
|
||||
platforms = ["baidu", "douyin", "zhihu", "xiaohongshu", "bilibili", "weibo"]
|
||||
thread_results = [None] * len(platforms)
|
||||
threads = []
|
||||
|
||||
for i, plat in enumerate(platforms):
|
||||
t = threading.Thread(
|
||||
target=_search_platform_thread,
|
||||
args=(keyword, plat, max_per_platform, thread_results, i),
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
# 等待所有线程完成(总超时60秒)
|
||||
for t in threads:
|
||||
t.join(timeout=60)
|
||||
|
||||
# 合并结果,附带元数据
|
||||
all_results = []
|
||||
for i, plat in enumerate(platforms):
|
||||
items = thread_results[i] or []
|
||||
for item in items:
|
||||
all_results.append({
|
||||
"platform": plat,
|
||||
"label": PLATFORM_LABELS.get(plat, plat),
|
||||
"type": CONTENT_TYPES.get(plat, "其他"),
|
||||
"title": item.get("title", ""),
|
||||
"author": item.get("author", ""),
|
||||
"url": item.get("url", ""),
|
||||
"brief": item.get("brief", ""),
|
||||
"size_hint": _guess_content_size(item, plat),
|
||||
"score": _calc_match_score(keyword, item),
|
||||
})
|
||||
|
||||
# 按匹配度排序
|
||||
all_results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return all_results
|
||||
|
||||
|
||||
def _guess_content_size(item: dict, platform: str) -> str:
|
||||
"""估算内容量(视频时长/文字字数)"""
|
||||
title = item.get("title", "") or ""
|
||||
brief = item.get("brief", "") or ""
|
||||
combined = title + brief
|
||||
|
||||
if platform in ("douyin", "bilibili"):
|
||||
# 视频平台:平均时长约 3-10 分钟
|
||||
return "3-10分钟视频"
|
||||
|
||||
if platform in ("baidu", "zhihu", "weibo", "xiaohongshu"):
|
||||
# 文字平台:按摘要长度估算
|
||||
word_count = len(combined)
|
||||
if word_count > 100:
|
||||
return f"{word_count}字左右"
|
||||
elif platform in ("zhihu", "baidu"):
|
||||
return "长文(千字以上)"
|
||||
else:
|
||||
return "短文(简明)"
|
||||
|
||||
return "未知"
|
||||
|
||||
|
||||
def _calc_match_score(keyword: str, item: dict) -> int:
|
||||
"""
|
||||
计算匹配度分数(1-5)
|
||||
基于标题和描述中关键词的匹配程度
|
||||
"""
|
||||
title = (item.get("title", "") or "").lower()
|
||||
brief = (item.get("brief", "") or "").lower()
|
||||
kw = keyword.lower()
|
||||
|
||||
score = 1
|
||||
# 标题包含完整关键词 → 高分
|
||||
if kw in title:
|
||||
score += 3
|
||||
# 描述包含关键词 → 中分
|
||||
if kw in brief:
|
||||
score += 2
|
||||
# 标题包含关键词的部分词 → 基础分
|
||||
for word in kw.split():
|
||||
if len(word) > 1 and word in title:
|
||||
score += 1
|
||||
|
||||
return min(score, 5)
|
||||
@@ -0,0 +1,57 @@
|
||||
# 口播素材采集系统配置
|
||||
# 使用前请根据本机环境修改以下内容
|
||||
|
||||
# 采集平台(目前支持 xiaohongshu / douyin / bilibili)
|
||||
platforms:
|
||||
- xiaohongshu
|
||||
|
||||
# 采集关键词列表
|
||||
keywords:
|
||||
- "普通人逆袭"
|
||||
- "情感故事"
|
||||
- "科普冷知识"
|
||||
- "电影解说结构"
|
||||
|
||||
# 每个关键词每次采集的最大数量
|
||||
max_count: 20
|
||||
|
||||
# 本地 AI 模型配置(oMLX,非 Ollama)
|
||||
llm:
|
||||
model: "Qwen2.5-VL-3B-Instruct-8bit"
|
||||
base_url: "http://localhost:8000/v1"
|
||||
api_key: "omlx"
|
||||
timeout: 120 # 每条分析超时秒数
|
||||
temperature: 0.3 # 低温度保证输出稳定
|
||||
|
||||
# 数据存储路径(相对于项目根目录)
|
||||
storage:
|
||||
raw_dir: "data/raw" # JSONL 原始数据
|
||||
db_path: "data/database.db" # SQLite 数据库
|
||||
media_dir: "~/workbuddy-agent-os/agent-local/materials/video" # 下载的视频
|
||||
audio_dir: "~/workbuddy-agent-os/agent-local/materials/audio" # 下载的音频
|
||||
logs_dir: "logs" # 运行日志
|
||||
|
||||
# MediaCrawler 配置
|
||||
crawler:
|
||||
# MediaCrawler 项目路径(需手动 clone)
|
||||
# 安装方式:cd ~/workbuddy-agent-os/agent-sync/05_tools/05_crawl && git clone https://github.com/NanmiCoder/MediaCrawler.git
|
||||
project_path: ""
|
||||
# 浏览器模式:headless(后台) / headed(有界面,调试用)
|
||||
browser_mode: "headless"
|
||||
# 请求间隔(秒),防反爬
|
||||
request_delay: 2
|
||||
|
||||
# Web 界面配置
|
||||
web:
|
||||
host: "127.0.0.1"
|
||||
port: 7860
|
||||
title: "口播灵感工坊"
|
||||
|
||||
# 下载配置
|
||||
download:
|
||||
# yt-dlp 下载速率限制,0 表示不限
|
||||
rate_limit: "1M"
|
||||
# 下载重试次数
|
||||
retries: 3
|
||||
# 文件名模板
|
||||
filename_template: "{platform}_{uploader}_{title:.20s}_{upload_date}.{ext}"
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
# OpenCLI 集成说明
|
||||
|
||||
> 最后更新:2026-06-27
|
||||
|
||||
## 用途
|
||||
|
||||
通过 Chrome 扩展桥接,实现各平台搜索(抖音/知乎/小红书/B站/微博等)。
|
||||
|
||||
## 安装(每台新机器)
|
||||
|
||||
```bash
|
||||
bash install_opencli_extension.sh
|
||||
```
|
||||
|
||||
然后手动:Chrome → chrome://extensions/ → 开发者模式 → 加载已解包 → 选 ~/opencli-extension/
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
opencli doctor
|
||||
# [OK] Daemon + [OK] Extension: connected + [OK] Connectivity
|
||||
```
|
||||
|
||||
## 平台登录要求
|
||||
|
||||
| 平台 | 需要登录? |
|
||||
|------|-----------|
|
||||
| 百度 | ❌ 不需要 |
|
||||
| B站 | ✅ 搜索需要登录 |
|
||||
| 知乎/抖音/小红书/微博 | ✅ 需要登录 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 每台机器各自安装,各自登录
|
||||
- 重启电脑后需重新启动 daemon
|
||||
|
||||
## 本机配置
|
||||
|
||||
- 扩展连接码: `9zdft8vm`(随机生成,各机不同)
|
||||
- Chrome 已登录平台: 百度、B站、知乎、小红书、抖音、微博
|
||||
- OpenCLI v1.7.21 + 扩展 v1.0.15
|
||||
- 可用升级: `npm install -g @jackwener/opencli`(到 v1.8.4)+ 扩展 v1.0.20
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
豆包浏览器驱动 v7.1 —— 稳定等待法
|
||||
|
||||
改进:
|
||||
1. 豆包保持独立标签页,不关闭,不 rebind
|
||||
2. 抖音用另一个标签页,互不干扰
|
||||
3. 稳定等待法:触发增长后等连续 3 轮无变化才算完成,防止提前截断
|
||||
4. 改用 fill + click #flow-end-msg-send 替代 KeyboardEvent(React兼容)
|
||||
"""
|
||||
|
||||
import re, json, time, os, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
OPENCLI = os.path.expanduser("~/.workbuddy/binaries/node/versions/22.12.0/bin/opencli")
|
||||
|
||||
ANALYSIS_PROMPT = """请从这段视频内容中提取所有知识性内容,按以下格式完整记录,不要概括,不要总结:
|
||||
|
||||
【完整字幕】
|
||||
逐句记录视频中的每一句话,一字不差
|
||||
|
||||
【提示词/指令原文】
|
||||
如果视频中出现了AI提示词、模板、指令,请完整抄录,一字不改
|
||||
|
||||
【操作步骤】
|
||||
视频中演示的具体操作流程,分步骤详细记录
|
||||
|
||||
【关键参数】
|
||||
视频中提到的工具名称、参数设置、具体数字等
|
||||
|
||||
【核心技巧】
|
||||
视频中传授的具体方法、技巧、经验
|
||||
|
||||
记住:只提取不总结,不要分析结构,不要分析背景音乐。"""
|
||||
|
||||
|
||||
def _oc(args, timeout=30):
|
||||
env = {**os.environ, "NODE_OPTIONS": ""}
|
||||
for k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]:
|
||||
env.pop(k, None)
|
||||
try:
|
||||
r = subprocess.run([OPENCLI] + args, capture_output=True, text=True, timeout=timeout, env=env)
|
||||
return r.stdout
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_sections(raw):
|
||||
"""
|
||||
按标记提取各段落。
|
||||
支持两种格式:
|
||||
- 【】格式(旧)
|
||||
- === 标题 === 格式(新,豆包AI常用)
|
||||
"""
|
||||
sections = {}
|
||||
|
||||
# 先找 AI 回复的起始位置(跳过用户消息)
|
||||
lines = raw.split('\n')
|
||||
ai_start = -1
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if (stripped.startswith('===') and
|
||||
any(k in stripped for k in ['完整字幕', '提示词', '操作步骤', '关键参数', '核心技巧'])):
|
||||
next_chunk = '\n'.join(lines[i:i+4])
|
||||
if '逐句记录' not in next_chunk and '如果视频' not in next_chunk:
|
||||
ai_start = i
|
||||
break
|
||||
|
||||
ai_raw = '\n'.join(lines[ai_start:]) if ai_start >= 0 else raw
|
||||
|
||||
# 格式1:=== 标题 === 内容
|
||||
pattern = r'===\s*([^=]+?)\s*===\s*([\s\S]*?)(?====\s*[^=]|$)'
|
||||
for m in re.finditer(pattern, ai_raw):
|
||||
title = m.group(1).strip()
|
||||
content = m.group(2).strip()
|
||||
skip = ['逐句记录', '视频中演示', '视频中提到', '如果视频', '记住:', '完整抄录']
|
||||
if content and len(content) > 5 and not any(s in content[:40] for s in skip):
|
||||
sections[title] = content
|
||||
|
||||
# 格式2:旧格式 【】兜底
|
||||
if not sections:
|
||||
markers = ["完整字幕", "提示词/指令原文", "提示词", "指令原文", "操作步骤", "关键参数", "核心技巧"]
|
||||
for marker in markers:
|
||||
m = re.search(rf'【{re.escape(marker)}】\s*([\s\S]*?)(?=【|\Z)', raw)
|
||||
if m:
|
||||
content = m.group(1).strip()
|
||||
content = re.sub(r'逐句记录.*|如果视频中.*|视频中演示.*|视频中提到.*|记住:.*', '', content)
|
||||
sections[marker] = content
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _wait_for_response(session="doubao", max_wait=120, min_growth=500, stable_checks=3):
|
||||
"""
|
||||
稳定等待法:检测页面文本显著增长后,再等连续 stable_checks 轮无变化才算完成。
|
||||
防止 AI 还在生成就被提前截断的老 bug。
|
||||
"""
|
||||
before = len(_oc(["browser", session, "eval", "document.body.innerText"], timeout=10) or "")
|
||||
prev_len = before
|
||||
stable_count = 0
|
||||
triggered = False
|
||||
|
||||
for i in range(max_wait // 5):
|
||||
time.sleep(5)
|
||||
body = _oc(["browser", session, "eval", "document.body.innerText"], timeout=10) or ""
|
||||
current_len = len(body)
|
||||
growth = current_len - prev_len
|
||||
total_growth = current_len - before
|
||||
|
||||
has_sections = '===完整字幕' in body or '===操作步骤' in body or '===核心技巧' in body
|
||||
|
||||
if total_growth > min_growth or has_sections:
|
||||
triggered = True
|
||||
|
||||
if triggered:
|
||||
if growth == 0:
|
||||
stable_count += 1
|
||||
else:
|
||||
stable_count = 0 # 还在增长,重置
|
||||
|
||||
if i > 0 and i % 2 == 0:
|
||||
status = f" ⏳ {(i+1)*5}s...总增长{total_growth}字"
|
||||
if triggered:
|
||||
status += f" (稳定{stable_count}/{stable_checks})" if growth == 0 else " (增长中)"
|
||||
if has_sections:
|
||||
status += " ✅含结构"
|
||||
print(status, flush=True)
|
||||
|
||||
if triggered and stable_count >= stable_checks:
|
||||
print(f" ✅ 回复稳定,确认完成", flush=True)
|
||||
return body
|
||||
|
||||
print(f" ⏰ 超时({max_wait}s),返回当前内容", flush=True)
|
||||
return _oc(["browser", session, "eval", "document.body.innerText"], timeout=10)
|
||||
|
||||
|
||||
class DoubaoDriver:
|
||||
"""豆包分析驱动 v7.1(稳定等待法)"""
|
||||
|
||||
def __init__(self):
|
||||
self.doubao_ready = False
|
||||
self.douyin_session = "douyin"
|
||||
self.doubao_session = "doubao"
|
||||
|
||||
def start_session(self):
|
||||
"""启动豆包会话(只一次,标签页保持打开)"""
|
||||
# 先开一个空白标签给 douyin session
|
||||
_oc(["browser", self.douyin_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
# 开新标签给 doubao session(在新标签页打开豆包)
|
||||
_oc(["browser", self.doubao_session, "tab", "new", "https://www.doubao.com/chat"], timeout=15)
|
||||
time.sleep(5)
|
||||
self.doubao_ready = True
|
||||
print(" ✅ 豆包会话已启动(独立标签页)")
|
||||
|
||||
def analyze_video(self, url):
|
||||
"""分析单个视频"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
# 1. 抖音标签页:打开视频提取文字
|
||||
_oc(["browser", self.douyin_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
_oc(["browser", self.douyin_session, "open", url], timeout=15)
|
||||
time.sleep(6)
|
||||
page_text = _oc(["browser", self.douyin_session, "eval", "document.body.innerText"], timeout=10)
|
||||
page_text = re.sub(r'^\{[^}]*"result"[^}]*"|^"[}]$', '', page_text).strip()
|
||||
|
||||
if not page_text or len(page_text) < 50:
|
||||
return {"success": False, "error": "无法提取视频页内容", "url": url}
|
||||
|
||||
# 清理
|
||||
skip_kw = ['开启读屏', '读屏标签', '精选', '推荐', '搜索',
|
||||
'关注', '朋友', '我的', '直播', '放映厅', '壁纸',
|
||||
'通知', '私信', '投稿', '登录', '倍速', '智能',
|
||||
'清屏', '连播', '点击加载更多', '展开', '条回复',
|
||||
'评论', '广告投放', '用户服务']
|
||||
useful = [l.strip() for l in page_text.split('\n')
|
||||
if len(l.strip()) > 5 and not any(k in l for k in skip_kw)]
|
||||
|
||||
msg = f"视频 {url}\n\n视频内容:\n{chr(10).join(useful[:30])}\n\n{ANALYSIS_PROMPT}"
|
||||
|
||||
# 2. 豆包标签页(已存在,用 fill 填入 + click 发送按钮)
|
||||
_oc(["browser", self.doubao_session, "bind"], timeout=5)
|
||||
time.sleep(0.5)
|
||||
_oc(["browser", self.doubao_session, "fill", "--nth", "0", "textarea", msg], timeout=15)
|
||||
time.sleep(1)
|
||||
# 触发发送(React 页面无法通过 dispatchEvent 捕获 KeyboardEvent)
|
||||
# 优先使用显式发送按钮 click,这个按钮有 id=flow-end-msg-send
|
||||
_oc(["browser", self.doubao_session, "click", "#flow-end-msg-send"], timeout=5)
|
||||
|
||||
# 3. 智能等待回复
|
||||
raw = _wait_for_response(session=self.doubao_session)
|
||||
sections = _parse_sections(raw or "")
|
||||
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
return {
|
||||
"success": True,
|
||||
"url": url,
|
||||
"elapsed": elapsed,
|
||||
"raw_response": raw or "",
|
||||
"sections": sections,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "url": url}
|
||||
|
||||
def analyze_profile(self, profile_url, max_videos=5):
|
||||
"""分析主播主页"""
|
||||
_oc(["browser", "profile", "bind"], timeout=5)
|
||||
_oc(["browser", "profile", "open", profile_url], timeout=15)
|
||||
time.sleep(8)
|
||||
for _ in range(2):
|
||||
_oc(["browser", "profile", "scroll", "down"])
|
||||
time.sleep(3)
|
||||
|
||||
links_raw = _oc(["browser", "profile", "eval",
|
||||
"JSON.stringify(Array.from(document.querySelectorAll('a[href*=\"/video/\"]'))"
|
||||
f".slice(0,{max_videos}).map(a=>({{url:a.href,title:(a.textContent||'').trim().slice(0,40)}})))"],
|
||||
timeout=10)
|
||||
try:
|
||||
videos = json.loads(links_raw)
|
||||
except:
|
||||
videos = []
|
||||
|
||||
if not videos:
|
||||
return [{"success": False, "error": "无法获取视频列表", "url": profile_url}]
|
||||
|
||||
self.start_session()
|
||||
print(f" ✅ 获取到 {len(videos)} 个视频")
|
||||
|
||||
results = []
|
||||
for i, v in enumerate(videos):
|
||||
print(f"\n [{i+1}/{len(videos)}] {v.get('title','')[:30]}...")
|
||||
r = self.analyze_video(v["url"])
|
||||
r["title"] = v.get("title", "")
|
||||
results.append(r)
|
||||
time.sleep(2)
|
||||
return results
|
||||
|
||||
|
||||
def analyze_video(url):
|
||||
return DoubaoDriver().analyze_video(url)
|
||||
|
||||
def analyze_profile(url, max_n=5):
|
||||
return DoubaoDriver().analyze_profile(url, max_n)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
口播素材系统 - 媒体下载脚本
|
||||
用法:python downloader.py [--limit N] [--all]
|
||||
|
||||
功能:
|
||||
1. 查询所有 download_status='pending' 的素材
|
||||
2. 使用 yt-dlp 下载视频/音频
|
||||
3. 保存到 ~/workbuddy-agent-os/agent-local/materials/video/ 或 audio/
|
||||
4. 更新数据库中的 local_files 和 download_status
|
||||
|
||||
前置条件:
|
||||
- yt-dlp 已安装:pip install yt-dlp
|
||||
- ffmpeg 已安装(音频提取需要)
|
||||
|
||||
注意:
|
||||
- 抖音/小红书链接有时效性,采集后尽快下载
|
||||
- 下载速率限制默认 1MB/s,防止 IP 封禁
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils import load_config, get_db, init_db, setup_logger, truncate_text, PROJECT_ROOT
|
||||
|
||||
|
||||
def download_one(material: dict, config: dict, logger) -> dict:
|
||||
"""
|
||||
下载单条素材的视频/音频
|
||||
|
||||
返回:{"video": "path", "audio": "path"} 或 None
|
||||
"""
|
||||
url = material.get("url", "")
|
||||
if not url:
|
||||
logger.warning(f"素材 {material['id']} 无 URL,跳过")
|
||||
return None
|
||||
|
||||
try:
|
||||
from yt_dlp import YoutubeDL
|
||||
except ImportError:
|
||||
logger.error("yt-dlp 未安装,请执行:pip install yt-dlp")
|
||||
return None
|
||||
|
||||
platform = material["platform"]
|
||||
title = material.get("title", "untitled")
|
||||
author = material.get("author", "unknown")
|
||||
|
||||
# 确定输出路径
|
||||
media_dir = os.path.expanduser(config["storage"].get("media_dir", "~/workbuddy-agent-os/agent-local/materials/video"))
|
||||
os.makedirs(media_dir, exist_ok=True)
|
||||
|
||||
# 文件名
|
||||
safe_title = truncate_text(title, 20)
|
||||
safe_author = truncate_text(author, 15)
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
filename = f"{platform}_{safe_author}_{safe_title}_{date_str}"
|
||||
|
||||
outtmpl = os.path.join(media_dir, filename + ".%(ext)s")
|
||||
|
||||
# yt-dlp 配置
|
||||
ydl_opts = {
|
||||
"outtmpl": outtmpl,
|
||||
"format": "best[ext=mp4]/best", # 优先 mp4
|
||||
"max_filesize": 500 * 1024 * 1024, # 500MB 上限
|
||||
"retries": config["download"].get("retries", 3),
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"extract_flat": False,
|
||||
# 速率限制
|
||||
"ratelimit": 1024 * 1024, # 1MB/s
|
||||
}
|
||||
|
||||
result_files = {"video": None, "audio": None}
|
||||
|
||||
try:
|
||||
with YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
|
||||
if info:
|
||||
downloaded_file = ydl.prepare_filename(info)
|
||||
# yt-dlp 可能修改了扩展名
|
||||
if os.path.exists(downloaded_file):
|
||||
result_files["video"] = downloaded_file
|
||||
logger.info(f"下载成功: {os.path.basename(downloaded_file)}")
|
||||
else:
|
||||
# 查找实际下载的文件
|
||||
for ext in ["mp4", "flv", "webm", "mkv"]:
|
||||
candidate = os.path.join(media_dir, f"{filename}.{ext}")
|
||||
if os.path.exists(candidate):
|
||||
result_files["video"] = candidate
|
||||
break
|
||||
|
||||
return result_files if result_files["video"] else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"下载失败 [{material['id']}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="口播素材媒体下载")
|
||||
parser.add_argument("--limit", "-n", type=int, default=0, help="下载数量上限,0=全部")
|
||||
parser.add_argument("--all", action="store_true", help="下载所有 worth_downloading=yes 的素材")
|
||||
parser.add_argument("--url", "-u", help="手动指定 URL 下载(不查数据库)")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config()
|
||||
logger = setup_logger("download")
|
||||
|
||||
db_path = init_db()
|
||||
db = get_db(db_path)
|
||||
|
||||
if args.url:
|
||||
# 手动 URL 下载
|
||||
material = {
|
||||
"id": 0,
|
||||
"url": args.url,
|
||||
"platform": "manual",
|
||||
"title": "手动下载",
|
||||
"author": "manual",
|
||||
}
|
||||
result = download_one(material, config, logger)
|
||||
if result:
|
||||
print(f"[OK] 下载完成: {result}")
|
||||
else:
|
||||
print("[FAIL] 下载失败")
|
||||
db.close()
|
||||
return
|
||||
|
||||
# 查询待下载记录
|
||||
if args.all:
|
||||
# 下载所有 AI 推荐的
|
||||
query = """
|
||||
SELECT m.* FROM materials m
|
||||
JOIN analysis a ON m.id = a.material_id
|
||||
WHERE a.worth_downloading = 'yes' AND m.download_status = 'none'
|
||||
"""
|
||||
else:
|
||||
query = "SELECT * FROM materials WHERE download_status = 'pending'"
|
||||
|
||||
if args.limit > 0:
|
||||
query += f" LIMIT {args.limit}"
|
||||
|
||||
materials = db.execute(query).fetchall()
|
||||
materials = [dict(m) for m in materials]
|
||||
|
||||
if not materials:
|
||||
print("[OK] 没有待下载的素材")
|
||||
db.close()
|
||||
return
|
||||
|
||||
print(f"[INFO] 待下载: {len(materials)} 条")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, material in enumerate(materials, 1):
|
||||
print(f"[{i}/{len(materials)}] 下载: {material['title'][:40]}")
|
||||
|
||||
# 标记为下载中
|
||||
db.execute("UPDATE materials SET download_status = 'downloading' WHERE id = ?", (material["id"],))
|
||||
db.commit()
|
||||
|
||||
result = download_one(material, config, logger)
|
||||
|
||||
if result and result.get("video"):
|
||||
db.execute("""
|
||||
UPDATE materials
|
||||
SET download_status = 'done', local_files = ?
|
||||
WHERE id = ?
|
||||
""", (json.dumps(result, ensure_ascii=False), material["id"]))
|
||||
db.commit()
|
||||
success += 1
|
||||
else:
|
||||
db.execute("UPDATE materials SET download_status = 'failed' WHERE id = ?", (material["id"],))
|
||||
db.commit()
|
||||
failed += 1
|
||||
|
||||
db.close()
|
||||
print(f"\n[DONE] 下载完成: 成功 {success}, 失败 {failed}, 共 {len(materials)} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# OpenCLI 安装脚本 — 新机器一键部署
|
||||
set -e
|
||||
echo "=== OpenCLI 安装脚本 ==="
|
||||
echo ""
|
||||
|
||||
NODE=$(which node 2>/dev/null || echo "/Users/chengzige/.workbuddy/binaries/node/versions/22.12.0/bin/node")
|
||||
if [ ! -x "$NODE" ]; then echo "❌ Node.js 未找到"; exit 1; fi
|
||||
echo " Node: $($NODE --version)"
|
||||
|
||||
echo "[2/4] 安装 OpenCLI..."
|
||||
npm install -g @jackwener/opencli 2>&1 | tail -1
|
||||
|
||||
echo "[3/4] 下载 Chrome 扩展..."
|
||||
EXT_DIR="$HOME/opencli-extension"
|
||||
if [ -f "$EXT_DIR/manifest.json" ]; then
|
||||
echo " 扩展已存在,跳过下载"
|
||||
else
|
||||
mkdir -p "$EXT_DIR"
|
||||
curl -L -o /tmp/opencli-ext.zip "https://github.com/jackwener/OpenCLI/releases/download/v1.7.12/opencli-extension-v1.0.5.zip"
|
||||
unzip -o /tmp/opencli-ext.zip -d "$EXT_DIR" 2>&1 | tail -1
|
||||
echo " ✅ 下载完成: $EXT_DIR"
|
||||
fi
|
||||
|
||||
echo "[4/4] 启动 daemon..."
|
||||
opencli daemon restart 2>&1 | head -1
|
||||
|
||||
echo ""
|
||||
echo "=== 完成 ==="
|
||||
echo "下一步:"
|
||||
echo " 1. 打开 Chrome → chrome://extensions/"
|
||||
echo " 2. 开启开发者模式"
|
||||
echo " 3. 加载已解包扩展 → 选择 $EXT_DIR"
|
||||
echo " 4. 验证: opencli doctor"
|
||||
@@ -0,0 +1,11 @@
|
||||
# 口播素材系统 Python 依赖
|
||||
# 安装:~/.workbuddy/binaries/python/envs/agent-os/bin/pip install -r requirements.txt
|
||||
|
||||
# 必需(通常已随 AgentOS 安装)
|
||||
httpx>=0.24.0
|
||||
PyYAML>=6.0
|
||||
Pillow>=9.0
|
||||
|
||||
# 需要额外安装
|
||||
gradio>=4.0
|
||||
yt-dlp>=2024.0
|
||||
@@ -0,0 +1,74 @@
|
||||
-- ============================================================
|
||||
-- 口播素材系统数据库初始化
|
||||
-- 使用:sqlite3 data/database.db < schema.sql
|
||||
-- ============================================================
|
||||
|
||||
-- 开启 WAL 模式,提高并发读取
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- 素材元数据表
|
||||
CREATE TABLE IF NOT EXISTS materials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL, -- 平台:xiaohongshu / douyin / bilibili
|
||||
original_id TEXT NOT NULL, -- 平台内唯一 ID
|
||||
url TEXT, -- 原始链接
|
||||
title TEXT, -- 标题
|
||||
description TEXT, -- 描述/正文
|
||||
author TEXT, -- 作者
|
||||
author_id TEXT, -- 作者 ID
|
||||
cover_url TEXT, -- 封面图 URL
|
||||
cover_local TEXT, -- 本地封面图路径
|
||||
music_name TEXT, -- BGM 名称
|
||||
tags_platform TEXT, -- 平台自带标签(逗号分隔)
|
||||
duration INTEGER, -- 时长(秒)
|
||||
like_count INTEGER DEFAULT 0, -- 点赞数
|
||||
collect_count INTEGER DEFAULT 0, -- 收藏数
|
||||
comment_count INTEGER DEFAULT 0, -- 评论数
|
||||
share_count INTEGER DEFAULT 0, -- 分享数
|
||||
raw_json TEXT, -- 原始 JSON 数据(完整备份)
|
||||
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 采集时间
|
||||
analyzed INTEGER DEFAULT 0, -- 0:未分析 1:已分析 2:分析失败
|
||||
download_status TEXT DEFAULT 'none', -- none / pending / downloading / done / failed
|
||||
local_files TEXT, -- JSON: {"video":"path","audio":"path"}
|
||||
notes TEXT -- 用户备注
|
||||
);
|
||||
|
||||
-- 平台+ID 联合唯一索引(去重)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_platform_id ON materials(platform, original_id);
|
||||
|
||||
-- 查询常用索引
|
||||
CREATE INDEX IF NOT EXISTS idx_analyzed ON materials(analyzed);
|
||||
CREATE INDEX IF NOT EXISTS idx_download_status ON materials(download_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform ON materials(platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_fetched_at ON materials(fetched_at);
|
||||
|
||||
-- AI 分析结果表
|
||||
CREATE TABLE IF NOT EXISTS analysis (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
material_id INTEGER UNIQUE NOT NULL, -- 关联素材
|
||||
tags TEXT, -- AI 标签(逗号分隔)
|
||||
golden_quote TEXT, -- 可引用金句
|
||||
core_idea TEXT, -- 核心立意
|
||||
structure TEXT, -- 口播脚本结构
|
||||
emotion TEXT, -- 整体情绪
|
||||
worth_downloading TEXT, -- yes / no / maybe
|
||||
download_reason TEXT, -- 下载理由
|
||||
raw_model_output TEXT, -- 模型原始返回(调试)
|
||||
analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 采集批次记录表
|
||||
CREATE TABLE IF NOT EXISTS collect_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 0, -- 本次采集数量
|
||||
jsonl_file TEXT, -- JSONL 文件路径
|
||||
status TEXT DEFAULT 'pending', -- pending / running / done / failed
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_status ON collect_batches(status);
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
AVE v2.0 脚本工厂 — 从素材生成导演脚本
|
||||
"""
|
||||
import os, sys, json, yaml, argparse, datetime, urllib.request, re
|
||||
from pathlib import Path
|
||||
|
||||
OUTPUT_DIR = Path(__file__).parent / "scripts_output"
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
OMLX_URL = "http://localhost:8000/v1/chat/completions"
|
||||
|
||||
def ai(prompt, tokens=2000, temp=0.3):
|
||||
body = json.dumps({"model": "Qwen3-8B-MLX-4bit", "messages": [{"role":"user","content":prompt}], "max_tokens":tokens, "temperature":temp}).encode()
|
||||
req = urllib.request.Request(OMLX_URL, data=body, headers={"Authorization":"Bearer omlx","Content-Type":"application/json"}, method="POST")
|
||||
return json.loads(urllib.request.urlopen(req, timeout=300).read())["choices"][0]["message"]["content"]
|
||||
|
||||
def analyze(title, desc):
|
||||
r = ai(f"""分析以下内容,输出JSON:
|
||||
标题:{title}
|
||||
描述:{desc}
|
||||
输出:{{"core_topic":"一句话主题","target_audience":"目标人群","hook_type":"钩子类型","key_points":["点1","点2"],"emotional_tone":"情绪基调","suggested_style":"knowledge_lecture/bedtime_story/funny_talk/tech_review"}}""", 800, 0.3)
|
||||
m = re.search(r'\{.*\}', r, re.DOTALL)
|
||||
return json.loads(m.group()) if m else {"core_topic":title,"key_points":[]}
|
||||
|
||||
def segments(analysis, style):
|
||||
kp = analysis.get("key_points",[])
|
||||
pts = "\n".join([f"- {p}" for p in kp]) if kp else ""
|
||||
r = ai(f"""你是一个短视频脚本专家。生成分段口播脚本。
|
||||
|
||||
主题:{analysis.get("core_topic","")}
|
||||
要点:{pts}
|
||||
风格:{style}
|
||||
|
||||
字段:id(数字), text(口播文本), duration_sec(秒), voice_emotion(语气), camera(镜头), bgm_section(intro/main/climax/outro), material.search(英文搜索词), subtitles(true/false)
|
||||
|
||||
要求:4-6段,总60-90秒。首段5-8秒钩子,末段总结。
|
||||
|
||||
输出JSON数组:
|
||||
[{{"id":1,"text":"...","duration_sec":8,"voice_emotion":"开场悬念","camera":"slow_zoom_in","bgm_section":"intro","material":{{"search":"..."}},"subtitles":true}}]""", 2000, 0.3)
|
||||
m = re.search(r'\[.*?\]', r, re.DOTALL)
|
||||
segs = json.loads(m.group()) if m else []
|
||||
for i,s in enumerate(segs):
|
||||
s["id"]=i+1
|
||||
bgm=str(s.get("bgm_section","")).lower()
|
||||
for v in ["intro","main","climax","outro"]:
|
||||
if v in bgm: s["bgm_section"]=v; break
|
||||
else: s["bgm_section"]="main"
|
||||
return segs
|
||||
|
||||
def run(topic, style=None):
|
||||
a = analyze(topic, topic)
|
||||
s = style or a.get("suggested_style","knowledge_lecture")
|
||||
segs = segments(a, s) or [{"id":1,"text":topic,"duration_sec":30,"voice_emotion":"正常讲述","camera":"medium_shot","bgm_section":"main","material":{"search":topic},"subtitles":True}]
|
||||
pid = f"vid_{datetime.date.today().strftime('%Y%m%d')}_{int(datetime.datetime.now().timestamp())%10000:04d}"
|
||||
y = {"meta":{"project_id":pid,"style":s,"voice":{"provider":"volcano","voice_id":"my_voice","default_emotion":a.get("emotional_tone","专业沉稳")},"bgm":{"provider":"ace_step","genre":"cinematic","tempo":110,"mood":"inspirational","structure":[{"section":"intro"},{"section":"main"},{"section":"climax"},{"section":"outro"}]},"output":{"resolution":"1080x1920","fps":30},"avatar":{"image_url":"","provider":"wan2.2_s2v","default_position":"bottom_right"},"material":{"fallback":True}},"segments":segs}
|
||||
yp = OUTPUT_DIR/f"{pid}.yaml"; mp = OUTPUT_DIR/f"{pid}.md"
|
||||
with open(yp,"w",encoding="utf-8") as f: yaml.dump(y,f,allow_unicode=True,default_flow_style=False,sort_keys=False)
|
||||
md = f"""# {pid} — {a.get('core_topic','')}
|
||||
|
||||
> 由 AVE 脚本工厂自动生成 | {datetime.date.today().isoformat()}
|
||||
|
||||
## 来源信息
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 核心主题 | {a.get('core_topic','-')} |
|
||||
| 目标人群 | {a.get('target_audience','-')} |
|
||||
| 风格 | {s} | 钩子 | {a.get('hook_type','-')} | 情绪 | {a.get('emotional_tone','-')} |
|
||||
|
||||
## 关键要点
|
||||
{chr(10).join([f'- {p}' for p in a.get('key_points',[])]) if a.get('key_points') else '- (AI分析中)'}
|
||||
|
||||
## 生产备注
|
||||
- **语音**: 火山引擎 **BGM**: Ace Studio **素材**: Pexels搜索+本地兜底 **分辨率**: 1080×1920竖屏
|
||||
- **注意**: 脚本为AI生成,建议人工审核。配画关键词可能需要手动调整。
|
||||
"""
|
||||
with open(mp,"w",encoding="utf-8") as f: f.write(md)
|
||||
print(f"✅ {pid}: {len(segs)}段 | {yp} | {mp}")
|
||||
return yp, mp
|
||||
|
||||
if __name__=="__main__":
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument("--topic", required=True); p.add_argument("--style")
|
||||
args=p.parse_args()
|
||||
run(args.topic, args.style)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# vid_20260504_8817 — 毛主席的语言艺术将失败转化为传奇
|
||||
|
||||
> 由 AVE 脚本工厂自动生成 | 2026-05-04
|
||||
|
||||
## 来源信息
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 核心主题 | 毛主席的语言艺术将失败转化为传奇 |
|
||||
| 目标人群 | 历史爱好者/文学研究者/毛泽东思想研究者 |
|
||||
| 风格 | knowledge_lecture | 钩子 | 故事性钩子 | 情绪 | 崇敬与赞叹 |
|
||||
|
||||
## 关键要点
|
||||
- 通过命名赋予历史事件新的意义
|
||||
- 语言重构改变了历史叙事的维度
|
||||
|
||||
## 生产备注
|
||||
- **语音**: 火山引擎 **BGM**: Ace Studio **素材**: Pexels搜索+本地兜底 **分辨率**: 1080×1920竖屏
|
||||
- **注意**: 脚本为AI生成,建议人工审核。配画关键词可能需要手动调整。
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
meta:
|
||||
project_id: vid_20260504_8817
|
||||
style: knowledge_lecture
|
||||
voice:
|
||||
provider: volcano
|
||||
voice_id: my_voice
|
||||
default_emotion: 崇敬与赞叹
|
||||
bgm:
|
||||
provider: ace_step
|
||||
genre: cinematic
|
||||
tempo: 110
|
||||
mood: inspirational
|
||||
structure:
|
||||
- section: intro
|
||||
- section: main
|
||||
- section: climax
|
||||
- section: outro
|
||||
output:
|
||||
resolution: 1080x1920
|
||||
fps: 30
|
||||
avatar:
|
||||
image_url: ''
|
||||
provider: wan2.2_s2v
|
||||
default_position: bottom_right
|
||||
material:
|
||||
fallback: true
|
||||
segments:
|
||||
- id: 1
|
||||
text: 当历史的尘埃落定,某些失败却因一句命名而成为传奇。比如‘井冈山’——这个原本只是地理坐标的名字,被赋予了‘革命摇篮’的象征意义。
|
||||
duration_sec: 8
|
||||
voice_emotion: 开场悬念
|
||||
camera: slow_zoom_in
|
||||
bgm_section: intro
|
||||
material:
|
||||
search: 命名赋予历史意义
|
||||
subtitles: true
|
||||
- id: 2
|
||||
text: 毛主席擅长用语言重构历史维度。他将‘长征’从单纯的军事撤退,转化为‘战略转移’的史诗叙事,让失败成为民族精神的转折点。
|
||||
duration_sec: 12
|
||||
voice_emotion: 理性分析
|
||||
camera: close_up
|
||||
bgm_section: main
|
||||
material:
|
||||
search: 语言重构历史叙事
|
||||
subtitles: true
|
||||
- id: 3
|
||||
text: 这种叙事重构不仅改变历史解释框架,更塑造了集体记忆。‘延安’从军事据点升华为‘精神圣地’,其背后是语言对历史事件的重新编码与价值赋予。
|
||||
duration_sec: 15
|
||||
voice_emotion: 深度解析
|
||||
camera: montage
|
||||
bgm_section: climax
|
||||
material:
|
||||
search: 历史叙事重构案例
|
||||
subtitles: true
|
||||
- id: 4
|
||||
text: 毛主席的语言艺术,本质是用精准的符号系统将失败转化为文化资本。这种转化让历史事件超越时间维度,成为民族精神的永恒坐标。
|
||||
duration_sec: 10
|
||||
voice_emotion: 总结升华
|
||||
camera: wide_angle
|
||||
bgm_section: outro
|
||||
material:
|
||||
search: 失败转化为传奇案例
|
||||
subtitles: true
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# vid_20260505_em001 — 我爸给我打了20年电话,我从来没接过
|
||||
|
||||
> 由人工创作 + 参考今日头条情感爆款结构 | 2026-05-05
|
||||
|
||||
---
|
||||
|
||||
## 一、来源信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 故事类型 | 亲情/父子/子欲养而亲不待 |
|
||||
| 参考对象 | 今日头条情感类爆款《我妈骂了我爸四十年》 |
|
||||
| 风格定位 | bedtime_story(深夜情感故事) |
|
||||
| 预计时长 | 约 100 秒(1分40秒) |
|
||||
| 核心情绪 | 遗憾、愧疚、珍惜 |
|
||||
|
||||
## 二、结构拆解
|
||||
|
||||
| 段落 | 时长 | 内容 | 作用 |
|
||||
|------|------|------|------|
|
||||
| ① 钩子 | 12s | 最后一次通话,三个未接来电 | 悬念开场,一句话抓住 |
|
||||
| ② 背景 | 18s | 父亲是农民工,30年绑钢筋 | 建立人物形象和时代感 |
|
||||
| ③ 铺垫 | 14s | 二十年如一日不变的电话 | 积累情感,制造"我"的不懂事 |
|
||||
| ④ 高潮积累 | 22s | 北京实习,父亲寄钱 | 父爱的极致表现——他其实知道 |
|
||||
| ⑤ 高潮爆发 | 20s | 三个未接来电,再没有第四通 | 全篇最高泪点 |
|
||||
| ⑥ 余韵 | 15s | 梦中重逢的对话 | 给观众消化时间 |
|
||||
| ⑦ 金句收尾 | 10s | 别让那句"吃饭了吗"变成等不到的电话 | 总结升华,带传播力 |
|
||||
|
||||
## 三、口播脚本与标注
|
||||
|
||||
详见 YAML 文件 `vid_20260505_em001.yaml`,每段已标注:
|
||||
- `voice_emotion` — 语气/情绪提示
|
||||
- `camera` — 镜头语言建议
|
||||
- `bgm_section` — 配乐段落
|
||||
- `material.search` — 配画关键词
|
||||
|
||||
## 四、可复用的口播技法
|
||||
|
||||
| 技法 | 在本篇中的应用 |
|
||||
|------|---------------|
|
||||
| 具体日期 | "2018年3月12号" → 增加真实感 |
|
||||
| 数字量化 | "三个电话"、"二十年"、"三千五留五百" |
|
||||
| 场景细节 | "绑钢筋"、"下雨没活干"、"喝药酒的细节" |
|
||||
| 对话还原 | "缺钱跟爸说,爸有" |
|
||||
| 反转推进 | 以为是啰嗦,结果是最后的通话 |
|
||||
| 梦境穿插 | "梦里我坐在老家门口" |
|
||||
| 金句收尾 | 最后一句是总结也是转发点 |
|
||||
|
||||
## 五、生产备注
|
||||
|
||||
- **语音**: 火山引擎,建议用中年男声,语气稳重略低沉
|
||||
- **BGM**: 钢琴独奏或弦乐,80bpm 慢速
|
||||
- **画面**: 推荐黑白/暖色调滤镜,配合回忆感
|
||||
- **字幕**: 逐句字幕(首段+结尾金句建议关闭字幕增强冲击力)
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# ============================================
|
||||
# AVE v2.0 导演脚本
|
||||
# 我爸给我打了20年电话,我从来没接过
|
||||
# ============================================
|
||||
# 这是一个关于中国式父子关系的真实情感故事。
|
||||
|
||||
meta:
|
||||
project_id: "vid_20260505_em001"
|
||||
style: "bedtime_story" # knowledge_lecture | bedtime_story | funny_talk | tech_review
|
||||
|
||||
voice:
|
||||
provider: "volcano"
|
||||
voice_id: "my_voice"
|
||||
default_emotion: "深情回忆,略带克制"
|
||||
|
||||
bgm:
|
||||
provider: "ace_step"
|
||||
genre: "cinematic"
|
||||
tempo: 80
|
||||
mood: "nostalgic"
|
||||
structure:
|
||||
- section: intro # 0-20s
|
||||
- section: main # 20-120s
|
||||
- section: climax # 120-220s
|
||||
- section: outro # 220-280s
|
||||
|
||||
output:
|
||||
resolution: "1080x1920"
|
||||
fps: 30
|
||||
|
||||
avatar:
|
||||
image_url: ""
|
||||
provider: "wan2.2_s2v"
|
||||
default_position: "bottom_right"
|
||||
|
||||
material:
|
||||
fallback: true
|
||||
|
||||
# ——— 分段指令 ———
|
||||
segments:
|
||||
- id: 1
|
||||
text: "我跟我爸的最后一次通话,是2018年3月12号。那天他打了三个电话,我一个都没接。后来我再也没有机会接了。"
|
||||
duration_sec: 12
|
||||
voice_emotion: "平静开场,语速缓慢"
|
||||
camera: "slow_zoom_in"
|
||||
bgm_section: "intro"
|
||||
material:
|
||||
search: "old father working construction site, cinematic"
|
||||
subtitles: true
|
||||
|
||||
- id: 2
|
||||
text: "我爸是个农民工,在工地绑钢筋,一绑就是三十年。我从小最怕两件事:一个是开学,因为要交学费;一个是下雨,因为一下雨他就没活干,没活干他就喝酒,一喝酒他就会打电话回来。"
|
||||
duration_sec: 18
|
||||
voice_emotion: "正常叙述,略带苦涩"
|
||||
camera: "medium_shot"
|
||||
bgm_section: "main"
|
||||
material:
|
||||
search: "construction worker in rain, Chinese countryside"
|
||||
subtitles: true
|
||||
|
||||
- id: 3
|
||||
text: "他的电话永远是那几句:吃饭了吗?天冷了多穿点。钱够不够花?翻来覆去,二十年如一日。我嫌他啰嗦。每次看到屏幕上亮起他的名字,我就按掉。"
|
||||
duration_sec: 14
|
||||
voice_emotion: "带一丝愧疚"
|
||||
camera: "close_up"
|
||||
bgm_section: "main"
|
||||
material:
|
||||
search: "rejected phone call, lonely father"
|
||||
subtitles: true
|
||||
|
||||
- id: 4
|
||||
text: "2012年我在北京实习,他打电话问我过得好不好。我说挺好的,公司管吃管住。他说你别骗我。我说真的挺好。他沉默了几秒,说:缺钱跟爸说,爸有。我知道他没有。他一个月工资三千五,自己留五百,剩下三千全打到我卡上。我从来没说过谢谢。"
|
||||
duration_sec: 22
|
||||
voice_emotion: "逐渐哽咽,真情流露"
|
||||
camera: "slow_pan_right"
|
||||
bgm_section: "climax"
|
||||
material:
|
||||
search: "father sends money to son, love letter"
|
||||
subtitles: true
|
||||
|
||||
- id: 5
|
||||
text: "2018年3月12号。他打了三个电话。第一个我在开会,按掉了。第二个我在跟朋友吃饭,没接。第三个晚上九点,我想着明天回吧。然后就再也没有第四通了。那天晚上他从脚手架上摔了下来。我妈说他的手机最后停留在我的通话记录上。三个未接来电。全是打给我的。"
|
||||
duration_sec: 20
|
||||
voice_emotion: "压抑悲痛,声音微颤"
|
||||
camera: "hand_held"
|
||||
bgm_section: "climax"
|
||||
material:
|
||||
search: "phone with missed calls, tragedy at construction site"
|
||||
subtitles: true
|
||||
|
||||
- id: 6
|
||||
text: "我后来经常做一个梦。梦里我坐在老家门口,远远看见他走过来。他走到跟前,笑着问我:吃饭了吗?我在梦里说:吃了,爸。你呢?然后我就醒了。醒来发现枕头是湿的。"
|
||||
duration_sec: 15
|
||||
voice_emotion: "深情追忆,轻声"
|
||||
camera: "top_down"
|
||||
bgm_section: "outro"
|
||||
material:
|
||||
search: "wet pillow morning, father son reunion dream"
|
||||
subtitles: true
|
||||
|
||||
- id: 7
|
||||
text: "有些电话,你永远等不到下一个了。别让你爸妈的那句吃饭了吗,变成你余生都接不到的那通电话。"
|
||||
duration_sec: 10
|
||||
voice_emotion: "坚定有力,字字清晰"
|
||||
camera: "slow_zoom_in"
|
||||
bgm_section: "outro"
|
||||
material:
|
||||
search: "empty chair, sunset, father and son silhouette"
|
||||
subtitles: false
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# 豆包持久化浏览器登录设置
|
||||
# 运行此脚本后,在弹出的浏览器中登录豆包,然后关掉浏览器即可
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
echo "=== 豆包持久化浏览器登录设置 ==="
|
||||
echo ""
|
||||
echo "即将打开浏览器窗口..."
|
||||
echo "请在浏览器中登录你的豆包账号"
|
||||
echo "登录完成后,关掉浏览器窗口即可"
|
||||
echo "以后系统将自动复用本次登录态"
|
||||
echo ""
|
||||
|
||||
/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3 -c "
|
||||
from doubao_driver import DoubaoDriver
|
||||
import time
|
||||
|
||||
d = DoubaoDriver()
|
||||
print('启动持久化浏览器...')
|
||||
d._start_persistent()
|
||||
d.page.goto('https://www.doubao.com/chat', timeout=30000)
|
||||
time.sleep(2)
|
||||
|
||||
print('浏览器已打开,请登录豆包...')
|
||||
print('关掉浏览器窗口后继续...')
|
||||
|
||||
# 等待浏览器关闭(context 断开连接)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(2)
|
||||
try:
|
||||
_ = d.page.title
|
||||
except:
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
print('浏览器已关闭,session 已保存')
|
||||
print('位置: ~/.cloakbrowser/doubao_profile/')
|
||||
"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# 豆包驱动测试脚本
|
||||
# 执行前请确保已在 Chrome 中登录 doubao.com
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 豆包驱动测试 ==="
|
||||
echo ""
|
||||
echo "请先确认:Chrome 已登录 doubao.com ✅"
|
||||
read -p "按回车继续..."
|
||||
|
||||
echo ""
|
||||
echo "测试 1: 打开豆包并检查登录状态..."
|
||||
/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3 -c "
|
||||
from doubao_driver import DoubaoDriver
|
||||
d = DoubaoDriver()
|
||||
d._start_browser()
|
||||
d.page.goto('https://www.doubao.com/chat', timeout=30000)
|
||||
ok = d._ensure_logged_in()
|
||||
print(f'登录状态: {\"✅ 已登录\" if ok else \"❌ 未登录\"}')
|
||||
if ok:
|
||||
inp = d._find_input()
|
||||
print(f'输入框: {\"✅ 找到\" if inp else \"❌ 未找到\"}')
|
||||
if inp:
|
||||
print(f' placeholder: {inp.get_attribute(\"placeholder\")}')
|
||||
d._close_browser()
|
||||
" 2>&1
|
||||
|
||||
echo ""
|
||||
echo "完成"
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
口播素材系统 - 通用工具函数
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录(本文件所在目录的父级)
|
||||
PROJECT_ROOT = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""加载 config.yaml 配置文件"""
|
||||
import yaml
|
||||
config_path = PROJECT_ROOT / "config.yaml"
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {config_path}")
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
# 展开路径中的 ~
|
||||
for key in ("media_dir", "audio_dir", "raw_dir", "db_path", "logs_dir"):
|
||||
if key in config.get("storage", {}):
|
||||
config["storage"][key] = os.path.expanduser(config["storage"][key])
|
||||
return config
|
||||
|
||||
|
||||
def get_db_path(config: dict = None) -> str:
|
||||
"""获取数据库文件路径"""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
db_path = config["storage"]["db_path"]
|
||||
if not os.path.isabs(db_path):
|
||||
db_path = str(PROJECT_ROOT / db_path)
|
||||
return db_path
|
||||
|
||||
|
||||
def get_db(db_path: str = None) -> sqlite3.Connection:
|
||||
"""获取数据库连接(开启 WAL 模式)"""
|
||||
if db_path is None:
|
||||
db_path = get_db_path()
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: str = None):
|
||||
"""初始化数据库(执行 schema.sql)"""
|
||||
if db_path is None:
|
||||
db_path = get_db_path()
|
||||
schema_path = PROJECT_ROOT / "schema.sql"
|
||||
conn = get_db(db_path)
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
def setup_logger(name: str, log_file: str = None, config: dict = None) -> logging.Logger:
|
||||
"""配置日志"""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
if log_file is None:
|
||||
logs_dir = config["storage"].get("logs_dir", "logs")
|
||||
if not os.path.isabs(logs_dir):
|
||||
logs_dir = str(PROJECT_ROOT / logs_dir)
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
log_file = os.path.join(logs_dir, f"{name}_{datetime.now():%Y%m%d}.log")
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
# 避免重复添加 handler
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
sh.setLevel(logging.INFO)
|
||||
logger.addHandler(fh)
|
||||
logger.addHandler(sh)
|
||||
return logger
|
||||
|
||||
|
||||
def parse_jsonl(file_path: str) -> list[dict]:
|
||||
"""逐行读取 JSONL 文件,返回字典列表"""
|
||||
records = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line_no, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[WARN] JSONL 第 {line_no} 行解析失败: {e}")
|
||||
return records
|
||||
|
||||
|
||||
def save_jsonl(records: list[dict], file_path: str, append: bool = True):
|
||||
"""写入 JSONL 文件"""
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
mode = "a" if append else "w"
|
||||
with open(file_path, mode, encoding="utf-8") as f:
|
||||
for rec in records:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def truncate_text(text: str, max_len: int = 20, ellipsis: str = "") -> str:
|
||||
"""截断文本用于文件名"""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
# 去除不合法文件名字符
|
||||
import re
|
||||
text = re.sub(r'[<>:"|?*]', '', text)
|
||||
if len(text) > max_len:
|
||||
return text[:max_len] + ellipsis
|
||||
return text
|
||||
|
||||
|
||||
def check_omlx(config: dict = None) -> bool:
|
||||
"""检查 oMLX 是否可用"""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
import httpx
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{config['llm']['base_url']}/models",
|
||||
headers={"Authorization": f"Bearer {config['llm']['api_key']}"},
|
||||
timeout=5
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
models = [m["id"] for m in resp.json().get("data", [])]
|
||||
target = config["llm"]["model"]
|
||||
if target in models:
|
||||
print(f"[OK] oMLX 可用,模型: {target}")
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] oMLX 可用但缺少模型 {target},已有: {models}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[WARN] oMLX 不可用: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LongCat 每日免费额度自动领取 v3.3
|
||||
Camoufox persistent_context 模式,Cookie 持久化到 Profile 目录。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from camoufox import AsyncCamoufox
|
||||
|
||||
# 领取按钮匹配关键词
|
||||
CLAIM_KEYWORDS = ['立即申请', '申请', '领取', 'claim', '获取额度']
|
||||
# 点击策略:每 300ms 轮询,持续 65 秒
|
||||
CLICK_INTERVAL_MS = 300
|
||||
CLICK_DURATION_S = 65
|
||||
|
||||
|
||||
async def claim(args: argparse.Namespace) -> int:
|
||||
profile_dir = Path(args.profile)
|
||||
if not profile_dir.is_absolute():
|
||||
profile_dir = Path.cwd() / profile_dir
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
headless = args.auto
|
||||
|
||||
print(f"[LongCat Claimer v3.3]")
|
||||
print(f" Profile: {profile_dir}")
|
||||
print(f" Headless: {headless}")
|
||||
|
||||
exit_code = 1
|
||||
|
||||
async with AsyncCamoufox(
|
||||
headless=headless,
|
||||
persistent_context=True,
|
||||
user_data_dir=str(profile_dir),
|
||||
proxy={'server': 'socks5://127.0.0.1:10800'},
|
||||
) as ctx:
|
||||
page = await ctx.new_page()
|
||||
|
||||
try:
|
||||
# 1. 打开 longcat.chat
|
||||
print("[1/3] 打开 longcat.chat ...")
|
||||
await page.goto('https://longcat.chat', wait_until='domcontentloaded', timeout=30000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 2. 检查登录状态
|
||||
content = await page.content()
|
||||
if 'Log In' in content and 'Log Out' not in content:
|
||||
print("[FAIL] 未登录 — Cookie 已过期,需手动登录 longcat.chat")
|
||||
# 截图留档
|
||||
await page.screenshot(path=str(profile_dir / 'login_fail.png'))
|
||||
return 1
|
||||
|
||||
login_indicators = ['登录', 'Sign In', 'signin']
|
||||
if any(ind in content for ind in login_indicators) and '开始对话' not in content:
|
||||
print("[FAIL] 检测到登录页面,Cookie 已失效")
|
||||
await page.screenshot(path=str(profile_dir / 'login_fail.png'))
|
||||
return 1
|
||||
|
||||
print("[OK] 已登录")
|
||||
|
||||
# 3. 激进点击策略
|
||||
print(f"[2/3] 开始点击领取({CLICK_DURATION_S}s 内每 {CLICK_INTERVAL_MS}ms 轮询)...")
|
||||
clicked = False
|
||||
total_ticks = int(CLICK_DURATION_S * 1000 / CLICK_INTERVAL_MS)
|
||||
|
||||
for i in range(total_ticks):
|
||||
for kw in CLAIM_KEYWORDS:
|
||||
try:
|
||||
btn = page.locator(f'button:has-text("{kw}")').first
|
||||
if await btn.is_visible(timeout=100):
|
||||
await btn.click(force=True)
|
||||
print(f"[3/3] ✅ 已点击 '{kw}' 按钮!")
|
||||
clicked = True
|
||||
exit_code = 0
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 也尝试 a 标签和 div 按钮
|
||||
el = page.locator(f'a:has-text("{kw}"), div:has-text("{kw}")').first
|
||||
if await el.is_visible(timeout=50):
|
||||
await el.click(force=True)
|
||||
print(f"[3/3] ✅ 已点击 '{kw}' 元素!")
|
||||
clicked = True
|
||||
exit_code = 0
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if clicked:
|
||||
break
|
||||
|
||||
if i % 20 == 0 and i > 0:
|
||||
print(f" 已轮询 {i * CLICK_INTERVAL_MS / 1000:.0f}s ...")
|
||||
await asyncio.sleep(CLICK_INTERVAL_MS / 1000)
|
||||
|
||||
if not clicked:
|
||||
print("[FAIL] 未找到任何领取按钮")
|
||||
await page.screenshot(path=str(profile_dir / 'no_button.png'))
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {e}")
|
||||
await page.screenshot(path=str(profile_dir / 'error.png'))
|
||||
exit_code = 1
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='LongCat 每日免费额度自动领取')
|
||||
parser.add_argument('--auto', action='store_true', help='无头模式(自动化运行)')
|
||||
parser.add_argument('--account', default='longcat_185', help='账号名(对应 profiles/<account>/)')
|
||||
parser.add_argument('--profile', default=None, help='自定义 Profile 路径')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.profile is None:
|
||||
args.profile = f'profiles/{args.account}'
|
||||
|
||||
exit_code = asyncio.run(claim(args))
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SOCKS5 本地转发器
|
||||
把本地无鉴权 SOCKS5 → 转发到远程有鉴权 SOCKS5
|
||||
解决 Firefox/Camoufox 不支持 SOCKS5 密码认证的问题
|
||||
|
||||
用法: python socks5_forwarder.py [本地端口]
|
||||
启动后修改 identity 的 proxy 为 socks5://127.0.0.1:10800
|
||||
"""
|
||||
import asyncio, sys, struct, os, signal
|
||||
|
||||
# ─── 远程代理(天行) ───
|
||||
REMOTE_HOST = "36.212.9.145"
|
||||
REMOTE_PORT = 3570
|
||||
REMOTE_USER = "iiiaaazh123"
|
||||
REMOTE_PASS = "wqshyuwftc!@#123"
|
||||
|
||||
# ─── 本地监听 ───
|
||||
LOCAL_HOST = "127.0.0.1"
|
||||
LOCAL_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 10800
|
||||
|
||||
async def socks5_forward(client_reader, client_writer):
|
||||
"""处理一个 SOCKS5 客户端连接"""
|
||||
peer = client_writer.get_extra_info('peername')
|
||||
try:
|
||||
# ── 1. 握手 ──
|
||||
greeting = await client_reader.readexactly(2)
|
||||
ver, nmethods = struct.unpack('!BB', greeting)
|
||||
methods = await client_reader.readexactly(nmethods)
|
||||
# 回应:支持无鉴权
|
||||
client_writer.write(struct.pack('!BB', 0x05, 0x00))
|
||||
await client_writer.drain()
|
||||
|
||||
# ── 2. 请求 ──
|
||||
request = await client_reader.readexactly(4)
|
||||
ver, cmd, rsv, atyp = struct.unpack('!BBBB', request)
|
||||
if cmd != 0x01: # 只支持 CONNECT
|
||||
client_writer.write(struct.pack('!BBBB', 0x05, 0x07, 0x00, 0x01) + b'\x00' * 6)
|
||||
await client_writer.drain()
|
||||
return
|
||||
|
||||
if atyp == 0x01: # IPv4
|
||||
addr = await client_reader.readexactly(4)
|
||||
dest_host = '.'.join(str(b) for b in addr)
|
||||
elif atyp == 0x03: # 域名
|
||||
length = await client_reader.readexactly(1)
|
||||
dest_host = (await client_reader.readexactly(length[0])).decode()
|
||||
else:
|
||||
return
|
||||
|
||||
dest_port = struct.unpack('!H', await client_reader.readexactly(2))[0]
|
||||
|
||||
# ── 3. 连接远程 SOCKS5(带鉴权) ───
|
||||
remote_reader, remote_writer = await asyncio.open_connection(REMOTE_HOST, REMOTE_PORT)
|
||||
|
||||
# 远程握手:用户名密码鉴权
|
||||
remote_writer.write(struct.pack('!BB', 0x05, 0x02) + struct.pack('BB', 0x00, 0x02))
|
||||
await remote_writer.drain()
|
||||
auth_resp = await remote_reader.readexactly(2)
|
||||
if auth_resp[1] == 0x02: # 需要用户名密码
|
||||
user_bytes = REMOTE_USER.encode()
|
||||
pass_bytes = REMOTE_PASS.encode()
|
||||
auth_pkt = struct.pack('!B', 0x01) + struct.pack('!B', len(user_bytes)) + user_bytes + struct.pack('!B', len(pass_bytes)) + pass_bytes
|
||||
remote_writer.write(auth_pkt)
|
||||
await remote_writer.drain()
|
||||
auth_result = await remote_reader.readexactly(2)
|
||||
if auth_result[1] != 0x00:
|
||||
raise Exception("远程鉴权失败")
|
||||
|
||||
# 发送 CONNECT 请求到远程
|
||||
if atyp == 0x01: # IPv4
|
||||
req = struct.pack('!BBBB', 0x05, 0x01, 0x00, 0x01) + addr + struct.pack('!H', dest_port)
|
||||
elif atyp == 0x03: # 域名
|
||||
host_bytes = dest_host.encode()
|
||||
req = struct.pack('!BBBB', 0x05, 0x01, 0x00, 0x03) + struct.pack('!B', len(host_bytes)) + host_bytes + struct.pack('!H', dest_port)
|
||||
|
||||
remote_writer.write(req)
|
||||
await remote_writer.drain()
|
||||
resp = await remote_reader.readexactly(4)
|
||||
if resp[1] != 0x00:
|
||||
raise Exception(f"远程连接失败: {resp[1]}")
|
||||
|
||||
# 读取剩余响应
|
||||
atyp_resp = resp[3]
|
||||
if atyp_resp == 0x01:
|
||||
await remote_reader.readexactly(6)
|
||||
elif atyp_resp == 0x03:
|
||||
length = await remote_reader.readexactly(1)
|
||||
await remote_reader.readexactly(length[0] + 2)
|
||||
|
||||
# 回复客户端成功
|
||||
client_writer.write(struct.pack('!BBBB', 0x05, 0x00, 0x00, 0x01) + b'\x00' * 6)
|
||||
await client_writer.drain()
|
||||
|
||||
# ── 4. 双向转发 ──
|
||||
async def forward(src, dst):
|
||||
try:
|
||||
while True:
|
||||
data = await src.read(65536)
|
||||
if not data:
|
||||
break
|
||||
dst.write(data)
|
||||
await dst.drain()
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try: dst.close()
|
||||
except: pass
|
||||
|
||||
await asyncio.gather(
|
||||
forward(client_reader, remote_writer),
|
||||
forward(remote_reader, client_writer),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {peer}: {e}")
|
||||
finally:
|
||||
try: client_writer.close()
|
||||
except: pass
|
||||
|
||||
async def main():
|
||||
# 打印 PID 方便后续结束进程
|
||||
print(f"socks5://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
print(f" → {REMOTE_HOST}:{REMOTE_PORT} (鉴权转发)")
|
||||
print(f" PID: {os.getpid()}")
|
||||
|
||||
server = await asyncio.start_server(socks5_forward, LOCAL_HOST, LOCAL_PORT)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n ⛔ 转发器已停止")
|
||||
@@ -0,0 +1,3 @@
|
||||
# 06_mobile/
|
||||
|
||||
此目录已清空。当前无移动端工具实现。
|
||||
@@ -0,0 +1,21 @@
|
||||
profiles/
|
||||
|
||||
# Build / Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Local config (per-machine, not synced)
|
||||
local.yaml
|
||||
# Temp artifacts
|
||||
*.png
|
||||
temp_*.json
|
||||
diagnose_ops.py
|
||||
step_by_step_*.py
|
||||
praise_all.sh
|
||||
@@ -0,0 +1 @@
|
||||
2026-04-30T11:39:39.643502+08:00
|
||||
@@ -0,0 +1 @@
|
||||
2026-04-30T11:39:39.643640+08:00
|
||||
@@ -0,0 +1,212 @@
|
||||
# Douyin 全模拟运营系统架构 v1.0
|
||||
|
||||
> 最后更新: 2026-05-15
|
||||
> 基于 AgentOS 多机协同体系
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **代码走同步,数据留本机** — 脚本在 agent-sync (Gitee),工作区在 agent-local (不参与同步)
|
||||
2. **四层隔离** — 管理层/流水线层/操作层/数据层,每层独立演进
|
||||
3. **每账号独立工作区** — 身份、素材、文案、发布记录各自独立
|
||||
4. **原子操作可组合** — 所有操作遵循 `前置锚点 → 执行 → 后置锚点` 模式
|
||||
5. **与现有养号系统共存** — 新架构不破坏 `matrix.py` / `nurture_blueprint.py`
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
~/workbuddy-agent-os/
|
||||
├── agent-sync/ ← Gitee 同步(代码/技能)
|
||||
│ └── 05_tools/
|
||||
│ └── 07_matrix/
|
||||
│ ├── ARCHITECTURE.md ← 本文档
|
||||
│ ├── scripts/
|
||||
│ │ ├── matrix.py ← 统一 CLI
|
||||
│ │ ├── atom_ops.py ← 原子操作库
|
||||
│ │ ├── nurture_blueprint.py ← 养号蓝图
|
||||
│ │ ├── douyin_ops.py ← 抖音专用操作
|
||||
│ │ ├── matrix_modules/ ← 子模块
|
||||
│ │ ├── pipelines/ ← [新] 流水线脚本
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── content_publish.py ← 内容发布流水线(待建)
|
||||
│ │ │ ├── ecom_pipeline.py ← 电商流水线(待建)
|
||||
│ │ │ └── live_pipeline.py ← 直播流水线(待建)
|
||||
│ │ └── ops/ ← 平台操作
|
||||
│ └── blueprints/
|
||||
│
|
||||
├── agent-local/ ← 本机数据(不参与同步)
|
||||
│ └── tools/
|
||||
│ └── matrix/
|
||||
│ ├── config/
|
||||
│ │ └── accounts.yaml ← 账号注册信息
|
||||
│ ├── identities/ ← 浏览器指纹+登录态
|
||||
│ │ ├── douyin_01_camo/
|
||||
│ │ ├── douyin_02_camo/
|
||||
│ │ └── douyin_camo01/
|
||||
│ └── accounts/ ← [新] 每账号工作区
|
||||
│ ├── douyin_01/
|
||||
│ │ ├── content/ ← 视频素材
|
||||
│ │ │ ├── raw/ ← 原始素材
|
||||
│ │ │ ├── edited/ ← 剪辑成品
|
||||
│ │ │ └── thumbnails/ ← 封面图
|
||||
│ │ ├── scripts/ ← 脚本文案
|
||||
│ │ │ ├── drafts/ ← 草稿
|
||||
│ │ │ └── posted/ ← 已发布
|
||||
│ │ ├── publish/ ← 发布记录
|
||||
│ │ │ ├── queue/ ← 待发布队列
|
||||
│ │ │ └── published/ ← 已发布记录
|
||||
│ │ └── backup/
|
||||
│ │ └── cookies/ ← 登录态备份
|
||||
│ ├── douyin_02/ ← 同上
|
||||
│ └── douyin_camo01/ ← 同上
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四层架构
|
||||
|
||||
### Layer 1: 数据层(本机,不同步)
|
||||
|
||||
每个账号独立文件夹,包含四类数据:
|
||||
|
||||
| 子目录 | 用途 | 数据来源 |
|
||||
|:-------|:-----|:---------|
|
||||
| `content/` | 视频素材(raw/edited/thumbnails) | 外部导入 / 剪辑 |
|
||||
| `scripts/` | 脚本文案(drafts/posted) | AI 生成 / 手动编写 |
|
||||
| `publish/` | 发布队列 + 历史记录 | 系统自动写入 |
|
||||
| `backup/` | 登录态备份 | 系统自动备份 |
|
||||
|
||||
### Layer 2: 操作层(代码,Gitee 同步)
|
||||
|
||||
原子操作(`atom_ops.py` 和 `douyin_ops.py`):
|
||||
|
||||
| 操作 | 状态 | 说明 |
|
||||
|:-----|:----:|:-----|
|
||||
| browse | ✅ | 浏览视频流 |
|
||||
| like / collect | ✅ | 点赞/收藏 |
|
||||
| comment path a | ✅ | 弹窗覆盖层评论(KeyX+Enter) |
|
||||
| comment path b | ✅ | 全屏视频页评论(scroll+click+Enter) |
|
||||
| search | ✅ | 搜索并打开 |
|
||||
| follow | ✅ | 关注 |
|
||||
| **publish_video** | ⏳ 待建 | 上传视频+文案+封面 |
|
||||
| **live_sim** | ⏳ 待建 | 直播推流模拟 |
|
||||
| **product_select** | ⏳ 待建 | 选品/带货 |
|
||||
| **dm_send** | ⏳ 待建 | 私信 |
|
||||
|
||||
### Layer 3: 流水线层(代码,Gitee 同步)
|
||||
|
||||
| 流水线 | 状态 | 操作链 |
|
||||
|:-------|:----:|:-------|
|
||||
| 养号流水线 (nurture) | ✅ | browse → interact → comment → loop |
|
||||
| 内容发布流水线 | ⏳ 待建 | 读取队列 → 登录 → 上传 → 发布 → 记录 |
|
||||
| 电商流水线 | ⏳ 待建 | 选品 → 添加到橱窗 → 推广 |
|
||||
| 直播流水线 | ⏳ 待建 | OBS推流 → 直播间互动 → 带货 |
|
||||
|
||||
### Layer 4: 管理层(规划中)
|
||||
|
||||
- 内容排期日历
|
||||
- 多账号任务调度
|
||||
- 发布成功率 KPI
|
||||
|
||||
---
|
||||
|
||||
## 两条评论路径对比
|
||||
|
||||
| 维度 | Path A: 弹窗覆盖层 | Path B: 全屏视频页 |
|
||||
|:-----|:------------------|:------------------|
|
||||
| 入口 | 精选页→点卡片→弹窗播放器 | 直接打开 `/video/xxx` URL |
|
||||
| 评论区状态 | 隐藏 (hasCL=false) → KeyX 打开 | 页面上方直接可见 |
|
||||
| 激活方式 | 坐标 (479,687) 双击 | scrollIntoView + click 输入容器 |
|
||||
| 输入方式 | pbcopy + Meta+V (共享) | pbcopy + Meta+V (共享) |
|
||||
| 发送方式 | Enter | Enter |
|
||||
| 适用场景 | 养号流程中的日常操作 | 收到指定链接后自动评论 |
|
||||
|
||||
Route 规则:
|
||||
```
|
||||
if URL 包含 /video/ → Path B
|
||||
else → 检查 hasCL
|
||||
if hasCL=true → 直接聚焦
|
||||
if hasCL=false → KeyX 打开 → Path A
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并行执行架构(v4.3 新增)
|
||||
|
||||
`mc run` 使用 `mc/engine.py` 的 `BatchEngine` 执行批量任务。v4.3 改为并行模式:
|
||||
|
||||
### 设计要点
|
||||
|
||||
1. **单账号全轮复用浏览器** — 每个账号创建一个 Camoufox 连接,跑完全部轮次再关闭
|
||||
2. **多账号 `asyncio.gather`** — 所有账号的 `_run_account_all_rounds()` 同时启动
|
||||
3. **浏览器启停优化** — N 个账号 × R 轮 → 启停 N 次(而非 N×R 次)
|
||||
|
||||
### 新旧对比
|
||||
|
||||
```
|
||||
旧版 (v1.1):
|
||||
轮1: A→B→C (串行) → 间隔 → 轮2: A→B→C → 间隔 → 轮3: A→B→C
|
||||
# 每步开闭一次浏览器,后一个等前一个完成
|
||||
# 3账号×3轮 ≈ 500-900s/批次
|
||||
|
||||
新版 (v1.2):
|
||||
A的全部3轮 ──┐
|
||||
B的全部3轮 ──┼── asyncio.gather ── 同时完成
|
||||
C的全部3轮 ──┘
|
||||
# 每账号一个持久浏览器,互不等待
|
||||
# 3账号×3轮 ≈ 150-350s/批次(约节省 60%)
|
||||
```
|
||||
|
||||
### 代码入口
|
||||
|
||||
| 方法 | 职责 |
|
||||
|:-----|:-----|
|
||||
| `BatchEngine.run()` | prepare 所有账号 → gather 子任务 → 汇总 |
|
||||
| `_run_account_all_rounds()` | 每账号:resolve → cookie 检查 → 启动浏览器 → 循环轮次 → 关闭 |
|
||||
| `run_single(conn=...)` | 单轮单账号:导航到首页 → 执行蓝图步骤 → 返回报告 |
|
||||
| `_pick_blueprint(round_idx)` | 混合随机/顺序选择本轮蓝图 |
|
||||
|
||||
### 共享连接模式
|
||||
|
||||
`run_single()` 新增 `conn` 参数:
|
||||
- `conn=None` → 自行创建并关闭浏览器(独立使用场景)
|
||||
- `conn=CDPConnector` → 复用已有连接(`_run_account_all_rounds` 内部调用)
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 并行启动多个 Camoufox 实例时,不要同时调用 `connect()` 中的 `pkill -f camoufox`
|
||||
(目前 connect() 中始终执行,但因同步调用在 async 之前完成,3 个并行的 connect() 的
|
||||
pkill 都在任何 Camoufox 启动前执行完毕,因此不会跨进程误杀)
|
||||
- `BrowserManager.prepare()` 在 `run()` 中统一调用,不在子任务中重复执行
|
||||
|
||||
|
||||
2026-05-15 修改:窗口位置固定从 `accounts.yaml` 读取,不再保存回写。
|
||||
Camoufox 启动偏移会导致保存的坐标偏离预期(如 0→652, 400→2169)。
|
||||
|
||||
| 账号 | 固定位置 |
|
||||
|:-----|:--------:|
|
||||
| douyin_01 | (0, 0) |
|
||||
| douyin_02 | (400, 0) |
|
||||
| douyin_camo01 | (750, 0) |
|
||||
|
||||
---
|
||||
|
||||
## 评论故障修复记录(2026-05-15)
|
||||
|
||||
### 问题
|
||||
养号流程中评论发送失败,日志显示 `页面出现评论: ❌`
|
||||
|
||||
### 根因
|
||||
1. **输入方式错误**: `keyboard.type()` 被 Draft.js 忽略,execCommand fallback 不触发 React 状态
|
||||
2. **发送键错误**: Alt+Enter 不再被新版抖音支持
|
||||
|
||||
### 修复
|
||||
| 文件 | 行 | 改前 | 改后 |
|
||||
|:-----|:---|:-----|:-----|
|
||||
| `runner.py` | Step 2 输入 | keyboard.type → execCommand | **pbcopy + Meta+V** |
|
||||
| `runner.py` | Step 3 发送 | Ctrl+Enter → Alt+Enter | **Enter** |
|
||||
| `runner.py` | 坐标保存 | 每次结束回写 accounts.yaml | **删除,固定读配置** |
|
||||
| `nurture_blueprint.py` | op_comment 发送 | Alt+Enter (osascript) | **keyboard.press Enter** |
|
||||
@@ -0,0 +1,120 @@
|
||||
# Matrix 系统文档索引
|
||||
|
||||
> 版本: v5.3 | 最后更新: 2026-06-13
|
||||
> 新机器部署请先看本文档,按序号阅读
|
||||
|
||||
---
|
||||
|
||||
## 一、新机器部署(必读)
|
||||
|
||||
| # | 文档 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `init_matrix.sh` | **一键部署脚本** — 检测环境→修复→启动 Dashboard |
|
||||
| 2 | `config_template/accounts.yaml` | 账号配置模板(含身份共享规范) |
|
||||
| 3 | `config_template/profiles.json` | 人设数据模板 |
|
||||
| 4 | `config_template/schedule.yaml` | 定时任务配置模板 |
|
||||
| 5 | `config_template/ai.yaml` | AI 评论生成配置模板 |
|
||||
| 6 | `start_dashboard.sh` | Dashboard 启停脚本 |
|
||||
| 7 | `fix_dashboard_launchd.sh` | launchd plist 修复脚本 |
|
||||
|
||||
## 二、核心文档
|
||||
|
||||
| # | 文档 | 说明 |
|
||||
|---|------|------|
|
||||
| 8 | `README.md` | **项目总览** — 功能、命令、架构 |
|
||||
| 9 | `TOOL.md` | **工具说明** — 版本、前置依赖、文件结构 |
|
||||
| 10 | `MODULE.md` | **模块架构** — 系统层级、文件依赖 |
|
||||
| 11 | `docs/MC_COMMAND_REFERENCE.md` | **命令参考手册** — 所有 CLI 命令详解 |
|
||||
| 12 | `docs/MATRIX_V5_GUIDE.md` | **v5 使用指南** — 完整操作指南 |
|
||||
| 13 | `docs/CORPUS_V2_PLAN.md` | 语料库架构升级方案 v2.0 |
|
||||
|
||||
## 三、配置说明
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 14 | `accounts_registry.yaml` | 账号注册表(联邦同步用) |
|
||||
| 15 | `config/accounts.yaml`(agent-local) | 本地账号配置 |
|
||||
| 16 | `config/sms.yaml` | 短信 API 配置 |
|
||||
| 17 | `config/ai.yaml` | AI 评论生成配置 |
|
||||
| 18 | `config/schedule.yaml` | 定时任务配置 |
|
||||
| 19 | `corpus/douyin.yaml` | 抖音语料库(33条) |
|
||||
| 20 | `corpus/xiaohongshu.yaml` | 小红书语料库(31条) |
|
||||
|
||||
## 四、蓝图清单
|
||||
|
||||
| # | 蓝图 | 平台 | 步骤 | 用途 |
|
||||
|---|------|------|------|------|
|
||||
| 21 | `douyin_daily` | 🎵 | 23步 | 日常养号(随机浏览+点赞+收藏+评论) |
|
||||
| 22 | `douyin_active_v1` | 🎵 | 27步 | 高活跃养号 |
|
||||
| 23 | `douyin_comment` | 🎵 | 5步 | 定向评论(给链接→打开→评论) |
|
||||
| 24 | `douyin_search` | 🎵 | 14步 | 搜索浏览(搜关键词→随机浏览+互动) |
|
||||
| 25 | `douyin_collect` | 🎵 | 5步 | 信息采集(搜博主→采集主页信息) |
|
||||
| 26 | `douyin_reply` | 🎵 | 5步 | 作者回复(打开自己视频→读评论→回复) |
|
||||
| 27 | `douyin_read_profile` | 🎵 | 9步 | 读取抖音主页信息 |
|
||||
| 28 | `xhs_daily` | 📕 | 17步 | 小红书日常养号 |
|
||||
| 29 | `xhs_active_v1` | 📕 | 26步 | 小红书高活跃养号 |
|
||||
| 30 | `xiaohongshu_read_profile` | 📕 | 8步 | 读取小红书主页信息 |
|
||||
|
||||
## 五、原子操作
|
||||
|
||||
| # | 操作 | 说明 |
|
||||
|---|------|------|
|
||||
| 31 | `goto_home` | 导航到首页 |
|
||||
| 32 | `wait_watch` | 等待观看(随机5-12秒) |
|
||||
| 33 | `like` | 点赞 |
|
||||
| 34 | `collect` | 收藏 |
|
||||
| 35 | `post_comment` | 发表评论(含@corpus占位符替换) |
|
||||
| 36 | `open_video` | 打开视频(双击卡片) |
|
||||
| 37 | `next_video` | 切换到下一个视频 |
|
||||
| 38 | `search` | 搜索关键词 |
|
||||
| 39 | `scroll_feed` | 滚动信息流 |
|
||||
| 40 | `goto_profile` | 进入个人主页(采集全部字段) |
|
||||
| 41 | `read_my_comments` | 读当前视频评论区 |
|
||||
| 42 | `reply_comment` | 回复评论 |
|
||||
| 43 | `set_account_id` | 设置账号上下文(人设/语料匹配用) |
|
||||
|
||||
## 六、旧文档(已归档,仅供参考)
|
||||
|
||||
```
|
||||
docs/ARCHITECTURE_FULL.md
|
||||
docs/BASE_CONVENTIONS.md
|
||||
docs/REFACTOR-PLAN.md
|
||||
docs/REPAIR_PLAN.md
|
||||
docs/SYSTEM_ARCHITECTURE.md
|
||||
docs/PROJECT_OVERVIEW.md
|
||||
docs/ANTI-DETECTION-PLAN.md
|
||||
(以上为 v4.x 旧文档,内容已过时)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 新机器部署步骤
|
||||
|
||||
```bash
|
||||
# 1. 拉取代码
|
||||
cd ~/workbuddy-agent-os/agent-sync
|
||||
git pull
|
||||
|
||||
# 2. 运行初始化脚本(自动检测环境)
|
||||
bash 05_tools/07_matrix/init_matrix.sh
|
||||
|
||||
# 3. 重启 Dashboard
|
||||
launchctl kickstart -k gui/$(id -u)/com.agentos.dashboard
|
||||
|
||||
# 4. 浏览器打开 http://localhost:9988
|
||||
# 左侧「矩阵系统」→「身份与账号」→ 确认账号列表
|
||||
```
|
||||
|
||||
如果 Dashboard 无法启动,单独修复:
|
||||
```bash
|
||||
bash 05_tools/07_matrix/fix_dashboard_launchd.sh
|
||||
```
|
||||
|
||||
## 关键变更说明(v5.0 → v5.3)
|
||||
|
||||
- **身份共享**:同手机号共用同一浏览器指纹(identity_dir=phone_手机号)
|
||||
- **引擎重构**:按身份分组→同浏览器多平台→不同组错峰启动
|
||||
- **@corpus 恢复**:蓝图中的 @corpus 占位符自动替换为真实评论
|
||||
- **蓝图平台标记**:所有蓝图名称带平台前缀(抖音-日常养号/小红书-日常养号)
|
||||
- **清除登录**:每个平台可单独清除登录状态(不影响同身份其他平台)
|
||||
- **批量执行**:账号不默认勾选、蓝图显示全部带平台标签
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: matrix
|
||||
version: "2.1.0"
|
||||
type: tool
|
||||
description: 多平台社交账号矩阵养号工具
|
||||
author: agent-os
|
||||
last_updated: 2026-05-01
|
||||
---
|
||||
|
||||
# Matrix 养号工具模块
|
||||
|
||||
## 模块信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 名称 | matrix |
|
||||
| 版本 | 2.1.0 |
|
||||
| 类型 | tool |
|
||||
| 路径 | `05_tools/07_matrix/` |
|
||||
| 本地数据 | `agent-local/tools/matrix/` |
|
||||
| 依赖 | patchright, camoufox, PyYAML |
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
05_tools/07_matrix/ ← 同步目录(Git + 坚果云)
|
||||
├── MODULE.md ← 本文件(模块描述)
|
||||
├── install.sh ← 新机部署脚本
|
||||
├── requirements.txt ← Python 依赖
|
||||
├── local.yaml.template ← 本地配置模板
|
||||
├── config_template/ ← 账号配置模板
|
||||
├── scripts/ ← 核心代码
|
||||
├── blueprints/ ← 蓝图文件
|
||||
└── docs/ ← 标准化文档
|
||||
|
||||
agent-local/tools/matrix/ ← 本地数据(每机独立,不同步)
|
||||
├── config/accounts.yaml ← 本机账号配置
|
||||
├── data/cookies/ ← 登录 Cookie
|
||||
├── data/matrix.db ← 执行记录
|
||||
├── profiles/ ← Chrome Profile(需重新登录)
|
||||
├── logs/ ← 运行日志
|
||||
└── screenshots/ ← 截图
|
||||
```
|
||||
|
||||
## 部署方式
|
||||
|
||||
```bash
|
||||
# 本模块的部署由 agentos upgrade 统一管理
|
||||
# 如单独执行:
|
||||
bash install.sh # 建目录 + 装依赖 + 生成 local.yaml
|
||||
pip install -r requirements.txt # 安装 Python 包
|
||||
python -m patchright install chromium # 安装 Playwright Chromium
|
||||
pip install camoufox && python -m camoufox fetch # 安装 Camoufox
|
||||
```
|
||||
|
||||
## 升级历史
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|------|------|------|
|
||||
| 2.1.0 | 2026-05-01 | 原子化登录模块 auth_manager.py + 稳定性修复 |
|
||||
| 2.0.0 | 2026-04-30 | Camoufox 集成 + 蓝图引擎升级 |
|
||||
| 1.0.0 | 2026-04-27 | 初始版本 |
|
||||
@@ -0,0 +1,222 @@
|
||||
# Matrix 矩阵养号系统
|
||||
|
||||
> **版本**: v5.2 | **环境**: macOS (Apple Silicon) | **引擎**: Camoufox (Firefox 内核)
|
||||
> **平台**: 抖音 ✅ | 小红书 ✅ | 知乎 🔄
|
||||
> **最后更新**: 2026-06-13
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
|
||||
Matrix 是一套社交账号养号自动化系统。通过 Camoufox 控制浏览器,自动完成日常养号任务(浏览、点赞、评论、搜索),模拟真实用户行为。
|
||||
|
||||
代码在 `agent-sync`(Gitee + GitHub 双同步),数据在 `agent-local`(本机,不同步)。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# 1. 进入目录
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix
|
||||
|
||||
# 2. 查看所有命令
|
||||
./mc --help
|
||||
|
||||
# 3. 日常养号(抖音)
|
||||
./mc run --accounts douyin_test --blueprints douyin_daily --rounds 3
|
||||
|
||||
# 4. 日常养号(小红书)
|
||||
./mc run --accounts xhs_01 --blueprints xhs_daily --rounds 2
|
||||
|
||||
# 5. 定向评论(给链接+方向)
|
||||
./mc task comment --url https://v.douyin.com/xxx --direction 称赞
|
||||
|
||||
# 6. 搜索浏览
|
||||
./mc task search --keyword 美食探店 --rounds 5
|
||||
|
||||
# 7. 启动 Dashboard
|
||||
open http://localhost:9988
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard 导航
|
||||
|
||||
| 菜单 | 功能 |
|
||||
|------|------|
|
||||
| **首页** | 运营数据看板(本机账号/已登录/已采集/联邦账号) |
|
||||
| **身份与账号** | 短信接收、注册新账号、账号卡片(四维状态+人设)、删除/登录/采集 |
|
||||
| **命令与任务** | TAB1: 批量执行 / TAB2: 定向评论 / TAB3: 定时任务 |
|
||||
| **账号总览** | 多机摘要 + 全账号表格 |
|
||||
| **蓝图编排** | 管理所有蓝图 |
|
||||
| **原子操作** | 原子操作库 |
|
||||
| **录制管理** | 录制/分析/导出操作 |
|
||||
| **⚙️ 矩阵设置** | TAB1: 导入导出 / TAB2: 备份恢复 / TAB3: 语料库 |
|
||||
|
||||
启动 Dashboard:
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
nohup ~/.workbuddy/binaries/python/envs/agent-os/bin/uvicorn app:app --host 0.0.0.0 --port 9988 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 日常养号
|
||||
|
||||
5 个核心蓝图:
|
||||
|
||||
| 蓝图 | 用途 | 执行 |
|
||||
|------|------|------|
|
||||
| `douyin_daily` | 抖音日常养号(浏览+点赞+收藏+评论) | `mc run --accounts A --blueprints douyin_daily --rounds 3` |
|
||||
| `xhs_daily` | 小红书日常养号 | `mc run --accounts A --blueprints xhs_daily --rounds 3` |
|
||||
| `douyin_comment` | 定向评论(打开链接→评论) | `mc task comment --url URL --direction 称赞` |
|
||||
| `douyin_search` | 搜索浏览(搜关键词→随机看视频) | `mc task search --keyword K --rounds 5` |
|
||||
| `douyin_collect` | 信息采集(搜博主→采主页信息) | `mc task collect --keyword 博主名` |
|
||||
| `douyin_reply` | 作者回复(看评论→回复) | `mc task reply --account douyin_test` |
|
||||
|
||||
### 智能任务(mc task)
|
||||
|
||||
```bash
|
||||
# 定向评论(自动识别平台、选账号、生成评论)
|
||||
mc task comment --url https://v.douyin.com/xxx --direction 称赞
|
||||
|
||||
# 搜索浏览
|
||||
mc task search --keyword 美食探店 --rounds 5
|
||||
|
||||
# 信息采集
|
||||
mc task collect --keyword 张三说科技
|
||||
|
||||
# 作者回复
|
||||
mc task reply --account douyin_test
|
||||
|
||||
# 参数说明
|
||||
mc task --list
|
||||
```
|
||||
|
||||
### 账号管理
|
||||
|
||||
每个账号四维状态一目了然:
|
||||
```
|
||||
🎵 小美养生茶 douyin_test
|
||||
📱 15370103682
|
||||
✅ 身份 ✅ 登录 ✅ 昵称 ✅ 同步
|
||||
🎯 美食·养生·旅行 | 💬 称赞/提问 ← 人设
|
||||
[🔑 登录] [👤 采集] [🗑 删除]
|
||||
```
|
||||
|
||||
- **状态**:身份 ✅/❌ | 登录 ✅/❌ | 昵称 ✅/❌ | 同步 ✅/❌
|
||||
- **操作**:🔑登录(打开浏览器扫码) | 👤采集(获取昵称/粉丝数) | 🗑删除
|
||||
|
||||
### 注册新账号
|
||||
|
||||
```bash
|
||||
# 方式一:Dashboard 身份与账号 → 注册新账号
|
||||
# 方式二:CLI
|
||||
mc account register --platform douyin --phone 138xxxx --name 备注名
|
||||
```
|
||||
|
||||
- 同手机号注册第二个平台时,自动复用同一份浏览器指纹
|
||||
- 自动创建身份目录 → 打开浏览器 → 扫码登录
|
||||
|
||||
### 定时任务
|
||||
|
||||
```bash
|
||||
# 添加定时任务
|
||||
mc schedule add --id morning --account douyin_test --blueprint douyin_daily --time 09:00 --rounds 3
|
||||
|
||||
# 列出定时任务
|
||||
mc schedule list
|
||||
|
||||
# 启动调度器(保持终端运行)
|
||||
mc schedule start
|
||||
```
|
||||
|
||||
### 账号人设
|
||||
|
||||
每个账号可配置人设,养号时自动按人设选语料方向和搜索关键词:
|
||||
|
||||
```json
|
||||
{
|
||||
"nickname": "小美养生茶",
|
||||
"persona": {
|
||||
"interests": ["美食", "养生", "旅行"],
|
||||
"comment_style": ["称赞", "提问"],
|
||||
"search_keywords": ["美食探店", "养生茶", "周末去哪"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置位置:`agent-local/tools/matrix/data/profiles.json`
|
||||
|
||||
---
|
||||
|
||||
## 新机部署
|
||||
|
||||
```bash
|
||||
# 1. 拉取代码
|
||||
cd ~
|
||||
git clone [email protected]:neowoodland-art/mac-agent-os.git workbuddy-agent-os/agent-sync
|
||||
cd workbuddy-agent-os/agent-sync
|
||||
|
||||
# 2. 运行初始化
|
||||
bash 00_bootstrap/init.sh
|
||||
|
||||
# 3. 安装 Matrix 依赖
|
||||
cd 05_tools/07_matrix
|
||||
bash install.sh
|
||||
|
||||
# 4. 同步本机账号
|
||||
# 从 accounts_registry.yaml 自动同步 assigned_machine 为本机的账号
|
||||
|
||||
# 5. 启动 Dashboard
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
nohup ~/.workbuddy/binaries/python/envs/agent-os/bin/uvicorn app:app --host 0.0.0.0 --port 9988 &
|
||||
|
||||
# 6. 第一次使用需扫码登录
|
||||
./mc account login douyin_test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI 命令参考
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `mc run --accounts A,B --blueprints X,Y --rounds N` | 批量执行养号任务 |
|
||||
| `mc task comment --url U --direction D` | 智能定向评论 |
|
||||
| `mc task search --keyword K` | 搜索浏览 |
|
||||
| `mc task collect --keyword K` | 信息采集 |
|
||||
| `mc task reply --account A` | 作者回复 |
|
||||
| `mc account list` | 列出所有账号 |
|
||||
| `mc account login <name>` | 扫码登录 |
|
||||
| `mc schedule list` | 列出定时任务 |
|
||||
| `mc schedule add --id X --account Y --blueprint Z --time HH:MM` | 添加定时任务 |
|
||||
| `mc schedule start` | 启动定时调度器 |
|
||||
| `mc record start --account A` | 录制原子操作 |
|
||||
| `mc record list/analyze/export` | 录制管理 |
|
||||
| `mc corpus list` | 查看语料库 |
|
||||
|
||||
---
|
||||
|
||||
## 数据存储
|
||||
|
||||
```
|
||||
~/workbuddy-agent-os/
|
||||
├── agent-sync/05_tools/07_matrix/ ← 代码(Git 同步)
|
||||
│ ├── blueprints/ ← 任务蓝图(JSON)
|
||||
│ ├── scripts/mc/ ← 核心引擎
|
||||
│ ├── corpus/ ← 评论语料库
|
||||
│ └── config/ ← 配置(定时任务、AI 等)
|
||||
│
|
||||
└── agent-local/tools/matrix/ ← 数据(本机,不同步)
|
||||
├── config/accounts.yaml ← 本机账号配置
|
||||
├── identities/ ← 浏览器身份目录
|
||||
│ ├── douyin_test/ ← 账号A(含 cookies、指纹)
|
||||
│ └── phone_138xxxx/ ← 同手机号共享身份(新注册)
|
||||
├── data/profiles.json ← 昵称/人设数据
|
||||
├── recordings/ ← 录制包
|
||||
└── logs/ ← 运行日志
|
||||
```
|
||||
@@ -0,0 +1,278 @@
|
||||
# TOOL.md — 矩阵养号系统 Matrix
|
||||
|
||||
> **工具版本**: v4.1
|
||||
> **接入日期**: 2026-04-29
|
||||
> **维护者**: ghai
|
||||
> **路径方案**: local.yaml + local_paths.py(无软链接,多机安全)
|
||||
> **更新日期**: 2026-05-03
|
||||
> **平台支持**: 抖音 ✅ | 小红书 🔄 | 知乎 🔄 | 快手 📋
|
||||
|
||||
---
|
||||
|
||||
## 一句话说明
|
||||
|
||||
通过 Chrome CDP 和 Camoufox(Firefox 内核)程序化控制浏览器,自动完成多平台社交账号的日常养号任务(浏览、点赞、评论、收藏等),模拟真实用户行为。
|
||||
|
||||
---
|
||||
|
||||
## 架构概览(v4.1)
|
||||
|
||||
```
|
||||
matrix.py ← 统一 CLI 入口(account/nurture/config/status 四域)
|
||||
│
|
||||
├── account/ ← 账号管理域
|
||||
│ ├── create_identity.py ← 身份工厂(BrowserForge 固化指纹)
|
||||
│ ├── login_identity.py ← 首次登录(持久化保存登录态)
|
||||
│ ├── sms/ ← 短信验证码接口(预留)
|
||||
│ └── captcha/ ← 图形验证码接口(预留)
|
||||
│
|
||||
├── nurture/ ← 养号执行域
|
||||
│ ├── runner.py ← 常驻循环引擎(Chrome CDP + Camoufox 双引擎)
|
||||
│ └── behavior.py ← 行为模拟参数化(13项可配置)
|
||||
│
|
||||
├── engine/ ← 浏览器引擎层
|
||||
│ └── cdp_connector.py ← Chrome CDP / Camoufox 持久化连接
|
||||
│
|
||||
├── ops/ ← 操作层(多平台)
|
||||
│ └── douyin/
|
||||
│ ├── browse.py ← 浏览类操作
|
||||
│ └── interact.py ← 交互类操作
|
||||
│
|
||||
└── config/ ← 配置管理
|
||||
└── accounts.yaml ← 账号配置(含 identity_dir)
|
||||
```
|
||||
└── screenshots/ # 截图快照
|
||||
|
||||
⭐ 路径解析方式 (v4.0):
|
||||
local_paths.py → 读取 local.yaml → 获取 data_root
|
||||
→ config_path("x") → data_root/config/x
|
||||
→ data_path("x") → data_root/data/x
|
||||
→ logs_path("x") → data_root/logs/x
|
||||
→ profiles_path("x") → data_root/profiles/x
|
||||
→ screenshots_path("x")→ data_root/screenshots/x
|
||||
→ code_dir() → 05_tools/07_matrix/(代码目录)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速使用
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix
|
||||
|
||||
# 启动抖音账号(Chrome CDP)
|
||||
python scripts/switch_account.py --method profile --target douyin_01 --port 9222
|
||||
|
||||
# 执行日常浏览蓝图
|
||||
python scripts/task_engine.py --blueprint douyin_browse_v2 --account douyin_01
|
||||
|
||||
# 定时调度
|
||||
python scripts/task_scheduler.py
|
||||
|
||||
# 查看账号状态
|
||||
python scripts/switch_account.py --status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境依赖
|
||||
|
||||
| 依赖 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Python | 3.10+ | 使用 agent-os venv |
|
||||
| Google Chrome | 最新版 | CDP 直连 |
|
||||
| Camoufox | 最新版 | Firefox 内核,反检测 |
|
||||
| playwright | ≥1.40 | 浏览器自动化 |
|
||||
|
||||
完整依赖见 `requirements.txt`,安装:
|
||||
```bash
|
||||
pip install -r ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 新机恢复
|
||||
|
||||
```bash
|
||||
# 一键恢复(建立本地目录骨架 + 生成 local.yaml + 安装依赖)
|
||||
bash ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/install.sh
|
||||
|
||||
# 然后将账号配置复制/重新填写
|
||||
cp ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/config_template/accounts.yaml \
|
||||
~/workbuddy-agent-os/agent-local/tools/matrix/config/accounts.yaml
|
||||
# 编辑 accounts.yaml 填入本机账号信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 统一 CLI 使用(v4.1 推荐)
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts
|
||||
|
||||
# 账号管理
|
||||
python matrix.py account list # 列出所有账号
|
||||
python matrix.py account create <name> # 创建新身份
|
||||
python matrix.py account login <name> # 首次登录
|
||||
python matrix.py account status [name] # 查看状态
|
||||
|
||||
# 养号执行
|
||||
python matrix.py nurture run -a <name> -r 10 # 单账号循环
|
||||
python matrix.py nurture run -a a -a b -r 10 # 多账号并发
|
||||
python matrix.py nurture run -a <name> -r 10 --daemon # 保持连接不退出
|
||||
|
||||
# 配置管理
|
||||
python matrix.py config show # 查看配置
|
||||
python matrix.py config blueprint list # 查看蓝图
|
||||
|
||||
# 状态监控
|
||||
python matrix.py status all # 全局状态
|
||||
python matrix.py status browsers # 浏览器状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心机制
|
||||
|
||||
### 双引擎架构
|
||||
|
||||
| 引擎 | 适用账号 | 连接方式 | 窗口控制 | 视口控制 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| Chrome CDP | douyin_01/02 | CDP WebSocket | --window-size 参数 | UA + viewport + touchEmulation 三合一 |
|
||||
| Camoufox 持久化 | douyin_camo01 | launch_persistent_context | window.open(features) | 启动前 config 注入 DOM 属性 |
|
||||
|
||||
---
|
||||
|
||||
### Chrome 反检测方案(三合一)
|
||||
|
||||
Chrome CDP 模式下,抖音识别移动端需要**三个设置缺一不可**:
|
||||
|
||||
```python
|
||||
# 1) UA 覆盖 — 告诉抖音是安卓平板
|
||||
await cdp.send("Network.setUserAgentOverride", {"userAgent": ANDROID_TABLET_UA})
|
||||
|
||||
# 2) 视口尺寸 — 告诉抖音是小屏设备
|
||||
await page.set_viewport_size({"width": 702, "height": 783})
|
||||
|
||||
# 3) 触摸模拟 — 告诉抖音是触摸设备(关键!缺这个就是桌面版)
|
||||
await cdp.send("Emulation.setTouchEmulationEnabled", {
|
||||
"enabled": True, "maxTouchPoints": 5, "configuration": "mobile"
|
||||
})
|
||||
```
|
||||
|
||||
⚠️ 注意:`Emulation.setDeviceMetricsOverride` 在 **Chrome 148** 上 `width/height` 参数失效,不要使用。
|
||||
|
||||
### Camoufox 窗口/视口控制方案(v4.1 修复)
|
||||
|
||||
**问题**:Playwright 的 Firefox `launch_persistent_context` 模式完全接管了窗口管理和视口设置,
|
||||
`--width/--height` 参数、`window.resizeTo`、`page.set_viewport_size` 均无效。
|
||||
|
||||
**解决方案(双层控制)**:
|
||||
|
||||
1. **物理窗口尺寸** — 启动后用 `window.open(url, name, 'width=702,height=783')` 创建新窗口。
|
||||
Firefox 的 `window.open` 特性参数字段是原生支持的,Playwright 无法覆盖。
|
||||
|
||||
2. **页面视口/屏幕尺寸** — 通过 Camoufox 的 `config` 参数在启动前注入 DOM 属性覆盖:
|
||||
```python
|
||||
config={
|
||||
"window.innerWidth": 702,
|
||||
"window.innerHeight": 783,
|
||||
"window.outerWidth": 702,
|
||||
"window.outerHeight": 783,
|
||||
"screen.width": 702,
|
||||
"screen.height": 783,
|
||||
"screen.availWidth": 702,
|
||||
"screen.availHeight": 783,
|
||||
}
|
||||
```
|
||||
Camoufox 的扩展会在每个页面加载前读取 config 并覆盖 DOM 属性,
|
||||
效果等同 Playwright 的 `set_viewport_size` 但不受其限制。
|
||||
|
||||
3. **窗口尺寸配置** — 写入 `identities/{name}/config.yaml`:
|
||||
```yaml
|
||||
window: [702, 783]
|
||||
```
|
||||
|
||||
### 行为模拟参数化
|
||||
|
||||
行为参数可在 `identities/{name}/config.yaml` 中覆盖默认值:
|
||||
|
||||
```yaml
|
||||
behavior:
|
||||
base_delay: 1.5 # 操作间基础间隔(秒)
|
||||
delay_variance: 0.8 # 随机波动范围
|
||||
attention:
|
||||
distraction_chance: 0.05 # "分心"概率
|
||||
watch_duration: [4, 12] # 观看视频时长(秒)
|
||||
round_break:
|
||||
min_break: 5 # 轮间最短休息(秒)
|
||||
max_break: 20 # 轮间最长休息(秒)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口预留
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `account/sms/base.py` | 抽象基类 | 短信验证码处理(默认手动输入) |
|
||||
| `account/captcha/base.py` | 抽象基类 | 图形验证码处理(默认手动处理) |
|
||||
| `ops/xiaohongshu/` | 目录预留 | 小红书操作集 |
|
||||
| `ops/kuaishou/` | 目录预留 | 快手操作集 |
|
||||
| `ops/bilibili/` | 目录预留 | B站操作集 |
|
||||
|
||||
---
|
||||
|
||||
## 环境依赖
|
||||
|
||||
| 依赖 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Python | 3.10+ | 使用 agent-os venv |
|
||||
| Google Chrome | 最新版 | CDP 直连 |
|
||||
| Camoufox | ≥0.4.11 | Firefox 内核,反检测 |
|
||||
| playwright | ≥1.40 | 浏览器自动化 |
|
||||
| browserforge | 最新 | BrowserForge 指纹生成 |
|
||||
| camoufox | ≥0.4 | Firefox 反检测浏览器 |
|
||||
|
||||
完整依赖见 `requirements.txt`,安装:
|
||||
```bash
|
||||
pip install -r ~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/requirements.txt
|
||||
```
|
||||
|
||||
## 多机同步说明 (v4.0)
|
||||
|
||||
本工具不再使用软链接,改为 `local.yaml` + `local_paths.py` 方案:
|
||||
|
||||
- `local.yaml` — 记录本机数据目录路径,每台机器独立生成
|
||||
- `local.yaml` 不参与坚果云同步(请加入排除列表)
|
||||
- 所有 Python 脚本通过 `scripts/local_paths.py` 读取路径
|
||||
- 任何脚本启动时若 `local.yaml` 不存在,会报错并提示运行 `install.sh`
|
||||
|
||||
`local.yaml` 格式(自动生成,无需手动编辑):
|
||||
```yaml
|
||||
matrix:
|
||||
local_data_root: /Users/xxx/workbuddy-agent-os/agent-local/tools/matrix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 当前开发状态
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| A | Chrome CDP + 抖音原子操作(18个)+ 蓝图引擎 | ✅ 已完成 |
|
||||
| B | Camoufox 集成 + 多浏览器内核 | 🔄 进行中 |
|
||||
| C | 鼠标轨迹仿真 + 语料库完善 | 📋 规划中 |
|
||||
| D | 小红书/知乎完整支持 | 📋 规划中 |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `local.yaml` 每台机器独立配置,**不纳入坚果云同步**(加入排除列表)
|
||||
- `profiles/` 目录含 Chrome 用户数据(~100MB),存于 `agent-local/` 本地
|
||||
- `config/accounts.yaml` 含账号标识信息,存于 `agent-local/` 本地
|
||||
- 换机后 profiles 需重新登录各平台账号
|
||||
- Camoufox 需单独安装(见 docs/CAMOUFOX_LOGIN_MANAGEMENT.md)
|
||||
- 多机同步只需每台机器运行一次 `install.sh`
|
||||
- 避免使用软链接指向 agent-local,统一通过 `local_paths.py` 管理路径
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# agentos — AgentOS 联邦智能体统一命令入口
|
||||
# 安装: chmod +x agentos && sudo ln -s $(pwd)/agentos /usr/local/bin/agentos
|
||||
# 或: alias agentos='$(pwd)/agentos'
|
||||
|
||||
_SRC="${BASH_SOURCE[0]}"
|
||||
while [ -L "$_SRC" ]; do _SRC="$(readlink "$_SRC")"; done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$_SRC")" && pwd)"
|
||||
|
||||
PYTHON=""
|
||||
if [ -n "$AGENTOS_PYTHON" ] && [ -x "$AGENTOS_PYTHON" ]; then
|
||||
PYTHON="$AGENTOS_PYTHON"
|
||||
elif [ -f "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3" ]; then
|
||||
PYTHON="$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
|
||||
elif command -v python3 &>/dev/null; then
|
||||
PYTHON="python3"
|
||||
else
|
||||
echo "找不到 Python 3,请设置 AGENTOS_PYTHON 环境变量" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR/scripts"
|
||||
exec "$PYTHON" -m agentos "$@"
|
||||
@@ -0,0 +1,701 @@
|
||||
"""
|
||||
corpus.py — 评论语料库管理
|
||||
|
||||
双平台隔离: 抖音/小红书 各自独立语料
|
||||
每个平台下分多个分类,含权重、启用状态
|
||||
|
||||
文件结构:
|
||||
corpus/
|
||||
├── douyin.yaml ← 抖音语料
|
||||
└── xiaohongshu.yaml ← 小红书语料
|
||||
|
||||
YAML 格式:
|
||||
version: "1.0"
|
||||
categories:
|
||||
赞美:
|
||||
weight: 30
|
||||
enabled: true
|
||||
label: "赞美评论"
|
||||
comments:
|
||||
- "讲得太好了,受益匪浅!"
|
||||
- "这个观点很新颖,学习了"
|
||||
搞笑:
|
||||
weight: 20
|
||||
enabled: true
|
||||
label: "搞笑评论"
|
||||
comments:
|
||||
- "笑死我了哈哈哈哈哈"
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TOOL_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CORPUS_DIR = TOOL_DIR / "corpus"
|
||||
CONFIG_DIR = TOOL_DIR / "config"
|
||||
|
||||
# 项目根目录(agent-sync/05_tools/.. 的上一层,即 workbuddy-agent-os)
|
||||
PROJECT_ROOT = TOOL_DIR.parent.parent.parent
|
||||
PROFILES_PATH = PROJECT_ROOT / "agent-local/tools/matrix/data/profiles.json"
|
||||
|
||||
# ── 关键词 → 方向 映射 ──────────────────────────────────────────
|
||||
KEYWORD_CATEGORY_MAP = [
|
||||
(["科技", "数码", "手机"], ["称赞", "提问"]),
|
||||
(["美食", "做饭", "菜"], ["称赞", "共鸣"]),
|
||||
(["旅游", "风景", "旅行"], ["称赞", "提问"]),
|
||||
(["情感", "生活", "感悟"], ["共鸣", "安慰"]),
|
||||
(["知识", "科普", "教育"], ["提问", "补充"]),
|
||||
]
|
||||
|
||||
# 方向名称 → 实际分类名称(兼容两个平台已有的分类)
|
||||
DIRECTION_TO_CATEGORY = {
|
||||
"称赞": "赞美",
|
||||
"正面": "赞美",
|
||||
"提问": "提问",
|
||||
"共鸣": "感慨",
|
||||
"安慰": "感慨",
|
||||
"补充": "客观",
|
||||
}
|
||||
|
||||
# 默认方向标签(用于 fallback)
|
||||
ALL_DIRECTIONS = list(DIRECTION_TO_CATEGORY.keys())
|
||||
CORPUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 默认跨平台通用分类
|
||||
DEFAULT_DOUYIN_COMMENTS = {
|
||||
"赞美": {
|
||||
"weight": 30, "enabled": True, "label": "赞美评论",
|
||||
"comments": [
|
||||
"讲得太好了,受益匪浅!",
|
||||
"这个观点很新颖,学习了",
|
||||
"说得真是振聋发聩",
|
||||
"干货满满,感谢分享",
|
||||
"条理清晰,逻辑严密",
|
||||
"每一句话都说到了心坎里",
|
||||
],
|
||||
"templates": ["关于{keyword}的观点很到位", "感谢分享{keyword}的心得", "说得太好了,{keyword}这部分启发很大"],
|
||||
},
|
||||
"搞笑": {
|
||||
"weight": 20, "enabled": True, "label": "搞笑评论",
|
||||
"comments": ["哈哈哈哈哈笑死", "有才,这评论比视频还精彩", "笑不活了家人们", "你要把我笑死"],
|
||||
"templates": ["{keyword}这个梗笑死我了", "哈哈哈哈{keyword}太有才了"],
|
||||
},
|
||||
"客观": {
|
||||
"weight": 15, "enabled": True, "label": "客观评价",
|
||||
"comments": ["比较客观", "抛开立场看,说得有道理", "中肯的评价", "理性分析,值得一读"],
|
||||
"templates": ["关于{keyword}的分析很客观", "理性的声音,{keyword}这部分说得好"],
|
||||
},
|
||||
"提问": {
|
||||
"weight": 10, "enabled": True, "label": "提问互动",
|
||||
"comments": ["请问这是在哪里?", "能分享一下具体方法吗?", "新手想问一下怎么入门", "这个需要什么工具?"],
|
||||
"templates": ["{keyword}具体怎么操作?", "能详细讲讲{keyword}吗?"],
|
||||
},
|
||||
"感慨": {
|
||||
"weight": 10, "enabled": True, "label": "生活感慨",
|
||||
"comments": ["生活不易,且行且珍惜", "每个人都有自己的故事", "看得我热泪盈眶", "这就是生活啊"],
|
||||
"templates": ["看到{keyword}想起了自己", "{keyword}真的太真实了"],
|
||||
},
|
||||
}
|
||||
|
||||
# 小红书专属分类
|
||||
DEFAULT_XHS_COMMENTS = {
|
||||
"穿搭": {
|
||||
"weight": 25, "enabled": True, "label": "穿搭评论",
|
||||
"comments": ["这套搭配好好看!", "求链接!", "色系搭配太舒服了", "超有气质", "这个风格很适合你"],
|
||||
"templates": ["{keyword}这套太好看了", "求{keyword}的链接"],
|
||||
},
|
||||
"美妆": {
|
||||
"weight": 20, "enabled": True, "label": "美妆评论",
|
||||
"comments": ["这个妆面好干净", "求粉底色号", "口红颜色太美了", "教程很详细,收藏了"],
|
||||
"templates": ["{keyword}这个妆容好干净", "{keyword}是什么色号"],
|
||||
},
|
||||
"种草": {
|
||||
"weight": 20, "enabled": True, "label": "种草评论",
|
||||
"comments": ["种草了!", "我也买了同款,真的不错", "已加购物车", "求推荐更多好物"],
|
||||
"templates": ["被{keyword}种草了", "{keyword}已加入清单"],
|
||||
},
|
||||
"美食": {
|
||||
"weight": 15, "enabled": True, "label": "美食评论",
|
||||
"comments": ["看着好好吃", "求地址", "收藏了,周末去试试", "这个做法很简单,明天就试"],
|
||||
"templates": ["{keyword}看着好好吃", "求{keyword}的店名"],
|
||||
},
|
||||
"家居": {
|
||||
"weight": 10, "enabled": True, "label": "家居评论",
|
||||
"comments": ["布置得好温馨", "求家具链接", "这个风格好喜欢", "家的感觉真好"],
|
||||
"templates": ["{keyword}的布置好温馨", "求{keyword}的链接"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AI 评论生成器
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class AIGenerator:
|
||||
"""AI 评论生成器(OpenAI 兼容 API)
|
||||
|
||||
使用简单的 HTTP 请求,不依赖 openai 库。
|
||||
没有配置 API key 时跳过,不影响现有功能。
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
|
||||
config = self._load_config()
|
||||
|
||||
raw_key = api_key or config.get("api_key", "") or os.environ.get("OPENAI_API_KEY", "")
|
||||
self.api_key = raw_key.strip() if raw_key else ""
|
||||
|
||||
self.base_url = (base_url or config.get("base_url") or "https://api.openai.com/v1").rstrip("/")
|
||||
self.model = model or config.get("model") or "gpt-4o-mini"
|
||||
self.temperature = config.get("temperature", 0.8)
|
||||
|
||||
# ── 配置加载 ────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _load_config() -> dict:
|
||||
"""从 config/ai.yaml 加载配置"""
|
||||
path = CONFIG_DIR / "ai.yaml"
|
||||
if path.exists():
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
return (raw or {}).get("ai", {})
|
||||
except Exception as exc:
|
||||
log.warning(" ⚠️ ai.yaml 解析失败: %s", exc)
|
||||
return {}
|
||||
|
||||
# ── 公共 API ────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""是否有 API key 可用"""
|
||||
return bool(self.api_key)
|
||||
|
||||
async def generate_comment(
|
||||
self,
|
||||
video_title: str,
|
||||
video_desc: str = "",
|
||||
direction: str = "称赞",
|
||||
persona: dict = None,
|
||||
) -> Optional[str]:
|
||||
"""根据视频标题和方向生成评论
|
||||
|
||||
Args:
|
||||
video_title: 视频标题
|
||||
video_desc: 视频描述(可选)
|
||||
direction: 评论方向(称赞/提问/共鸣/补充/搞笑)
|
||||
persona: 账号人设字典(可选)
|
||||
|
||||
Returns:
|
||||
生成的评论文本,失败返回 None
|
||||
"""
|
||||
if not self.available:
|
||||
log.debug(" ⏭️ AI 生成跳过(未配置 API key)")
|
||||
return None
|
||||
|
||||
prompt = self._build_prompt(video_title, video_desc, direction, persona)
|
||||
text = await self._call_api(prompt)
|
||||
return text
|
||||
|
||||
# ── 内部方法 ────────────────────────────────────────────
|
||||
|
||||
def _build_prompt(self, video_title: str, video_desc: str, direction: str, persona: dict = None) -> str:
|
||||
"""构建 prompt"""
|
||||
style_hint = f"(风格要求:{direction})"
|
||||
|
||||
persona_hint = ""
|
||||
if persona:
|
||||
parts = []
|
||||
if persona.get("age_group"):
|
||||
parts.append(f"年龄层: {persona['age_group']}")
|
||||
if persona.get("gender"):
|
||||
parts.append(f"性别: {persona['gender']}")
|
||||
if persona.get("interests"):
|
||||
parts.append(f"兴趣: {'、'.join(persona['interests'])}")
|
||||
if persona.get("comment_style"):
|
||||
parts.append(f"评论风格: {'、'.join(persona['comment_style'])}")
|
||||
if parts:
|
||||
persona_hint = f"\n账号人设: {'; '.join(parts)}"
|
||||
|
||||
desc_part = f"\n视频描述: {video_desc}" if video_desc else ""
|
||||
|
||||
return (
|
||||
f"你是一个短视频平台的活跃用户。请根据以下视频信息写一条 {direction} 方向的评论{style_hint}。"
|
||||
f"{persona_hint}"
|
||||
f"\n视频标题: {video_title}"
|
||||
f"{desc_part}"
|
||||
"\n\n要求:"
|
||||
"\n- 语言自然,像真实用户的评论"
|
||||
"\n- 长度 10~50 字"
|
||||
"\n- 不要用 emoji"
|
||||
"\n- 直接输出评论内容,不要加引号或前缀"
|
||||
)
|
||||
|
||||
async def _call_api(self, prompt: str) -> Optional[str]:
|
||||
"""调用 OpenAI 兼容 API 生成文本"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": 150,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning(" ⚠️ AI API 返回 %s: %s", resp.status_code, resp.text[:200])
|
||||
return None
|
||||
data = resp.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
return None
|
||||
text = choices[0].get("message", {}).get("content", "").strip()
|
||||
return text if text else None
|
||||
except Exception as exc:
|
||||
log.warning(" ⚠️ AI API 调用失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 语料库管理器
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class CorpusManager:
|
||||
"""语料库管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
self._personas = {} # account_id → persona dict
|
||||
self._ai_gen = None # AIGenerator 懒实例化
|
||||
self._ensure_files()
|
||||
|
||||
# ── 语料文件管理 ────────────────────────────────────────
|
||||
|
||||
def _ensure_files(self):
|
||||
"""确保语料库 YAML 文件存在"""
|
||||
for platform, data in [("douyin", DEFAULT_DOUYIN_COMMENTS), ("xiaohongshu", DEFAULT_XHS_COMMENTS)]:
|
||||
path = CORPUS_DIR / f"{platform}.yaml"
|
||||
if not path.exists():
|
||||
content = {"version": "1.0", "categories": data}
|
||||
path.write_text(yaml.dump(content, default_flow_style=False, allow_unicode=True, sort_keys=False))
|
||||
log.info(f" 📝 创建语料文件: {path.name} ({sum(1 for c in data.values() for _ in c.get('comments',[]))} 条)")
|
||||
|
||||
def _load(self, platform: str) -> dict:
|
||||
"""加载指定平台的语料"""
|
||||
if platform not in self._cache:
|
||||
path = CORPUS_DIR / f"{platform}.yaml"
|
||||
if path.exists():
|
||||
self._cache[platform] = yaml.safe_load(path.read_text()) or {}
|
||||
else:
|
||||
self._cache[platform] = {"version": "1.0", "categories": {}}
|
||||
return self._cache[platform]
|
||||
|
||||
# ── 人设管理 ──────────────────────────────────────────
|
||||
|
||||
def load_personas(self) -> dict:
|
||||
"""从 profiles.json 加载所有人设,返回 account_id → persona dict"""
|
||||
log.info(f" 📂 加载人设数据: {PROFILES_PATH}")
|
||||
try:
|
||||
if PROFILES_PATH.exists():
|
||||
raw = json.loads(PROFILES_PATH.read_text())
|
||||
self._personas = {}
|
||||
for account_id, profile in raw.items():
|
||||
persona = profile.get("persona")
|
||||
if persona:
|
||||
self._personas[account_id] = persona
|
||||
log.info(f" ✅ 加载 {len(self._personas)} 个人设")
|
||||
else:
|
||||
log.warning(" ⚠️ 人设文件不存在: %s", PROFILES_PATH)
|
||||
self._personas = {}
|
||||
except Exception as exc:
|
||||
log.warning(" ⚠️ 加载人设失败: %s", exc)
|
||||
self._personas = {}
|
||||
return self._personas
|
||||
|
||||
def get_persona(self, account_id: str) -> Optional[dict]:
|
||||
"""获取指定账号的人设
|
||||
|
||||
Args:
|
||||
account_id: 账号 ID
|
||||
|
||||
Returns:
|
||||
人设字典,或 None
|
||||
"""
|
||||
if not self._personas:
|
||||
self.load_personas()
|
||||
persona = self._personas.get(account_id)
|
||||
if persona is None:
|
||||
log.debug(f" ℹ️ 账号 %s 无人设", account_id)
|
||||
return persona
|
||||
|
||||
def get_search_keywords(self, account_id: str) -> list:
|
||||
"""获取账号人设中的搜索关键词
|
||||
|
||||
Args:
|
||||
account_id: 账号 ID
|
||||
|
||||
Returns:
|
||||
关键词列表(无人设时返回空列表)
|
||||
"""
|
||||
persona = self.get_persona(account_id)
|
||||
if persona:
|
||||
return persona.get("search_keywords", [])
|
||||
return []
|
||||
|
||||
# ── v2 格式支持 ────────────────────────────────────────
|
||||
|
||||
def list_personas(self) -> list:
|
||||
"""列出所有身份(v2格式)"""
|
||||
result = []
|
||||
for platform in ["douyin", "xiaohongshu"]:
|
||||
data = self._load(platform)
|
||||
for pid, info in data.get("personas", {}).items():
|
||||
result.append({
|
||||
"platform": platform,
|
||||
"id": pid,
|
||||
"name": info.get("name", pid),
|
||||
"tags": info.get("tags", []),
|
||||
"style": info.get("style", ""),
|
||||
})
|
||||
return result
|
||||
|
||||
def list_scenes(self, persona_id: str = None) -> list:
|
||||
"""列出所有场景(v2格式)"""
|
||||
scenes_set = {}
|
||||
for platform in ["douyin", "xiaohongshu"]:
|
||||
data = self._load(platform)
|
||||
for pid, pinfo in data.get("personas", {}).items():
|
||||
if persona_id and pid != persona_id:
|
||||
continue
|
||||
for scene_id, sinfo in data.get("scenes", {}).items():
|
||||
key = f"{pid}.{scene_id}"
|
||||
if key not in scenes_set:
|
||||
scenes_set[key] = {
|
||||
"persona": pid,
|
||||
"id": scene_id,
|
||||
"label": sinfo.get("label", scene_id),
|
||||
"rounds": sinfo.get("rounds", 1),
|
||||
}
|
||||
return list(scenes_set.values())
|
||||
|
||||
def get_comment_for_scene(self, persona: str, scene: str,
|
||||
keyword: str = "", round_num: int = 1) -> Optional[str]:
|
||||
"""根据身份和场景获取评论(v2格式)
|
||||
|
||||
Args:
|
||||
persona: 身份ID(如 health_lover)
|
||||
scene: 场景ID(如 first_comment)
|
||||
keyword: 关键词
|
||||
round_num: 多轮对话第几轮
|
||||
|
||||
Returns:
|
||||
评论文本,或 None
|
||||
"""
|
||||
for platform in ["douyin", "xiaohongshu"]:
|
||||
data = self._load(platform)
|
||||
content = data.get("content", {})
|
||||
# 尝试精确匹配 persona.scene
|
||||
key = f"{persona}.{scene}"
|
||||
if key in content:
|
||||
items = content[key]
|
||||
if isinstance(items, list):
|
||||
return random.choice(items).replace("{keyword}", keyword) if items else None
|
||||
# 多轮格式
|
||||
round_key = f"round_{round_num}"
|
||||
if isinstance(items, dict) and round_key in items:
|
||||
round_items = items[round_key]
|
||||
if round_items:
|
||||
return random.choice(round_items).replace("{keyword}", keyword)
|
||||
# 尝试匹配 scene 但不限定 persona
|
||||
for ck, cv in content.items():
|
||||
if ck.endswith(f".{scene}"):
|
||||
if isinstance(cv, list) and cv:
|
||||
return random.choice(cv).replace("{keyword}", keyword)
|
||||
return None
|
||||
|
||||
# ── 语料查询 ──────────────────────────────────────────
|
||||
|
||||
def list_categories(self) -> list:
|
||||
"""列出所有平台的分类"""
|
||||
result = []
|
||||
for platform in ["douyin", "xiaohongshu"]:
|
||||
data = self._load(platform)
|
||||
for name, info in data.get("categories", {}).items():
|
||||
count = len(info.get("comments", [])) + len(info.get("templates", []))
|
||||
result.append({
|
||||
"platform": platform,
|
||||
"name": name,
|
||||
"weight": info.get("weight", 0),
|
||||
"enabled": info.get("enabled", True),
|
||||
"count": count,
|
||||
"label": info.get("label", name),
|
||||
})
|
||||
return result
|
||||
|
||||
def get_comments(self, categories: List[str] = None, platform: str = None, count: int = 20) -> List[str]:
|
||||
"""获取评论列表
|
||||
|
||||
Args:
|
||||
categories: 指定分类(None = 所有启用分类)
|
||||
platform: 指定平台(None = 所有平台)
|
||||
count: 最多返回数量
|
||||
|
||||
Returns:
|
||||
评论文本列表
|
||||
"""
|
||||
results = []
|
||||
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
|
||||
|
||||
for p in platforms:
|
||||
data = self._load(p)
|
||||
for name, info in data.get("categories", {}).items():
|
||||
# 筛选分类
|
||||
if categories and name not in categories:
|
||||
continue
|
||||
if not info.get("enabled", True):
|
||||
continue
|
||||
if info.get("weight", 0) <= 0:
|
||||
continue
|
||||
|
||||
# 取评论
|
||||
comments = info.get("comments", [])
|
||||
templates = info.get("templates", [])
|
||||
combined = comments + templates
|
||||
# 按权重比例取
|
||||
take = max(1, int(count * info.get("weight", 10) / 100))
|
||||
results.extend(random.sample(combined, min(take, len(combined))))
|
||||
|
||||
random.shuffle(results)
|
||||
return results[:count]
|
||||
|
||||
def add_comment(self, category: str, text: str, platform: str = "douyin"):
|
||||
"""添加一条评论到指定平台指定分类"""
|
||||
data = self._load(platform)
|
||||
cats = data.get("categories", {})
|
||||
if category not in cats:
|
||||
cats[category] = {"weight": 10, "enabled": True, "label": category, "comments": [], "templates": []}
|
||||
if "comments" not in cats[category]:
|
||||
cats[category]["comments"] = []
|
||||
cats[category]["comments"].append(text)
|
||||
data["categories"] = cats
|
||||
|
||||
path = CORPUS_DIR / f"{platform}.yaml"
|
||||
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
|
||||
self._cache.pop(platform, None) # 清除缓存
|
||||
log.info(f" ✅ 已添加评论到 [{platform}/{category}]: {text}")
|
||||
|
||||
def delete_comment(self, category: str, index: int, platform: str = "douyin"):
|
||||
"""删除指定索引的评论"""
|
||||
data = self._load(platform)
|
||||
cats = data.get("categories", {}).get(category, {})
|
||||
comments = cats.get("comments", [])
|
||||
if 0 <= index < len(comments):
|
||||
removed = comments.pop(index)
|
||||
cats["comments"] = comments
|
||||
path = CORPUS_DIR / f"{platform}.yaml"
|
||||
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
|
||||
self._cache.pop(platform, None)
|
||||
log.info(f" 🗑 已删除评论 [{platform}/{category}]: {removed}")
|
||||
|
||||
def update_category(self, platform: str, category: str, **kwargs):
|
||||
"""更新分类设置(weight/enabled/label 等)"""
|
||||
data = self._load(platform)
|
||||
cats = data.get("categories", {})
|
||||
if category not in cats:
|
||||
cats[category] = {"weight": 10, "enabled": True, "label": category, "comments": [], "templates": []}
|
||||
for k, v in kwargs.items():
|
||||
if k in ("weight", "enabled", "label"):
|
||||
cats[category][k] = v
|
||||
data["categories"] = cats
|
||||
path = CORPUS_DIR / f"{platform}.yaml"
|
||||
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False))
|
||||
self._cache.pop(platform, None)
|
||||
log.info(f" ✅ 已更新分类 [{platform}/{category}]: {kwargs}")
|
||||
|
||||
# ── 账号上下文 ──────────────────────────────────────────
|
||||
|
||||
def set_account_id(self, account_id: str):
|
||||
"""设置当前账号 ID,方便后续跟踪上下文"""
|
||||
self._account_id = account_id
|
||||
log.info(f" 👤 设置账号: {account_id}")
|
||||
|
||||
# ── AI 生成器 ──────────────────────────────────────────
|
||||
|
||||
def _get_ai_generator(self) -> AIGenerator:
|
||||
"""懒获取 AI 生成器实例"""
|
||||
if self._ai_gen is None:
|
||||
self._ai_gen = AIGenerator()
|
||||
return self._ai_gen
|
||||
|
||||
# ── 视频标题匹配 → 评论 ──────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_first_keyword(video_title: str) -> Optional[str]:
|
||||
"""从视频标题中提取第一个匹配的关键词"""
|
||||
for keywords, _ in KEYWORD_CATEGORY_MAP:
|
||||
for kw in keywords:
|
||||
if kw in video_title:
|
||||
return kw
|
||||
# fallback: 取标题第一个有意义的词
|
||||
words = re.findall(r'[\w\u4e00-\u9fff]{2,}', video_title)
|
||||
return words[0] if words else ""
|
||||
|
||||
@staticmethod
|
||||
def _match_keywords(video_title: str) -> list:
|
||||
"""根据视频标题匹配关键词,返回匹配到的方向列表"""
|
||||
matched_directions = []
|
||||
for keywords, directions in KEYWORD_CATEGORY_MAP:
|
||||
for kw in keywords:
|
||||
if kw in video_title:
|
||||
matched_directions.extend(directions)
|
||||
break # 一个关键词组只匹配一次
|
||||
return matched_directions
|
||||
|
||||
def _get_comment_from_categories(self, category_names: list, platform: str = None,
|
||||
allow_templates: bool = True) -> Optional[str]:
|
||||
"""从指定的分类名列表中随机取一条评论(遍历所有平台)
|
||||
Args:
|
||||
allow_templates: 是否允许使用含 {keyword} 的模板
|
||||
"""
|
||||
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
|
||||
candidates = []
|
||||
|
||||
for p in platforms:
|
||||
data = self._load(p)
|
||||
for cat_name, info in data.get("categories", {}).items():
|
||||
if cat_name not in category_names:
|
||||
continue
|
||||
if not info.get("enabled", True):
|
||||
continue
|
||||
if allow_templates:
|
||||
candidates.extend(info.get("comments", []) + info.get("templates", []))
|
||||
else:
|
||||
candidates.extend(info.get("comments", []))
|
||||
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
return None
|
||||
|
||||
def _get_random_comment(self, platform: str = None) -> Optional[str]:
|
||||
"""从所有启用分类中随机取一条评论"""
|
||||
platforms = [platform] if platform else ["douyin", "xiaohongshu"]
|
||||
all_comments = []
|
||||
|
||||
for p in platforms:
|
||||
data = self._load(p)
|
||||
for name, info in data.get("categories", {}).items():
|
||||
if not info.get("enabled", True):
|
||||
continue
|
||||
if info.get("weight", 0) <= 0:
|
||||
continue
|
||||
comments = info.get("comments", []) # 随机兜底不包含 {keyword} 模板
|
||||
all_comments.extend(comments)
|
||||
|
||||
if all_comments:
|
||||
return random.choice(all_comments)
|
||||
return None
|
||||
|
||||
def get_comment_for_video(
|
||||
self,
|
||||
video_title: str,
|
||||
direction: str = None,
|
||||
account_id: str = None,
|
||||
) -> Optional[str]:
|
||||
"""根据视频标题(和可选方向/账号人设)从语料库中匹配一条评论
|
||||
|
||||
Args:
|
||||
video_title: 视频标题文本
|
||||
direction: 可选,指定方向,如 "称赞"/"提问"/"共鸣"/"补充"
|
||||
account_id: 可选,账号 ID,用于根据人设的 comment_style 优先选择方向
|
||||
|
||||
Returns:
|
||||
匹配到的评论文本,或 None
|
||||
"""
|
||||
# 0) 有人设 → 优先用人设的 comment_style 作为方向
|
||||
persona = None
|
||||
if account_id:
|
||||
persona = self.get_persona(account_id)
|
||||
if persona and not direction:
|
||||
# 如果未指定 direction,用人设的第一个 comment_style
|
||||
styles = persona.get("comment_style", [])
|
||||
if styles:
|
||||
# 先用 comment_style 去匹配,最后 fallback 到随机
|
||||
pass # 后面有具体逻辑
|
||||
|
||||
# 1) 从标题匹配关键词,得到方向列表
|
||||
matched_directions = self._match_keywords(video_title)
|
||||
|
||||
# 2) 如果用户指定了 direction,优先使用
|
||||
if direction and direction in DIRECTION_TO_CATEGORY:
|
||||
if direction in matched_directions:
|
||||
matched_directions.remove(direction)
|
||||
matched_directions = [direction] + matched_directions
|
||||
elif direction:
|
||||
pass
|
||||
|
||||
# 2.5) 如果有人设且未指定 direction,把人设的 comment_style 优先排列
|
||||
if persona and not direction:
|
||||
styles = persona.get("comment_style", [])
|
||||
# 把人设风格中在 matched_directions 里的提到最前面
|
||||
for style in reversed(styles):
|
||||
if style in matched_directions:
|
||||
matched_directions.remove(style)
|
||||
matched_directions = [style] + matched_directions
|
||||
elif style in DIRECTION_TO_CATEGORY:
|
||||
# 人设风格虽未匹配到关键词,也尝试作为候选方向
|
||||
matched_directions = [style] + matched_directions
|
||||
|
||||
# 3) 有匹配的方向 → 尝试依次取对应分类的评论
|
||||
if matched_directions:
|
||||
for d in matched_directions:
|
||||
cat = DIRECTION_TO_CATEGORY.get(d)
|
||||
if not cat:
|
||||
continue
|
||||
kw = self._extract_first_keyword(video_title)
|
||||
# 有关键词 → 可用模板(含 {keyword} 替换)
|
||||
if kw:
|
||||
comment = self._get_comment_from_categories([cat], allow_templates=True)
|
||||
if comment:
|
||||
return comment.replace("{keyword}", kw)
|
||||
# 无关键词 → 只用成品评论(不含模板)
|
||||
comment = self._get_comment_from_categories([cat], allow_templates=False)
|
||||
if comment:
|
||||
return comment
|
||||
|
||||
# 4) 没有语料匹配 → fallback: 尝试 AI 生成
|
||||
ai = self._get_ai_generator()
|
||||
if ai.available:
|
||||
try:
|
||||
# 同步上下文执行异步 AI 调用
|
||||
comment = asyncio.run(
|
||||
ai.generate_comment(
|
||||
video_title=video_title,
|
||||
direction=direction or (persona.get("comment_style", [""])[0] if persona else "称赞"),
|
||||
persona=persona,
|
||||
)
|
||||
)
|
||||
if comment:
|
||||
log.info(" 🤖 AI 生成评论: %s", comment[:40])
|
||||
return comment
|
||||
except Exception as exc:
|
||||
log.warning(" ⚠️ AI 生成失败: %s", exc)
|
||||
|
||||
# 5) 最后兜底 → 随机返回一条
|
||||
comment = self._get_random_comment()
|
||||
if comment and "{keyword}" in comment:
|
||||
kw = self._extract_first_keyword(video_title)
|
||||
if kw:
|
||||
comment = comment.replace("{keyword}", kw)
|
||||
return comment
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "daily_comment",
|
||||
"name": "日常-评论激活",
|
||||
"description": "日常评论:打开视频 → 观看 → 评论 → 关闭。每天2-3次保持账号活跃,弹验证自动处理",
|
||||
"version": "1.1",
|
||||
"platform": "douyin",
|
||||
"steps": [
|
||||
{"step_id": 1, "op": "goto_home", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 2, "op": "scroll_feed", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 3, "op": "open_video", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 4, "op": "wait_watch", "args": {"seconds": null}, "pre": {}, "post": {}},
|
||||
{"step_id": 5, "op": "like", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 6, "op": "open_comments", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 7, "op": "post_comment", "args": {"text": "@corpus"}, "pre": {}, "post": {}},
|
||||
{"step_id": 8, "op": "auto_verify", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 9, "op": "close_comments", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 10, "op": "scroll_feed", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 11, "op": "open_video", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 12, "op": "wait_watch", "args": {"seconds": null}, "pre": {}, "post": {}},
|
||||
{"step_id": 13, "op": "open_comments", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 14, "op": "post_comment", "args": {"text": "@corpus"}, "pre": {}, "post": {}},
|
||||
{"step_id": 15, "op": "auto_verify", "args": {}, "pre": {}, "post": {}},
|
||||
{"step_id": 16, "op": "close_comments", "args": {}, "pre": {}, "post": {}}
|
||||
],
|
||||
"daily_limit": 3,
|
||||
"min_interval_minutes": 30,
|
||||
"args_schema": {
|
||||
"corpus": {"type": "string", "desc": "语料分类"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "douyin_comment",
|
||||
"name": "抖音-定向评论",
|
||||
"description": "给视频链接 → 打开 → 观看 → 评论",
|
||||
"version": "1.0",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": 1,
|
||||
"op": "goto_url",
|
||||
"args": {
|
||||
"url": "@url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_id": 2,
|
||||
"op": "wait_watch",
|
||||
"args": {
|
||||
"seconds": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_id": 3,
|
||||
"op": "open_comments",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"step_id": 4,
|
||||
"op": "post_comment",
|
||||
"args": {
|
||||
"text": "@comment_text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_id": 5,
|
||||
"op": "close_comments",
|
||||
"args": {}
|
||||
}
|
||||
],
|
||||
"platform": "douyin"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "douyin_reply",
|
||||
"name": "抖音-作者回复",
|
||||
"description": "打开自己视频 → 读评论 → 回复评论",
|
||||
"version": "1.0",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": 1,
|
||||
"op": "goto_home",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"step_id": 2,
|
||||
"op": "goto_profile",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"step_id": 3,
|
||||
"op": "read_my_comments",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"step_id": 4,
|
||||
"op": "reply_comment",
|
||||
"args": {
|
||||
"text": "@reply_text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_id": 5,
|
||||
"op": "go_back",
|
||||
"args": {}
|
||||
}
|
||||
],
|
||||
"platform": "douyin"
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
engine.py — mc run 执行引擎(v5.3 — 身份共享 + 多平台同浏览器)
|
||||
|
||||
核心逻辑:
|
||||
1. 按 identity_dir 分组账号 → 同身份共用一个浏览器
|
||||
2. 每个浏览器内先做完抖音,再做小红书
|
||||
3. 不同身份组之间错峰启动(防假死)
|
||||
4. 每个账号在其平台上顺序执行蓝图
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||
TOOL_DIR = SCRIPTS_DIR.parent
|
||||
from matrix_mgmt import AGENT_SYNC, AGENT_LOCAL
|
||||
|
||||
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 工具函数
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def resolve_account(account_id: str) -> dict:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == account_id:
|
||||
return a
|
||||
raise ValueError(f"账号不存在: {account_id}")
|
||||
|
||||
|
||||
def resolve_blueprint(name: str) -> Optional[dict]:
|
||||
bp_dir = TOOL_DIR / "blueprints"
|
||||
for f in sorted(bp_dir.glob("*.json")):
|
||||
bp = json.loads(f.read_text())
|
||||
if name in (bp.get("id", ""), bp.get("name", ""), f.stem):
|
||||
return bp
|
||||
if name in f.stem or f.stem in name:
|
||||
return json.loads(f.read_text())
|
||||
return None
|
||||
|
||||
|
||||
def check_cookie(identity_hint: str) -> str:
|
||||
"""检查身份目录 cookie 状态 → 'ok'|'no_cookie'|'expired'|'no_identity'"""
|
||||
if not identity_hint:
|
||||
return "no_identity"
|
||||
ident_dir = IDENTITIES_ROOT / identity_hint
|
||||
if not ident_dir.exists():
|
||||
return "no_identity"
|
||||
ck = ident_dir / "user_data" / "cookies.sqlite"
|
||||
if not ck.exists() or ck.stat().st_size < 100:
|
||||
return "no_cookie"
|
||||
try:
|
||||
import sqlite3
|
||||
conn_ck = sqlite3.connect(str(ck), timeout=2)
|
||||
cnt = conn_ck.execute("SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'").fetchone()[0]
|
||||
conn_ck.close()
|
||||
return "ok" if cnt > 0 else "expired"
|
||||
except:
|
||||
return "error"
|
||||
|
||||
|
||||
def group_accounts_by_identity(account_ids: List[str]) -> dict:
|
||||
"""按 identity_dir 分组账号,同目录的放到一个组里"""
|
||||
groups = {}
|
||||
for aid in account_ids:
|
||||
try:
|
||||
info = resolve_account(aid)
|
||||
except ValueError:
|
||||
continue
|
||||
ident = info.get("identity_dir", aid).replace("identities/", "")
|
||||
groups.setdefault(ident, []).append(info)
|
||||
return groups
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 执行报告
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class StepResult:
|
||||
def __init__(self, op: str, step_id: int, success: bool = True,
|
||||
duration: float = 0, error: str = ""):
|
||||
self.op = op; self.step_id = step_id; self.success = success
|
||||
self.duration = duration; self.error = error
|
||||
|
||||
def __str__(self):
|
||||
icon = "✅" if self.success else "❌"
|
||||
return f"{icon} [{self.step_id:2d}] {self.op:20s} ({self.duration:.1f}s) {self.error}"
|
||||
|
||||
|
||||
class AccountRunReport:
|
||||
def __init__(self, account: str, blueprint: str, round_idx: int):
|
||||
self.account = account; self.blueprint = blueprint; self.round = round_idx
|
||||
self.steps: List[StepResult] = []; self.start = time.time()
|
||||
self.success = 0; self.failed = 0; self.skipped = False
|
||||
|
||||
@property
|
||||
def duration(self) -> float: return time.time() - self.start
|
||||
|
||||
def add_step(self, result: StepResult):
|
||||
self.steps.append(result)
|
||||
if result.success: self.success += 1
|
||||
else: self.failed += 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"account": self.account, "blueprint": self.blueprint,
|
||||
"round": self.round, "success": self.success, "failed": self.failed,
|
||||
"skipped": self.skipped, "duration": round(self.duration, 1),
|
||||
"steps": [{"op": s.op, "step_id": s.step_id, "success": s.success,
|
||||
"duration": round(s.duration, 1), "error": s.error} for s in self.steps]}
|
||||
|
||||
|
||||
class BatchReport:
|
||||
def __init__(self):
|
||||
self.accounts: List[str] = []; self.blueprints: List[str] = []
|
||||
self.rounds_total = 0; self.mix_mode = False
|
||||
self.start = time.time(); self.account_reports: List[AccountRunReport] = []
|
||||
self.error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def duration(self) -> float: return time.time() - self.start
|
||||
@property
|
||||
def success(self) -> int: return sum(r.success for r in self.account_reports)
|
||||
@property
|
||||
def failed(self) -> int: return sum(r.failed for r in self.account_reports)
|
||||
@property
|
||||
def skipped(self) -> int: return sum(1 for r in self.account_reports if r.skipped)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"accounts": self.accounts, "blueprints": self.blueprints,
|
||||
"rounds": self.rounds_total, "mix_mode": self.mix_mode,
|
||||
"duration": round(self.duration, 1),
|
||||
"total_steps": self.success + self.failed,
|
||||
"success": self.success, "failed": self.failed,
|
||||
"skipped": self.skipped,
|
||||
"account_reports": [r.to_dict() for r in self.account_reports],
|
||||
"error": self.error}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 核心引擎
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 平台→默认蓝图映射(当不传蓝图时自动匹配)
|
||||
PLATFORM_BLUEPRINTS = {
|
||||
"douyin": "douyin_read_profile",
|
||||
"xiaohongshu": "xiaohongshu_read_profile",
|
||||
}
|
||||
PLATFORM_DAILY_BLUEPRINTS = {
|
||||
"douyin": "douyin_daily",
|
||||
"xiaohongshu": "xhs_daily",
|
||||
}
|
||||
|
||||
|
||||
class BatchEngine:
|
||||
def __init__(self, accounts: List[str], blueprints: List[str] = None,
|
||||
rounds: int = 10, mix: bool = False,
|
||||
corpus: List[str] = None,
|
||||
stagger: str = "15-30",
|
||||
keep_open: bool = False,
|
||||
max_browsers: int = 3,
|
||||
run_id: str = "",
|
||||
task_params: dict = None):
|
||||
self.accounts = accounts
|
||||
self.blueprints = blueprints or []
|
||||
self.rounds_total = rounds
|
||||
self.mix = mix
|
||||
self.corpus = corpus or []
|
||||
self.stagger = stagger
|
||||
self.keep_open = keep_open
|
||||
self.max_browsers = max_browsers
|
||||
self.run_id = run_id
|
||||
self.task_params = task_params or {}
|
||||
|
||||
def _mark_banned(self, account_id: str, platform: str):
|
||||
"""将账号标记为封号状态,写入 profiles.json"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
home = Path.home()
|
||||
profiles_path = home / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
|
||||
if profiles_path.exists():
|
||||
import json
|
||||
all_p = json.loads(profiles_path.read_text())
|
||||
else:
|
||||
all_p = {}
|
||||
all_p[account_id] = {
|
||||
"nickname": all_p.get(account_id, {}).get("nickname", "?"),
|
||||
"fans": "0", "following": "0", "likes": "0", "posts": "0", "bio": "",
|
||||
"status": "banned",
|
||||
"platform": platform,
|
||||
"updated": __import__("time").strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profiles_path.write_text(json.dumps(all_p, ensure_ascii=False, indent=2))
|
||||
log.warning(f" 🚫 [{account_id}] 已写入封号标记到 profiles.json")
|
||||
except Exception as e:
|
||||
log.warning(f" ❌ [{account_id}] 写入封号标记失败: {e}")
|
||||
|
||||
def _pick_blueprint(self, round_idx: int) -> str:
|
||||
if not self.blueprints:
|
||||
return "" # 由 _run_acct_on_conn 按平台自动匹配
|
||||
if self.mix:
|
||||
return random.choice(self.blueprints)
|
||||
return self.blueprints[(round_idx - 1) % len(self.blueprints)]
|
||||
|
||||
def _resolve_args(self, sargs: dict, account_id: str, platform: str) -> dict:
|
||||
"""解析蓝图步骤中的占位符,替换为实际内容"""
|
||||
import copy
|
||||
resolved = copy.deepcopy(sargs)
|
||||
for k, v in resolved.items():
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if "@corpus" in v:
|
||||
from mc.corpus import CorpusManager
|
||||
cm = CorpusManager()
|
||||
direction = resolved.get("direction", "") or self.task_params.get("direction", "")
|
||||
text = cm.get_comment_for_video(
|
||||
video_title=self.task_params.get("keyword", ""),
|
||||
direction=direction,
|
||||
account_id=account_id,
|
||||
)
|
||||
resolved[k] = v.replace("@corpus", text)
|
||||
if "@keyword" in v:
|
||||
kw = self.task_params.get("keyword", "")
|
||||
resolved[k] = v.replace("@keyword", kw)
|
||||
if "@direction" in v:
|
||||
dr = self.task_params.get("direction", "")
|
||||
resolved[k] = v.replace("@direction", dr)
|
||||
if "@url" in v:
|
||||
url = self.task_params.get("url", "")
|
||||
resolved[k] = v.replace("@url", url)
|
||||
if "@comment_text" in v:
|
||||
ct = self.task_params.get("comment_text", "")
|
||||
if ct:
|
||||
resolved[k] = v.replace("@comment_text", ct)
|
||||
if "@reply_text" in v:
|
||||
rt = self.task_params.get("reply_text", "")
|
||||
if rt:
|
||||
resolved[k] = v.replace("@reply_text", rt)
|
||||
if "@my_code" in v:
|
||||
mc = self.task_params.get("my_code", "")
|
||||
if mc:
|
||||
resolved[k] = v.replace("@my_code", mc)
|
||||
if "@target_code" in v:
|
||||
tc = self.task_params.get("target_code", "")
|
||||
if tc:
|
||||
resolved[k] = v.replace("@target_code", tc)
|
||||
if "@scene" in v:
|
||||
scene = self.task_params.get("scene", "first_comment")
|
||||
from mc.corpus import CorpusManager
|
||||
cm = CorpusManager()
|
||||
text = cm.get_comment_for_scene(
|
||||
persona=self.task_params.get("persona", ""),
|
||||
scene=scene,
|
||||
keyword=self.task_params.get("keyword", ""),
|
||||
round_num=self.task_params.get("round_num", 1),
|
||||
)
|
||||
if text:
|
||||
resolved[k] = v.replace("@scene", text)
|
||||
return resolved
|
||||
|
||||
async def _run_acct_on_conn(self, account_info: dict, blueprint_name: str,
|
||||
round_idx: int, conn) -> AccountRunReport:
|
||||
"""在已有浏览器连接上执行单个账号的单个轮次"""
|
||||
account_id = account_info["id"]
|
||||
platform = account_info.get("platform", "douyin")
|
||||
|
||||
# 没传蓝图时按平台自动匹配(支持不同平台账号同一次批量执行)
|
||||
if not blueprint_name:
|
||||
blueprint_name = PLATFORM_BLUEPRINTS.get(platform, "douyin_read_profile")
|
||||
report = AccountRunReport(account_id, blueprint_name, round_idx)
|
||||
|
||||
bp = resolve_blueprint(blueprint_name)
|
||||
if not bp:
|
||||
report.skipped = True
|
||||
return report
|
||||
steps = bp.get("steps", [])
|
||||
log.info(f" 📱 {account_id} ({platform}) → {blueprint_name} ({len(steps)}步)")
|
||||
|
||||
# 导航到该平台的首页
|
||||
home_url = "https://www.xiaohongshu.com/explore" if platform == "xiaohongshu" else "https://www.douyin.com/"
|
||||
try:
|
||||
await conn.page.goto(home_url, timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(5)
|
||||
except:
|
||||
await conn.page.goto(home_url, timeout=60000, wait_until="load")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# ── 钩子1: 登录状态检测(执行前确保登录)──
|
||||
from matrix_modules.account.login_state_machine import LoginStateMachine
|
||||
lsm = LoginStateMachine()
|
||||
login_ok = await lsm.ensure_login(conn.page, account_id, platform)
|
||||
if not login_ok:
|
||||
# 检测是否被封号
|
||||
if hasattr(lsm, 'last_status') and lsm.last_status == 'banned':
|
||||
log.warning(f" 🚫 [{account_id}] 账号被封禁,写入标记")
|
||||
self._mark_banned(account_id, platform)
|
||||
else:
|
||||
log.warning(f" ❌ [{account_id}] 登录检测不通过,跳过本轮")
|
||||
report.skipped = True
|
||||
return report
|
||||
|
||||
# 创建 platform ops
|
||||
if platform == "xiaohongshu":
|
||||
from ops.xhs_ops import XhsOps
|
||||
ops = XhsOps(conn.page)
|
||||
else:
|
||||
from douyin_ops import DouyinOps
|
||||
ops = DouyinOps(conn.page)
|
||||
print(f"[engine] set_account_id({account_id}) for platform={platform}")
|
||||
ops.set_account_id(account_id)
|
||||
|
||||
# 抖音:进入视频播放
|
||||
# 如果蓝图第一步是 goto_url(@url),用户提供了目标 URL,
|
||||
# 跳过 open_video 预调用(避免点开随机视频又导航走,浪费时间)
|
||||
_needs_open_video = True
|
||||
if steps and steps[0].get("op") == "goto_url":
|
||||
_args = steps[0].get("args", {})
|
||||
_url_arg = _args.get("url", "")
|
||||
if "@url" in _url_arg:
|
||||
_needs_open_video = False
|
||||
log.info(f" ⏭️ 跳过 open_video(蓝图第一步使用 @url 跳转到目标视频)")
|
||||
if platform == "douyin" and _needs_open_video:
|
||||
await ops.execute("open_video")
|
||||
|
||||
# 执行步骤
|
||||
for i, step in enumerate(steps):
|
||||
sn = step.get("step_id", i + 1)
|
||||
op_name = step.get("op", "")
|
||||
sargs = self._resolve_args(step.get("args", {}), account_id, platform)
|
||||
result = await ops.execute(op=op_name, args=sargs, step_id=sn)
|
||||
report.add_step(StepResult(op_name, sn, result.success, result.elapsed,
|
||||
"" if result.success else result.error))
|
||||
icon = "✅" if result.success else "❌"
|
||||
log.info(f" {icon} [{sn:2d}] {op_name:18s} → {result.detail[:25]} ({result.elapsed:.1f}s)")
|
||||
|
||||
# ── 钩子2: 操作后检查验证弹窗 ──
|
||||
verify_type = await lsm.check_verify_dialog(conn.page)
|
||||
if verify_type == "sms":
|
||||
log.warning(f" 📱 [{account_id}] 触发短信验证,自动恢复...")
|
||||
await lsm.recover_sms(conn.page, account_id)
|
||||
# 跳过当前步
|
||||
log.info(f" ⏭️ 跳过当前操作 [{sn}]")
|
||||
elif verify_type == "captcha":
|
||||
log.warning(f" 🔐 [{account_id}] 滑块验证,需手动处理")
|
||||
# 上报但不中断(留给你手动处理)
|
||||
|
||||
# ── 钩子3: 冷却管理 ──
|
||||
cooldown = self._get_cooldown(op_name)
|
||||
await asyncio.sleep(cooldown)
|
||||
|
||||
return report
|
||||
|
||||
def _get_cooldown(self, op_name: str) -> float:
|
||||
"""操作完成后冷却时间(自动去掉 xhs_/dy_ 前缀匹配)"""
|
||||
import random, re
|
||||
COOLDOWNS = {
|
||||
"like": (2, 4),
|
||||
"collect": (3, 6),
|
||||
"comment": (30, 45),
|
||||
"post_comment": (30, 45),
|
||||
"follow": (10, 20),
|
||||
"search": (3, 6),
|
||||
"scroll_feed": (2, 3),
|
||||
"wait_watch": (0, 0), # wait_watch 自带等待
|
||||
}
|
||||
# 去掉 xhs_/dy_ 前缀再匹配
|
||||
base_name = re.sub(r'^(xhs_|dy_|xhs_|dy_)', '', op_name)
|
||||
base = COOLDOWNS.get(base_name, COOLDOWNS.get(op_name, (2, 5)))
|
||||
return random.uniform(*base)
|
||||
|
||||
async def _run_identity_group(self, group_accts: List[dict]) -> List[AccountRunReport]:
|
||||
"""运行同一身份下的所有账号(共用一个浏览器)"""
|
||||
reports = []
|
||||
identity_dir = group_accts[0].get("identity_dir", group_accts[0]["id"]).replace("identities/", "")
|
||||
|
||||
# ── 浏览器数限制检查(L2 层,谁开浏览器谁检查)──
|
||||
import subprocess as _sp
|
||||
while True:
|
||||
try:
|
||||
out = _sp.run("pgrep -f camoufox | wc -l", shell=True, capture_output=True, text=True, timeout=5)
|
||||
count = int(out.stdout.strip() or 0)
|
||||
except:
|
||||
count = 0
|
||||
if count < self.max_browsers:
|
||||
break
|
||||
log.warning(f" ⏳ 已达最大浏览器数({self.max_browsers}),等待其他任务释放...")
|
||||
await asyncio.sleep(15)
|
||||
|
||||
if check_cookie(identity_dir) == "no_identity":
|
||||
log.warning(f" ⏭️ 身份 {identity_dir}: 身份目录不存在,全部跳过")
|
||||
for acct in group_accts:
|
||||
for r in range(1, self.rounds_total + 1):
|
||||
rpt = AccountRunReport(acct["id"], "", r)
|
||||
rpt.skipped = True
|
||||
reports.append(rpt)
|
||||
return reports
|
||||
|
||||
# cookie 不存在或过期 → 让 ensure_login 恢复链处理
|
||||
if check_cookie(identity_dir) in ("no_cookie", "expired", "error"):
|
||||
log.info(f" 🔐 [{identity_dir}] cookie 状态需恢复,由 LoginStateMachine 处理")
|
||||
|
||||
# 分配窗口位置槽位
|
||||
try:
|
||||
from mc.execution_policy import slot_for
|
||||
slot = slot_for(group_accts[0]["id"])
|
||||
win_pos = tuple(slot["position"]) if slot else (0, 0)
|
||||
win_size = tuple(slot["size"]) if slot else (802, 783)
|
||||
except Exception:
|
||||
win_pos, win_size = (0, 0), (802, 783)
|
||||
|
||||
from cdp_connector import CDPConnector
|
||||
conn = CDPConnector(browser_type="camoufox", headless=False,
|
||||
window=win_size,
|
||||
window_position=win_pos,
|
||||
identity_dir=str(IDENTITIES_ROOT / identity_dir))
|
||||
try:
|
||||
await conn.connect()
|
||||
await conn.init_anti_detection()
|
||||
log.info(f" 🖥️ 浏览器已启动 [身份: {identity_dir}]")
|
||||
|
||||
# 按平台排序:先抖音后小红书
|
||||
sorted_accts = sorted(group_accts, key=lambda a: (a.get("platform", "") == "xiaohongshu", a.get("platform", "")))
|
||||
|
||||
for acct_info in sorted_accts:
|
||||
acct_id = acct_info["id"]
|
||||
for round_idx in range(1, self.rounds_total + 1):
|
||||
bp_name = self._pick_blueprint(round_idx)
|
||||
rpt = await self._run_acct_on_conn(acct_info, bp_name, round_idx, conn)
|
||||
reports.append(rpt)
|
||||
if round_idx < self.rounds_total:
|
||||
wait = random.uniform(5, 10)
|
||||
log.info(f" ⏳ [{acct_id}] 等待 {wait:.0f}s...")
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f" ❌ 身份 {identity_dir}: {e}")
|
||||
for acct in group_accts:
|
||||
if not any(r.account == acct["id"] and r.blueprint for r in reports):
|
||||
rpt = AccountRunReport(acct["id"], "", 1)
|
||||
rpt.failed = 99
|
||||
reports.append(rpt)
|
||||
finally:
|
||||
if not self.keep_open:
|
||||
try:
|
||||
# 先给 8 秒优雅关闭
|
||||
await asyncio.wait_for(conn.close(), timeout=8)
|
||||
log.info(f" 🛑 浏览器已关闭 [身份: {identity_dir}]")
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
log.warning(f" ⚠️ 浏览器关闭超时 [{identity_dir}]: {e}")
|
||||
# 无论 close 成不成功,强制杀本身份的浏览器进程
|
||||
# 注意: identity_dir = 手机号/账号名, 唯一且不含空格, 不会误杀
|
||||
subprocess.run(["pkill", "-f", identity_dir],
|
||||
capture_output=True, timeout=3)
|
||||
await asyncio.sleep(2)
|
||||
# 验证
|
||||
for _ in range(3):
|
||||
chk = subprocess.run(["pgrep", "-f", identity_dir],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
if not chk.stdout.strip():
|
||||
break
|
||||
subprocess.run(["pkill", "-9", "-f", identity_dir],
|
||||
capture_output=True, timeout=2)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return reports
|
||||
|
||||
async def run(self) -> BatchReport:
|
||||
report = BatchReport()
|
||||
report.accounts = self.accounts; report.blueprints = self.blueprints
|
||||
report.rounds_total = self.rounds_total; report.mix_mode = self.mix
|
||||
|
||||
# 1. 按身份分组
|
||||
groups = group_accounts_by_identity(self.accounts)
|
||||
if not groups:
|
||||
report.error = "没有有效的账号"
|
||||
return report
|
||||
|
||||
log.info(f"\n{'='*50}")
|
||||
log.info(f" 🚀 批量执行计划")
|
||||
log.info(f" 身份组: {len(groups)}")
|
||||
for ident, accts in groups.items():
|
||||
log.info(f" 🖥️ {ident} → {[a['id'] for a in accts]}")
|
||||
log.info(f" 每账号 {self.rounds_total} 轮 | 蓝图: {self.blueprints}")
|
||||
log.info(f"{'='*50}")
|
||||
|
||||
# 2. 解析错峰参数
|
||||
try:
|
||||
s_min, s_max = [int(x) for x in self.stagger.split("-")]
|
||||
except:
|
||||
s_min, s_max = 15, 30
|
||||
|
||||
# 3. 并行执行身份组(Semaphore 控制并发数 = max_browsers)
|
||||
sem = asyncio.Semaphore(self.max_browsers)
|
||||
all_reports = []
|
||||
|
||||
async def _run_one_group(ident, accts):
|
||||
async with sem:
|
||||
log.info(f"\n 🖥️ 启动身份组 [{ident}]")
|
||||
return await self._run_identity_group(accts)
|
||||
|
||||
group_list = list(groups.items())
|
||||
tasks = [asyncio.create_task(_run_one_group(ident, accts))
|
||||
for ident, accts in group_list]
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
group_reports = await coro
|
||||
all_reports.extend(group_reports)
|
||||
|
||||
report.account_reports = all_reports
|
||||
|
||||
# 写入标准结果文件(供 CommandBus / 看板读取)
|
||||
if self.run_id:
|
||||
try:
|
||||
from mc.execution_policy import write_result
|
||||
status = "completed" if not report.error else "failed"
|
||||
write_result(self.run_id, status, report=report.to_dict(),
|
||||
error=report.error)
|
||||
except:
|
||||
pass
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
interact.py — 互动编排引擎 (InteractOrchestrator)
|
||||
|
||||
负责:
|
||||
1. 解析互动策略
|
||||
2. 生成识别码(三级接力用)
|
||||
3. 生成时间线
|
||||
4. 分配 chain_position 和 target_code
|
||||
5. 依次执行(含步间等待)
|
||||
|
||||
识别码系统(三级接力):
|
||||
first账号 发评时末尾加 "🌸XX"
|
||||
reply账号 搜 "🌸XX" → 找到 → 回复(末尾加"🌸YY")
|
||||
second账号 搜 "🌸YY" → 找到 → 回复
|
||||
|
||||
识别码格式: 🌸 + 两位数字 (如 🌸42)
|
||||
- 看起来像自然emoji,不触发风控
|
||||
- 100个组合,一轮接力用2个
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("interact")
|
||||
|
||||
# ── 识别码系统 ──────────────────────────────────────────
|
||||
|
||||
_CODE_POOL = list(range(10, 100)) # 10-99
|
||||
|
||||
def _alloc_codes(count: int = 2) -> list[int]:
|
||||
"""分配不重复的识别码"""
|
||||
random.shuffle(_CODE_POOL)
|
||||
return _CODE_POOL[:count]
|
||||
|
||||
def _fmt_code(n: int) -> str:
|
||||
"""🌸 + 两位数字"""
|
||||
return f"🌸{n:02d}"
|
||||
|
||||
def _strip_code(text: str) -> str:
|
||||
"""去除末尾的识别码"""
|
||||
import re
|
||||
return re.sub(r'\s*🌸\d{2}\s*$', '', text).strip()
|
||||
|
||||
|
||||
# ── 互动策略定义 ──────────────────────────────────────────
|
||||
|
||||
STRATEGIES = {
|
||||
"comment": {
|
||||
"label": "定向评论", "blueprint": "interact_comment",
|
||||
"description": "给指定视频发评论",
|
||||
},
|
||||
"chain": {
|
||||
"label": "三级接力", "blueprint": "interact_chain",
|
||||
"description": "A评→B回复→C再回复",
|
||||
},
|
||||
"like": {
|
||||
"label": "点赞互动", "blueprint": "interact_like",
|
||||
"description": "点赞视频+评论+关注",
|
||||
},
|
||||
"hot": {
|
||||
"label": "热评互动", "blueprint": "interact_hot",
|
||||
"description": "在热评下回复提升曝光",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 三级接力语料 ─────────────────────────────────────────
|
||||
|
||||
CHAIN_TEMPLATES = {
|
||||
"food": [
|
||||
{"first": "看着就很有食欲,周末去试试", "reply": "同意!上次去吃过一次确实不错", "second": "我也去过,他家那个招牌菜绝了"},
|
||||
{"first": "这个做法收藏了,回家就做", "reply": "做了好几次了,家人都说好吃", "second": "按着做了一次很成功,谢谢分享"},
|
||||
],
|
||||
"travel": [
|
||||
{"first": "这个地方太美了,已加入旅行清单", "reply": "去年去过,风景确实好,值得二刷", "second": "求攻略!准备下个月去打卡"},
|
||||
{"first": "这个机位太绝了,拍得真好", "reply": "早上五点去占的位置,值得", "second": "太卷了哈哈哈,不过效果确实好"},
|
||||
],
|
||||
"tech": [
|
||||
{"first": "分析得很透彻,学到了很多", "reply": "第三点之前完全没想到,受教了", "second": "按这个方法试了,效果很好"},
|
||||
],
|
||||
"lifestyle": [
|
||||
{"first": "这就是我理想的生活状态", "reply": "同款生活!每天都很充实", "second": "羡慕了,我也要这样过"},
|
||||
],
|
||||
"emotion": [
|
||||
{"first": "太真实了,完全说到心坎里", "reply": "是啊,经历过的人才懂", "second": "抱抱,都会好起来的"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _get_chain_texts(corpus: str = "") -> dict:
|
||||
"""获取一组三级接力文本"""
|
||||
pool = CHAIN_TEMPLATES.get(corpus, CHAIN_TEMPLATES["food"])
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
class InteractOrchestrator:
|
||||
"""互动编排器"""
|
||||
|
||||
def __init__(self, accounts: list, params: dict):
|
||||
self.accounts = accounts
|
||||
self.params = params
|
||||
self.url = params.get("url", "")
|
||||
self.strategy = params.get("strategy", "comment")
|
||||
self.direction = params.get("direction", "")
|
||||
self.corpus = params.get("corpus", "")
|
||||
self.blueprint = params.get("blueprint", "")
|
||||
self.rounds = params.get("rounds", 1)
|
||||
self.interval = params.get("interval", {"min": 300, "max": 600})
|
||||
self.dry_run = params.get("dry_run", False)
|
||||
|
||||
if not self.blueprint:
|
||||
self.blueprint = STRATEGIES.get(self.strategy, STRATEGIES["comment"])["blueprint"]
|
||||
|
||||
self.timeline: list[dict] = []
|
||||
self.results: list[dict] = []
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────
|
||||
|
||||
def plan(self) -> dict:
|
||||
"""生成执行计划"""
|
||||
self.timeline = self._generate_timeline()
|
||||
return {
|
||||
"strategy": self.strategy,
|
||||
"label": STRATEGIES.get(self.strategy, {}).get("label", "未知"),
|
||||
"blueprint": self.blueprint,
|
||||
"url": self.url,
|
||||
"total_accounts": len(self.accounts),
|
||||
"timeline": self.timeline,
|
||||
"steps": len(self.timeline),
|
||||
}
|
||||
|
||||
async def run(self) -> dict:
|
||||
"""执行互动计划"""
|
||||
self.timeline = self._generate_timeline()
|
||||
if self.dry_run:
|
||||
return {"status": "plan", "plan": self.plan()}
|
||||
|
||||
log.info(f"🚀 InteractOrchestrator | 策略={self.strategy} 账号={len(self.accounts)}")
|
||||
for step in self.timeline:
|
||||
if step.get("scheduled_at"):
|
||||
wait_sec = (step["scheduled_at"] - datetime.now()).total_seconds()
|
||||
if wait_sec > 0:
|
||||
log.info(f"⏳ 等待 {wait_sec:.0f}s → {step['label']}")
|
||||
await asyncio.sleep(wait_sec)
|
||||
|
||||
result = await self._execute_step(step)
|
||||
self.results.append(result)
|
||||
|
||||
if step != self.timeline[-1]:
|
||||
gap = random.randint(self.interval["min"], self.interval["max"])
|
||||
log.info(f"⏳ 步间间隔 {gap}s")
|
||||
await asyncio.sleep(gap)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"total_steps": len(self.timeline),
|
||||
"success": sum(1 for r in self.results if r.get("ok")),
|
||||
"failed": sum(1 for r in self.results if not r.get("ok")),
|
||||
"results": self.results,
|
||||
}
|
||||
|
||||
# ── 时间线生成 ──────────────────────────────────────
|
||||
|
||||
def _generate_timeline(self) -> list[dict]:
|
||||
if self.strategy == "chain":
|
||||
return self._gen_chain_timeline()
|
||||
elif self.strategy == "hot":
|
||||
return self._gen_hot_timeline()
|
||||
elif self.strategy == "like":
|
||||
return self._gen_like_timeline()
|
||||
else:
|
||||
return self._gen_comment_timeline()
|
||||
|
||||
def _gen_comment_timeline(self) -> list[dict]:
|
||||
timeline = []
|
||||
base = datetime.now()
|
||||
for i, acct in enumerate(self.accounts):
|
||||
offset = i * random.randint(self.interval["min"], self.interval["max"])
|
||||
timeline.append({
|
||||
"step": i, "account": acct["id"],
|
||||
"platform": acct.get("platform", "douyin"),
|
||||
"machine": acct.get("machine", ""),
|
||||
"blueprint": self.blueprint,
|
||||
"label": f"评论 {acct['id']}",
|
||||
"url": self.url,
|
||||
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
|
||||
})
|
||||
return timeline
|
||||
|
||||
def _gen_chain_timeline(self) -> list[dict]:
|
||||
"""三级接力:生成识别码 + 分配文本"""
|
||||
timeline = []
|
||||
base = datetime.now()
|
||||
codes = _alloc_codes(2) # 分配两个识别码
|
||||
texts = _get_chain_texts(self.corpus)
|
||||
min_gap, max_gap = self.interval["min"], self.interval["max"]
|
||||
|
||||
for i, acct in enumerate(self.accounts[:3]): # 最多3个
|
||||
position = ["first", "reply", "second_reply"][i]
|
||||
offset = i * random.randint(min_gap, max_gap)
|
||||
|
||||
step = {
|
||||
"step": i,
|
||||
"account": acct["id"],
|
||||
"platform": acct.get("platform", "douyin"),
|
||||
"machine": acct.get("machine", ""),
|
||||
"blueprint": "interact_chain",
|
||||
"label": f"{['一级评论','二级回复','三级再回复'][i]} {acct['id']}",
|
||||
"url": self.url,
|
||||
"chain_position": position,
|
||||
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
|
||||
}
|
||||
|
||||
if position == "first":
|
||||
step["comment_text"] = f"{texts['first']} {_fmt_code(codes[0])}"
|
||||
step["my_code"] = _fmt_code(codes[0])
|
||||
elif position == "reply":
|
||||
step["target_code"] = _fmt_code(codes[0])
|
||||
step["reply_text"] = f"{texts['reply']} {_fmt_code(codes[1])}"
|
||||
step["my_code"] = _fmt_code(codes[1])
|
||||
elif position == "second_reply":
|
||||
step["target_code"] = _fmt_code(codes[1])
|
||||
step["reply_text"] = texts["second"]
|
||||
|
||||
timeline.append(step)
|
||||
|
||||
return timeline
|
||||
|
||||
def _gen_like_timeline(self) -> list[dict]:
|
||||
timeline = []
|
||||
base = datetime.now()
|
||||
for i, acct in enumerate(self.accounts):
|
||||
offset = i * random.randint(self.interval["min"] // 2, self.interval["max"] // 2)
|
||||
timeline.append({
|
||||
"step": i, "account": acct["id"],
|
||||
"platform": acct.get("platform", "douyin"),
|
||||
"machine": acct.get("machine", ""),
|
||||
"blueprint": self.blueprint,
|
||||
"label": f"点赞 {acct['id']}",
|
||||
"url": self.url,
|
||||
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
|
||||
})
|
||||
return timeline
|
||||
|
||||
def _gen_hot_timeline(self) -> list[dict]:
|
||||
timeline = []
|
||||
base = datetime.now()
|
||||
for i, acct in enumerate(self.accounts):
|
||||
offset = i * random.randint(self.interval["min"], self.interval["max"])
|
||||
timeline.append({
|
||||
"step": i, "account": acct["id"],
|
||||
"platform": acct.get("platform", "douyin"),
|
||||
"machine": acct.get("machine", ""),
|
||||
"blueprint": "interact_hot",
|
||||
"label": f"热评 {acct['id']}",
|
||||
"url": self.url,
|
||||
"scheduled_at": base + timedelta(seconds=offset) if offset > 0 else None,
|
||||
})
|
||||
return timeline
|
||||
|
||||
# ── 单步执行 ──────────────────────────────────────────
|
||||
|
||||
async def _execute_step(self, step: dict) -> dict:
|
||||
"""
|
||||
执行时间线中的一步。
|
||||
|
||||
实际 DOM 操作由你录制的原子操作完成(占位)。
|
||||
当前实现:输出要执行的命令到日志,供后续挂接。
|
||||
"""
|
||||
log.info(f"▶ {step['label']}")
|
||||
log.info(f" 蓝图={step['blueprint']} url={step.get('url','')[:50]}")
|
||||
|
||||
if self.strategy == "chain":
|
||||
pos = step.get("chain_position", "")
|
||||
if pos == "first":
|
||||
log.info(f" → 发评: {step.get('comment_text','')}")
|
||||
log.info(f" 需要原子操作: post_comment_with_code(text, code)")
|
||||
elif pos in ("reply", "second_reply"):
|
||||
log.info(f" → 搜识别码: {step.get('target_code','')}")
|
||||
log.info(f" → 回复: {step.get('reply_text','')}")
|
||||
log.info(f" 需要原子操作: find_comment_by_code(code)")
|
||||
log.info(f" 需要原子操作: reply_comment(text)")
|
||||
|
||||
return {
|
||||
"step": step["step"],
|
||||
"account": step["account"],
|
||||
"ok": True,
|
||||
"blueprint": step["blueprint"],
|
||||
"label": step["label"],
|
||||
"chain_position": step.get("chain_position", ""),
|
||||
"code": step.get("my_code", ""),
|
||||
"target_code": step.get("target_code", ""),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user