feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
验证码处理(接口预留)
|
||||
|
||||
与 sms/ 类似,captcha 提供验证码(图形/滑块)的抽象接口。
|
||||
当前默认实现为 ManualCaptchaHandler(手动处理)。
|
||||
"""
|
||||
|
||||
from .base import CaptchaHandler, ManualCaptchaHandler
|
||||
|
||||
__all__ = ["CaptchaHandler", "ManualCaptchaHandler"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
验证码处理 — 抽象接口
|
||||
|
||||
CaptchaHandler 是所有验证码处理器的基类:
|
||||
- ManualCaptchaHandler: 手动处理(默认)
|
||||
- OCRCaptchaHandler: OCR 自动识别(预留)
|
||||
- ThirdPartyCaptchaHandler: 打码平台(预留)
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class CaptchaHandler(ABC):
|
||||
"""验证码处理器抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def solve(self, page=None, timeout: int = 60) -> bool:
|
||||
"""处理验证码
|
||||
|
||||
Args:
|
||||
page: 浏览器页面对象(可选,用于截图/交互)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
是否成功解决
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ManualCaptchaHandler(CaptchaHandler):
|
||||
"""手动处理验证码(默认实现)"""
|
||||
|
||||
async def solve(self, page=None, timeout: int = 60) -> bool:
|
||||
print(f"\n🔐 需要验证码")
|
||||
print(f" 请在浏览器中手动完成验证")
|
||||
print(f" 超时: {timeout}秒")
|
||||
# 等待用户手动处理
|
||||
import asyncio
|
||||
for _ in range(timeout):
|
||||
await asyncio.sleep(1)
|
||||
# 检测页面 URL 是否验证通过(简单实现)
|
||||
if page:
|
||||
url = page.url
|
||||
if 'captcha' not in url.lower() and 'verify' not in url.lower():
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
douyin_login.py — 抖音账号登录接入(手机号 + SMS 验证码)
|
||||
|
||||
流程:
|
||||
1. 准备浏览器身份目录(自动创建)
|
||||
2. 启动 Camoufox,加载身份目录
|
||||
3. 导航到抖音首页
|
||||
4. 检测登录面板 → 点击"一键登录"
|
||||
5. ApiSMSHandler 自动轮询获取验证码
|
||||
6. 填入验证码 → 确认 → 验证登录态
|
||||
7. Cookie 自动持久化(Camoufox 持久化目录)
|
||||
8. 刷新 Dashboard 账号状态
|
||||
|
||||
用法:
|
||||
python douyin_login.py douyin_137 # 用默认手机号
|
||||
python douyin_login.py douyin_137 --phone 13754826808 # 指定手机号
|
||||
|
||||
版本: 1.0.0 | 更新: 2026-06-01
|
||||
"""
|
||||
import argparse, asyncio, json, os, sys, time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from cdp_connector import CDPConnector
|
||||
from browser_utils import GracefulBrowser
|
||||
from matrix_modules.account.sms_login import sms_login, has_login_panel
|
||||
from matrix_modules.account.sms import ApiSMSHandler
|
||||
|
||||
HOME = Path.home()
|
||||
|
||||
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||||
LOG_FILE = AGENT_LOCAL / "runtime" / "login_log.jsonl"
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def write_op_log(account_id: str, platform: str, status: str, detail: str = ""):
|
||||
entry = {"op": f"{platform}_login", "account": account_id, "status": status, "detail": detail, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
async def douyin_login(account_id: str, phone: str = "", timeout_minutes: int = 30):
|
||||
# ── 自动更新 SMS API 配置 ──
|
||||
if phone:
|
||||
sms_yaml_path = SCRIPTS_DIR / "config" / "sms.yaml"
|
||||
if sms_yaml_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
cfg = yaml.safe_load(sms_yaml_path.read_text()) or {}
|
||||
if "sms" not in cfg: cfg["sms"] = {}
|
||||
cfg["sms"]["phone"] = phone
|
||||
sms_yaml_path.write_text(yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
|
||||
log(f"📝 SMS API 已切换到手机号: {phone}")
|
||||
except Exception as e:
|
||||
log(f"⚠️ 更新 sms.yaml 失败: {e}")
|
||||
else:
|
||||
log(f"⚠️ sms.yaml 不存在,跳过配置更新")
|
||||
|
||||
identity_dir = str(IDENTITIES_ROOT / account_id)
|
||||
Path(identity_dir).mkdir(parents=True, exist_ok=True)
|
||||
log(f"📁 身份目录: {identity_dir}")
|
||||
|
||||
log("🚀 启动浏览器...")
|
||||
conn = CDPConnector(identity_dir=identity_dir, headless=False,
|
||||
window=(702, 783), window_position=(0, 0))
|
||||
|
||||
# 用 GracefulBrowser 包装(优雅退出 + 前置检查)
|
||||
gb = GracefulBrowser(conn, account_id=account_id,
|
||||
timeout_minutes=timeout_minutes)
|
||||
await gb.setup(check_running=True)
|
||||
|
||||
try:
|
||||
await conn.connect()
|
||||
except Exception as e:
|
||||
write_op_log(account_id, "douyin", "failed", f"浏览器启动失败: {e}")
|
||||
log(f"❌ 浏览器连接失败: {e}")
|
||||
return False
|
||||
await conn.page.set_viewport_size({"width": 702, "height": 783})
|
||||
log("✅ 浏览器就绪")
|
||||
|
||||
log("🌐 打开抖音首页...")
|
||||
await conn.page.goto("https://www.douyin.com/", timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(6)
|
||||
|
||||
log("📱 检测登录面板...")
|
||||
# 多轮检测: 自动检测失败后等用户手动操作
|
||||
panel_detected = False
|
||||
for attempt in range(3):
|
||||
if await has_login_panel(conn.page):
|
||||
panel_detected = True
|
||||
break
|
||||
# 也可能已经登录了
|
||||
if await conn.page.evaluate('() => !!document.querySelector("[data-e2e=user-avatar]")'):
|
||||
log("✅ 已登录,跳过")
|
||||
write_op_log(account_id, "douyin", "already_logged_in")
|
||||
return True
|
||||
if attempt == 0:
|
||||
log("⏳ 未检测到登录面板,请在浏览器上手动点击「登录」按钮...")
|
||||
log(" (等待 15 秒)")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if not panel_detected:
|
||||
log("⏳ 尝试导航到登录页面...")
|
||||
await conn.page.goto("https://www.douyin.com/", timeout=20000)
|
||||
await asyncio.sleep(4)
|
||||
for attempt in range(3):
|
||||
if await has_login_panel(conn.page):
|
||||
panel_detected = True
|
||||
break
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not panel_detected:
|
||||
log("❌ 未检测到登录面板,请手动在浏览器上操作")
|
||||
log(" 点击「登录」或「一键登录」后,脚本会自动处理验证码")
|
||||
log(" (等待 30 秒)...")
|
||||
await asyncio.sleep(30)
|
||||
# 最后检查一次登录态
|
||||
if await conn.page.evaluate('() => !!document.querySelector("[data-e2e=user-avatar]")'):
|
||||
log("✅ 已手动登录成功")
|
||||
write_op_log(account_id, "douyin", "success")
|
||||
return True
|
||||
|
||||
ok = await sms_login(conn.page, account_name=account_id, log_func=log)
|
||||
if ok:
|
||||
log("🎉 抖音登录成功!")
|
||||
write_op_log(account_id, "douyin", "success")
|
||||
try:
|
||||
from c2.profile_scraper import ProfileScraper
|
||||
scraper = ProfileScraper()
|
||||
scraper.refresh([account_id])
|
||||
log("📊 资料已采集")
|
||||
except Exception as e:
|
||||
log(f"⚠️ 资料采集失败: {e}")
|
||||
else:
|
||||
log("❌ 登录失败")
|
||||
write_op_log(account_id, "douyin", "failed", "登录流程未完成")
|
||||
|
||||
log("\n💡 浏览器保持打开,超时自动关闭")
|
||||
gb.start_auto_shutdown_timer()
|
||||
|
||||
# 等待直到超时自动关闭或被信号中断
|
||||
while not gb._shutdown_flag:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return logged_in
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="抖音账号登录接入")
|
||||
parser.add_argument("account_id", help="账号 ID(如 douyin_137)")
|
||||
parser.add_argument("--phone", "-p", default="", help="手机号(选填,默认从 accounts.yaml 读取)")
|
||||
parser.add_argument("--timeout", "-t", type=int, default=30,
|
||||
help="超时自动关闭分钟数(默认 30)")
|
||||
args = parser.parse_args()
|
||||
print(f"\n{'='*55}\n 📱 抖音登录接入: {args.account_id}\n{'='*55}\n")
|
||||
asyncio.run(douyin_login(args.account_id, args.phone, args.timeout))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
短信验证码处理
|
||||
|
||||
使用方式:
|
||||
from matrix_modules.account.sms import SMSHandler, ManualSMSHandler, ApiSMSHandler
|
||||
|
||||
# 自动模式(API 轮询)
|
||||
handler = ApiSMSHandler()
|
||||
code = await handler.wait("抖音", timeout=120)
|
||||
|
||||
# 手动模式
|
||||
handler = ManualSMSHandler()
|
||||
code = await handler.wait("抖音")
|
||||
"""
|
||||
|
||||
from .base import SMSHandler, ManualSMSHandler
|
||||
from .api import ApiSMSHandler
|
||||
|
||||
__all__ = ["SMSHandler", "ManualSMSHandler", "ApiSMSHandler"]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
短信验证码 — API 自动获取(wx.tyhtak.com)
|
||||
|
||||
代替手动输入,自动轮询 API 获取验证码。
|
||||
配置从 ../config/sms.yaml 读取,不再硬编码。
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
import json
|
||||
import yaml
|
||||
|
||||
from .base import SMSHandler
|
||||
|
||||
# 默认值(会被 config/sms.yaml 覆盖)
|
||||
API_KEY = "gtmsg2026"
|
||||
PHONE = "15370103682"
|
||||
BASE_URL = "https://wx.tyhtak.com/api/biz/msg/messages"
|
||||
POLL_INTERVAL = 3
|
||||
TIMEOUT = 120
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""从 config/sms.yaml 读取配置"""
|
||||
config_path = os.path.join(
|
||||
os.path.dirname(__file__), # account/sms/
|
||||
"..", "..", "..", "..", # → scripts/
|
||||
"config", "sms.yaml"
|
||||
)
|
||||
config_path = os.path.normpath(config_path)
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data.get("sms", {})
|
||||
except (FileNotFoundError, yaml.YAMLError):
|
||||
return {}
|
||||
|
||||
|
||||
class ApiSMSHandler(SMSHandler):
|
||||
"""通过 API 自动获取短信验证码"""
|
||||
|
||||
def __init__(self, api_key: str = None, phone: str = None):
|
||||
cfg = _load_config()
|
||||
self.api_key = api_key or cfg.get("api_key", API_KEY)
|
||||
self.phone = phone or cfg.get("phone", PHONE)
|
||||
self.base_url = cfg.get("base_url", BASE_URL)
|
||||
self.poll_interval = cfg.get("poll_interval", POLL_INTERVAL)
|
||||
self.default_timeout = cfg.get("timeout", TIMEOUT)
|
||||
self._last_id: Optional[int] = None
|
||||
self._cancel_flag = False
|
||||
|
||||
def _fetch_messages(self) -> list:
|
||||
"""调用 API 获取短信列表"""
|
||||
url = f"{self.base_url}?api_key={self.api_key}&receiver_phone={self.phone}&page=1&per_page=20"
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "curl/7.0"})
|
||||
resp = urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read().decode())
|
||||
if data.get("code") == 200:
|
||||
return data.get("data", {}).get("items", [])
|
||||
return []
|
||||
except (URLError, json.JSONDecodeError, Exception) as e:
|
||||
print(f" ⚠️ API 请求失败: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_code(content: str) -> Optional[str]:
|
||||
"""从短信内容中提取验证码(4位或6位)
|
||||
|
||||
匹配模式:验证码后跟4-6位数字
|
||||
"""
|
||||
# 常见格式: 【平台】验证码556314 / 验证码,346758 / 验证码:729472
|
||||
m = re.search(r'验证码[::,,]\s*(\d{4,6})', content)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# 验证码直接跟数字(无分隔符)
|
||||
m = re.search(r'验证码(\d{4,6})', content)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# 兜底:提取4-6位纯数字(避免提取手机号等长数字)
|
||||
nums = re.findall(r'\b(\d{4,6})\b', content)
|
||||
if nums:
|
||||
# 优先取6位,其次4位;排除明显的年份/月份
|
||||
for n in nums:
|
||||
if len(n) == 6 and n[0] != '2': # 6位且不是年份
|
||||
return n
|
||||
for n in nums:
|
||||
if len(n) in (4, 6) and n not in ('2024','2025','2026'): # 排除年份
|
||||
return n
|
||||
return None
|
||||
|
||||
async def wait(self, platform: str = "", timeout: int = 120,
|
||||
after_time: float = None) -> str:
|
||||
"""轮询 API 直到获取验证码或超时
|
||||
|
||||
Args:
|
||||
platform: 平台名称(仅日志)
|
||||
timeout: 超时秒数
|
||||
after_time: 只接受该时间戳之后到达的短信(time.time() 格式)
|
||||
|
||||
Returns:
|
||||
验证码字符串,超时返回空字符串
|
||||
"""
|
||||
print(f" 📱 等待短信验证码 ({platform})")
|
||||
print(f" 手机号: {self.phone}")
|
||||
print(f" 轮询间隔: {self.poll_interval}秒 超时: {timeout}秒")
|
||||
|
||||
if after_time:
|
||||
print(f" ⏱️ 只接受 {datetime.fromtimestamp(after_time).strftime('%H:%M:%S')} 之后的短信")
|
||||
self._cancel_flag = False
|
||||
|
||||
start = time.time()
|
||||
while not self._cancel_flag and (time.time() - start) < timeout:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
msgs = self._fetch_messages()
|
||||
if not msgs:
|
||||
continue
|
||||
|
||||
for msg in msgs:
|
||||
# 用时间过滤:只接受 after_time 之后到达的消息
|
||||
if after_time:
|
||||
msg_time_str = msg.get("created_at") or msg.get("time") or msg.get("send_time") or ""
|
||||
if msg_time_str:
|
||||
try:
|
||||
msg_ts = datetime.fromisoformat(msg_time_str.replace("Z", "+00:00")).timestamp()
|
||||
if msg_ts <= after_time:
|
||||
continue
|
||||
except Exception:
|
||||
pass # 时间解析失败,不过滤
|
||||
|
||||
content = msg.get("content", "")
|
||||
code = self._extract_code(content)
|
||||
if code:
|
||||
print(f" ✅ 获取到验证码: {code}")
|
||||
return code
|
||||
|
||||
elapsed = int(time.time() - start)
|
||||
if elapsed % 15 < 3: # 每 15 秒打印一次状态
|
||||
print(f" ⏳ 等待中... ({elapsed}s / {timeout}s)")
|
||||
|
||||
print(f" ⏰ 超时 ({timeout}秒),未获取到验证码")
|
||||
return ""
|
||||
|
||||
async def cancel(self):
|
||||
"""取消等待"""
|
||||
self._cancel_flag = True
|
||||
print(" ⏹ API 短信等待已取消")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
短信验证码 — 抽象接口
|
||||
|
||||
所有短信接收方式继承 SMSHandler 基类:
|
||||
- ManualSMSHandler: 手动输入(当前默认)
|
||||
- AliyunSMSHandler: 阿里云短信转发(预留)
|
||||
- TelegramSMSHandler: Telegram bot 推送(预留)
|
||||
"""
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class SMSHandler(ABC):
|
||||
"""短信验证码处理器抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def wait(self, platform: str = "", timeout: int = 120) -> str:
|
||||
"""等待并返回验证码
|
||||
|
||||
Args:
|
||||
platform: 平台名称(用于提示)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
验证码字符串
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def cancel(self):
|
||||
"""取消等待"""
|
||||
...
|
||||
|
||||
|
||||
class ManualSMSHandler(SMSHandler):
|
||||
"""手动输入验证码(当前默认实现)"""
|
||||
|
||||
async def wait(self, platform: str = "", timeout: int = 120) -> str:
|
||||
print(f"\n📱 需要短信验证码 ({platform})")
|
||||
print(f" 请查看手机并将验证码输入到终端")
|
||||
print(f" 超时: {timeout}秒")
|
||||
|
||||
# 通过信号文件交互(同 login_identity 模式)
|
||||
signal_file = f"/tmp/sms_code_{id(self)}.signal"
|
||||
try:
|
||||
for _ in range(timeout // 2):
|
||||
await asyncio.sleep(2)
|
||||
if not signal_file:
|
||||
continue
|
||||
# 等待用户写入信号文件
|
||||
try:
|
||||
with open(signal_file) as f:
|
||||
code = f.read().strip()
|
||||
if code:
|
||||
return code
|
||||
except:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
async def cancel(self):
|
||||
"""取消等待"""
|
||||
print(" ⏹ 短信等待已取消")
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
sms_login.py — SMS 验证码登录原子操作
|
||||
|
||||
流程:
|
||||
1. 检测登录面板 → 点"一键登录" → 触发短信
|
||||
2. ApiSMSHandler 轮询获取验证码
|
||||
3. 填入验证码 → 点确认 → 登录成功
|
||||
|
||||
支持:超时重新发送、系统级鼠标兜底
|
||||
"""
|
||||
import asyncio, time, subprocess
|
||||
from typing import Optional
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 配置
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
SMS_INPUT_SELECTOR = "input[placeholder*='验证码']"
|
||||
LOGIN_PANEL_SEL = '.login-container' # 小红书登录面板
|
||||
PHONE_INPUT_SEL = "input[placeholder*='手机']"
|
||||
ONEKEY_TEXT = '一键登录'
|
||||
CONFIRM_BTN_TEXTS = ['确认', '提交', '验证', '登录', '确定', '下一步', '立即登录']
|
||||
RESEND_TEXTS = ['重新发送', '获取验证码', '重发']
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 状态检测
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def has_login_panel(page) -> bool:
|
||||
"""检测登录面板是否可见"""
|
||||
for sel in [LOGIN_PANEL_SEL, '#login-panel-new', '.reds-modal-open']:
|
||||
try:
|
||||
if await page.evaluate(f'() => !!document.querySelector("{sel}")'):
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def has_sms_input(page) -> bool:
|
||||
return await page.evaluate(f'() => !!document.querySelector("{SMS_INPUT_SELECTOR}")')
|
||||
|
||||
async def has_resend_btn(page) -> bool:
|
||||
"""检测"重新发送"按钮是否出现(超时可点击)"""
|
||||
return await page.evaluate(f"""() => {{
|
||||
var texts = {RESEND_TEXTS};
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
for (var i = 0; i < all.length; i++) {{
|
||||
for (var t = 0; t < texts.length; t++) {{
|
||||
if (all[i].textContent.includes(texts[t]) && all[i].offsetParent) return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}""")
|
||||
|
||||
async def is_logged_in(page) -> bool:
|
||||
"""检测登录成功(面板消失+用户已登录)"""
|
||||
if await has_login_panel(page):
|
||||
return False # 面板还在就不算登录
|
||||
try:
|
||||
from matrix_modules.account.login_state_machine import DETECTORS
|
||||
detector = DETECTORS.get("xiaohongshu")
|
||||
if detector:
|
||||
status = await detector.detect(page, "")
|
||||
return status == "logged_in"
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 操作函数
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def click_onekey_login(page):
|
||||
"""点"一键登录"按钮"""
|
||||
await page.evaluate(f"""() => {{
|
||||
var all = document.querySelectorAll('span, div, a, button');
|
||||
for (var i = 0; i < all.length; i++) {{
|
||||
if (all[i].textContent.trim() === '{ONEKEY_TEXT}') {{ all[i].click(); return true; }}
|
||||
}}
|
||||
return false;
|
||||
}}""")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
async def click_resend(page):
|
||||
"""点"重新发送"按钮(超时后)"""
|
||||
clicked = await page.evaluate(f"""() => {{
|
||||
var texts = {RESEND_TEXTS};
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
for (var i = 0; i < all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
for (var t = 0; t < texts.length; t++) {{
|
||||
if (all[i].textContent.includes(texts[t])) {{ all[i].click(); return true; }}
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}""")
|
||||
await asyncio.sleep(1)
|
||||
return clicked
|
||||
|
||||
async def fill_code(page, code: str):
|
||||
"""填入验证码(用 JS 设置 value + dispatch input)"""
|
||||
# 用双引号避免单引号冲突
|
||||
await page.evaluate(f"""() => {{
|
||||
var inp = document.querySelector("input[placeholder*='验证码']");
|
||||
if (!inp) return;
|
||||
inp.value = '{code}';
|
||||
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
}}""")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _click_exact_login(page) -> bool:
|
||||
"""精确匹配文本为「登录」的按钮(排除协议元素)——找不到时打印调试信息"""
|
||||
result = await page.evaluate("""() => {
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
var debug = [];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (!all[i].offsetParent) continue;
|
||||
var txt = all[i].textContent.trim();
|
||||
debug.push({tag: all[i].tagName, text: txt, cls: (all[i].className||'').slice(0,30)});
|
||||
if (txt === '登录' && !all[i].textContent.includes('协议')) {
|
||||
all[i].click(); return JSON.stringify({found: true, tag: all[i].tagName, text: txt});
|
||||
}
|
||||
}
|
||||
// 没找到「登录」,返回所有可见按钮文本供调试
|
||||
return JSON.stringify({found: false, buttons: debug.filter(function(d) { return d.text.length > 0 && d.text.length < 30; }).slice(0, 40)});
|
||||
}""")
|
||||
import json as _json
|
||||
data = _json.loads(result)
|
||||
if data.get('found'):
|
||||
return True
|
||||
print(f"⚠️ click_confirm Phase1 未找到「登录」按钮")
|
||||
print(f" 可见按钮列表:")
|
||||
for b in (data.get('buttons') or []):
|
||||
print(f" [{b['tag']}] text='{b['text']}' cls='{b['cls']}'")
|
||||
return False
|
||||
|
||||
|
||||
async def _click_by_id(page, log_func=print) -> bool:
|
||||
"""Phase 0: 多层选择器匹配——优先ID,兜底class/文本"""
|
||||
result = await page.evaluate("""() => {
|
||||
// 1. 优先用唯一ID匹配(最快最准)
|
||||
var btn = document.querySelector('#douyin_login_comp_btn_id');
|
||||
if (btn && btn.offsetParent) {
|
||||
return JSON.stringify({ok: true, by: 'id', cls: btn.className.slice(0,30)});
|
||||
}
|
||||
// 2. 用录制到的class名匹配(ID被改时兜底)
|
||||
var clsBtns = document.querySelectorAll('div.r7j70rK2');
|
||||
for (var i = 0; i < clsBtns.length; i++) {
|
||||
if (clsBtns[i].offsetParent && clsBtns[i].textContent.trim() === '登录') {
|
||||
clsBtns[i].click();
|
||||
return JSON.stringify({ok: true, by: 'class', id: (clsBtns[i].id || '')});
|
||||
}
|
||||
}
|
||||
// 3. 找所有文本为"登录"的可点击元素(class也被改时兜底)
|
||||
var all = document.querySelectorAll('div, button, span, a');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].offsetParent && all[i].textContent.trim() === '登录') {
|
||||
all[i].click();
|
||||
return JSON.stringify({ok: true, by: 'text', tag: all[i].tagName, id: (all[i].id || ''), cls: (all[i].className || '').slice(0,30)});
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ok: false});
|
||||
}""")
|
||||
import json as _j
|
||||
data = _j.loads(result)
|
||||
if data.get('ok'):
|
||||
by = data.get('by', '?')
|
||||
if by == 'id':
|
||||
pass # 正常
|
||||
elif by == 'class':
|
||||
log_func(f" ⚠️ 登录按钮ID已变更!改为用class匹配,新ID: {data.get('id','?')}")
|
||||
elif by == 'text':
|
||||
log_func(f" ⚠️ 登录按钮ID和class均变更!改为文本匹配,新标签={data.get('tag','?')} 新ID={data.get('id','?')} 新class={data.get('cls','?')}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def click_confirm(page):
|
||||
"""点确认/提交按钮——四阶段:ID匹配 → 精确匹配 → 模糊匹配 → 兜底"""
|
||||
# Phase 0: 按唯一 ID 匹配(最可靠)
|
||||
if await _click_by_id(page):
|
||||
return True
|
||||
|
||||
# Phase 1: 精确匹配「登录」
|
||||
if await _click_exact_login(page):
|
||||
return True
|
||||
|
||||
# Phase 2: 模糊匹配「登录」(兼容「同意协议并登录」类按钮)
|
||||
clicked_fuzzy = await page.evaluate("""() => {
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (!all[i].offsetParent) continue;
|
||||
if (all[i].textContent.includes('登录') && !all[i].textContent.includes('协议')) {
|
||||
all[i].click(); return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
if clicked_fuzzy:
|
||||
return True
|
||||
|
||||
# Phase 3: 原逻辑兜底(兼容小红书等平台)
|
||||
return await page.evaluate(f"""() => {{
|
||||
var texts = {CONFIRM_BTN_TEXTS};
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
for (var i = 0; i < all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
for (var t = 0; t < texts.length; t++) {{
|
||||
if (all[i].textContent.includes(texts[t])) {{ all[i].click(); return true; }}
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}""")
|
||||
|
||||
async def click_confirm_system(page):
|
||||
"""系统级鼠标点确认按钮(兜底)"""
|
||||
btn_info = await page.evaluate(f"""() => {{
|
||||
var texts = {CONFIRM_BTN_TEXTS};
|
||||
var all = document.querySelectorAll('button, span, div, a');
|
||||
for (var i = 0; i < all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
for (var t = 0; t < texts.length; t++) {{
|
||||
if (all[i].textContent.includes(texts[t])) {{
|
||||
var r = all[i].getBoundingClientRect();
|
||||
return {{x: Math.round(r.left+r.width/2), y: Math.round(r.top+r.height/2)}};
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
}}""")
|
||||
if btn_info:
|
||||
# 窗口位置 (652, 0),视口起始 = 窗口起始
|
||||
sx, sy = 652 + btn_info['x'], btn_info['y']
|
||||
subprocess.run(['osascript','-e',f'tell application "System Events" to click at {{{sx},{sy}}}'], timeout=5)
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 主流程
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def sms_login(page, account_name: str = '', log_func=print) -> bool:
|
||||
"""SMS 验证码登录完整流程
|
||||
|
||||
Args:
|
||||
page: Playwright page
|
||||
account_name: 账号名(用于查手机号),为空则用 sms.yaml 默认
|
||||
log_func: 日志函数
|
||||
|
||||
Returns:
|
||||
True=登录成功, False=失败
|
||||
"""
|
||||
# 从 accounts.yaml 查手机号
|
||||
phone = ''
|
||||
if account_name:
|
||||
import yaml, os
|
||||
cfg_path = os.path.expanduser('~/workbuddy-agent-os/agent-local/tools/matrix/config/accounts.yaml')
|
||||
try:
|
||||
with open(cfg_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for a in data.get('accounts', []):
|
||||
if a.get('id') == account_name:
|
||||
phone = a.get('phone', '')
|
||||
break
|
||||
except: pass
|
||||
log_func(f'📞 手机号: {phone or "默认"}({account_name})')
|
||||
|
||||
if not await has_login_panel(page):
|
||||
log_func('❌ 未检测到登录面板')
|
||||
return False
|
||||
|
||||
# ── Step 1: 点一键登录 ──
|
||||
log_func('📱 Step 1: 点击一键登录')
|
||||
await click_onekey_login(page)
|
||||
|
||||
if not await has_sms_input(page):
|
||||
log_func('❌ 未弹出验证码输入框')
|
||||
return False
|
||||
log_func('✅ 验证码输入框已出现')
|
||||
|
||||
# ── Step 2: 轮询获取验证码(含超时重发)──
|
||||
log_func('📡 Step 2: 获取验证码')
|
||||
from matrix_modules.account.sms import ApiSMSHandler
|
||||
# 确保 phone 非空才传入
|
||||
handler = ApiSMSHandler(phone=phone) if phone and phone.strip() else ApiSMSHandler()
|
||||
if await is_logged_in(page):
|
||||
log_func('✅ 已登录,跳过')
|
||||
return True
|
||||
|
||||
if not await has_login_panel(page):
|
||||
log_func('❌ 未检测到登录面板')
|
||||
return False
|
||||
|
||||
# ── Step 1: 点一键登录 ──
|
||||
log_func('📱 Step 1: 点击一键登录')
|
||||
await click_onekey_login(page)
|
||||
|
||||
if not await has_sms_input(page):
|
||||
log_func('❌ 未弹出验证码输入框')
|
||||
return False
|
||||
log_func('✅ 验证码输入框已出现')
|
||||
|
||||
# ── Step 2: 轮询获取验证码(含超时重发)──
|
||||
log_func('📡 Step 2: 获取验证码')
|
||||
from matrix_modules.account.sms import ApiSMSHandler
|
||||
handler = ApiSMSHandler(phone=phone) if phone and phone.strip() else ApiSMSHandler()
|
||||
code = ''
|
||||
|
||||
code = ''
|
||||
for attempt in range(3): # 最多3次获取尝试
|
||||
code = await handler.wait('抖音登录', timeout=60)
|
||||
if code and len(code) in (4, 5, 6):
|
||||
log_func(f'✅ 获取到验证码: {code}')
|
||||
break
|
||||
|
||||
log_func(f'⏰ 第{attempt+1}次超时,点重新发送...')
|
||||
await click_resend(page)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if not code or len(code) not in (4, 5, 6):
|
||||
log_func('❌ 获取验证码失败')
|
||||
return False
|
||||
|
||||
# ── Step 3: 填入验证码 ──
|
||||
log_func(f'📝 Step 3: 填入验证码 {code}')
|
||||
await fill_code(page, code)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# ── Step 4: 点确认 ──
|
||||
log_func('🔘 Step 4: 点击确认')
|
||||
ok = await click_confirm(page)
|
||||
if not ok:
|
||||
log_func('⚠️ DOM点击失败,用系统级鼠标')
|
||||
ok = await click_confirm_system(page)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# ── Step 5: 验证登录 ──
|
||||
if await is_logged_in(page):
|
||||
log_func('🎉 登录成功!')
|
||||
return True
|
||||
else:
|
||||
log_func('⚠️ 登录可能未完成,检查页面状态')
|
||||
return False
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
xhs_login.py — 小红书 SMS 验证码登录原子操作
|
||||
|
||||
对照 sms_login.py(抖音)模式,为小红书提供完整的原子操作。
|
||||
|
||||
注意:小红书登录面板不勾选协议(无法准确定位复选框),
|
||||
改用下方「同意并登录」弹出浮窗方案。
|
||||
|
||||
2026-06-16 用户新发现(待 DOM 扫描验证):
|
||||
填手机号 → 点「继续」(SMS发) → 等6个验证码框
|
||||
→ 找「同意并登录」(可能是弹窗按钮配合「登录」按钮触发)
|
||||
→ 收验证码 → 逐个填6个框
|
||||
|
||||
原子操作列表:(对标 sms_login.py)
|
||||
has_login_panel ✅
|
||||
has_sms_inputs ✅ (6个input[type=number])
|
||||
has_resend_btn ✅ 新增
|
||||
has_agree_text ✅ 检测"同意"文字(v2备用)
|
||||
is_logged_in ✅
|
||||
fill_phone ✅ (page.fill + Tab)
|
||||
click_continue ✅ (点「继续」触发SMS)
|
||||
click_agree_and_login ✅ (v3核心: 点「同意并登录」)
|
||||
click_agreement_checkbox ✅ (v2备用)
|
||||
click_resend ✅ (点「重新获取」)
|
||||
click_login_system ✅ 新增 (系统级鼠标兜底)
|
||||
fill_6_digit_code ✅ (6个 input 逐个填)
|
||||
click_largest_login_btn ✅ (按面积找最大)
|
||||
wait_login_panel ✅
|
||||
wait_sms_inputs ✅
|
||||
get_visible_texts ✅ (调试)
|
||||
debug_snapshot_texts ✅ (调试)
|
||||
|
||||
用法:
|
||||
from xhs_login import xhs_login, has_login_panel, click_continue, ...
|
||||
"""
|
||||
import asyncio, subprocess, time
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 配置常量
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
PHONE_PLACEHOLDER = '输入手机号'
|
||||
CONTINUE_TEXT = '继续'
|
||||
AGREE_LOGIN_TEXT = '同意并继续'
|
||||
LOGIN_BTN_TEXTS = ['登录', '登 录']
|
||||
RESEND_TEXTS = ['重新获取', '重发', '重新发送']
|
||||
LOGIN_INDICATORS = ['.user-avatar', '.reds-count']
|
||||
|
||||
# 窗口偏移(由 xiaohongshu_login.py 传入的一致性窗口定位)
|
||||
# 用于系统级鼠标点击
|
||||
WINDOW_OFFSET_X = 652
|
||||
WINDOW_OFFSET_Y = 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 状态检测
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def has_login_panel(page) -> bool:
|
||||
"""检测登录面板是否弹出(含手机号输入框)"""
|
||||
try:
|
||||
return await page.evaluate(
|
||||
'() => !!document.querySelector(\'input[placeholder*="手机"]\')'
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def has_sms_inputs(page) -> bool:
|
||||
"""检测6个数字验证码框是否出现(小红书6位输入框)"""
|
||||
try:
|
||||
info = await page.evaluate(
|
||||
"""() => {
|
||||
var a = document.querySelectorAll('input');
|
||||
var nums = [];
|
||||
for (var i=0; i<a.length; i++) {
|
||||
if (a[i].type==='number' && a[i].offsetParent) {
|
||||
nums.push(i);
|
||||
}
|
||||
}
|
||||
return nums;
|
||||
}"""
|
||||
)
|
||||
return isinstance(info, list) and len(info) >= 6
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def has_resend_btn(page) -> bool:
|
||||
"""检测"重新获取"按钮是否出现(验证码超时可点)"""
|
||||
return await page.evaluate(
|
||||
f"""() => {{
|
||||
var texts = {_js_list(RESEND_TEXTS)};
|
||||
var all = document.querySelectorAll('span,div,button,a');
|
||||
for (var i=0; i<all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
var t = all[i].textContent.trim();
|
||||
for (var j=0; j<texts.length; j++) {{
|
||||
if (t.includes(texts[j])) return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}"""
|
||||
)
|
||||
|
||||
|
||||
async def has_agree_text(page) -> bool:
|
||||
"""检测页面上是否有包含"同意"的文字(v2备用)"""
|
||||
try:
|
||||
return await page.evaluate(
|
||||
"""() => {
|
||||
var s = document.querySelectorAll('span,div,label');
|
||||
for (var i=0; i<s.length; i++) {
|
||||
var t = (s[i].textContent||'').trim();
|
||||
if (t.includes('同意') && !t.includes('扫码') && s[i].offsetParent) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def is_logged_in(page) -> bool:
|
||||
"""检测小红书是否已登录(严格模式)"""
|
||||
try:
|
||||
# 登录面板可见 → 肯定没登录
|
||||
if await has_login_panel(page):
|
||||
return False
|
||||
return await page.evaluate("""() => {
|
||||
// 查用户头像 — 只在顶栏区域(不是 feed 里的创作者头像)
|
||||
const topAvatars = document.querySelectorAll(
|
||||
'.reds-count, [class*="reds-user"], ' +
|
||||
'[class*="user-sidebar"], .side-bar [class*="avatar"], ' +
|
||||
'[class*="user-info"] [class*="avatar"]'
|
||||
);
|
||||
if (topAvatars.length > 0) return true;
|
||||
|
||||
// 兜底:找整体登录指示元素
|
||||
const indicators = document.querySelectorAll(
|
||||
'[class*="user-center"], [class*="user-profile"], ' +
|
||||
'[class*="my-info"], [data-testid*="user"]'
|
||||
);
|
||||
return indicators.length > 0;
|
||||
}""")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 操作函数
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def fill_phone(page, phone: str):
|
||||
"""填入手机号 — 必须用 page.fill + Tab(React 框架不认 JS set value)"""
|
||||
try:
|
||||
inp = page.locator('input[placeholder*="手机"]')
|
||||
await inp.fill(phone)
|
||||
await page.keyboard.press("Tab")
|
||||
except Exception:
|
||||
# 兜底:按字符逐个输入
|
||||
await page.type('input[placeholder*="手机"]', phone, delay=30)
|
||||
await page.keyboard.press("Tab")
|
||||
|
||||
|
||||
async def click_continue(page) -> bool:
|
||||
"""点「继续」按钮(触发SMS发送)"""
|
||||
# 先检查是否有 oauth-tip(切换登录方式),有则先点它
|
||||
try:
|
||||
oauth = page.locator('div.oauth-tip')
|
||||
if await oauth.count() > 0 and await oauth.first.is_visible():
|
||||
await oauth.first.click()
|
||||
await asyncio.sleep(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
clicked = await page.evaluate(
|
||||
f"""() => {{
|
||||
var all = document.querySelectorAll('span,div,button');
|
||||
for (var i=0; i<all.length; i++) {{
|
||||
if (all[i].textContent.trim() === '{CONTINUE_TEXT}' && all[i].offsetParent) {{
|
||||
all[i].click();
|
||||
return 'OK';
|
||||
}}
|
||||
}}
|
||||
return 'NOT_FOUND';
|
||||
}}"""
|
||||
)
|
||||
return clicked == 'OK'
|
||||
|
||||
|
||||
async def click_agree_and_login(page) -> bool:
|
||||
"""
|
||||
点「同意并继续」按钮(先点登录后弹出的浮层按钮)
|
||||
先试 .foot-btn CSS 选择器,再搜文本
|
||||
"""
|
||||
# 先试 CSS 选择器
|
||||
try:
|
||||
fb = page.locator('div.foot-btn')
|
||||
cnt = await fb.count()
|
||||
if cnt > 0 and await fb.first.is_visible():
|
||||
await fb.first.click()
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
# 再搜文本(找 z-index 最高的可见元素)
|
||||
for _ in range(6):
|
||||
clicked = await page.evaluate(
|
||||
f"""() => {{
|
||||
var all = document.querySelectorAll('span,div,button');
|
||||
var found = null, maxZ = 0;
|
||||
for (var i=0; i<all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
var t = all[i].textContent.trim();
|
||||
if (t.includes('同意并继续') || t.includes('同意并登录')) {{
|
||||
var z = parseInt(window.getComputedStyle(all[i]).zIndex) || 0;
|
||||
if (z > maxZ) {{ maxZ = z; found = all[i]; }}
|
||||
}}
|
||||
}}
|
||||
if (found) {{ found.click(); return true; }}
|
||||
return false;
|
||||
}}"""
|
||||
)
|
||||
if clicked:
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
async def click_agreement_checkbox(page) -> bool:
|
||||
"""
|
||||
(备用 v2) 鼠标点击"同意"文字左侧20px位置勾协议
|
||||
|
||||
注意:小红书复选框是自定义元素,非原生 input[type=checkbox],
|
||||
left-20px 定位极不可靠。仅作 v3 方案的兜底。
|
||||
"""
|
||||
pos = await _get_agreement_element(page)
|
||||
if not pos:
|
||||
return False
|
||||
click_x = max(0, pos["x"] - 20)
|
||||
await page.mouse.click(click_x, pos["y"])
|
||||
return True
|
||||
|
||||
|
||||
async def click_resend(page) -> bool:
|
||||
"""点「重新获取」按钮(验证码超时后重发)"""
|
||||
clicked = await page.evaluate(
|
||||
f"""() => {{
|
||||
var texts = {_js_list(RESEND_TEXTS)};
|
||||
var all = document.querySelectorAll('span,div,button,a');
|
||||
for (var i=0; i<all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
var t = all[i].textContent.trim();
|
||||
for (var j=0; j<texts.length; j++) {{
|
||||
if (t.includes(texts[j])) {{
|
||||
all[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}"""
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
return clicked
|
||||
|
||||
|
||||
async def fill_6_digit_code(page, code: str, log_func=print) -> bool:
|
||||
"""
|
||||
逐个填入6位验证码
|
||||
|
||||
策略:先一次 evaluate 捕获所有 input[type=number] 的 DOM 索引,
|
||||
再按索引逐个填入,防止 DOM 在迭代中变动。
|
||||
"""
|
||||
indices = await page.evaluate(
|
||||
"""() => {
|
||||
var a = document.querySelectorAll('input');
|
||||
var r = [];
|
||||
for (var j=0; j<a.length; j++) {
|
||||
if (a[j].type==='number' && a[j].offsetParent) {
|
||||
r.push(j);
|
||||
if (r.length >= 6) break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}"""
|
||||
)
|
||||
log_func(f" 验证码框 DOM 索引: {indices}")
|
||||
|
||||
for idx, digit in enumerate(code[:6]):
|
||||
if idx >= len(indices):
|
||||
break
|
||||
ok = await page.evaluate(
|
||||
f"""() => {{
|
||||
var all = document.querySelectorAll('input');
|
||||
var inp = all[{indices[idx]}];
|
||||
if (!inp) return 0;
|
||||
inp.focus();
|
||||
// 模仿手动输入
|
||||
var nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(inp, '{digit}');
|
||||
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
inp.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
return 1;
|
||||
}}"""
|
||||
)
|
||||
log_func(f" 第{idx+1}位: {digit} -> {'OK' if ok else 'FAIL'}")
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def click_largest_login_btn(page, log_func=print) -> bool:
|
||||
"""按面积找最大的「登录」按钮(排除导航栏)"""
|
||||
btns = await _get_login_buttons(page)
|
||||
if not btns:
|
||||
log_func(" ⚠️ 无登录按钮")
|
||||
return False
|
||||
target = btns[0]
|
||||
await page.mouse.click(target["x"], target["y"])
|
||||
log_func(f" ✅ 点登录 ({target['x']},{target['y']}) {target['w']}x{target['h']}")
|
||||
return True
|
||||
|
||||
|
||||
async def click_login_system(page, log_func=print) -> bool:
|
||||
"""
|
||||
系统级鼠标点登录按钮(DOM 点击失败时兜底)
|
||||
使用 osascript 在系统层模拟点击,绕过网页 JS 限制
|
||||
"""
|
||||
btns = await _get_login_buttons(page)
|
||||
if not btns:
|
||||
return False
|
||||
target = btns[0]
|
||||
sx = WINDOW_OFFSET_X + target["x"]
|
||||
sy = WINDOW_OFFSET_Y + target["y"]
|
||||
log_func(f" 🔧 系统级鼠标点击 ({sx},{sy})")
|
||||
subprocess.run(
|
||||
['osascript', '-e',
|
||||
f'tell application "System Events" to click at {{{sx},{sy}}}'],
|
||||
timeout=5
|
||||
)
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
|
||||
async def wait_login_panel(page, timeout_sec: int = 30) -> bool:
|
||||
"""等待登录面板弹出(含手机号输入框)"""
|
||||
for _ in range(timeout_sec):
|
||||
if await has_login_panel(page):
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
async def wait_sms_inputs(page, timeout_sec: int = 40) -> bool:
|
||||
"""等待6个验证码输入框出现"""
|
||||
for _ in range(timeout_sec):
|
||||
if await has_sms_inputs(page):
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 调试辅助
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def get_visible_texts(page, limit: int = 15) -> list:
|
||||
"""获取页面上所有可见文字(调试用)"""
|
||||
try:
|
||||
return await page.evaluate(
|
||||
f"""() => {{
|
||||
var a = document.querySelectorAll('span,div,button');
|
||||
var r = [];
|
||||
for (var i=0; i<a.length; i++) {{
|
||||
if (!a[i].offsetParent) continue;
|
||||
var t = a[i].textContent.trim();
|
||||
if (t) r.push(t);
|
||||
if (r.length >= {limit}) break;
|
||||
}}
|
||||
return r;
|
||||
}}"""
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def debug_snapshot_texts(page, log_func=print):
|
||||
"""快照当前页面: URL + 可见文字(调试用)"""
|
||||
try:
|
||||
url = await page.evaluate('window.location.href')
|
||||
texts = await get_visible_texts(page)
|
||||
log_func(f" URL: {url}")
|
||||
log_func(f" 可见文字: {texts}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 私有辅助
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _js_list(items: list) -> str:
|
||||
"""生成 JS 数组字面量字符串,正确处理引号"""
|
||||
quoted = [f"'{x}'" for x in items]
|
||||
return "[" + ",".join(quoted) + "]"
|
||||
|
||||
|
||||
async def _get_agreement_element(page) -> Optional[dict]:
|
||||
"""找最小高度(最内层)包含"同意"的可见元素"""
|
||||
try:
|
||||
result = await page.evaluate(
|
||||
"""() => {
|
||||
var s = document.querySelectorAll('span,div,label');
|
||||
var best = null;
|
||||
for (var i=0; i<s.length; i++) {
|
||||
var t = (s[i].textContent||'').trim();
|
||||
if (t.includes('同意') && !t.includes('扫码') && s[i].offsetParent) {
|
||||
var b = s[i].getBoundingClientRect();
|
||||
if (!best || b.height < best.h) {
|
||||
best = {
|
||||
x: Math.round(b.left),
|
||||
y: Math.round(b.top + b.height/2),
|
||||
h: b.height,
|
||||
text: t.slice(0,20)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ? JSON.stringify(best) : null;
|
||||
}"""
|
||||
)
|
||||
if result:
|
||||
import json as _j
|
||||
return _j.loads(result)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_login_buttons(page) -> list:
|
||||
"""获取所有可见「登录」按钮信息(按面积降序)"""
|
||||
try:
|
||||
result = await page.evaluate(
|
||||
f"""() => {{
|
||||
var all = document.querySelectorAll('span,div,button');
|
||||
var r = [];
|
||||
for (var i=0; i<all.length; i++) {{
|
||||
if (!all[i].offsetParent) continue;
|
||||
var t = all[i].textContent.trim();
|
||||
for (var j=0; j<{_js_list(LOGIN_BTN_TEXTS)}.length; j++) {{
|
||||
if (t === {_js_list(LOGIN_BTN_TEXTS)}[j]) {{
|
||||
var b = all[i].getBoundingClientRect();
|
||||
r.push({{
|
||||
x: Math.round(b.left + b.width/2),
|
||||
y: Math.round(b.top + b.height/2),
|
||||
w: Math.round(b.width),
|
||||
h: Math.round(b.height)
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
r.sort(function(a,b){{return (b.w*b.h) - (a.w*a.h);}});
|
||||
return JSON.stringify(r);
|
||||
}}"""
|
||||
)
|
||||
import json as _j
|
||||
return _j.loads(result)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 主流程
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
async def _lookup_phone(account_name: str) -> str:
|
||||
"""从 accounts.override.yaml 查找手机号"""
|
||||
import yaml, os
|
||||
home = os.path.expanduser('~')
|
||||
base = home + '/workbuddy-agent-os/agent-local/tools/matrix/config'
|
||||
for name in ['accounts.override.yaml', 'accounts.yaml']:
|
||||
path = base + '/' + name
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
for a in data.get('accounts', []):
|
||||
if a.get('id') == account_name:
|
||||
phone = a.get('phone', '')
|
||||
if phone:
|
||||
return phone
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
async def xhs_login(page, phone: str = '', account_name: str = '',
|
||||
log_func=print) -> bool:
|
||||
"""小红书 SMS 验证码登录完整流程
|
||||
|
||||
Args:
|
||||
page: Playwright page 对象
|
||||
phone: 手机号(可选,空则从 accounts.yaml 查)
|
||||
account_name: 账号 ID(用于日志/查手机号)
|
||||
log_func: 日志输出函数
|
||||
|
||||
Returns:
|
||||
True=登录成功, False=失败
|
||||
"""
|
||||
# ── 获取手机号 ──
|
||||
if not phone and account_name:
|
||||
phone = await _lookup_phone(account_name)
|
||||
log_func(f'📞 手机号: {phone or "未指定"} ({account_name})')
|
||||
|
||||
# ── 检测已登录 ──
|
||||
if await is_logged_in(page):
|
||||
log_func('✅ 已登录,跳过')
|
||||
return True
|
||||
|
||||
# ── Step 1: 等登录面板 ──
|
||||
if not await wait_login_panel(page):
|
||||
log_func('❌ 登录面板未弹出')
|
||||
return False
|
||||
log_func('✅ 登录面板已弹出')
|
||||
|
||||
# ── Step 2: 填手机号 ──
|
||||
if phone:
|
||||
log_func(f'📱 Step 2: 填手机号 {phone}')
|
||||
await fill_phone(page, phone)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# ── 创建 SMS handler + 记录当前时间(点继续之前,用于时间过滤)──
|
||||
import time
|
||||
from matrix_modules.account.sms.api import ApiSMSHandler
|
||||
handler = ApiSMSHandler(phone=phone) if phone and phone.strip() else ApiSMSHandler()
|
||||
handler.poll_interval = 5
|
||||
sms_after_time = time.time()
|
||||
log_func(f' 📡 记录时间戳 {time.strftime("%H:%M:%S")},只接受之后的验证码')
|
||||
|
||||
# ── Step 3: 点继续(触发 SMS)──
|
||||
log_func('👉 Step 3: 点继续')
|
||||
if not await click_continue(page):
|
||||
log_func('⚠️ 未找到继续按钮,重试...')
|
||||
await asyncio.sleep(2)
|
||||
await click_continue(page)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 调试快照
|
||||
await debug_snapshot_texts(page, log_func)
|
||||
|
||||
# ── Step 4: 等6个验证码框 ──
|
||||
log_func('⏳ Step 4: 等6个验证码框...')
|
||||
if not await wait_sms_inputs(page):
|
||||
log_func('❌ 验证码框未出现')
|
||||
return False
|
||||
log_func('✅ 验证码框已出现')
|
||||
|
||||
code = ''
|
||||
for attempt in range(2): # 最多2次(第3次封禁3分钟)
|
||||
code = await handler.wait(f'小红书 {account_name}', timeout=180, after_time=sms_after_time)
|
||||
if code and len(code) in (4, 5, 6):
|
||||
log_func(f'✅ 验证码: {code}')
|
||||
break
|
||||
|
||||
log_func(f'⏰ 第{attempt+1}次超时,等待180秒后点重新获取...')
|
||||
# 等180秒倒计时 → 点重新获取
|
||||
for _ in range(180):
|
||||
if await click_resend(page):
|
||||
log_func(' ✅ 已点"重新获取"')
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
log_func(' ⚠️ 未找到"重新获取"按钮')
|
||||
else:
|
||||
log_func('❌ 验证码获取失败')
|
||||
return False
|
||||
|
||||
if not code or len(code) not in (4, 5, 6):
|
||||
return False
|
||||
|
||||
# ── Step 6: 填6位验证码 ──
|
||||
log_func(f'📝 Step 6: 填验证码 {code}')
|
||||
await fill_6_digit_code(page, code[:6], log_func)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# ── Step 7: 点「登录」按钮(触发「同意并登录」浮层)──
|
||||
# 先点登录,会弹出「同意并登录」浮动层,然后再点它
|
||||
log_func('🔘 Step 7: 点击登录')
|
||||
ok = await click_largest_login_btn(page, log_func)
|
||||
if not ok:
|
||||
log_func(' ⚠️ DOM 点击失败,尝试系统级鼠标点击')
|
||||
ok = await click_login_system(page, log_func)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# ── Step 8: 点「同意并继续」(登录后弹出的浮层)──
|
||||
# 等浮层出现(最多5秒),点击后即完成登录
|
||||
log_func('📄 Step 8: 同意并继续')
|
||||
await asyncio.sleep(0.5)
|
||||
v3_ok = await click_agree_and_login(page)
|
||||
if v3_ok:
|
||||
log_func(' ✅ 已点「同意并登录」')
|
||||
else:
|
||||
log_func(' ⚠️ 未找到"同意并继续"按钮,尝试继续...')
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# ── Step 9: 验证登录 ──
|
||||
if await is_logged_in(page):
|
||||
log_func('🎉 登录成功!')
|
||||
return True
|
||||
|
||||
# 再试一次(可能页面跳转慢)
|
||||
log_func('⏳ 等待登录完成...')
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(2)
|
||||
if await is_logged_in(page):
|
||||
log_func('🎉 登录成功!')
|
||||
return True
|
||||
else:
|
||||
log_func('⚠️ 登录可能未完成,需检查页面状态')
|
||||
return False
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
xiaohongshu_login.py — 小红书账号登录接入(手机号 + SMS 验证码)
|
||||
|
||||
流程:
|
||||
1. 准备浏览器身份目录
|
||||
2. 优雅退出检查 + 前置浏览器检测 (browser_utils)
|
||||
3. 启动 Camoufox,加载身份目录
|
||||
4. 导航到小红书首页
|
||||
5. 检测登录面板 → 触发短信验证码登录
|
||||
6. ApiSMSHandler 自动轮询获取验证码
|
||||
7. 填入验证码 → 确认 → 验证登录态
|
||||
8. Cookie 自动持久化(Camoufox 持久化目录)
|
||||
9. 优雅退出/超时自动关闭
|
||||
|
||||
用法:
|
||||
python xiaohongshu_login.py xhs_01 # 用默认手机号
|
||||
python xiaohongshu_login.py xhs_01 --phone 13754826808 # 指定手机号
|
||||
python xiaohongshu_login.py xhs_01 --timeout 60 # 60分钟超时自动关闭
|
||||
|
||||
版本: 1.0.0 | 更新: 2026-06-16
|
||||
"""
|
||||
import argparse, asyncio, json, os, sys, time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from cdp_connector import CDPConnector
|
||||
from browser_utils import GracefulBrowser
|
||||
|
||||
HOME = Path.home()
|
||||
from matrix_mgmt import AGENT_LOCAL, AGENT_SYNC
|
||||
|
||||
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||||
LOG_FILE = AGENT_LOCAL / "runtime" / "login_log.jsonl"
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def write_op_log(account_id: str, platform: str, status: str, detail: str = ""):
|
||||
entry = {"op": f"{platform}_login", "account": account_id, "status": status,
|
||||
"detail": detail, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
async def xiaohongshu_login(account_id: str, phone: str = "", timeout_minutes: int = 30, force: bool = False):
|
||||
"""小红书全自动登录"""
|
||||
# ── 确认账号平台是小程序 ──
|
||||
# 从矩阵管理获取账号信息
|
||||
platform = "xiaohongshu"
|
||||
acct_identity = account_id
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == account_id:
|
||||
if not phone:
|
||||
phone = a.get("phone", "")
|
||||
id_dir = a.get("identity_dir", "") or a.get("identity_hint", "")
|
||||
if id_dir:
|
||||
acct_identity = id_dir.replace("identities/", "")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
identity_dir = str(IDENTITIES_ROOT / acct_identity)
|
||||
Path(identity_dir).mkdir(parents=True, exist_ok=True)
|
||||
log(f"📁 身份目录: {identity_dir}")
|
||||
|
||||
# ── 启动浏览器(带优雅退出和前置检查)──
|
||||
log("🚀 启动浏览器...")
|
||||
conn = CDPConnector(identity_dir=identity_dir, headless=False,
|
||||
window=(702, 783), window_position=(652, 0))
|
||||
|
||||
# 用 GracefulBrowser 包装
|
||||
gb = GracefulBrowser(conn, account_id=account_id,
|
||||
timeout_minutes=timeout_minutes)
|
||||
await gb.setup(check_running=True)
|
||||
|
||||
try:
|
||||
await conn.connect()
|
||||
except Exception as e:
|
||||
write_op_log(account_id, platform, "failed", f"浏览器启动失败: {e}")
|
||||
log(f"❌ 浏览器连接失败: {e}")
|
||||
return False
|
||||
|
||||
await conn.page.set_viewport_size({"width": 702, "height": 783})
|
||||
log("✅ 浏览器就绪")
|
||||
|
||||
# ── 导航到小红书首页 ──
|
||||
log("🌐 打开小红书首页...")
|
||||
await conn.page.goto("https://www.xiaohongshu.com/explore",
|
||||
timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(6)
|
||||
|
||||
# ── 检测登录状态(主方案:Cookie 检测,DOM 仅辅助)──
|
||||
log("🔍 检测登录状态...")
|
||||
logged_in = False
|
||||
if force:
|
||||
log(" 🔄 --force 模式:跳过已登录检测,强制重新登录")
|
||||
else:
|
||||
try:
|
||||
# Cookie 检测(最可靠)
|
||||
cookies = await conn.page.context.cookies()
|
||||
from auth_manager import check_login_by_cookie_sync
|
||||
logged_in = check_login_by_cookie_sync(cookies, "xiaohongshu")
|
||||
if logged_in:
|
||||
log(f" ✅ Cookie 检测: 已登录 (a1/web_session)")
|
||||
else:
|
||||
log(f" ⚠️ Cookie 检测: 未检测到小红书 session cookie")
|
||||
except Exception as e:
|
||||
log(f" ⚠️ Cookie 检测异常: {e}")
|
||||
|
||||
if logged_in:
|
||||
log("✅ 已登录,跳过登录流程")
|
||||
write_op_log(account_id, platform, "already_logged_in")
|
||||
log("\n💡 浏览器保持打开,超时自动关闭")
|
||||
gb.start_auto_shutdown_timer()
|
||||
return True
|
||||
|
||||
# ── 检测登录面板(小红书无cookie时自动弹出登录弹窗)──
|
||||
log("📱 检测登录面板...")
|
||||
panel_detected = False
|
||||
|
||||
for attempt in range(8):
|
||||
panel_found = await conn.page.evaluate("""() => {
|
||||
var sels = ['.login-container','.reds-modal-open','.login-modal',
|
||||
'input[placeholder*=\"手机\"]','.phone-login'];
|
||||
for (var s of sels) { var el = document.querySelector(s);
|
||||
if (el && el.offsetParent) return s; }
|
||||
return null;
|
||||
}""")
|
||||
|
||||
if panel_found:
|
||||
panel_detected = True
|
||||
log(f" ✅ 检测到登录面板: {panel_found}")
|
||||
break
|
||||
|
||||
# 方式2: 尝试点击页面上的"登录"按钮
|
||||
if attempt == 0:
|
||||
log(" 🔍 尝试点击登录按钮...")
|
||||
login_clicked = await conn.page.evaluate("""() => {
|
||||
var all = document.querySelectorAll('span, div, a, button');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var t = all[i].textContent.trim();
|
||||
if ((t === '登录' || t === '登录/注册') && all[i].offsetParent) {
|
||||
all[i].click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
|
||||
if login_clicked:
|
||||
log(" ✅ 已点击登录按钮,等待面板出现...")
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
log(" ⏳ 等待登录面板...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not panel_detected:
|
||||
log("❌ 未检测到登录面板")
|
||||
log(" ⏳ 等待 60 秒,请手动在浏览器上点击「登录」...")
|
||||
log(" (脚本检测到登录面板后会自动处理验证码)")
|
||||
|
||||
for _ in range(12):
|
||||
await asyncio.sleep(5)
|
||||
if await conn.page.evaluate("""() => {
|
||||
var s = document.querySelector('input[placeholder*=\"手机\"], input[type=\"tel\"]');
|
||||
return s && s.offsetParent ? true : false;
|
||||
}"""):
|
||||
panel_detected = True
|
||||
log(" ✅ 检测到登录面板(手动触发)")
|
||||
break
|
||||
|
||||
if not panel_detected:
|
||||
# 最后检查: 是否已经登录了
|
||||
final_check = await conn.page.evaluate(
|
||||
'() => !!document.querySelector(".user-avatar, .reds-count")'
|
||||
)
|
||||
if final_check:
|
||||
log("✅ 已登录成功")
|
||||
write_op_log(account_id, platform, "success")
|
||||
return True
|
||||
log("❌ 登录面板未出现,请手动完成登录")
|
||||
return False
|
||||
|
||||
# ── 执行 SMS 验证码登录(委托 xhs_login.py 完整流程)──
|
||||
log("📱 执行短信验证码登录...")
|
||||
from matrix_modules.account.xhs_login import xhs_login, wait_login_panel
|
||||
|
||||
# 确保登录面板已弹出
|
||||
await asyncio.sleep(1)
|
||||
if not await wait_login_panel(conn.page):
|
||||
log(" ⚠️ 登录面板未弹出,尝试点击登录按钮...")
|
||||
await conn.page.evaluate("""() => {
|
||||
var all = document.querySelectorAll('span, div, a, button');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].textContent.trim() === '登录' && all[i].offsetParent) {
|
||||
all[i].click(); return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
ok = await xhs_login(conn.page, phone=phone, account_name=account_id, log_func=log)
|
||||
|
||||
if ok:
|
||||
log("🎉 小红书登录成功!")
|
||||
write_op_log(account_id, platform, "success")
|
||||
else:
|
||||
log("⚠️ 登录可能未完成,检查页面状态")
|
||||
write_op_log(account_id, platform, "failed", "登录后验证未通过")
|
||||
|
||||
# ── 保持浏览器打开,启动超时自动关闭 ──
|
||||
log("\n💡 浏览器保持打开,超时自动关闭")
|
||||
gb.start_auto_shutdown_timer()
|
||||
|
||||
# 等待直到超时自动关闭或被信号中断
|
||||
while not gb._shutdown_flag:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return logged_in
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="小红书账号登录接入")
|
||||
parser.add_argument("account_id", help="账号 ID(如 xhs_01)")
|
||||
parser.add_argument("--phone", "-p", default="", help="手机号(选填)")
|
||||
parser.add_argument("--timeout", "-t", type=int, default=30,
|
||||
help="超时自动关闭分钟数(默认 30)")
|
||||
parser.add_argument("--force", "-f", action="store_true",
|
||||
help="强制重新登录(跳过已登录检测)")
|
||||
args = parser.parse_args()
|
||||
print(f"\n{'='*55}\n {'📕 小红书强制登录' if args.force else '📕 小红书登录接入'}: {args.account_id}\n{'='*55}\n")
|
||||
asyncio.run(xiaohongshu_login(args.account_id, args.phone, args.timeout, force=args.force))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user