feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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()
@@ -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
@@ -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()