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

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

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