移植自 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 带网关原话 / 正常返回)。
2307 lines
102 KiB
Python
2307 lines
102 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
抖音原子操作库 v2.0 — PlatformOps 接口实现
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import random
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from ops._base import PlatformOps, OpResult, Condition
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# 持久化路径
|
||
# ════════════════════════════════════════════════════════════
|
||
_HOME = Path.home()
|
||
PROFILES_JSON = _HOME / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
|
||
|
||
# ── 抖音 Web 选择器常量 ──────────────────────────────────────────
|
||
SELECTORS = {
|
||
# 全局
|
||
"search_input": '[data-e2e="searchbar-input"]',
|
||
"search_button": '[data-e2e="searchbar-button"]',
|
||
"nav_recommend": '[data-e2e="douyin-navigation"] a:has-text("推荐")',
|
||
"avatar": '[data-e2e="live-avatar"]',
|
||
|
||
# 视频互动
|
||
"digg": '[data-e2e="video-player-digg"]',
|
||
"comment_icon": '[data-e2e="feed-comment-icon"]',
|
||
"collect": '[data-e2e="video-player-collect"]',
|
||
"share": '[data-e2e="video-player-share"]',
|
||
"follow": '[data-e2e="feed-follow-icon"]',
|
||
"prev_arrow": '[data-e2e="video-switch-prev-arrow"]',
|
||
"next_arrow": '[data-e2e="video-switch-next-arrow"]',
|
||
"more": '[data-e2e="video-play-more"]',
|
||
|
||
# 评论区
|
||
"comment_list": '[data-e2e="comment-list"]',
|
||
"comment_editor": '.public-DraftEditor-content',
|
||
"comment_send": '.commentInput-right-ct .WFB7wUOX',
|
||
"comment_emoji": '.commentInput-right-ct .BVMl8WNl', # 第2个
|
||
"comment_more": '[data-e2e="video-comment-more"]',
|
||
"comment_like": '[data-e2e="comment-digg"]', # 评论点赞按钮
|
||
"comment_item": '[data-e2e="comment-item"]', # 单条评论容器
|
||
"comment_reply_btn": '[class*="reply"],[class*="Reply"]', # 回复按钮
|
||
"hot_tag": '.hot-tag,[class*="hot"]', # 热评标记
|
||
|
||
# 搜索结果
|
||
"search_card": '.search-result-card',
|
||
"tab_active": '.NiqGqBbw',
|
||
"tab_inactive": '.t3OsOj2N',
|
||
"sub_filter": '.neT9xRMd.vZeqhI2r',
|
||
|
||
# 关注页
|
||
"follow_user": '.ILK2RAD5',
|
||
|
||
# 个人主页
|
||
"user_detail": '[data-e2e="user-detail"]',
|
||
"user_info": '[data-e2e="user-info"]',
|
||
"user_post_list": '[data-e2e="user-post-list"]',
|
||
|
||
# 头像悬停菜单
|
||
"hover_menu": '.userMenuPanelShadowAnimation',
|
||
"hover_menu_item": '.uz1VJwFY.espXX7re',
|
||
|
||
# 验证码(兼容新旧两种 class)
|
||
"verify_mask": '.second-verify-mask',
|
||
"verify_panel": '.second-verify-panel, .uc-ui-verify_sms-verify', # 旧+新
|
||
"verify_input": '.uc-ui-verify_sms-verify_input, input[placeholder*="验证码"]',
|
||
"verify_get_code": '.uc-ui-typography_descript, [class*="getCode"], button:has-text("获取验证码")',
|
||
"verify_confirm": '.uc-ui-verify_sms-verify_button.primary, .uc-ui-verify_sms-verify_b',
|
||
"verify_cancel": '.uc-ui-verify_sms-verify_button.second',
|
||
"verify_phone_input": 'input[placeholder*="手机"]',
|
||
}
|
||
|
||
# 键盘快捷键
|
||
KEYS = {
|
||
"like": "z",
|
||
"comment": "x",
|
||
"follow": "g",
|
||
"danmaku": "b",
|
||
"play_pause": "Space",
|
||
"prev": "ArrowUp",
|
||
"next": "ArrowDown",
|
||
"enter": "Enter",
|
||
}
|
||
|
||
# 起始 URL
|
||
HOME_URL = "https://www.douyin.com/"
|
||
|
||
# 频率安全阈值
|
||
RATE_LIMITS = {
|
||
"likes_per_hour": 20,
|
||
"comments_per_hour": 5,
|
||
"follows_per_hour": 10,
|
||
"collects_per_hour": 15,
|
||
"searches_per_hour": 10,
|
||
}
|
||
|
||
|
||
class DouyinOps(PlatformOps):
|
||
"""抖音原子操作库 v2.0 — 实现 PlatformOps 接口"""
|
||
|
||
name = "douyin"
|
||
retry_count = 1
|
||
|
||
def __init__(self, page, db=None, execution_id=None):
|
||
self.page = page
|
||
self.db = db
|
||
self.execution_id = execution_id
|
||
self._account_id = None
|
||
self._action_counts = {
|
||
"like": 0, "comment": 0, "follow": 0,
|
||
"collect": 0, "search": 0,
|
||
}
|
||
self._session_start = time.time()
|
||
self._app_login_required = False # 标记:是否需要使用抖音App登录
|
||
|
||
def set_account_id(self, aid: str):
|
||
print(f"[set_account_id] id(self)={id(self)}, aid={aid}")
|
||
self._account_id = aid
|
||
|
||
def _save_profiles_json(self):
|
||
"""保存主页信息到 profiles.json + homepage_info.json(两个 Dashboard 数据源)"""
|
||
if not self._account_id:
|
||
print(f"[douyin_ops] _save_profiles_json: _account_id 为空,跳过")
|
||
return
|
||
try:
|
||
prof = getattr(self, '_profile', {})
|
||
PROFILES_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ── 写 profiles.json(供账号管理页面读取)──
|
||
if PROFILES_JSON.exists():
|
||
all_p = json.loads(PROFILES_JSON.read_text())
|
||
else:
|
||
all_p = {}
|
||
|
||
# v5 防覆盖保护:新值有效(非 ?/空)才覆盖,否则保留旧值
|
||
def _pick(new_v, old_v):
|
||
if new_v not in (None, "", "?"):
|
||
return new_v
|
||
return old_v if old_v not in (None, "") else "?"
|
||
|
||
old_p = all_p.get(self._account_id, {}) if isinstance(all_p, dict) else {}
|
||
ban_status = prof.get("_status", "normal")
|
||
all_p[self._account_id] = {
|
||
"nickname": _pick(prof.get("nickname"), old_p.get("nickname")),
|
||
"user_id": _pick(prof.get("user_id"), old_p.get("user_id")),
|
||
"following": _pick(prof.get("following"), old_p.get("following")),
|
||
"fans": _pick(prof.get("fans"), old_p.get("fans")),
|
||
"likes": _pick(prof.get("likes"), old_p.get("likes")),
|
||
"posts": _pick(prof.get("posts"), old_p.get("posts")),
|
||
"bio": _pick(prof.get("bio"), old_p.get("bio")),
|
||
"status": ban_status,
|
||
"platform": "douyin",
|
||
"updated": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||
}
|
||
PROFILES_JSON.write_text(json.dumps(all_p, ensure_ascii=False, indent=2))
|
||
|
||
# ── 同步写 homepage_info.json(供信息采集页面读取)──
|
||
hp_path = PROFILES_JSON.parent / "homepage_info.json"
|
||
hp_data = {"collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "results": []}
|
||
if hp_path.exists():
|
||
try:
|
||
hp_data = json.loads(hp_path.read_text())
|
||
except Exception:
|
||
hp_data = {"collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "results": []}
|
||
entry = None
|
||
for r in hp_data.get("results", []):
|
||
rid = r.get("identity_dir", "").replace("identities/", "")
|
||
if rid == self._account_id:
|
||
entry = r
|
||
break
|
||
if not entry:
|
||
hp_data.setdefault("results", []).append({
|
||
"identity_dir": f"identities/{self._account_id}",
|
||
"phone": self._account_id.split("_")[-1] if "_" in self._account_id else self._account_id,
|
||
"douyin": {}, "xiaohongshu": None
|
||
})
|
||
entry = hp_data["results"][-1]
|
||
if entry.get("douyin") is None:
|
||
entry["douyin"] = {}
|
||
entry["douyin"].update({
|
||
"nickname": _pick(prof.get("nickname"), entry["douyin"].get("nickname")),
|
||
"fans": _pick(prof.get("fans"), entry["douyin"].get("fans")),
|
||
"following": _pick(prof.get("following"), entry["douyin"].get("following")),
|
||
"likes": _pick(prof.get("likes"), entry["douyin"].get("likes")),
|
||
"posts": _pick(prof.get("posts"), entry["douyin"].get("posts")),
|
||
"bio": _pick(prof.get("bio"), entry["douyin"].get("bio")),
|
||
"status": ban_status,
|
||
"updated": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||
})
|
||
hp_path.write_text(json.dumps(hp_data, ensure_ascii=False, indent=2))
|
||
|
||
except Exception as e:
|
||
print(f"[douyin_ops] _save_profiles_json 失败: {e}")
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# v5 主页数据 API 采集(不依赖 DOM 结构,抖音改版不再影响)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def _known_user_id(self) -> str:
|
||
"""从已有 profiles.json 读取该账号的 user_id(供 other 接口降级用)"""
|
||
try:
|
||
if PROFILES_JSON.exists():
|
||
all_p = json.loads(PROFILES_JSON.read_text())
|
||
p = all_p.get(self._account_id, {})
|
||
uid = str(p.get("user_id", "") or "")
|
||
if uid and uid != "?":
|
||
return uid
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _profile_from_api_user(u: dict) -> dict:
|
||
"""把 API user 对象转成统一 profile 结构"""
|
||
avatar = ""
|
||
try:
|
||
avatar = (u.get("avatar_thumb") or {}).get("url_list", [""])[0]
|
||
except Exception:
|
||
pass
|
||
def _num(v):
|
||
return "" if v is None else str(v)
|
||
return {
|
||
"nickname": u.get("nickname", "") or "",
|
||
"user_id": str(u.get("uid", u.get("unique_id", "")) or ""),
|
||
"following": _num(u.get("following_count")),
|
||
"fans": _num(u.get("follower_count")),
|
||
"likes": _num(u.get("total_favorited")),
|
||
"posts": _num(u.get("aweme_count")),
|
||
"bio": (u.get("signature", "") or "").strip()[:50],
|
||
"avatar": avatar,
|
||
}
|
||
|
||
async def _fetch_profile_api(self) -> dict:
|
||
"""v5: 通过抖音 Web API 获取主页数据(同源 fetch,不依赖 DOM)
|
||
|
||
优先 /aweme/v1/web/user/profile/self/(无需 uid)
|
||
失败降级 /aweme/v1/web/user/profile/other/(用已有 user_id)
|
||
返回统一 profile 结构;失败返回空 dict
|
||
"""
|
||
# 1. 先尝试 self 接口(当前登录用户)
|
||
js_self = """async () => {
|
||
try {
|
||
const r = await fetch('/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383', {
|
||
headers: { 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.douyin.com/' }
|
||
});
|
||
const d = await r.json();
|
||
return JSON.stringify({ok: !!(d && d.user), data: (d && d.user) || {}});
|
||
} catch(e) {
|
||
return JSON.stringify({ok: false, error: e.message});
|
||
}
|
||
}"""
|
||
try:
|
||
raw = await self.page.evaluate(js_self)
|
||
payload = json.loads(raw)
|
||
if payload.get("ok"):
|
||
prof = self._profile_from_api_user(payload["data"])
|
||
print(f"[douyin_ops] ✅ API self 成功: {prof['nickname']} 粉丝={prof['fans']}")
|
||
return prof
|
||
except Exception as e:
|
||
print(f"[douyin_ops] API self 异常: {e}")
|
||
|
||
# 2. 降级 other 接口(需要数字 uid,从已有 profiles.json 读取)
|
||
uid = self._known_user_id()
|
||
if uid:
|
||
js_other = f"""async () => {{
|
||
try {{
|
||
const r = await fetch('/aweme/v1/web/user/profile/other/?user_id={uid}&device_platform=webapp&aid=6383', {{
|
||
headers: {{ 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://www.douyin.com/' }}
|
||
}});
|
||
const d = await r.json();
|
||
return JSON.stringify({{ok: !!(d && d.user), data: (d && d.user) || {{}}}});
|
||
}} catch(e) {{
|
||
return JSON.stringify({{ok: false, error: e.message}});
|
||
}}
|
||
}}"""
|
||
try:
|
||
raw2 = await self.page.evaluate(js_other)
|
||
payload2 = json.loads(raw2)
|
||
if payload2.get("ok"):
|
||
prof2 = self._profile_from_api_user(payload2["data"])
|
||
print(f"[douyin_ops] ✅ API other 成功: {prof2['nickname']} 粉丝={prof2['fans']}")
|
||
return prof2
|
||
except Exception as e:
|
||
print(f"[douyin_ops] API other 异常: {e}")
|
||
else:
|
||
print("[douyin_ops] ⚠️ 无已知 user_id,跳过 other 接口降级")
|
||
return {}
|
||
|
||
async def _collect_profile_dom(self) -> dict:
|
||
"""DOM 兜底解析(v4 旧逻辑,仅当 API 全部失败时使用)"""
|
||
profile = await self.page.evaluate("""() => {
|
||
try {
|
||
const text = (document.body.innerText || '').trim();
|
||
// 昵称:优先从标题取,失败则尝试 DOM 选择器
|
||
var nickFromTitle = (document.title || '').replace(' - 抖音', '').replace('的抖音', '').trim();
|
||
// DOM 兜底:找页面中可能包含昵称的元素
|
||
var nickFromDom = '';
|
||
var nickEl = document.querySelector('[data-e2e="user-info"] span, [class*="profile"] [class*="name"], .user-info .name, .profile-info span');
|
||
if (nickEl && nickEl.textContent) nickFromDom = nickEl.textContent.trim();
|
||
const nickname = nickFromTitle || nickFromDom || '?';
|
||
const uidM = text.match(/抖音号[::]\\s*(\\S+)/);
|
||
// 正则匹配两种顺序:"数字 标签" 和 "标签 数字"
|
||
function extractNum(label) {
|
||
var m1 = text.match(new RegExp('(\\\\d+(?:\\\\.\\\\d+)?[万w]?)\\\\s*' + label));
|
||
if (m1) return m1[1];
|
||
var m2 = text.match(new RegExp(label + '\\\\s*(\\\\d+(?:\\\\.\\\\d+)?[万w]?)'));
|
||
if (m2) return m2[1];
|
||
return null;
|
||
}
|
||
const folM = extractNum('关注');
|
||
const fanM = extractNum('粉丝');
|
||
const likM = extractNum('获赞');
|
||
const posM = extractNum('作品');
|
||
// e2e 兜底:从原始文本中只提取数字部分
|
||
function e2eNum(s) {
|
||
try {
|
||
var el = document.querySelector('[data-e2e="'+s+'"]');
|
||
if (!el) return null;
|
||
var m = (el.textContent||'').trim().match(/\\d+(?:\\.\\d+)?[万w]?/);
|
||
return m ? m[0] : null;
|
||
} catch(e) { return null; }
|
||
}
|
||
// DOM 兜底:data-e2e 容器内的数字(结构: <div data-e2e="xxx"><div>标签</div><div>数字</div></div>)
|
||
function statByE2e(e2eName) {
|
||
try {
|
||
var container = document.querySelector('[data-e2e="'+e2eName+'"]');
|
||
if (!container) return null;
|
||
var divs = container.querySelectorAll('div');
|
||
for (var j = 0; j < divs.length; j++) {
|
||
var m = (divs[j].textContent||'').trim().match(/^\\d+(?:\\.\\d+)?[万w]?$/);
|
||
if (m) return m[0];
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
return {
|
||
nickname: nickname, user_id: uidM ? uidM[1] : '?',
|
||
following: folM || (e2eNum('user-info-follow')||statByE2e('user-info-follow')||'?'),
|
||
fans: fanM || (e2eNum('user-info-fans')||statByE2e('user-info-fans')||'?'),
|
||
likes: likM || (e2eNum('user-info-like')||statByE2e('user-info-like')||'?'),
|
||
posts: posM || (e2eNum('user-tab-count')||statByE2e('user-tab-count')||'?'),
|
||
bio: (document.querySelector('[data-e2e="user-bio"]')?.textContent?.trim() || '?').slice(0, 50),
|
||
};
|
||
} catch(e) {
|
||
return { nickname: '?', user_id: '?', following: '?', fans: '?', likes: '?', posts: '?', bio: '?', _error: e.message };
|
||
}
|
||
}""")
|
||
return profile
|
||
|
||
def supported_ops(self) -> list:
|
||
return ["goto_home", "goto_url", "like", "collect", "follow",
|
||
"open_comments", "close_comments", "post_comment",
|
||
"next_video", "prev_video", "search", "wait_watch",
|
||
"scroll_feed", "open_video", "wait", "go_back",
|
||
"goto_profile", "read_profile_field", "read_my_comments",
|
||
"reply_comment", "search_browse", "sms_login",
|
||
"click_search_result",
|
||
"dy_goto_profile",
|
||
"dy_read_nickname", "dy_read_douyin_id",
|
||
"dy_read_following", "dy_read_fans",
|
||
"dy_read_likes", "dy_read_posts", "dy_read_bio"]
|
||
|
||
# ── 三段式操作模型 v2.0 ──────────────────────────────────
|
||
|
||
# 状态采集时检查的关键选择器
|
||
STATE_SELECTORS = [
|
||
'[data-e2e="video-player-digg"]', # 点赞按钮
|
||
'[data-e2e="video-player-collect"]', # 收藏按钮
|
||
'[data-e2e="feed-comment-icon"]', # 评论图标
|
||
'[data-e2e="feed-follow-icon"]', # 关注按钮
|
||
'[data-e2e="searchbar-input"]', # 搜索框
|
||
'video', # 视频元素
|
||
]
|
||
|
||
def _get_pre_conditions(self, op: str) -> list[Condition]:
|
||
"""每个操作的前置条件(三段式 v2.0)"""
|
||
conds = {
|
||
# ── 视频播放页操作 ──
|
||
"like": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
Condition("selector", '[data-e2e="video-player-digg"]', True,
|
||
message="需要点赞按钮可见", soft=True), # 软条件:按钮选择器失效时放行键盘Z兜底
|
||
],
|
||
"collect": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
Condition("selector", '[data-e2e="video-player-collect"]', True,
|
||
message="需要收藏按钮可见", soft=True), # 软条件:放行JS/坐标兜底
|
||
],
|
||
"follow": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
Condition("selector", '[data-e2e="feed-follow-icon"]', True,
|
||
message="需要关注按钮可见", soft=True), # 软条件:放行DOM/JS兜底
|
||
],
|
||
"open_comments": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
],
|
||
"close_comments": [],
|
||
"next_video": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
],
|
||
"prev_video": [
|
||
Condition("page_mode", "page_mode", "player", message="需要在视频播放页"),
|
||
],
|
||
"post_comment": [
|
||
# B模式兼容:评论区已在页面中无需额外条件
|
||
],
|
||
|
||
# ── Feed 流页操作 ──
|
||
"scroll_feed": [
|
||
Condition("page_mode", "page_mode", "grid", message="需要在 feed 流页"),
|
||
],
|
||
"click_search_result": [
|
||
Condition("page_mode", "page_mode", "search", message="需要在搜索结果页"),
|
||
],
|
||
|
||
# ── 导航操作 ──
|
||
"goto_home": [],
|
||
"goto_url": [],
|
||
"search": [
|
||
Condition("selector", '[data-e2e="searchbar-input"]', True,
|
||
message="需要搜索框可见"),
|
||
],
|
||
"open_video": [],
|
||
"search_browse": [
|
||
Condition("selector", '[data-e2e="searchbar-input"]', True,
|
||
message="需要搜索框可见"),
|
||
],
|
||
|
||
# ── 读操作(任何页面都能执行,失败不计)──
|
||
"dy_read_nickname": [],
|
||
"dy_read_douyin_id": [],
|
||
"dy_read_following": [],
|
||
"dy_read_fans": [],
|
||
"dy_read_likes": [],
|
||
"dy_read_posts": [],
|
||
"dy_read_bio": [],
|
||
|
||
# ── 无需条件的操作 ──
|
||
"wait_watch": [],
|
||
"wait": [],
|
||
"go_back": [],
|
||
"goto_profile": [],
|
||
"read_profile_field": [],
|
||
"read_my_comments": [],
|
||
"reply_comment": [],
|
||
"sms_login": [],
|
||
}
|
||
return conds.get(op, [])
|
||
|
||
def _get_post_conditions(self, op: str) -> list[Condition]:
|
||
"""每个操作的后置验证条件(三段式 v2.0)"""
|
||
conds = {
|
||
"like": [
|
||
Condition("selector", '[data-e2e="video-player-digg"]', True,
|
||
message="点赞按钮应仍在页面"),
|
||
],
|
||
"goto_home": [
|
||
Condition("page_mode", "page_mode", "grid",
|
||
message="应回到 feed 首页"),
|
||
],
|
||
"search": [
|
||
Condition("selector", '[data-e2e="searchbar-input"]', True,
|
||
message="搜索框应仍可见"),
|
||
],
|
||
}
|
||
return conds.get(op, [])
|
||
|
||
async def _do_execute(self, op: str, args: dict, step_id: int) -> Optional[OpResult]:
|
||
"""实现 PlatformOps._do_execute — 操作分发"""
|
||
t0 = time.time()
|
||
|
||
if op == "goto_home":
|
||
t0 = time.time()
|
||
for _ in range(3):
|
||
await self.page.goto(HOME_URL, timeout=30000, wait_until="domcontentloaded")
|
||
# 等首页真正加载完成:等视频卡片出现,最多等10秒
|
||
for w in range(10):
|
||
url = self.page.url
|
||
card_count = await self.page.evaluate(
|
||
"document.querySelectorAll('.discover-video-card-item, "
|
||
"[class*=\"video-card\"], [data-e2e=\"alink-item\"]').length")
|
||
if card_count > 0 and '/user/' not in url and '/login' not in url:
|
||
return OpResult(op, step_id, True, "home", time.time()-t0)
|
||
await asyncio.sleep(1)
|
||
# 10秒没等到 → 再跳一次
|
||
await asyncio.sleep(2)
|
||
return OpResult(op, step_id, True, f"home(last_url={self.page.url[:50]})", time.time()-t0)
|
||
|
||
if op == "goto_url":
|
||
url = args.get("url", HOME_URL)
|
||
await self.page.goto(url, timeout=30000, wait_until="domcontentloaded")
|
||
await asyncio.sleep(5)
|
||
return OpResult(op, step_id, True, f"goto {url[:30]}", time.time()-t0)
|
||
|
||
if op == "like":
|
||
ok = await self.like(step_id=step_id, probability=args.get("probability", 1.0))
|
||
if not ok:
|
||
# KeyZ fallback
|
||
video = self.page.locator('video')
|
||
if await video.count() > 0:
|
||
box = await video.first.bounding_box()
|
||
if box:
|
||
await self.page.mouse.click(box['x']+box['width']//2, box['y']+box['height']//3)
|
||
await asyncio.sleep(0.5)
|
||
await self.page.keyboard.press("z")
|
||
await asyncio.sleep(1.5)
|
||
ok = await self.like(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "👍" if ok else "-", time.time()-t0)
|
||
|
||
if op == "collect":
|
||
ok = await self.collect(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "⭐" if ok else "-", time.time()-t0)
|
||
|
||
if op == "follow":
|
||
ok = await self.follow(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "➕" if ok else "-", time.time()-t0)
|
||
|
||
if op == "open_comments":
|
||
state = await self._detect_page_state()
|
||
# 只检查 player 相关状态
|
||
if state not in ('player', 'player_full', 'player_modal'):
|
||
# 不在播放页 → 无法打开评论区
|
||
return OpResult(op, step_id, False, f"not_player({state})", time.time()-t0)
|
||
# x 键打开(旧版成功的方法)
|
||
ok = await self.open_comments(step_id=step_id)
|
||
await asyncio.sleep(3)
|
||
return OpResult(op, step_id, ok, "opened" if ok else "not_found", time.time()-t0)
|
||
|
||
if op == "post_comment":
|
||
text = args.get("text", "太棒了")
|
||
result = await self.post_comment(text, step_id=step_id)
|
||
success = result == 'ok'
|
||
return OpResult(op, step_id, success, result if not success else "sent", time.time()-t0)
|
||
|
||
if op == "close_comments":
|
||
page_url = self.page.url
|
||
is_full = "/video/" in page_url and "modal_id" not in page_url
|
||
if is_full:
|
||
await self.page.evaluate("() => window.scrollTo(0, 0)")
|
||
else:
|
||
await self.page.keyboard.press("Escape")
|
||
await asyncio.sleep(1)
|
||
return OpResult(op, step_id, True, "closed", time.time()-t0)
|
||
|
||
if op == "next_video":
|
||
ok = await self.next_video(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "⬇️" if ok else "-", time.time()-t0)
|
||
|
||
if op == "prev_video":
|
||
await self.page.keyboard.press("ArrowUp")
|
||
await asyncio.sleep(2)
|
||
return OpResult(op, step_id, True, "⬆️", time.time()-t0)
|
||
|
||
if op == "search":
|
||
kw = args.get("keyword", "热门推荐")
|
||
if kw == "@random":
|
||
import random as _r
|
||
kw = _r.choice(["穿搭推荐","美食日常","旅行攻略","电影解说","科技数码",
|
||
"宠物搞笑","健身教程","音乐推荐","美妆教程","家居好物"])
|
||
ok = await self.search(kw, step_id=step_id)
|
||
return OpResult(op, step_id, ok, f"{kw[:10]}", time.time()-t0)
|
||
|
||
if op == "wait_watch":
|
||
seconds = args.get("seconds", random.randint(5, 12))
|
||
await self.wait_watch(seconds=seconds, step_id=step_id)
|
||
return OpResult(op, step_id, True, f"{seconds}s", time.time()-t0)
|
||
|
||
if op == "scroll_feed":
|
||
await self.page.evaluate("() => window.scrollBy(0, 600)")
|
||
await asyncio.sleep(1)
|
||
return OpResult(op, step_id, True, "scroll", time.time()-t0)
|
||
|
||
if op == "open_video":
|
||
"""进入视频播放页 — 鼠标点击第一张视频卡片"""
|
||
t0 = time.time()
|
||
# 先查:已在播放页则跳过(看URL是否已进入视频页,不看video元素——feed上的video预览也会count>0)
|
||
url = self.page.url
|
||
if '/video/' in url or 'modal_id' in url:
|
||
await self._ensure_video_focused()
|
||
return OpResult(op, step_id, True, "already_player", time.time()-t0)
|
||
|
||
# 找第一张视频卡片,鼠标点击进入播放
|
||
card = self.page.locator('.discover-video-card-item, a[href*="/video/"], [class*="video-card"]').first
|
||
for _ in range(15):
|
||
if await card.count() > 0:
|
||
break
|
||
await asyncio.sleep(1)
|
||
if await card.count() > 0:
|
||
await card.click()
|
||
await asyncio.sleep(1)
|
||
await card.click()
|
||
await asyncio.sleep(3)
|
||
return OpResult(op, step_id, True, "video_card_clicked", time.time()-t0)
|
||
return OpResult(op, step_id, False, "no_card_found", time.time()-t0)
|
||
|
||
if op == "wait":
|
||
await asyncio.sleep(args.get("seconds", 2))
|
||
return OpResult(op, step_id, True, f"{args.get('seconds',2)}s", time.time()-t0)
|
||
|
||
if op == "go_back":
|
||
await self.page.go_back()
|
||
await asyncio.sleep(2)
|
||
return OpResult(op, step_id, True, "back", time.time()-t0)
|
||
|
||
if op == "goto_profile":
|
||
d = await self.goto_profile(step_id=step_id)
|
||
return OpResult(op, step_id, True, d.get("nickname","ok"), time.time()-t0)
|
||
|
||
if op == "read_profile_field":
|
||
v = await self.read_profile_field(args.get("field","nickname"), step_id=step_id)
|
||
return OpResult(op, step_id, True, str(v)[:20], time.time()-t0)
|
||
|
||
if op == "read_my_comments":
|
||
c = await self.read_my_comments(step_id=step_id)
|
||
return OpResult(op, step_id, True, f"{len(c)}条", time.time()-t0)
|
||
|
||
if op == "reply_comment":
|
||
ok = await self.reply_comment(args.get("text","谢谢支持"), step_id=step_id)
|
||
return OpResult(op, step_id, ok, "replied" if ok else "no_btn", time.time()-t0)
|
||
|
||
if op == "like_comment":
|
||
target = args.get("target", "first")
|
||
ok = await self.like_comment(target=target, step_id=step_id)
|
||
return OpResult(op, step_id, ok, "liked" if ok else "no_btn", time.time()-t0)
|
||
|
||
if op == "scroll_to_hot_comment":
|
||
ok = await self.scroll_to_hot_comment(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "scrolled", time.time()-t0)
|
||
|
||
if op == "find_comment_by_code":
|
||
code = args.get("code", "")
|
||
ok = await self.find_comment_by_code(code, step_id=step_id)
|
||
return OpResult(op, step_id, ok, f"found:{code}" if ok else f"not_found:{code}", time.time()-t0)
|
||
|
||
if op == "reply_with_text":
|
||
text = args.get("text", "")
|
||
ok = await self.reply_with_text(text, step_id=step_id)
|
||
return OpResult(op, step_id, ok, "replied" if ok else "failed", time.time()-t0)
|
||
|
||
if op == "post_comment_with_code":
|
||
text = args.get("text", "")
|
||
code = args.get("code", "")
|
||
r = await self.post_comment_with_code(text, code, step_id=step_id)
|
||
return OpResult(op, step_id, r == "ok", r, time.time()-t0)
|
||
|
||
if op == "auto_verify":
|
||
ok = await self.auto_verify(step_id=step_id)
|
||
return OpResult(op, step_id, ok, "verified" if ok else "no_verify", time.time()-t0)
|
||
|
||
if op == "search_browse":
|
||
kw = args.get("keyword", "热门推荐")
|
||
await self.search(kw, step_id=step_id)
|
||
await self.click_search_result(step_id=step_id)
|
||
await self.wait_watch(seconds=random.randint(5, 12), step_id=step_id)
|
||
import random as _rnd
|
||
if _rnd.random() < 0.6: await self.like(step_id=step_id)
|
||
if _rnd.random() < 0.2: await self.collect(step_id=step_id)
|
||
return OpResult(op, step_id, True, "searched+browsed", time.time()-t0)
|
||
|
||
if op == "click_search_result":
|
||
index = args.get("index", 0)
|
||
ok = await self.click_search_result(index=index, step_id=step_id)
|
||
return OpResult(op, step_id, ok, "clicked" if ok else "no_result", time.time()-t0)
|
||
|
||
# dy_* 系列操作(read_profile 蓝图用)
|
||
if op == "dy_goto_profile":
|
||
await self.page.goto("https://www.douyin.com/user/self", timeout=20000, wait_until="domcontentloaded")
|
||
logged_in = await self._ensure_logged_in()
|
||
if logged_in:
|
||
# 登录成功后重新导航到个人主页(弹窗改变了URL)
|
||
await self.page.goto("https://www.douyin.com/user/self", timeout=20000, wait_until="domcontentloaded")
|
||
await asyncio.sleep(3)
|
||
else:
|
||
print("⚠️ dy_goto_profile: 未能登录")
|
||
await asyncio.sleep(3)
|
||
return OpResult(op, step_id, True, "profile", time.time()-t0)
|
||
|
||
if op.startswith("dy_read_"):
|
||
field_map = {
|
||
"dy_read_nickname": "nickname",
|
||
"dy_read_douyin_id": "user_id",
|
||
"dy_read_following": "following",
|
||
"dy_read_fans": "fans",
|
||
"dy_read_likes": "likes",
|
||
"dy_read_posts": "posts",
|
||
"dy_read_bio": "bio",
|
||
}
|
||
field = field_map.get(op, op.replace("dy_read_", ""))
|
||
val = await self.read_profile_field(field, step_id=step_id)
|
||
# dy_read_bio 是最后一步,读完后保存 profiles.json
|
||
if op == "dy_read_bio":
|
||
print(f"[dy_read_bio] id(self)={id(self)}, _account_id={self._account_id}, _profile keys={list((self._profile or {}).keys())}")
|
||
self._save_profiles_json()
|
||
return OpResult(op, step_id, True, f"{field}={val}", time.time()-t0)
|
||
|
||
return OpResult(op, step_id, False, f"unknown_op:{op}", time.time()-t0)
|
||
self.account_id = ""
|
||
self._profile = {}
|
||
self._last_comments = []
|
||
|
||
def set_account_id(self, aid: str):
|
||
self._account_id = aid
|
||
|
||
# ── 内部工具 ──────────────────────────────────────────────
|
||
|
||
def _elapsed_hours(self) -> float:
|
||
return (time.time() - self._session_start) / 3600
|
||
|
||
def _check_rate(self, action: str) -> bool:
|
||
key = f"{action}s_per_hour"
|
||
limit = RATE_LIMITS.get(key, 999)
|
||
rate = self._action_counts.get(action, 0) / max(self._elapsed_hours(), 0.01)
|
||
return rate < limit
|
||
|
||
async def _log_op(self, step_id: int, op_name: str, locator: str,
|
||
success: bool, duration_ms: int, error: str = ""):
|
||
"""记录操作日志到数据库"""
|
||
if not self.db:
|
||
return
|
||
import uuid
|
||
self.db.execute("""
|
||
INSERT INTO operation_logs
|
||
(id, execution_id, step_id, atomic_operation, execution_success,
|
||
locator_used, duration_ms, error_detail, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||
""", (
|
||
str(uuid.uuid4()), self.execution_id, step_id, op_name,
|
||
1 if success else 0, locator, duration_ms, error
|
||
))
|
||
self.db.commit()
|
||
|
||
async def _wait(self, base: float, jitter: float = 0):
|
||
"""带随机抖动的等待"""
|
||
await asyncio.sleep(base + random.uniform(-jitter, jitter))
|
||
|
||
async def _click_selector(self, selector: str, timeout: int = 5000) -> bool:
|
||
"""安全点击选择器 — 先用 Playwright 原生点击,失败后 JS 兜底"""
|
||
try:
|
||
# 策略1: Playwright 原生点击(实时鼠标事件)
|
||
el = self.page.locator(selector)
|
||
if await el.count() > 0:
|
||
await el.first.click(timeout=timeout)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 策略2: JS click 兜底(绕过可见性检查)
|
||
try:
|
||
clicked = await self.page.evaluate(f"""() => {{
|
||
var el = document.querySelector('{selector.replace("'", "\\'")}');
|
||
if (el) {{ el.click(); return true; }}
|
||
return false;
|
||
}}""")
|
||
return clicked
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
async def _ensure_video_focused(self):
|
||
"""确保焦点在视频区域"""
|
||
video = self.page.locator('video')
|
||
if await video.count() > 0:
|
||
box = await video.first.bounding_box()
|
||
if box:
|
||
await self.page.mouse.click(
|
||
box['x'] + box['width'] // 2,
|
||
box['y'] + box['height'] // 3
|
||
)
|
||
await asyncio.sleep(0.2)
|
||
|
||
# ── 状态检测 ──────────────────────────────────────────────
|
||
|
||
async def _detect_page_state(self) -> str:
|
||
"""检测当前页面状态(参考旧版 _detect_page_state + _check_anchor)
|
||
|
||
Returns:
|
||
'grid' — 首页feed(有卡片列表)
|
||
'player_modal' — 弹窗播放(jingxuan?modal_id=xxx)
|
||
'player_full' — 独立播放页(/video/xxx)
|
||
'search' — 搜索页
|
||
'profile' — 个人主页
|
||
'unknown' — 未知
|
||
"""
|
||
try:
|
||
url = self.page.url
|
||
vc = await self.page.evaluate("document.querySelectorAll('video').length")
|
||
cards = await self.page.evaluate(
|
||
"document.querySelectorAll('.discover-video-card-item, [class*=\"video-card\"], "
|
||
"[data-e2e=\"alink-item\"]').length"
|
||
)
|
||
has_search = '/search/' in url
|
||
|
||
if vc > 0 and '/video/' in url:
|
||
return 'player_full'
|
||
if vc > 0 and 'modal_id' in url:
|
||
return 'player_modal'
|
||
if vc > 0:
|
||
return 'player'
|
||
if has_search:
|
||
return 'search'
|
||
if '/user/' in url:
|
||
return 'profile'
|
||
if cards > 0 or '/jingxuan' in url:
|
||
return 'grid'
|
||
return 'unknown'
|
||
except:
|
||
return 'unknown'
|
||
|
||
async def _check_anchor(self, anchor_type: str, timeout: float = 3.0) -> bool:
|
||
"""检测页面锚点(参考旧版 _check_anchor)"""
|
||
try:
|
||
import asyncio
|
||
if anchor_type == 'video_page':
|
||
vc = await asyncio.wait_for(
|
||
self.page.evaluate("document.querySelectorAll('video').length"),
|
||
timeout=timeout
|
||
)
|
||
return vc > 0
|
||
elif anchor_type == 'home_page':
|
||
has_cards = await asyncio.wait_for(
|
||
self.page.evaluate(
|
||
"document.querySelectorAll('.discover-video-card-item, "
|
||
"[class*=\"video-card\"]').length > 0"
|
||
), timeout=timeout
|
||
)
|
||
return has_cards
|
||
elif anchor_type == 'has_videos':
|
||
links = await asyncio.wait_for(
|
||
self.page.evaluate(
|
||
"document.querySelectorAll('a[href*=\"/video/\"], "
|
||
"[href*=\"modal_id\"]').length > 0"
|
||
), timeout=timeout
|
||
)
|
||
return links
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
async def goto_home(self, step_id: int = 0) -> bool:
|
||
"""AO_NAV: 回到推荐页(固定起点)"""
|
||
t0 = time.time()
|
||
try:
|
||
await self.page.goto(HOME_URL, wait_until='domcontentloaded')
|
||
await self._wait(2)
|
||
|
||
# 检查是否在精选页(无视频feed),点击推荐导航
|
||
video = self.page.locator('video')
|
||
if await video.count() == 0 or not await self.page.evaluate(
|
||
'() => { const v = document.querySelector("video"); return v && v.duration > 0; }'
|
||
):
|
||
rec = self.page.locator(SELECTORS['nav_recommend'])
|
||
if await rec.count() > 0:
|
||
await rec.first.click()
|
||
await self._wait(3)
|
||
|
||
# 等待视频加载
|
||
for _ in range(15):
|
||
if await self.page.evaluate(
|
||
'() => { const v = document.querySelector("video"); return v && v.duration > 0 && v.readyState >= 2; }'
|
||
):
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_NAV", HOME_URL, True, dur)
|
||
return True
|
||
await asyncio.sleep(1)
|
||
|
||
await self._log_op(step_id, "AO_NAV", HOME_URL, False, int((time.time()-t0)*1000), "视频加载超时")
|
||
return False
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_NAV", HOME_URL, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def goto_url(self, url: str, step_id: int = 0) -> bool:
|
||
"""AO_NAV: 导航到指定URL(含登录恢复后页面前置检测)"""
|
||
t0 = time.time()
|
||
try:
|
||
# 如果页面被登录恢复卡住(弹窗/加载中),先 reload 一次
|
||
try:
|
||
cur = self.page.url
|
||
if cur and ('login' in cur.lower() or 'passport' in cur.lower()):
|
||
await self.page.reload(timeout=15000)
|
||
await self._wait(1)
|
||
except Exception:
|
||
pass
|
||
|
||
await self.page.goto(url, timeout=30000, wait_until='domcontentloaded')
|
||
await self._wait(2)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_NAV", url, True, dur)
|
||
return True
|
||
except Exception as e:
|
||
# 兜底:reload 重试一次
|
||
try:
|
||
await self.page.reload(timeout=15000)
|
||
await self._wait(1)
|
||
await self.page.goto(url, timeout=30000, wait_until='domcontentloaded')
|
||
await self._wait(2)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_NAV", f"{url}(retry)", True, dur)
|
||
return True
|
||
except Exception as e2:
|
||
await self._log_op(step_id, "AO_NAV", url, False, int((time.time()-t0)*1000), str(e2))
|
||
return False
|
||
|
||
async def go_back(self, step_id: int = 0) -> bool:
|
||
"""AO_NAV: 浏览器后退"""
|
||
t0 = time.time()
|
||
try:
|
||
await self.page.go_back()
|
||
await self._wait(1.5)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_NAV", "go_back", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_NAV", "go_back", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
# ── 互动类原子操作 ──────────────────────────────────────────
|
||
|
||
async def like(self, step_id: int = 0, probability: float = 1.0) -> bool:
|
||
"""AO_LIKE: 点赞当前视频 — 键盘 z + DOM 双击兜底
|
||
probability: 概率控制,0-1之间,默认1.0(必定执行)
|
||
"""
|
||
if not self._check_rate("like"):
|
||
return True
|
||
if probability < 1.0 and random.random() > probability:
|
||
return True
|
||
t0 = time.time()
|
||
try:
|
||
# 策略1: 键盘 Z(抖音 feed 流/详情页通用)
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['like'])
|
||
await self._wait(0.5 + random.uniform(0, 1))
|
||
self._action_counts["like"] += 1
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_LIKE", "keyboard_z", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_LIKE", "keyboard_z", False, dur, str(e))
|
||
return False
|
||
|
||
async def collect(self, step_id: int = 0, probability: float = 1.0) -> bool:
|
||
"""AO_COLLECT: 收藏当前视频(DOM+JS+坐标3层兜底)"""
|
||
if not self._check_rate("collect"):
|
||
return True
|
||
if probability < 1.0 and random.random() > probability:
|
||
return True
|
||
t0 = time.time()
|
||
# 策略1: DOM选择器
|
||
selector = SELECTORS['collect']
|
||
try:
|
||
ok = await self._click_selector(selector)
|
||
if ok:
|
||
self._action_counts["collect"] += 1
|
||
await self._wait(0.5 + random.uniform(0, 1))
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COLLECT", selector, True, dur)
|
||
return True
|
||
except:
|
||
pass
|
||
# 策略2: JS找包含"收藏"文字或collect/save类名的元素
|
||
try:
|
||
clicked = await self.page.evaluate("""() => {
|
||
const all = document.querySelectorAll('button, span, div, [class*="collect"], [class*="save"]');
|
||
for (const el of all) {
|
||
const t = el.textContent || '';
|
||
const c = el.className || '';
|
||
if ((t.includes('收藏') || c.includes('collect') || c.includes('save')) && el.offsetParent) {
|
||
el.click(); return true;
|
||
}
|
||
}
|
||
return false;
|
||
}""")
|
||
if clicked:
|
||
self._action_counts["collect"] += 1
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COLLECT", "js_search", True, dur)
|
||
return True
|
||
except:
|
||
pass
|
||
# 策略3: 坐标兜底(点赞右边70px)
|
||
try:
|
||
like = self.page.locator('[data-e2e="video-player-digg"]')
|
||
if await like.count() > 0:
|
||
box = await like.first.bounding_box()
|
||
if box:
|
||
await self.page.mouse.click(box['x'] + 70, box['y'])
|
||
await self._wait(0.5)
|
||
self._action_counts["collect"] += 1
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COLLECT", "coordinate", True, dur)
|
||
return True
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
async def follow(self, step_id: int = 0) -> bool:
|
||
"""AO_FOLLOW: 关注当前视频作者(DOM点击+键盘g兜底)"""
|
||
if not self._check_rate("follow"):
|
||
return True
|
||
t0 = time.time()
|
||
# 策略1: DOM点击
|
||
selector = SELECTORS['follow']
|
||
try:
|
||
ok = await self._click_selector(selector)
|
||
if ok:
|
||
self._action_counts["follow"] += 1
|
||
await self._wait(1 + random.uniform(0, 1))
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_FOLLOW", selector, True, dur)
|
||
return True
|
||
except:
|
||
pass
|
||
# 策略2: 键盘 g(录制发现用户用 g 键关注)
|
||
try:
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['follow'])
|
||
await self._wait(1 + random.uniform(0, 1))
|
||
self._action_counts["follow"] += 1
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_FOLLOW", "keyboard_g", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_FOLLOW", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def next_video(self, step_id: int = 0) -> bool:
|
||
"""AO_SWIPE: 下翻到下一个视频"""
|
||
t0 = time.time()
|
||
selector = SELECTORS['next_arrow']
|
||
try:
|
||
# 优先点击箭头(比键盘更可靠)
|
||
ok = await self._click_selector(selector)
|
||
if not ok:
|
||
# fallback: 键盘
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['next'])
|
||
await self._wait(2 + random.uniform(0, 1))
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_SWIPE", selector, True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_SWIPE", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def prev_video(self, step_id: int = 0) -> bool:
|
||
"""AO_SWIPE: 上翻到上一个视频"""
|
||
t0 = time.time()
|
||
selector = SELECTORS['prev_arrow']
|
||
try:
|
||
ok = await self._click_selector(selector)
|
||
if not ok:
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['prev'])
|
||
await self._wait(2 + random.uniform(0, 1))
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_SWIPE", selector, True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_SWIPE", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def toggle_play(self, step_id: int = 0) -> bool:
|
||
"""播放/暂停切换"""
|
||
t0 = time.time()
|
||
try:
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['play_pause'])
|
||
await self._wait(0.5)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_CLICK", "keyboard:Space", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_CLICK", "keyboard:Space", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
# ── 搜索类原子操作 ──────────────────────────────────────────
|
||
|
||
async def search(self, keyword: str, step_id: int = 0) -> bool:
|
||
"""AO_SEARCH: 搜索关键词"""
|
||
if not self._check_rate("search"):
|
||
return True
|
||
t0 = time.time()
|
||
selector = SELECTORS['search_input']
|
||
try:
|
||
el = self.page.locator(selector)
|
||
if await el.count() == 0:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_SEARCH", selector, False, dur, "搜索框未找到")
|
||
return False
|
||
|
||
await el.click()
|
||
await self._wait(0.3)
|
||
await el.fill('')
|
||
await el.press_sequentially(keyword, delay=random.uniform(60, 120))
|
||
await self._wait(0.3)
|
||
await el.press('Enter')
|
||
self._action_counts["search"] += 1
|
||
await self._wait(3)
|
||
|
||
# 验证:URL 包含 search
|
||
url = self.page.url
|
||
ok = 'search' in url
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_SEARCH", f"{selector}:{keyword}", ok, dur)
|
||
return ok
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_SEARCH", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def click_search_result(self, index: int = 0, step_id: int = 0) -> bool:
|
||
"""AO_CLICK: 点击第N个搜索结果(0-indexed)"""
|
||
t0 = time.time()
|
||
selector = SELECTORS['search_card']
|
||
try:
|
||
cards = self.page.locator(selector)
|
||
count = await cards.count()
|
||
if index >= count:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_CLICK", selector, False, dur, f"只有{count}个结果,第{index+1}个不存在")
|
||
return False
|
||
|
||
await cards.nth(index).click()
|
||
await self._wait(3)
|
||
|
||
# 验证:URL 包含 modal_id
|
||
url = self.page.url
|
||
ok = 'modal_id' in url
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_CLICK", f"{selector}[{index}]", ok, dur)
|
||
return ok
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_CLICK", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
# ── 评论类原子操作 ──────────────────────────────────────────
|
||
|
||
async def _click_comment_btn(self) -> bool:
|
||
"""点击页面上的评论区入口按钮(快速探测,100ms超时)"""
|
||
for sel in [
|
||
SELECTORS['comment_icon'],
|
||
'[data-e2e="video-player-comment"]',
|
||
'[class*="comment-action"]',
|
||
'[class*="comment-count"]',
|
||
'[aria-label*="评论"]',
|
||
]:
|
||
try:
|
||
btn = self.page.locator(sel)
|
||
await btn.first.wait_for(state="attached", timeout=100)
|
||
await btn.first.click(timeout=1000)
|
||
return True
|
||
except Exception:
|
||
continue
|
||
return False
|
||
|
||
async def open_comments(self, step_id: int = 0) -> bool:
|
||
"""打开评论区(和成功版一致的逻辑)"""
|
||
t0 = time.time()
|
||
try:
|
||
# 1. 先检测评论区是否已打开
|
||
try:
|
||
if await self.page.locator(SELECTORS['comment_list']).count() > 0:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "already_open", True, dur)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
url = self.page.url
|
||
is_standalone = '/video/' in url and 'modal_id' not in url
|
||
|
||
if is_standalone:
|
||
# ── B模式:评论区在页面下方,滚动触发懒加载 ──
|
||
# 首先检测评论区是否已加载
|
||
_cl = self.page.locator(SELECTORS['comment_list'])
|
||
if await _cl.count() > 0:
|
||
await _cl.first.scroll_into_view_if_needed()
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "B_already_loaded", True, dur)
|
||
return True
|
||
# 滚动到页面底部触发评论区懒加载
|
||
await self.page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
|
||
await self._wait(2)
|
||
if await _cl.count() > 0:
|
||
await _cl.first.scroll_into_view_if_needed()
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "B_scroll_bottom", True, dur)
|
||
return True
|
||
# 再试:滚动到 70% 位置(评论区可能在中间)
|
||
await self.page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight * 0.7)")
|
||
await self._wait(1.5)
|
||
if await _cl.count() > 0:
|
||
await _cl.first.scroll_into_view_if_needed()
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "B_scroll_mid", True, dur)
|
||
return True
|
||
# 回到顶部
|
||
await self.page.evaluate("window.scrollTo(0, 0)")
|
||
else:
|
||
# ── A模式:键盘X打开评论区浮层 ──
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['comment'])
|
||
await self._wait(1.0)
|
||
try:
|
||
if await self.page.locator(SELECTORS['comment_list']).count() > 0:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "A:keyboard_x", True, dur)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_OPEN", "failed", False, dur)
|
||
return False
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_OPEN", "error", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def close_comments(self, step_id: int = 0) -> bool:
|
||
"""关闭评论区"""
|
||
t0 = time.time()
|
||
try:
|
||
await self._ensure_video_focused()
|
||
await self.page.keyboard.press(KEYS['comment'])
|
||
await self._wait(1)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_CLICK", "keyboard:x", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_CLICK", "keyboard:x", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def post_comment(self, text: str, step_id: int = 0) -> str:
|
||
"""AO_COMMENT: 发表评论(纯Playwright操作)。返回 'ok' / 'verify_needed' / 'failed'"""
|
||
if not self._check_rate("comment"):
|
||
print(f" [post_comment] ⚡ 频率限制跳过")
|
||
return 'ok'
|
||
t0 = time.time()
|
||
try:
|
||
# 1. 确保评论区打开
|
||
_cl_count = await self.page.locator(SELECTORS['comment_list']).count()
|
||
print(f" [post_comment] comment_list.count={_cl_count}")
|
||
if _cl_count == 0:
|
||
await self.open_comments(step_id)
|
||
await self._wait(2)
|
||
|
||
# 2. 滚动评论区到可视区域
|
||
cl = self.page.locator(SELECTORS['comment_list'])
|
||
if await cl.count() > 0:
|
||
try:
|
||
await cl.first.scroll_into_view_if_needed(timeout=3000)
|
||
except Exception:
|
||
pass
|
||
await self._wait(0.5)
|
||
|
||
# 3. 找输入框——多选择器兜底
|
||
editor_selectors = [
|
||
'[contenteditable="true"]',
|
||
SELECTORS['comment_editor'],
|
||
'[data-e2e="comment-input"]',
|
||
'[class*="comment-input"]',
|
||
'textarea[placeholder*="评论"]',
|
||
'input[placeholder*="评论"]',
|
||
]
|
||
editor = None
|
||
for sel in editor_selectors:
|
||
el = self.page.locator(sel)
|
||
c = await el.count()
|
||
print(f" [post_comment] sel={sel} count={c}")
|
||
if c > 0:
|
||
editor = el
|
||
print(f" [post_comment] ✅ 命中: {sel}")
|
||
break
|
||
if editor is None:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COMMENT", "all_selectors", False, dur, "所有选择器未命中")
|
||
print(f" [post_comment] ❌ 无输入框, 耗时{dur}ms")
|
||
return 'failed'
|
||
|
||
# 4. 真实鼠标点击激活编辑器(不用 force=True,不用 JS evaluate)
|
||
try:
|
||
await editor.first.wait_for(state="visible", timeout=3000)
|
||
await editor.first.scroll_into_view_if_needed(timeout=2000)
|
||
await self._wait(0.3)
|
||
except Exception:
|
||
pass
|
||
# Playwright 真实鼠标点击(非 JS click)
|
||
box = await editor.first.bounding_box()
|
||
if box:
|
||
await self.page.mouse.click(
|
||
box['x'] + box['width'] // 2,
|
||
box['y'] + box['height'] // 2
|
||
)
|
||
else:
|
||
await editor.first.click()
|
||
await self._wait(0.8)
|
||
|
||
# 5. pbcopy + Meta+V 粘贴输入(复制 reply_comment 的成功方案)
|
||
# 绕过 Draft.js + Camoufox 键盘事件兼容问题,粘贴是浏览器原生操作
|
||
proc = await asyncio.create_subprocess_exec(
|
||
'pbcopy', stdin=asyncio.subprocess.PIPE
|
||
)
|
||
await proc.communicate(input=text.encode())
|
||
await self._wait(0.3)
|
||
await self.page.keyboard.press('Meta+V')
|
||
await self._wait(0.5)
|
||
|
||
# 6. 发送——用 Enter (你说可用回车或 Ctrl+回车)
|
||
sent = False
|
||
for attempt in range(3):
|
||
# 策略A: Enter
|
||
await self._wait(0.3)
|
||
await self.page.keyboard.press('Enter')
|
||
await self._wait(2.5)
|
||
if await self._verify_comment_posted(text):
|
||
sent = True
|
||
break
|
||
# 策略B: Ctrl+Enter
|
||
await self.page.keyboard.press('Control+Enter')
|
||
await self._wait(2.5)
|
||
if await self._verify_comment_posted(text):
|
||
sent = True
|
||
break
|
||
|
||
if not sent:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COMMENT", "send_failed", False, dur, "所有发送方式均失败")
|
||
print(f" [post_comment] ❌ 发送失败, 耗时{dur}ms")
|
||
return 'failed'
|
||
|
||
self._action_counts["comment"] += 1
|
||
await self._wait(1.5)
|
||
|
||
# 7. 检查验证码
|
||
if await self.page.locator(SELECTORS['verify_panel']).count() > 0:
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COMMENT", "verify_panel", True, dur, "触发验证码")
|
||
return 'verify_needed'
|
||
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_COMMENT", f"ok:{text[:20]}", True, dur)
|
||
print(f" [post_comment] ✅ 发送成功, 耗时{dur}ms")
|
||
return 'ok'
|
||
|
||
except Exception as e:
|
||
print(f" [post_comment] ❌ 异常: {e}")
|
||
await self._log_op(step_id, "AO_COMMENT", "error", False, int((time.time()-t0)*1000), str(e))
|
||
return 'failed'
|
||
|
||
async def cancel_verify(self, step_id: int = 0) -> bool:
|
||
"""取消验证码弹窗"""
|
||
t0 = time.time()
|
||
selector = SELECTORS['verify_cancel']
|
||
try:
|
||
ok = await self._click_selector(selector)
|
||
await self._wait(1)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_CLICK", selector, ok, dur)
|
||
return ok
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_CLICK", selector, False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def auto_verify(self, step_id: int = 0) -> bool:
|
||
"""
|
||
自动处理验证弹窗:检测→填手机→获验证码→填码→确认
|
||
兼容新旧两种 class 名,复用 SMS API 获取验证码。
|
||
"""
|
||
t0 = time.time()
|
||
try:
|
||
panel = self.page.locator(SELECTORS['verify_panel'])
|
||
if await panel.count() == 0:
|
||
return False
|
||
|
||
self.log(f" ⚠️ 检测到验证弹窗, 自动处理...")
|
||
|
||
# 1. 找手机号输入框并填号
|
||
phone_input = self.page.locator(SELECTORS['verify_phone_input']).first
|
||
if await phone_input.count() > 0:
|
||
await phone_input.click()
|
||
await self._wait(0.5)
|
||
phone = self._get_phone_from_account()
|
||
if phone:
|
||
await phone_input.fill(phone)
|
||
self.log(f" 📱 已填入手机号: {phone}")
|
||
await self._wait(1)
|
||
|
||
# 2. 点击"获取验证码"
|
||
get_code_btn = self.page.locator(SELECTORS['verify_get_code']).first
|
||
if await get_code_btn.count() > 0:
|
||
await get_code_btn.click()
|
||
self.log(f" 📡 已点击获取验证码")
|
||
await self._wait(1)
|
||
|
||
# 3. 等待验证码输入框出现
|
||
for _ in range(45):
|
||
if await self.page.locator(SELECTORS['verify_input']).count() > 0:
|
||
break
|
||
await asyncio.sleep(1)
|
||
|
||
# 4. 获取验证码(复用 SMS API)
|
||
code = await self._fetch_sms_code()
|
||
if not code:
|
||
self.log(f" ❌ 获取验证码失败")
|
||
return False
|
||
self.log(f" ✅ 获取到验证码: {code}")
|
||
|
||
# 5. 填验证码
|
||
code_input = self.page.locator(SELECTORS['verify_input']).first
|
||
if await code_input.count() > 0:
|
||
await code_input.fill(code)
|
||
await self._wait(0.5)
|
||
|
||
# 6. 点确认
|
||
confirm = self.page.locator(SELECTORS['verify_confirm']).first
|
||
if await confirm.count() > 0:
|
||
await confirm.click()
|
||
else:
|
||
await self.page.keyboard.press('Enter')
|
||
|
||
await self._wait(2)
|
||
|
||
# 6.5 检测"请使用抖音App登录"
|
||
try:
|
||
body_text = (await self.page.evaluate("document.body.innerText")) or ""
|
||
for keyword in ["请使用抖音App登录", "请使用抖音 APP", "请使用抖音app"]:
|
||
if keyword in body_text:
|
||
self.log(f" ⛔ 检测到: {keyword} — 该账号需使用抖音App登录")
|
||
self._app_login_required = True
|
||
self._mark_app_login_required()
|
||
return False
|
||
except:
|
||
pass
|
||
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_VERIFY", "auto", True, dur)
|
||
self.log(f" ✅ 验证完成")
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_VERIFY", "auto", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def _get_phone_from_account(self) -> str:
|
||
"""从 accounts.yaml 读取当前账号手机号"""
|
||
try:
|
||
import yaml, os
|
||
cfg = os.path.expanduser(
|
||
'~/workbuddy-agent-os/agent-local/tools/matrix/config/accounts.yaml')
|
||
aid = self._account_id if hasattr(self, '_account_id') else ''
|
||
if os.path.exists(cfg):
|
||
data = yaml.safe_load(open(cfg))
|
||
for a in data.get('accounts', []):
|
||
if a.get('id') == aid:
|
||
return a.get('phone', '')
|
||
except:
|
||
pass
|
||
return ''
|
||
|
||
async def _fetch_sms_code(self) -> str:
|
||
"""获取 SMS 验证码(复用短信 API)"""
|
||
try:
|
||
from matrix_modules.account.sms.api import ApiSMSHandler
|
||
phone = self._get_phone_from_account()
|
||
if not phone:
|
||
return ''
|
||
handler = ApiSMSHandler(phone=phone)
|
||
code = await handler.wait(platform=self._account_id or "douyin", timeout=120)
|
||
if code:
|
||
self._reset_sms_failures()
|
||
return code
|
||
except:
|
||
pass
|
||
# 走到这里说明 SMS 获取失败
|
||
if self._track_sms_failure():
|
||
self._mark_sms_failed()
|
||
return ''
|
||
|
||
# ── 评论验证 ──────────────────────────────────────────────
|
||
|
||
async def _verify_comment_posted(self, text: str) -> bool:
|
||
"""验证评论是否已发布:检查评论区前几条是否包含刚发的文字"""
|
||
try:
|
||
cl = self.page.locator(SELECTORS['comment_list'])
|
||
if await cl.count() == 0:
|
||
return False
|
||
# 获取评论区所有评论项
|
||
items = cl.first.locator('> div, [data-e2e="comment-item"]')
|
||
count = await items.count()
|
||
if count == 0:
|
||
return False
|
||
# 检查前 5 条评论是否包含我们的文字(全文匹配)
|
||
check_text = text.strip()
|
||
for i in range(min(count, 5)):
|
||
try:
|
||
item_text = await items.nth(i).inner_text(timeout=2000)
|
||
if check_text in item_text.strip():
|
||
print(f" [verify_comment] ✅ 评论已发布 (第{i+1}条)")
|
||
return True
|
||
except Exception:
|
||
continue
|
||
print(f" [verify_comment] ❌ 未在评论区找到匹配文字")
|
||
return False
|
||
except Exception as e:
|
||
print(f" [verify_comment] ❌ 验证异常: {e}")
|
||
return False
|
||
|
||
# ── 等待类原子操作 ──────────────────────────────────────────
|
||
|
||
async def wait_watch(self, seconds: float = None, step_id: int = 0) -> bool:
|
||
"""AO_WAIT: 模拟观看视频,随机 5-30 秒"""
|
||
if seconds is None:
|
||
seconds = random.uniform(5, 30)
|
||
t0 = time.time()
|
||
await asyncio.sleep(seconds)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_WAIT", f"{seconds:.1f}s", True, dur)
|
||
return True
|
||
|
||
# ── 状态查询 ──────────────────────────────────────────────
|
||
|
||
async def get_state(self) -> dict:
|
||
"""获取当前页面状态快照"""
|
||
return await self.page.evaluate('''() => {
|
||
const v = document.querySelector('video');
|
||
const d = document.querySelector('[data-e2e="video-player-digg"]');
|
||
return {
|
||
url: location.href,
|
||
title: document.title,
|
||
video: v ? {
|
||
paused: v.paused,
|
||
currentTime: Math.round(v.currentTime),
|
||
duration: Math.round(v.duration) || 0
|
||
} : null,
|
||
digg_state: d ? d.getAttribute('data-e2e-state') : null,
|
||
};
|
||
}''')
|
||
|
||
async def is_on_feed(self) -> bool:
|
||
"""是否在视频 Feed 页"""
|
||
return await self.page.locator('video').count() > 0
|
||
|
||
async def is_on_search(self) -> bool:
|
||
"""是否在搜索结果页"""
|
||
return 'search' in self.page.url
|
||
|
||
async def is_verify_shown(self) -> bool:
|
||
"""是否弹出了验证码"""
|
||
return await self.page.locator(SELECTORS['verify_panel']).count() > 0
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 个人主页 & 信息采集
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def goto_profile(self, step_id: int = 0) -> dict:
|
||
"""AO_PROFILE: 进入个人主页,一次性采集全部字段
|
||
|
||
v5 策略(2026-08-15):API 优先,DOM 兜底
|
||
1. 同源 fetch 抖音 Web API(profile/self → profile/other)
|
||
—— 不依赖页面 DOM 结构,抖音改版不再影响
|
||
2. API 失败 → DOM innerText 正则(旧逻辑保留)
|
||
"""
|
||
t0 = time.time()
|
||
await self.page.goto("https://www.douyin.com/user/self", timeout=20000, wait_until="domcontentloaded")
|
||
# 检查登录状态,未登录时自动处理弹窗
|
||
logged_in = await self._ensure_logged_in()
|
||
if logged_in:
|
||
# 登录成功后需要重新导航到个人主页(登录弹窗可能改变了页面URL)
|
||
await self.page.goto("https://www.douyin.com/user/self", timeout=20000, wait_until="domcontentloaded")
|
||
await asyncio.sleep(3)
|
||
else:
|
||
print("⚠️ 未能登录,但仍尝试采集数据")
|
||
await asyncio.sleep(3)
|
||
|
||
# ── v5: API 优先(不依赖 DOM 结构)──
|
||
profile = await self._fetch_profile_api()
|
||
if not profile or not profile.get("nickname"):
|
||
print("[douyin_ops] ⚠️ API 采集失败,降级 DOM 解析")
|
||
profile = await self._collect_profile_dom()
|
||
|
||
self._profile = profile
|
||
|
||
# 抖音封号检测
|
||
ban_status = await self._check_douyin_banned()
|
||
profile["_status"] = ban_status
|
||
if ban_status == "banned":
|
||
print(f"[douyin_ops] ⚠️ 账号被封禁")
|
||
|
||
self._save_profiles_json() # ← 写入 profiles.json
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_PROFILE", "user/self", True, dur)
|
||
return profile
|
||
|
||
async def _check_douyin_banned(self) -> str:
|
||
"""检测抖音账号是否被封禁
|
||
Returns: "normal" / "banned" / "unknown"
|
||
"""
|
||
try:
|
||
text = (await self.page.evaluate("document.body.innerText")) or ""
|
||
# 封号关键词
|
||
ban_keywords = ["账号已重置", "因违规", "已被封禁", "账号存在风险",
|
||
"已被限制", "账号异常", "违规封禁", "处罚通知"]
|
||
for kw in ban_keywords:
|
||
if kw in text:
|
||
print(f"[douyin_ops] ⚠️ 检测到封号关键词: {kw}")
|
||
return "banned"
|
||
# DOM 检测:特定封号提示元素
|
||
try:
|
||
ban_el = self.page.locator('[class*="ban"],[class*="punish"],[class*="forbid"]')
|
||
if await ban_el.count() > 0 and await ban_el.first.is_visible():
|
||
return "banned"
|
||
except:
|
||
pass
|
||
except:
|
||
pass
|
||
return "normal"
|
||
|
||
async def read_profile_field(self, field: str, step_id: int = 0) -> str:
|
||
"""AO_PROFILE: 从已缓存的 profile 读取单个字段"""
|
||
if not hasattr(self, '_profile') or not self._profile:
|
||
await self.goto_profile(step_id)
|
||
return self._profile.get(field, '?')
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 评论区互动
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def read_my_comments(self, step_id: int = 0) -> list[dict]:
|
||
"""AO_COMMENT: 读取当前视频的评论区,返回评论列表"""
|
||
t0 = time.time()
|
||
await self.open_comments(step_id)
|
||
await asyncio.sleep(2)
|
||
comments = await self.page.evaluate("""() => {
|
||
const items = document.querySelectorAll('[class*="comment-item"], [class*="CommentItem"]');
|
||
return Array.from(items).slice(0, 20).map(el => {
|
||
const textEl = el.querySelector('[class*="text"], [class*="content"]');
|
||
const nameEl = el.querySelector('[class*="name"], [class*="author"]');
|
||
return {
|
||
text: textEl ? textEl.textContent.trim().slice(0, 200) : '',
|
||
author: nameEl ? nameEl.textContent.trim() : '',
|
||
};
|
||
});
|
||
}""")
|
||
self._last_comments = comments
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_READ_COMMENTS", "comment-item", True, dur)
|
||
return comments
|
||
|
||
async def reply_comment(self, reply_text: str, step_id: int = 0) -> bool:
|
||
"""AO_COMMENT: 回复当前评论区的第一条评论"""
|
||
t0 = time.time()
|
||
if not await self.page.locator(SELECTORS['comment_list']).count() > 0:
|
||
await self.open_comments(step_id)
|
||
await asyncio.sleep(2)
|
||
# 点击回复按钮
|
||
reply_btn = await self.page.evaluate("""() => {
|
||
const btns = document.querySelectorAll('[class*="reply"], [class*="Reply"]');
|
||
for (const b of btns) {
|
||
if (b.offsetParent !== null) { b.click(); return true; }
|
||
}
|
||
return false;
|
||
}""")
|
||
await asyncio.sleep(1)
|
||
# pbcopy + Meta+V 发送回复
|
||
if reply_btn:
|
||
proc = await asyncio.create_subprocess_exec("pbcopy", stdin=asyncio.subprocess.PIPE)
|
||
await proc.communicate(input=reply_text.encode())
|
||
await asyncio.sleep(0.5)
|
||
await self.page.keyboard.press("Meta+V")
|
||
await asyncio.sleep(1)
|
||
await self.page.keyboard.press("Enter")
|
||
await asyncio.sleep(2)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_REPLY", "reply", reply_btn, dur)
|
||
return reply_btn
|
||
|
||
async def like_comment(self, target: str = "first", step_id: int = 0) -> bool:
|
||
"""点赞评论。target=first 点第一条, hot 点热评, 或数字指定索引"""
|
||
t0 = time.time()
|
||
try:
|
||
if await self.page.locator(SELECTORS['comment_list']).count() == 0:
|
||
await self.open_comments(step_id)
|
||
await asyncio.sleep(2)
|
||
|
||
items = await self.page.locator(SELECTORS['comment_like']).all()
|
||
if not items:
|
||
await self._log_op(step_id, "AO_LIKE_COMMENT", "comment_like", False, int((time.time()-t0)*1000), "无评论点赞按钮")
|
||
return False
|
||
|
||
idx = 0
|
||
if target == "hot":
|
||
# 找有热评标记的评论
|
||
hot_items = await self.page.locator(f'{SELECTORS["comment_item"]}:has({SELECTORS["hot_tag"]})').all()
|
||
if hot_items:
|
||
idx = 1 # 热评通常是第二条(第一条是置顶)
|
||
elif target.isdigit():
|
||
idx = int(target)
|
||
|
||
if idx >= len(items):
|
||
idx = 0
|
||
await items[idx].click()
|
||
await self._wait(1)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_LIKE_COMMENT", f"comment_like[{idx}]", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_LIKE_COMMENT", "comment_like", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def scroll_to_hot_comment(self, step_id: int = 0) -> bool:
|
||
"""滚动到热评区域"""
|
||
t0 = time.time()
|
||
try:
|
||
if await self.page.locator(SELECTORS['comment_list']).count() == 0:
|
||
await self.open_comments(step_id)
|
||
await asyncio.sleep(2)
|
||
|
||
# 滚动评论区到热评
|
||
await self.page.evaluate("""() => {
|
||
const list = document.querySelector('[data-e2e="comment-list"]');
|
||
if (list) {
|
||
const items = list.querySelectorAll('[data-e2e="comment-item"]');
|
||
if (items.length > 3) {
|
||
items[Math.min(2, items.length-1)].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||
}
|
||
}
|
||
}""")
|
||
await self._wait(1.5)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_SCROLL_HOT", "comment-list", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_SCROLL_HOT", "comment-list", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
# ── 占位: 三级接力原子操作(等你录制后替换)──
|
||
|
||
async def find_comment_by_code(self, code: str, step_id: int = 0) -> bool:
|
||
"""
|
||
在评论区搜索包含识别码的评论。
|
||
遍历所有 comment-item,匹配文本内容中的 🌸XX 码。
|
||
"""
|
||
t0 = time.time()
|
||
try:
|
||
if await self.page.locator(SELECTORS['comment_list']).count() == 0:
|
||
await self.open_comments(step_id)
|
||
await asyncio.sleep(2)
|
||
|
||
# 遍历所有评论,找包含 code 的
|
||
found = await self.page.evaluate(f"""() => {{
|
||
const items = document.querySelectorAll('[data-e2e="comment-item"]');
|
||
for (const item of items) {{
|
||
const text = (item.textContent || '').trim();
|
||
if (text.includes('{code}')) {{
|
||
item.scrollIntoView({{behavior:'smooth', block:'center'}});
|
||
item.style.border = '2px solid red';
|
||
// 点击评论使其聚焦
|
||
const replyBtn = item.querySelector('[class*="reply"],[class*="Reply"]');
|
||
if (replyBtn) {{
|
||
replyBtn.click();
|
||
return 'found_and_reply';
|
||
}}
|
||
item.click();
|
||
return 'found';
|
||
}}
|
||
}}
|
||
return '';
|
||
}}""")
|
||
if found:
|
||
await self._wait(1.5)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_FIND_COMMENT", f"code={code} result={found}", True, dur)
|
||
return True
|
||
else:
|
||
await self._log_op(step_id, "AO_FIND_COMMENT", f"code={code} not_found", False, int((time.time()-t0)*1000))
|
||
return False
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_FIND_COMMENT", f"code={code}", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def reply_with_text(self, text: str, step_id: int = 0) -> bool:
|
||
"""
|
||
回复当前聚焦的评论(find_comment_by_code 后调用)。
|
||
在已聚焦的评论回复框中输入文本并发送。
|
||
"""
|
||
t0 = time.time()
|
||
try:
|
||
# 找 Draft.js 编辑器
|
||
editor = self.page.locator(SELECTORS['comment_editor'])
|
||
if await editor.count() == 0:
|
||
await self._log_op(step_id, "AO_REPLY_TEXT", "editor", False, int((time.time()-t0)*1000), "回复输入框未找到")
|
||
return False
|
||
|
||
await editor.click()
|
||
await self._wait(0.5)
|
||
await editor.press_sequentially(text, delay=random.uniform(50, 120))
|
||
await self._wait(0.5)
|
||
|
||
# 发送:先找发送按钮,失败则 Enter
|
||
sent = await self.page.evaluate("""() => {
|
||
const btns = document.querySelectorAll('button, [class*="send"], [class*="submit"]');
|
||
for (const b of btns) {
|
||
const t = (b.textContent || '').trim();
|
||
if (t.includes('发送') || t.includes('发布') || b.className.includes('send')) {
|
||
b.click(); return true;
|
||
}
|
||
}
|
||
return false;
|
||
}""")
|
||
if not sent:
|
||
await self.page.keyboard.press('Enter')
|
||
|
||
await self._wait(1.5)
|
||
dur = int((time.time() - t0) * 1000)
|
||
await self._log_op(step_id, "AO_REPLY_TEXT", f"text={text[:20]}", True, dur)
|
||
return True
|
||
except Exception as e:
|
||
await self._log_op(step_id, "AO_REPLY_TEXT", f"text={text[:20]}", False, int((time.time()-t0)*1000), str(e))
|
||
return False
|
||
|
||
async def post_comment_with_code(self, text: str, code: str, step_id: int = 0) -> str:
|
||
"""发评+识别码。复用 post_comment,末尾追加 code"""
|
||
full_text = f"{text} {code}" if code else text
|
||
return await self.post_comment(full_text, step_id=step_id)
|
||
|
||
async def _ensure_logged_in(self) -> bool:
|
||
"""状态机:检查抖音登录状态,未登录则自动处理弹窗
|
||
|
||
流程:
|
||
1. 看右上角是否有 [data-e2e="user-info"] (已登录标志)
|
||
2. 如果已登录 → return True
|
||
3. 如果未登录:
|
||
a. 找登录弹窗(verify_panel)
|
||
b. 如果有一键登录按钮 → 点它
|
||
c. 如果有验证码输入框 → 切 tab → 调 sms_login
|
||
d. 如果无弹窗 → 点右上角「登录」触发弹窗
|
||
4. 再次检查右上角 → 有 user-info 则成功
|
||
"""
|
||
page = self.page
|
||
await asyncio.sleep(6) # 等页面稳定(抖音首页首次加载较慢)
|
||
|
||
# ── 1. 检查当前登录状态 ──
|
||
async def _has_avatar() -> bool:
|
||
"""右上角有头像/用户信息 = 已登录"""
|
||
try:
|
||
# 方法1: data-e2e 用户信息区
|
||
info = await page.query_selector('[data-e2e="user-info"]')
|
||
if info and await info.is_visible():
|
||
return True
|
||
# 方法2: 右上角头像区域(无 e2e 时的 fallback)
|
||
avatar = await page.query_selector('[class*="avatar"],[class*="Avatar"],[data-e2e*="avatar"]')
|
||
if avatar and await avatar.is_visible():
|
||
return True
|
||
# 方法3: 页面文本不含"登录"按钮字样
|
||
has_login_btn = await page.query_selector('button:has-text("登录"), span:has-text("登录"), div:has-text("登录")')
|
||
if not has_login_btn:
|
||
# 没有登录按钮 + 能拿到 user-info? 可能是变体
|
||
return False
|
||
btn_visible = await has_login_btn.is_visible()
|
||
return not btn_visible
|
||
except:
|
||
return False
|
||
|
||
if await _has_avatar():
|
||
print(" ✅ 已登录")
|
||
return True
|
||
|
||
print(" ⚠️ 未登录状态,检测登录弹窗...")
|
||
|
||
# ── 2. 查找登录弹窗(优先找 iframe,因为手机号输入框在 iframe 内)──
|
||
login_frame = page
|
||
panel = None
|
||
try:
|
||
# 先检查 iframe(passport 登录域)
|
||
for f in page.frames:
|
||
url = f.url.lower()
|
||
if "passport" in url or "login" in url or "sso" in url:
|
||
p = await f.query_selector(SELECTORS.get("verify_panel", '[class*="verify"]'))
|
||
if p:
|
||
panel = p
|
||
login_frame = f
|
||
break
|
||
# iframe 没找到 → 检查主页面(遮罩层)
|
||
if not panel:
|
||
panel = await page.query_selector(SELECTORS.get("verify_panel", '[class*="verify"]'))
|
||
except:
|
||
pass
|
||
|
||
# ── 3. 处理弹窗 ──
|
||
if panel:
|
||
print(" 📱 检测到登录弹窗")
|
||
# 3a. 检查是否是一键登录
|
||
try:
|
||
oneclick = await login_frame.query_selector('button:has-text("一键登录"), span:has-text("一键登录")')
|
||
if oneclick and await oneclick.is_visible():
|
||
print(" ✅ 检测到一键登录 → 点击")
|
||
await oneclick.click()
|
||
await asyncio.sleep(5)
|
||
if await _has_avatar():
|
||
print(" ✅ 一键登录成功")
|
||
return True
|
||
# 一键登录可能跳到验证码页,继续走 sms
|
||
except:
|
||
pass
|
||
|
||
# 3b. 检查是否是验证码登录界面(有手机号输入框)
|
||
try:
|
||
phone_inp = await login_frame.query_selector(SELECTORS["verify_phone_input"])
|
||
if phone_inp and await phone_inp.is_visible():
|
||
print(" 📱 检测到验证码登录界面 → 调用 sms_login")
|
||
return await self.sms_login(login_frame=login_frame)
|
||
except:
|
||
pass
|
||
|
||
# 3c. 如果既不是一键也不是验证码,尝试切到验证码 tab
|
||
try:
|
||
for text in ["验证码登录", "手机号登录", "短信登录"]:
|
||
tab = await login_frame.query_selector(
|
||
f'div:has-text("{text}"), span:has-text("{text}"), label:has-text("{text}")'
|
||
)
|
||
if tab and await tab.is_visible():
|
||
await tab.click()
|
||
await asyncio.sleep(3)
|
||
print(f" ✅ 切换到 {text}")
|
||
return await self.sms_login(login_frame=login_frame)
|
||
except:
|
||
pass
|
||
else:
|
||
# ── 4. 无弹窗 → 主动触发登录 ──
|
||
print(" 🔘 无登录弹窗,尝试点击登录按钮触发")
|
||
try:
|
||
login_btn = await page.query_selector(
|
||
'button:has-text("登录"), span:has-text("登录"), [data-e2e*="login"]'
|
||
)
|
||
if login_btn and await login_btn.is_visible():
|
||
await login_btn.click()
|
||
await asyncio.sleep(3)
|
||
# 递归检查弹窗
|
||
return await self._ensure_logged_in()
|
||
except:
|
||
pass
|
||
print(" ❌ 找不到登录按钮或弹窗")
|
||
return False
|
||
|
||
# ── 5. 最终检查 ──
|
||
await asyncio.sleep(3)
|
||
if await _has_avatar():
|
||
print(" ✅ 已登录")
|
||
return True
|
||
print(" ❌ 登录失败")
|
||
return False
|
||
|
||
async def sms_login(self, phone: str = "", step_id: int = 0, login_frame=None) -> bool:
|
||
"""抖音 SMS 验证码登录(处理 passport iframe 登录页)
|
||
|
||
Args:
|
||
phone: 手机号,为空则从账号配置读取
|
||
Returns:
|
||
True=登录成功
|
||
"""
|
||
page = self.page
|
||
log = lambda msg: None # 静默日志,出错时打印
|
||
|
||
# 1. 检查是否已在登录页,不在则导航过去
|
||
if "passport" not in page.url.lower() and "login" not in page.url.lower():
|
||
log("导航到抖音登录页...")
|
||
await page.goto("https://www.douyin.com/passport/sso/login/",
|
||
wait_until="domcontentloaded", timeout=30000)
|
||
await asyncio.sleep(4)
|
||
|
||
# 2. 查找登录 iframe(抖音护照登录页在 iframe 内)
|
||
if login_frame is None: # 未被 _ensure_logged_in 传入时才自己找
|
||
login_frame = None
|
||
for attempt in range(5):
|
||
frames = page.frames
|
||
for f in frames:
|
||
url = f.url.lower()
|
||
if "passport" in url or "login" in url or "sso" in url:
|
||
login_frame = f
|
||
break
|
||
if login_frame:
|
||
break
|
||
await asyncio.sleep(2)
|
||
|
||
if not login_frame:
|
||
print("❌ 未找到登录 iframe,尝试直接页面操作...")
|
||
login_frame = page # fallback
|
||
|
||
# 3. 等待登录页面完全渲染
|
||
await asyncio.sleep(3)
|
||
|
||
# 4. 检测当前登录模式(二维码 vs 手机号)
|
||
qr_visible = False
|
||
try:
|
||
qr = await login_frame.query_selector("div[class*=qrcode], img[class*=qrcode], canvas[class*=qrcode]")
|
||
if qr:
|
||
qr_visible = True
|
||
print("⚠️ 检测到二维码登录,尝试切换到手机号登录")
|
||
except:
|
||
pass
|
||
|
||
# 5. 无论二维码是否可见,都尝试切换到"验证码登录"tab
|
||
# (录制确认:抖音登录页默认显示二维码,需手动切tab)
|
||
switched_tab = False
|
||
for text in ["手机号登录", "手机登录", "短信登录", "验证码登录"]:
|
||
try:
|
||
tab = await login_frame.query_selector(f"div:has-text('{text}'), span:has-text('{text}'), label:has-text('{text}')")
|
||
if tab and await tab.is_visible():
|
||
await tab.click()
|
||
await asyncio.sleep(3) # 等 tab 切换动画 + 输入框渲染
|
||
print(f" ✅ 切换到 {text}")
|
||
switched_tab = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if qr_visible and not switched_tab:
|
||
print("⚠️ 检测到二维码但无法自动切换到手机号登录")
|
||
return False
|
||
|
||
# 6. 找手机号输入框并填入(带重试)
|
||
phone_filled = False
|
||
phone_value = phone
|
||
if not phone_value:
|
||
# 从账号配置查
|
||
try:
|
||
from matrix_mgmt import MatrixManager
|
||
mgr = MatrixManager()
|
||
for a in mgr.list_accounts():
|
||
if a["id"] == self._account_id:
|
||
phone_value = a.get("phone", "")
|
||
break
|
||
except:
|
||
pass
|
||
|
||
if not phone_value:
|
||
from matrix_modules.account.sms import ApiSMSHandler
|
||
handler = ApiSMSHandler()
|
||
phone_value = handler.get_phone()
|
||
if phone_value:
|
||
phone_value = str(phone_value)
|
||
|
||
print(f" 手机号: {phone_value or '默认'}")
|
||
|
||
# 尝试在 iframe 里找手机号输入框(带 3 次重试,防渲染延迟)
|
||
phone_sel = "input[placeholder*='手机'], input[type='tel'], input[name='mobile'], input[id*='phone'], input[id*='mobile']"
|
||
for retry in range(3):
|
||
for sel in [phone_sel, "input:first-of-type"]:
|
||
try:
|
||
inp = await login_frame.query_selector(sel)
|
||
if inp and await inp.is_visible():
|
||
await inp.click()
|
||
await asyncio.sleep(0.5)
|
||
if not phone_value:
|
||
print("❌ 未配置手机号,无法登录")
|
||
return False
|
||
await inp.fill(phone_value)
|
||
await asyncio.sleep(1)
|
||
phone_filled = True
|
||
print(f" ✅ 已填手机号: {phone_value}")
|
||
break
|
||
except:
|
||
continue
|
||
if phone_filled:
|
||
break
|
||
await asyncio.sleep(1) # 重试前等 1 秒
|
||
|
||
if not phone_filled:
|
||
# fallback: 填第一个可见 input
|
||
try:
|
||
inputs = await login_frame.query_selector_all("input:visible")
|
||
if inputs and len(inputs) > 0:
|
||
if not phone_value:
|
||
print("❌ 未配置手机号,无法登录")
|
||
return False
|
||
await inputs[0].click()
|
||
await inputs[0].fill(phone_value)
|
||
await asyncio.sleep(1)
|
||
phone_filled = True
|
||
except:
|
||
pass
|
||
|
||
if not phone_filled:
|
||
print("❌ 未找到手机号输入框")
|
||
return False
|
||
|
||
# 7. 点"获取验证码"按钮
|
||
code_sent = False
|
||
for text in ["获取验证码", "发送验证码", "获取"]:
|
||
try:
|
||
btn = await login_frame.query_selector(f"button:has-text('{text}'), span:has-text('{text}'), div:has-text('{text}')")
|
||
if btn and await btn.is_visible():
|
||
await btn.click()
|
||
await asyncio.sleep(2)
|
||
code_sent = True
|
||
print(f" ✅ 点击 '{text}'")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not code_sent:
|
||
# fallback: 点最后一个 button
|
||
try:
|
||
btns = await login_frame.query_selector_all("button")
|
||
if btns and len(btns) > 0:
|
||
await btns[-1].click()
|
||
await asyncio.sleep(2)
|
||
code_sent = True
|
||
except:
|
||
pass
|
||
|
||
# 8. 轮询 SMS 验证码
|
||
from matrix_modules.account.sms import ApiSMSHandler
|
||
handler = ApiSMSHandler(phone=phone_value) if phone_value else ApiSMSHandler()
|
||
code = ""
|
||
for retry in range(6): # 最多等 60s
|
||
await asyncio.sleep(10)
|
||
try:
|
||
code = await handler.wait("抖音登录", timeout=5)
|
||
if code and len(code) in (4, 5, 6):
|
||
print(f" ✅ 获取到验证码: {code}")
|
||
break
|
||
except:
|
||
continue
|
||
if retry == 3:
|
||
print(" ⏳ 重发验证码...")
|
||
# 尝试重新发送
|
||
for text in ["重新发送", "获取验证码", "重发"]:
|
||
try:
|
||
btn = await login_frame.query_selector(f"button:has-text('{text}')")
|
||
if btn and await btn.is_visible():
|
||
await btn.click()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not code or len(code) not in (4, 5, 6):
|
||
print("❌ 获取验证码失败")
|
||
return False
|
||
|
||
# 9. 填入验证码
|
||
code_filled = False
|
||
code_sel = "input[placeholder*='验证码'], input[maxlength='6'], input[maxlength='4'], input[autocomplete='one-time-code']"
|
||
for sel in [code_sel, "input:nth-of-type(2)", "input:last-of-type"]:
|
||
try:
|
||
inp = await login_frame.query_selector(sel)
|
||
if inp:
|
||
await inp.click()
|
||
await inp.fill(code)
|
||
await asyncio.sleep(1)
|
||
code_filled = True
|
||
print(f" ✅ 已填验证码")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not code_filled:
|
||
# fallback: 用 JS 填
|
||
try:
|
||
await login_frame.evaluate(f'''
|
||
() => {{
|
||
const inputs = document.querySelectorAll("input");
|
||
for (const i of inputs) {{
|
||
if (!i.value && i.offsetParent !== null) {{
|
||
i.value = "{code}";
|
||
i.dispatchEvent(new Event("input", {{bubbles: true}}));
|
||
break;
|
||
}}
|
||
}}
|
||
}}
|
||
''')
|
||
await asyncio.sleep(1)
|
||
except:
|
||
pass
|
||
|
||
# 10. 点登录确认
|
||
for text in ["登录", "确认", "同意并登录", "下一步"]:
|
||
try:
|
||
btn = await login_frame.query_selector(f"button:has-text('{text}')")
|
||
if btn and await btn.is_visible():
|
||
await btn.click()
|
||
await asyncio.sleep(3)
|
||
print(f" ✅ 点击 '{text}'")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 11. 检测"请使用抖音App登录"(验证码正确但账号需App登录)
|
||
await asyncio.sleep(3)
|
||
try:
|
||
body_text = (await page.evaluate("document.body.innerText")) or ""
|
||
for keyword in ["请使用抖音App登录", "请使用抖音 APP", "请使用抖音app"]:
|
||
if keyword in body_text:
|
||
print(f" ⛔ 检测到: {keyword} — 该账号需使用抖音App登录,短信已失效")
|
||
self._app_login_required = True
|
||
self._mark_app_login_required()
|
||
return False
|
||
except:
|
||
pass
|
||
|
||
# 12. 等待登录结果(URL 变化 = 登录成功)
|
||
await asyncio.sleep(5)
|
||
current_url = page.url
|
||
if "passport" not in current_url.lower() and "login" not in current_url.lower():
|
||
print(f" ✅ 登录成功! URL: {current_url[:60]}")
|
||
return True
|
||
# 兜底:即使 URL 还在登录域,但如果右上角有用户头像也视为成功
|
||
try:
|
||
info = await page.query_selector('[data-e2e="user-info"]')
|
||
if info and await info.is_visible():
|
||
print(" ✅ 右上角检测到用户信息,登录成功")
|
||
return True
|
||
except:
|
||
pass
|
||
print(f" ⚠️ 登录后仍在登录页,可能需要手动处理")
|
||
return False
|
||
|
||
def _mark_app_login_required(self):
|
||
"""标记账号为"需使用抖音App登录" — 写入 profiles.json"""
|
||
if not self._account_id:
|
||
return
|
||
try:
|
||
from datetime import datetime
|
||
PROFILES_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||
if PROFILES_JSON.exists():
|
||
all_p = json.loads(PROFILES_JSON.read_text())
|
||
else:
|
||
all_p = {}
|
||
all_p[self._account_id] = {
|
||
**all_p.get(self._account_id, {}),
|
||
"status": "app_login_required",
|
||
"_status": "app_login_required",
|
||
"status_detail": "需使用抖音App登录,短信验证码方式已失效",
|
||
"platform": "douyin",
|
||
"updated": datetime.now().isoformat(),
|
||
}
|
||
PROFILES_JSON.write_text(json.dumps(all_p, ensure_ascii=False, indent=2))
|
||
print(f" 📝 profiles.json 已更新: {self._account_id} → app_login_required")
|
||
except Exception as e:
|
||
print(f" ⚠️ _mark_app_login_required 写入失败: {e}")
|
||
|
||
def _mark_sms_failed(self):
|
||
"""标记账号为'短信接收失败' — 写入 profiles.json"""
|
||
if not self._account_id:
|
||
return
|
||
try:
|
||
from datetime import datetime
|
||
PROFILES_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||
if PROFILES_JSON.exists():
|
||
all_p = json.loads(PROFILES_JSON.read_text())
|
||
else:
|
||
all_p = {}
|
||
all_p[self._account_id] = {
|
||
**all_p.get(self._account_id, {}),
|
||
"status": "sms_failed",
|
||
"_status": "sms_failed",
|
||
"status_detail": "多次短信验证码接收失败,请检查手机号或短信服务",
|
||
"platform": "douyin",
|
||
"updated": datetime.now().isoformat(),
|
||
}
|
||
PROFILES_JSON.write_text(json.dumps(all_p, ensure_ascii=False, indent=2))
|
||
print(f" 📝 profiles.json 已更新: {self._account_id} → sms_failed")
|
||
except Exception as e:
|
||
print(f" ⚠️ _mark_sms_failed 写入失败: {e}")
|
||
|
||
_SMS_FAIL_FILE = Path(str(PROFILES_JSON.parent / "sms_failures.json"))
|
||
|
||
def _track_sms_failure(self) -> bool:
|
||
"""记录一次短信接收失败,连续2次返回 True(触发标记)"""
|
||
if not self._account_id:
|
||
return False
|
||
try:
|
||
self._SMS_FAIL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
fails = {}
|
||
if self._SMS_FAIL_FILE.exists():
|
||
fails = json.loads(self._SMS_FAIL_FILE.read_text())
|
||
acct = fails.get(self._account_id, {"count": 0})
|
||
acct["count"] = acct.get("count", 0) + 1
|
||
acct["last_attempt"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||
fails[self._account_id] = acct
|
||
self._SMS_FAIL_FILE.write_text(json.dumps(fails, ensure_ascii=False, indent=2))
|
||
print(f" 📡 SMS 接收失败 #{acct['count']} [{self._account_id}]")
|
||
return acct["count"] >= 2
|
||
except:
|
||
return False
|
||
|
||
def _reset_sms_failures(self):
|
||
"""SMS 成功后重置失败计数"""
|
||
if not self._account_id:
|
||
return
|
||
try:
|
||
if self._SMS_FAIL_FILE.exists():
|
||
fails = json.loads(self._SMS_FAIL_FILE.read_text())
|
||
if self._account_id in fails:
|
||
del fails[self._account_id]
|
||
self._SMS_FAIL_FILE.write_text(json.dumps(fails, ensure_ascii=False, indent=2))
|
||
except:
|
||
pass
|
||
|
||
def get_action_summary(self) -> dict:
|
||
"""获取本次会话的操作统计"""
|
||
return {
|
||
**self._action_counts,
|
||
"elapsed_minutes": round(self._elapsed_hours() * 60, 1),
|
||
} |