- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
496 lines
15 KiB
Python
496 lines
15 KiB
Python
"""养号场景:首页推荐 / 搜索列表 — 点击笔记打开弹窗拟人(不走路由、不拉详情)。
|
||
|
||
- 场景 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)
|