- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
584 lines
20 KiB
Python
584 lines
20 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import random
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
from xhs_bridge.exceptions import HumanGuardError
|
||
|
||
ProgressFn = Callable[[str], None]
|
||
|
||
# 发布类工具
|
||
PUBLISH_TOOLS = frozenset({"publish_content", "publish_with_video"})
|
||
# 浏览类动作键
|
||
BROWSE_ACTIONS = frozenset({
|
||
"list_feeds",
|
||
"search_feeds",
|
||
"my_account_feeds",
|
||
"get_feed_detail",
|
||
"rest_list_feeds",
|
||
"rest_search_feeds",
|
||
"rest_my_account_feeds",
|
||
"rest_feed_detail",
|
||
})
|
||
# 互动类
|
||
INTERACT_TOOLS = frozenset({
|
||
"post_comment_to_feed",
|
||
"reply_comment_in_feed",
|
||
"like_feed",
|
||
"favorite_feed",
|
||
})
|
||
# 不参与限流
|
||
EXEMPT_TOOLS = frozenset({
|
||
"check_login_status",
|
||
"browser_login",
|
||
"delete_cookies",
|
||
})
|
||
|
||
EXTREME_WORDS = (
|
||
"最好", "第一", "全网最低", "绝对", "100%", "必看", "震惊",
|
||
"秒杀", "顶级", "最强", "唯一", "国家级", "世界级",
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class PublishGuardConfig:
|
||
max_per_day: int = 2
|
||
min_interval_hours: float = 6.0
|
||
pre_delay_min_sec: int = 60
|
||
pre_delay_max_sec: int = 180
|
||
warmup_enabled: bool = True
|
||
warmup_pause_min_sec: int = 30
|
||
warmup_pause_max_sec: int = 90
|
||
post_stay_min_sec: int = 0
|
||
post_stay_max_sec: int = 0
|
||
|
||
|
||
@dataclass
|
||
class BrowseGuardConfig:
|
||
min_interval_sec: int = 25
|
||
max_interval_sec: int = 45
|
||
max_per_hour: int = 20
|
||
|
||
|
||
@dataclass
|
||
class InteractGuardConfig:
|
||
min_interval_sec: int = 45
|
||
max_per_day: int = 10
|
||
|
||
|
||
@dataclass
|
||
class QuietHoursConfig:
|
||
enabled: bool = True
|
||
start: str = "02:00"
|
||
end: str = "07:00"
|
||
|
||
|
||
@dataclass
|
||
class ContentGuardConfig:
|
||
max_title_len: int = 20
|
||
warn_extreme_words: bool = True
|
||
|
||
|
||
@dataclass
|
||
class HumanGuardConfig:
|
||
enabled: bool = True
|
||
publish: PublishGuardConfig = field(default_factory=PublishGuardConfig)
|
||
browse: BrowseGuardConfig = field(default_factory=BrowseGuardConfig)
|
||
interact: InteractGuardConfig = field(default_factory=InteractGuardConfig)
|
||
quiet_hours: QuietHoursConfig = field(default_factory=QuietHoursConfig)
|
||
content: ContentGuardConfig = field(default_factory=ContentGuardConfig)
|
||
|
||
|
||
def parse_human_guard_config(raw: dict[str, Any] | None) -> HumanGuardConfig:
|
||
raw = raw or {}
|
||
pub = raw.get("publish") or {}
|
||
br = raw.get("browse") or {}
|
||
inter = raw.get("interact") or {}
|
||
quiet = raw.get("quiet_hours") or {}
|
||
content = raw.get("content") or {}
|
||
|
||
return HumanGuardConfig(
|
||
enabled=bool(raw.get("enabled", True)),
|
||
publish=PublishGuardConfig(
|
||
max_per_day=int(pub.get("max_per_day", 2)),
|
||
min_interval_hours=float(pub.get("min_interval_hours", 6.0)),
|
||
pre_delay_min_sec=int(pub.get("pre_delay_min_sec", 60)),
|
||
pre_delay_max_sec=int(pub.get("pre_delay_max_sec", 180)),
|
||
warmup_enabled=bool(pub.get("warmup_enabled", True)),
|
||
warmup_pause_min_sec=int(pub.get("warmup_pause_min_sec", 30)),
|
||
warmup_pause_max_sec=int(pub.get("warmup_pause_max_sec", 90)),
|
||
post_stay_min_sec=int(pub.get("post_stay_min_sec", 0)),
|
||
post_stay_max_sec=int(pub.get("post_stay_max_sec", 0)),
|
||
),
|
||
browse=BrowseGuardConfig(
|
||
min_interval_sec=int(br.get("min_interval_sec", 25)),
|
||
max_interval_sec=int(br.get("max_interval_sec", 45)),
|
||
max_per_hour=int(br.get("max_per_hour", 20)),
|
||
),
|
||
interact=InteractGuardConfig(
|
||
min_interval_sec=int(inter.get("min_interval_sec", 45)),
|
||
max_per_day=int(inter.get("max_per_day", 10)),
|
||
),
|
||
quiet_hours=QuietHoursConfig(
|
||
enabled=bool(quiet.get("enabled", True)),
|
||
start=str(quiet.get("start", "02:00")),
|
||
end=str(quiet.get("end", "07:00")),
|
||
),
|
||
content=ContentGuardConfig(
|
||
max_title_len=int(content.get("max_title_len", 20)),
|
||
warn_extreme_words=bool(content.get("warn_extreme_words", True)),
|
||
),
|
||
)
|
||
|
||
|
||
def human_guard_config_to_dict(cfg: HumanGuardConfig) -> dict[str, Any]:
|
||
"""序列化为 config.yaml 的 human_guard 段。"""
|
||
return {
|
||
"enabled": cfg.enabled,
|
||
"publish": {
|
||
"max_per_day": cfg.publish.max_per_day,
|
||
"min_interval_hours": cfg.publish.min_interval_hours,
|
||
"pre_delay_min_sec": cfg.publish.pre_delay_min_sec,
|
||
"pre_delay_max_sec": cfg.publish.pre_delay_max_sec,
|
||
"warmup_enabled": cfg.publish.warmup_enabled,
|
||
"warmup_pause_min_sec": cfg.publish.warmup_pause_min_sec,
|
||
"warmup_pause_max_sec": cfg.publish.warmup_pause_max_sec,
|
||
"post_stay_min_sec": cfg.publish.post_stay_min_sec,
|
||
"post_stay_max_sec": cfg.publish.post_stay_max_sec,
|
||
},
|
||
"browse": {
|
||
"min_interval_sec": cfg.browse.min_interval_sec,
|
||
"max_interval_sec": cfg.browse.max_interval_sec,
|
||
"max_per_hour": cfg.browse.max_per_hour,
|
||
},
|
||
"interact": {
|
||
"min_interval_sec": cfg.interact.min_interval_sec,
|
||
"max_per_day": cfg.interact.max_per_day,
|
||
},
|
||
"quiet_hours": {
|
||
"enabled": cfg.quiet_hours.enabled,
|
||
"start": cfg.quiet_hours.start,
|
||
"end": cfg.quiet_hours.end,
|
||
},
|
||
"content": {
|
||
"max_title_len": cfg.content.max_title_len,
|
||
"warn_extreme_words": cfg.content.warn_extreme_words,
|
||
},
|
||
}
|
||
|
||
|
||
def validate_human_guard_config(cfg: HumanGuardConfig) -> list[str]:
|
||
"""校验配置合法性,返回错误信息列表(空=通过)。"""
|
||
errors: list[str] = []
|
||
pub = cfg.publish
|
||
if pub.pre_delay_max_sec < pub.pre_delay_min_sec:
|
||
errors.append("发布前等待:上限不能小于下限")
|
||
if pub.warmup_pause_max_sec < pub.warmup_pause_min_sec:
|
||
errors.append("预热停留:上限不能小于下限")
|
||
if pub.post_stay_max_sec < pub.post_stay_min_sec:
|
||
errors.append("发布后停留:上限不能小于下限")
|
||
if cfg.browse.max_interval_sec < cfg.browse.min_interval_sec:
|
||
errors.append("浏览间隔:上限不能小于下限")
|
||
for label, value in (
|
||
("每日发布上限", pub.max_per_day),
|
||
("发布最短间隔(小时)", pub.min_interval_hours),
|
||
):
|
||
if value <= 0:
|
||
errors.append(f"{label} 须大于 0")
|
||
if cfg.browse.max_per_hour <= 0:
|
||
errors.append("每小时浏览上限须大于 0")
|
||
if cfg.interact.max_per_day <= 0:
|
||
errors.append("每日互动上限须大于 0")
|
||
for label, text in (("静默开始", cfg.quiet_hours.start), ("静默结束", cfg.quiet_hours.end)):
|
||
parts = text.strip().split(":")
|
||
if len(parts) != 2:
|
||
errors.append(f"{label} 须为 HH:MM 格式")
|
||
continue
|
||
try:
|
||
h, m = int(parts[0]), int(parts[1])
|
||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||
raise ValueError
|
||
except ValueError:
|
||
errors.append(f"{label} 须为有效时间(00:00~23:59)")
|
||
return errors
|
||
|
||
|
||
class HumanGuard:
|
||
"""单账号拟人化节奏与频率控制(本机浏览器引擎,不涉及多 IP / 多账号)。"""
|
||
|
||
def __init__(self, config: HumanGuardConfig, state_path: Path) -> None:
|
||
self.config = config
|
||
self.state_path = state_path
|
||
self._state: dict[str, Any] = {}
|
||
self._load_state()
|
||
|
||
def _load_state(self) -> None:
|
||
if self.state_path.is_file():
|
||
try:
|
||
with self.state_path.open("r", encoding="utf-8") as f:
|
||
self._state = json.load(f)
|
||
except (json.JSONDecodeError, OSError):
|
||
self._state = {}
|
||
if not isinstance(self._state, dict):
|
||
self._state = {}
|
||
|
||
def _save_state(self) -> None:
|
||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with self.state_path.open("w", encoding="utf-8") as f:
|
||
json.dump(self._state, f, ensure_ascii=False, indent=2)
|
||
|
||
@staticmethod
|
||
def _today_key() -> str:
|
||
return date.today().isoformat()
|
||
|
||
def _daily_count(self, key: str) -> int:
|
||
daily = self._state.setdefault("daily", {})
|
||
day = daily.get(self._today_key(), {})
|
||
return int(day.get(key, 0))
|
||
|
||
def _inc_daily(self, key: str) -> None:
|
||
daily = self._state.setdefault("daily", {})
|
||
today = self._today_key()
|
||
day = daily.setdefault(today, {})
|
||
day[key] = int(day.get(key, 0)) + 1
|
||
# 清理 7 天前的记录
|
||
cutoff = (date.today() - timedelta(days=7)).isoformat()
|
||
for old in list(daily.keys()):
|
||
if old < cutoff:
|
||
del daily[old]
|
||
self._save_state()
|
||
|
||
def _last_ts(self, key: str) -> float | None:
|
||
val = self._state.get(key)
|
||
return float(val) if val is not None else None
|
||
|
||
def _set_last_ts(self, key: str) -> None:
|
||
self._state[key] = time.time()
|
||
self._save_state()
|
||
|
||
@staticmethod
|
||
def _parse_hhmm(text: str) -> tuple[int, int]:
|
||
parts = text.strip().split(":")
|
||
h = int(parts[0]) if parts else 0
|
||
m = int(parts[1]) if len(parts) > 1 else 0
|
||
return h, m
|
||
|
||
def _in_quiet_hours(self, now: datetime | None = None) -> bool:
|
||
qh = self.config.quiet_hours
|
||
if not qh.enabled:
|
||
return False
|
||
now = now or datetime.now()
|
||
sh, sm = self._parse_hhmm(qh.start)
|
||
eh, em = self._parse_hhmm(qh.end)
|
||
cur = now.hour * 60 + now.minute
|
||
start = sh * 60 + sm
|
||
end = eh * 60 + em
|
||
if start <= end:
|
||
return start <= cur < end
|
||
return cur >= start or cur < end
|
||
|
||
def _browse_count_last_hour(self) -> int:
|
||
events = self._state.get("browse_events") or []
|
||
cutoff = time.time() - 3600
|
||
recent = [t for t in events if float(t) >= cutoff]
|
||
self._state["browse_events"] = recent[-200:]
|
||
return len(recent)
|
||
|
||
def _record_browse_event(self) -> None:
|
||
events = self._state.setdefault("browse_events", [])
|
||
events.append(time.time())
|
||
self._state["browse_events"] = events[-200:]
|
||
self._save_state()
|
||
|
||
@staticmethod
|
||
def _sleep_random(min_sec: float, max_sec: float, on_progress: ProgressFn | None, label: str) -> float:
|
||
if max_sec <= 0:
|
||
return 0.0
|
||
duration = random.uniform(min_sec, max(max_sec, min_sec))
|
||
if on_progress:
|
||
on_progress(f"{label}(约 {duration:.0f} 秒)…")
|
||
time.sleep(duration)
|
||
return duration
|
||
|
||
def validate_publish_content(self, arguments: dict[str, Any]) -> list[str]:
|
||
"""内容合规提示(不阻断,仅警告)。"""
|
||
warnings: list[str] = []
|
||
cfg = self.config.content
|
||
title = str(arguments.get("title") or "").strip()
|
||
content = str(arguments.get("content") or "").strip()
|
||
|
||
if title and len(title) > cfg.max_title_len:
|
||
warnings.append(
|
||
f"标题 {len(title)} 字,建议 ≤ {cfg.max_title_len} 字(过长可能影响推荐)"
|
||
)
|
||
if content and len(content) > 1000:
|
||
warnings.append("正文较长,建议控制在 200~800 字更易阅读")
|
||
if cfg.warn_extreme_words:
|
||
blob = title + content
|
||
hits = [w for w in EXTREME_WORDS if w in blob]
|
||
if hits:
|
||
warnings.append(f"含营销敏感词:{', '.join(hits[:5])}")
|
||
return warnings
|
||
|
||
def check_can_publish(self, now: datetime | None = None) -> tuple[bool, str]:
|
||
if not self.config.enabled:
|
||
return True, ""
|
||
|
||
now = now or datetime.now()
|
||
pub = self.config.publish
|
||
|
||
if self._in_quiet_hours(now):
|
||
return False, f"静默时段({self.config.quiet_hours.start}~{self.config.quiet_hours.end})不建议发布"
|
||
|
||
today_count = self._daily_count("publish")
|
||
if today_count >= pub.max_per_day:
|
||
return False, f"今日已发布 {today_count} 篇,达到每日上限 {pub.max_per_day}"
|
||
|
||
last = self._last_ts("last_publish")
|
||
if last is not None:
|
||
min_gap = pub.min_interval_hours * 3600
|
||
elapsed = time.time() - last
|
||
if elapsed < min_gap:
|
||
wait_min = int((min_gap - elapsed) / 60) + 1
|
||
return False, f"距上次发布未满 {pub.min_interval_hours:g} 小时,建议再等约 {wait_min} 分钟"
|
||
|
||
return True, ""
|
||
|
||
def check_can_browse(self) -> tuple[bool, str]:
|
||
if not self.config.enabled:
|
||
return True, ""
|
||
|
||
br = self.config.browse
|
||
if self._browse_count_last_hour() >= br.max_per_hour:
|
||
return False, f"近 1 小时浏览请求已达 {br.max_per_hour} 次,请稍后再试"
|
||
|
||
last = self._last_ts("last_browse")
|
||
if last is not None:
|
||
gap = random.uniform(br.min_interval_sec, br.max_interval_sec)
|
||
elapsed = time.time() - last
|
||
if elapsed < gap:
|
||
wait = int(gap - elapsed) + 1
|
||
return False, f"浏览过快,建议 {wait} 秒后再操作"
|
||
|
||
return True, ""
|
||
|
||
def check_can_interact(self, tool_name: str) -> tuple[bool, str]:
|
||
if not self.config.enabled:
|
||
return True, ""
|
||
|
||
inter = self.config.interact
|
||
today = self._daily_count("interact")
|
||
if today >= inter.max_per_day:
|
||
return False, f"今日互动已达 {inter.max_per_day} 次上限"
|
||
|
||
last = self._last_ts("last_interact")
|
||
if last is not None:
|
||
elapsed = time.time() - last
|
||
if elapsed < inter.min_interval_sec:
|
||
wait = int(inter.min_interval_sec - elapsed) + 1
|
||
return False, f"互动过快({tool_name}),建议 {wait} 秒后再试"
|
||
|
||
return True, ""
|
||
|
||
@staticmethod
|
||
def _sleep_interruptible(
|
||
seconds: float,
|
||
on_progress: ProgressFn | None,
|
||
label: str,
|
||
cancel_check: Callable[[], bool] | None,
|
||
) -> None:
|
||
if seconds <= 0:
|
||
return
|
||
if on_progress:
|
||
on_progress(f"{label} {seconds:.0f} 秒…")
|
||
deadline = time.monotonic() + seconds
|
||
while time.monotonic() < deadline:
|
||
if cancel_check and cancel_check():
|
||
return
|
||
time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
|
||
|
||
def wait_before_browse(
|
||
self,
|
||
action: str,
|
||
on_progress: ProgressFn | None = None,
|
||
*,
|
||
cancel_check: Callable[[], bool] | None = None,
|
||
) -> None:
|
||
if not self.config.enabled:
|
||
if on_progress:
|
||
on_progress("防封号节奏:完成")
|
||
return
|
||
|
||
ok, reason = self.check_can_browse()
|
||
if not ok:
|
||
raise HumanGuardError(reason)
|
||
|
||
last = self._last_ts("last_browse")
|
||
if last is not None:
|
||
br = self.config.browse
|
||
gap = random.uniform(br.min_interval_sec, br.max_interval_sec)
|
||
elapsed = time.time() - last
|
||
if elapsed < gap:
|
||
wait = gap - elapsed
|
||
if on_progress:
|
||
on_progress("防封号节奏:等待中…")
|
||
self._sleep_interruptible(
|
||
wait,
|
||
on_progress,
|
||
"浏览节奏控制:等待",
|
||
cancel_check,
|
||
)
|
||
if on_progress:
|
||
on_progress("防封号节奏:完成")
|
||
|
||
def record_browse(self, action: str) -> None:
|
||
self._set_last_ts("last_browse")
|
||
self._record_browse_event()
|
||
self._inc_daily("browse")
|
||
|
||
def wait_before_interact(self, tool_name: str, on_progress: ProgressFn | None = None) -> None:
|
||
if not self.config.enabled:
|
||
return
|
||
|
||
ok, reason = self.check_can_interact(tool_name)
|
||
if not ok:
|
||
raise HumanGuardError(reason)
|
||
|
||
last = self._last_ts("last_interact")
|
||
if last is not None:
|
||
gap = self.config.interact.min_interval_sec
|
||
elapsed = time.time() - last
|
||
if elapsed < gap:
|
||
wait = gap - elapsed
|
||
if on_progress:
|
||
on_progress(f"互动节奏:等待 {wait:.0f} 秒…")
|
||
time.sleep(wait)
|
||
|
||
def record_interact(self, tool_name: str) -> None:
|
||
self._set_last_ts("last_interact")
|
||
self._inc_daily("interact")
|
||
|
||
def run_publish_prelude(
|
||
self,
|
||
warmup_fn: Callable[[], None] | None,
|
||
on_progress: ProgressFn | None = None,
|
||
) -> list[str]:
|
||
"""
|
||
发布前:校验 → 可选预热浏览 → 随机等待。
|
||
返回各阶段日志行。
|
||
"""
|
||
log: list[str] = []
|
||
if not self.config.enabled:
|
||
log.append("拟人化防护已关闭")
|
||
return log
|
||
|
||
ok, reason = self.check_can_publish()
|
||
if not ok:
|
||
raise HumanGuardError(reason)
|
||
|
||
pub = self.config.publish
|
||
|
||
if pub.warmup_enabled and warmup_fn is not None:
|
||
if on_progress:
|
||
on_progress("发布前预热:浏览首页推荐…")
|
||
log.append("预热:拉取首页推荐")
|
||
try:
|
||
warmup_fn()
|
||
except Exception as exc:
|
||
log.append(f"预热浏览失败(已跳过):{exc}")
|
||
else:
|
||
pause = self._sleep_random(
|
||
pub.warmup_pause_min_sec,
|
||
pub.warmup_pause_max_sec,
|
||
on_progress,
|
||
"预热后停留",
|
||
)
|
||
log.append(f"预热停留 {pause:.0f}s")
|
||
|
||
pre = self._sleep_random(
|
||
pub.pre_delay_min_sec,
|
||
pub.pre_delay_max_sec,
|
||
on_progress,
|
||
"发布前缓冲",
|
||
)
|
||
log.append(f"发布前等待 {pre:.0f}s")
|
||
return log
|
||
|
||
def record_publish(self) -> None:
|
||
self._set_last_ts("last_publish")
|
||
self._inc_daily("publish")
|
||
|
||
def run_publish_postlude(self, on_progress: ProgressFn | None = None) -> list[str]:
|
||
log: list[str] = []
|
||
pub = self.config.publish
|
||
if not self.config.enabled:
|
||
return log
|
||
if pub.post_stay_max_sec <= 0:
|
||
return log
|
||
stay = self._sleep_random(
|
||
pub.post_stay_min_sec,
|
||
pub.post_stay_max_sec,
|
||
on_progress,
|
||
"发布后停留",
|
||
)
|
||
log.append(f"发布后停留 {stay:.0f}s")
|
||
return log
|
||
|
||
def status_summary(self) -> dict[str, Any]:
|
||
"""供 GUI / CLI 展示当前配额与下次可发布时间。"""
|
||
pub = self.config.publish
|
||
today_pub = self._daily_count("publish")
|
||
today_interact = self._daily_count("interact")
|
||
browse_hour = self._browse_count_last_hour()
|
||
|
||
next_publish_hint = ""
|
||
last_pub = self._last_ts("last_publish")
|
||
if last_pub is not None and pub.min_interval_hours > 0:
|
||
next_at = last_pub + pub.min_interval_hours * 3600
|
||
if next_at > time.time():
|
||
next_publish_hint = datetime.fromtimestamp(next_at).strftime("%H:%M")
|
||
|
||
can_pub, pub_reason = self.check_can_publish()
|
||
|
||
return {
|
||
"enabled": self.config.enabled,
|
||
"quiet_hours_active": self._in_quiet_hours(),
|
||
"publish_today": today_pub,
|
||
"publish_max_per_day": pub.max_per_day,
|
||
"publish_can": can_pub,
|
||
"publish_block_reason": pub_reason,
|
||
"next_publish_after": next_publish_hint,
|
||
"interact_today": today_interact,
|
||
"interact_max_per_day": self.config.interact.max_per_day,
|
||
"browse_last_hour": browse_hour,
|
||
"browse_max_per_hour": self.config.browse.max_per_hour,
|
||
"last_publish_at": (
|
||
datetime.fromtimestamp(last_pub).isoformat(timespec="minutes")
|
||
if last_pub else None
|
||
),
|
||
}
|
||
|
||
def format_status_text(self) -> str:
|
||
s = self.status_summary()
|
||
if not s["enabled"]:
|
||
return "拟人化防护:已关闭"
|
||
lines = [
|
||
f"今日发布 {s['publish_today']}/{s['publish_max_per_day']}",
|
||
f"今日互动 {s['interact_today']}/{s['interact_max_per_day']}",
|
||
f"近1h浏览 {s['browse_last_hour']}/{s['browse_max_per_hour']}",
|
||
]
|
||
if s["quiet_hours_active"]:
|
||
lines.append("当前处于静默时段")
|
||
if s["publish_can"]:
|
||
lines.append("当前可以发布")
|
||
else:
|
||
lines.append(f"发布受限:{s['publish_block_reason']}")
|
||
if s["next_publish_after"]:
|
||
lines.append(f"建议下次发布不早于 {s['next_publish_after']}")
|
||
return " · ".join(lines)
|