Files
MediaCrawler/mac-agent-os-main/02_skills/sync_manager/sync_manager.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

321 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
sync_manager.py — 知识库备份、导出、同步状态检查
模式:
backup — 全量备份 03_knowledge/ + 04_memory/long_term/ 到 memory_backup/
export — 导出可分发知识包(可按领域/标签过滤)
status — 检查坚果云同步路径状态
使用:
python3 sync_manager.py --mode backup
python3 sync_manager.py --mode backup --keep 5
python3 sync_manager.py --mode export --domain 10_concepts 20_methods
python3 sync_manager.py --mode export --tag AI工具
python3 sync_manager.py --mode status
python3 sync_manager.py --mode backup --dry-run
"""
import argparse
import os
import re
import shutil
import sys
import tarfile
from datetime import datetime
from pathlib import Path
# ── 配置 ──────────────────────────────────────────────────────────────────────
BACKUP_SOURCES = [
"03_knowledge",
"04_memory/long_term",
]
KNOWLEDGE_DOMAINS = [
"10_concepts", "20_methods", "30_facts",
"40_references", "50_resources", "60_opinions",
]
DEFAULT_NUTSTORE_PATH = "~/NutstoreCloudBridge"
DEFAULT_KEEP = 10
# ── 参数解析 ───────────────────────────────────────────────────────────────────
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 同步管理器")
parser.add_argument("--root", default="~/workbuddy-agent-os/agent-sync", help="agent-os 根目录")
parser.add_argument("--mode", choices=["backup", "export", "status"], required=True,
help="运行模式")
parser.add_argument("--keep", type=int, default=DEFAULT_KEEP,
help="备份模式:保留最近N份(默认10)")
parser.add_argument("--domain", nargs="+", default=None,
help="导出模式:只导出指定领域目录(如 10_concepts 20_methods)")
parser.add_argument("--tag", default=None,
help="导出模式:只导出含有指定 tag 的文件")
parser.add_argument("--nutstore", default=DEFAULT_NUTSTORE_PATH,
help="坚果云同步路径(用于 status 模式)")
parser.add_argument("--dry-run", action="store_true", help="模拟运行,不实际写入")
return parser.parse_args()
# ── 工具函数 ───────────────────────────────────────────────────────────────────
def ensure_dir(path: Path, dry_run: bool = False):
if not dry_run:
path.mkdir(parents=True, exist_ok=True)
def format_size(size_bytes: int) -> str:
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes //= 1024
return f"{size_bytes:.1f} TB"
def get_md_tag(file_path: Path) -> list:
"""从 frontmatter 提取 tags 列表"""
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
if not content.startswith("---"):
return []
end = content.find("\n---", 3)
if end == -1:
return []
frontmatter = content[3:end]
# 匹配 tags: [a, b] 或多行列表
m = re.search(r'tags\s*:\s*\[([^\]]+)\]', frontmatter)
if m:
return [t.strip().strip('"\'') for t in m.group(1).split(",")]
# 多行格式
tags = []
in_tags = False
for line in frontmatter.splitlines():
if re.match(r'\s*tags\s*:', line):
in_tags = True
continue
if in_tags:
if line.strip().startswith("- "):
tags.append(line.strip()[2:].strip())
elif line.strip() and not line.startswith(" "):
break
return tags
except Exception:
return []
# ── backup 模式 ────────────────────────────────────────────────────────────────
def run_backup(root: Path, keep: int, dry_run: bool):
backup_dir = root / "04_memory" / "memory_backup"
ensure_dir(backup_dir, dry_run)
now = datetime.now()
filename = f"kb_backup_{now.strftime('%Y-%m-%d_%H%M')}.tar.gz"
output_path = backup_dir / filename
sources = []
for src in BACKUP_SOURCES:
p = root / src
if p.exists():
sources.append(p)
else:
print(f" [跳过] {p} 不存在")
if not sources:
print("[ERROR] 没有找到任何备份源")
sys.exit(1)
total_files = sum(
sum(1 for _ in src.rglob("*") if _.is_file())
for src in sources
)
total_size = sum(
sum(f.stat().st_size for f in src.rglob("*") if f.is_file())
for src in sources
)
print(f"备份目标: {', '.join(str(s.relative_to(root)) for s in sources)}")
print(f"文件数量: {total_files} 预计大小: {format_size(total_size)}")
print(f"输出路径: {output_path}")
if dry_run:
print("[dry-run] 跳过实际打包")
return
with tarfile.open(output_path, "w:gz") as tar:
for src in sources:
tar.add(src, arcname=src.relative_to(root))
actual_size = output_path.stat().st_size
print(f"✅ 备份完成: {filename} ({format_size(actual_size)})")
# 清理旧备份
_prune_backups(backup_dir, keep)
def _prune_backups(backup_dir: Path, keep: int):
"""保留最近 keep 份,删除更旧的"""
pattern = re.compile(r"kb_backup_\d{4}-\d{2}-\d{2}_\d{4}\.tar\.gz")
backups = sorted(
[f for f in backup_dir.iterdir() if f.is_file() and pattern.match(f.name)],
key=lambda f: f.stat().st_mtime,
reverse=True,
)
to_delete = backups[keep:]
if to_delete:
print(f"清理旧备份(保留最近{keep}份):")
for f in to_delete:
print(f" 删除: {f.name}")
f.unlink()
else:
print(f"备份数量: {len(backups)} 份,无需清理")
# ── export 模式 ────────────────────────────────────────────────────────────────
def run_export(root: Path, domains: list, tag: str, dry_run: bool):
now = datetime.now()
suffix = f"_tag-{tag}" if tag else ""
suffix += f"_dom-{'_'.join(domains)}" if domains else ""
filename = f"kb_export_{now.strftime('%Y-%m-%d_%H%M')}{suffix}.tar.gz"
export_dir = root / "04_memory" / "memory_backup"
ensure_dir(export_dir, dry_run)
output_path = export_dir / filename
# 确定要导出的目录
kb_root = root / "03_knowledge"
if domains:
scan_dirs = [kb_root / d for d in domains if (kb_root / d).exists()]
missing = [d for d in domains if not (kb_root / d).exists()]
if missing:
print(f"[警告] 以下领域目录不存在: {', '.join(missing)}")
else:
scan_dirs = [kb_root / d for d in KNOWLEDGE_DOMAINS if (kb_root / d).exists()]
# 收集文件
all_files = []
for d in scan_dirs:
for f in d.rglob("*.md"):
if tag:
if tag in get_md_tag(f):
all_files.append(f)
else:
all_files.append(f)
if not all_files:
print("[ERROR] 没有找到符合条件的文件")
sys.exit(1)
total_size = sum(f.stat().st_size for f in all_files)
filter_desc = f" 标签过滤: {tag}" if tag else ""
domain_desc = f" 领域: {', '.join(domains)}" if domains else " 领域: 全部"
print(f"导出范围: {domain_desc}{filter_desc}")
print(f"文件数量: {len(all_files)} 预计大小: {format_size(total_size)}")
print(f"输出路径: {output_path}")
if dry_run:
print("[dry-run] 跳过实际打包")
for f in all_files[:5]:
print(f" 示例: {f.relative_to(root)}")
if len(all_files) > 5:
print(f" ... 共 {len(all_files)} 个文件")
return
with tarfile.open(output_path, "w:gz") as tar:
for f in all_files:
tar.add(f, arcname=f.relative_to(root))
actual_size = output_path.stat().st_size
print(f"✅ 导出完成: {filename} ({format_size(actual_size)},{len(all_files)} 个文件)")
# ── status 模式 ────────────────────────────────────────────────────────────────
def run_status(root: Path, nutstore_path: str):
print("=" * 50)
print("AgentOS 同步状态检查")
print("=" * 50)
# 1. 本地知识库状态
kb_root = root / "03_knowledge"
facts_db = root / "04_memory" / "long_term" / "facts.db"
backup_dir = root / "04_memory" / "memory_backup"
if kb_root.exists():
md_count = sum(1 for _ in kb_root.rglob("*.md"))
kb_size = sum(f.stat().st_size for f in kb_root.rglob("*") if f.is_file())
print(f"\n📚 知识库: {md_count} 个 MD 文件 ({format_size(kb_size)})")
else:
print(f"\n📚 知识库: ❌ 目录不存在 ({kb_root})")
if facts_db.exists():
facts_mtime = datetime.fromtimestamp(facts_db.stat().st_mtime)
facts_size = facts_db.stat().st_size
print(f"🧠 facts.db: {format_size(facts_size)} 最后修改: {facts_mtime.strftime('%Y-%m-%d %H:%M')}")
else:
print(f"🧠 facts.db: ❌ 不存在")
# 2. 备份状态
if backup_dir.exists():
pattern = re.compile(r"kb_backup_\d{4}-\d{2}-\d{2}_\d{4}\.tar\.gz")
backups = sorted(
[f for f in backup_dir.iterdir() if f.is_file() and pattern.match(f.name)],
key=lambda f: f.stat().st_mtime,
reverse=True,
)
if backups:
latest = backups[0]
latest_time = datetime.fromtimestamp(latest.stat().st_mtime)
print(f"\n💾 最新备份: {latest.name}")
print(f" 时间: {latest_time.strftime('%Y-%m-%d %H:%M')} 大小: {format_size(latest.stat().st_size)}")
print(f" 共 {len(backups)} 份备份")
else:
print(f"\n💾 备份: ❌ 暂无备份文件")
else:
print(f"\n💾 备份目录: ❌ 不存在")
# 3. 坚果云同步状态
nutstore = Path(nutstore_path).expanduser()
print(f"\n☁️ 坚果云同步路径: {nutstore}")
if nutstore.exists():
all_files = [f for f in nutstore.rglob("*") if f.is_file()]
if all_files:
latest_sync = max(all_files, key=lambda f: f.stat().st_mtime)
sync_time = datetime.fromtimestamp(latest_sync.stat().st_mtime)
sync_size = sum(f.stat().st_size for f in all_files)
print(f" 状态: ✅ 路径存在")
print(f" 文件数: {len(all_files)} 总大小: {format_size(sync_size)}")
print(f" 最近同步: {sync_time.strftime('%Y-%m-%d %H:%M')} ({latest_sync.name})")
else:
print(f" 状态: ⚠️ 路径存在但为空")
else:
print(f" 状态: ❌ 路径不存在(坚果云未挂载或未安装)")
print()
# ── 入口 ───────────────────────────────────────────────────────────────────────
def main():
args = parse_args()
root = Path(args.root).expanduser()
if not root.exists():
print(f"[ERROR] agent-os 根目录不存在: {root}")
sys.exit(1)
if args.mode == "backup":
run_backup(root, args.keep, args.dry_run)
elif args.mode == "export":
run_export(root, args.domain or [], args.tag, args.dry_run)
elif args.mode == "status":
run_status(root, args.nutstore)
if __name__ == "__main__":
main()