front/xhs-agent-desktop/engine/scripts/xhs/note_detail_modal.py
zzh 5a26ae0048 feat: 小红书模块迁移、登录重构与 xhs-agent-desktop 桌面 Agent
- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手)

- 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度

- 登录页重构,整合微信登录与 OMS JWT 认证

- 订单、渠道、用户中心等页面与 API 增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:13:13 +08:00

496 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""养号场景:首页推荐 / 搜索列表 — 点击笔记打开弹窗拟人(不走路由、不拉详情)。
- 场景 1、2:list-feeds / search-feeds 浏览采集时点开笔记 → 本模块
- 场景 3:按 feed_id 查详情 → 使用 feed_detail.navigate_to_feed_detail / get_feed_detail(路由)
"""
from __future__ import annotations
import json
import logging
import random
import re
import time
from typing import TYPE_CHECKING, Literal
from .collect_buffer import is_cancelled
from .human import (
HOVER_TIME,
INACCESSIBLE_KEYWORDS,
POST_SCROLL,
get_scroll_interval,
get_scroll_ratio,
sleep_random,
)
from .human import _sleep_interruptible as sleep_interruptible_sec
if TYPE_CHECKING:
from .cdp import Page
logger = logging.getLogger(__name__)
BrowseScene = Literal["home", "search"]
_INACCESSIBLE_EXTRA = (
"页面不见了",
"你访问的页面不见了",
"error_code=300031",
)
# 仅计算封面点击坐标,不在页面内 dispatchEvent(避免触发 Vue Router 整页跳转)
_PICK_RANDOM_NOTE_CLICK_TARGET_JS = """
(() => {
const cards = Array.from(document.querySelectorAll(
'#exploreFeeds section.note-item, section.note-item, .feeds-container section.note-item'
));
const visible = cards.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 40 && r.height > 40 && r.top < window.innerHeight && r.bottom > 0;
});
if (!visible.length) {
const anchors = Array.from(document.querySelectorAll('a[href*="/explore/"]'))
.filter(a => /\\/explore\\/[a-f0-9]{16,24}/i.test(a.getAttribute('href') || a.href || ''));
if (!anchors.length) return { ok: false, reason: 'no-anchor' };
const a = anchors[Math.floor(Math.random() * anchors.length)];
const card = a.closest('section.note-item') || a.closest('section') || a.parentElement;
if (!card) return { ok: false, reason: 'no-card' };
card.scrollIntoView({ block: 'center', inline: 'nearest' });
const cover = a.querySelector('a.cover img, a.cover, img');
const hit = cover || a;
const r = hit.getBoundingClientRect();
return {
ok: true,
x: Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)),
y: Math.round(r.top + r.height * (0.38 + Math.random() * 0.24)),
via: 'anchor-fallback',
};
}
const card = visible[Math.floor(Math.random() * visible.length)];
card.scrollIntoView({ block: 'center', inline: 'nearest' });
const cover = card.querySelector('a.cover img, a.cover, img');
const hit = cover || card.querySelector('a.cover') || card;
const r = hit.getBoundingClientRect();
if (r.width < 30 || r.height < 30) return { ok: false, reason: 'too-small' };
return {
ok: true,
x: Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)),
y: Math.round(r.top + r.height * (0.38 + Math.random() * 0.24)),
via: 'section-note-item',
};
})()
"""
_INJECT_BLOCK_NAV_JS = """
(ms) => {
const until = Date.now() + ms;
const cleanup = () => {
for (const type of ['click', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick']) {
document.removeEventListener(type, window.__xhsBrowseBlockNav, true);
}
window.__xhsBrowseBlockNav = null;
window.__xhsBrowseBlockNavUntil = 0;
};
if (window.__xhsBrowseBlockNav) cleanup();
window.__xhsBrowseBlockNavUntil = until;
window.__xhsBrowseBlockNav = function(e) {
if (Date.now() > window.__xhsBrowseBlockNavUntil) return;
const link = e.target && e.target.closest
? e.target.closest('a[href*="/explore/"]') : null;
if (!link) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
};
for (const type of ['click', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick']) {
document.addEventListener(type, window.__xhsBrowseBlockNav, true);
}
setTimeout(cleanup, ms);
return until;
}
"""
_MODAL_VISIBLE_JS = """
(() => {
const sels = [
'.note-detail-mask',
'.note-container',
'[class*="note-detail"]',
'[class*="NoteDetail"]',
'.note-detail',
'#noteContainer',
'.engage-bar',
];
for (const sel of sels) {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
const st = getComputedStyle(el);
if (r.width > 100 && r.height > 100
&& st.display !== 'none' && st.visibility !== 'hidden'
&& parseFloat(st.opacity || '1') > 0.05) {
return true;
}
}
}
return false;
})()
"""
_IS_LIST_PAGE_JS = """
(() => {
const p = location.pathname || '';
const h = location.href || '';
if (h.includes('search_result')) return true;
if (p === '/explore' || p === '/explore/') return true;
if (p.startsWith('/explore') && !/\\/explore\\/[a-f0-9]{16,24}/i.test(p)) return true;
return false;
})()
"""
_BLOCKED_KWS_JSON = json.dumps(
list(INACCESSIBLE_KEYWORDS) + list(_INACCESSIBLE_EXTRA),
ensure_ascii=False,
)
_BLOCKED_PAGE_JS = f"""
(() => {{
const text = ((document.body && document.body.innerText) || '') + ' '
+ (location.href || '');
const kws = {_BLOCKED_KWS_JSON};
for (const kw of kws) {{
if (kw && text.includes(kw)) return kw;
}}
if ((location.href || '').includes('/404')) return '404';
return '';
}})()
"""
_FULL_NOTE_PAGE_RE = re.compile(r"/explore/[a-f0-9]{16,24}", re.I)
_SCROLL_MODAL_FN = """
(delta) => {
const roots = [
document.querySelector('.note-scroller'),
document.querySelector('.note-detail'),
document.querySelector('[class*="note-detail"]'),
document.querySelector('.interaction-container'),
document.querySelector('.note-container'),
document.querySelector('[class*="NoteDetail"]'),
].filter(Boolean);
let el = roots.find(r => r.scrollHeight > r.clientHeight + 40);
if (!el) el = document.scrollingElement || document.documentElement;
el.scrollBy({ top: delta, behavior: 'instant' });
}
"""
_PICK_MASK_CLICK_TARGET_JS = """
(() => {
const mask = document.querySelector('.note-detail-mask')
|| document.querySelector('[class*="note-detail-mask"]')
|| document.querySelector('[class*="Mask"]');
const panel = document.querySelector('.note-container')
|| document.querySelector('[class*="note-detail"]')
|| document.querySelector('#noteContainer');
const vw = window.innerWidth;
const vh = window.innerHeight;
const outsidePanel = (x, y, pr) => {
if (!pr || pr.width < 10) return true;
const pad = 12;
return x < pr.left - pad || x > pr.right + pad
|| y < pr.top - pad || y > pr.bottom + pad;
};
const pr = panel ? panel.getBoundingClientRect() : null;
const candidates = [];
if (mask) {
const mr = mask.getBoundingClientRect();
candidates.push(
{ x: mr.left + 24, y: mr.top + mr.height / 2 },
{ x: mr.right - 24, y: mr.top + mr.height / 2 },
{ x: mr.left + mr.width / 2, y: mr.top + 18 },
{ x: mr.left + mr.width / 2, y: mr.bottom - 18 },
);
}
candidates.push(
{ x: 28, y: vh / 2 },
{ x: vw - 28, y: vh / 2 },
{ x: vw / 2, y: 24 },
);
for (const c of candidates) {
if (outsidePanel(c.x, c.y, pr)) return c;
}
return { x: 32, y: vh / 2 };
})()
"""
_CLOSE_MODAL_JS = """
(() => {
const sels = [
'.close-circle',
'.close-box',
'.close-wrapper',
'[class*="close-icon"]',
'[class*="CloseIcon"]',
'svg.close',
];
for (const sel of sels) {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width >= 10 && r.height >= 10) {
const btn = el.closest('button,[role="button"],div') || el;
btn.click();
return true;
}
}
}
return false;
})()
"""
def _mouse_click_only(page: Page, x: float, y: float) -> None:
"""仅 CDP 鼠标点击,不触发 element.click()(避免点到 <a> 走路由)。"""
jx = x + random.uniform(-4, 4)
jy = y + random.uniform(-4, 4)
page.mouse_move(jx, jy)
time.sleep(random.uniform(0.08, 0.16))
page.mouse_click(jx, jy)
def _inject_block_link_navigation(page: Page, ms: int = 2800) -> None:
page.evaluate(f"({ _INJECT_BLOCK_NAV_JS })({int(ms)})")
def _is_on_list_page(page: Page) -> bool:
return page.evaluate(_IS_LIST_PAGE_JS) is True
def _navigated_to_full_note_page(page: Page) -> bool:
href = page.evaluate("window.location.href") or ""
if "/404" in href:
return True
if _FULL_NOTE_PAGE_RE.search(href) and page.evaluate(_MODAL_VISIBLE_JS) is not True:
return not _is_on_list_page(page)
return False
def _recover_from_wrong_navigation(page: Page, list_url: str) -> None:
"""误走路由页时优先 back,避免 navigate 再次触发风控。"""
for _ in range(2):
if _is_on_list_page(page) and page.evaluate(_MODAL_VISIBLE_JS) is not True:
return
try:
page.evaluate("window.history.back()")
page.wait_for_load()
sleep_random(500, 900)
except Exception as exc:
logger.debug("history.back: %s", exc)
break
if not _is_on_list_page(page) and list_url:
logger.debug("仍不在列表页,最后尝试 navigate: %s", list_url)
page.navigate(list_url)
page.wait_for_load()
page.wait_dom_stable()
def _wait_modal(page: Page, timeout: float = 12.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if is_cancelled():
return False
if page.evaluate(_MODAL_VISIBLE_JS) is True:
return True
time.sleep(0.3)
return False
def _wait_modal_closed(page: Page, timeout: float = 6.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if is_cancelled():
return True
if page.evaluate(_MODAL_VISIBLE_JS) is not True:
return True
time.sleep(0.25)
return False
def _click_mask_to_close(page: Page) -> None:
target = page.evaluate(_PICK_MASK_CLICK_TARGET_JS)
if isinstance(target, dict):
_mouse_click_only(page, float(target.get("x", 32)), float(target.get("y", 200)))
else:
_mouse_click_only(page, 32, page.get_viewport_height() / 2)
def _blocked_reason(page: Page) -> str:
reason = page.evaluate(_BLOCKED_PAGE_JS)
return str(reason).strip() if reason else ""
def _click_random_note_on_page(page: Page) -> bool:
"""在列表 DOM 上用 CDP 真实鼠标点击封面;长期拦截 /explore/ 链接默认路由。"""
list_href = page.evaluate("window.location.href") or ""
_inject_block_link_navigation(page, 8000)
result = page.evaluate(_PICK_RANDOM_NOTE_CLICK_TARGET_JS)
if not isinstance(result, dict) or not result.get("ok"):
logger.debug("未找到可点击笔记卡片: %s", result)
return False
x = float(result.get("x", 0))
y = float(result.get("y", 0))
if x <= 0 or y <= 0:
return False
logger.debug(
"CDP 点击封面 (%.0f, %.0f) via=%s",
x,
y,
result.get("via", ""),
)
_mouse_click_only(page, x, y)
sleep_random(600, 1200)
if _wait_modal(page, timeout=12.0):
return True
if _navigated_to_full_note_page(page):
logger.info("CDP 点击后误进笔记路由页,history.back 恢复列表")
_recover_from_wrong_navigation(page, list_href)
sleep_random(400, 800)
if _wait_modal(page, timeout=5.0):
return True
return False
# 未出现弹窗也未走路由:再试一次(仍不 dispatch)
_inject_block_link_navigation(page, 5000)
result2 = page.evaluate(_PICK_RANDOM_NOTE_CLICK_TARGET_JS)
if isinstance(result2, dict) and result2.get("ok"):
x2 = float(result2.get("x", 0))
y2 = float(result2.get("y", 0))
if x2 > 0 and y2 > 0:
_mouse_click_only(page, x2, y2)
sleep_random(600, 1200)
if _wait_modal(page, timeout=10.0):
return True
if _navigated_to_full_note_page(page):
_recover_from_wrong_navigation(page, list_href)
return False
def open_random_note_modal(page: Page, *, scene: BrowseScene) -> bool:
"""养号:随机点击列表笔记打开弹窗,绝不 page.navigate 到笔记 URL。"""
list_href = page.evaluate("window.location.href") or ""
if not _click_random_note_on_page(page):
logger.debug("[%s] 页面点击未打开弹窗", scene)
if _navigated_to_full_note_page(page):
_recover_from_wrong_navigation(page, list_href)
return False
if _navigated_to_full_note_page(page):
logger.info("[%s] 点击后误进路由页,已回退列表", scene)
_recover_from_wrong_navigation(page, list_href)
return False
blocked = _blocked_reason(page)
if blocked:
logger.info("[%s] 弹窗不可浏览: %s", scene, blocked)
close_browse_note_modal(page, list_href)
return False
return True
def dwell_in_browse_note_modal(
page: Page,
scroll_speed: str,
*,
stay_sec: float,
scroll_count: int,
) -> None:
"""弹窗内拟人停留:详情区滚动 + 补足停留秒数(不解析数据)。"""
page.wait_dom_stable()
budget = max(10.0, float(stay_sec))
scrolls = max(1, int(scroll_count))
started = time.monotonic()
viewport = max(400, page.get_viewport_height())
ratio = get_scroll_ratio(scroll_speed)
for i in range(scrolls):
if is_cancelled():
return
delta = max(260, int(viewport * (ratio + random.random() * 0.12)))
page.evaluate(f"({_SCROLL_MODAL_FN})({delta})")
sleep_random(*POST_SCROLL)
time.sleep(get_scroll_interval(scroll_speed))
if i % 2 == 1:
sleep_random(*HOVER_TIME)
remaining = budget - (time.monotonic() - started)
if remaining > 0:
sleep_interruptible_sec(remaining)
def close_browse_note_modal(page: Page, list_url: str) -> None:
"""关闭养号弹窗:点击弹窗外空白,回到列表可继续滚动。"""
if page.evaluate(_MODAL_VISIBLE_JS) is not True:
if _navigated_to_full_note_page(page):
_recover_from_wrong_navigation(page, list_url)
return
_click_mask_to_close(page)
sleep_random(450, 850)
if _wait_modal_closed(page, timeout=5.0):
sleep_random(300, 500)
if _navigated_to_full_note_page(page):
_recover_from_wrong_navigation(page, list_url)
return
logger.debug("点击遮罩未关闭,尝试关闭按钮 / Esc")
page.evaluate(_CLOSE_MODAL_JS)
sleep_random(350, 650)
if not _wait_modal_closed(page, timeout=3.0):
try:
page.press_key("Escape")
except Exception:
pass
sleep_random(400, 700)
_wait_modal_closed(page, timeout=3.0)
if _navigated_to_full_note_page(page):
_recover_from_wrong_navigation(page, list_url)
elif not _is_on_list_page(page) and list_url:
_recover_from_wrong_navigation(page, list_url)
else:
page.wait_dom_stable()
def browse_note_modal_session(
page: Page,
list_url: str,
scroll_speed: str,
*,
scene: BrowseScene,
stay_sec: float,
scroll_count: int,
) -> bool:
"""完整一轮:点开弹窗 → 拟人停留 → 点空白关闭。成功返回 True。"""
if not open_random_note_modal(page, scene=scene):
return False
try:
dwell_in_browse_note_modal(
page,
scroll_speed,
stay_sec=stay_sec,
scroll_count=scroll_count,
)
return True
finally:
close_browse_note_modal(page, list_url)