移植自 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 带网关原话 / 正常返回)。
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
⚠️ 已废弃 — 请使用 Dashboard 采集 API 替代。
|
|
本文件保留以供 douyin_login.py 引用,新代码禁止导入。
|
|
|
|
profile_scraper.py — 账号主页资料自动采集引擎 v1.0
|
|
|
|
功能:
|
|
- 对每个已登录的 Camoufox 账号
|
|
- 打开平台个人主页
|
|
- 提取: 昵称 / 粉丝 / 关注 / 简介 / 作品数 / 最热视频
|
|
- 存储到 machines/{uid}/profiles/{account_id}.json
|
|
- 集成到养号流程 (每次养号后自动刷新)
|
|
|
|
版本: 1.0.0 | 更新: 2026-05-31
|
|
"""
|
|
import json, os, asyncio, re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
HOME = Path.home()
|
|
AGENT_SYNC = HOME / "workbuddy-agent-os" / "agent-sync"
|
|
AGENT_LOCAL = HOME / "workbuddy-agent-os" / "agent-local"
|
|
|
|
PROFILES_DIR = AGENT_SYNC / "04_memory" / "cross_machine" / "machines"
|
|
LOCAL_PROFILES = AGENT_LOCAL / "runtime" / "profiles"
|
|
|
|
# ── 平台主页 URL 模板 (按平台扩展) ─────────────────────
|
|
# 格式: platform → (url_pattern, selectors)
|
|
PLATFORM_PROFILES = {
|
|
"douyin": {
|
|
"url": "https://www.douyin.com/user/self",
|
|
"selectors": {
|
|
"display_name": ".nickname, .account-info .name, h1",
|
|
"followers": ".follower-count, .follower-num, span[class*=follower]",
|
|
"following": ".following-count, .following-num",
|
|
"bio": ".signature, .desc, .bio",
|
|
"video_count": ".video-count, .post-count, span[class*=video]",
|
|
"likes_received": ".like-count, .total-favorited",
|
|
}
|
|
},
|
|
"xiaohongshu": {
|
|
"url": "https://www.xiaohongshu.com/user/profile",
|
|
"selectors": {
|
|
"display_name": ".username, .user-name, h1",
|
|
"followers": ".follower-count, span[class*=fans]",
|
|
"following": ".following-count, span[class*=follow]",
|
|
"bio": ".desc, .user-desc, .introduction",
|
|
"video_count": ".note-count, .post-count",
|
|
"collects_received": ".collected-count",
|
|
}
|
|
},
|
|
"kuaishou": {
|
|
"url": "https://www.kuaishou.com/profile",
|
|
"selectors": {
|
|
"display_name": ".name, .username",
|
|
"followers": ".fans, .follower-count",
|
|
"following": ".follow, .following-count",
|
|
"bio": ".description, .signature",
|
|
"video_count": ".works-count, .video-count",
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
class ProfileScraper:
|
|
"""账号主页资料采集器"""
|
|
|
|
def __init__(self):
|
|
self._local_uid = self._resolve_uid()
|
|
self._local_hostname = self._resolve_hostname()
|
|
LOCAL_PROFILES.mkdir(parents=True, exist_ok=True)
|
|
|
|
def refresh_all(self) -> list[dict]:
|
|
"""刷新所有本机已登录账号的资料"""
|
|
try:
|
|
from matrix_mgmt import MatrixManager
|
|
mgr = MatrixManager()
|
|
accounts = mgr.list_accounts()
|
|
local = [a for a in accounts if a.get("is_local") and a.get("_status") == "logged_in"]
|
|
except ImportError:
|
|
return [{"error": "matrix_mgmt not available"}]
|
|
|
|
results = []
|
|
for acct in local:
|
|
try:
|
|
result = self._scrape_one(acct)
|
|
results.append(result)
|
|
except Exception as e:
|
|
results.append({"account": acct["id"], "error": str(e)})
|
|
return results
|
|
|
|
def refresh(self, account_ids: list[str]) -> list[dict]:
|
|
"""刷新指定账号的资料"""
|
|
try:
|
|
from matrix_mgmt import MatrixManager
|
|
mgr = MatrixManager()
|
|
accounts = mgr.list_accounts()
|
|
targets = [a for a in accounts if a["id"] in account_ids]
|
|
except ImportError:
|
|
return [{"error": "matrix_mgmt not available"}]
|
|
|
|
results = []
|
|
for acct in targets:
|
|
try:
|
|
result = self._scrape_one(acct)
|
|
results.append(result)
|
|
except Exception as e:
|
|
results.append({"account": acct["id"], "error": str(e)})
|
|
return results
|
|
|
|
def _scrape_one(self, acct: dict) -> dict:
|
|
"""采集单个账号的主页资料
|
|
|
|
实际执行时通过 matrix_nurture 的 Camoufox 实例完成。
|
|
此处为框架接口——Camoufox 操作由 nurture_runner 调用。
|
|
"""
|
|
platform = acct.get("platform", "")
|
|
profile_config = PLATFORM_PROFILES.get(platform)
|
|
if not profile_config:
|
|
return {"account": acct["id"], "error": f"不支持的平台: {platform}"}
|
|
|
|
# 构建结果框架
|
|
result = {
|
|
"account_id": acct["id"],
|
|
"platform": platform,
|
|
"collected_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
# 采集流程:
|
|
# 1. 启动 Camoufox (用该账号的 identity_dir)
|
|
# 2. 导航到 platform.url
|
|
# 3. 等待页面加载 + 提取 selectors
|
|
# 4. 关闭浏览器
|
|
# 5. 存储结果
|
|
|
|
# 待 Camoufox API 就绪后实现具体采集逻辑
|
|
# 当前返回框架占位
|
|
result["_status"] = "pending_implementation"
|
|
result["_note"] = "采集逻辑待 Camoufox API 集成"
|
|
|
|
# 写入本地存储
|
|
self._save_profile(result)
|
|
return result
|
|
|
|
def _save_profile(self, data: dict):
|
|
"""存储采集到的资料到 machines/{uid}/profiles/{account_id}.json"""
|
|
profiles_dir = PROFILES_DIR / self._local_uid / "profiles"
|
|
profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
path = profiles_dir / f"{data['account_id']}.json"
|
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
def get_profile(self, account_id: str) -> Optional[dict]:
|
|
"""读取已存储的账号资料"""
|
|
path = PROFILES_DIR / self._local_uid / "profiles" / f"{account_id}.json"
|
|
if path.exists():
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def _resolve_uid(self) -> str:
|
|
uid_file = AGENT_LOCAL / "identity" / "machine_uid"
|
|
if uid_file.exists():
|
|
return uid_file.read_text().strip()
|
|
return os.uname().nodename
|
|
|
|
def _resolve_hostname(self) -> str:
|
|
hn_file = AGENT_LOCAL / "identity" / "cached_hostname"
|
|
if hn_file.exists():
|
|
return hn_file.read_text().strip()
|
|
return os.uname().nodename
|
|
|
|
|
|
if __name__ == "__main__":
|
|
scraper = ProfileScraper()
|
|
results = scraper.refresh_all()
|
|
print(json.dumps(results, indent=2, ensure_ascii=False))
|