feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AgentOS 知识入库脚本
|
||||
用途:将网页内容或纯文本清洗、分类、生成知识卡片并写入 Obsidian 知识库
|
||||
使用:python3 kb_ingest.py --root ~/workbuddy-agent-os/agent-sync --url https://example.com
|
||||
python3 kb_ingest.py --root ~/workbuddy-agent-os/agent-sync --file input.md
|
||||
python3 kb_ingest.py --root ~/workbuddy-agent-os/agent-sync --text "原始文本内容"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 预定义领域列表
|
||||
DOMAINS = [
|
||||
"计算机科学", "人工智能", "金融", "法律", "医学", "物理", "数学",
|
||||
"心理学", "哲学", "历史", "工程", "设计", "商业", "个人管理",
|
||||
"个人洞见", "其他"
|
||||
]
|
||||
|
||||
# 知识属性类型
|
||||
NATURE_TYPES = ["fact", "opinion", "method", "regulation", "reference", "data", "quote", "axiom"]
|
||||
|
||||
# nature → 目录映射
|
||||
NATURE_DIR_MAP = {
|
||||
"concept": "10_concepts",
|
||||
"method": "20_methods",
|
||||
"fact": "30_facts",
|
||||
"reference": "40_references/docs",
|
||||
"resource": "50_resources",
|
||||
"opinion": "60_opinions",
|
||||
"data": "30_facts",
|
||||
"regulation": "30_facts",
|
||||
"quote": "60_opinions",
|
||||
"axiom": "10_concepts",
|
||||
}
|
||||
|
||||
# 领域英文 → 中文映射
|
||||
DOMAIN_DIR_MAP = {
|
||||
"计算机科学": "cs",
|
||||
"人工智能": "ai",
|
||||
"金融": "finance",
|
||||
"法律": "law",
|
||||
"医学": "medicine",
|
||||
"物理": "physics",
|
||||
"数学": "math",
|
||||
"心理学": "psychology",
|
||||
"哲学": "philosophy",
|
||||
"历史": "history",
|
||||
"工程": "engineering",
|
||||
"设计": "design",
|
||||
"商业": "business",
|
||||
"个人管理": "personal-management",
|
||||
"个人洞见": "personal-insight",
|
||||
"其他": "other",
|
||||
}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="AgentOS 知识入库")
|
||||
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
|
||||
parser.add_argument("--url", default=None, help="网页 URL")
|
||||
parser.add_argument("--file", default=None, help="输入文件路径")
|
||||
parser.add_argument("--text", default=None, help="直接输入文本")
|
||||
parser.add_argument("--title", default=None, help="知识标题")
|
||||
parser.add_argument("--domain", default=None, help="领域(逗号分隔)")
|
||||
parser.add_argument("--nature", default=None, help="知识属性")
|
||||
parser.add_argument("--tags", default=None, help="标签(逗号分隔)")
|
||||
parser.add_argument("--inbox", action="store_true", help="仅放入收件箱,不分类")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def fetch_url_content(url: str) -> str:
|
||||
"""抓取网页内容"""
|
||||
try:
|
||||
import trafilatura
|
||||
downloaded = trafilatura.fetch_url(url)
|
||||
if downloaded:
|
||||
content = trafilatura.extract(downloaded)
|
||||
return content or ""
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# fallback: 用 urllib
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
html = resp.read().decode("utf-8", errors="ignore")
|
||||
|
||||
# 简单提取正文(去除 HTML 标签)
|
||||
text = re.sub(r'<[^>]+>', ' ', html)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text[:10000] # 限制长度
|
||||
|
||||
|
||||
def classify_content(content: str, title: str = "") -> dict:
|
||||
"""
|
||||
分类知识内容(简化版,实际应调用 LLM)
|
||||
返回: {domain, nature, confidence, tags, summary}
|
||||
"""
|
||||
# 简单规则分类
|
||||
domain = "其他"
|
||||
nature = "fact"
|
||||
confidence = 0.5
|
||||
tags = []
|
||||
summary = content[:60]
|
||||
|
||||
# 关键词匹配领域
|
||||
domain_keywords = {
|
||||
"计算机科学": ["代码", "编程", "程序", "数据库", "服务器", "API", "Python", "JavaScript", "算法"],
|
||||
"人工智能": ["AI", "模型", "训练", "深度学习", "LLM", "GPT", "embedding", "向量"],
|
||||
"金融": ["股票", "基金", "投资", "利率", "市场", "财报", "估值"],
|
||||
"个人洞见": ["我觉得", "我认为", "感悟", "心得", "人生", "经历"],
|
||||
}
|
||||
|
||||
for d, keywords in domain_keywords.items():
|
||||
for kw in keywords:
|
||||
if kw.lower() in content.lower():
|
||||
domain = d
|
||||
break
|
||||
if domain != "其他":
|
||||
break
|
||||
|
||||
# 关键词匹配 nature
|
||||
if any(kw in content for kw in ["应该", "步骤", "方法", "如何"]):
|
||||
nature = "method"
|
||||
elif any(kw in content for kw in ["我认为", "我觉得", "可能", "也许"]):
|
||||
nature = "opinion"
|
||||
elif any(kw in content for kw in ["规定", "法规", "制度", "条例"]):
|
||||
nature = "regulation"
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"nature": nature,
|
||||
"confidence": confidence,
|
||||
"tags": tags,
|
||||
"summary": summary
|
||||
}
|
||||
|
||||
|
||||
def generate_kb_id(date: str, index: int = 1) -> str:
|
||||
"""生成知识 ID"""
|
||||
return f"KB-{date.replace('-', '')}-{index:03d}"
|
||||
|
||||
|
||||
def generate_frontmatter(kb_id: str, title: str, classification: dict, source: str = "") -> str:
|
||||
"""生成 Frontmatter"""
|
||||
domain = classification.get("domain", "其他")
|
||||
if isinstance(domain, str):
|
||||
domain = [domain]
|
||||
|
||||
fm = f"""---
|
||||
id: {kb_id}
|
||||
title: "{title}"
|
||||
type: {classification.get('nature', 'concept')}
|
||||
status: draft
|
||||
nature: {classification.get('nature', 'fact')}
|
||||
domain: {json.dumps(domain, ensure_ascii=False)}
|
||||
confidence: {classification.get('confidence', 0.5)}
|
||||
source: "{source}"
|
||||
date_created: {datetime.now().strftime('%Y-%m-%d')}
|
||||
date_modified: {datetime.now().strftime('%Y-%m-%d')}
|
||||
version: 1
|
||||
summary: "{classification.get('summary', '')[:60]}"
|
||||
---"""
|
||||
return fm
|
||||
|
||||
|
||||
def determine_target_dir(root: str, nature: str, domain: str) -> Path:
|
||||
"""确定知识卡片的目标目录"""
|
||||
base_dir = NATURE_DIR_MAP.get(nature, "10_concepts")
|
||||
domain_dir = DOMAIN_DIR_MAP.get(domain, "other")
|
||||
|
||||
# 概念类知识按领域分子目录
|
||||
if base_dir == "10_concepts":
|
||||
return Path(root) / "03_knowledge" / base_dir / domain_dir
|
||||
else:
|
||||
return Path(root) / "03_knowledge" / base_dir
|
||||
|
||||
|
||||
def write_knowledge_card(root: str, target_dir: Path, filename: str,
|
||||
frontmatter: str, content: str):
|
||||
"""写入知识卡片文件"""
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = target_dir / filename
|
||||
|
||||
full_content = frontmatter + "\n\n" + content
|
||||
file_path.write_text(full_content, encoding="utf-8")
|
||||
return file_path
|
||||
|
||||
|
||||
def log_ingest(root: str, kb_id: str, decision: str, reason: str, confidence: float):
|
||||
"""记录入库日志"""
|
||||
log_dir = Path(root) / "04_memory" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "kb_ingest.log"
|
||||
|
||||
timestamp = datetime.now().isoformat()
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"{timestamp} | {kb_id} | {decision} | {reason} | {confidence}\n")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = os.path.expanduser(args.root)
|
||||
|
||||
# 获取内容
|
||||
content = ""
|
||||
source = ""
|
||||
|
||||
if args.url:
|
||||
print(f"[INFO] 抓取网页: {args.url}")
|
||||
content = fetch_url_content(args.url)
|
||||
source = args.url
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"[ERROR] 文件不存在: {args.file}")
|
||||
return
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
source = str(file_path)
|
||||
elif args.text:
|
||||
content = args.text
|
||||
source = "direct_input"
|
||||
else:
|
||||
print("[ERROR] 请指定 --url, --file 或 --text")
|
||||
return
|
||||
|
||||
if not content.strip():
|
||||
print("[ERROR] 内容为空")
|
||||
return
|
||||
|
||||
title = args.title or "未命名知识"
|
||||
|
||||
# 分类
|
||||
if args.inbox:
|
||||
# 仅放入收件箱
|
||||
target_dir = Path(root) / "03_knowledge" / "00_inbox"
|
||||
classification = {
|
||||
"domain": args.domain or "其他",
|
||||
"nature": args.nature or "fact",
|
||||
"confidence": 0.5,
|
||||
"tags": [],
|
||||
"summary": content[:60]
|
||||
}
|
||||
else:
|
||||
# 自动分类
|
||||
classification = classify_content(content, title)
|
||||
# 命令行参数覆盖
|
||||
if args.domain:
|
||||
classification["domain"] = args.domain
|
||||
if args.nature:
|
||||
classification["nature"] = args.nature
|
||||
|
||||
# 生成知识卡片
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
kb_id = generate_kb_id(today)
|
||||
frontmatter = generate_frontmatter(kb_id, title, classification, source)
|
||||
|
||||
if args.inbox:
|
||||
target_dir = Path(root) / "03_knowledge" / "00_inbox"
|
||||
else:
|
||||
target_dir = determine_target_dir(root, classification["nature"], classification["domain"])
|
||||
|
||||
# 生成文件名(英文,用时间戳确保唯一)
|
||||
safe_title = re.sub(r'[^\w\u4e00-\u9fff-]', '_', title)[:50]
|
||||
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{safe_title}.md"
|
||||
|
||||
# 写入
|
||||
file_path = write_knowledge_card(root, target_dir, filename, frontmatter, content)
|
||||
print(f"[OK] 知识卡片已创建: {file_path}")
|
||||
|
||||
# 记录日志
|
||||
log_ingest(root, kb_id, "created", "自动分类入库", classification["confidence"])
|
||||
|
||||
print(f"[INFO] KB-ID: {kb_id}")
|
||||
print(f"[INFO] 领域: {classification['domain']}")
|
||||
print(f"[INFO] 属性: {classification['nature']}")
|
||||
print(f"[INFO] 置信度: {classification['confidence']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user