feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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}"
@@ -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)
@@ -0,0 +1,18 @@
# vid_20260504_8817 — 毛主席的语言艺术将失败转化为传奇
> 由 AVE 脚本工厂自动生成 | 2026-05-04
## 来源信息
| 项目 | 内容 |
|------|------|
| 核心主题 | 毛主席的语言艺术将失败转化为传奇 |
| 目标人群 | 历史爱好者/文学研究者/毛泽东思想研究者 |
| 风格 | knowledge_lecture | 钩子 | 故事性钩子 | 情绪 | 崇敬与赞叹 |
## 关键要点
- 通过命名赋予历史事件新的意义
- 语言重构改变了历史叙事的维度
## 生产备注
- **语音**: 火山引擎 **BGM**: Ace Studio **素材**: Pexels搜索+本地兜底 **分辨率**: 1080×1920竖屏
- **注意**: 脚本为AI生成,建议人工审核。配画关键词可能需要手动调整。
@@ -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
@@ -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 慢速
- **画面**: 推荐黑白/暖色调滤镜,配合回忆感
- **字幕**: 逐句字幕(首段+结尾金句建议关闭字幕增强冲击力)
@@ -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