Files
xhs_crawler/xhs_crawler_core.py
T

967 lines
36 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.
# 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