📘 实战教学:基于 DrissionPage 的小红书自动化采集

开篇:为什么要搞「XHS监听式采集」?

如果你是用过 requestsScrapy 爬小红书的朋友,大概率踩过这两个坑:

  1. 逆向成本爆炸:光是分析 x-sx-t 这类动态签名就要耗掉大半天,甚至分分钟被风控重置算法
  2. DOM结构乱跳:小红书前端经常换类名、嵌套层级,辛辛苦苦写的 XPath 用一周就失效

而本文的 DrissionPage + Listen 方案,完美避开了这两个雷区——直接拦截浏览器渲染前的 API 数据包,拿到的是最干净、最完整的 JSON 结构化数据。


🛠️ 极简环境配置

  1. Python 基础:版本 ≥ 3.6(用较新的 3.9-3.11 兼容性最好)
  2. 核心库一键安装
pip install DrissionPage loguru DataRecorder

补充说明:

  • DrissionPage:核心自动化+抓包工具,自带 WebDriver 特征抹除
  • loguru:替代原生 print,日志带时间、颜色,调 Bug 超爽
  • DataRecorder:DrissionPage 官方配套的数据存储库,支持 Excel/CSV/JSON 一键生成

🧠 核心原理拆解(对应代码逻辑)

整个采集流程可以浓缩成 7个闭环动作,比盲目写点击逻辑稳10倍:

动作编号核心任务DrissionPage 实现为什么这么做?
1️⃣ 登录打开小红书首页,等待人工扫码page.get('XHS首页') + input()XHS 风控最严的是未登录状态,登录后的访问频率容忍度高很多
2️⃣ 布网启动指定接口的监听page.listen.start('web/v1/feed')只抓笔记详情的核心数据包,过滤掉图片、广告等无效流量
3️⃣ 定位目标区域进入搜索页 + 定位所有笔记卡片page.get(搜索URL) + page.eles('通用XPath')用外层 section 标签(带唯一 data-index)定位,类名变了也能用
4️⃣ 触发请求JS 点击卡片/图片target.click(by_js=True)绕过封面的透明点击层(UI 遮挡、广告埋点),强制发详情请求
5️⃣ 截获数据等待数据包返回 + 解析page.listen.wait() + 字典键值提取JSON 数据比 HTML 全10倍(带博主ID、精准互动数、标签等),解析速度快100倍
6️⃣ 去重存储data-index 做 set 去重 + Recorder 存 Excels.add(index) + recorder.add_data()避免重复处理已加载的笔记,节省时间+风控风险
7️⃣ 重置状态ESC 键关闭详情页 + 自适应滚动page.actions.type('\ue00c') + page.scroll.down(随机值)ESC 是弹窗通用关闭键,比找 X 按钮坐标安全;随机等待防机械化识别

🚀 完整可运行实战代码

import os
import time
import random
from loguru import logger
from DrissionPage import ChromiumPage
from DataRecorder import Recorder


def extract_note_data(raw_json: dict) -> dict:
    """
    解析小红书详情 API 返回的 JSON 数据包
    兼容两种常见的返回结构
    """
    # 提取笔记核心信息节点
    if isinstance(raw_json, dict):
        # 结构1:feed流接口返回的嵌套 items
        note = raw_json.get('data', {}).get('items', [{}])[0].get('note_card', {})
        if not note:
            # 结构2:偶尔直接返回的 note 节点
            note = raw_json.get('note', {})
    else:
        note = {}

    # 结构化输出你需要的字段(可自行增减)
    return {
        '博主昵称': note.get('user', {}).get('nickname', '未知博主'),
        '博主ID': note.get('user', {}).get('user_id', ''),
        '笔记标题': note.get('title', ''),
        '笔记正文': note.get('desc', ''),
        '评论数': note.get('interact_info', {}).get('comment_count', 0),
        '点赞数': note.get('interact_info', {}).get('liked_count', 0),
        '收藏数': note.get('interact_info', {}).get('collected_count', 0),
        '分享数': note.get('interact_info', {}).get('shared_count', 0),
        '发布时间': note.get('time', ''),
    }


def create_data_recorder(keyword: str) -> Recorder:
    """
    为每个关键词创建独立的 Excel 存储文件
    自动删除同关键词的旧文件,避免数据混乱
    """
    filename = f'小红书_{keyword.strip()}_{time.strftime("%Y%m%d_%H%M%S")}.xlsx'
    if os.path.exists(filename):
        os.remove(filename)
        logger.warning(f'🗑️ 已清理同关键词旧文件: {filename}')
    
    recorder = Recorder(filename)
    recorder.set.show_msg(False)  # 关闭 DataRecorder 的默认提示
    return recorder


def crawl_single_keyword(page: ChromiumPage, keyword: str, max_data: int = 20) -> None:
    """
    单个关键词的采集闭环
    """
    # 1️⃣ 初始化存储+布网
    recorder = create_data_recorder(keyword)
    page.listen.start('web/v1/feed')
    logger.info(f'🚀 已启动关键词【{keyword}】的采集,目标条数:{max_data}')

    # 2️⃣ 进入搜索页
    search_url = f'https://www.xiaohongshu.com/search_result?keyword={keyword}&source=web_explore_feed'
    page.get(search_url)
    page.wait.load_start()  # 等待页面DOM初步加载完成

    # 3️⃣ 去重+计数初始化
    seen_index = set()
    data_collected = 0

    while data_collected < max_data:
        # 获取当前页面所有可见的笔记卡片(外层带 data-index 的 section 最稳)
        cards = page.eles('xpath://*[@id="global"]//div[contains(@class,"feeds-page")]//section')
        
        # 没抓到卡片说明滚动过快/页面没渲染,多等会儿再试
        if not cards:
            logger.warning('⚠️ 未加载到新卡片,尝试等待+滚动...')
            page.wait(random.uniform(1.5, 2.5))
            page.scroll.down(random.randint(800, 1200))
            continue

        # 遍历当前页的卡片
        for card in cards:
            if data_collected >= max_data:
                break

            # 用 data-index 做唯一标识去重(比用标题/图片URL稳)
            card_index = card.attr('data-index')
            if not card_index or card_index in seen_index:
                continue
            seen_index.add(card_index)

            try:
                logger.info(f'正在处理第 {data_collected + 1}/{max_data} 条数据...')

                # 4️⃣ JS点击触发详情请求(优先点图片,兜底点卡片)
                target_click = card.ele('xpath:.//img[contains(@class,"Cover")]')
                if target_click:
                    target_click.click(by_js=True)
                else:
                    card.click(by_js=True)

                # 5️⃣ 等待详情数据包(超时4秒防卡死)
                api_response = page.listen.wait(timeout=4)
                if not api_response:
                    logger.warning(f'⏱️ 第 {data_collected + 1} 条抓包超时,跳过')
                    continue

                # 解析+存储数据
                note_info = extract_note_data(api_response.response.body)
                recorder.add_data(note_info)
                recorder.record()  # 逐条保存防止程序崩溃数据丢失
                data_collected += 1
                logger.success(f'✅ 第 {data_collected} 条数据已保存')

            except Exception as e:
                logger.error(f'❌ 第 {data_collected + 1} 条处理异常: {str(e)[:50]}')
            finally:
                # 6️⃣ 无论成功失败都ESC关闭详情页,重置页面状态
                page.actions.type('\ue00c')
                page.wait(random.uniform(0.5, 1.2))  # 给页面0.5-1.2秒缓冲

        # 7️⃣ 本轮卡片处理完,自适应滚动加载下一批
        logger.info('📜 本轮卡片处理完毕,正在滚动加载更多...')
        page.scroll.down(random.randint(1000, 1500))
        page.wait(random.uniform(1.2, 2.0))

    logger.success(f'🏆 关键词【{keyword}】采集完成!共保存 {data_collected} 条数据到: {recorder.path}')


def main():
    """
    主程序入口:配置关键词、启动浏览器、扫码登录、批量采集
    """
    # 配置你要采集的关键词列表
    target_keywords = ['极简书桌布置', '平价减脂早餐']
    # 配置每个关键词的采集条数
    per_keyword_max = 15

    # 启动 Chromium 浏览器(自动下载适配的驱动,无需手动配置)
    page = ChromiumPage()

    try:
        # 先打开首页,等待人工扫码登录(必须登录!否则大概率被风控)
        page.get('https://www.xiaohongshu.com')
        input('🔐 请在浏览器中扫码登录小红书,完成后在此按【回车键】继续...')

        # 批量采集关键词
        for kw in target_keywords:
            crawl_single_keyword(page, kw, per_keyword_max)
            # 不同关键词之间多等一会儿,降低连续请求的风险
            logger.info('⏸️ 不同关键词之间等待 3-5 秒...')
            time.sleep(random.uniform(3, 5))

    except Exception as main_e:
        logger.error(f'❌ 主程序异常: {main_e}')
    finally:
        # 无论成功失败都关闭浏览器
        logger.info('🚪 正在关闭浏览器...')
        page.quit()


if __name__ == '__main__':
    main()

📝 新手避坑+进阶优化指南

💥 高频避坑

  1. 监听一直超时?
    • 检查是否弹出了「图形验证码」/「短信验证码」,手动解决后程序会自动继续
    • 确认监听的接口有没有写错(小红书偶尔会改接口版本,按 F12 网络面板抓一条详情请求核对)
  2. XPath 找不到卡片?
    • 尽量用代码里的「通用外层 section」,不要依赖具体的类名
    • 如果彻底失效,可以在 F12 中右键复制任意卡片的 XPath,然后把具体的 div[1]/div[2] 改成 contains 模糊匹配
  3. 采集几条就被风控?
    • 必须登录!必须登录!必须登录!
    • 增加随机等待时间(代码里已经加了,不要改成固定的 0.5 秒)
    • 每个关键词的采集条数不要一次超过 100 条,分批次采集

🎯 进阶优化

  1. 提升采集效率
    • recorder.record() 移出内部循环,改在关键词采集结束后统一调用(适合大规模采集)
    • 增加「线程池」?不建议! XHS 对单账号多窗口/多线程容忍度极低,老老实实单线程最稳
  2. 拓展采集字段
    • extract_note_data 函数里增加字典键值提取即可(比如标签、图片URL、IP属地等,都在 JSON 里)
  3. 规避账号封禁
    • 使用「闲置小号」采集,不要用主号
    • 每天的总采集条数控制在 500 条以内
    • 可以在代码里增加「随机点赞/收藏」的低概率动作,模拟真人行为