Files
MediaCrawler/mac-agent-os-main/05_tools/07_matrix/scripts/c2/profile_scraper.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

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))