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:
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
matrix_modules — 统一架构的核心模块包
|
||||
|
||||
包含:
|
||||
account/ 账号管理(身份工厂 + sms/captcha 接口)
|
||||
nurture/ 养号执行(行为模拟 + 循环引擎)
|
||||
"""
|
||||
+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()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
🎯 AI 评论生成器 — 基于本地 oMLX 模型
|
||||
|
||||
通过本地 oMLX API (localhost:8000) 生成小红书风格 AI 评论。
|
||||
作为静态语料库的增强替代,支持上下文感知(基于笔记标题/内容)。
|
||||
|
||||
用法:
|
||||
from matrix_modules.comment.ai_generator import AICommentGenerator
|
||||
|
||||
gen = AICommentGenerator()
|
||||
comment = gen.generate(topic="美食", style="种草")
|
||||
comment = gen.generate_with_context(note_title="周末去苏州旅游攻略")
|
||||
comment = gen.generate_from_note(page=page) # 从当前页面提取内容
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import urllib.request
|
||||
import re
|
||||
from typing import Optional, List
|
||||
|
||||
# 默认 oMLX API 地址
|
||||
OLLAMA_BASE = "http://localhost:8000/v1"
|
||||
API_KEY = "5omlx"
|
||||
|
||||
# 系统提示词 — 小红书风格评论生成
|
||||
SYSTEM_PROMPT = """你是一个小红书重度用户,正在给笔记写评论。
|
||||
|
||||
你的评论风格要求:
|
||||
1. 亲切自然,像真人用户的真实感受(不是营销号)
|
||||
2. 长度10-25字,简洁但有信息量
|
||||
3. 可以带少量 emoji(但要自然,不要堆砌)
|
||||
4. 语气因人而异:种草类热情、经验类实用、旅行美景类温暖
|
||||
5. 避免重复、套路化的表达
|
||||
|
||||
你生成的每条评论必须:
|
||||
- 与笔记主题相关(基于给定的主题/标题/内容)
|
||||
- 语气自然真实
|
||||
- 不营销、不推销、不带链接
|
||||
- 不涉及政治、色情、暴力
|
||||
|
||||
只输出评论文本本身,不要加引号、前缀或其他格式。"""
|
||||
|
||||
|
||||
def _call_omlx(messages: list, max_tokens: int = 64,
|
||||
temperature: float = 0.9, timeout: int = 10) -> Optional[str]:
|
||||
"""
|
||||
调用 oMLX 本地 API 生成文本。
|
||||
|
||||
Args:
|
||||
messages: OpenAI 格式的消息列表
|
||||
max_tokens: 最大生成 token 数
|
||||
temperature: 生成温度 (0-1)
|
||||
timeout: 超时秒数
|
||||
|
||||
Returns:
|
||||
生成的文本,失败返回 None
|
||||
"""
|
||||
payload = json.dumps({
|
||||
"model": "Qwen3.5-4B-MLX-4bit", # 可用模型
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
"max_completion_tokens": 100,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE}/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
text = result["choices"][0]["message"]["content"].strip()
|
||||
# 清理可能的引号
|
||||
text = text.strip("\"'「」『』")
|
||||
return text
|
||||
except Exception as e:
|
||||
print(f" ⚠️ oMLX 调用失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 主类
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class AICommentGenerator:
|
||||
"""AI 评论生成器 — 基于 oMLX 本地模型"""
|
||||
|
||||
def __init__(self, base_url: str = OLLAMA_BASE, api_key: str = API_KEY,
|
||||
model: str = "Qwen3.5-4B-MLX-4bit"):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self._used_comments: set = set() # 会话级去重
|
||||
|
||||
def set_url(self, url: str, key: str):
|
||||
"""设置 oMLX 地址和密钥"""
|
||||
self.base_url = url
|
||||
self.api_key = key
|
||||
|
||||
def reset_session(self):
|
||||
"""重置去重记录"""
|
||||
self._used_comments.clear()
|
||||
|
||||
def _is_duplicate(self, text: str) -> bool:
|
||||
"""检查是否与已用评论重复"""
|
||||
text_clean = text.strip().lower()
|
||||
if text_clean in self._used_comments:
|
||||
return True
|
||||
# 相似度检查:如果已有的评论包含当前文本或反之
|
||||
for used in self._used_comments:
|
||||
if text_clean in used or used in text_clean:
|
||||
return True
|
||||
return False
|
||||
|
||||
def generate(self, topic: str = "",
|
||||
style: str = "general",
|
||||
max_tokens: int = 64,
|
||||
temperature: float = 0.9,
|
||||
retries: int = 3) -> str:
|
||||
"""
|
||||
生成一条小红书风格评论。
|
||||
|
||||
Args:
|
||||
topic: 评论主题(空字符串则随机)
|
||||
style: 风格 (general, zhongcao/种草, tips/技巧, travel/旅行, food/美食)
|
||||
max_tokens: 生成长度
|
||||
temperature: 生成温度
|
||||
retries: 失败重试次数
|
||||
|
||||
Returns:
|
||||
评论文本(失败时 fallback 到随机静态语料)
|
||||
"""
|
||||
style_desc = {
|
||||
"general": "日常评论,语气自然亲切",
|
||||
"zhongcao": "种草风,热情推荐,简短有力",
|
||||
"tips": "实用技术型,分享经验感受",
|
||||
"travel": "旅行/风景类,温暖治愈",
|
||||
"food": "美食类,表达馋了/想尝试",
|
||||
}.get(style, "日常评论,语气自然亲切")
|
||||
|
||||
for attempt in range(retries):
|
||||
content = f"生成一条小红书评论。\n"
|
||||
if topic:
|
||||
content += f"主题: {topic}\n"
|
||||
content += f"风格: {style_desc}\n"
|
||||
content += "只输出评论文本,不要加任何前缀或格式。"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
text = _call_omlx(messages, max_tokens=max_tokens,
|
||||
temperature=temperature)
|
||||
if text and not self._is_duplicate(text):
|
||||
self._used_comments.add(text.strip().lower())
|
||||
return text
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 所有重试失败,fallback 到静态语料
|
||||
return self._fallback_comment()
|
||||
|
||||
def generate_with_context(self, note_title: str = "",
|
||||
note_content: str = "",
|
||||
style: str = "general") -> str:
|
||||
"""
|
||||
基于笔记上下文生成相关评论。
|
||||
|
||||
Args:
|
||||
note_title: 笔记标题
|
||||
note_content: 笔记内容摘要
|
||||
style: 评论风格
|
||||
|
||||
Returns:
|
||||
评论文本
|
||||
"""
|
||||
context = "根据以下笔记内容生成一条评论:\n"
|
||||
if note_title:
|
||||
context += f"标题: {note_title}\n"
|
||||
if note_content:
|
||||
context += f"内容: {note_content[:200]}\n"
|
||||
context += "评论要基于笔记内容,像是真实用户的自然感受。"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": context},
|
||||
]
|
||||
|
||||
text = _call_omlx(messages, max_tokens=80, temperature=0.85)
|
||||
if text and not self._is_duplicate(text):
|
||||
self._used_comments.add(text.strip().lower())
|
||||
return text
|
||||
|
||||
return self._fallback_comment(topic=note_title[:20]
|
||||
if note_title else "")
|
||||
|
||||
async def generate_from_note(self, page) -> str:
|
||||
"""
|
||||
从当前页面提取笔记内容并生成评论。
|
||||
|
||||
Args:
|
||||
page: Playwright Page 对象
|
||||
|
||||
Returns:
|
||||
评论文本
|
||||
"""
|
||||
try:
|
||||
note_info = await page.evaluate("""
|
||||
() => {
|
||||
const title = document.querySelector('.title, h1, [class*=note-title]');
|
||||
const content = document.querySelector('.content, .desc, [class*=content]');
|
||||
return {
|
||||
title: title ? title.textContent.trim().substring(0, 100) : '',
|
||||
content: content ? content.textContent.trim().substring(0, 200) : '',
|
||||
};
|
||||
}
|
||||
""")
|
||||
except Exception:
|
||||
note_info = {"title": "", "content": ""}
|
||||
|
||||
return self.generate_with_context(
|
||||
note_title=note_info.get("title", ""),
|
||||
note_content=note_info.get("content", ""),
|
||||
)
|
||||
|
||||
def _fallback_comment(self, topic: str = "") -> str:
|
||||
"""静态语料 fallback"""
|
||||
from matrix_modules.comment.xhs.corpus import get_comment
|
||||
return get_comment(length="medium")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 便捷函数(与 comment_corpus.py 接口一致)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_global_generator: AICommentGenerator = None
|
||||
|
||||
|
||||
def _get_generator() -> AICommentGenerator:
|
||||
"""获取全局生成器实例"""
|
||||
global _global_generator
|
||||
if _global_generator is None:
|
||||
_global_generator = AICommentGenerator()
|
||||
return _global_generator
|
||||
|
||||
|
||||
def ai_comment(topic: str = "", style: str = "general") -> str:
|
||||
"""获取一条 AI 生成的小红书评论"""
|
||||
return _get_generator().generate(topic=topic, style=style)
|
||||
|
||||
|
||||
def reset_ai_session():
|
||||
"""重置 AI 评论去重记录"""
|
||||
_get_generator().reset_session()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 测试入口
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🎯 AI 评论生成器测试\n")
|
||||
|
||||
gen = AICommentGenerator()
|
||||
|
||||
# 测试:不同主题
|
||||
for topic in ["美食探店心得", "周末旅行攻略", "护肤好物分享", "穿搭技巧总结"]:
|
||||
comment = gen.generate(topic=topic)
|
||||
print(f" 📝 [{topic}] → {comment}")
|
||||
|
||||
# 测试:上下文感知
|
||||
print()
|
||||
comment = gen.generate_with_context(
|
||||
note_title="在苏州发现一家宝藏咖啡馆",
|
||||
note_content="周末和朋友去了平江路一家新开的咖啡馆,环境超好,咖啡也很棒!",
|
||||
)
|
||||
print(f" 📝 [上下文] → {comment}")
|
||||
|
||||
# 测试:oMLX 不可用时 fallback
|
||||
print()
|
||||
gen.set_url("http://localhost:9999", "invalid_key")
|
||||
fallback = gen.generate(topic="测试")
|
||||
print(f" 📝 [fallback] → {fallback}")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
小红书评论语料模块
|
||||
"""
|
||||
from .corpus import get_comment, get_comments, random_comment, short_comment, long_comment
|
||||
|
||||
__all__ = ["get_comment", "get_comments", "random_comment", "short_comment", "long_comment"]
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
小红书评论语料库
|
||||
风格: 种草/经验分享/生活方式/美食/旅行/美妆/穿搭
|
||||
特点: 语气亲切、emoji 适度、实用性强、避免营销感
|
||||
|
||||
分类标签:
|
||||
- lifestyle: 生活方式/日常
|
||||
- food: 美食/探店
|
||||
- travel: 旅行/出游
|
||||
- beauty: 美妆/护肤
|
||||
- fashion: 穿搭/时尚
|
||||
- tips: 经验/技巧
|
||||
- emotion: 情感共鸣
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 会话级去重 — 同一运行中不重复发送相同评论
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_used_comments: set = set()
|
||||
|
||||
|
||||
def reset_session():
|
||||
"""清空已用评论记录(新账号/新运行开始时调用)"""
|
||||
_used_comments.clear()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 按分类组织的语料
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
CORPUS = {
|
||||
"lifestyle": [
|
||||
"这就是我理想的生活状态",
|
||||
"看完立刻去试了,真的有用",
|
||||
"收藏了,周末就安排",
|
||||
"这个思路太棒了,学到了",
|
||||
"已经开始期待了",
|
||||
"这就是我想要的感觉",
|
||||
"看完心情都变好了",
|
||||
"太真实了,完全说到心坎里",
|
||||
"这个分享太及时了",
|
||||
"已经转发给闺蜜了",
|
||||
"这就是生活该有的样子",
|
||||
"看完立刻动手试了一下",
|
||||
"这个角度从来没想过",
|
||||
"太治愈了,反复看了好几遍",
|
||||
"这就是我最近在找的东西",
|
||||
],
|
||||
"food": [
|
||||
"看着就很有食欲",
|
||||
"下次去一定要试试",
|
||||
"这个做法收藏了",
|
||||
"看起来好好吃",
|
||||
"已经流口水了",
|
||||
"这个搭配绝了",
|
||||
"周末就去做",
|
||||
"求具体做法",
|
||||
"这个店在哪里",
|
||||
"看起来就很正宗",
|
||||
"已经加入收藏夹",
|
||||
"这个摆盘太美了",
|
||||
"食材都很新鲜的样子",
|
||||
"这个口味应该很适合我",
|
||||
"已经截图保存了",
|
||||
],
|
||||
"travel": [
|
||||
"这个地方太美了",
|
||||
"已经加入旅行清单",
|
||||
"下次假期就去",
|
||||
"这个机位太绝了",
|
||||
"攻略做得很详细",
|
||||
"风景太治愈了",
|
||||
"已经分享给朋友了",
|
||||
"这个路线规划得很好",
|
||||
"看完立刻想去",
|
||||
"这个地方人不多吧",
|
||||
"住宿看起来不错",
|
||||
"这个角度拍出来太好看了",
|
||||
"已经收藏了,下次参考",
|
||||
"这个景点值得去吗",
|
||||
"交通方便吗",
|
||||
],
|
||||
"beauty": [
|
||||
"这个效果太明显了",
|
||||
"已经种草了",
|
||||
"求链接",
|
||||
"这个颜色很适合我",
|
||||
"肤质看起来变好了",
|
||||
"这个步骤学到了",
|
||||
"已经加入购物车",
|
||||
"这个手法很关键",
|
||||
"效果能维持多久",
|
||||
"敏感肌可以用吗",
|
||||
"这个搭配思路很好",
|
||||
"已经截图保存教程",
|
||||
"这个产品的确不错",
|
||||
"步骤很详细,收藏了",
|
||||
"这个技巧太实用了",
|
||||
],
|
||||
"fashion": [
|
||||
"这套搭配太绝了",
|
||||
"颜色搭配得很好",
|
||||
"这个风格很适合你",
|
||||
"已经截图保存",
|
||||
"求衣服链接",
|
||||
"这个配饰点睛了",
|
||||
"整体比例很好",
|
||||
"这个单品很百搭",
|
||||
"已经加入灵感库",
|
||||
"这个配色可以学",
|
||||
"风格很独特",
|
||||
"这套日常也能穿",
|
||||
"细节处理得很好",
|
||||
"这个搭配思路学到了",
|
||||
"已经转发给姐妹了",
|
||||
],
|
||||
"tips": [
|
||||
"这个技巧太实用了",
|
||||
"已经收藏,下次用",
|
||||
"步骤写得很清楚",
|
||||
"这个坑我也踩过",
|
||||
"感谢分享,省了很多时间",
|
||||
"这个方法效率很高",
|
||||
"已经试过了,确实好用",
|
||||
"这个思路可以举一反三",
|
||||
"细节讲得很到位",
|
||||
"这个工具我也在用",
|
||||
"已经加入知识库",
|
||||
"这个经验太宝贵了",
|
||||
"看完立刻去实践了",
|
||||
"这个总结很到位",
|
||||
"已经分享给同事了",
|
||||
],
|
||||
"emotion": [
|
||||
"太真实了",
|
||||
"完全共鸣",
|
||||
"这就是我想说的",
|
||||
"看完很有感触",
|
||||
"太懂这种感觉了",
|
||||
"说得太对了",
|
||||
"这就是生活",
|
||||
"看完沉默了",
|
||||
"太戳心了",
|
||||
"这个视角很独特",
|
||||
"完全同意",
|
||||
"这就是成长吧",
|
||||
"看完想了很多",
|
||||
"这个分享很有力量",
|
||||
"感谢分享",
|
||||
],
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 通用短评(不分类,随机使用)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
GENERAL_SHORT = [
|
||||
"学到了",
|
||||
"收藏了",
|
||||
"确实如此",
|
||||
"很有道理",
|
||||
"太棒了",
|
||||
"感谢分享",
|
||||
"已三连",
|
||||
"好文",
|
||||
"mark",
|
||||
"码住",
|
||||
"实用",
|
||||
"干货",
|
||||
"受教了",
|
||||
"涨知识了",
|
||||
"这个好",
|
||||
"不错",
|
||||
"可以",
|
||||
"666",
|
||||
"绝了",
|
||||
"牛",
|
||||
]
|
||||
|
||||
# ── 三级接力语料(评论→回复→再回复的自然互动链)──
|
||||
# 每个分类含 first/reply/second 三层
|
||||
CHAIN_CORPUS = {
|
||||
"food": [
|
||||
{"first": "看着就很有食欲,周末去试试", "reply": "同意!上次去吃过一次确实不错", "second": "我也去过,他家那个招牌菜绝了"},
|
||||
{"first": "这个做法收藏了,回家就做", "reply": "做了好几次了,家人都说好吃", "second": "是的,跟着做了一次很成功"},
|
||||
],
|
||||
"travel": [
|
||||
{"first": "这个地方太美了,已加入旅行清单", "reply": "去年去过,风景确实好,值得二刷", "second": "求攻略!准备下个月去"},
|
||||
{"first": "这个机位太绝了,拍得真好", "reply": "早上五点去占的位置,值得", "second": "太卷了哈哈哈,不过效果确实好"},
|
||||
],
|
||||
"tech": [
|
||||
{"first": "分析得很透彻,学到了", "reply": "对,第三点我之前完全没想到", "second": "是的,按这个方法试了效果很好"},
|
||||
],
|
||||
"lifestyle": [
|
||||
{"first": "这就是我理想的生活状态", "reply": "同款生活!每天都很充实", "second": "羡慕了,我也要这样过"},
|
||||
],
|
||||
"emotion": [
|
||||
{"first": "太真实了,完全说到心坎里", "reply": "是啊,经历过的人才懂", "second": "抱抱,都会好起来的"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_chain(group: str = "food", position: str = "first") -> str:
|
||||
"""获取三级接力语料。group=分类, position=first/reply/second"""
|
||||
import random
|
||||
pool = CHAIN_CORPUS.get(group, CHAIN_CORPUS["food"])
|
||||
item = random.choice(pool)
|
||||
return item.get(position, item["first"])
|
||||
|
||||
|
||||
def get_chain_group(group: str = "food") -> dict:
|
||||
"""获取一组完整的三级接力语料"""
|
||||
import random
|
||||
pool = CHAIN_CORPUS.get(group, CHAIN_CORPUS["food"])
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 评论生成器
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def get_comment(category: str = None, length: str = "medium") -> str:
|
||||
"""
|
||||
获取一条小红书风格评论(自动去重:同一运行中不会重复返回相同评论)
|
||||
|
||||
Args:
|
||||
category: 分类标签 (lifestyle/food/travel/beauty/fashion/tips/emotion)
|
||||
None 时随机选择分类
|
||||
length: 长度控制
|
||||
- short: 2~8 字短评
|
||||
- medium: 10~25 字常规评论 (默认)
|
||||
- long: 25~50 字长评论
|
||||
|
||||
Returns:
|
||||
评论文本
|
||||
"""
|
||||
global _used_comments
|
||||
|
||||
if length == "short":
|
||||
available = [c for c in GENERAL_SHORT if c not in _used_comments]
|
||||
if not available:
|
||||
available = GENERAL_SHORT # 用完了就复用
|
||||
comment = random.choice(available)
|
||||
_used_comments.add(comment)
|
||||
return comment
|
||||
|
||||
if category and category in CORPUS:
|
||||
pool = [c for c in CORPUS[category] if c not in _used_comments]
|
||||
if not pool:
|
||||
pool = CORPUS[category] # 用完了就复用
|
||||
else:
|
||||
all_items = []
|
||||
for cat_items in CORPUS.values():
|
||||
all_items.extend(cat_items)
|
||||
pool = [c for c in all_items if c not in _used_comments]
|
||||
if not pool:
|
||||
pool = all_items # 用完了就复用
|
||||
|
||||
comment = random.choice(pool)
|
||||
_used_comments.add(comment)
|
||||
|
||||
# 长度控制
|
||||
if length == "long" and len(comment) < 20:
|
||||
# 追加一条短评组合
|
||||
comment += "。" + random.choice(GENERAL_SHORT)
|
||||
elif length == "medium" and len(comment) > 30:
|
||||
# 截断到合适长度
|
||||
comment = comment[:28] + "..."
|
||||
|
||||
return comment
|
||||
|
||||
|
||||
def get_comments(count: int = 3, category: str = None, length: str = "medium") -> list:
|
||||
"""获取多条不重复评论"""
|
||||
results = []
|
||||
pool = []
|
||||
|
||||
if category and category in CORPUS:
|
||||
pool = CORPUS[category].copy()
|
||||
else:
|
||||
for cat_items in CORPUS.values():
|
||||
pool.extend(cat_items)
|
||||
|
||||
# 去重并随机选择
|
||||
unique_pool = list(set(pool))
|
||||
if count > len(unique_pool):
|
||||
count = len(unique_pool)
|
||||
|
||||
selected = random.sample(unique_pool, count)
|
||||
|
||||
for comment in selected:
|
||||
if length == "short":
|
||||
comment = random.choice(GENERAL_SHORT)
|
||||
elif length == "long" and len(comment) < 20:
|
||||
comment += "。" + random.choice(GENERAL_SHORT)
|
||||
results.append(comment)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 快捷函数
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def random_comment() -> str:
|
||||
"""随机获取一条中等长度评论(养号默认使用)"""
|
||||
return get_comment(length="medium")
|
||||
|
||||
|
||||
def short_comment() -> str:
|
||||
"""获取短评"""
|
||||
return get_comment(length="short")
|
||||
|
||||
|
||||
def long_comment() -> str:
|
||||
"""获取长评"""
|
||||
return get_comment(length="long")
|
||||
@@ -0,0 +1 @@
|
||||
# nurture 模块
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
anchor_db.py — 锚点规则数据库管理
|
||||
|
||||
锚点规则 + 原子操作 + 检测日志 + 校准候选 全部存储在 SQLite 中。
|
||||
可拆解、可更新、可版本管理。
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_DIR = Path(__file__).parent / "data"
|
||||
DB_PATH = DB_DIR / "anchor.db"
|
||||
SCHEMA_PATH = DB_DIR / "schema.sql"
|
||||
|
||||
|
||||
# ── 数据库初始化 ──
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
"""获取数据库连接(每次调用创建新连接,线程安全)"""
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(force: bool = False):
|
||||
"""初始化/重置数据库"""
|
||||
if force and DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
conn = get_conn()
|
||||
# 检查是否已初始化
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='anchor_rules'")
|
||||
if cursor.fetchone() and not force:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 执行 schema
|
||||
schema = SCHEMA_PATH.read_text(encoding='utf-8')
|
||||
conn.executescript(schema)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"✅ 数据库已初始化: {DB_PATH}")
|
||||
|
||||
|
||||
# ── 锚点规则 CRUD ──
|
||||
|
||||
def get_rules(state_name: str = None) -> list:
|
||||
"""获取锚点规则
|
||||
|
||||
Args:
|
||||
state_name: 指定状态名, None=返回所有
|
||||
Returns:
|
||||
[{state_name, field, operator, value, logic_group}, ...]
|
||||
"""
|
||||
conn = get_conn()
|
||||
if state_name:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM anchor_rules WHERE state_name=? AND active=1 ORDER BY logic_group, id",
|
||||
(state_name,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM anchor_rules WHERE active=1 ORDER BY state_name, logic_group, id"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_rule(state_name: str, field: str, operator: str, value: str,
|
||||
logic_group: int = 0) -> int:
|
||||
"""添加锚点规则"""
|
||||
conn = get_conn()
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO anchor_rules (state_name, field, operator, value, logic_group) VALUES (?,?,?,?,?)",
|
||||
(state_name, field, operator, value, logic_group)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def update_rule(rule_id: int, **kwargs):
|
||||
"""更新规则字段"""
|
||||
allowed = {'field', 'operator', 'value', 'logic_group', 'active'}
|
||||
sets = []
|
||||
params = []
|
||||
for k, v in kwargs.items():
|
||||
if k in allowed:
|
||||
sets.append(f"{k}=?")
|
||||
params.append(v)
|
||||
if not sets:
|
||||
return
|
||||
params.append(rule_id)
|
||||
conn = get_conn()
|
||||
conn.execute(f"UPDATE anchor_rules SET {', '.join(sets)}, updated_at=datetime('now','localtime') WHERE id=?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_rule(rule_id: int):
|
||||
"""软删除规则"""
|
||||
update_rule(rule_id, active=0)
|
||||
|
||||
|
||||
# ── 原子操作 CRUD ──
|
||||
|
||||
def get_ops(op_name: str = None) -> list:
|
||||
"""获取原子操作"""
|
||||
conn = get_conn()
|
||||
if op_name:
|
||||
rows = conn.execute("SELECT * FROM atomic_ops WHERE op_name=?", (op_name,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM atomic_ops ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_op(op_name: str, description: str, action_type: str,
|
||||
target: str, verify_rule: str = None) -> int:
|
||||
"""添加原子操作"""
|
||||
conn = get_conn()
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO atomic_ops (op_name, description, action_type, target, verify_rule) VALUES (?,?,?,?,?)",
|
||||
(op_name, description, action_type, target, verify_rule)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
# ── 检测日志 ──
|
||||
|
||||
def log_detection(identity: str, expected_state: str, detected_state: str,
|
||||
match: bool, anchor_snapshot: dict = None,
|
||||
screenshot_path: str = None):
|
||||
"""记录一次锚点检测结果"""
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO detection_logs (identity, expected_state, detected_state, match, anchor_snapshot, screenshot_path) VALUES (?,?,?,?,?,?)",
|
||||
(identity, expected_state, detected_state, int(match),
|
||||
json.dumps(anchor_snapshot, ensure_ascii=False) if anchor_snapshot else None,
|
||||
screenshot_path)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_accuracy(identity: str = None, count: int = 50) -> float:
|
||||
"""获取最近 N 次检测的成功率"""
|
||||
conn = get_conn()
|
||||
if identity:
|
||||
rows = conn.execute(
|
||||
"SELECT match FROM detection_logs WHERE identity=? ORDER BY id DESC LIMIT ?",
|
||||
(identity, count)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT match FROM detection_logs ORDER BY id DESC LIMIT ?",
|
||||
(count,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
if not rows:
|
||||
return 1.0
|
||||
return sum(r['match'] for r in rows) / len(rows)
|
||||
|
||||
|
||||
def get_recent_failures(identity: str = None, threshold: int = 5) -> bool:
|
||||
"""检查最近是否连续失败 threshold 次"""
|
||||
conn = get_conn()
|
||||
if identity:
|
||||
rows = conn.execute(
|
||||
"SELECT match FROM detection_logs WHERE identity=? ORDER BY id DESC LIMIT ?",
|
||||
(identity, threshold)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT match FROM detection_logs ORDER BY id DESC LIMIT ?",
|
||||
(threshold,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
if len(rows) < threshold:
|
||||
return False
|
||||
return all(r['match'] == 0 for r in rows)
|
||||
|
||||
|
||||
# ── 校准候选 ──
|
||||
|
||||
def add_candidate(state_name: str, source: str, old_rules: list, new_rules: list) -> int:
|
||||
"""添加校准候选"""
|
||||
conn = get_conn()
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO calibration_candidates (state_name, source, old_rules, new_rules) VALUES (?,?,?,?)",
|
||||
(state_name, source, json.dumps(old_rules, ensure_ascii=False),
|
||||
json.dumps(new_rules, ensure_ascii=False))
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def update_candidate(candidate_id: int, status: str, test_count: int = None, test_pass: int = None):
|
||||
"""更新校准候选状态"""
|
||||
conn = get_conn()
|
||||
sets = ["status=?"]
|
||||
params = [status]
|
||||
if test_count is not None:
|
||||
sets.append("test_count=?")
|
||||
params.append(test_count)
|
||||
if test_pass is not None:
|
||||
sets.append("test_pass=?")
|
||||
params.append(test_pass)
|
||||
params.append(candidate_id)
|
||||
conn.execute(f"UPDATE calibration_candidates SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── 状态迁移 ──
|
||||
|
||||
def get_valid_next_states(from_state: str) -> list:
|
||||
"""获取从当前状态可以迁移到哪些状态"""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT to_state, via_op FROM state_transitions WHERE from_state=?",
|
||||
(from_state,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 初始化数据库并导入初始数据
|
||||
init_db(force=True)
|
||||
print(f" 规则数: {len(get_rules())}")
|
||||
print(f" 操作数: {len(get_ops())}")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
anchor_rules.py — 锚点检测引擎
|
||||
|
||||
从 anchor.db 读取规则,执行状态检测。
|
||||
规则可动态更新(通过 anchor_db.py),无需修改代码。
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
from . import anchor_db as db
|
||||
|
||||
|
||||
# ── 运算符映射 ──
|
||||
|
||||
def _apply_op(field_value, operator, target_value):
|
||||
"""对单个字段应用运算符比较(不区分大小写)"""
|
||||
if field_value is None:
|
||||
return False
|
||||
|
||||
# 全部转字符串,不区分大小写
|
||||
fv = str(field_value).lower()
|
||||
tv = str(target_value).lower()
|
||||
|
||||
if operator == 'eq':
|
||||
return fv == tv
|
||||
elif operator == 'neq':
|
||||
return fv != tv
|
||||
elif operator == 'gt':
|
||||
try:
|
||||
return float(fv) > float(tv)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
elif operator == 'gte':
|
||||
try:
|
||||
return float(fv) >= float(tv)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
elif operator == 'lt':
|
||||
try:
|
||||
return float(fv) < float(tv)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
elif operator == 'lte':
|
||||
try:
|
||||
return float(fv) <= float(tv)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
elif operator == 'contains':
|
||||
return tv in fv
|
||||
elif operator == 'not_contains':
|
||||
return tv not in fv
|
||||
elif operator == 'regex':
|
||||
try:
|
||||
return bool(re.search(tv, fv))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ── 核心检测逻辑 ──
|
||||
|
||||
def detect_state(anchors: dict, strict: bool = False) -> str:
|
||||
"""检测当前页面状态
|
||||
|
||||
Args:
|
||||
anchors: DOM 锚点数据字典
|
||||
strict: True=必须所有规则满足; False=按置信度选最佳匹配
|
||||
|
||||
Returns:
|
||||
state_name 或 'UNKNOWN'
|
||||
"""
|
||||
rules = db.get_rules()
|
||||
if not rules:
|
||||
return "UNKNOWN"
|
||||
|
||||
# 按 state_name 分组
|
||||
state_rules = {}
|
||||
for r in rules:
|
||||
sn = r['state_name']
|
||||
if sn not in state_rules:
|
||||
state_rules[sn] = {}
|
||||
lg = r['logic_group']
|
||||
if lg not in state_rules[sn]:
|
||||
state_rules[sn][lg] = []
|
||||
state_rules[sn][lg].append(r)
|
||||
|
||||
best_state = "UNKNOWN"
|
||||
best_score = 0
|
||||
|
||||
# 检测优先级(子状态优先于父状态)
|
||||
PRIORITY = ['SEARCH_PLAYER', 'DETAIL', 'PLAYER', 'SEARCH', 'HOME', 'POPUP', 'LOGIN']
|
||||
state_groups = sorted(state_rules.items(),
|
||||
key=lambda x: PRIORITY.index(x[0]) if x[0] in PRIORITY else 99)
|
||||
|
||||
for state_name, groups in state_groups:
|
||||
# 每个 logic_group 内的规则 AND,不同 group 之间 OR
|
||||
group_results = []
|
||||
for group_id, group_rules in groups.items():
|
||||
all_match = all(
|
||||
_apply_op(anchors.get(r['field']), r['operator'], r['value'])
|
||||
for r in group_rules
|
||||
)
|
||||
group_results.append(all_match)
|
||||
|
||||
# OR 连接:任一 group 通过即该状态匹配
|
||||
state_match = any(group_results)
|
||||
|
||||
if strict:
|
||||
if state_match:
|
||||
return state_name
|
||||
else:
|
||||
# 非严格模式:计算匹配得分
|
||||
total_rules = sum(len(g) for g in groups.values())
|
||||
matched_rules = sum(
|
||||
sum(1 for r in g if _apply_op(anchors.get(r['field']), r['operator'], r['value']))
|
||||
for g in groups.values()
|
||||
)
|
||||
score = matched_rules / max(total_rules, 1)
|
||||
if state_match and score > best_score:
|
||||
best_score = score
|
||||
best_state = state_name
|
||||
elif not state_match and score > best_score:
|
||||
# 即使不完全匹配,高分也记录
|
||||
if score > 0.6:
|
||||
best_score = score
|
||||
best_state = f"{state_name}?"
|
||||
|
||||
return best_state
|
||||
|
||||
|
||||
def verify_op(op_name: str, anchors: dict) -> bool:
|
||||
"""验证原子操作是否成功执行"""
|
||||
ops = db.get_ops(op_name)
|
||||
if not ops:
|
||||
return True # 无验证规则默认通过
|
||||
op = ops[0]
|
||||
verify = op.get('verify_rule')
|
||||
if not verify:
|
||||
return True
|
||||
|
||||
# 验证规则映射
|
||||
VERIFY_MAP = {
|
||||
'video_count_gte_3': lambda a: a.get('video_count', 0) >= 3,
|
||||
'video_src_change': lambda a: True, # 需要前后对比
|
||||
'like_count_change': lambda a: True,
|
||||
'title_contains_搜索': lambda a: '搜索' in str(a.get('title', '')),
|
||||
'video_count_eq_1': lambda a: a.get('video_count', 0) == 1,
|
||||
'popup_gone': lambda a: not a.get('has_popup_close', False),
|
||||
}
|
||||
|
||||
check = VERIFY_MAP.get(verify)
|
||||
if check:
|
||||
return check(anchors)
|
||||
return True
|
||||
|
||||
|
||||
def log_and_check(identity: str, expected_state: str, anchors: dict,
|
||||
screenshot_path: str = None) -> dict:
|
||||
"""检测 + 记录 + 返回结果"""
|
||||
detected = detect_state(anchors)
|
||||
match = (detected == expected_state)
|
||||
|
||||
db.log_detection(
|
||||
identity=identity,
|
||||
expected_state=expected_state,
|
||||
detected_state=detected,
|
||||
match=match,
|
||||
anchor_snapshot=anchors,
|
||||
screenshot_path=screenshot_path,
|
||||
)
|
||||
|
||||
return {
|
||||
"expected": expected_state,
|
||||
"detected": detected,
|
||||
"match": match,
|
||||
"accuracy": db.get_recent_accuracy(identity),
|
||||
"needs_calibration": db.get_recent_failures(identity, threshold=5),
|
||||
}
|
||||
|
||||
|
||||
# ── 便捷函数 ──
|
||||
|
||||
def list_rules(state_name: str = None) -> str:
|
||||
"""以人类可读格式列出规则"""
|
||||
rules = db.get_rules(state_name)
|
||||
if not rules:
|
||||
return "暂无锚点规则"
|
||||
|
||||
lines = []
|
||||
current_state = None
|
||||
for r in rules:
|
||||
if r['state_name'] != current_state:
|
||||
current_state = r['state_name']
|
||||
lines.append(f"\n[{current_state}]")
|
||||
lines.append(f" {r['field']} {r['operator']} {r['value']} (group={r['logic_group']})")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def list_ops() -> str:
|
||||
"""列出所有原子操作"""
|
||||
ops = db.get_ops()
|
||||
if not ops:
|
||||
return "暂无原子操作"
|
||||
lines = []
|
||||
for op in ops:
|
||||
lines.append(f" {op['op_name']:15s} | {op['action_type']:10s} | {op.get('target', ''):30s} | {op.get('verify_rule', '-')}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试:用采集数据验证规则
|
||||
import json
|
||||
summary_path = db.DB_DIR.parent.parent / "data" / "anchor_summary.json"
|
||||
if summary_path.exists():
|
||||
data = json.loads(summary_path.read_text())
|
||||
print("📊 用采集数据验证锚点规则:")
|
||||
for d in data:
|
||||
step = d['step']
|
||||
anchors = d['anchors']
|
||||
state = detect_state(anchors)
|
||||
print(f" 步{step}: {state:15s} | {d['url'][:40]}")
|
||||
print(f"\n📋 当前规则:\n{list_rules()}")
|
||||
print(f"\n📋 原子操作:\n{list_ops()}")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
行为模拟参数配置 — 拟人化操作行为
|
||||
|
||||
每个身份可以独立配置 behavior 参数,覆盖默认值。
|
||||
默认配置在 DEFAULT_CONFIG 中定义,用户可在 identities/{name}/config.yaml 中覆盖。
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# ── 默认行为配置 ──────────────────────────────────────────────
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
# 基础延迟
|
||||
'base_delay': 1.5, # 操作间基础间隔(秒)
|
||||
'delay_variance': 0.8, # 随机波动范围
|
||||
|
||||
# 鼠标行为
|
||||
'mouse': {
|
||||
'speed_range': [0.5, 1.5], # 鼠标移动速度系数
|
||||
'click_delay': [0.08, 0.25], # 点击前停顿(秒)
|
||||
},
|
||||
|
||||
# 滚动行为
|
||||
'scroll': {
|
||||
'mode': 'human', # human / random / fast
|
||||
'pause_chance': 0.3, # 滚动中暂停的概率
|
||||
'pause_duration': [0.5, 2.0], # 暂停时长范围(秒)
|
||||
'distance_range': [200, 700], # 每次滚动距离(px)
|
||||
},
|
||||
|
||||
# 操作顺序
|
||||
'sequence': {
|
||||
'shuffle': True, # 是否打乱操作顺序
|
||||
'repetition_chance': 0.1, # 重复上一个操作的概率
|
||||
'min_gap': 1.5, # 操作间最小间隔(秒)
|
||||
'max_gap': 6.0, # 操作间最大间隔(秒)
|
||||
},
|
||||
|
||||
# 注意力模拟
|
||||
'attention': {
|
||||
'distraction_chance': 0.05, # "分心"概率
|
||||
'distraction_duration': [2, 6], # 分心持续时长(秒)
|
||||
'watch_duration': [4, 12], # 观看视频时长(秒)
|
||||
'linger_after_action': [1, 4], # 操作后的停留(秒)
|
||||
},
|
||||
|
||||
# 轮间休息
|
||||
'round_break': {
|
||||
'min_break': 5, # 轮间最短休息(秒)
|
||||
'max_break': 20, # 轮间最长休息(秒)
|
||||
},
|
||||
|
||||
# ★ 反检测 — 拟人化操作策略(2026-05-04)
|
||||
'anti_detection': {
|
||||
'card_position_range': [0, 4], # 点卡片时选第几个(0=第1个)
|
||||
'search_result_range': [1, 4], # 搜索结果点第几个(0=第1个)
|
||||
'use_keyboard_like': False, # 是否用键盘 L 键点赞(预留)
|
||||
'use_close_button': False, # 是否用X按钮返回(预留)
|
||||
'scroll_method': 'mouse', # 切换方式: mouse/keyboard/js
|
||||
},
|
||||
|
||||
# 异常处理
|
||||
'retry': {
|
||||
'max_retries': 2, # 操作失败最大重试次数
|
||||
'retry_delay': [1, 3], # 重试前等待(秒)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BehaviorConfig:
|
||||
"""行为配置 — 控制所有操作的拟人化参数"""
|
||||
|
||||
def __init__(self, config: Optional[dict] = None):
|
||||
self._cfg = DEFAULT_CONFIG.copy()
|
||||
if config:
|
||||
self._deep_merge(self._cfg, config)
|
||||
|
||||
def _deep_merge(self, base: dict, override: dict):
|
||||
"""递归合并配置"""
|
||||
for key, val in override.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
||||
self._deep_merge(base[key], val)
|
||||
else:
|
||||
base[key] = val
|
||||
|
||||
# ── 延迟方法 ──────────────────────────────────────────
|
||||
|
||||
def action_delay(self) -> float:
|
||||
"""两个操作之间的随机延迟"""
|
||||
base = self._cfg['base_delay']
|
||||
variance = self._cfg['delay_variance']
|
||||
return max(0.3, base + random.uniform(-variance, variance))
|
||||
|
||||
def sequence_gap(self) -> float:
|
||||
"""操作序列之间的间隔"""
|
||||
sg = self._cfg['sequence']
|
||||
return random.uniform(sg['min_gap'], sg['max_gap'])
|
||||
|
||||
def click_delay(self) -> float:
|
||||
"""点击前的微停顿"""
|
||||
cd = self._cfg['mouse']['click_delay']
|
||||
return random.uniform(cd[0], cd[1])
|
||||
|
||||
# ── 滚动方法 ──────────────────────────────────────────
|
||||
|
||||
def scroll_distance(self) -> int:
|
||||
"""每次滚动的距离"""
|
||||
dr = self._cfg['scroll']['distance_range']
|
||||
return random.randint(dr[0], dr[1])
|
||||
|
||||
def should_pause_scroll(self) -> bool:
|
||||
"""滚动中是否应该暂停"""
|
||||
return random.random() < self._cfg['scroll']['pause_chance']
|
||||
|
||||
def scroll_pause(self) -> float:
|
||||
"""滚动暂停时长"""
|
||||
pd = self._cfg['scroll']['pause_duration']
|
||||
return random.uniform(pd[0], pd[1])
|
||||
|
||||
# ── 注意力方法 ────────────────────────────────────────
|
||||
|
||||
def watch_duration(self) -> float:
|
||||
"""观看一个视频的时长"""
|
||||
wd = self._cfg['attention']['watch_duration']
|
||||
return random.uniform(wd[0], wd[1])
|
||||
|
||||
def should_distract(self) -> bool:
|
||||
"""是否应该"分心"(模拟走神)"""
|
||||
return random.random() < self._cfg['attention']['distraction_chance']
|
||||
|
||||
def distraction_duration(self) -> float:
|
||||
"""分心持续时长"""
|
||||
dd = self._cfg['attention']['distraction_duration']
|
||||
return random.uniform(dd[0], dd[1])
|
||||
|
||||
def linger_after_action(self) -> float:
|
||||
"""操作后的停留"""
|
||||
la = self._cfg['attention']['linger_after_action']
|
||||
|
||||
# ── 反检测方法 ────────────────────────────────────────
|
||||
|
||||
def card_position(self) -> int:
|
||||
"""随机选视频卡片位置(避免每次都点第1个)"""
|
||||
cr = self._cfg['anti_detection']['card_position_range']
|
||||
return random.randint(cr[0], cr[1])
|
||||
|
||||
def search_result_position(self) -> int:
|
||||
"""随机选搜索结果位置(避免每次都点第1个)"""
|
||||
sr = self._cfg['anti_detection']['search_result_range']
|
||||
return random.randint(sr[0], sr[1])
|
||||
return random.uniform(la[0], la[1])
|
||||
|
||||
# ── 轮间休息 ──────────────────────────────────────────
|
||||
|
||||
def round_break(self) -> float:
|
||||
"""轮间休息时长"""
|
||||
rb = self._cfg['round_break']
|
||||
return random.uniform(rb['min_break'], rb['max_break'])
|
||||
|
||||
# ── 操作顺序 ──────────────────────────────────────────
|
||||
|
||||
def should_shuffle(self) -> bool:
|
||||
"""是否打乱操作顺序"""
|
||||
return self._cfg['sequence']['shuffle']
|
||||
|
||||
def should_repeat(self) -> bool:
|
||||
"""是否重复上一个操作"""
|
||||
return random.random() < self._cfg['sequence']['repetition_chance']
|
||||
|
||||
# ── 重试 ──────────────────────────────────────────────
|
||||
|
||||
def retry_delay(self) -> float:
|
||||
"""重试前等待"""
|
||||
rd = self._cfg['retry']['retry_delay']
|
||||
return random.uniform(rd[0], rd[1])
|
||||
|
||||
@property
|
||||
def max_retries(self) -> int:
|
||||
return self._cfg['retry']['max_retries']
|
||||
|
||||
# ── 工具方法 ──────────────────────────────────────────
|
||||
|
||||
def sleep(self, seconds: float):
|
||||
"""带拟人化的 sleep(加入微抖动)"""
|
||||
jitter = random.uniform(-0.05, 0.05)
|
||||
time.sleep(max(0, seconds + jitter))
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
calibration.py — 校准触发器 + AI 兜底
|
||||
|
||||
功能:
|
||||
1. 定期检查锚点检测成功率
|
||||
2. 触发校准(连续失败/成功率低)
|
||||
3. AI 兜底分析(基于 DOM 数据)
|
||||
4. 生成校准候选规则
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from . import anchor_db as db
|
||||
|
||||
# oMLX 配置
|
||||
OLLAMA_BASE = "http://localhost:8000"
|
||||
OLLAMA_API_KEY = "5omlx"
|
||||
LLM_MODEL = "mlx-community--Qwen3.5-4B-MLX-4bit" # 或 gemma-3-4b-it-4bit
|
||||
|
||||
|
||||
# ── 校准检查 ──
|
||||
|
||||
def check_calibration(identity: str) -> dict:
|
||||
"""检查是否需要校准
|
||||
|
||||
Returns:
|
||||
dict: {needs, accuracy, consecutive_failures, level}
|
||||
"""
|
||||
accuracy = db.get_recent_accuracy(identity, count=50)
|
||||
consecutive_fail = db.get_recent_failures(identity, threshold=5)
|
||||
|
||||
# 判断级别
|
||||
if accuracy >= 0.9:
|
||||
level = "normal"
|
||||
elif accuracy >= 0.6:
|
||||
level = "warn"
|
||||
elif accuracy >= 0.3:
|
||||
level = "error"
|
||||
else:
|
||||
level = "alert"
|
||||
|
||||
needs = (accuracy < 0.6) or consecutive_fail
|
||||
|
||||
return {
|
||||
"needs_calibration": needs,
|
||||
"accuracy": round(accuracy, 2),
|
||||
"consecutive_failures": consecutive_fail,
|
||||
"level": level,
|
||||
}
|
||||
|
||||
|
||||
def should_trigger(identity: str) -> bool:
|
||||
"""快速判断是否应触发校准"""
|
||||
acc = db.get_recent_accuracy(identity, count=20)
|
||||
fail = db.get_recent_failures(identity, threshold=5)
|
||||
return acc < 0.6 or fail
|
||||
|
||||
|
||||
# ── AI 兜底 ──
|
||||
|
||||
def _build_ai_prompt(anchors: dict, screenshot_path: str = None) -> str:
|
||||
"""构建发给 LLM 的 prompt"""
|
||||
# 清理敏感数据
|
||||
safe_anchors = {k: v for k, v in anchors.items()
|
||||
if k not in ('body_text', 'video_src_prefix')}
|
||||
|
||||
prompt = f"""你是一个抖音页面状态分析专家。请根据以下页面DOM数据,判断当前页面处于什么状态。
|
||||
|
||||
DOM数据:
|
||||
```json
|
||||
{json.dumps(safe_anchors, ensure_ascii=False, indent=2)}
|
||||
```
|
||||
|
||||
请返回以下JSON格式的分析结果(不要包含其他内容):
|
||||
```json
|
||||
{{
|
||||
"state": "当前页面状态(HOME/PLAYER/SEARCH/SEARCH_PLAYER/DETAIL/POPUP/UNKNOWN)",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["判断依据1", "判断依据2"],
|
||||
"suggested_anchors": [
|
||||
{{"field": "video_count", "operator": "gte", "value": "3", "reason": "为什么这个锚点有效"}}
|
||||
],
|
||||
"next_action": {{
|
||||
"op": "建议执行的操作(swipe_down/click_card/like/search/go_home/dismiss_popup)",
|
||||
"reason": "为什么建议这个操作"
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
注意:
|
||||
- HOME: 首页/精选页,video_count=1,card_count>0
|
||||
- PLAYER: 视频播放页,video_count>=3,有视频在播放
|
||||
- SEARCH: 搜索结果页,title含"搜索"
|
||||
- SEARCH_PLAYER: 从搜索结果进入的播放页
|
||||
- 如果DOM数据异常(如video_count=0),可能是页面未加载或崩溃"""
|
||||
return prompt
|
||||
|
||||
|
||||
async def ai_analyze(anchors: dict, screenshot_path: str = None) -> dict:
|
||||
"""调用本地LLM分析当前页面状态
|
||||
|
||||
Args:
|
||||
anchors: DOM 锚点数据
|
||||
screenshot_path: 截图路径(暂时不用,模型不支持vision)
|
||||
|
||||
Returns:
|
||||
dict: {state, confidence, evidence, suggested_anchors, next_action}
|
||||
"""
|
||||
prompt = _build_ai_prompt(anchors, screenshot_path)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{OLLAMA_BASE}/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OLLAMA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个抖音页面状态分析专家,只输出JSON。"},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 1000,
|
||||
},
|
||||
)
|
||||
result = resp.json()
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
|
||||
# 提取 JSON
|
||||
# 先尝试直接解析
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# 尝试从 ```json ... ``` 中提取
|
||||
import re
|
||||
m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL)
|
||||
if m:
|
||||
parsed = json.loads(m.group(1))
|
||||
else:
|
||||
return {
|
||||
"state": "UNKNOWN",
|
||||
"confidence": 0,
|
||||
"error": f"LLM返回格式异常: {content[:200]}",
|
||||
}
|
||||
|
||||
return parsed
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"state": "UNKNOWN",
|
||||
"confidence": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
# ── 校准流程 ──
|
||||
|
||||
async def run_calibration(identity: str, anchors: dict,
|
||||
screenshot_path: str = None) -> dict:
|
||||
"""执行完整校准流程
|
||||
|
||||
1. 检查当前成功率
|
||||
2. AI 分析当前页面
|
||||
3. 生成校准候选
|
||||
4. 返回校准结果
|
||||
"""
|
||||
check = check_calibration(identity)
|
||||
print(f"[校准] {identity} 准确率: {check['accuracy']}, 级别: {check['level']}")
|
||||
|
||||
# AI 分析
|
||||
print(f"[校准] AI分析中...")
|
||||
ai_result = await ai_analyze(anchors, screenshot_path)
|
||||
ai_state = ai_result.get("state", "UNKNOWN")
|
||||
ai_confidence = ai_result.get("confidence", 0)
|
||||
print(f"[校准] AI判断: {ai_state} (置信度: {ai_confidence})")
|
||||
|
||||
# 如果 AI 建议了新锚点,生成校准候选
|
||||
suggested = ai_result.get("suggested_anchors", [])
|
||||
if suggested and ai_confidence >= 0.7:
|
||||
# 获取当前规则作为 old_rules
|
||||
old_rules = db.get_rules(ai_state)
|
||||
|
||||
candidate_id = db.add_candidate(
|
||||
state_name=ai_state,
|
||||
source="ai",
|
||||
old_rules=[dict(r) for r in old_rules],
|
||||
new_rules=suggested,
|
||||
)
|
||||
print(f"[校准] 候选规则已生成 (id={candidate_id})")
|
||||
|
||||
return {
|
||||
"calibration_triggered": True,
|
||||
"candidate_id": candidate_id,
|
||||
"ai_state": ai_state,
|
||||
"ai_confidence": ai_confidence,
|
||||
"check": check,
|
||||
"ai_result": ai_result,
|
||||
}
|
||||
|
||||
return {
|
||||
"calibration_triggered": False,
|
||||
"check": check,
|
||||
"ai_result": ai_result,
|
||||
}
|
||||
|
||||
|
||||
# ── 候选规则审批 ──
|
||||
|
||||
async def test_candidate(candidate_id: int, test_data: list) -> dict:
|
||||
"""用测试数据验证候选规则
|
||||
|
||||
Args:
|
||||
candidate_id: 校准候选ID
|
||||
test_data: [{anchors: dict, expected_state: str}, ...]
|
||||
|
||||
Returns:
|
||||
{candidate_id, old_accuracy, new_accuracy, accepted}
|
||||
"""
|
||||
candidate_rows = db.get_conn().execute(
|
||||
"SELECT * FROM calibration_candidates WHERE id=?", (candidate_id,)
|
||||
).fetchall()
|
||||
if not candidate_rows:
|
||||
return {"error": "candidate not found"}
|
||||
candidate = dict(candidate_rows[0])
|
||||
new_rules = json.loads(candidate["new_rules"])
|
||||
old_rules = json.loads(candidate["old_rules"])
|
||||
|
||||
# 不需要真的测试,直接在调用方处理
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"new_rules": new_rules,
|
||||
"old_rules": old_rules,
|
||||
}
|
||||
|
||||
|
||||
def accept_candidate(candidate_id: int):
|
||||
"""采纳候选规则"""
|
||||
candidate_rows = db.get_conn().execute(
|
||||
"SELECT * FROM calibration_candidates WHERE id=?", (candidate_id,)
|
||||
).fetchall()
|
||||
if not candidate_rows:
|
||||
return
|
||||
candidate = dict(candidate_rows[0])
|
||||
new_rules = json.loads(candidate["new_rules"])
|
||||
state_name = candidate["state_name"]
|
||||
|
||||
# 停用旧规则
|
||||
for r in db.get_rules(state_name):
|
||||
db.update_rule(r["id"], active=0)
|
||||
|
||||
# 添加新规则
|
||||
for rule in new_rules:
|
||||
db.add_rule(
|
||||
state_name=state_name,
|
||||
field=rule["field"],
|
||||
operator=rule["operator"],
|
||||
value=rule["value"],
|
||||
logic_group=rule.get("logic_group", 0),
|
||||
)
|
||||
|
||||
db.update_candidate(candidate_id, "accepted")
|
||||
print(f"[校准] 候选#{candidate_id} 已采纳,{state_name}规则已更新")
|
||||
|
||||
|
||||
def reject_candidate(candidate_id: int):
|
||||
"""拒绝候选规则"""
|
||||
db.update_candidate(candidate_id, "rejected")
|
||||
print(f"[校准] 候选#{candidate_id} 已拒绝")
|
||||
|
||||
|
||||
# ── 快速测试(不依赖外部LLM)──
|
||||
|
||||
async def quick_fallback(anchors: dict) -> dict:
|
||||
"""快速兜底:基于简单规则判断(不调LLM),用于LLM不可用时的降级"""
|
||||
vc = anchors.get("video_count", 0)
|
||||
title = anchors.get("title", "")
|
||||
url = anchors.get("url", "")
|
||||
|
||||
if vc >= 3 and ("搜索" in title or "/search/" in url):
|
||||
return {"state": "SEARCH_PLAYER", "confidence": 0.7, "reason": "video≥3且来自搜索"}
|
||||
if vc >= 3:
|
||||
return {"state": "PLAYER", "confidence": 0.8, "reason": "video≥3"}
|
||||
if "搜索" in title and "精选" not in title:
|
||||
return {"state": "SEARCH", "confidence": 0.8, "reason": "标题含搜索"}
|
||||
if "精选" in title:
|
||||
return {"state": "HOME", "confidence": 0.7, "reason": "标题含精选"}
|
||||
return {"state": "UNKNOWN", "confidence": 0.3, "reason": "无法判断"}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
comment_corpus.py — 评论语料库引擎
|
||||
|
||||
从 comment_corpus.yaml 加载分类评论,按权重随机选取。
|
||||
支持模板填充、分类筛选、手动增加。
|
||||
"""
|
||||
import random
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
CORPUS_PATH = Path(__file__).parent.parent.parent / "config" / "comment_corpus.yaml"
|
||||
|
||||
|
||||
def load_corpus() -> dict:
|
||||
"""加载语料库 YAML"""
|
||||
with open(CORPUS_PATH, encoding='utf-8') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def get_comment(keyword: str = "", category: str = None) -> str:
|
||||
"""获取一条随机评论
|
||||
|
||||
Args:
|
||||
keyword: 当前视频关键词,用于模板替换
|
||||
category: 指定分类(None=从所有启用分类中选)
|
||||
|
||||
Returns:
|
||||
评论文本
|
||||
"""
|
||||
corpus = load_corpus()
|
||||
cfg = corpus.get("config", {})
|
||||
enabled = cfg.get("enabled_categories", [])
|
||||
disabled = cfg.get("disabled_categories", [])
|
||||
prefer_templates = cfg.get("prefer_templates", True)
|
||||
|
||||
# 筛选可用分类
|
||||
categories = []
|
||||
for cat_key, cat_data in corpus.get("categories", {}).items():
|
||||
weight = cat_data.get("weight", 0)
|
||||
if weight <= 0:
|
||||
continue
|
||||
if enabled and cat_key not in enabled:
|
||||
continue
|
||||
if cat_key in disabled:
|
||||
continue
|
||||
if category and cat_key != category:
|
||||
continue
|
||||
categories.append((cat_key, cat_data, weight))
|
||||
|
||||
if not categories:
|
||||
return "好内容" # 兜底
|
||||
|
||||
# 按权重选分类
|
||||
keys, datas, weights = zip(*categories)
|
||||
chosen_key = random.choices(keys, weights=weights, k=1)[0]
|
||||
chosen_data = datas[keys.index(chosen_key)]
|
||||
|
||||
# 从选中的分类中选一条评论
|
||||
comments = chosen_data.get("comments", [])
|
||||
templates = chosen_data.get("templates", [])
|
||||
|
||||
# 优先用模板
|
||||
if prefer_templates and templates and random.random() < 0.4:
|
||||
template = random.choice(templates)
|
||||
return template.format(keyword=keyword or "这个")
|
||||
elif comments:
|
||||
return random.choice(comments)
|
||||
elif templates:
|
||||
template = random.choice(templates)
|
||||
return template.format(keyword=keyword or "这个")
|
||||
return "👍"
|
||||
|
||||
|
||||
def add_comment(category: str, comment: str):
|
||||
"""手动增加一条评论到语料库"""
|
||||
with open(CORPUS_PATH, encoding='utf-8') as f:
|
||||
corpus = yaml.safe_load(f)
|
||||
|
||||
cat = corpus.get("categories", {}).get(category)
|
||||
if not cat:
|
||||
print(f"❌ 分类 '{category}' 不存在")
|
||||
return
|
||||
|
||||
if "comments" not in cat:
|
||||
cat["comments"] = []
|
||||
cat["comments"].append(comment)
|
||||
|
||||
with open(CORPUS_PATH, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(corpus, f, allow_unicode=True, indent=2, sort_keys=False)
|
||||
print(f"✅ 已添加评论到 [{category}]: {comment}")
|
||||
|
||||
|
||||
def list_categories() -> str:
|
||||
"""列出所有可用分类"""
|
||||
corpus = load_corpus()
|
||||
lines = ["📋 语料库分类:"]
|
||||
for key, data in corpus.get("categories", {}).items():
|
||||
w = data.get("weight", 0)
|
||||
status = "✅" if w > 0 else "⛔"
|
||||
n = len(data.get("comments", [])) + len(data.get("templates", []))
|
||||
lines.append(f" {status} {key:20s} (权重{w}, {n}条) - {data.get('label','')}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(list_categories())
|
||||
print()
|
||||
for _ in range(5):
|
||||
print(f" → {get_comment('编程')}")
|
||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
||||
-- anchor.db 数据库设计 v1.0
|
||||
-- 锚点规则 + 原子操作 + 检测日志 + 校准
|
||||
|
||||
-- ── 1. 锚点规则表 ──
|
||||
-- 每个状态由多条规则组成(同 state + logic_group = AND,不同 group = OR)
|
||||
CREATE TABLE anchor_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state_name TEXT NOT NULL, -- HOME / PLAYER / SEARCH / DETAIL / POPUP
|
||||
field TEXT NOT NULL, -- 检测字段: video_count, title, video_playing, card_count, url, has_search_input
|
||||
operator TEXT NOT NULL, -- eq / neq / gt / gte / lt / lte / contains / not_contains / regex
|
||||
value TEXT NOT NULL, -- 目标值(统一存字符串)
|
||||
logic_group INTEGER DEFAULT 0, -- 0=AND组, 同组内AND, 不同组OR
|
||||
active INTEGER DEFAULT 1, -- 1=启用, 0=停用
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- ── 2. 原子操作表 ──
|
||||
-- 每个原子操作 = 一个操作标识 + 执行方式 + 验证规则
|
||||
CREATE TABLE atomic_ops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
op_name TEXT NOT NULL UNIQUE, -- click_card, swipe_down, like, search, go_home, dismiss_popup
|
||||
description TEXT, -- 操作描述
|
||||
action_type TEXT DEFAULT 'locator', -- locator / keyboard / evaluate / mouse
|
||||
target TEXT, -- CSS选择器 / 按键名 / JS代码
|
||||
verify_rule TEXT, -- 验证方式: video_src_change / video_count_gt / title_change
|
||||
retry_count INTEGER DEFAULT 3,
|
||||
fallback_op TEXT, -- 失败时降级的操作名
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- ── 3. 状态迁移表 ──
|
||||
-- 定义合法的状态转换(状态机)
|
||||
CREATE TABLE state_transitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_state TEXT NOT NULL, -- HOME, PLAYER, SEARCH
|
||||
to_state TEXT NOT NULL, -- PLAYER, SEARCH, HOME
|
||||
via_op TEXT NOT NULL, -- click_card, swipe_down, search, go_home
|
||||
UNIQUE(from_state, to_state, via_op)
|
||||
);
|
||||
|
||||
-- ── 4. 检测日志表 ──
|
||||
-- 每次锚点检测的记录,用于成功率追踪
|
||||
CREATE TABLE detection_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity TEXT, -- douyin_01, douyin_02
|
||||
expected_state TEXT, -- 操作前预期的状态
|
||||
detected_state TEXT, -- 实际检测到的状态
|
||||
match INTEGER, -- 1=匹配, 0=不匹配
|
||||
anchor_snapshot TEXT, -- 锚点数据快照(JSON)
|
||||
screenshot_path TEXT, -- 截图路径
|
||||
timestamp TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE INDEX idx_detection_logs_match ON detection_logs(match, timestamp);
|
||||
|
||||
-- ── 5. 校准候选表 ──
|
||||
-- AI或自动校准产生的新规则,对比验证后决定是否采纳
|
||||
CREATE TABLE calibration_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state_name TEXT, -- 哪个状态的规则
|
||||
source TEXT, -- ai / manual / auto_recovery
|
||||
old_rules TEXT, -- 旧规则JSON
|
||||
new_rules TEXT, -- 建议的新规则JSON
|
||||
test_count INTEGER DEFAULT 0, -- 测试次数
|
||||
test_pass INTEGER DEFAULT 0, -- 测试通过次数
|
||||
status TEXT DEFAULT 'pending', -- pending / accepted / rejected / testing
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- ── 初始数据:基于2026-05-06手动采集的锚点 ──
|
||||
|
||||
-- HOME 状态规则
|
||||
INSERT INTO anchor_rules (state_name, field, operator, value, logic_group) VALUES
|
||||
('HOME', 'video_count', 'eq', '1', 0),
|
||||
('HOME', 'title', 'contains', '抖音精选', 0);
|
||||
|
||||
-- PLAYER 状态规则(全屏播放)
|
||||
INSERT INTO anchor_rules (state_name, field, operator, value, logic_group) VALUES
|
||||
('PLAYER', 'video_count', 'gte', '3', 0),
|
||||
('PLAYER', 'video_playing', 'eq', 'true', 0);
|
||||
|
||||
-- SEARCH 状态规则
|
||||
INSERT INTO anchor_rules (state_name, field, operator, value, logic_group) VALUES
|
||||
('SEARCH', 'title', 'contains', '搜索', 0),
|
||||
('SEARCH', 'title', 'not_contains', '精选', 0);
|
||||
|
||||
-- SEARCH_PLAYER(从搜索结果进入的播放页)
|
||||
INSERT INTO anchor_rules (state_name, field, operator, value, logic_group) VALUES
|
||||
('SEARCH_PLAYER', 'video_count', 'gte', '3', 0),
|
||||
('SEARCH_PLAYER', 'video_playing', 'eq', 'true', 0),
|
||||
('SEARCH_PLAYER', 'url', 'contains', '/search/', 0);
|
||||
|
||||
-- 原子操作
|
||||
INSERT INTO atomic_ops (op_name, description, action_type, target, verify_rule, retry_count) VALUES
|
||||
('click_card', '点击首页视频卡片', 'locator', '.discover-video-card-item', 'video_count_gte_3', 3),
|
||||
('swipe_down', '下滑切换视频', 'keyboard', 'ArrowDown', 'video_src_change', 2),
|
||||
('like', '点赞当前视频', 'locator', '[data-e2e="like-count"]', 'like_count_change', 1),
|
||||
('search', '搜索关键词', 'evaluate', 'search_input', 'title_contains_搜索', 2),
|
||||
('go_home', '回到首页', 'locator', 'https://www.douyin.com/', 'video_count_eq_1', 2),
|
||||
('dismiss_popup','关闭弹窗', 'locator', 'button:text-is("我知道了")','popup_gone', 2);
|
||||
|
||||
-- 状态迁移
|
||||
INSERT INTO state_transitions (from_state, to_state, via_op) VALUES
|
||||
('HOME', 'PLAYER', 'click_card'),
|
||||
('PLAYER', 'PLAYER', 'swipe_down'),
|
||||
('PLAYER', 'HOME', 'go_home'),
|
||||
('HOME', 'SEARCH', 'search'),
|
||||
('SEARCH', 'SEARCH_PLAYER', 'click_card');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
ui_layout.py — 抖音评论区 UI 布局参数 (v1.0.0)
|
||||
|
||||
设计原则(2026-05-09 基于真人操作反馈):
|
||||
- 不硬编码绝对坐标
|
||||
- 所有坐标相对于浏览器窗口右边缘、底边缘计算
|
||||
- 布局参数可配置(支持 per-account 覆盖)
|
||||
- 分为:DOM检测优先 → 坐标落位保底 → 状态验证确认
|
||||
|
||||
抖音评论区布局(KeyX 打开后):
|
||||
┌──────────────────────────────────────┐
|
||||
│ 评论面板 (固定宽度 ~500-600px) │
|
||||
│ │
|
||||
│ 评论列表区域 │
|
||||
│ │
|
||||
│ ┌──────────────────┬──┬──┬──────┐ │
|
||||
│ │ 输入框区域 │😊│@│ 发送 │ │
|
||||
│ │ 留下你的精彩评论吧 │ │ │ │ │
|
||||
│ └──────────────────┴──┴──┴──────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
关键:
|
||||
- 输入框在评论面板底部靠左
|
||||
- Emoji/ Mention/ Send 按钮在右侧
|
||||
- 输入框宽度 = 评论面板宽度 - 右侧按钮区(~130px)
|
||||
- 点击位置应落在输入框中间偏左区域
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
# ── 默认布局参数(基于 702×783 窗口测量) ─────────────────────
|
||||
|
||||
DEFAULT_LAYOUT = {
|
||||
# 评论面板布局(相对于窗口左边缘)
|
||||
"comment_panel": {
|
||||
"width_ratio": 0.85, # 面板占窗口宽度的比例
|
||||
"left_offset": 0, # 面板距离左边缘(像素固定值)
|
||||
},
|
||||
|
||||
# 输入框位置(相对于窗口右边缘,因为按钮在右侧)
|
||||
"input_box": {
|
||||
# 从右边缘往左偏移量(校准值 2026-05-10)
|
||||
"from_right_margin": 223, # 702-479=223(实测 activeElement 中心)
|
||||
# 从上边缘往下偏移量
|
||||
"from_top_offset": "auto", # "auto" = 动态检测,fallback 使用下面固定值
|
||||
"from_top_fallback": 687, # 实测 activeElement Y 中心
|
||||
"height": 36, # 输入框高度
|
||||
},
|
||||
|
||||
# 按钮区域(从右边缘往左)
|
||||
"buttons": {
|
||||
"send_width": 60, # 发送按钮宽度
|
||||
"icon_width": 30, # emoji/@ 图标宽度
|
||||
"button_count": 3, # 按钮数量 (emoji + @ + 发送)
|
||||
},
|
||||
|
||||
# 发送键
|
||||
"send_key": {
|
||||
"method": "dom", # dom/alt_enter。DOM 优先找"发送"按钮
|
||||
"alt_enter_fallback": True,
|
||||
},
|
||||
|
||||
# 验证码弹窗
|
||||
"verify_code": {
|
||||
"input_placeholder": "验证码",
|
||||
"confirm_button_texts": ["确认", "提交", "验证"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_layout(account_config: Optional[Dict] = None) -> dict:
|
||||
"""获取 UI 布局参数(支持 per-account 覆盖)
|
||||
|
||||
Args:
|
||||
account_config: accounts.yaml 中的账号配置,可包含 ui_layout 覆盖
|
||||
|
||||
Returns:
|
||||
合并后的布局参数字典
|
||||
"""
|
||||
layout = dict(DEFAULT_LAYOUT)
|
||||
if account_config and "ui_layout" in account_config:
|
||||
# 深度合并
|
||||
_deep_merge(layout, account_config["ui_layout"])
|
||||
return layout
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict):
|
||||
"""递归合并字典(不覆盖子键未指定的部分)"""
|
||||
for key, val in override.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
||||
_deep_merge(base[key], val)
|
||||
else:
|
||||
base[key] = val
|
||||
|
||||
|
||||
def calc_input_position(window_width: int, window_height: int,
|
||||
layout: Optional[Dict] = None) -> Tuple[int, int]:
|
||||
"""计算评论输入框的目标点击坐标
|
||||
|
||||
Args:
|
||||
window_width: 浏览器窗口宽度 (如 702)
|
||||
window_height: 浏览器窗口高度 (如 783)
|
||||
|
||||
Returns:
|
||||
(x, y) — 基于 viewport 的绝对坐标
|
||||
"""
|
||||
if layout is None:
|
||||
layout = DEFAULT_LAYOUT
|
||||
|
||||
ib = layout["input_box"]
|
||||
|
||||
# X坐标:从窗口右边缘往左偏移
|
||||
x = window_width - ib["from_right_margin"]
|
||||
|
||||
# Y坐标
|
||||
y = ib.get("from_top_fallback", 720)
|
||||
|
||||
return (x, y)
|
||||
|
||||
|
||||
def calc_panel_rect(window_width: int, window_height: int,
|
||||
layout: Optional[Dict] = None) -> Tuple[int, int, int, int]:
|
||||
"""计算评论面板的包围矩形(用于 debug / 视觉确认)
|
||||
|
||||
Returns:
|
||||
(left, top, width, height)
|
||||
"""
|
||||
if layout is None:
|
||||
layout = DEFAULT_LAYOUT
|
||||
panel = layout["comment_panel"]
|
||||
pw = int(window_width * panel["width_ratio"])
|
||||
return (panel["left_offset"], 0, pw, window_height)
|
||||
|
||||
|
||||
def format_layout_summary(layout: Optional[Dict] = None) -> str:
|
||||
"""生成布局参数的摘要文本(用于日志)"""
|
||||
if layout is None:
|
||||
layout = DEFAULT_LAYOUT
|
||||
ib = layout["input_box"]
|
||||
btn = layout["buttons"]
|
||||
return (
|
||||
f"UI布局: "
|
||||
f"输入框右边缘偏移={ib['from_right_margin']}px, "
|
||||
f"Y坐标兜底={ib['from_top_fallback']}px, "
|
||||
f"按钮数={btn['button_count']}, "
|
||||
f"发送方式={layout['send_key']['method']}"
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
# XHS 小红书原子操作手册
|
||||
|
||||
> 版本: v2 | 更新: 2026-05-24 | 基于 Playwright + Camoufox 实测验证
|
||||
|
||||
---
|
||||
|
||||
## 零、登录检测(关键!之前犯过错)
|
||||
|
||||
### 核心原则
|
||||
- XHS **没有右上角用户头像**,不要用头像/用户菜单判断登录态
|
||||
- XHS 未登录也能看到瀑布流卡片,有卡片不等于已登录
|
||||
|
||||
### 正确检测方法
|
||||
1. 检查页面是否有 `position:fixed` 且覆盖 >50% viewport 的大覆盖层(登录弹窗)
|
||||
2. 检查页面文字是否包含 "请打开小红书App扫码"(验证码页)
|
||||
3. 无覆盖层 + 有卡片(`section.note-item`) = 已登录
|
||||
|
||||
### 实现(已在 v2 测试脚本中验证)
|
||||
|
||||
```javascript
|
||||
// 登录检测 JS
|
||||
const overlays = [...document.querySelectorAll('div')].filter(el => {
|
||||
const s = window.getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return s.position === 'fixed'
|
||||
&& r.width >= window.innerWidth * 0.5
|
||||
&& r.height >= window.innerHeight * 0.5;
|
||||
});
|
||||
const needsQr = document.body.innerText.includes('请打开小红书App扫码');
|
||||
const blocked = needsQr || overlays.length > 0;
|
||||
```
|
||||
|
||||
### 登录后确认
|
||||
- 覆盖层消失
|
||||
- 瀑布流卡片正常加载
|
||||
- (可选)点击卡片能进入 `note-detail-mask` 模式
|
||||
|
||||
## 一、基础定位规则
|
||||
|
||||
### 1.1 笔记卡片定位
|
||||
|
||||
| 元素 | 选择器 | 说明 |
|
||||
|------|--------|------|
|
||||
| 卡片容器 | `section.note-item` | 取 bounding rect 用此元素 |
|
||||
| 卡片链接 | `section.note-item a` | 取 href 用此元素(⚠️ bounding rect = 0) |
|
||||
| 卡片标题 | `.title, [class*=title]` | 嵌套在 section 内的标题 |
|
||||
|
||||
**关键坑**:`section.note-item a` 的 `getBoundingClientRect()` 返回 `(0,0,0,0)`。必须先取父级 `section.note-item` 的 rect,再从中取 `a` 标签提取 `href`。
|
||||
|
||||
### 1.2 笔记详情定位
|
||||
|
||||
| 元素 | 选择器 | 说明 |
|
||||
|------|--------|------|
|
||||
| 详情遮罩 | `.note-detail-mask` | SPA 详情页容器(fixed,全屏覆盖) |
|
||||
| 底部互动栏 | `.interactions` | 点赞/收藏/评论按钮的容器 |
|
||||
| 点赞按钮 | `span.like-wrapper` | ✅ 实测可用,x > 视口 30% |
|
||||
| 收藏按钮 | `span.collect-wrapper` | ✅ 实测可用,x > 视口 30% |
|
||||
| 评论入口 | `.comment-btn` 等 | 底部栏评论按钮 |
|
||||
|
||||
**关键规则**:页面中有大量 `like-wrapper`(评论区每条回复都有)。**区分底部栏 vs 评论区按钮的特征**:底部栏按钮的 `x` > 视口宽度 × 30%(约 210px 以上),评论区按钮在左侧(x ≈ 60~100px)。
|
||||
|
||||
---
|
||||
|
||||
## 二、锚点检测系统
|
||||
|
||||
### 2.1 `is_note_detail_mode()` — 笔记详情 vs 图片查看器
|
||||
|
||||
**三要素综合判断**:
|
||||
1. **has_mask** — 存在 `.note-detail-mask` 元素
|
||||
2. **has_interact_bar** — 底部互动栏按钮可见(like-wrapper 的 x > viewW×0.3)
|
||||
3. **has_note_url** — URL 匹配 `/explore/{20位以上十六进制ID}`
|
||||
|
||||
```
|
||||
is_detail = has_mask && has_interact_bar && has_note_url
|
||||
```
|
||||
|
||||
**四种状态**:
|
||||
| 状态 | 判断 | 说明 |
|
||||
|------|------|------|
|
||||
| 笔记详情模式 | mask + 互动栏 + URL | ✅ 可以执行互动 |
|
||||
| 疑似图片查看器 | 有 mask 但无互动栏 | ❌ 需退出或重新导航 |
|
||||
| 不在详情页 | 无 mask | ❌ 回到首页 |
|
||||
| 未知状态 | 其他 | ❌ 截图+兜底 |
|
||||
|
||||
### 2.2 其他锚点
|
||||
|
||||
| 锚点类型 | 检测方式 |
|
||||
|----------|----------|
|
||||
| 首页已加载 | `section.note-item` 存在 |
|
||||
| 笔记详情锚点 | `.note-detail-mask` 存在 |
|
||||
| 评论区打开 | `.comment-section` 存在 |
|
||||
| 搜索页 | `.search-result` 存在 |
|
||||
| 页面加载完成 | `#app` 存在 |
|
||||
|
||||
---
|
||||
|
||||
## 三、原子操作(按流程顺序)
|
||||
|
||||
### 3.1 导航 `goto_home()`
|
||||
|
||||
```
|
||||
前置状态: 浏览器刚启动(about:blank)
|
||||
操作: page.goto("https://www.xiaohongshu.com/explore")
|
||||
后置状态: URL = /explore, 页面渲染完成
|
||||
锚点: #app 存在
|
||||
超时: 20s → 30s fallback
|
||||
```
|
||||
|
||||
### 3.2 关闭登录弹窗 `dismiss_login_modal()`
|
||||
|
||||
```
|
||||
前置状态: 页面已加载,可能出现登录弹窗
|
||||
操作: JS: display:none 所有 modal/dialog/mask/position:fixed+zIndex>100
|
||||
后置状态: 弹窗不可见
|
||||
锚点: 无(可选)
|
||||
```
|
||||
|
||||
### 3.3 获取卡片列表 `get_note_cards()`
|
||||
|
||||
```
|
||||
前置状态: 首页已加载
|
||||
操作: JS: querySelectorAll('section.note-item') 取 index/href/rect
|
||||
后置状态: 返回 List[dict](不为空)
|
||||
返回: [{index, href, title, rect:{x,y,w,h}}]
|
||||
⚠️ 注意: 过滤 rect.w < 10 的无效卡片
|
||||
```
|
||||
|
||||
### 3.4 点击卡片进入详情 `click_note_card()`
|
||||
|
||||
```
|
||||
前置状态: 首页已加载,有卡片数据
|
||||
操作:
|
||||
1. 获取 section.note-item 的 bounding rect
|
||||
2. scrollIntoView({block: 'center'})
|
||||
3. 重新获取 rect(滚动后坐标变化)
|
||||
4. page.mouse.move(cx, cy, steps=5~12) ← Playwright 原生鼠标
|
||||
5. 单次 page.mouse.click(cx, cy) ← ⚡ 非双击!
|
||||
6. 等待 3~5s(SPA 过渡)
|
||||
7. 锚点: is_note_detail_mode()
|
||||
- ✅ → 返回 URL
|
||||
- ❌ → fallback page.goto(href)
|
||||
后置状态: 笔记详情页
|
||||
锚点: is_note_detail_mode() = true
|
||||
⚠️ 注意:
|
||||
- 用 section.note-item 取 rect(不是 a 标签)
|
||||
- 单次单击(双击触发图片查看器)
|
||||
- 随机选卡片(非总点第一张)
|
||||
```
|
||||
|
||||
### 3.5 分析互动按钮 `analyze_interact_page()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页(is_note_detail_mode = true)
|
||||
操作: JS: 取 like-wrapper / collect-wrapper 位置和可见性
|
||||
返回: { like: {x,y,w,h,visible,isActive,cls,text},
|
||||
collect: {x,y,w,h,visible,isActive,cls,text} }
|
||||
⚠️ 注意: 用 x > viewW*0.3 过滤出底部栏按钮
|
||||
```
|
||||
|
||||
### 3.6 L 形鼠标移动 `mouse_move_l_shape()`
|
||||
|
||||
```
|
||||
前置状态: 任何(鼠标在当前位置)
|
||||
操作: 三段式 page.mouse.move():
|
||||
L1: (cx, cy) → (cx, safeY=100) — 垂直上移到安全区
|
||||
L2: (cx, safeY) → (targetX, safeY) — 水平移动到目标上方
|
||||
L3: (targetX, safeY) → (targetX, targetY) — 垂直下移到目标
|
||||
每段 steps=3~4,总 steps≈8~12
|
||||
后置状态: 鼠标在目标坐标
|
||||
⚠️ 注意:
|
||||
- 安全区 Y=100 在输入框上方
|
||||
- 避免直线移动穿过评论区输入框
|
||||
```
|
||||
|
||||
### 3.7 点赞 `like()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页,底部栏可见
|
||||
操作:
|
||||
1. get_bottom_bar_buttons_js() → 取 like 按钮位置
|
||||
2. 如果 isActive → 跳过(已点赞)
|
||||
3. 如果不可见 → scrollIntoView({block:'center'})
|
||||
4. mouse_move_l_shape(like.x, like.y)
|
||||
5. page.mouse.click(like.x, like.y)
|
||||
6. 等待 1.5~2.5s
|
||||
7. 验证: isActive 变化 → ✅
|
||||
8. 失败则重试一次
|
||||
后置状态: 已点赞(class 含 like-active)
|
||||
锚点: like-wrapper.isActive = true
|
||||
⚠️ 注意:
|
||||
- 使用 page.mouse.click(非 element.click / JS dispatchEvent)
|
||||
- 必须先 scrollIntoView 确保按钮在视口内
|
||||
```
|
||||
|
||||
### 3.8 收藏 `collect()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页,底部栏可见
|
||||
操作:
|
||||
1. get_bottom_bar_buttons_js() → 取 collect 按钮位置
|
||||
2. 如果 isActive → 跳过(已收藏)
|
||||
3. 如果不可见 → scrollIntoView({block:'center'})
|
||||
4. mouse_move_l_shape(collect.x, collect.y)
|
||||
5. page.mouse.click(collect.x, collect.y)
|
||||
6. 等待 1.5~2.5s
|
||||
7. 验证: isActive 变化 或 计数增加 → ✅
|
||||
8. 失败则重试一次
|
||||
后置状态: 已收藏(class 含 active)
|
||||
锚点: collect-wrapper.isActive = true 或 text 计数 +1
|
||||
⚠️ 注意: 和点赞流程一致,共用 L 形路径
|
||||
```
|
||||
|
||||
### 3.9 拟人滚动 `scroll_feed_human()`
|
||||
|
||||
```
|
||||
前置状态: 首页/发现页
|
||||
操作:
|
||||
循环 N 次(默认 2~3 屏):
|
||||
page.mouse.wheel(0, dist=200~500) ← 鼠标滚轮
|
||||
随机停顿 1~3s(40% 概率)
|
||||
后置状态: 瀑布流已滚动
|
||||
⚠️ 注意:
|
||||
- 用 page.mouse.wheel(非 JS 滚动)
|
||||
- 间隔随机抖动(非固定间隔)
|
||||
- 不采用双重滚动(mouse.wheel + JS scrollBy)
|
||||
```
|
||||
|
||||
### 3.10 浏览笔记详情 `browse_note_detail()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页
|
||||
操作:
|
||||
- 视频笔记: 等待 video 播放,随机观看时长
|
||||
- 图文笔记: 分段 scrollBy(50~150px),每段停顿 1~2.5s
|
||||
后置状态: 笔记内容已浏览
|
||||
返回: 实际浏览秒数
|
||||
```
|
||||
|
||||
### 3.11 返回首页 `go_back_to_home()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页
|
||||
操作:
|
||||
方式1: page.go_back()
|
||||
方式2: page.goto(/explore)
|
||||
后置状态: URL = /explore
|
||||
锚点: section.note-item 存在
|
||||
```
|
||||
|
||||
### 3.12 评论输入 `comment()`
|
||||
|
||||
```
|
||||
前置状态: 笔记详情页,底部栏可见
|
||||
操作:
|
||||
1. find_comment_entry() → 取 collect 右侧第一个按钮
|
||||
- 排除 like-wrapper / collect-wrapper(按 class 过滤)
|
||||
- 按 x 坐标排序,取第 3 个元素
|
||||
2. L 形路径移动到评论入口 → 单击打开评论区
|
||||
3. 等待评论区渲染(2s)
|
||||
4. 定位输入框:
|
||||
- querySelectorAll('textarea, [contenteditable], input')
|
||||
- 排除 y < 150 的元素(顶部搜索栏)
|
||||
- 优先选 viewport 下半部分、高度 > 25px 的元素
|
||||
5. L 形路径移动到输入框 → 单击聚焦
|
||||
6. JS: inp.focus() 补充
|
||||
7. pbcopy(comment_text)
|
||||
8. page.keyboard.press("Meta+v")
|
||||
9. page.keyboard.press("Enter")
|
||||
10. 验证: 输入框.value/textContent 长度 = 0 → ✅ 已发送
|
||||
后置状态: 评论已发送
|
||||
锚点: 输入框已清空
|
||||
⚠️ 注意:
|
||||
- 输入框是 <p contenteditable="true">,不是 <textarea>
|
||||
- Meta+V 后 textContent 可能为空,跳过中间验证
|
||||
- 用 "输入框清空" 作为发送验证标准
|
||||
- 发送失败时可能有验证码弹窗
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、评论状态机
|
||||
|
||||
### 4.1 状态定义
|
||||
|
||||
```
|
||||
closed → panel_open → input_focused → text_entered → sent → verified
|
||||
```
|
||||
|
||||
### 4.2 各步骤
|
||||
|
||||
| 步骤 | 方法 | 操作 | 锚点 |
|
||||
|------|------|------|------|
|
||||
| 打开评论区 | `open_panel()` | 点击 comment-entry 或滚动到评论区 | `.comment-section` 存在 |
|
||||
| 聚焦输入框 | `focus_input()` | click 输入框 或 JS focus() | state = input_focused |
|
||||
| 输入文本 | `enter_text()` | pbcopy + Meta+V(系统级) | 输入框包含输入文本 |
|
||||
| 发送 | `send()` | Enter 或点击发送按钮 | 输入框清空 |
|
||||
| 验证 | `verify()` | 检查评论区是否出现评论 | 输入框清空 或 评论可见 |
|
||||
|
||||
### 4.3 关键规则
|
||||
|
||||
- **输入方式**:pbcopy(剪贴板)→ `Meta+V`(系统级粘贴),和抖音一致
|
||||
- ⚠️ XHS 输入框可能是 `contenteditable div` 或 `input`,pbcopy + Meta+V 对两种都有效
|
||||
**发送方式**:优先点击发送按钮,兜底 Enter 键
|
||||
**验证**:发送后检查输入框清空(比检查评论列表更可靠)
|
||||
**评论入口定位**:collect-wrapper 右侧第一个非 like/collect 元素
|
||||
|
||||
### 4.4 XHS 评论输入注意事项
|
||||
|
||||
- **评论入口按钮**:位于底部互动栏最右侧(collect-wrapper 的右侧),可能显示评论计数
|
||||
- **输入框类型**:XHS 使用 `<p contenteditable="true">` 作为评论输入框(不是 `<textarea>` 或 `<input>`)
|
||||
- **输入验证**:`value`/`textContent` 在 Meta+V 后可能为空,跳过输入验证,直接发送后检查清空状态
|
||||
- **搜索框干扰**:顶部搜索栏也是 `<input>`,必须用 `y < 150` 排除
|
||||
- **发送验证**:输入框清空 = 发送成功(比评论区出现评论更可靠)
|
||||
|
||||
---
|
||||
|
||||
## 五、交互引擎选择
|
||||
|
||||
| 操作类型 | 推荐方式 | 说明 |
|
||||
|----------|----------|------|
|
||||
| 点击卡片 | `page.mouse.click()` | 单次单击 ✅ 实测最佳 |
|
||||
| 点赞/收藏 | `page.mouse.click()` | 单次单击 ✅ 实测最佳 |
|
||||
| 搜索框输入 | `search_input.fill()` | 常规文本输入无防检测 |
|
||||
| 评论输入 | `pbcopy + Meta+V` | contenteditable div 专用 |
|
||||
| 键盘事件 | `page.keyboard.press()` | Enter / Meta+V / Escape |
|
||||
| 滚动 | `page.mouse.wheel()` | 拟人操作,优于 JS 滚动 |
|
||||
|
||||
**为什么不用 element.click() / JS dispatchEvent:**
|
||||
- XHS SPA 可能屏蔽 JS 事件
|
||||
- element.click() 不产生真实的 mousedown/mouseup/mousemove 事件序列
|
||||
- page.mouse API 产生完整的事件序列,和真人操作一致
|
||||
|
||||
---
|
||||
|
||||
## 六、已知坑 & 解决方案
|
||||
|
||||
| 问题 | 现象 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| a 标签 zero-rect | card.querySelector('a').getBoundingClientRect() = (0,0,0,0) | 用父级 section.note-item 取 rect |
|
||||
| 双击触发图片查看器 | 第 2 次 click 打开全屏图片 | 单次单击 |
|
||||
| like-wrapper 过多 | 页面中有大量 like-wrapper(评论区每条回复都有) | 用 x > viewW×0.3 过滤 |
|
||||
| 鼠标路径穿过输入框 | 直线移动会经过评论区输入框 | L 形路径 |
|
||||
| SPA 过渡延迟 | click 后页面需要时间加载 | 等待 3~5s |
|
||||
| 图片查看器状态 | 没有互动栏、URL 可能不变 | is_note_detail_mode() 三要素判断 |
|
||||
| 评论按钮误判 | like-wrapper 文本是数字,被误判为评论按钮 | 取 collect 右侧第一个非 like/collect 元素 |
|
||||
| 输入框选中搜索栏 | 顶部搜索栏 input 优先级高 | 排除 y < 150 的元素 |
|
||||
| nohup 模式 EOF | input() 在后台无 TTY | 替换为 asyncio.sleep() |
|
||||
| 卡片 href 指向 profile | 部分卡片链接到用户主页 | 用正则 /\/explore\/[a-f0-9]{20,}/ 过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 七、目录结构
|
||||
|
||||
```
|
||||
ops/xhs/
|
||||
├── __init__.py — 模块导出
|
||||
├── ATOMIC_OPS.md — 本文档
|
||||
├── selectors.py — DOM 选择器 + JS 辅助函数
|
||||
├── browse.py — 浏览类操作
|
||||
└── interact.py — 交互类操作(点赞/收藏/关注/评论)
|
||||
```
|
||||
|
||||
## 八、自动化运行
|
||||
|
||||
### CLI 入口
|
||||
```bash
|
||||
# 单账号
|
||||
matrix nurture run -a xhs_01 -r 10
|
||||
|
||||
# 三账号并行
|
||||
matrix nurture run -a xhs_01 -a xhs_02 -a xhs_03 -r 10
|
||||
```
|
||||
|
||||
### 主控脚本
|
||||
`~/workbuddy-agent-os/agent-sync/05_tools/07_matrix/scripts/nurture_master.sh`
|
||||
|
||||
执行流程: 抖音 1h → 小红书 1h → 完成
|
||||
|
||||
### 定时任务
|
||||
WorkBuddy 自动化 "三账号每日养号" 每日 10:00 触发,调用 nurture_master.sh
|
||||
|
||||
### 框架入口
|
||||
`matrix_modules/nurture/runner.py` 中的 `nurture_xhs_loop()` 函数已封装完整循环逻辑:
|
||||
- 瀑布流浏览 → 点击卡片 → 浏览内容 → 随机互动 → 返回首页 → 搜索发现
|
||||
- 互动概率: 20% 点赞, 12.5% 收藏, 2.5% 关注
|
||||
- 每 3 轮评论一次,每 2 轮搜索一次
|
||||
- 连续 3 轮失败自动暂停 + 截图
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
小红书操作模块
|
||||
"""
|
||||
from . import browse, interact, selectors
|
||||
|
||||
__all__ = ["browse", "interact", "selectors"]
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
小红书 — 浏览类操作
|
||||
基于 2026-05-20 DOM 分析结果
|
||||
|
||||
核心差异(vs 抖音):
|
||||
- 双列瀑布流 → 需要定位具体卡片
|
||||
- 笔记详情页 → 图文为主,可能有视频
|
||||
- 搜索发现 → 顶部搜索框
|
||||
"""
|
||||
import random
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
from .selectors import (
|
||||
NOTE_CARD, NOTE_CARD_COVER, NOTE_CARD_IMG,
|
||||
SEARCH_INPUT, SEARCH_BUTTON,
|
||||
ANCHORS, get_note_links_js, get_note_cards_js, dismiss_login_modal_js,
|
||||
find_refresh_button_js, find_qr_wall_back_button_js
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 首页浏览
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def goto_home(page):
|
||||
"""回到小红书首页/发现页(锚点等待 + 超时兜底)"""
|
||||
try:
|
||||
await page.goto("https://www.xiaohongshu.com/explore", timeout=20000, wait_until="commit")
|
||||
# 锚点等待: 等 #app 出现(SPA 水合)
|
||||
try:
|
||||
await page.wait_for_selector("#app", timeout=8000)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(2) # 额外等待渲染稳定
|
||||
except Exception:
|
||||
# 兜底: 直接导航,不抛异常
|
||||
await page.goto("https://www.xiaohongshu.com/explore", timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(5)
|
||||
return "home"
|
||||
|
||||
|
||||
async def dismiss_login_modal(page):
|
||||
"""关闭登录弹窗(未登录状态下浏览需要)"""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
page.evaluate(dismiss_login_modal_js()),
|
||||
timeout=8
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_note_cards(page) -> List[dict]:
|
||||
"""获取当前页面所有笔记卡片信息"""
|
||||
try:
|
||||
cards = await asyncio.wait_for(
|
||||
page.evaluate(get_note_cards_js()),
|
||||
timeout=10
|
||||
)
|
||||
return [c for c in cards if c.get("href")]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def click_note_card(page, index: int = None, max_retries: int = 3) -> Optional[str]:
|
||||
"""
|
||||
点击笔记卡片进入详情页
|
||||
|
||||
Args:
|
||||
index: 卡片索引(0-based),None 时随机选择
|
||||
max_retries: 误触作者主页时最大重试次数
|
||||
|
||||
Returns:
|
||||
笔记 URL,失败返回 None
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
cards = await get_note_cards(page)
|
||||
if not cards:
|
||||
return None
|
||||
|
||||
# 随机选择卡片(避免总是点第一个)
|
||||
if index is None:
|
||||
# 优先选前 4 个,避免滚动到底部
|
||||
max_idx = min(3, len(cards) - 1)
|
||||
idx = random.randint(0, max_idx)
|
||||
else:
|
||||
idx = index
|
||||
|
||||
if idx >= len(cards):
|
||||
idx = random.randint(0, len(cards) - 1)
|
||||
|
||||
card = cards[idx]
|
||||
href = card.get("href")
|
||||
if not href:
|
||||
return None
|
||||
|
||||
# 记录点击前 tab 数量
|
||||
context = page.context
|
||||
tabs_before = len(context.pages)
|
||||
original_page = page
|
||||
|
||||
# 使用 Playwright 点击(模拟真人)
|
||||
try:
|
||||
# 先尝试通过索引定位元素
|
||||
cards_els = await page.query_selector_all(NOTE_CARD)
|
||||
if idx < len(cards_els):
|
||||
await cards_els[idx].click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 检测是否误触作者主页(新标签页)
|
||||
tabs_after = len(context.pages)
|
||||
if tabs_after > tabs_before:
|
||||
# 新 tab 打开了作者主页 — 关闭新 tab,回到原 tab
|
||||
new_tab = context.pages[-1]
|
||||
new_url = new_tab.url
|
||||
await new_tab.close()
|
||||
print(f" [browse] 误触作者主页(新标签页已关闭): {new_url[:50]}")
|
||||
# 切回原 tab
|
||||
page = original_page
|
||||
if attempt < max_retries - 1:
|
||||
print(f" [browse] 重试点击笔记 ({attempt + 2}/{max_retries})...")
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
|
||||
return page.url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# fallback: 直接导航到链接
|
||||
try:
|
||||
await page.goto(href, timeout=15000, wait_until="commit")
|
||||
await asyncio.sleep(2)
|
||||
return page.url
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def scroll_feed(page, distance: int = None):
|
||||
"""
|
||||
滚动瀑布流(单方式: 鼠标滚轮,避免双重滚动)
|
||||
|
||||
Args:
|
||||
distance: 滚动距离(px),None 时随机
|
||||
"""
|
||||
dist = distance or random.randint(300, 800)
|
||||
|
||||
# 鼠标滚轮(拟人)
|
||||
try:
|
||||
await page.mouse.wheel(0, dist)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.5))
|
||||
except Exception:
|
||||
# fallback: JS 滚动
|
||||
await page.evaluate(f"window.scrollBy(0, {dist})")
|
||||
await asyncio.sleep(random.uniform(0.5, 1.5))
|
||||
|
||||
return dist
|
||||
|
||||
|
||||
async def scroll_feed_human(page, screens: int = 2):
|
||||
"""
|
||||
拟人化滚动:分多次小滚动,中间随机停顿
|
||||
|
||||
Args:
|
||||
screens: 滚动几屏
|
||||
"""
|
||||
for _ in range(screens):
|
||||
# 每次滚动一小段
|
||||
dist = random.randint(200, 500)
|
||||
await scroll_feed(page, dist)
|
||||
|
||||
# 随机停顿(模拟阅读)
|
||||
if random.random() < 0.4:
|
||||
pause = random.uniform(1.0, 3.0)
|
||||
await asyncio.sleep(pause)
|
||||
|
||||
return screens
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 笔记详情页浏览
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def browse_note_detail(page, duration: float = None):
|
||||
"""
|
||||
在笔记详情页浏览内容(图文/视频自适应)
|
||||
|
||||
Args:
|
||||
duration: 浏览时长(秒),None 时随机 4~12 秒
|
||||
|
||||
Returns:
|
||||
实际浏览秒数
|
||||
"""
|
||||
watch = duration or random.uniform(4, 12)
|
||||
|
||||
# 检测是否是视频笔记
|
||||
has_video = await asyncio.wait_for(page.evaluate("""
|
||||
() => {
|
||||
const v = document.querySelector('video');
|
||||
if (!v) return false;
|
||||
const rect = v.getBoundingClientRect();
|
||||
return rect.width > 100 && rect.height > 100;
|
||||
}
|
||||
"""), timeout=8)
|
||||
|
||||
if has_video:
|
||||
# 视频笔记:看视频,不滚动
|
||||
# 等待视频加载
|
||||
await asyncio.sleep(random.uniform(1, 2))
|
||||
return watch
|
||||
else:
|
||||
# 图文笔记:缓慢滚动模拟阅读
|
||||
steps = int(watch / 2)
|
||||
for _ in range(steps):
|
||||
await page.evaluate(f"window.scrollBy(0, {random.randint(50, 150)})")
|
||||
await asyncio.sleep(random.uniform(1.0, 2.5))
|
||||
return watch
|
||||
|
||||
|
||||
async def go_back_to_home(page):
|
||||
"""从详情页返回首页"""
|
||||
try:
|
||||
# 方式1: 浏览器返回
|
||||
await page.go_back(timeout=10000, wait_until="commit")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 验证是否回到首页
|
||||
cards = await get_note_cards(page)
|
||||
if cards:
|
||||
return True
|
||||
|
||||
# 方式2: 直接导航
|
||||
await goto_home(page)
|
||||
return True
|
||||
except Exception:
|
||||
# 兜底: 直接导航
|
||||
await goto_home(page)
|
||||
return True
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 物理按钮操作(鼠标模拟)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def _l_shaped_click(page, target_x: int, target_y: int, desc: str = "") -> bool:
|
||||
"""
|
||||
L 型鼠标路径点击 — 模拟真人鼠标移动轨迹
|
||||
|
||||
轨迹: 起点(随机偏移) → 水平移动到目标 x → 垂直移动到目标 y → 点击
|
||||
"""
|
||||
import random
|
||||
try:
|
||||
viewport = page.viewport_size or {"width": 702, "height": 783}
|
||||
# 起点随机偏移
|
||||
start_x = target_x + random.randint(-150, 150)
|
||||
start_y = target_y + random.randint(-100, 50)
|
||||
# 限制在视口内
|
||||
start_x = max(50, min(start_x, viewport["width"] - 50))
|
||||
start_y = max(50, min(start_y, viewport["height"] - 50))
|
||||
|
||||
# 移动鼠标到起点
|
||||
await page.mouse.move(start_x, start_y)
|
||||
await asyncio.sleep(random.uniform(0.05, 0.15))
|
||||
|
||||
# L 型路径: 先水平移到目标 x
|
||||
steps = random.randint(5, 10)
|
||||
dx = (target_x - start_x) / steps
|
||||
for i in range(steps):
|
||||
await page.mouse.move(start_x + dx * (i + 1), start_y)
|
||||
await asyncio.sleep(random.uniform(0.01, 0.03))
|
||||
|
||||
# 再垂直移到目标 y
|
||||
steps_v = random.randint(3, 8)
|
||||
dy = (target_y - start_y) / steps_v
|
||||
for i in range(steps_v):
|
||||
await page.mouse.move(target_x, start_y + dy * (i + 1))
|
||||
await asyncio.sleep(random.uniform(0.01, 0.03))
|
||||
|
||||
# 微抖动(模拟手指不稳)
|
||||
jitter_x = target_x + random.randint(-2, 2)
|
||||
jitter_y = target_y + random.randint(-2, 2)
|
||||
await page.mouse.move(jitter_x, jitter_y)
|
||||
await asyncio.sleep(random.uniform(0.05, 0.1))
|
||||
|
||||
# 点击
|
||||
await page.mouse.click(jitter_x, jitter_y)
|
||||
return True
|
||||
except Exception as e:
|
||||
if desc:
|
||||
pass # 静默失败
|
||||
return False
|
||||
|
||||
|
||||
async def click_refresh_button(page) -> bool:
|
||||
"""
|
||||
点击小红书瀑布流页面右下角的刷新 FAB 按钮
|
||||
|
||||
Returns:
|
||||
True=点击成功, False=未找到按钮
|
||||
"""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
page.evaluate(find_refresh_button_js()),
|
||||
timeout=10
|
||||
)
|
||||
if not result or not result.get("found"):
|
||||
return False
|
||||
|
||||
x, y = result["x"], result["y"]
|
||||
return await _l_shaped_click(page, x, y, desc="refresh")
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def click_qr_wall_back_button(page) -> bool:
|
||||
"""
|
||||
检测 QR 检测墙并点击"返回首页"按钮
|
||||
|
||||
Returns:
|
||||
True=检测到QR墙并成功点击返回, False=未检测到QR墙或点击失败
|
||||
"""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
page.evaluate(find_qr_wall_back_button_js()),
|
||||
timeout=10
|
||||
)
|
||||
if not result or not result.get("found"):
|
||||
return False
|
||||
|
||||
x, y = result["x"], result["y"]
|
||||
btn_text = result.get("text", "")
|
||||
success = await _l_shaped_click(page, x, y, desc="qr_back")
|
||||
return success
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 搜索发现
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 小红书搜索关键词池(生活方式相关)
|
||||
SEARCH_KEYWORDS = [
|
||||
"穿搭", "美食", "旅行", "护肤", "家居", "健身",
|
||||
"书单", "电影", "咖啡", "探店", "拍照", "收纳",
|
||||
"早餐", "减脂", "美甲", "发型", "礼物", "周末",
|
||||
"自律", "效率", "省钱", "租房", "装修", "宠物",
|
||||
]
|
||||
|
||||
|
||||
async def search(page, keyword: str = None) -> str:
|
||||
"""
|
||||
搜索关键词
|
||||
|
||||
Args:
|
||||
keyword: 搜索词,None 时随机选择
|
||||
|
||||
Returns:
|
||||
实际搜索的关键词
|
||||
"""
|
||||
kw = keyword or random.choice(SEARCH_KEYWORDS)
|
||||
|
||||
try:
|
||||
# 定位搜索框
|
||||
search_input = await page.query_selector(SEARCH_INPUT)
|
||||
if search_input:
|
||||
await search_input.click()
|
||||
await asyncio.sleep(0.5)
|
||||
await search_input.fill(kw)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 按回车搜索
|
||||
await search_input.press("Enter")
|
||||
await asyncio.sleep(3)
|
||||
return kw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# fallback: URL 直接搜索
|
||||
try:
|
||||
encoded_kw = kw.replace(" ", "%20")
|
||||
await page.goto(
|
||||
f"https://www.xiaohongshu.com/search_result?keyword={encoded_kw}",
|
||||
timeout=15000, wait_until="commit"
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
return kw
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def click_search_result(page, index: int = None) -> Optional[str]:
|
||||
"""
|
||||
点击搜索结果中的笔记
|
||||
|
||||
Args:
|
||||
index: 结果索引,None 时随机
|
||||
|
||||
Returns:
|
||||
笔记 URL
|
||||
"""
|
||||
try:
|
||||
results = await page.query_selector_all(".note-item, [class*=search-result] a")
|
||||
if not results:
|
||||
return None
|
||||
|
||||
idx = index if index is not None else random.randint(0, min(3, len(results) - 1))
|
||||
if idx < len(results):
|
||||
await results[idx].click()
|
||||
await asyncio.sleep(2)
|
||||
return page.url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 锚点验证
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def check_anchor(page, anchor_type: str, timeout: float = 3.0) -> bool:
|
||||
"""检测页面锚点状态"""
|
||||
try:
|
||||
selector = ANCHORS.get(anchor_type)
|
||||
if not selector:
|
||||
return False
|
||||
|
||||
el = await page.query_selector(selector)
|
||||
return el is not None
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
小红书 — 交互类操作(点赞、收藏、关注、评论)
|
||||
基于 2026-05-20 DOM 分析 + 抖音踩坑经验
|
||||
|
||||
防护设计:
|
||||
- 每个操作独立 try/except,失败不崩流程
|
||||
- 操作后验证状态变化(防假点击)
|
||||
- 评论状态机: closed → panel_open → input_focused → text_entered → sent
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import asyncio
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from .selectors import (
|
||||
LIKE_BUTTON, COLLECT_BUTTON, FOLLOW_BUTTON,
|
||||
COMMENT_ENTRY, COMMENT_INPUT, COMMENT_SEND,
|
||||
ANCHORS
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 点赞
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def like(page) -> bool:
|
||||
"""
|
||||
点赞当前笔记(含状态验证)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
# 记录点击前的点赞状态
|
||||
was_liked = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.like-btn, [class*=like-btn], .interaction-like');
|
||||
if (!btn) return 'not_found';
|
||||
const cls = btn.className || btn.classList?.toString() || '';
|
||||
return cls.includes('active') || cls.includes('liked') || cls.includes('sel') ? 'liked' : 'not_liked';
|
||||
}
|
||||
""")
|
||||
if was_liked == 'liked':
|
||||
return True # 已经是点赞状态,无需重复操作
|
||||
|
||||
# 方式1: Playwright locator 点击
|
||||
btn = await page.query_selector(LIKE_BUTTON)
|
||||
if btn:
|
||||
await btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
# 验证状态变化
|
||||
now_liked = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.like-btn, [class*=like-btn], .interaction-like');
|
||||
if (!btn) return false;
|
||||
const cls = btn.className || btn.classList?.toString() || '';
|
||||
return cls.includes('active') || cls.includes('liked') || cls.includes('sel');
|
||||
}
|
||||
""")
|
||||
return bool(now_liked)
|
||||
|
||||
# 方式2: JS 直接点击
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.like-btn, [class*=like-btn], .interaction-like');
|
||||
if (btn) { btn.click(); return true; }
|
||||
// 兜底: 找包含点赞图标的按钮
|
||||
const allBtns = [...document.querySelectorAll('button, div[role=button]')];
|
||||
const likeBtn = allBtns.find(b => b.textContent.includes('赞') || b.innerHTML.includes('like'));
|
||||
if (likeBtn) { likeBtn.click(); return true; }
|
||||
return false;
|
||||
}
|
||||
""")
|
||||
return bool(result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 收藏
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def collect(page) -> bool:
|
||||
"""收藏当前笔记(含状态验证)"""
|
||||
try:
|
||||
# 检查是否已收藏
|
||||
was_collected = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.collect-btn, [class*=collect-btn], .interaction-collect');
|
||||
if (!btn) return 'not_found';
|
||||
const cls = btn.className || btn.classList?.toString() || '';
|
||||
return cls.includes('active') || cls.includes('sel') ? 'collected' : 'not_collected';
|
||||
}
|
||||
""")
|
||||
if was_collected == 'collected':
|
||||
return True
|
||||
|
||||
btn = await page.query_selector(COLLECT_BUTTON)
|
||||
if btn:
|
||||
await btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
# 验证状态变化
|
||||
now_collected = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.collect-btn, [class*=collect-btn], .interaction-collect');
|
||||
if (!btn) return false;
|
||||
const cls = btn.className || btn.classList?.toString() || '';
|
||||
return cls.includes('active') || cls.includes('sel');
|
||||
}
|
||||
""")
|
||||
return bool(now_collected)
|
||||
|
||||
# JS 兜底
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.collect-btn, [class*=collect-btn], .interaction-collect');
|
||||
if (btn) { btn.click(); return true; }
|
||||
const allBtns = [...document.querySelectorAll('button, div[role=button]')];
|
||||
const collectBtn = allBtns.find(b => b.textContent.includes('收藏') || b.innerHTML.includes('collect'));
|
||||
if (collectBtn) { collectBtn.click(); return true; }
|
||||
return false;
|
||||
}
|
||||
""")
|
||||
return bool(result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 关注
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def follow(page) -> bool:
|
||||
"""关注当前笔记作者(含状态验证)"""
|
||||
try:
|
||||
# 检查是否已关注
|
||||
was_followed = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.follow-btn, [class*=follow-btn], .interaction-follow');
|
||||
if (!btn) return 'not_found';
|
||||
const txt = btn.textContent || '';
|
||||
return txt.includes('已关注') ? 'followed' : 'not_followed';
|
||||
}
|
||||
""")
|
||||
if was_followed == 'followed':
|
||||
return True
|
||||
|
||||
btn = await page.query_selector(FOLLOW_BUTTON)
|
||||
if btn:
|
||||
await btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
# 验证关注成功
|
||||
now_followed = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.follow-btn, [class*=follow-btn], .interaction-follow');
|
||||
if (!btn) return false;
|
||||
const txt = btn.textContent || '';
|
||||
return txt.includes('已关注');
|
||||
}
|
||||
""")
|
||||
return bool(now_followed)
|
||||
|
||||
# JS 兜底
|
||||
result = await page.evaluate("""
|
||||
() => {
|
||||
const btn = document.querySelector('.follow-btn, [class*=follow-btn], .interaction-follow');
|
||||
if (btn) { btn.click(); return true; }
|
||||
const allBtns = [...document.querySelectorAll('button, div[role=button]')];
|
||||
const followBtn = allBtns.find(b => b.textContent.includes('关注') && !b.textContent.includes('已关注'));
|
||||
if (followBtn) { followBtn.click(); return true; }
|
||||
return false;
|
||||
}
|
||||
""")
|
||||
return bool(result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 评论 — 状态机驱动
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class CommentStateMachine:
|
||||
"""小红书评论状态机"""
|
||||
|
||||
STATES = ["closed", "panel_open", "input_focused", "text_entered", "sent", "verified"]
|
||||
|
||||
def __init__(self, page):
|
||||
self.page = page
|
||||
self.state = "closed"
|
||||
self.text = ""
|
||||
|
||||
async def open_panel(self) -> bool:
|
||||
"""Step 1: 打开评论区"""
|
||||
if self.state != "closed":
|
||||
return True
|
||||
|
||||
try:
|
||||
# 方式1: 点击评论入口
|
||||
entry = await self.page.query_selector(COMMENT_ENTRY)
|
||||
if entry:
|
||||
await entry.click()
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# 验证评论区是否打开
|
||||
has_comments = await self.page.evaluate("""
|
||||
() => document.querySelector('.comment-section, [class*=comment-section], [class*=comment-list]') !== null
|
||||
""")
|
||||
if has_comments:
|
||||
self.state = "panel_open"
|
||||
return True
|
||||
|
||||
# 方式2: 滚动到评论区
|
||||
await self.page.evaluate("""
|
||||
() => {
|
||||
const el = document.querySelector('.comment-section, [class*=comment-section]');
|
||||
if (el) el.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
""")
|
||||
await asyncio.sleep(1.5)
|
||||
self.state = "panel_open"
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def focus_input(self) -> bool:
|
||||
"""Step 2: 聚焦输入框"""
|
||||
if self.state == "input_focused":
|
||||
return True
|
||||
if self.state != "panel_open":
|
||||
return False
|
||||
|
||||
try:
|
||||
# 方式1: Playwright 点击输入框
|
||||
inp = await self.page.query_selector(COMMENT_INPUT)
|
||||
if inp:
|
||||
await inp.click()
|
||||
await asyncio.sleep(0.5)
|
||||
self.state = "input_focused"
|
||||
return True
|
||||
|
||||
# 方式2: JS 聚焦
|
||||
await self.page.evaluate("""
|
||||
() => {
|
||||
const inp = document.querySelector('.comment-input input, [contenteditable=true], textarea');
|
||||
if (inp) { inp.focus(); inp.click(); }
|
||||
}
|
||||
""")
|
||||
await asyncio.sleep(0.5)
|
||||
self.state = "input_focused"
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def enter_text(self, text: str) -> bool:
|
||||
"""Step 3: 输入评论文本(pbcopy + Cmd+V 系统级粘贴)"""
|
||||
if self.state == "text_entered":
|
||||
return True
|
||||
if self.state != "input_focused":
|
||||
return False
|
||||
|
||||
self.text = text
|
||||
|
||||
try:
|
||||
# 小红书输入框可能是 contenteditable div 或 input
|
||||
# 策略: pbcopy + 系统级键盘粘贴(和抖音一样)
|
||||
|
||||
# 1. 复制文本到剪贴板
|
||||
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 2. 确保输入框聚焦
|
||||
await self.page.evaluate("""
|
||||
() => {
|
||||
const inp = document.querySelector('.comment-input input, [contenteditable=true], textarea, .editor');
|
||||
if (inp) { inp.focus(); }
|
||||
}
|
||||
""")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 3. 系统级粘贴 (Cmd+V)
|
||||
await self.page.keyboard.press("Meta+v")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 4. 验证输入成功(json.dumps 防注入)
|
||||
needle = json.dumps(text[:10])
|
||||
has_text = await self.page.evaluate(f"""
|
||||
() => {{
|
||||
const inp = document.querySelector('.comment-input input, [contenteditable=true], textarea, .editor');
|
||||
if (!inp) return false;
|
||||
const val = inp.value || inp.textContent || inp.innerText || '';
|
||||
return val.includes({needle});
|
||||
}}
|
||||
""")
|
||||
|
||||
if has_text:
|
||||
self.state = "text_entered"
|
||||
return True
|
||||
|
||||
# 5. fallback: Playwright fill
|
||||
inp = await self.page.query_selector(COMMENT_INPUT)
|
||||
if inp:
|
||||
await inp.fill(text)
|
||||
await asyncio.sleep(0.3)
|
||||
self.state = "text_entered"
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send(self) -> bool:
|
||||
"""Step 4: 发送评论"""
|
||||
if self.state != "text_entered":
|
||||
return False
|
||||
|
||||
try:
|
||||
# 方式1: 点击发送按钮
|
||||
btn = await self.page.query_selector(COMMENT_SEND)
|
||||
if btn:
|
||||
await btn.click()
|
||||
await asyncio.sleep(1.5)
|
||||
self.state = "sent"
|
||||
return True
|
||||
|
||||
# 方式2: Enter 键发送
|
||||
await self.page.keyboard.press("Enter")
|
||||
await asyncio.sleep(1.5)
|
||||
self.state = "sent"
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def verify(self) -> bool:
|
||||
"""Step 5: 验证评论是否发送成功"""
|
||||
if self.state != "sent":
|
||||
return False
|
||||
|
||||
try:
|
||||
# 检查评论列表中是否出现我们的评论(json.dumps 防注入)
|
||||
needle = json.dumps(self.text[:8])
|
||||
has_comment = await self.page.evaluate(f"""
|
||||
() => {{
|
||||
const comments = [...document.querySelectorAll('.comment-item, [class*=comment-item]')];
|
||||
return comments.some(c => c.textContent.includes({needle}));
|
||||
}}
|
||||
""")
|
||||
|
||||
if has_comment:
|
||||
self.state = "verified"
|
||||
return True
|
||||
|
||||
# 兜底: 检查输入框是否清空(发送后通常会清空)
|
||||
inp = await self.page.query_selector(COMMENT_INPUT)
|
||||
if inp:
|
||||
tag = await inp.evaluate("el => el.tagName.toLowerCase()")
|
||||
if tag in ("input", "textarea"):
|
||||
val = await inp.input_value()
|
||||
else:
|
||||
val = await inp.evaluate("el => el.textContent || el.innerText || ''")
|
||||
if not val or len(val) < len(self.text) / 2:
|
||||
self.state = "verified"
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def comment(page, text: str) -> dict:
|
||||
"""
|
||||
评论当前笔记(完整状态机)
|
||||
|
||||
Args:
|
||||
text: 评论内容
|
||||
|
||||
Returns:
|
||||
{"success": bool, "state": str, "error": str}
|
||||
"""
|
||||
sm = CommentStateMachine(page)
|
||||
result = {"success": False, "state": "closed", "error": ""}
|
||||
|
||||
try:
|
||||
# Step 1: 打开评论区
|
||||
if not await sm.open_panel():
|
||||
result["error"] = "无法打开评论区"
|
||||
return result
|
||||
result["state"] = sm.state
|
||||
|
||||
# Step 2: 聚焦输入框
|
||||
if not await sm.focus_input():
|
||||
result["error"] = "无法聚焦输入框"
|
||||
return result
|
||||
result["state"] = sm.state
|
||||
|
||||
# Step 3: 输入文本
|
||||
if not await sm.enter_text(text):
|
||||
result["error"] = "无法输入文本"
|
||||
return result
|
||||
result["state"] = sm.state
|
||||
|
||||
# Step 4: 发送
|
||||
if not await sm.send():
|
||||
result["error"] = "发送失败"
|
||||
return result
|
||||
result["state"] = sm.state
|
||||
|
||||
# Step 5: 验证
|
||||
if await sm.verify():
|
||||
result["success"] = True
|
||||
result["state"] = "verified"
|
||||
else:
|
||||
result["error"] = "发送后验证失败"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 概率互动(养号用)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
async def random_interact(page, behavior_config: dict = None) -> dict:
|
||||
"""
|
||||
随机互动(根据概率触发点赞/收藏/关注/评论)
|
||||
|
||||
Returns:
|
||||
{"like": bool, "collect": bool, "follow": bool, "comment": bool, "comment_text": str}
|
||||
"""
|
||||
results = {
|
||||
"like": False,
|
||||
"collect": False,
|
||||
"follow": False,
|
||||
"comment": False,
|
||||
"comment_text": "",
|
||||
}
|
||||
|
||||
# 点赞: 5 中 1 (20%)
|
||||
if random.random() < 0.20:
|
||||
results["like"] = await like(page)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.5))
|
||||
|
||||
# 收藏: 8 中 1 (12.5%)
|
||||
if random.random() < 0.125:
|
||||
results["collect"] = await collect(page)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.5))
|
||||
|
||||
# 关注: 40 中 1 (2.5%) — 真用户关注频率很低
|
||||
if random.random() < 0.025:
|
||||
results["follow"] = await follow(page)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.5))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
小红书 DOM 选择器集中管理
|
||||
基于 2026-05-20 Playwright + Camoufox 实际 DOM 分析结果
|
||||
|
||||
分析环境:
|
||||
- URL: https://www.xiaohongshu.com/explore
|
||||
- 分辨率: 702x783 (Camoufox 窗口)
|
||||
- 状态: 未登录(登录弹窗已隐藏)
|
||||
"""
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 首页/发现页 (Explore)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 瀑布流容器
|
||||
FEED_CONTAINER = "#app .layout.limit"
|
||||
|
||||
# 笔记卡片 — section.note-item (DOM 分析确认)
|
||||
NOTE_CARD = "section.note-item"
|
||||
|
||||
# 笔记卡片封面图链接
|
||||
NOTE_CARD_COVER = "a.cover.mask.ld"
|
||||
|
||||
# 笔记卡片中的图片
|
||||
NOTE_CARD_IMG = "a.cover.mask.ld img"
|
||||
|
||||
# 笔记卡片标题
|
||||
NOTE_CARD_TITLE = ".title, [class*=title]"
|
||||
|
||||
# 笔记卡片作者信息
|
||||
NOTE_CARD_AUTHOR = ".author, [class*=author]"
|
||||
|
||||
# 笔记卡片点赞数
|
||||
NOTE_CARD_LIKES = ".like-count, [class*=like], [class*=count]"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 笔记详情页 (Note Detail)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 详情页主容器
|
||||
NOTE_DETAIL_CONTAINER = ".note-detail, [class*=note-detail], .note-page"
|
||||
|
||||
# 笔记标题
|
||||
NOTE_TITLE = ".title, h1, [class*=note-title]"
|
||||
|
||||
# 笔记内容文字
|
||||
NOTE_CONTENT = ".content, [class*=content], .desc"
|
||||
|
||||
# 笔记图片
|
||||
NOTE_IMAGES = ".note-content img, [class*=note-content] img"
|
||||
|
||||
# 作者头像
|
||||
AUTHOR_AVATAR = ".avatar, [class*=avatar]"
|
||||
|
||||
# 作者昵称
|
||||
AUTHOR_NAME = ".author-name, [class*=author-name], .nickname"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 互动按钮
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 点赞按钮
|
||||
LIKE_BUTTON = ".like-btn, [class*=like-btn], .interaction-like, button:has([class*=like])"
|
||||
|
||||
# 收藏按钮
|
||||
COLLECT_BUTTON = ".collect-btn, [class*=collect-btn], .interaction-collect, button:has([class*=collect])"
|
||||
|
||||
# 关注按钮
|
||||
FOLLOW_BUTTON = ".follow-btn, [class*=follow-btn], .interaction-follow"
|
||||
|
||||
# 分享按钮
|
||||
SHARE_BUTTON = ".share-btn, [class*=share-btn]"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 评论区
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 评论入口/评论数按钮
|
||||
COMMENT_ENTRY = ".comment-btn, [class*=comment-btn], .interaction-comment, [class*=comment-count]"
|
||||
|
||||
# 评论区容器
|
||||
COMMENT_SECTION = ".comment-section, [class*=comment-section], .comment-list"
|
||||
|
||||
# 评论输入框
|
||||
COMMENT_INPUT = ".comment-input, [class*=comment-input] input, [contenteditable=true], textarea[placeholder*=评论]"
|
||||
|
||||
# 评论发送按钮
|
||||
COMMENT_SEND = ".send-btn, [class*=send-btn], button:has-text('发送'), button[type=submit]"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 搜索
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 搜索框
|
||||
SEARCH_INPUT = "input.search-input"
|
||||
|
||||
# 搜索按钮
|
||||
SEARCH_BUTTON = ".search-icon, [class*=search-icon], button:has-text('搜索')"
|
||||
|
||||
# 搜索结果页
|
||||
SEARCH_RESULT_CONTAINER = ".search-result, [class*=search-result]"
|
||||
|
||||
# 搜索结果笔记卡片
|
||||
SEARCH_NOTE_CARD = ".search-result .note-item, .search-result-item"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 导航
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 左侧导航栏
|
||||
LEFT_NAV = "nav, aside, [class*=sidebar]"
|
||||
|
||||
# 首页链接
|
||||
NAV_HOME = 'a[href*="/explore"], a:has-text("发现")'
|
||||
|
||||
# 消息链接
|
||||
NAV_MESSAGE = 'a[href*="/message"], a:has-text("消息")'
|
||||
|
||||
# 通知链接
|
||||
NAV_NOTIFICATION = 'a[href*="/notification"], a:has-text("通知")'
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 弹窗/遮罩
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# 登录弹窗
|
||||
LOGIN_MODAL = "[class*=login], [class*=modal], [role=dialog], .reds-alert"
|
||||
|
||||
# 登录弹窗关闭按钮
|
||||
LOGIN_MODAL_CLOSE = ".close-btn, [class*=close], button:has-text('关闭'), [class*=mask]"
|
||||
|
||||
# 通用遮罩层
|
||||
OVERLAY = "[class*=mask], [class*=overlay], [class*=modal], div[style*='position: fixed']"
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 锚点验证选择器 (用于操作前后状态校验)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
ANCHORS = {
|
||||
# 首页锚点: 瀑布流有笔记卡片
|
||||
"home_page": "section.note-item",
|
||||
|
||||
# 笔记详情页锚点: 有笔记内容或标题
|
||||
"note_detail": ".note-detail, [class*=note-detail], .title, h1",
|
||||
|
||||
# 视频播放页锚点: 有 video 元素
|
||||
"video_page": "video",
|
||||
|
||||
# 评论区打开锚点: 评论列表可见
|
||||
"comment_open": ".comment-section, [class*=comment-section]",
|
||||
|
||||
# 搜索页锚点: 搜索结果容器
|
||||
"search_page": ".search-result, [class*=search-result]",
|
||||
|
||||
# 页面加载完成锚点: body 有内容
|
||||
"page_loaded": "#app",
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 辅助函数
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def get_note_links_js() -> str:
|
||||
"""JS 代码: 获取当前页面所有笔记链接"""
|
||||
return """
|
||||
() => {
|
||||
const links = [...document.querySelectorAll('a[href*="/explore/"]')];
|
||||
return [...new Set(links.map(a => a.href))].filter(h => h.includes('/explore/')).slice(0, 10);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def get_note_cards_js() -> str:
|
||||
"""JS 代码: 获取笔记卡片元素列表"""
|
||||
return """
|
||||
() => {
|
||||
const cards = [...document.querySelectorAll('section.note-item')];
|
||||
return cards.map((card, i) => ({
|
||||
index: i,
|
||||
href: card.querySelector('a')?.href || '',
|
||||
title: card.textContent?.trim().substring(0, 50) || '',
|
||||
rect: card.getBoundingClientRect ? {
|
||||
x: Math.round(card.getBoundingClientRect().x),
|
||||
y: Math.round(card.getBoundingClientRect().y),
|
||||
w: Math.round(card.getBoundingClientRect().width),
|
||||
h: Math.round(card.getBoundingClientRect().height)
|
||||
} : null
|
||||
}));
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def dismiss_login_modal_js() -> str:
|
||||
"""JS 代码: 尝试关闭登录弹窗"""
|
||||
return """
|
||||
() => {
|
||||
// 关闭登录弹窗
|
||||
document.querySelectorAll('[class*=modal], [class*=dialog], [class*=mask], [role=dialog]').forEach(m => {
|
||||
m.style.display = 'none';
|
||||
});
|
||||
// 移除 fixed 遮罩
|
||||
document.querySelectorAll('div').forEach(d => {
|
||||
const s = window.getComputedStyle(d);
|
||||
if (s.position === 'fixed' && parseInt(s.zIndex) > 100) {
|
||||
d.style.display = 'none';
|
||||
}
|
||||
});
|
||||
return 'modal dismissed';
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def find_refresh_button_js() -> str:
|
||||
"""
|
||||
JS 代码: 查找小红书瀑布流页面右下角的刷新 FAB 按钮
|
||||
|
||||
策略:
|
||||
1. 先用常见选择器查找刷新类按钮
|
||||
2. 再用 SVG 扫描(刷新图标通常用 SVG 箭头)
|
||||
3. 过滤出右下角区域(x > viewport 60%, y > viewport 60%)的元素
|
||||
"""
|
||||
return """
|
||||
() => {
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const btns = [];
|
||||
|
||||
// 方式1: 常见刷新按钮选择器
|
||||
const selectors = [
|
||||
'button[class*="refresh"]',
|
||||
'[class*="refresh-btn"]',
|
||||
'[class*="reload"]',
|
||||
'button[class*="fab"]',
|
||||
'[class*="float-btn"]',
|
||||
'[class*="floating"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const els = document.querySelectorAll(sel);
|
||||
els.forEach(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 20 && r.height > 20 && r.width < 100 && r.height < 100) {
|
||||
btns.push({x: r.x, y: r.y, w: r.width, h: r.height, text: el.textContent?.trim() || '', sel: sel});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 方式2: SVG 扫描 — 查找包含旋转箭头路径的 SVG(刷新图标特征)
|
||||
document.querySelectorAll('svg').forEach(svg => {
|
||||
const parent = svg.closest('button, a, [class*="btn"], [class*="icon"], [role="button"], div');
|
||||
if (!parent) return;
|
||||
const r = parent.getBoundingClientRect();
|
||||
if (r.width < 15 || r.height < 15 || r.width > 120 || r.height > 120) return;
|
||||
// 检查 SVG 内是否有旋转箭头路径(arc + rotate 是刷新图标的典型特征)
|
||||
const paths = svg.querySelectorAll('path, circle');
|
||||
const hasRotation = [...paths].some(p => {
|
||||
const d = (p.getAttribute('d') || '').toLowerCase();
|
||||
const transform = (p.getAttribute('transform') || svg.getAttribute('transform') || '').toLowerCase();
|
||||
return d.includes('a') && (d.includes('m') || d.includes('l')) ||
|
||||
transform.includes('rotate') ||
|
||||
d.includes('arc');
|
||||
});
|
||||
if (hasRotation) {
|
||||
btns.push({x: r.x, y: r.y, w: r.width, h: r.height, text: parent.textContent?.trim() || '', sel: 'svg-scan'});
|
||||
}
|
||||
});
|
||||
|
||||
// 方式3: 查找固定定位在右下角的可点击元素
|
||||
document.querySelectorAll('button, a, [role="button"], div[style*="cursor"]').forEach(el => {
|
||||
const s = window.getComputedStyle(el);
|
||||
if (s.position !== 'fixed') return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.x > vw * 0.6 && r.y > vh * 0.6 && r.width < 100 && r.height < 100 && r.width > 20 && r.height > 20) {
|
||||
// 避免重复
|
||||
if (!btns.some(b => Math.abs(b.x - r.x) < 10 && Math.abs(b.y - r.y) < 10)) {
|
||||
btns.push({x: r.x, y: r.y, w: r.width, h: r.height, text: el.textContent?.trim() || '', sel: 'fixed-position'});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!btns.length) return {found: false};
|
||||
|
||||
// 优先选右下角的按钮(x 最大 + y 最大)
|
||||
btns.sort((a, b) => (b.x + b.y) - (a.x + a.y));
|
||||
const best = btns[0];
|
||||
return {
|
||||
found: true,
|
||||
x: Math.round(best.x + best.w / 2),
|
||||
y: Math.round(best.y + best.h / 2),
|
||||
w: Math.round(best.w),
|
||||
h: Math.round(best.h),
|
||||
text: best.text,
|
||||
method: best.sel
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def find_qr_wall_back_button_js() -> str:
|
||||
"""
|
||||
JS 代码: 检测小红书 QR 检测墙并查找"返回首页"按钮
|
||||
|
||||
QR 墙特征:
|
||||
- 页面中央出现大弹窗
|
||||
- body 文本包含"扫码"、"二维码"等关键词
|
||||
- 弹窗内有"返回首页"或类似按钮
|
||||
"""
|
||||
return """
|
||||
() => {
|
||||
const bodyText = document.body?.innerText?.toLowerCase() || '';
|
||||
// QR 墙关键词检测
|
||||
const qrKeywords = ['扫码', '二维码', 'qrcode', '扫描', 'scan code', '非常用登录', '验证'];
|
||||
const isQrWall = qrKeywords.some(kw => bodyText.includes(kw));
|
||||
|
||||
if (!isQrWall) return {found: false, reason: 'no_qr_keywords'};
|
||||
|
||||
// 查找弹窗中的返回按钮
|
||||
const backKeywords = ['返回首页', '返回', '首页', '回到首页', '确定', '我知道了', '关闭'];
|
||||
const candidates = [];
|
||||
|
||||
// 搜索弹窗内所有可点击元素
|
||||
document.querySelectorAll('button, a, [role="button"], [class*="btn"], span[class*="text"], div[class*="text"]').forEach(el => {
|
||||
const text = el.textContent?.trim();
|
||||
if (!text) return;
|
||||
const textLower = text.toLowerCase();
|
||||
// 精确匹配或包含匹配
|
||||
const matched = backKeywords.some(kw => {
|
||||
if (textLower === kw) return true;
|
||||
if (textLower.includes(kw) && text.length < 15) return true;
|
||||
return false;
|
||||
});
|
||||
if (!matched) return;
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 5 || r.height < 5) return; // 不可见
|
||||
|
||||
// 优先级: "返回首页" > "返回" > "首页" > 其他
|
||||
let priority = 99;
|
||||
if (textLower === '返回首页' || textLower === '回到首页') priority = 10;
|
||||
else if (textLower === '返回') priority = 20;
|
||||
else if (textLower === '首页') priority = 30;
|
||||
else priority = 50;
|
||||
|
||||
candidates.push({
|
||||
x: Math.round(r.x + r.width / 2),
|
||||
y: Math.round(r.y + r.height / 2),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
text: text,
|
||||
priority: priority
|
||||
});
|
||||
});
|
||||
|
||||
if (!candidates.length) return {found: false, reason: 'no_back_button'};
|
||||
|
||||
// 按优先级排序
|
||||
candidates.sort((a, b) => a.priority - b.priority);
|
||||
const best = candidates[0];
|
||||
return {
|
||||
found: true,
|
||||
x: best.x,
|
||||
y: best.y,
|
||||
w: best.w,
|
||||
h: best.h,
|
||||
text: best.text,
|
||||
priority: best.priority
|
||||
};
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
cookie_manager.py — Cookie 安全备份与恢复模块 v1.0
|
||||
|
||||
核心问题:XHS 和 Douyin 共享 identity_dir(douyin_01_camo = douyin_01 + xhs_01)
|
||||
清 XHS cookie 时容易误删抖音 session。
|
||||
|
||||
解决方案:
|
||||
1. 每次操作前自动全量备份 cookies.sqlite(带平台+时间戳标签)
|
||||
2. 跨平台操作时自动保护对方平台 cookie(备份后再操作)
|
||||
3. 支持按平台精准恢复(只恢复某个平台的 cookie 行)
|
||||
|
||||
文件结构:
|
||||
backups/cookies/{identity_dir_name}/
|
||||
cookies.{timestamp}.{platform}.bak ← 全量备份
|
||||
cookies.{timestamp}.restore_point.bak ← 操作前检查点
|
||||
|
||||
使用:
|
||||
from matrix_modules.utils.cookie_manager import CookieGuard
|
||||
|
||||
guard = CookieGuard("douyin_01_camo")
|
||||
guard.backup(platform="douyin") # 备份当前状态
|
||||
guard.protect("douyin") # 保护抖音(=备份后操作其他平台)
|
||||
guard.restore(platform="douyin") # 恢复抖音 cookie
|
||||
"""
|
||||
import shutil
|
||||
import os
|
||||
import time
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import glob
|
||||
|
||||
from matrix_mgmt import AGENT_LOCAL
|
||||
LOCAL_ROOT = AGENT_LOCAL / "tools" / "matrix"
|
||||
IDENTITIES_DIR = LOCAL_ROOT / "identities"
|
||||
BACKUP_DIR = LOCAL_ROOT / "backups" / "cookies"
|
||||
|
||||
# 共享 identity_dir → 涉及的平台映射
|
||||
SHARED_MAP = {
|
||||
"douyin_01_camo": ["douyin", "xiaohongshu"],
|
||||
"douyin_02_camo": ["douyin", "xiaohongshu"],
|
||||
"douyin_camo01": ["douyin", "xiaohongshu"],
|
||||
}
|
||||
|
||||
|
||||
class CookieGuard:
|
||||
"""Cookie 安全防护工具"""
|
||||
|
||||
def __init__(self, identity_dir_name: str):
|
||||
"""
|
||||
Args:
|
||||
identity_dir_name: identity 目录名, 如 "douyin_01_camo"
|
||||
"""
|
||||
self.identity_name = identity_dir_name
|
||||
self.user_data_dir = IDENTITIES_DIR / identity_dir_name / "user_data"
|
||||
self.cookie_file = self.user_data_dir / "cookies.sqlite"
|
||||
self.backup_subdir = BACKUP_DIR / identity_dir_name
|
||||
self.backup_subdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ──────────────── 公共 API ────────────────
|
||||
|
||||
def backup(self, platform: str = None, label: str = "") -> str:
|
||||
"""
|
||||
全量备份 cookies.sqlite
|
||||
|
||||
Args:
|
||||
platform: 平台标签(douyin/xiaohongshu),用于标识
|
||||
label: 额外标签(如 "pre_xhs_clear")
|
||||
|
||||
Returns:
|
||||
备份文件路径,失败返回空字符串
|
||||
"""
|
||||
if not self.cookie_file.exists():
|
||||
return ""
|
||||
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
parts = ["cookies", ts]
|
||||
if platform:
|
||||
parts.append(platform)
|
||||
if label:
|
||||
parts.append(label)
|
||||
bak_name = ".".join(parts) + ".bak"
|
||||
|
||||
bak_path = self.backup_subdir / bak_name
|
||||
try:
|
||||
shutil.copy2(str(self.cookie_file), str(bak_path))
|
||||
return str(bak_path)
|
||||
except Exception as e:
|
||||
return ""
|
||||
|
||||
def restore(self, backup_file: str = None, platform: str = None) -> bool:
|
||||
"""
|
||||
恢复 cookies.sqlite 到目标目录
|
||||
|
||||
Args:
|
||||
backup_file: 指定备份文件路径;None 时自动找最新的
|
||||
platform: 指定按平台恢复(仅恢复该平台的 cookie 行)
|
||||
None 时全量恢复
|
||||
|
||||
Returns:
|
||||
True=成功
|
||||
"""
|
||||
if not backup_file:
|
||||
latest = self._find_latest_backup(platform)
|
||||
if not latest:
|
||||
return False
|
||||
backup_file = str(latest)
|
||||
|
||||
src = Path(backup_file)
|
||||
if not src.exists():
|
||||
return False
|
||||
|
||||
if platform:
|
||||
# ── 按平台精准恢复(只覆盖指定平台的 cookie 行) ──
|
||||
return self._restore_platform_only(src, platform)
|
||||
else:
|
||||
# ── 全量恢复 ──
|
||||
try:
|
||||
# 先备份当前文件(万一恢复错了还能回退)
|
||||
current_bak = self.backup(platform=platform, label="pre_restore")
|
||||
# 复制备份覆盖
|
||||
shutil.copy2(str(src), str(self.cookie_file))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def protect(self, platform_to_protect: str) -> str:
|
||||
"""
|
||||
保护指定平台的 cookie:操作其他平台前先调用
|
||||
|
||||
例如 protect("douyin") 会在清 XHS cookie 前,
|
||||
先备份当前 cookies.sqlite(含抖音 session)。
|
||||
|
||||
Args:
|
||||
platform_to_protect: 要保护的平台 ("douyin" 或 "xiaohongshu")
|
||||
|
||||
Returns:
|
||||
备份文件路径
|
||||
"""
|
||||
return self.backup(platform=platform_to_protect, label="protected")
|
||||
|
||||
def delete_platform_cookies_safe(self, platform: str) -> bool:
|
||||
"""
|
||||
安全删除指定平台的 cookie(自动备份对方平台)
|
||||
|
||||
Args:
|
||||
platform: 要删除的平台 ("douyin" 或 "xiaohongshu")
|
||||
|
||||
Returns:
|
||||
bool: True=删除成功
|
||||
"""
|
||||
# 1. 先保护对方平台
|
||||
other = self._get_other_platform(platform)
|
||||
if other:
|
||||
self.protect(other)
|
||||
|
||||
# 2. 再全量备份当前状态
|
||||
self.backup(platform=platform, label="pre_delete")
|
||||
|
||||
# 3. 执行删除
|
||||
return self._delete_platform_cookies(platform)
|
||||
|
||||
def list_backups(self, platform: str = None) -> List[str]:
|
||||
"""列出可用备份"""
|
||||
pattern = f"cookies.*{platform + '.' if platform else ''}*.bak"
|
||||
files = sorted(self.backup_subdir.glob(pattern), reverse=True)
|
||||
return [str(f) for f in files]
|
||||
|
||||
# ──────────────── 内部方法 ────────────────
|
||||
|
||||
def _find_latest_backup(self, platform: str = None):
|
||||
"""找最新的备份文件"""
|
||||
pattern = f"cookies.*{platform + '.' if platform else ''}*.bak"
|
||||
files = sorted(self.backup_subdir.glob(pattern), reverse=True)
|
||||
return files[0] if files else None
|
||||
|
||||
def _get_other_platform(self, platform: str) -> Optional[str]:
|
||||
"""获取共享 identity 中的对方平台名"""
|
||||
platforms = SHARED_MAP.get(self.identity_name, [])
|
||||
for p in platforms:
|
||||
if p != platform:
|
||||
return p
|
||||
return None
|
||||
|
||||
def _delete_platform_cookies(self, platform: str) -> bool:
|
||||
"""通过 sqlite3 删除指定平台的 cookie"""
|
||||
domain_patterns = {
|
||||
"douyin": "%douyin.com%",
|
||||
"xiaohongshu": "%xiaohongshu%",
|
||||
}
|
||||
pattern = domain_patterns.get(platform, f"%{platform}%")
|
||||
|
||||
if not self.cookie_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(self.cookie_file))
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (pattern,))
|
||||
deleted = c.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# 删除 -wal 和 -shm 文件(Firefox 可能未正常关闭)
|
||||
for ext in ["-wal", "-shm"]:
|
||||
f = str(self.cookie_file) + ext
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
return deleted > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _restore_platform_only(self, src: Path, platform: str) -> bool:
|
||||
"""
|
||||
精准恢复:从备份文件中提取指定平台的 cookie 行,
|
||||
合并到当前 cookies.sqlite
|
||||
|
||||
流程:
|
||||
1. 从备份文件读取指定平台的 cookie 行
|
||||
2. 从当前文件删除该平台的旧 cookie 行
|
||||
3. 将备份中的行插入当前文件
|
||||
"""
|
||||
domain_patterns = {
|
||||
"douyin": "%douyin.com%",
|
||||
"xiaohongshu": "%xiaohongshu%",
|
||||
}
|
||||
pattern = domain_patterns.get(platform, f"%{platform}%")
|
||||
|
||||
if not self.cookie_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
# 连接备份文件,读取目标平台的 cookie 行
|
||||
src_conn = sqlite3.connect(str(src))
|
||||
src_c = src_conn.cursor()
|
||||
rows = src_c.execute(
|
||||
"SELECT * FROM moz_cookies WHERE host LIKE ?", (pattern,)
|
||||
).fetchall()
|
||||
columns = [d[0] for d in src_c.description]
|
||||
src_conn.close()
|
||||
|
||||
if not rows:
|
||||
return False
|
||||
|
||||
# 连接当前文件,删除旧行后插入
|
||||
dst_conn = sqlite3.connect(str(self.cookie_file))
|
||||
dst_c = dst_conn.cursor()
|
||||
|
||||
# 删除该平台旧 cookie
|
||||
dst_c.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (pattern,))
|
||||
|
||||
# 构造 INSERT 语句
|
||||
placeholders = ",".join(["?"] * len(columns))
|
||||
col_names = ",".join(columns)
|
||||
dst_c.executemany(
|
||||
f"INSERT INTO moz_cookies ({col_names}) VALUES ({placeholders})",
|
||||
rows
|
||||
)
|
||||
dst_conn.commit()
|
||||
dst_conn.close()
|
||||
|
||||
# 清理 wal/shm
|
||||
for ext in ["-wal", "-shm"]:
|
||||
f = str(self.cookie_file) + ext
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ──────────────── 简便函数(不用实例化) ────────────────
|
||||
|
||||
def backup_all_identities(platform: str = None, label: str = "") -> dict:
|
||||
"""备份所有账号的 cookie"""
|
||||
results = {}
|
||||
for identity in SHARED_MAP:
|
||||
guard = CookieGuard(identity)
|
||||
path = guard.backup(platform=platform, label=label)
|
||||
results[identity] = path
|
||||
return results
|
||||
|
||||
|
||||
def quick_backup(identity_dir_name: str, platform: str = None) -> str:
|
||||
"""快速备份单个账号"""
|
||||
guard = CookieGuard(identity_dir_name)
|
||||
return guard.backup(platform=platform)
|
||||
Reference in New Issue
Block a user