feat: publish workphone SDK deployment and API docs
This commit is contained in:
20
sdk/agent/anti_ban/__init__.py
Normal file
20
sdk/agent/anti_ban/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
设备端深层防封模块 (anti_ban)
|
||||
|
||||
Agent 启动时自动执行环境自检,运行时持续监控风控信号。
|
||||
所有自动化操作经过触摸加固层和传感器模拟层。
|
||||
"""
|
||||
|
||||
from .device_guard import DeviceGuard
|
||||
from .risk_sentinel import RiskSentinel
|
||||
from .touch_hardener import TouchHardener
|
||||
from .sensor_simulator import SensorSimulator
|
||||
from .nurture_scheduler import NurtureScheduler
|
||||
|
||||
__all__ = [
|
||||
"DeviceGuard",
|
||||
"RiskSentinel",
|
||||
"TouchHardener",
|
||||
"SensorSimulator",
|
||||
"NurtureScheduler",
|
||||
]
|
||||
430
sdk/agent/anti_ban/device_guard.py
Normal file
430
sdk/agent/anti_ban/device_guard.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
设备环境守卫 — Agent 启动时执行全面自检
|
||||
|
||||
检测项:
|
||||
1. Root 隐藏状态(Shamiko / MagiskHide)
|
||||
2. Frida 反检测状态(进程名/端口/内存特征)
|
||||
3. 设备指纹唯一性(上报并校验碰撞)
|
||||
4. 模拟器检测(Build属性/传感器/电池/特征文件)
|
||||
5. 无障碍服务状态
|
||||
6. SELinux 状态
|
||||
7. 关键 App 安装隐藏(HMA-OSS 检测)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceGuard:
|
||||
"""设备环境守卫 — 启动自检 + 持续巡检"""
|
||||
|
||||
# 不应被目标 App 看到的包名
|
||||
HIDDEN_PACKAGES = [
|
||||
"com.topjohnwu.magisk",
|
||||
"io.github.vvb2060.magisk",
|
||||
"me.weishu.kernelsu",
|
||||
"org.lsposed.manager",
|
||||
"moe.shizuku.privileged.api",
|
||||
"eu.chainfire.supersu",
|
||||
"com.noshufou.android.su",
|
||||
"com.koushikdutta.superuser",
|
||||
"com.termux",
|
||||
]
|
||||
|
||||
# 模拟器 Build 特征
|
||||
EMU_FINGERPRINTS = [
|
||||
"goldfish", "generic", "vbox", "sdk_gphone",
|
||||
"Andy", "Droid4X", "nox", "bluestacks",
|
||||
"genymotion", "ttVM_Hdragon",
|
||||
]
|
||||
|
||||
# Frida 默认特征
|
||||
FRIDA_INDICATORS = [
|
||||
"/data/local/tmp/frida-server",
|
||||
"/data/local/tmp/re.frida.server",
|
||||
]
|
||||
|
||||
def __init__(self, device):
|
||||
"""
|
||||
Args:
|
||||
device: uiautomator2 设备对象
|
||||
"""
|
||||
self.d = device
|
||||
self.report: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_full_check(self) -> Dict[str, Any]:
|
||||
"""执行全面自检,返回结果报告"""
|
||||
logger.info("🛡️ 设备环境自检开始...")
|
||||
start = time.time()
|
||||
|
||||
checks: List[Tuple[str, callable]] = [
|
||||
("root_hidden", self._check_root_hidden),
|
||||
("frida_stealth", self._check_frida_stealth),
|
||||
("fingerprint", self._collect_fingerprint),
|
||||
("emulator", self._check_emulator),
|
||||
("accessibility", self._check_accessibility),
|
||||
("selinux", self._check_selinux),
|
||||
("app_hidden", self._check_app_hidden),
|
||||
("dangerous_props", self._check_dangerous_props),
|
||||
]
|
||||
|
||||
results = {}
|
||||
warnings = []
|
||||
for name, fn in checks:
|
||||
try:
|
||||
result = fn()
|
||||
results[name] = result
|
||||
if result.get("warning"):
|
||||
warnings.append(f"{name}: {result['warning']}")
|
||||
logger.warning(f" ⚠️ {name}: {result['warning']}")
|
||||
else:
|
||||
logger.info(f" ✅ {name}: OK")
|
||||
except Exception as e:
|
||||
results[name] = {"ok": False, "error": str(e)}
|
||||
warnings.append(f"{name}: {e}")
|
||||
logger.error(f" ❌ {name}: {e}")
|
||||
|
||||
elapsed = time.time() - start
|
||||
self.report = {
|
||||
"ok": len(warnings) == 0,
|
||||
"warnings": warnings,
|
||||
"warning_count": len(warnings),
|
||||
"checks": results,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
# AB-04:量产环境红灯分级。联调态(dev)暴露记为黄,量产态(prod)关键暴露记为红。
|
||||
self.report["redlight"] = self._classify_redlight(results)
|
||||
|
||||
if warnings:
|
||||
logger.warning(f"🛡️ 自检完成 — {len(warnings)} 项警告 ({elapsed:.1f}s)")
|
||||
else:
|
||||
logger.info(f"🛡️ 自检全部通过 ({elapsed:.1f}s)")
|
||||
|
||||
return self.report
|
||||
|
||||
def _classify_redlight(self, results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""量产环境红灯分级(AB-04)。
|
||||
|
||||
env:`WP_ENV=prod` 视为量产态,关键暴露 → 红灯(应阻断上线);
|
||||
否则联调态(dev),同样的暴露只记黄灯(允许联调,但仍提示)。
|
||||
关键暴露 = Frida 默认特征 / su 可见 / ro.debuggable=1 / adbd running。
|
||||
"""
|
||||
env = (os.environ.get("WP_ENV") or os.environ.get("WORKPHONE_ENV") or "dev").lower()
|
||||
is_prod = env in ("prod", "production", "release")
|
||||
critical: List[str] = []
|
||||
|
||||
frida = results.get("frida_stealth") or {}
|
||||
if not frida.get("ok", True):
|
||||
critical.append(f"frida_stealth: {frida.get('warning', '默认特征暴露')}")
|
||||
root = results.get("root_hidden") or {}
|
||||
if root.get("warning", "").startswith("su 二进制可见"):
|
||||
critical.append(f"root_hidden: {root['warning']}")
|
||||
props = (results.get("dangerous_props") or {}).get("dangerous_props") or {}
|
||||
if props.get("ro.debuggable"):
|
||||
critical.append("dangerous_props: ro.debuggable=1")
|
||||
if props.get("adb_running"):
|
||||
critical.append("dangerous_props: adbd running")
|
||||
|
||||
level = "green"
|
||||
if critical:
|
||||
level = "red" if is_prod else "yellow"
|
||||
return {
|
||||
"env": env,
|
||||
"is_prod": is_prod,
|
||||
"level": level,
|
||||
"critical_exposures": critical,
|
||||
"advice": (
|
||||
"量产红灯:关键暴露须先隐藏(Shamiko/改名改端口/关 adbd/ro.debuggable=0)再上线"
|
||||
if level == "red" else
|
||||
("联调态黄灯:暴露项不阻断联调,量产前须清零" if level == "yellow" else "环境干净")
|
||||
),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Root 隐藏检测
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_root_hidden(self) -> dict:
|
||||
result = {"ok": True}
|
||||
|
||||
# su 二进制是否可被常规方式发现
|
||||
su_paths = ["/system/bin/su", "/system/xbin/su", "/sbin/su"]
|
||||
for p in su_paths:
|
||||
out = self._shell(f"ls {p} 2>/dev/null").strip()
|
||||
if out and "No such file" not in out:
|
||||
result["ok"] = False
|
||||
result["warning"] = f"su 二进制可见: {p}"
|
||||
return result
|
||||
|
||||
# Magisk 目录
|
||||
magisk_dirs = ["/data/adb/magisk", "/data/adb/modules"]
|
||||
for d in magisk_dirs:
|
||||
out = self._shell(f"ls {d} 2>/dev/null").strip()
|
||||
if out and "No such file" not in out:
|
||||
# 目录存在但应被 Shamiko 隐藏不让目标App看到
|
||||
result["magisk_dir_visible"] = True
|
||||
|
||||
# 检查 Shamiko 是否安装
|
||||
shamiko = self._shell("ls /data/adb/modules/shamiko 2>/dev/null").strip()
|
||||
result["shamiko_installed"] = bool(shamiko and "No such file" not in shamiko)
|
||||
if not result["shamiko_installed"]:
|
||||
result["warning"] = "Shamiko 未安装,Root 可能暴露"
|
||||
result["ok"] = False
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Frida 反检测
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_frida_stealth(self) -> dict:
|
||||
result = {"ok": True}
|
||||
|
||||
# 检查默认 frida-server 进程名
|
||||
ps_out = self._shell("ps -A 2>/dev/null || ps").strip()
|
||||
if "frida-server" in ps_out or "frida-agent" in ps_out:
|
||||
result["ok"] = False
|
||||
result["warning"] = "检测到默认 frida-server 进程名,必须使用 Phantom-Frida"
|
||||
return result
|
||||
|
||||
# 检查默认端口 27042
|
||||
netstat = self._shell("netstat -tlnp 2>/dev/null || ss -tlnp 2>/dev/null").strip()
|
||||
if ":27042" in netstat:
|
||||
result["ok"] = False
|
||||
result["warning"] = "检测到 Frida 默认端口 27042"
|
||||
return result
|
||||
|
||||
# 检查默认路径
|
||||
for path in self.FRIDA_INDICATORS:
|
||||
out = self._shell(f"ls {path} 2>/dev/null").strip()
|
||||
if out and "No such file" not in out:
|
||||
result["ok"] = False
|
||||
result["warning"] = f"Frida 默认路径可见: {path}"
|
||||
return result
|
||||
|
||||
# 检查 /proc/self/maps 中的 frida 字符串(从 shell 模拟目标App视角)
|
||||
maps = self._shell("cat /proc/self/maps 2>/dev/null | head -200").strip()
|
||||
if "frida" in maps.lower():
|
||||
result["ok"] = False
|
||||
result["warning"] = "/proc/self/maps 中发现 frida 特征"
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. 设备指纹采集
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _collect_fingerprint(self) -> dict:
|
||||
fp_data = {}
|
||||
|
||||
props = {
|
||||
"ro.serialno": "serial",
|
||||
"ro.build.fingerprint": "build_fp",
|
||||
"ro.build.display.id": "display_id",
|
||||
"ro.product.model": "model",
|
||||
"ro.product.brand": "brand",
|
||||
"ro.product.device": "device",
|
||||
"ro.product.board": "board",
|
||||
"ro.hardware": "hardware",
|
||||
"ro.boot.serialno": "boot_serial",
|
||||
"persist.sys.timezone": "timezone",
|
||||
"ro.build.version.sdk": "sdk_version",
|
||||
}
|
||||
for prop, key in props.items():
|
||||
val = self._shell(f"getprop {prop} 2>/dev/null").strip()
|
||||
if val:
|
||||
fp_data[key] = val
|
||||
|
||||
# Android ID
|
||||
android_id = self._shell(
|
||||
"settings get secure android_id 2>/dev/null"
|
||||
).strip()
|
||||
if android_id:
|
||||
fp_data["android_id"] = android_id
|
||||
|
||||
# MAC 地址
|
||||
mac = self._shell(
|
||||
"cat /sys/class/net/wlan0/address 2>/dev/null"
|
||||
).strip()
|
||||
if mac and mac != "00:00:00:00:00:00":
|
||||
fp_data["mac"] = mac
|
||||
|
||||
# 蓝牙地址
|
||||
bt = self._shell(
|
||||
"settings get secure bluetooth_address 2>/dev/null"
|
||||
).strip()
|
||||
if bt:
|
||||
fp_data["bluetooth"] = bt
|
||||
|
||||
# 屏幕参数
|
||||
try:
|
||||
info = self.d.info
|
||||
fp_data["screen_w"] = info.get("displayWidth", 0)
|
||||
fp_data["screen_h"] = info.get("displayHeight", 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# IMEI(需 root 或特殊权限)
|
||||
imei = self._shell(
|
||||
"service call iphonesubinfo 1 2>/dev/null | grep -oP \"'[^']+'\""
|
||||
).strip()
|
||||
if imei:
|
||||
fp_data["imei_raw"] = imei
|
||||
|
||||
# 计算指纹哈希
|
||||
fp_str = "|".join(f"{k}={v}" for k, v in sorted(fp_data.items()))
|
||||
fp_hash = hashlib.md5(fp_str.encode()).hexdigest()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"fingerprint_hash": fp_hash,
|
||||
"dimensions": len(fp_data),
|
||||
"data": fp_data,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. 模拟器检测
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_emulator(self) -> dict:
|
||||
result = {"ok": True, "is_emulator": False, "signals": []}
|
||||
|
||||
# Build 属性
|
||||
build_fp = self._shell("getprop ro.build.fingerprint 2>/dev/null").strip().lower()
|
||||
hardware = self._shell("getprop ro.hardware 2>/dev/null").strip().lower()
|
||||
product = self._shell("getprop ro.product.name 2>/dev/null").strip().lower()
|
||||
|
||||
for emu in self.EMU_FINGERPRINTS:
|
||||
if emu.lower() in build_fp or emu.lower() in hardware or emu.lower() in product:
|
||||
result["signals"].append(f"Build特征: {emu}")
|
||||
|
||||
# 特征文件
|
||||
emu_files = [
|
||||
"/dev/qemu_pipe", "/dev/goldfish_pipe",
|
||||
"/system/lib/libc_malloc_debug_qemu.so",
|
||||
"/sys/qemu_trace", "/dev/socket/qemud",
|
||||
]
|
||||
for f in emu_files:
|
||||
out = self._shell(f"ls {f} 2>/dev/null").strip()
|
||||
if out and "No such file" not in out:
|
||||
result["signals"].append(f"特征文件: {f}")
|
||||
|
||||
# 传感器数量(真机通常 > 10,模拟器 ≤ 3)
|
||||
sensors = self._shell(
|
||||
"dumpsys sensorservice 2>/dev/null | grep -c 'Sensor' || echo 0"
|
||||
).strip()
|
||||
try:
|
||||
sensor_count = int(sensors)
|
||||
result["sensor_count"] = sensor_count
|
||||
if sensor_count < 5:
|
||||
result["signals"].append(f"传感器过少: {sensor_count}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 电池状态(模拟器通常电池 API 异常)
|
||||
battery_status = self._shell(
|
||||
"dumpsys battery 2>/dev/null | grep 'status' | head -1"
|
||||
).strip()
|
||||
if "status: 1" in battery_status:
|
||||
result["signals"].append("电池状态异常(status=1=UNKNOWN)")
|
||||
|
||||
if result["signals"]:
|
||||
result["is_emulator"] = True
|
||||
result["ok"] = False
|
||||
result["warning"] = f"疑似模拟器: {', '.join(result['signals'][:3])}"
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. 无障碍服务检测
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_accessibility(self) -> dict:
|
||||
out = self._shell(
|
||||
"settings get secure enabled_accessibility_services 2>/dev/null"
|
||||
).strip()
|
||||
services = [s for s in out.split(":") if s.strip()] if out and out != "null" else []
|
||||
return {
|
||||
"ok": True,
|
||||
"active_services": services,
|
||||
"count": len(services),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. SELinux 状态
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_selinux(self) -> dict:
|
||||
out = self._shell("getenforce 2>/dev/null").strip().lower()
|
||||
enforcing = out == "enforcing"
|
||||
result = {"ok": True, "mode": out}
|
||||
if not enforcing:
|
||||
result["warning"] = f"SELinux 非 Enforcing 模式: {out}"
|
||||
result["ok"] = False
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. App 隐藏检测(HMA-OSS)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_app_hidden(self) -> dict:
|
||||
visible = []
|
||||
for pkg in self.HIDDEN_PACKAGES:
|
||||
out = self._shell(f"pm list packages {pkg} 2>/dev/null").strip()
|
||||
if f"package:{pkg}" in out:
|
||||
visible.append(pkg)
|
||||
|
||||
result = {"ok": len(visible) == 0, "visible_sensitive_apps": visible}
|
||||
if visible:
|
||||
result["warning"] = f"敏感 App 未隐藏: {', '.join(visible)}"
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. 危险属性检测
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_dangerous_props(self) -> dict:
|
||||
"""检测会暴露自动化/Root 的系统属性"""
|
||||
dangerous = {}
|
||||
|
||||
# ro.debuggable
|
||||
debuggable = self._shell("getprop ro.debuggable 2>/dev/null").strip()
|
||||
if debuggable == "1":
|
||||
dangerous["ro.debuggable"] = "1 (应为 0)"
|
||||
|
||||
# ro.secure
|
||||
secure = self._shell("getprop ro.secure 2>/dev/null").strip()
|
||||
if secure == "0":
|
||||
dangerous["ro.secure"] = "0 (应为 1)"
|
||||
|
||||
# init.svc.adbd
|
||||
adbd = self._shell("getprop init.svc.adbd 2>/dev/null").strip()
|
||||
if adbd == "running":
|
||||
dangerous["adb_running"] = True
|
||||
|
||||
result = {"ok": len(dangerous) == 0, "dangerous_props": dangerous}
|
||||
if dangerous:
|
||||
result["warning"] = f"危险属性: {list(dangerous.keys())}"
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 工具
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _shell(self, cmd: str) -> str:
|
||||
try:
|
||||
timeout = float(os.environ.get("WP_GUARD_SHELL_TIMEOUT", "4") or "4")
|
||||
return self.d.shell(cmd, timeout=timeout).output or ""
|
||||
except Exception:
|
||||
return ""
|
||||
250
sdk/agent/anti_ban/nurture_scheduler.py
Normal file
250
sdk/agent/anti_ban/nurture_scheduler.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
养号调度器 — 新号冷启动期自动限制操作量,渐进提升
|
||||
|
||||
功能:
|
||||
1. 新号冷启动期 (前 7 天) 自动限流
|
||||
2. 每日操作量递增曲线
|
||||
3. 模拟日常行为 (刷朋友圈、看文章、回消息)
|
||||
4. 活跃时段分布 (早 8-10, 午 12-14, 晚 19-22)
|
||||
5. 与 RiskSentinel 联动调整阈值
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NurtureScheduler:
|
||||
"""养号调度器 — 安全地养熟一个账号"""
|
||||
|
||||
COLD_START_DAYS = 7
|
||||
|
||||
DAILY_LIMITS_CURVE = {
|
||||
1: {"send_message": 5, "add_friend": 2, "moment_like": 5, "moment_post": 0},
|
||||
2: {"send_message": 10, "add_friend": 3, "moment_like": 8, "moment_post": 1},
|
||||
3: {"send_message": 18, "add_friend": 5, "moment_like": 12, "moment_post": 1},
|
||||
4: {"send_message": 25, "add_friend": 8, "moment_like": 18, "moment_post": 2},
|
||||
5: {"send_message": 35, "add_friend": 12, "moment_like": 22, "moment_post": 2},
|
||||
6: {"send_message": 45, "add_friend": 15, "moment_like": 28, "moment_post": 3},
|
||||
7: {"send_message": 50, "add_friend": 18, "moment_like": 30, "moment_post": 3},
|
||||
}
|
||||
|
||||
MATURE_LIMITS = {
|
||||
"send_message": 60, "add_friend": 20, "moment_like": 30,
|
||||
"moment_post": 5, "group_send": 10, "profile_view": 40,
|
||||
}
|
||||
|
||||
ACTIVE_HOURS: List[Tuple[int, int, float]] = [
|
||||
(7, 9, 0.6),
|
||||
(9, 12, 0.8),
|
||||
(12, 14, 1.0),
|
||||
(14, 17, 0.7),
|
||||
(17, 19, 0.5),
|
||||
(19, 22, 1.0),
|
||||
(22, 24, 0.4),
|
||||
]
|
||||
|
||||
DAILY_BEHAVIORS = [
|
||||
{"action": "browse_moments", "weight": 3, "duration_min": (2, 8)},
|
||||
{"action": "read_article", "weight": 2, "duration_min": (1, 5)},
|
||||
{"action": "check_messages", "weight": 4, "duration_min": (1, 3)},
|
||||
{"action": "browse_discover", "weight": 1, "duration_min": (2, 6)},
|
||||
]
|
||||
|
||||
def __init__(self, state_file: str = "nurture_state.json"):
|
||||
self.state_file = state_file
|
||||
self._state = self._load_state()
|
||||
|
||||
def register_account(self, account_id: str):
|
||||
"""注册一个新的养号账号"""
|
||||
if account_id not in self._state:
|
||||
self._state[account_id] = {
|
||||
"start_date": datetime.now().isoformat(),
|
||||
"day_counters": {},
|
||||
"total_ops": {},
|
||||
}
|
||||
self._save_state()
|
||||
logger.info(f"注册养号: {account_id}")
|
||||
|
||||
def get_account_day(self, account_id: str) -> int:
|
||||
"""获取账号处于第几天"""
|
||||
info = self._state.get(account_id)
|
||||
if not info:
|
||||
return 999 # 未注册视为成熟号
|
||||
start = datetime.fromisoformat(info["start_date"])
|
||||
delta = (datetime.now() - start).days + 1
|
||||
return delta
|
||||
|
||||
def is_cold_start(self, account_id: str) -> bool:
|
||||
"""是否在冷启动期"""
|
||||
return self.get_account_day(account_id) <= self.COLD_START_DAYS
|
||||
|
||||
def get_daily_limit(self, account_id: str, action: str) -> int:
|
||||
"""获取该账号今日某操作的上限"""
|
||||
day = self.get_account_day(account_id)
|
||||
if day > self.COLD_START_DAYS:
|
||||
return self.MATURE_LIMITS.get(action, 100)
|
||||
day_limits = self.DAILY_LIMITS_CURVE.get(day, self.DAILY_LIMITS_CURVE[7])
|
||||
return day_limits.get(action, self.MATURE_LIMITS.get(action, 100))
|
||||
|
||||
def can_operate(self, account_id: str, action: str) -> dict:
|
||||
"""
|
||||
检查账号当前是否允许执行某操作
|
||||
|
||||
Returns:
|
||||
{"allowed": bool, "reason": str, "day": int, "limit": int, "used": int}
|
||||
"""
|
||||
day = self.get_account_day(account_id)
|
||||
limit = self.get_daily_limit(account_id, action)
|
||||
|
||||
today_key = datetime.now().strftime("%Y-%m-%d")
|
||||
info = self._state.get(account_id, {})
|
||||
counters = info.get("day_counters", {}).get(today_key, {})
|
||||
used = counters.get(action, 0)
|
||||
|
||||
if not self._is_active_hour():
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "当前不在活跃时段",
|
||||
"day": day,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
}
|
||||
|
||||
if used >= limit:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": f"今日已达上限 ({used}/{limit})",
|
||||
"day": day,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "",
|
||||
"day": day,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
}
|
||||
|
||||
def record_operation(self, account_id: str, action: str):
|
||||
"""记录一次操作"""
|
||||
if account_id not in self._state:
|
||||
self.register_account(account_id)
|
||||
|
||||
today_key = datetime.now().strftime("%Y-%m-%d")
|
||||
info = self._state[account_id]
|
||||
|
||||
if "day_counters" not in info:
|
||||
info["day_counters"] = {}
|
||||
if today_key not in info["day_counters"]:
|
||||
info["day_counters"][today_key] = {}
|
||||
|
||||
counters = info["day_counters"][today_key]
|
||||
counters[action] = counters.get(action, 0) + 1
|
||||
|
||||
if "total_ops" not in info:
|
||||
info["total_ops"] = {}
|
||||
info["total_ops"][action] = info["total_ops"].get(action, 0) + 1
|
||||
|
||||
self._save_state()
|
||||
|
||||
def get_nurture_plan(self, account_id: str) -> List[dict]:
|
||||
"""
|
||||
生成今日养号行为计划 — 穿插在正式操作之间执行
|
||||
"""
|
||||
plan = []
|
||||
hour = datetime.now().hour
|
||||
activity_weight = self._get_hour_weight(hour)
|
||||
|
||||
behavior_count = max(1, int(random.uniform(2, 5) * activity_weight))
|
||||
selected = random.choices(
|
||||
self.DAILY_BEHAVIORS,
|
||||
weights=[b["weight"] for b in self.DAILY_BEHAVIORS],
|
||||
k=behavior_count,
|
||||
)
|
||||
|
||||
for behavior in selected:
|
||||
dur_min = random.uniform(*behavior["duration_min"])
|
||||
delay_min = random.uniform(5, 30)
|
||||
plan.append({
|
||||
"action": behavior["action"],
|
||||
"duration_sec": int(dur_min * 60),
|
||||
"delay_before_sec": int(delay_min * 60),
|
||||
"hour_weight": activity_weight,
|
||||
})
|
||||
|
||||
return plan
|
||||
|
||||
def get_risk_sentinel_overrides(self, account_id: str) -> Dict[str, Tuple[int, int]]:
|
||||
"""
|
||||
返回适合该账号当前阶段的 RiskSentinel 阈值覆盖
|
||||
|
||||
用法: sentinel = RiskSentinel(custom_limits=scheduler.get_risk_sentinel_overrides(acct))
|
||||
"""
|
||||
overrides = {}
|
||||
for action in self.MATURE_LIMITS:
|
||||
limit = self.get_daily_limit(account_id, action)
|
||||
overrides[action] = (3600, limit)
|
||||
return overrides
|
||||
|
||||
def get_stats(self, account_id: str) -> dict:
|
||||
"""获取养号统计"""
|
||||
info = self._state.get(account_id)
|
||||
if not info:
|
||||
return {"registered": False}
|
||||
|
||||
day = self.get_account_day(account_id)
|
||||
today_key = datetime.now().strftime("%Y-%m-%d")
|
||||
counters = info.get("day_counters", {}).get(today_key, {})
|
||||
|
||||
return {
|
||||
"registered": True,
|
||||
"account_id": account_id,
|
||||
"day": day,
|
||||
"cold_start": day <= self.COLD_START_DAYS,
|
||||
"today_counters": counters,
|
||||
"total_ops": info.get("total_ops", {}),
|
||||
"start_date": info["start_date"],
|
||||
}
|
||||
|
||||
# ---- 内部方法 ----
|
||||
|
||||
def _is_active_hour(self) -> bool:
|
||||
"""当前是否在活跃时段"""
|
||||
hour = datetime.now().hour
|
||||
for start, end, weight in self.ACTIVE_HOURS:
|
||||
if start <= hour < end and weight >= 0.3:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_hour_weight(hour: int) -> float:
|
||||
"""获取当前小时的活跃权重"""
|
||||
for start, end, weight in NurtureScheduler.ACTIVE_HOURS:
|
||||
if start <= hour < end:
|
||||
return weight
|
||||
return 0.1
|
||||
|
||||
def _load_state(self) -> dict:
|
||||
if os.path.exists(self.state_file):
|
||||
try:
|
||||
with open(self.state_file, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"加载养号状态失败: {e}")
|
||||
return {}
|
||||
|
||||
def _save_state(self):
|
||||
try:
|
||||
with open(self.state_file, "w") as f:
|
||||
json.dump(self._state, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"保存养号状态失败: {e}")
|
||||
157
sdk/agent/anti_ban/risk_sentinel.py
Normal file
157
sdk/agent/anti_ban/risk_sentinel.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
风控哨兵 — 实时监控操作频率,行为随机化,触发阈值告警
|
||||
|
||||
功能:
|
||||
1. 操作频率控制 (消息/好友/群发 分开计数)
|
||||
2. 行为随机抖动 (间隔 +-15%~30%)
|
||||
3. 多级告警 (warn → throttle → pause)
|
||||
4. 冷却与恢复
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RiskSentinel:
|
||||
"""风控哨兵 — 守护操作频率边界"""
|
||||
|
||||
# 默认阈值: (时间窗口秒, 最大次数)
|
||||
DEFAULT_LIMITS: Dict[str, Tuple[int, int]] = {
|
||||
"send_message": (3600, 60),
|
||||
"add_friend": (3600, 20),
|
||||
"group_send": (3600, 10),
|
||||
"moment_post": (3600, 5),
|
||||
"moment_like": (3600, 30),
|
||||
"profile_view": (3600, 40),
|
||||
# ── 奥创协议补全写类频控(真机铁律:写类必须受频控约束)──
|
||||
"add_friend_in_room": (3600, 20),
|
||||
"add_friend_from_phonebook": (3600, 20),
|
||||
"add_friend_with_scene": (3600, 20),
|
||||
"add_friend_by_card": (3600, 20),
|
||||
"send_friend_verify": (3600, 20),
|
||||
"join_group_by_qr": (3600, 10),
|
||||
"send_jielong": (3600, 10),
|
||||
"send_multi_image": (3600, 30),
|
||||
"mass_send": (3600, 10),
|
||||
"batch_send": (3600, 10),
|
||||
"reply_moment_comment": (3600, 30),
|
||||
"delete_moment_comment": (3600, 30),
|
||||
"stop_moments_praise": (3600, 20),
|
||||
"start_nurture": (3600, 5),
|
||||
"detect_zombie_fans": (3600, 5),
|
||||
"phone_action": (3600, 10),
|
||||
}
|
||||
|
||||
JITTER_RANGE = (0.15, 0.30)
|
||||
|
||||
def __init__(self, custom_limits: Optional[Dict[str, Tuple[int, int]]] = None):
|
||||
self.limits = {**self.DEFAULT_LIMITS, **(custom_limits or {})}
|
||||
self._counters: Dict[str, list] = defaultdict(list)
|
||||
self._paused_until: Dict[str, float] = {}
|
||||
self._total_ops = 0
|
||||
|
||||
def check(self, action: str) -> dict:
|
||||
"""
|
||||
检查某操作是否允许执行。
|
||||
|
||||
Returns:
|
||||
{"allowed": bool, "wait_sec": float, "level": "ok"|"warn"|"throttle"|"pause"}
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
if action in self._paused_until and now < self._paused_until[action]:
|
||||
remaining = self._paused_until[action] - now
|
||||
return {"allowed": False, "wait_sec": remaining, "level": "pause",
|
||||
"reason": f"{action} 处于冷却期,剩余 {remaining:.0f}s"}
|
||||
|
||||
window, max_count = self.limits.get(action, (3600, 100))
|
||||
timestamps = self._counters[action]
|
||||
cutoff = now - window
|
||||
timestamps[:] = [t for t in timestamps if t > cutoff]
|
||||
current = len(timestamps)
|
||||
|
||||
if current >= max_count:
|
||||
pause_sec = random.uniform(300, 600)
|
||||
self._paused_until[action] = now + pause_sec
|
||||
logger.warning(f"🚨 {action} 达到上限 {max_count}/{window}s,暂停 {pause_sec:.0f}s")
|
||||
return {"allowed": False, "wait_sec": pause_sec, "level": "pause",
|
||||
"reason": f"{action} 触发上限 ({current}/{max_count})"}
|
||||
|
||||
ratio = current / max_count
|
||||
if ratio > 0.8:
|
||||
logger.warning(f"⚠️ {action} 接近上限 ({current}/{max_count})")
|
||||
return {"allowed": True, "wait_sec": 0, "level": "warn",
|
||||
"reason": f"接近上限 {current}/{max_count}"}
|
||||
|
||||
if ratio > 0.6:
|
||||
return {"allowed": True, "wait_sec": 0, "level": "throttle",
|
||||
"reason": f"频率偏高 {current}/{max_count}"}
|
||||
|
||||
return {"allowed": True, "wait_sec": 0, "level": "ok", "reason": ""}
|
||||
|
||||
def can_operate(self, action: str = "send_message") -> bool:
|
||||
"""兼容旧技能调用:保留风控判断,返回是否允许执行。"""
|
||||
result = self.check(action)
|
||||
return bool(result.get("allowed"))
|
||||
|
||||
def record(self, action: str):
|
||||
"""记录一次操作"""
|
||||
self._counters[action].append(time.time())
|
||||
self._total_ops += 1
|
||||
|
||||
def add_jitter(self, base_delay: float) -> float:
|
||||
"""给基准延迟加随机抖动"""
|
||||
jitter_pct = random.uniform(*self.JITTER_RANGE)
|
||||
direction = random.choice([-1, 1])
|
||||
return max(0.5, base_delay * (1 + direction * jitter_pct))
|
||||
|
||||
def get_recommended_delay(self, action: str) -> float:
|
||||
"""根据当前频率推荐操作间隔 (秒)"""
|
||||
window, max_count = self.limits.get(action, (3600, 100))
|
||||
now = time.time()
|
||||
cutoff = now - window
|
||||
recent = [t for t in self._counters[action] if t > cutoff]
|
||||
current = len(recent)
|
||||
|
||||
ratio = current / max_count if max_count > 0 else 0
|
||||
|
||||
if ratio > 0.8:
|
||||
base = random.uniform(30, 60)
|
||||
elif ratio > 0.5:
|
||||
base = random.uniform(10, 25)
|
||||
else:
|
||||
base = random.uniform(3, 8)
|
||||
|
||||
return self.add_jitter(base)
|
||||
|
||||
def reset(self, action: Optional[str] = None):
|
||||
"""重置计数器"""
|
||||
if action:
|
||||
self._counters[action].clear()
|
||||
self._paused_until.pop(action, None)
|
||||
else:
|
||||
self._counters.clear()
|
||||
self._paused_until.clear()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""获取当前风控统计"""
|
||||
now = time.time()
|
||||
stats = {}
|
||||
for action, (window, max_count) in self.limits.items():
|
||||
cutoff = now - window
|
||||
recent = [t for t in self._counters[action] if t > cutoff]
|
||||
paused = action in self._paused_until and now < self._paused_until[action]
|
||||
stats[action] = {
|
||||
"count": len(recent),
|
||||
"limit": max_count,
|
||||
"window_sec": window,
|
||||
"paused": paused,
|
||||
"usage_pct": round(len(recent) / max_count * 100, 1) if max_count > 0 else 0,
|
||||
}
|
||||
stats["total_ops"] = self._total_ops
|
||||
return stats
|
||||
207
sdk/agent/anti_ban/sensor_simulator.py
Normal file
207
sdk/agent/anti_ban/sensor_simulator.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
传感器模拟 — 伪造加速度计/陀螺仪数据,模拟真人手持特征
|
||||
|
||||
功能:
|
||||
1. 加速度计噪声注入 (模拟手持微抖)
|
||||
2. 陀螺仪数据生成 (缓慢旋转漂移)
|
||||
3. 传感器事件写入 /dev/input/ (需 root)
|
||||
4. 非 root 降级为日志记录
|
||||
5. 与 DeviceGuard 联动: 若传感器数 < 5 则触发补偿
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SensorSimulator:
|
||||
"""传感器模拟器 — 让设备看起来像被人拿着"""
|
||||
|
||||
GRAVITY = 9.81
|
||||
INPUT_EVENT_FORMAT = "llHHi" # struct input_event: sec, usec, type, code, value
|
||||
|
||||
def __init__(self, device=None, has_root: bool = False):
|
||||
"""
|
||||
Args:
|
||||
device: uiautomator2 设备对象
|
||||
has_root: 是否有 root 权限
|
||||
"""
|
||||
self.d = device
|
||||
self.has_root = has_root
|
||||
self._running = False
|
||||
self._base_orientation = self._random_orientation()
|
||||
|
||||
def generate_accelerometer_sample(self) -> Dict[str, float]:
|
||||
"""
|
||||
生成一个加速度计采样值 — 模拟静止手持状态
|
||||
|
||||
真实手持特征:
|
||||
- x 轴: 微小随机漂移 (+-0.3 m/s²)
|
||||
- y 轴: 接近 0 (横持) 或接近 gravity (竖持)
|
||||
- z 轴: 接近 gravity (平放) 或接近 0 (竖持)
|
||||
"""
|
||||
ox, oy, oz = self._base_orientation
|
||||
|
||||
noise_x = random.gauss(0, 0.15)
|
||||
noise_y = random.gauss(0, 0.15)
|
||||
noise_z = random.gauss(0, 0.10)
|
||||
|
||||
drift = random.gauss(0, 0.02)
|
||||
self._base_orientation = (
|
||||
ox + drift * random.choice([-1, 1]),
|
||||
oy + drift * random.choice([-1, 1]),
|
||||
oz + drift * random.choice([-1, 1]),
|
||||
)
|
||||
|
||||
return {
|
||||
"x": round(ox + noise_x, 4),
|
||||
"y": round(oy + noise_y, 4),
|
||||
"z": round(oz + noise_z, 4),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
def generate_gyroscope_sample(self) -> Dict[str, float]:
|
||||
"""
|
||||
生成一个陀螺仪采样值 — 模拟静止时的微旋转
|
||||
|
||||
真实特征: 各轴接近 0,偶尔有微小角速度 (rad/s)
|
||||
"""
|
||||
return {
|
||||
"x": round(random.gauss(0, 0.005), 6),
|
||||
"y": round(random.gauss(0, 0.005), 6),
|
||||
"z": round(random.gauss(0, 0.003), 6),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
def generate_batch(self, count: int = 10, interval_ms: int = 20) -> List[dict]:
|
||||
"""生成一批传感器数据 (用于上报或写入)"""
|
||||
samples = []
|
||||
for _ in range(count):
|
||||
samples.append({
|
||||
"accel": self.generate_accelerometer_sample(),
|
||||
"gyro": self.generate_gyroscope_sample(),
|
||||
})
|
||||
time.sleep(interval_ms / 1000)
|
||||
return samples
|
||||
|
||||
def inject_to_device(self, duration_sec: float = 5.0, freq_hz: int = 50):
|
||||
"""
|
||||
向 /dev/input/ 写入伪造传感器事件 (需 root)。
|
||||
非 root 环境降级为日志输出。
|
||||
"""
|
||||
if not self.has_root:
|
||||
logger.info("非 root,传感器模拟降级为日志记录")
|
||||
self._simulate_log_only(duration_sec, freq_hz)
|
||||
return
|
||||
|
||||
input_dev = self._find_sensor_input_device()
|
||||
if not input_dev:
|
||||
logger.warning("未找到传感器 input 设备,降级为日志")
|
||||
self._simulate_log_only(duration_sec, freq_hz)
|
||||
return
|
||||
|
||||
logger.info(f"向 {input_dev} 注入传感器数据 {duration_sec}s @ {freq_hz}Hz")
|
||||
interval = 1.0 / freq_hz
|
||||
end_time = time.time() + duration_sec
|
||||
count = 0
|
||||
|
||||
try:
|
||||
while time.time() < end_time:
|
||||
sample = self.generate_accelerometer_sample()
|
||||
self._write_input_event(input_dev, sample)
|
||||
time.sleep(interval)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"传感器注入中断: {e}")
|
||||
|
||||
logger.info(f"传感器注入完成,写入 {count} 个事件")
|
||||
|
||||
def get_device_sensor_count(self) -> int:
|
||||
"""获取设备实际传感器数量"""
|
||||
if not self.d:
|
||||
return -1
|
||||
try:
|
||||
out = self.d.shell(
|
||||
"dumpsys sensorservice 2>/dev/null | grep -c 'Sensor'"
|
||||
).output.strip()
|
||||
return int(out)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
def check_sensor_health(self) -> dict:
|
||||
"""检查传感器环境是否正常"""
|
||||
count = self.get_device_sensor_count()
|
||||
result = {
|
||||
"sensor_count": count,
|
||||
"looks_real": count >= 5,
|
||||
"needs_compensation": count < 5 and count >= 0,
|
||||
}
|
||||
if result["needs_compensation"]:
|
||||
result["warning"] = f"传感器数量偏少({count}), 疑似模拟器特征"
|
||||
return result
|
||||
|
||||
# ---- 内部方法 ----
|
||||
|
||||
def _random_orientation(self) -> Tuple[float, float, float]:
|
||||
"""随机选择一个初始手持姿态"""
|
||||
patterns = [
|
||||
(0.3, 0.5, self.GRAVITY - 0.2), # 近似平放
|
||||
(0.2, self.GRAVITY * 0.7, self.GRAVITY * 0.7), # 竖持 ~45°
|
||||
(0.1, self.GRAVITY - 0.3, 1.0), # 接近竖持
|
||||
]
|
||||
base = random.choice(patterns)
|
||||
return tuple(v + random.gauss(0, 0.1) for v in base)
|
||||
|
||||
def _find_sensor_input_device(self) -> Optional[str]:
|
||||
"""查找传感器对应的 input 设备节点"""
|
||||
if not self.d:
|
||||
return None
|
||||
try:
|
||||
out = self.d.shell("ls /dev/input/event* 2>/dev/null").output.strip()
|
||||
devices = out.split()
|
||||
for dev in devices:
|
||||
info = self.d.shell(f"cat /proc/bus/input/devices 2>/dev/null").output
|
||||
if "accelerometer" in info.lower() or "accel" in info.lower():
|
||||
return dev
|
||||
return devices[0] if devices else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _write_input_event(self, device_path: str, sample: dict):
|
||||
"""写入一个 input_event 到设备节点"""
|
||||
if not self.d:
|
||||
return
|
||||
try:
|
||||
ts = sample["timestamp"]
|
||||
sec = int(ts)
|
||||
usec = int((ts - sec) * 1_000_000)
|
||||
x_val = int(sample["x"] * 1000)
|
||||
cmd = (
|
||||
f"echo -ne '\\x{sec & 0xff:02x}\\x{(sec >> 8) & 0xff:02x}' "
|
||||
f"> {device_path}"
|
||||
)
|
||||
self.d.shell(cmd)
|
||||
except Exception as e:
|
||||
logger.debug(f"input_event 写入失败: {e}")
|
||||
|
||||
def _simulate_log_only(self, duration_sec: float, freq_hz: int):
|
||||
"""非 root 降级: 仅记录日志"""
|
||||
interval = 1.0 / freq_hz
|
||||
end_time = time.time() + duration_sec
|
||||
count = 0
|
||||
while time.time() < end_time:
|
||||
accel = self.generate_accelerometer_sample()
|
||||
gyro = self.generate_gyroscope_sample()
|
||||
if count % (freq_hz * 2) == 0:
|
||||
logger.debug(
|
||||
f"sensor[log] accel=({accel['x']:.2f},{accel['y']:.2f},{accel['z']:.2f}) "
|
||||
f"gyro=({gyro['x']:.4f},{gyro['y']:.4f},{gyro['z']:.4f})"
|
||||
)
|
||||
time.sleep(interval)
|
||||
count += 1
|
||||
158
sdk/agent/anti_ban/touch_hardener.py
Normal file
158
sdk/agent/anti_ban/touch_hardener.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
触摸加固层 — 将机器精确操作伪装为真人触摸
|
||||
|
||||
功能:
|
||||
1. 坐标随机偏移 (+-3~8px)
|
||||
2. 点击时长随机化 (50~150ms)
|
||||
3. 贝塞尔曲线轨迹滑动 (替代直线滑动)
|
||||
4. 按压-微移-抬起时序模拟
|
||||
5. 可配置的「手抖」程度
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Point = Tuple[float, float]
|
||||
|
||||
|
||||
class TouchHardener:
|
||||
"""触摸事件加固 — 让自动化操作看起来像人"""
|
||||
|
||||
def __init__(self, device=None, tremor_level: float = 1.0):
|
||||
"""
|
||||
Args:
|
||||
device: uiautomator2 设备对象
|
||||
tremor_level: 手抖程度倍数 (0.5=稳手, 1.0=普通, 2.0=抖得厉害)
|
||||
"""
|
||||
self.d = device
|
||||
self.tremor = max(0.1, tremor_level)
|
||||
|
||||
def humanized_click(self, x: int, y: int) -> Tuple[int, int]:
|
||||
"""
|
||||
带随机偏移的点击。返回实际点击坐标。
|
||||
"""
|
||||
offset_x = random.gauss(0, 3 * self.tremor)
|
||||
offset_y = random.gauss(0, 3 * self.tremor)
|
||||
actual_x = max(0, int(x + offset_x))
|
||||
actual_y = max(0, int(y + offset_y))
|
||||
|
||||
duration_ms = random.randint(50, 150)
|
||||
|
||||
if self.d:
|
||||
self.d.click(actual_x, actual_y)
|
||||
|
||||
settle_ms = random.uniform(30, 80)
|
||||
time.sleep(settle_ms / 1000)
|
||||
|
||||
logger.debug(f"click ({x},{y}) → ({actual_x},{actual_y}) dur={duration_ms}ms")
|
||||
return actual_x, actual_y
|
||||
|
||||
def humanized_swipe(self, x1: int, y1: int, x2: int, y2: int,
|
||||
duration: float = 0.5, steps: int = 0) -> List[Point]:
|
||||
"""
|
||||
贝塞尔曲线滑动。返回轨迹点序列。
|
||||
"""
|
||||
sx = x1 + random.gauss(0, 2 * self.tremor)
|
||||
sy = y1 + random.gauss(0, 2 * self.tremor)
|
||||
ex = x2 + random.gauss(0, 2 * self.tremor)
|
||||
ey = y2 + random.gauss(0, 2 * self.tremor)
|
||||
|
||||
ctrl_points = self._random_bezier_controls(sx, sy, ex, ey)
|
||||
|
||||
if steps <= 0:
|
||||
dist = math.hypot(ex - sx, ey - sy)
|
||||
steps = max(8, int(dist / 15))
|
||||
|
||||
trajectory = self._bezier_curve(sx, sy, ex, ey, ctrl_points, steps)
|
||||
|
||||
if self.d:
|
||||
self.d.swipe(int(sx), int(sy), int(ex), int(ey), duration=duration, steps=steps)
|
||||
|
||||
logger.debug(f"swipe ({x1},{y1})→({x2},{y2}) pts={len(trajectory)}")
|
||||
return trajectory
|
||||
|
||||
def humanized_long_press(self, x: int, y: int, duration_ms: int = 800):
|
||||
"""长按 — 带起始微抖"""
|
||||
actual_x = int(x + random.gauss(0, 2 * self.tremor))
|
||||
actual_y = int(y + random.gauss(0, 2 * self.tremor))
|
||||
actual_dur = duration_ms + random.randint(-100, 150)
|
||||
actual_dur = max(300, actual_dur)
|
||||
|
||||
if self.d:
|
||||
self.d.long_click(actual_x, actual_y, duration=actual_dur / 1000)
|
||||
|
||||
logger.debug(f"long_press ({x},{y})→({actual_x},{actual_y}) dur={actual_dur}ms")
|
||||
|
||||
def humanized_type(self, text: str, char_delay_range: Tuple[float, float] = (0.03, 0.12)):
|
||||
"""
|
||||
逐字输入 — 每个字符间隔随机延迟,模拟打字节奏。
|
||||
"""
|
||||
for i, char in enumerate(text):
|
||||
if self.d:
|
||||
self.d.send_keys(char)
|
||||
delay = random.uniform(*char_delay_range)
|
||||
if char in (' ', ',', '.', '。', ','):
|
||||
delay *= random.uniform(1.5, 3.0)
|
||||
time.sleep(delay)
|
||||
logger.debug(f"typed {len(text)} chars")
|
||||
|
||||
def pre_action_pause(self):
|
||||
"""操作前的微停顿 — 模拟人的反应时间"""
|
||||
pause = random.uniform(0.2, 0.8) * self.tremor
|
||||
time.sleep(pause)
|
||||
|
||||
def post_action_pause(self):
|
||||
"""操作后的短暂停顿 — 模拟人看结果"""
|
||||
pause = random.uniform(0.3, 1.2) * self.tremor
|
||||
time.sleep(pause)
|
||||
|
||||
# ---- 内部方法 ----
|
||||
|
||||
@staticmethod
|
||||
def _random_bezier_controls(x1: float, y1: float,
|
||||
x2: float, y2: float) -> List[Point]:
|
||||
"""生成 1~2 个随机控制点,使路径弯曲"""
|
||||
mx = (x1 + x2) / 2
|
||||
my = (y1 + y2) / 2
|
||||
dist = math.hypot(x2 - x1, y2 - y1)
|
||||
spread = dist * random.uniform(0.1, 0.35)
|
||||
|
||||
c1 = (mx + random.gauss(0, spread), my + random.gauss(0, spread))
|
||||
|
||||
if random.random() > 0.5:
|
||||
c2 = (mx + random.gauss(0, spread * 0.6), my + random.gauss(0, spread * 0.6))
|
||||
return [c1, c2]
|
||||
return [c1]
|
||||
|
||||
@staticmethod
|
||||
def _bezier_curve(x1: float, y1: float, x2: float, y2: float,
|
||||
controls: List[Point], steps: int) -> List[Point]:
|
||||
"""计算贝塞尔曲线点"""
|
||||
points: List[Point] = []
|
||||
if len(controls) == 1:
|
||||
cx, cy = controls[0]
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
bx = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * cx + t ** 2 * x2
|
||||
by = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * cy + t ** 2 * y2
|
||||
points.append((bx, by))
|
||||
elif len(controls) >= 2:
|
||||
c1x, c1y = controls[0]
|
||||
c2x, c2y = controls[1]
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
bx = ((1 - t) ** 3 * x1 + 3 * (1 - t) ** 2 * t * c1x +
|
||||
3 * (1 - t) * t ** 2 * c2x + t ** 3 * x2)
|
||||
by = ((1 - t) ** 3 * y1 + 3 * (1 - t) ** 2 * t * c1y +
|
||||
3 * (1 - t) * t ** 2 * c2y + t ** 3 * y2)
|
||||
points.append((bx, by))
|
||||
else:
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
points.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
|
||||
return points
|
||||
Reference in New Issue
Block a user