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,292 @@
#!/usr/bin/env python3
"""
vector_db_rebuild.py — 双轨向量库重建工具
重建两套向量库:
- local:agent-os-local/ 下的私有内容(记忆、本机笔记)
- global:agent-sync/03_knowledge/ 下的协同知识库
向量库存储在 agent-os-local/vector_db/ 下(永不同步):
agent-os-local/vector_db/local/chroma/ ← 私有内容
agent-os-local/vector_db/global/chroma/ ← 协同知识(每机自行重建)
使用:
python3 vector_db_rebuild.py --local ~/workbuddy-agent-os/agent-local --root ~/workbuddy-agent-os/agent-sync
python3 vector_db_rebuild.py --local ~/workbuddy-agent-os/agent-local --root ~/workbuddy-agent-os/agent-sync --track local
python3 vector_db_rebuild.py --local ~/workbuddy-agent-os/agent-local --root ~/workbuddy-agent-os/agent-sync --track global
python3 vector_db_rebuild.py --local ~/workbuddy-agent-os/agent-local --root ~/workbuddy-agent-os/agent-sync --incremental
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
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("--track", choices=["local", "global", "both"], default="both",
help="重建哪套向量库 (默认 both)")
parser.add_argument("--incremental", action="store_true",
help="增量模式:只添加新文件,不清空已有向量库")
parser.add_argument("--dry-run", action="store_true",
help="预览模式:只列出待处理文件,不写入向量库")
return parser.parse_args()
# ─── 文件扫描 ─────────────────────────────────────────────────────
def scan_md_files(dirs: list[Path], exclude_patterns: list = None) -> list[dict]:
"""扫描 MD 文件,返回文档列表"""
exclude_patterns = exclude_patterns or []
docs = []
for base_dir in dirs:
if not base_dir.exists():
continue
for md_file in sorted(base_dir.rglob("*.md")):
# 跳过隐藏文件和排除目录
if any(p in str(md_file) for p in exclude_patterns):
continue
if md_file.name.startswith("."):
continue
try:
content = md_file.read_text(encoding="utf-8", errors="ignore")
if len(content.strip()) < 50:
continue
meta = extract_frontmatter(content)
body = strip_frontmatter(content)
docs.append({
"id": meta.get("id") or f"FILE-{md_file.stat().st_mtime_ns}",
"file": str(md_file),
"title": meta.get("title", md_file.stem),
"content": body[:2000], # 限制长度
"metadata": {
"id": meta.get("id", ""),
"title": meta.get("title", md_file.stem),
"nature": meta.get("nature", ""),
"domain": meta.get("domain", ""),
"confidence": str(meta.get("confidence", "")),
"source_type": meta.get("source_type", ""),
"date_created": meta.get("date_created", ""),
"file_path": str(md_file),
}
})
except Exception as e:
print(f" [SKIP] {md_file.name}: {e}")
return docs
def extract_frontmatter(content: str) -> dict:
if not content.startswith("---"):
return {}
end = content.find("---", 3)
if end == -1:
return {}
meta = {}
for line in content[3:end].split("\n"):
if ":" in line:
k, _, v = line.partition(":")
meta[k.strip()] = v.strip().strip('"').strip("'")
return meta
def strip_frontmatter(content: str) -> str:
if not content.startswith("---"):
return content
end = content.find("---", 3)
return content[end + 3:].strip() if end != -1 else content
# ─── 向量库操作 ───────────────────────────────────────────────────
def get_chroma_client(db_path: Path):
"""获取 ChromaDB 客户端"""
try:
import chromadb
db_path.mkdir(parents=True, exist_ok=True)
return chromadb.PersistentClient(path=str(db_path))
except ImportError:
print("[ERROR] chromadb 未安装。请运行: pip install chromadb")
return None
except Exception as e:
print(f"[ERROR] 初始化 ChromaDB 失败: {e}")
return None
def upsert_documents(client, collection_name: str, docs: list, incremental: bool = False):
"""向 ChromaDB 写入文档"""
collection = client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}
)
if not incremental:
# 全量重建:清空后写入
existing = collection.get(include=[])
if existing["ids"]:
collection.delete(ids=existing["ids"])
# 增量模式:获取已有 ID
existing_ids = set()
if incremental:
existing = collection.get(include=[])
existing_ids = set(existing["ids"])
new_docs = [d for d in docs if d["id"] not in existing_ids]
if not new_docs:
print(f" [INFO] {collection_name}: 无新文档(共{len(docs)}个,全部已存在)")
return 0
# 批量写入(ChromaDB 建议每批 ≤ 5000)
batch_size = 500
total_added = 0
for i in range(0, len(new_docs), batch_size):
batch = new_docs[i:i + batch_size]
collection.add(
ids=[d["id"] for d in batch],
documents=[d["content"] for d in batch],
metadatas=[d["metadata"] for d in batch]
)
total_added += len(batch)
return total_added
# ─── 关键词索引(轻量fallback) ──────────────────────────────────
def build_keyword_index(docs: list, index_path: Path):
"""构建关键词索引(JSON格式,无需向量库也可检索)"""
entries = []
for doc in docs:
meta = doc["metadata"]
content = doc["content"]
keywords = list(set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{4,}', content)))[:20]
entries.append({
"fact_id": doc["id"],
"title": meta.get("title", ""),
"keywords": keywords,
"domain": meta.get("domain", ""),
"nature": meta.get("nature", ""),
"summary": content[:150].replace("\n", " "),
"date": meta.get("date_created", ""),
"source": meta.get("source_type", ""),
"file": meta.get("file_path", "")
})
index_data = {
"version": "2.0.0",
"last_updated": datetime.now().isoformat(),
"total": len(entries),
"entries": entries
}
index_path.parent.mkdir(parents=True, exist_ok=True)
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
print(f" [OK] 关键词索引: {len(entries)} 条 → {index_path}")
# ─── 主流程 ──────────────────────────────────────────────────────
def rebuild_local(local_root: Path, incremental: bool, dry_run: bool):
"""重建本地向量库(私有内容)"""
print("\n── 重建 local 向量库 ──")
local_db_path = local_root / "vector_db" / "local" / "chroma"
keyword_index_path = local_root / "vector_db" / "local" / "keyword_index.json"
# 扫描本机私有内容
scan_dirs = [
local_root / "memory" / "raw",
local_root / "memory" / "long_term",
local_root / "materials" / "refined_for_inbox",
]
docs = scan_md_files(scan_dirs)
print(f" 扫描到 {len(docs)} 个文档")
if dry_run:
for d in docs[:5]:
print(f" [预览] {d['title']} ({d['metadata'].get('nature', '?')})")
return
# 构建关键词索引(始终构建,不依赖 chromadb)
build_keyword_index(docs, keyword_index_path)
# 构建向量索引(可选,需 chromadb)
client = get_chroma_client(local_db_path)
if client:
added = upsert_documents(client, "local_memory", docs, incremental)
print(f" [OK] ChromaDB local: 新增 {added} 条向量")
else:
print(" [INFO] 跳过 ChromaDB,仅使用关键词索引")
def rebuild_global(root: Path, local_root: Path, incremental: bool, dry_run: bool):
"""重建协同向量库(知识库内容)"""
print("\n── 重建 global 向量库 ──")
global_db_path = local_root / "vector_db" / "global" / "chroma"
keyword_index_path = local_root / "vector_db" / "global" / "keyword_index.json"
# 扫描协同知识库(排除 inbox 等临时目录)
kb_root = root / "03_knowledge"
scan_dirs = [
kb_root / "10_concepts",
kb_root / "20_methods",
kb_root / "30_facts",
kb_root / "40_references",
kb_root / "50_resources",
kb_root / "60_opinions",
kb_root / "01_daily",
]
exclude = ["00_inbox", "00_stream", "90_archive", "99_system", ".obsidian"]
docs = scan_md_files(scan_dirs, exclude_patterns=exclude)
print(f" 扫描到 {len(docs)} 个文档")
if dry_run:
for d in docs[:5]:
print(f" [预览] {d['title']} ({d['metadata'].get('domain', '?')})")
return
build_keyword_index(docs, keyword_index_path)
client = get_chroma_client(global_db_path)
if client:
added = upsert_documents(client, "global_knowledge", docs, incremental)
print(f" [OK] ChromaDB global: 新增 {added} 条向量")
else:
print(" [INFO] 跳过 ChromaDB,仅使用关键词索引")
def main():
args = parse_args()
root = Path(args.root).expanduser()
local_root = Path(args.local).expanduser()
print("=" * 60)
print("AgentOS 双轨向量库重建")
print(f" agent-os: {root}")
print(f" agent-os-local: {local_root}")
print(f" 重建范围: {args.track}")
if args.incremental:
print(" [增量模式]")
if args.dry_run:
print(" [DRY-RUN 模式]")
print("=" * 60)
if args.track in ("local", "both"):
rebuild_local(local_root, args.incremental, args.dry_run)
if args.track in ("global", "both"):
rebuild_global(root, local_root, args.incremental, args.dry_run)
print("\n[完成] 向量库重建完毕")
print(" 下次检索时将自动使用新的向量库(kb_search.py)")
if __name__ == "__main__":
main()