#!/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 容器内的数字(结构:
标签
数字
) 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), }