967 lines
36 KiB
Python
967 lines
36 KiB
Python
# xhs_crawler_core.py
|
||
# 核心功能:
|
||
# - login_mode_auto(): 打开浏览器手动登录,检测到已登录后自动保存 storage_state.json
|
||
# - check_login_state_with_storage(): 用 storage_state.json 检查登录是否有效
|
||
# - resolve_profile_by_search(): 通过“搜索页-用户”解析出个人主页 url + 昵称 + 小红书号(需要登录态)
|
||
# - crawl_detail(): 详情弹框采集(标题/时间地点/赞藏评),防漏采滚动策略(需要登录态)
|
||
|
||
import re
|
||
import json
|
||
import random
|
||
import urllib.parse
|
||
import os
|
||
import sys
|
||
import shutil
|
||
|
||
# Excel 导出(可选)
|
||
try:
|
||
from openpyxl import Workbook
|
||
except Exception:
|
||
Workbook = None
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from typing import Callable, Optional, Tuple, Dict, List
|
||
|
||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeoutError
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
import time
|
||
|
||
|
||
# ========= 列表页选择器 =========
|
||
NOTE_CONTAINER_SELECTOR = ".feeds-container"
|
||
NOTE_ITEM_SELECTOR = "section.note-item"
|
||
OPEN_DETAIL_SELECTOR = "a.cover.mask.ld"
|
||
|
||
LIST_TITLE_SELECTOR = "a.title"
|
||
LIST_LIKE_SELECTOR = ".author-wrapper .like-wrapper .count"
|
||
|
||
# ========= 详情弹框选择器 =========
|
||
MODAL_ROOT_SELECTOR = ".note-detail-mask"
|
||
DETAIL_TITLE_SELECTOR = "#detail-title"
|
||
DETAIL_DATE_LOC_SELECTOR = ".bottom-container .date"
|
||
# 笔记正文内容选择器(多种选择器适配不同版本)
|
||
CONTENT_SELECTORS = [
|
||
".note-detail-mask .desc",
|
||
".note-detail-mask .content-text",
|
||
".note-detail-mask .post-content",
|
||
".note-detail-mask .note-text",
|
||
"#detail-desc",
|
||
".desc",
|
||
".content-text",
|
||
".post-content",
|
||
".note-text"
|
||
]
|
||
|
||
LIKE_COUNT_SELECTOR = ".buttons.engage-bar-style .like-wrapper .count, .engage-bar-container .like-wrapper .count"
|
||
COLLECT_COUNT_SELECTOR = ".buttons.engage-bar-style .collect-wrapper .count, .engage-bar-container .collect-wrapper .count"
|
||
COMMENT_COUNT_SELECTOR = ".buttons.engage-bar-style .chat-wrapper .count, .engage-bar-container .chat-wrapper .count"
|
||
COMMENT_TOTAL_SELECTOR = ".comments-container .total, .comment-wrapper .total, .comment-count"
|
||
|
||
CLOSE_SELECTOR = (
|
||
".note-detail-mask .close-circle .close, "
|
||
".note-detail-mask .close-box, "
|
||
".close-circle .close, "
|
||
".close-box"
|
||
)
|
||
|
||
# ========= 搜索页-用户结果 选择器 =========
|
||
USER_TAB_SELECTOR = "#channel-container #user.channel"
|
||
USER_ITEM_SELECTOR = ".user-list-item"
|
||
USER_ITEM_LINK_SELECTOR = ".user-list-item a[href^='/user/profile/']"
|
||
USER_NAME_SELECTOR = ".user-name"
|
||
USER_DESC_SELECTOR = ".user-desc"
|
||
|
||
# 未登录/拦截时常见文本(兜底判断)
|
||
BLOCK_TEXTS_STRONG = ["扫码登录", "安全验证", "请先登录", "登录后查看"]
|
||
|
||
def _winreg_app_path(exe_name: str) -> str:
|
||
"""Windows: 从注册表 App Paths 获取浏览器路径(支持非 C 盘安装)"""
|
||
if sys.platform != "win32":
|
||
return ""
|
||
try:
|
||
import winreg
|
||
except Exception:
|
||
return ""
|
||
subkey = fr"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe_name}"
|
||
for root in (getattr(winreg, "HKEY_CURRENT_USER", None), getattr(winreg, "HKEY_LOCAL_MACHINE", None)):
|
||
if root is None:
|
||
continue
|
||
try:
|
||
k = winreg.OpenKey(root, subkey)
|
||
try:
|
||
val, _ = winreg.QueryValueEx(k, "")
|
||
val = (val or "").strip()
|
||
if val and Path(val).exists():
|
||
return val
|
||
finally:
|
||
try:
|
||
winreg.CloseKey(k)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def _which(cmd: str) -> str:
|
||
try:
|
||
return shutil.which(cmd) or ""
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def find_system_browser_executable() -> str:
|
||
"""自动探测系统 Chrome/Edge 可执行文件路径"""
|
||
chrome = _winreg_app_path("chrome.exe")
|
||
if chrome:
|
||
return chrome
|
||
edge = _winreg_app_path("msedge.exe")
|
||
if edge:
|
||
return edge
|
||
|
||
candidates = [
|
||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||
os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe"),
|
||
]
|
||
for p in candidates:
|
||
try:
|
||
if p and Path(p).exists():
|
||
return p
|
||
except Exception:
|
||
pass
|
||
|
||
for cmd in ("chrome", "chrome.exe", "msedge", "msedge.exe"):
|
||
p = _which(cmd)
|
||
if p:
|
||
try:
|
||
if Path(p).exists():
|
||
return p
|
||
except Exception:
|
||
pass
|
||
|
||
return ""
|
||
|
||
|
||
def pick_browser_executable(manual_path: str = "") -> str:
|
||
"""优先使用手动路径;否则自动探测系统 Chrome/Edge;都没有则返回空字符串"""
|
||
mp = (manual_path or "").strip().strip('"')
|
||
if mp:
|
||
try:
|
||
p = Path(mp).expanduser()
|
||
if p.exists() and p.is_file():
|
||
return str(p)
|
||
except Exception:
|
||
pass
|
||
return find_system_browser_executable()
|
||
|
||
|
||
def clean_profile_url(url: str) -> str:
|
||
"""
|
||
清洗小红书个人主页 URL:去掉 ?xsec_token=...&xsec_source=... 等 query,只保留 path。
|
||
例如:
|
||
https://www.xiaohongshu.com/user/profile/xxx?xsec_token=... -> https://www.xiaohongshu.com/user/profile/xxx
|
||
"""
|
||
if not url:
|
||
return url
|
||
|
||
u = url.strip()
|
||
|
||
# 兼容相对路径:/user/profile/xxx?...
|
||
if u.startswith("/"):
|
||
u = "https://www.xiaohongshu.com" + u
|
||
|
||
parts = urlsplit(u)
|
||
# 去掉 query + fragment,仅保留 scheme/netloc/path
|
||
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||
|
||
def _log(log_cb: Optional[Callable[[str], None]], msg: str):
|
||
if log_cb:
|
||
log_cb(msg)
|
||
|
||
|
||
def _to_int(text: str) -> int:
|
||
if not text:
|
||
return 0
|
||
t = str(text).strip()
|
||
if not re.search(r"\d", t):
|
||
return 0
|
||
t = t.replace(",", "").lower()
|
||
m = re.match(r"^(\d+(?:\.\d+)?)(万|w|k)?$", t, re.I)
|
||
if m:
|
||
num = float(m.group(1))
|
||
unit = (m.group(2) or "").lower()
|
||
if unit in ("万", "w"):
|
||
return int(num * 10000)
|
||
if unit == "k":
|
||
return int(num * 1000)
|
||
return int(num)
|
||
digits = re.sub(r"[^\d]", "", t)
|
||
return int(digits) if digits else 0
|
||
|
||
|
||
def _safe_text(locator) -> str:
|
||
try:
|
||
if locator.count() == 0:
|
||
return ""
|
||
return locator.first.inner_text().strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _extract_profile_id_from_href(href: str) -> str:
|
||
if not href:
|
||
return ""
|
||
m = re.search(r"/user/profile/([^/?#]+)", href)
|
||
return m.group(1) if m else ""
|
||
|
||
|
||
def _wait_ms(page, rng: Tuple[int, int]):
|
||
a, b = rng
|
||
page.wait_for_timeout(random.randint(a, b))
|
||
|
||
|
||
def check_login_state_with_storage(
|
||
storage_state_path: str,
|
||
*,
|
||
headless: bool = True,
|
||
slow_mo: int = 0,
|
||
log: Optional[Callable[[str], None]] = None,
|
||
browser_exe_path: str = "",
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
使用 storage_state.json 打开小红书首页,做一个“是否被登录/风控拦截”的判断。
|
||
"""
|
||
pth = Path(storage_state_path)
|
||
if not pth.exists():
|
||
return False, f"未找到登录状态文件:{pth.name}"
|
||
|
||
try:
|
||
with sync_playwright() as p:
|
||
exe_path = pick_browser_executable(browser_exe_path)
|
||
if exe_path:
|
||
_log(log, f"[浏览器] 使用:{exe_path}")
|
||
launch_kwargs = dict(
|
||
headless=headless,
|
||
slow_mo=slow_mo,
|
||
args=["--no-sandbox", "--disable-setuid-sandbox"],
|
||
)
|
||
if exe_path:
|
||
launch_kwargs["executable_path"] = exe_path
|
||
browser = p.chromium.launch(**launch_kwargs)
|
||
context = browser.new_context(
|
||
storage_state=str(pth),
|
||
locale="zh-CN",
|
||
viewport={"width": 1200, "height": 800},
|
||
)
|
||
page = context.new_page()
|
||
page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded", timeout=60000)
|
||
page.wait_for_timeout(1200)
|
||
|
||
# 强拦截关键词
|
||
for t in BLOCK_TEXTS_STRONG:
|
||
try:
|
||
if page.get_by_text(t).first.is_visible():
|
||
context.close(); browser.close()
|
||
return False, f"登录状态无效或被拦截:页面出现「{t}」"
|
||
except Exception:
|
||
pass
|
||
|
||
# 弱判断:若明显出现“登录”且可见,可能未登录(但不绝对)
|
||
try:
|
||
if page.get_by_text("登录").first.is_visible():
|
||
# 这里不强判失败,给个提示
|
||
context.close(); browser.close()
|
||
return False, "疑似未登录(页面出现明显「登录」入口),建议重新登录保存状态"
|
||
except Exception:
|
||
pass
|
||
|
||
context.close()
|
||
browser.close()
|
||
|
||
return True, "登录状态正常"
|
||
except Exception as e:
|
||
return False, f"登录状态检查失败:{e}"
|
||
|
||
|
||
def login_mode_auto(
|
||
storage_state_path: str,
|
||
*,
|
||
log: Optional[Callable[[str], None]] = None,
|
||
headless: bool = False,
|
||
slow_mo: int = 80,
|
||
browser_exe_path: str = "",
|
||
):
|
||
"""
|
||
更稳的自动保存登录态:
|
||
- 打开浏览器让你手动扫码/验证码
|
||
- 程序只在“登录弹窗/扫码提示消失”且“连续多次判定已登录”后才保存并关闭
|
||
- 避免刚打开就因为基础 cookies 误判
|
||
"""
|
||
import time
|
||
|
||
pth = Path(storage_state_path)
|
||
_log(log, f"[登录] 将打开浏览器,请完成扫码/验证码登录。成功后会自动保存:{pth.name}")
|
||
|
||
# 这些选择器/文本用于判断“仍处于登录/扫码/拦截状态”
|
||
LOGIN_MODAL_SELECTORS = [
|
||
".login-container",
|
||
".reds-login-mask",
|
||
".login-modal",
|
||
"[class*='login']", # 兜底:class 包含 login
|
||
"[class*='Login']",
|
||
"[data-testid*='login']",
|
||
]
|
||
STRONG_BLOCK_TEXTS = ["扫码登录", "安全验证", "请先登录", "登录后查看"]
|
||
|
||
def is_login_block_visible(page) -> bool:
|
||
# 1) 强文本(最可靠)
|
||
for t in STRONG_BLOCK_TEXTS:
|
||
try:
|
||
if page.get_by_text(t).first.is_visible():
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
# 2) 登录弹窗类 DOM(可能会误伤,但宁可保守)
|
||
for sel in LOGIN_MODAL_SELECTORS:
|
||
try:
|
||
loc = page.locator(sel)
|
||
if loc.count() > 0 and loc.first.is_visible():
|
||
# class*='login' 可能会误命中页面其他元素,所以再加一层:同时出现“登录”文本更可信
|
||
try:
|
||
if page.get_by_text("登录").first.is_visible():
|
||
return True
|
||
except Exception:
|
||
# 没有“登录”文字就不强判
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def has_auth_like_cookie(context) -> bool:
|
||
"""
|
||
只用 cookies 是否为空会误判,所以这里找更像“登录态”的 cookie 名称特征(保守+兜底)。
|
||
不保证 100% 命中,但配合“登录弹窗消失 + 连续多次确认”会明显稳。
|
||
"""
|
||
try:
|
||
cookies = context.cookies()
|
||
except Exception:
|
||
cookies = []
|
||
|
||
if not cookies:
|
||
return False
|
||
|
||
names = [c.get("name", "").lower() for c in cookies]
|
||
# 常见登录态 cookie 命名特征:session/token/auth 等
|
||
for n in names:
|
||
if any(k in n for k in ["session", "token", "auth", "passport", "sid"]):
|
||
return True
|
||
return False
|
||
|
||
with sync_playwright() as p:
|
||
exe_path = pick_browser_executable(browser_exe_path)
|
||
if exe_path:
|
||
_log(log, f"[浏览器] 使用:{exe_path}")
|
||
launch_kwargs = dict(
|
||
headless=headless,
|
||
slow_mo=slow_mo,
|
||
args=["--window-size=1366,820", "--no-sandbox", "--disable-setuid-sandbox"],
|
||
)
|
||
if exe_path:
|
||
launch_kwargs["executable_path"] = exe_path
|
||
browser = p.chromium.launch(**launch_kwargs)
|
||
context = browser.new_context(locale="zh-CN", viewport={"width": 1366, "height": 820})
|
||
page = context.new_page()
|
||
|
||
page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded", timeout=60000)
|
||
|
||
start = time.monotonic()
|
||
stable_ok = 0
|
||
|
||
# 关键:最短等待时间,避免刚打开就误判
|
||
MIN_SECONDS_BEFORE_SAVE = 10
|
||
NEED_STABLE_OK_TIMES = 3 # 连续 3 次判定 OK 才保存
|
||
CHECK_INTERVAL_MS = 1500
|
||
|
||
while True:
|
||
if page.is_closed():
|
||
context.close(); browser.close()
|
||
raise RuntimeError("浏览器已关闭,未能保存登录态")
|
||
|
||
page.wait_for_timeout(CHECK_INTERVAL_MS)
|
||
|
||
blocked = is_login_block_visible(page)
|
||
|
||
# 如果仍然在扫码/登录弹窗,就清空稳定计数,继续等
|
||
if blocked:
|
||
stable_ok = 0
|
||
continue
|
||
|
||
# 不在登录弹窗了,再做“更像登录态”的 cookie 特征判断
|
||
auth_cookie = has_auth_like_cookie(context)
|
||
|
||
# 兜底:页面不再出现明显“登录”入口,也算一个弱信号
|
||
login_text_visible = False
|
||
try:
|
||
login_text_visible = page.get_by_text("登录").first.is_visible()
|
||
except Exception:
|
||
login_text_visible = False
|
||
|
||
ok_now = auth_cookie or (not login_text_visible)
|
||
|
||
if ok_now:
|
||
stable_ok += 1
|
||
else:
|
||
stable_ok = 0
|
||
|
||
elapsed = time.monotonic() - start
|
||
|
||
if elapsed >= MIN_SECONDS_BEFORE_SAVE and stable_ok >= NEED_STABLE_OK_TIMES:
|
||
context.storage_state(path=str(pth))
|
||
_log(log, f"[登录] ✅ 已保存登录态:{pth}")
|
||
break
|
||
|
||
context.close()
|
||
browser.close()
|
||
|
||
def resolve_profile_by_search(
|
||
keyword: str,
|
||
storage_state_path: str,
|
||
*,
|
||
headless: bool = True,
|
||
slow_mo: int = 0,
|
||
log: Optional[Callable[[str], None]] = None,
|
||
browser_exe_path: str = "",
|
||
) -> Dict[str, str]:
|
||
kw = (keyword or "").strip()
|
||
if not kw:
|
||
raise ValueError("关键词不能为空")
|
||
|
||
pth = Path(storage_state_path)
|
||
if not pth.exists():
|
||
raise FileNotFoundError(f"未找到 {pth.name},请先登录保存状态")
|
||
|
||
search_url = "https://www.xiaohongshu.com/search_result?keyword=" + urllib.parse.quote(kw, safe="")
|
||
_log(log, f"[账号] 打开搜索页:{search_url}")
|
||
|
||
with sync_playwright() as p:
|
||
exe_path = pick_browser_executable(browser_exe_path)
|
||
if exe_path:
|
||
_log(log, f"[浏览器] 使用:{exe_path}")
|
||
launch_kwargs = dict(
|
||
headless=headless,
|
||
slow_mo=slow_mo,
|
||
args=["--no-sandbox", "--disable-setuid-sandbox"],
|
||
)
|
||
if exe_path:
|
||
launch_kwargs["executable_path"] = exe_path
|
||
browser = p.chromium.launch(**launch_kwargs)
|
||
context = browser.new_context(
|
||
storage_state=str(pth),
|
||
locale="zh-CN",
|
||
viewport={"width": 1200, "height": 800},
|
||
)
|
||
page = context.new_page()
|
||
|
||
page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
|
||
page.wait_for_timeout(1200)
|
||
|
||
# ✅ 不再嵌套调用 check_login_state_with_storage(避免二次启动 playwright)
|
||
for t in ["扫码登录", "安全验证", "请先登录", "登录后查看"]:
|
||
try:
|
||
if page.get_by_text(t).first.is_visible():
|
||
context.close(); browser.close()
|
||
raise RuntimeError(f"无法解析账号:页面出现「{t}」,说明登录态无效/被拦截,请重新登录保存")
|
||
except Exception:
|
||
pass
|
||
|
||
def _try_click_user_tab() -> bool:
|
||
selectors = [
|
||
USER_TAB_SELECTOR,
|
||
"#channel-container .channel:has-text('用户')",
|
||
"#channel-container [data-testid*='user']",
|
||
"div.channel:has-text('用户')",
|
||
]
|
||
for sel in selectors:
|
||
try:
|
||
loc = page.locator(sel)
|
||
if loc.count() > 0:
|
||
loc.first.click(timeout=3000)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
try:
|
||
page.get_by_role("tab", name="用户").click(timeout=3000)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _wait_user_items() -> bool:
|
||
selectors = [USER_ITEM_LINK_SELECTOR, USER_ITEM_SELECTOR, "a[href^='/user/profile/']", ".search-user-card", ".user-item", ".search-user-item"]
|
||
for sel in selectors:
|
||
try:
|
||
page.wait_for_selector(sel, state="attached", timeout=25000)
|
||
if page.locator(sel).count() > 0:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
# 切到“用户”Tab(不同版本/AB 测试兼容)
|
||
def _find_user_items():
|
||
selectors = [
|
||
USER_ITEM_SELECTOR,
|
||
".search-user-card",
|
||
".user-item",
|
||
".search-user-item",
|
||
]
|
||
for sel in selectors:
|
||
try:
|
||
loc = page.locator(sel)
|
||
if loc.count() > 0:
|
||
return loc, False
|
||
except Exception:
|
||
pass
|
||
return page.locator("a[href^='/user/profile/']"), True
|
||
|
||
|
||
_try_click_user_tab()
|
||
page.wait_for_timeout(800)
|
||
|
||
if not _wait_user_items():
|
||
# 常见兜底提示:无结果/被拦截/需要登录
|
||
for t in ["没有找到", "暂无", "没有相关内容", "需要登录", "请先登录", "扫码登录"]:
|
||
try:
|
||
if page.get_by_text(t).first.is_visible():
|
||
context.close(); browser.close()
|
||
raise RuntimeError(f"未找到用户结果:页面提示「{t}」")
|
||
except Exception:
|
||
pass
|
||
context.close(); browser.close()
|
||
raise RuntimeError("未找到用户结果(页面结构变化或被拦截),请确认登录态并重试")
|
||
|
||
items, use_anchor_only = _find_user_items()
|
||
n = items.count()
|
||
if n == 0:
|
||
context.close(); browser.close()
|
||
raise RuntimeError("未找到用户结果(可能被拦截/需要更完整登录态)")
|
||
|
||
best = None
|
||
best_score = -1
|
||
|
||
for i in range(n):
|
||
it = items.nth(i)
|
||
if use_anchor_only:
|
||
href = (it.get_attribute("href") or "").strip()
|
||
nickname = ""
|
||
try:
|
||
nickname = (it.inner_text() or "").strip()
|
||
except Exception:
|
||
nickname = ""
|
||
xhs_no = ""
|
||
else:
|
||
href = (it.locator("a[href^='/user/profile/']").first.get_attribute("href") or "").strip()
|
||
nickname = ""
|
||
try:
|
||
nickname = (it.locator(USER_NAME_SELECTOR).first.inner_text() or "").strip()
|
||
except Exception:
|
||
nickname = ""
|
||
xhs_no = ""
|
||
try:
|
||
desc = (it.locator(USER_DESC_SELECTOR).first.inner_text() or "").strip()
|
||
m = re.search(r"\u5c0f\u7ea2\u4e66\u53f7\uff1a\s*(\S+)", desc)
|
||
if m:
|
||
xhs_no = m.group(1).strip()
|
||
except Exception:
|
||
xhs_no = ""
|
||
if not href:
|
||
continue
|
||
|
||
score = 0
|
||
if xhs_no and xhs_no.lower() == kw.lower():
|
||
score += 100
|
||
if nickname and kw in nickname:
|
||
score += 10
|
||
if _extract_profile_id_from_href(href):
|
||
score += 1
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
url = "https://www.xiaohongshu.com" + href if href.startswith("/") else href
|
||
best = {"xhs_no": xhs_no, "nickname": nickname or kw, "url": clean_profile_url(url)}
|
||
|
||
context.close()
|
||
browser.close()
|
||
|
||
if not best:
|
||
raise RuntimeError("解析失败:没有可用的用户条目")
|
||
|
||
_log(log, f"[账号] ✅ 已解析:{best['nickname']}({best['xhs_no'] or '无小红书号'}) -> {best['url']}")
|
||
return best
|
||
|
||
|
||
def crawl_detail(
|
||
profile_url: str,
|
||
limit: int = 20,
|
||
storage_state_path: str = "storage_state.json",
|
||
headless: bool = False,
|
||
slow_mo: int = 80,
|
||
batch_min: int = 3,
|
||
batch_max: int = 7,
|
||
open_wait: Tuple[int, int] = (1800, 3200),
|
||
between_wait: Tuple[int, int] = (1500, 3200),
|
||
scroll_wait: Tuple[int, int] = (1600, 2800),
|
||
long_break: Tuple[int, int] = (6000, 12000),
|
||
scroll_target_new: int = 2,
|
||
scroll_max_attempts: int = 10,
|
||
out_file: str = "out/result_modal_details.json",
|
||
stop_event=None,
|
||
log: Optional[Callable[[str], None]] = None,
|
||
browser_exe_path: str = "",
|
||
dedup_check: Optional[Callable[[str], bool]] = None, # 去重检查回调,返回True表示已采集
|
||
):
|
||
pth = Path(storage_state_path)
|
||
if not pth.exists():
|
||
raise FileNotFoundError(f"未找到 {pth.name},请先登录保存状态")
|
||
|
||
out_path = Path(out_file)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _stopped() -> bool:
|
||
return bool(stop_event and stop_event.is_set())
|
||
|
||
def get_visible_cards(page):
|
||
container = page.locator(NOTE_CONTAINER_SELECTOR)
|
||
return container.locator(NOTE_ITEM_SELECTOR)
|
||
|
||
def get_card_key(item) -> str:
|
||
opener = item.locator(OPEN_DETAIL_SELECTOR)
|
||
if opener.count() == 0:
|
||
return ""
|
||
href = opener.first.get_attribute("href") or ""
|
||
return href.split("?")[0].strip()
|
||
|
||
def count_visible_unprocessed(page, processed: set) -> int:
|
||
items = get_visible_cards(page)
|
||
n = items.count()
|
||
keys = set()
|
||
for i in range(n):
|
||
key = get_card_key(items.nth(i))
|
||
if key and key not in processed:
|
||
keys.add(key)
|
||
return len(keys)
|
||
|
||
def find_next_visible_unprocessed(page, processed: set):
|
||
items = get_visible_cards(page)
|
||
n = items.count()
|
||
for i in range(n):
|
||
item = items.nth(i)
|
||
opener = item.locator(OPEN_DETAIL_SELECTOR)
|
||
if opener.count() == 0:
|
||
continue
|
||
href = opener.first.get_attribute("href") or ""
|
||
key = href.split("?")[0].strip()
|
||
if not key or key in processed:
|
||
continue
|
||
ts = time.time()
|
||
if key in blocked_until and (ts - blocked_until[key]) < 60:
|
||
continue
|
||
list_title = _safe_text(item.locator(LIST_TITLE_SELECTOR))
|
||
list_like = _to_int(_safe_text(item.locator(LIST_LIKE_SELECTOR)))
|
||
return item, opener.first, href, key, list_title, list_like
|
||
return None
|
||
|
||
def close_modal(page):
|
||
try:
|
||
if page.locator(CLOSE_SELECTOR).count() > 0:
|
||
page.locator(CLOSE_SELECTOR).first.click(timeout=5000)
|
||
page.wait_for_selector(MODAL_ROOT_SELECTOR, state="hidden", timeout=8000)
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
page.keyboard.press("Escape")
|
||
page.wait_for_selector(MODAL_ROOT_SELECTOR, state="hidden", timeout=8000)
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
page.mouse.click(20, 20)
|
||
page.wait_for_selector(MODAL_ROOT_SELECTOR, state="hidden", timeout=8000)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _get_modal_root(page):
|
||
"""优先使用真正的弹框遮罩 .note-detail-mask;如缺失则回退到 #noteContainer。"""
|
||
try:
|
||
m = page.locator(".note-detail-mask")
|
||
if m.count() > 0 and m.first.is_visible():
|
||
return m.first
|
||
except Exception:
|
||
pass
|
||
return page.locator("#noteContainer").first
|
||
|
||
def _safe_text_scoped(page, root, selector: str) -> str:
|
||
"""先在 root 内找,找不到再全局找(适配部分结构把按钮区放在 noteContainer 外)。"""
|
||
try:
|
||
t = _safe_text(root.locator(selector))
|
||
if t:
|
||
return t
|
||
except Exception:
|
||
pass
|
||
return _safe_text(page.locator(selector))
|
||
|
||
def extract_detail_from_modal(page) -> dict:
|
||
modal = _get_modal_root(page)
|
||
title = _safe_text_scoped(page, modal, DETAIL_TITLE_SELECTOR)
|
||
date_loc = _safe_text_scoped(page, modal, DETAIL_DATE_LOC_SELECTOR)
|
||
like_cnt = _to_int(_safe_text_scoped(page, modal, LIKE_COUNT_SELECTOR))
|
||
collect_cnt = _to_int(_safe_text_scoped(page, modal, COLLECT_COUNT_SELECTOR))
|
||
comment_cnt = _to_int(_safe_text_scoped(page, modal, COMMENT_COUNT_SELECTOR))
|
||
if comment_cnt == 0:
|
||
comment_cnt = _to_int(_safe_text_scoped(page, modal, COMMENT_TOTAL_SELECTOR))
|
||
|
||
# 提取笔记正文内容(前200字)
|
||
content = ""
|
||
for sel in CONTENT_SELECTORS:
|
||
try:
|
||
elem = modal.locator(sel)
|
||
if elem.count() > 0:
|
||
full_text = elem.first.inner_text().strip()
|
||
if full_text:
|
||
# 取前200个字符
|
||
content = full_text[:200] + ("..." if len(full_text) > 200 else "")
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
return {
|
||
"detail_title": title,
|
||
"detail_date_loc": date_loc,
|
||
"detail_content": content,
|
||
"like": like_cnt,
|
||
"collect": collect_cnt,
|
||
"comment": comment_cnt,
|
||
}
|
||
|
||
def overlap_scroll(page, stagnant_times: int = 0):
|
||
try:
|
||
h = page.evaluate("() => window.innerHeight || 800")
|
||
except Exception:
|
||
h = 800
|
||
base = random.uniform(0.55, 0.85)
|
||
boost = 1.0 + min(stagnant_times * 0.10, 0.35)
|
||
dist = int(h * base * boost)
|
||
page.evaluate("(d) => window.scrollBy(0, d)", dist)
|
||
|
||
def scroll_to_load_more(page, processed: set) -> bool:
|
||
before = count_visible_unprocessed(page, processed)
|
||
stagnant = 0
|
||
for _ in range(scroll_max_attempts):
|
||
if _stopped():
|
||
return False
|
||
overlap_scroll(page, stagnant_times=stagnant)
|
||
extra = stagnant * 500
|
||
a, b = scroll_wait
|
||
page.wait_for_timeout(random.randint(a + extra, b + extra))
|
||
|
||
after = count_visible_unprocessed(page, processed)
|
||
gained = after - before
|
||
if gained >= scroll_target_new:
|
||
return True
|
||
if gained > 0:
|
||
stagnant = 0
|
||
before = after
|
||
else:
|
||
stagnant += 1
|
||
|
||
if stagnant >= 3:
|
||
try:
|
||
page.evaluate("""
|
||
() => {
|
||
const y = document.body.scrollHeight - window.innerHeight;
|
||
window.scrollTo(0, Math.max(0, y - Math.floor(Math.random() * 600)));
|
||
}
|
||
""")
|
||
except Exception:
|
||
pass
|
||
page.wait_for_timeout(random.randint(2000, 3500))
|
||
after2 = count_visible_unprocessed(page, processed)
|
||
if after2 > before:
|
||
return True
|
||
return False
|
||
|
||
_log(log, f"[采集] 打开:{profile_url}")
|
||
_log(log, f"[采集] limit={limit} headless={headless} slow_mo={slow_mo}")
|
||
|
||
results = []
|
||
processed = set()
|
||
fail_counts: Dict[str, int] = {}
|
||
blocked_until: Dict[str, float] = {} # key -> last fail timestamp (avoid immediate re-click)
|
||
batch_target = random.randint(batch_min, batch_max)
|
||
batch_count = 0
|
||
_log(log, f"[采集] 长休息批次:每采集 {batch_min}~{batch_max} 条随机一次(当前目标={batch_target})")
|
||
|
||
with sync_playwright() as p:
|
||
exe_path = pick_browser_executable(browser_exe_path)
|
||
if exe_path:
|
||
_log(log, f"[浏览器] 使用:{exe_path}")
|
||
launch_kwargs = dict(
|
||
headless=headless,
|
||
slow_mo=slow_mo,
|
||
args=["--window-size=1366,820", "--no-sandbox", "--disable-setuid-sandbox"],
|
||
)
|
||
if exe_path:
|
||
launch_kwargs["executable_path"] = exe_path
|
||
browser = p.chromium.launch(**launch_kwargs)
|
||
context = browser.new_context(
|
||
storage_state=str(pth),
|
||
viewport={"width": 1366, "height": 820},
|
||
locale="zh-CN",
|
||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||
)
|
||
context.set_default_timeout(60000)
|
||
page = context.new_page()
|
||
|
||
page.goto(profile_url, wait_until="domcontentloaded", timeout=60000)
|
||
page.wait_for_selector(NOTE_CONTAINER_SELECTOR, timeout=30000)
|
||
_wait_ms(page, (1200, 2200))
|
||
|
||
safety_round = 0
|
||
while len(results) < limit and safety_round < 2000:
|
||
safety_round += 1
|
||
if _stopped():
|
||
_log(log, "[采集] 收到停止请求,正在退出...")
|
||
break
|
||
|
||
found = find_next_visible_unprocessed(page, processed)
|
||
if found:
|
||
item, opener, href, key, list_title, list_like = found
|
||
|
||
# 去重检查:在点击之前先检查是否已采集
|
||
if dedup_check and key:
|
||
note_id = key.split("?")[0].strip() # 提取纯净的笔记ID
|
||
if dedup_check(note_id):
|
||
_log(log, f"[去重] 笔记已采集,跳过:{note_id}")
|
||
processed.add(key)
|
||
continue
|
||
|
||
try:
|
||
item.scroll_into_view_if_needed()
|
||
except Exception:
|
||
pass
|
||
_wait_ms(page, (700, 1300))
|
||
|
||
try:
|
||
opener.click(timeout=6000)
|
||
except Exception:
|
||
try:
|
||
item.click(timeout=6000)
|
||
except Exception:
|
||
continue
|
||
|
||
try:
|
||
# 关键:不要用 #noteContainer 这种“永远可见”的元素判断弹框是否打开
|
||
page.wait_for_selector(DETAIL_TITLE_SELECTOR, state="visible", timeout=25000)
|
||
except PWTimeoutError:
|
||
# 先做一次“强制点击+更长等待”的快速重试,尽量别直接跳过
|
||
retry_ok = False
|
||
try:
|
||
opener.click(timeout=6000, force=True)
|
||
page.wait_for_selector(DETAIL_TITLE_SELECTOR, state="visible", timeout=30000)
|
||
retry_ok = True
|
||
except Exception:
|
||
retry_ok = False
|
||
if not retry_ok:
|
||
|
||
# 关键增强:避免一直点第一个笔记
|
||
fail_counts[key] = fail_counts.get(key, 0) + 1
|
||
blocked_until[key] = time.time()
|
||
_log(log, f"[警告] 弹框打开超时:{fail_counts[key]} 次(将先跳过 60 秒)")
|
||
close_modal(page)
|
||
# 额外兜底:ESC 关闭潜在遮罩
|
||
try:
|
||
page.keyboard.press("Escape")
|
||
except Exception:
|
||
pass
|
||
# 轻微滚动,让“下一个卡片”进入可见区域,避免一直选中第一个
|
||
try:
|
||
overlap_scroll(page, stagnant_times=min(fail_counts[key], 3))
|
||
_wait_ms(page, scroll_wait)
|
||
except Exception:
|
||
pass
|
||
# 多次失败则临时跳过该笔记,避免卡死(后续你可以再跑一次补采)
|
||
if fail_counts[key] >= 3:
|
||
processed.add(key)
|
||
_log(log, "[跳过] 该笔记连续 3 次打开失败,已临时跳过以保证任务继续")
|
||
continue
|
||
|
||
_wait_ms(page, open_wait)
|
||
detail = extract_detail_from_modal(page)
|
||
|
||
# 兜底:部分笔记标题在弹框里可能为空(或加载慢),用列表标题补一下
|
||
if not (detail.get("detail_title") or "").strip():
|
||
detail["detail_title"] = (list_title or "").strip()
|
||
|
||
results.append({
|
||
"idx": len(results) + 1,
|
||
"profile_url": profile_url,
|
||
"href": href,
|
||
"list_title": list_title,
|
||
"list_like": list_like,
|
||
**detail,
|
||
"crawled_at": datetime.now().isoformat(timespec="seconds"),
|
||
})
|
||
processed.add(key)
|
||
|
||
_log(log, f"[成功] {len(results)}/{limit} 赞={detail['like']} 藏={detail['collect']} 评={detail['comment']} | {detail['detail_title'][:25]}")
|
||
|
||
close_modal(page)
|
||
_wait_ms(page, between_wait)
|
||
|
||
batch_count += 1
|
||
if batch_count >= batch_target and len(results) < limit:
|
||
_log(log, f"[休息] 已采集 {batch_count} 条 -> 长休息")
|
||
_wait_ms(page, long_break)
|
||
batch_target = random.randint(batch_min, batch_max)
|
||
batch_count = 0
|
||
_log(log, f"[采集] 新的长休息目标={batch_target}")
|
||
|
||
continue
|
||
|
||
ok = scroll_to_load_more(page, processed)
|
||
if not ok:
|
||
_log(log, "[警告] 多次滚动仍无新卡片:可能到底或受限。等待后再判断退出")
|
||
page.wait_for_timeout(random.randint(4000, 8000))
|
||
if count_visible_unprocessed(page, processed) == 0:
|
||
break
|
||
if str(out_path).lower().endswith((".xlsx", ".xls")):
|
||
if Workbook is None:
|
||
_log(log, "[错误] 需要 openpyxl 才能导出 Excel,请先安装:pip install openpyxl")
|
||
else:
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "data"
|
||
# 按需求文档的字段顺序
|
||
headers = ["href", "detail_title", "detail_content", "like", "collect", "comment", "detail_date_loc", "crawled_at"]
|
||
ws.append(headers)
|
||
for r in results:
|
||
ws.append([r.get(h, "") for h in headers])
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
wb.save(str(out_path))
|
||
_log(log, f"[保存] {out_path}(Excel,共 {len(results)} 条)")
|
||
else:
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
_log(log, f"[保存] {out_path}(JSON,共 {len(results)} 条)")
|
||
|
||
context.close()
|
||
browser.close()
|
||
|
||
return results
|