移植自 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 带网关原话 / 正常返回)。
313 lines
11 KiB
Python
313 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
collect_to_inbox.py —— 扫描知识库各分类目录,提取主要内容生成标准化 MD 放入收件箱
|
||
|
||
用法:
|
||
python3 collect_to_inbox.py --root ~/workbuddy-agent-os/agent-sync [--dry-run] [--force]
|
||
|
||
参数:
|
||
--root agent-os 根目录(默认 ~/workbuddy-agent-os/agent-sync)
|
||
--dry-run 只预览不写入
|
||
--force 忽略 collected 标记,重新收集
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
# ─── 角色检查:所有机器均可执行汇聚 ───
|
||
try:
|
||
sys.path.insert(0, str(Path.home() / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "01_system"))
|
||
from role_check import check_capability
|
||
if not check_capability("knowledge_collection"):
|
||
print("⛔ 能力 knowledge_collection 未启用,跳过 collect_to_inbox")
|
||
sys.exit(0)
|
||
except ImportError as e:
|
||
print(f"⚠️ 角色检查模块不可用 ({e}),继续执行")
|
||
|
||
# 扫描的源目录配置:(相对路径, 提取策略)
|
||
SCAN_DIRS = [
|
||
("03_knowledge/50_resources/视频笔记", "extract_summary"),
|
||
("03_knowledge/50_resources/字幕存档", "extract_head"),
|
||
("03_knowledge/50_resources/阅读笔记", "extract_summary"),
|
||
("03_knowledge/50_resources/全文存档", "extract_head"),
|
||
("03_knowledge/50_resources/翻译存档", "extract_summary"),
|
||
("03_knowledge/50_resources/灵感素材", "extract_summary"),
|
||
("03_knowledge/50_resources/语音转写", "extract_key_points"),
|
||
("03_knowledge/20_methods", "extract_method"),
|
||
("03_knowledge/01_daily/闪念笔记", "extract_key_points"),
|
||
("03_knowledge/40_references", "extract_summary"),
|
||
]
|
||
|
||
# 不扫描的目录
|
||
SKIP_DIRS = {"00_inbox", "99_system", "90_archive", "10_concepts", "30_facts", "60_opinions", "50_resources/资源索引"}
|
||
|
||
|
||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||
"""解析 Frontmatter,返回 (metadata_dict, body_text)"""
|
||
if not content.startswith("---"):
|
||
return {}, content
|
||
|
||
end = content.find("---", 3)
|
||
if end == -1:
|
||
return {}, content
|
||
|
||
fm_text = content[3:end].strip()
|
||
body = content[end + 3:].strip()
|
||
|
||
metadata = {}
|
||
for line in fm_text.split("\n"):
|
||
line = line.strip()
|
||
if ":" in line:
|
||
key, _, val = line.partition(":")
|
||
key = key.strip()
|
||
val = val.strip().strip('"').strip("'")
|
||
if val:
|
||
metadata[key] = val
|
||
|
||
return metadata, body
|
||
|
||
|
||
def extract_summary(body: str, max_chars: int = 500) -> str:
|
||
"""提取摘要:取前 max_chars 字符"""
|
||
if len(body) <= max_chars:
|
||
return body
|
||
return body[:max_chars] + "\n\n...(内容已截断,完整内容见源文件)"
|
||
|
||
|
||
def extract_head(body: str, max_chars: int = 500) -> str:
|
||
"""提取头部内容"""
|
||
return extract_summary(body, max_chars)
|
||
|
||
|
||
def extract_key_points(body: str, max_chars: int = 500) -> str:
|
||
"""提取关键要点:优先提取列表项"""
|
||
lines = body.split("\n")
|
||
points = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if stripped.startswith(("- ", "* ", "1.", "2.", "3.", "4.", "5.")):
|
||
points.append(stripped)
|
||
if len("\n".join(points)) > max_chars:
|
||
break
|
||
|
||
if points:
|
||
result = "\n".join(points[:20])
|
||
if len(result) > max_chars:
|
||
result = result[:max_chars] + "\n\n...(内容已截断)"
|
||
return result
|
||
|
||
return extract_summary(body, max_chars)
|
||
|
||
|
||
def extract_method(body: str, max_chars: int = 500) -> str:
|
||
"""提取方法论:方法名 + 步骤"""
|
||
lines = body.split("\n")
|
||
# 提取标题和编号列表
|
||
method_lines = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if stripped.startswith("#") or stripped.startswith(("-", "*", "1.", "2.", "3.", "4.", "5.")):
|
||
method_lines.append(stripped)
|
||
if len("\n".join(method_lines)) > max_chars:
|
||
break
|
||
|
||
if method_lines:
|
||
return "\n".join(method_lines[:30])
|
||
|
||
return extract_summary(body, max_chars)
|
||
|
||
|
||
EXTRACT_FUNCS = {
|
||
"extract_summary": extract_summary,
|
||
"extract_head": extract_head,
|
||
"extract_key_points": extract_key_points,
|
||
"extract_method": extract_method,
|
||
}
|
||
|
||
|
||
def infer_nature(source_dir: str, metadata: dict) -> str:
|
||
"""根据源目录和元数据推断 nature"""
|
||
dir_lower = source_dir.lower()
|
||
if "方法" in dir_lower or "method" in dir_lower:
|
||
return "method"
|
||
if "视频笔记" in dir_lower:
|
||
return "resource"
|
||
if "字幕" in dir_lower or "转写" in dir_lower:
|
||
return "reference"
|
||
if "阅读" in dir_lower or "全文" in dir_lower:
|
||
return "reference"
|
||
if "翻译" in dir_lower:
|
||
return "reference"
|
||
if "灵感" in dir_lower:
|
||
return "opinion"
|
||
if "闪念" in dir_lower:
|
||
return "opinion"
|
||
if "references" in dir_lower:
|
||
return "reference"
|
||
# 从 tags 推断
|
||
tags = metadata.get("tags", "")
|
||
if "方法论" in tags or "方法" in tags:
|
||
return "method"
|
||
if "概念" in tags:
|
||
return "concept"
|
||
return "resource"
|
||
|
||
|
||
def infer_domain(metadata: dict) -> str:
|
||
"""从标签推断领域"""
|
||
tags = metadata.get("tags", "").lower()
|
||
domain_keywords = {
|
||
"技术": ["编程", "代码", "开发", "tech", "python", "javascript", "ai", "llm", "机器学习"],
|
||
"金融": ["投资", "股票", "基金", "finance", "交易", "财经"],
|
||
"产品": ["产品", "设计", "ux", "product"],
|
||
"效率": ["效率", "工具", "workflow", "自动化"],
|
||
"阅读": ["读书", "阅读", "书评", "book"],
|
||
"生活": ["生活", "旅行", "美食", "健身"],
|
||
}
|
||
for domain, keywords in domain_keywords.items():
|
||
if any(kw in tags for kw in keywords):
|
||
return domain
|
||
return "general"
|
||
|
||
|
||
def collect_to_inbox(root_dir: str, dry_run: bool = False, force: bool = False):
|
||
"""主函数:扫描各目录,提取内容到收件箱"""
|
||
root = Path(root_dir).expanduser()
|
||
inbox_dir = root / "03_knowledge" / "00_inbox"
|
||
|
||
stats = {"scanned": 0, "collected": 0, "skipped_collected": 0, "skipped_duplicate": 0, "errors": 0}
|
||
|
||
# 确保收件箱存在
|
||
if not dry_run:
|
||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 获取收件箱已有文件名集合(用于去重)
|
||
existing_inbox = set()
|
||
if inbox_dir.exists():
|
||
for f in inbox_dir.glob("*.md"):
|
||
existing_inbox.add(f.name)
|
||
|
||
collected_date = datetime.now().strftime("%Y-%m-%d")
|
||
|
||
for rel_dir, extract_strategy in SCAN_DIRS:
|
||
scan_dir = root / rel_dir
|
||
if not scan_dir.exists():
|
||
continue
|
||
|
||
# 递归扫描子目录(灵感素材下有平台子目录)
|
||
for md_file in sorted(scan_dir.rglob("*.md")):
|
||
stats["scanned"] += 1
|
||
|
||
try:
|
||
content = md_file.read_text(encoding="utf-8")
|
||
except Exception as e:
|
||
print(f" ❌ 读取失败: {md_file.name} - {e}")
|
||
stats["errors"] += 1
|
||
continue
|
||
|
||
metadata, body = parse_frontmatter(content)
|
||
|
||
# 检查是否已收集
|
||
if not force and metadata.get("collected") == "true":
|
||
stats["skipped_collected"] += 1
|
||
continue
|
||
|
||
# 去重检查
|
||
dest_name = md_file.name
|
||
if dest_name in existing_inbox:
|
||
stats["skipped_duplicate"] += 1
|
||
continue
|
||
|
||
# 提取内容
|
||
extract_func = EXTRACT_FUNCS.get(extract_strategy, extract_summary)
|
||
extracted = extract_func(body)
|
||
|
||
# 推断属性
|
||
nature = infer_nature(rel_dir, metadata)
|
||
domain = infer_domain(metadata)
|
||
title = metadata.get("title", md_file.stem)
|
||
source_date = metadata.get("date", collected_date)
|
||
tags = metadata.get("tags", "")
|
||
source_rel = str(md_file.relative_to(root))
|
||
|
||
# 生成收件箱文件内容
|
||
inbox_content = f"""---
|
||
title: "{title}"
|
||
source_dir: {rel_dir}
|
||
source_file: {md_file.name}
|
||
date: {source_date}
|
||
collected_date: {collected_date}
|
||
tags: {tags}
|
||
nature: {nature}
|
||
domain: {domain}
|
||
status: inbox
|
||
---
|
||
|
||
# {title}
|
||
|
||
> 来源:{rel_dir}
|
||
|
||
{extracted}
|
||
"""
|
||
|
||
if dry_run:
|
||
print(f" 📥 [预览] {md_file.name} → 00_inbox/{dest_name}")
|
||
print(f" nature={nature}, domain={domain}, 策略={extract_strategy}")
|
||
else:
|
||
# 写入收件箱
|
||
dest_path = inbox_dir / dest_name
|
||
dest_path.write_text(inbox_content, encoding="utf-8")
|
||
print(f" 📥 {md_file.name} → 00_inbox/{dest_name}")
|
||
|
||
# 标记原文件为已收集
|
||
if content.startswith("---"):
|
||
# 在 Frontmatter 末尾添加 collected 标记
|
||
end = content.find("---", 3)
|
||
if end != -1:
|
||
fm = content[3:end]
|
||
if "collected:" not in fm:
|
||
fm += f"\ncollected: true\ncollected_date: {collected_date}"
|
||
new_content = "---" + fm + content[end:]
|
||
md_file.write_text(new_content, encoding="utf-8")
|
||
else:
|
||
# 没有 Frontmatter,添加一个简单的
|
||
new_content = f"---\ncollected: true\ncollected_date: {collected_date}\n---\n\n{content}"
|
||
md_file.write_text(new_content, encoding="utf-8")
|
||
|
||
existing_inbox.add(dest_name)
|
||
|
||
stats["collected"] += 1
|
||
|
||
# 输出统计
|
||
print(f"\n{'[预览] ' if dry_run else ''}收集统计:")
|
||
print(f" 扫描文件:{stats['scanned']}")
|
||
print(f" 提取到收件箱:{stats['collected']}")
|
||
print(f" 跳过(已收集):{stats['skipped_collected']}")
|
||
print(f" 跳过(重复):{stats['skipped_duplicate']}")
|
||
print(f" 错误:{stats['errors']}")
|
||
|
||
return stats
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="收集各分类目录内容到收件箱")
|
||
parser.add_argument("--root", default="~/workbuddy-agent-os/agent-sync", help="agent-os 根目录")
|
||
parser.add_argument("--dry-run", action="store_true", help="只预览不写入")
|
||
parser.add_argument("--force", action="store_true", help="忽略 collected 标记,重新收集")
|
||
args = parser.parse_args()
|
||
|
||
print(f"=== Collect to Inbox ===")
|
||
print(f"根目录:{args.root}")
|
||
print(f"模式:{'预览' if args.dry_run else '执行'}")
|
||
print()
|
||
|
||
collect_to_inbox(args.root, dry_run=args.dry_run, force=args.force)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|