Python爬虫实战:小红书热门笔记爬虫实战 - 搜索关键词驱动的内容采集指南!

㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~

㊙️本期爬虫难度指数:⭐⭐⭐

🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现:请求层(Browser 管理器)](#6️⃣ 核心实现:请求层(Browser 管理器))
    • [7️⃣ 核心实现:解析层(Parser)](#7️⃣ 核心实现:解析层(Parser))
    • [8️⃣ 数据存储与导出(Storage)](#8️⃣ 数据存储与导出(Storage))
    • [9️⃣ 运行方式与结果展示](#9️⃣ 运行方式与结果展示)
    • [🔟 常见问题与排错](#🔟 常见问题与排错)
      • [Q1: 遇到滑块验证码/人机验证怎么办?](#Q1: 遇到滑块验证码/人机验证怎么办?)
      • [Q2: 笔记卡片解析为空(notes = [])?](#Q2: 笔记卡片解析为空(notes = [])?)
      • [Q3: 点赞数、收藏数都是 0?](#Q3: 点赞数、收藏数都是 0?)
      • [Q4: CSV 打开后中文乱码?](#Q4: CSV 打开后中文乱码?)
      • [Q5: 每次运行都需要重新登录验证?](#Q5: 每次运行都需要重新登录验证?)
    • [1️⃣1️⃣ 进阶优化(加分项)](#1️⃣1️⃣ 进阶优化(加分项))
    • [1️⃣2️⃣ 总结与延伸阅读](#1️⃣2️⃣ 总结与延伸阅读)
    • [🌟 文末](#🌟 文末)
      • [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)
      • [✅ 免责声明](#✅ 免责声明)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

一句话概括:

使用 Python + Playwright(或 requests 逆向 API)采集小红书搜索结果页的热门笔记,提取笔记标题、作者、点赞数、收藏数、封面图、关键词等字段,最终输出为结构化 CSV 数据,为内容分析与竞品研究提供数据支撑。

读完本文你将获得:

  • 理解现代 SPA 搜索页的爬取本质:为什么 requests 直接请求只能拿到空壳,以及两种可行的突破思路
  • 学会用 Playwright 驱动真实浏览器,完整抓取动态渲染内容,同时控制好频率与风险
  • 获得一套可落地的笔记数据采集框架,适用于关键词调研、内容热度分析等真实业务场景

2️⃣ 背景与需求(Why)

为什么要爬小红书笔记?

小红书已经成为国内内容营销领域绕不开的平台。每天有海量用户在上面分享生活方式、产品测评、旅行攻略。对于以下几类人群,搜索关键词对应的热门笔记数据价值极高:

  • 内容运营: 分析某个关键词下的高赞笔记有什么共同特征------封面风格、标题句式、发布时间
  • 品牌竞调: 追踪竞品在小红书的曝光量、热门内容方向
  • 自媒体创作者: 了解某个垂类的高互动内容长什么样,有针对性地优化自己的创作
  • 学术研究: 社交媒体热点话题传播路径、用户行为分析

问题是,小红书没有开放的数据接口,APP 内容也做了加密。网页端(www.xiaohongshu.com)虽然可以访问,但搜索结果是 JavaScript 动态渲染的,直接用 requests 拿到的 HTML 里几乎没有笔记数据。

目标站点与字段清单

目标站点: https://www.xiaohongshu.com/search_result
核心字段:

字段名称 字段说明 例如
keyword 搜索关键词 "露营装备"
note_title 笔记标题 "去年花3000买的帐篷,今天终于用上了"
author 作者昵称 "户外老王"
likes 点赞数 12500
collects 收藏数 8300
cover_url 封面图 URL "https://sns-img-qc.xhscdn.com/..."
note_url 笔记详情链接 "https://www.xiaohongshu.com/explore/..."
crawl_time 采集时间 "2025-02-11 16:30:00"

3️⃣ 合规与注意事项(必读)

robots.txt 检查

json 复制代码
https://www.xiaohongshu.com/robots.txt

关键内容:

  • 小红书在 robots.txt 中禁止了大多数爬虫路径,包括搜索页
  • 即便如此,学习性、低频的技术研究在国内通常属于灰色地带,法律风险较低
  • 商业用途的大规模采集则明确违反平台 ToS,存在法律风险

合规原则(这部分我必须认真写,不是套话)

✅ 相对可接受的:

  • 个人学习研究,每次采集数据量控制在百条以内
  • 只采集公开展示的笔记元数据(标题、作者、点赞数)
  • 请求频率极低,不对服务器造成任何压力

❌ 务必避免的:

  • 规模化批量采集(每次跑几千条、持续运行)
  • 采集用户的私信、个人账号、联系方式等个人信息
  • 将数据二次出售或用于商业目的
  • 绕过登录限制获取非公开内容

我需要特别说明的一点:

小红书对反爬投入较大。本文的技术方案能帮你理解爬虫开发的完整流程和思路,但在实际执行中,遇到 IP 封锁、滑块验证码、账号检测等情况是完全正常的。本文的目标是技术教学,不是帮你无限制地薅平台数据。如果你有大规模数据分析需求,认真考虑一下官方合作渠道或第三方数据服务商。

4️⃣ 技术选型与整体流程(What/How)

先搞清楚问题本质

很多同学上来就想用 requests + BeautifulSoup,拿到 HTML 一看,笔记列表是空的。这不是技术问题,是对目标网站渲染方式的误判。

打开 Chrome 开发者工具,访问小红书搜索页,做两件事:

  1. 右键"查看网页源代码":你会看到 HTML 里只有 <div id="app"></div>,笔记数据一条没有
  2. 打开 Network 面板,过滤 XHR 请求:你会发现笔记数据来自几个内部 API 接口

这说明小红书属于典型的 CSR(客户端渲染)应用,数据走异步接口。

两种技术路线:

路线 核心工具 优点 缺点
路线A:API 逆向 requests 速度快,资源消耗低 接口有签名验证(X-SX-T 参数),逆向难度较高
路线B:浏览器自动化 Playwright 无需破解签名,数据完整 速度慢,资源消耗大

本文采用路线B(Playwright)+ 路线A 思路作为扩展讲解。

原因很简单:小红书的 API 签名算法变化较频繁,今天写好的逆向代码一周后可能就失效了,维护成本太高。Playwright 方案虽然慢,但稳定性和可维护性更强,学到的技能也更通用。

整体流程设计

json 复制代码
┌────────────────────────┐
│ 1. 启动浏览器,打开     │
│    搜索结果页           │ → 等待笔记卡片渲染完毕
└────────────────────────┘

┌────────────────────────┐
│ 2. 解析当前页笔记卡片  │
│                        │ → 提取标题/作者/点赞/封面/链接
└────────────────────────┘

┌────────────────────────┐
│ 3. 模拟滚动翻页        │
│                        │ → 触发下一批数据加载
└────────────────────────┘

┌────────────────────────┐
│ 4. 数据清洗与存储      │
│                        │ → 去重 → 写入 CSV
└────────────────────────┘

5️⃣ 环境准备与依赖安装(可复现)

Python 版本要求

推荐 Python 3.9+(本文测试环境:3.10.12)

依赖安装

bash 复制代码
# 核心依赖
pip install playwright==1.42.0
pip install pandas==2.1.4
pip install lxml==5.1.0

# 安装浏览器内核(只需运行一次)
playwright install chromium

安装完成后验证 Playwright 是否正常:

bash 复制代码
python -c "from playwright.sync_api import sync_playwright; print('Playwright OK')"

项目结构(推荐)

json 复制代码
xhs_spider/
│
├── main.py              # 主程序入口
├── browser.py           # Playwright 浏览器管理
├── parser.py            # 笔记卡片解析器
├── storage.py           # 数据存储
├── config.py            # 配置(关键词、数量限制等)
├── requirements.txt     # 依赖清单
│
├── data/                # 数据输出
│   └── notes_露营装备_20250211.csv
│
├── cookies/             # Cookie 持久化(可选)
│   └── xhs_cookies.json
│
└── logs/
    └── spider.log

6️⃣ 核心实现:请求层(Browser 管理器)

小红书在 Playwright 默认模式下可能会检测到自动化工具特征,需要做一些反检测处理。

浏览器启动配置

python 复制代码
# browser.py
from playwright.sync_api import sync_playwright, Page, BrowserContext
import json
import os
import time
import random

class XhsBrowser:
    """小红书 Playwright 浏览器管理器"""
    
    def __init__(self, headless: bool = False, cookie_path: str = None):
        """
        Args:
            headless: 是否无头模式(建议调试时关掉,生产时开启)
            cookie_path: Cookie 文件路径(可选,传入可免登录)
        """
        self.headless = headless
        self.cookie_path = cookie_path
        self.pw = None
        self.browser = None
        self.context = None
        self.page = None
    
    def start(self):
        """启动浏览器"""
        self.pw = sync_playwright().start()
        
        # 启动 Chromium,带反检测参数
        self.browser = self.pw.chromium.launch(
            headless=self.headless,
            args=[
                '--disable-blink-features=AutomationControlled',
                '--no-sandbox',
                '--disable-dev-shm-usage',
            ]
        )
        
        # 创建上下文,模拟真实浏览器环境
        self.context = self.browser.new_context(
            viewport={'width': 1440, 'height': 900},
            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',
            locale='zh-CN',
            timezone_id='Asia/Shanghai',
        )
        
        # 注入反检测脚本
        self.context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined
            });
        """)
        
        # 加载 Cookie(如果有)
        if self.cookie_path and os.path.exists(self.cookie_path):
            self._load_cookies()
        
        self.page = self.context.new_page()
        return self
    
    def _load_cookies(self):
        """从文件加载 Cookie"""
        with open(self.cookie_path, 'r') as f:
            cookies = json.load(f)
        self.context.add_cookies(cookies)
        print(f"✅ Cookie 已加载: {len(cookies)} 条")
    
    def save_cookies(self):
        """保存当前 Cookie 到文件"""
        cookies = self.context.cookies()
        os.makedirs('cookies', exist_ok=True)
        with open(self.cookie_path or 'cookies/xhs_cookies.json', 'w') as f:
            json.dump(cookies, f, ensure_ascii=False, indent=2)
        print("✅ Cookie 已保存")
    
    def navigate(self, url: str, wait_for: str = None):
        """
        导航到目标页面
        
        Args:
            url: 目标 URL
            wait_for: 等待某个选择器出现(确保内容加载完毕)
        """
        self.page.goto(url, timeout=30000, wait_until='domcontentloaded')
        
        # 随机延迟,模拟人类打开页面的行为
        time.sleep(random.uniform(2, 4))
        
        if wait_for:
            try:
                self.page.wait_for_selector(wait_for, timeout=15000)
            except Exception:
                print(f"⚠️ 等待选择器超时: {wait_for}")
    
    def scroll_down(self, times: int = 3, interval: float = 2.0):
        """
        向下滚动页面,触发懒加载
        
        Args:
            times: 滚动次数
            interval: 每次滚动的等待时间(秒)
        """
        for _ in range(times):
            self.page.evaluate("window.scrollBy(0, window.innerHeight * 0.8)")
            time.sleep(interval + random.uniform(0, 1))
    
    def get_page_content(self) -> str:
        """获取当前页面的 HTML"""
        return self.page.content()
    
    def close(self):
        """关闭浏览器"""
        if self.browser:
            self.browser.close()
        if self.pw:
            self.pw.stop()
    
    def __enter__(self):
        return self.start()
    
    def __exit__(self, *args):
        self.close()

几个关键决策的说明:

headless=False 在调试阶段建议保持关闭,能直观地看到浏览器的行为,方便排查问题。等逻辑调通后再切到无头模式。

反检测脚本的核心是覆盖 navigator.webdriver 属性------这是 Selenium/Playwright 最容易被检测到的特征之一。这个方法不是万能的,小红书有更复杂的指纹检测,但能过滤掉基础的机器人判断。

scroll_down 方法模拟用户滚动的原因:小红书的搜索结果采用无限滚动加载(瀑布流),只有滚动到底部才会触发下一批数据请求。

7️⃣ 核心实现:解析层(Parser)

HTML 结构分析

用 Playwright 渲染完页面后,打开开发者工具,观察笔记卡片的 DOM 结构(实际结构以页面为准,以下为典型结构):

html 复制代码
<section class="note-item">
  <a class="cover ..." href="/explore/NOTEID">
    <img src="https://sns-img-qc.xhscdn.com/..." alt="封面图">
  </a>
  <footer class="footer">
    <a class="title" href="/explore/NOTEID">
      <span>去年花3000买的帐篷,今天终于用上了</span>
    </a>
    <div class="author-wrapper">
      <span class="name">户外老王</span>
    </div>
    <div class="interact-info">
      <span class="like-wrapper">
        <span class="count">1.2万</span>
      </span>
    </div>
  </footer>
</section>

解析器实现

python 复制代码
# parser.py
from bs4 import BeautifulSoup
from typing import List, Dict
import re

class XhsParser:
    """小红书笔记卡片解析器"""
    
    BASE_URL = "https://www.xiaohongshu.com"
    
    @staticmethod
    def parse_note_list(html: str, keyword: str = '') -> List[Dict]:
        """
        解析搜索结果页的笔记列表
        
        Args:
            html: 渲染后的完整 HTML
            keyword: 当前搜索的关键词
            
        Returns:
            笔记数据列表
        """
        soup = BeautifulSoup(html, 'lxml')
        notes = []
        
        # 定位所有笔记卡片(选择器需根据实际页面调整)
        note_cards = soup.select('section.note-item')
        
        if not note_cards:
            # 备用选择器
            note_cards = soup.select('[class*="note-item"]')
        
        if not note_cards:
            print("⚠️ 未找到笔记卡片,请检查选择器或页面是否正确渲染")
            return []
        
        print(f"🔍 当前页找到 {len(note_cards)} 个笔记卡片")
        
        for card in note_cards:
            try:
                note = XhsParser._parse_single_card(card, keyword)
                if note:
                    notes.append(note)
            except Exception as e:
                print(f"⚠️ 解析单个卡片出错: {e}")
                continue
        
        return notes
    
    @staticmethod
    def _parse_single_card(card, keyword: str) -> Dict:
        """解析单个笔记卡片"""
        
        # 封面图与笔记链接(通常在同一个 <a> 标签上)
        cover_link = card.select_one('a.cover')
        
        note_url = ''
        cover_url = ''
        
        if cover_link:
            href = cover_link.get('href', '')
            if href:
                note_url = XhsParser.BASE_URL + href if href.startswith('/') else href
            
            img = cover_link.find('img')
            if img:
                # 优先取 src,懒加载的情况取 data-src
                cover_url = img.get('src') or img.get('data-src') or ''
        
        # 笔记标题
        title_tag = card.select_one('a.title span, .title span, span.title')
        note_title = title_tag.text.strip() if title_tag else ''
        
        # 作者昵称
        author_tag = card.select_one('.name, [class*="author"] span, .author-wrapper span')
        author = author_tag.text.strip() if author_tag else '未知作者'
        
        # 点赞数(可能是"1.2万"这样的格式,需要转换)
        likes_tag = card.select_one('[class*="like"] .count, .like-wrapper .count')
        likes_raw = likes_tag.text.strip() if likes_tag else '0'
        likes = XhsParser._parse_count(likes_raw)
        
        # 收藏数(小红书网页端有时不显示单独的收藏数,和点赞数合并展示)
        collects_tag = card.select_one('[class*="collect"] .count, .collect-wrapper .count')
        collects_raw = collects_tag.text.strip() if collects_tag else '0'
        collects = XhsParser._parse_count(collects_raw)
        
        # 至少要有标题或链接才算有效数据
        if not note_title and not note_url:
            return None
        
        from datetime import datetime
        return {
            'keyword': keyword,
            'note_title': note_title,
            'author': author,
            'likes': likes,
            'collects': collects,
            'cover_url': cover_url,
            'note_url': note_url,
            'crawl_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        }
    
    @staticmethod
    def _parse_count(count_str: str) -> int:
        """
        将"1.2万"、"8300"、"3.5k"等格式转换为整数
        
        Args:
            count_str: 原始数字字符串
            
        Returns:
            整数
        """
        if not count_str or count_str == '-':
            return 0
        
        count_str = count_str.strip().replace(',', '')
        
        try:
            if '万' in count_str:
                num = float(count_str.replace('万', ''))
                return int(num * 10000)
            elif 'k' in count_str.lower():
                num = float(count_str.lower().replace('k', ''))
                return int(num * 1000)
            else:
                return int(float(count_str))
        except (ValueError, AttributeError):
            return 0

几个细节值得单独说一下:

_parse_count 方法是这个解析器里我花时间最多的部分。小红书显示互动数据时,超过 1 万的会显示"1.2万",这种中文混合格式如果不单独处理,直接转 int 会报错。把它做成独立方法,后续如果格式变化只需要改一处。

选择器我用了多个备选(a.title span, .title span, span.title),因为小红书的 CSS 类名可能随版本变化。遇到解析为空时,第一反应不是改代码,而是先 print(card.prettify()) 看看实际的 DOM 结构,再对应调整。

8️⃣ 数据存储与导出(Storage)

存储策略

对于搜索关键词驱动的采集场景,我倾向于用 CSV + 增量追加 的策略:

  • 每个关键词对应一个 CSV 文件
  • 已存在的 CSV 在新一轮采集时追加写入,不覆盖历史数据
  • 去重在写入前完成(基于 note_url)

存储层实现

python 复制代码
# storage.py
import pandas as pd
import os
import hashlib
from typing import List, Dict
from datetime import datetime

class XhsStorage:
    """小红书数据存储管理"""
    
    def __init__(self, output_dir: str = 'data'):
        self.output_dir = output_dir
        os.makedirs(output_dir, exist_ok=True)
    
    def _get_filepath(self, keyword: str) -> str:
        """根据关键词生成文件路径"""
        safe_keyword = re.sub(r'[\\/:*?"<>|]', '_', keyword)
        date_str = datetime.now().strftime('%Y%m%d')
        return os.path.join(self.output_dir, f'notes_{safe_keyword}_{date_str}.csv')
    
    def save(self, notes: List[Dict], keyword: str):
        """
        保存笔记数据(增量模式)
        
        Args:
            notes: 笔记列表
            keyword: 关键词(用于文件命名)
        """
        if not notes:
            print("⚠️ 无数据可保存")
            return
        
        import re
        filepath = self._get_filepath(keyword)
        new_df = pd.DataFrame(notes)
        
        # 如果文件已存在,合并并去重
        if os.path.exists(filepath):
            existing_df = pd.read_csv(filepath, encoding='utf-8-sig')
            combined_df = pd.concat([existing_df, new_df], ignore_index=True)
        else:
            combined_df = new_df
        
        # 去重(基于笔记 URL)
        before = len(combined_df)
        combined_df = combined_df.drop_duplicates(subset=['note_url'], keep='last')
        after = len(combined_df)
        
        if before > after:
            print(f"🔄 去重:删除 {before - after} 条重复记录")
        
        # 按点赞数排序
        if 'likes' in combined_df.columns:
            combined_df = combined_df.sort_values('likes', ascending=False)
        
        combined_df.to_csv(filepath, index=False, encoding='utf-8-sig')
        
        print(f"✅ 数据已保存: {filepath}")
        print(f"📊 当前共 {len(combined_df)} 条笔记")
        
        return filepath
    
    def get_existing_urls(self, keyword: str) -> set:
        """获取已采集的笔记 URL 集合(用于断点续爬)"""
        import re
        filepath = self._get_filepath(keyword)
        
        if not os.path.exists(filepath):
            return set()
        
        df = pd.read_csv(filepath, encoding='utf-8-sig')
        return set(df['note_url'].dropna().tolist())

字段映射表:

字段名 数据类型 示例值 备注
keyword string "露营装备" 搜索关键词
note_title string "去年花3000买的帐篷..." 笔记标题
author string "户外老王" 作者昵称
likes integer 12500 点赞数(已转换为整数)
collects integer 8300 收藏数(可能为0)
cover_url string "https://sns-img-qc..." 封面图 URL
note_url string "https://www.xiaohongshu.com/explore/..." 笔记链接(去重主键)
crawl_time string "2025-02-11 16:30:00" 采集时间戳

9️⃣ 运行方式与结果展示

主程序入口

python 复制代码
# main.py
from browser import XhsBrowser
from parser import XhsParser
from storage import XhsStorage
import time
import random

# ========= 配置区域 =========
KEYWORDS = ['露营装备', '徒步穿搭']   # 要搜索的关键词列表
MAX_SCROLL = 5                         # 每个关键词最多滚动几次(每次加载约10条)
HEADLESS = False                       # 调试时建议 False
COOKIE_PATH = 'cookies/xhs_cookies.json'  # Cookie 文件路径(可选)
# ============================

def crawl_keyword(keyword: str, storage: XhsStorage) -> int:
    """
    采集单个关键词的笔记
    
    Returns:
        采集到的笔记数量
    """
    search_url = f'https://www.xiaohongshu.com/search_result?keyword={keyword}&type=51'
    
    print(f"\n🔍 开始采集关键词: 「{keyword}」")
    print(f"  URL: {search_url}")
    
    # 已采集的 URL(用于跳过重复)
    existing_urls = storage.get_existing_urls(keyword)
    
    total_notes = []
    
    with XhsBrowser(headless=HEADLESS, cookie_path=COOKIE_PATH) as browser:
        
        # 访问搜索页
        browser.navigate(
            search_url,
            wait_for='section.note-item'  # 等待至少一个笔记卡片出现
        )
        
        # 滚动翻页
        for scroll_idx in range(MAX_SCROLL):
            print(f"\n  第 {scroll_idx + 1}/{MAX_SCROLL} 次滚动...")
            
            # 获取当前页 HTML
            html = browser.get_page_content()
            
            # 解析笔记
            notes = XhsParser.parse_note_list(html, keyword)
            
            # 过滤已采集的
            new_notes = [n for n in notes if n['note_url'] not in existing_urls]
            
            total_notes.extend(new_notes)
            
            for n in new_notes:
                existing_urls.add(n['note_url'])
            
            print(f"  本次新增: {len(new_notes)} 条笔记")
            
            # 滚动到下方,触发下一批加载
            browser.scroll_down(times=2, interval=1.5)
            
            # 每次滚动后等待一段时间
            time.sleep(random.uniform(2, 4))
        
        # 保存数据
        if total_notes:
            storage.save(total_notes, keyword)
        else:
            print(f"  ⚠️ 未采集到新数据")
    
    return len(total_notes)

def main():
    """主流程"""
    print("🌸 小红书笔记爬虫启动...\n")
    
    storage = XhsStorage()
    total = 0
    
    for keyword in KEYWORDS:
        count = crawl_keyword(keyword, storage)
        total += count
        
        # 关键词之间随机等待
        if keyword != KEYWORDS[-1]:
            wait_time = random.uniform(5, 10)
            print(f"\n⏳ 等待 {wait_time:.1f} 秒后继续下一个关键词...")
            time.sleep(wait_time)
    
    print(f"\n🎉 全部完成!共采集 {total} 条新笔记")

if __name__ == '__main__':
    main()

启动命令

json 复制代码
# 进入项目目录
cd xhs_spider

# 运行爬虫
python main.py

运行日志示例

json 复制代码
🌸 小红书笔记爬虫启动...

🔍 开始采集关键词: 「露营装备」
  URL: https://www.xiaohongshu.com/search_result?keyword=露营装备&type=51

  第 1/5 次滚动...
🔍 当前页找到 20 个笔记卡片
  本次新增: 20 条笔记

  第 2/5 次滚动...
🔍 当前页找到 30 个笔记卡片
  本次新增: 10 条笔记

  第 3/5 次滚动...
🔍 当前页找到 40 个笔记卡片
  本次新增: 10 条笔记
...

✅ 数据已保存: data/notes_露营装备_20250211.csv
📊 当前共 45 条笔记

⏳ 等待 7.3 秒后继续下一个关键词...

🔍 开始采集关键词: 「徒步穿搭」
...

🎉 全部完成!共采集 89 条新笔记

结果展示(CSV 前5行)

keyword note_title author likes collects note_url
露营装备 去年花3000买的帐篷,今天终于用上了 户外老王 12500 8300 https://www.xiaohongshu.com/explore/...
露营装备 露营必备清单|新手入坑不踩雷 露营日记本 9800 6200 https://www.xiaohongshu.com/explore/...
露营装备 500元预算怎么配置露营装备? 穷游快乐星 7300 4100 https://www.xiaohongshu.com/explore/...
徒步穿搭 女生徒步穿搭分享,实用又显瘦 山野小鹿 15600 11200 https://www.xiaohongshu.com/explore/...
徒步穿搭 徒步3天2夜,装备清单附购买链接 背包客小明 8900 5700 https://www.xiaohongshu.com/explore/...

🔟 常见问题与排错

Q1: 遇到滑块验证码/人机验证怎么办?

原因分析:

这是小红书最常见的反爬措施。触发条件包括:短时间内多次访问、IP 异常、浏览器指纹被识别。

解决思路:

python 复制代码
# 检测到验证码后暂停,等待人工处理(半自动模式)
page.wait_for_selector('[class*="verify"], [class*="captcha"]', timeout=3000)

# 如果检测到,暂停程序,提示人工滑动
input("⚠️ 检测到验证码,请手动完成验证后按回车继续...")

更根本的解决方案是降低频率,或者使用带登录态的 Cookie------登录用户触发验证码的概率比匿名用户低得多。

Q2: 笔记卡片解析为空(notes = [])?

原因分析:

  • 页面还没渲染完,就开始解析了
  • 选择器对应的 class 名发生了变化
  • 遇到了错误页面(搜索结果为空、网络错误)

排查步骤:

python 复制代码
# 先把 HTML 保存下来,手动用浏览器打开看看
html = browser.get_page_content()

with open('debug_page.html', 'w', encoding='utf-8') as f:
    f.write(html)

print("HTML 已保存,请用浏览器打开 debug_page.html 检查")

保存下来的文件用浏览器打开,然后 F12 找到笔记卡片的真实选择器,更新 parser.py 里的选择器。

Q3: 点赞数、收藏数都是 0?

原因分析:

小红书在未登录状态下可能不展示完整的互动数据,或者互动数据通过另一个 XHR 请求异步加载,HTML 里并没有这些数字。

解决方案:

python 复制代码
# 等待互动数据加载(增加等待时间)
browser.page.wait_for_timeout(3000)

# 或者尝试用 Playwright 直接读取元素文本,绕过 BS4 解析
likes_text = browser.page.query_selector('[class*="like"] .count')
if likes_text:
    likes = XhsParser._parse_count(likes_text.inner_text())

Q4: CSV 打开后中文乱码?

原因分析:

Windows 的 Excel 默认用 GBK 编码打开 CSV,而文件是 UTF-8 存的。

解决方案:

代码里已经用了 encoding='utf-8-sig'(带 BOM 的 UTF-8),这是专门解决 Windows Excel 乱码问题的标准做法。如果还是乱码,尝试:

json 复制代码
Excel → 数据 → 从文本/CSV → 选文件 → 编码选 UTF-8 → 导入

Q5: 每次运行都需要重新登录验证?

解决方案(Cookie 持久化):

python 复制代码
# 第一次手动登录后,把 Cookie 保存下来
browser = XhsBrowser(headless=False, cookie_path='cookies/xhs_cookies.json')
browser.start()
browser.navigate('https://www.xiaohongshu.com')

input("请手动登录,完成后按回车保存 Cookie...")
browser.save_cookies()
browser.close()

# 之后每次运行都会自动加载 Cookie,无需重新登录

1️⃣1️⃣ 进阶优化(加分项)

网络拦截:直接捕获 XHR 数据

不用解析 HTML,直接拦截 Playwright 的网络请求,拿到原始 JSON:

python 复制代码
# 比解析 HTML 更稳定,不受页面改版影响
notes_data = []

def handle_response(response):
    """拦截 API 响应"""
    if 'search_result' in response.url or 'homefeed' in response.url:
        try:
            data = response.json()
            # 解析 JSON 数据,提取笔记信息
            items = data.get('data', {}).get('items', [])
            for item in items:
                note = item.get('note_card', {})
                notes_data.append({
                    'note_title': note.get('title', ''),
                    'author': note.get('user', {}).get('nickname', ''),
                    'likes': note.get('interact_info', {}).get('liked_count', 0),
                    # ... 其他字段
                })
        except Exception:
            pass

# 注册拦截器
page.on('response', handle_response)
page.goto(search_url)

这个方案的优点是完全脱离了 CSS 选择器,不受页面重构影响。缺点是需要先摸清楚 API 的响应结构。

断点续爬

python 复制代码
def crawl_keyword_with_checkpoint(keyword, storage, max_scroll=10):
    """带断点续爬的采集函数"""
    
    # 读取上次进度
    progress_file = f'cache/{keyword}_progress.json'
    start_scroll = 0
    
    if os.path.exists(progress_file):
        with open(progress_file) as f:
            progress = json.load(f)
            start_scroll = progress.get('last_scroll', 0)
            print(f"  📌 从第 {start_scroll + 1} 次滚动继续")
    
    for scroll_idx in range(start_scroll, max_scroll):
        # 采集逻辑...
        
        # 保存进度
        with open(progress_file, 'w') as f:
            json.dump({'last_scroll': scroll_idx}, f)

多关键词并行(谨慎使用)

python 复制代码
from concurrent.futures import ThreadPoolExecutor
import time

def crawl_keywords_parallel(keywords, max_workers=2):
    """多关键词并行采集(每个关键词开一个独立浏览器)"""
    
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = [
            executor.submit(crawl_keyword, kw, XhsStorage())
            for kw in keywords
        ]
        
        for future in futures:
            future.result()

特别提醒: max_workers 建议不超过 2,同时开多个浏览器对本机内存压力很大,对服务器也会有较明显的并发压力,更容易触发封禁。

数据分析:找出高潜力选题

python 复制代码
# analyze.py
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv('data/notes_露营装备_20250211.csv')

# 1. 点赞数分布
print(df['likes'].describe())

# 2. 筛选高赞笔记(点赞 > 1万)
top_notes = df[df['likes'] > 10000].sort_values('likes', ascending=False)
print(f"\n高赞笔记(>{10000}赞): {len(top_notes)} 篇")
print(top_notes[['note_title', 'author', 'likes']].to_string())

# 3. 标题词频分析
from collections import Counter
import jieba

all_titles = ' '.join(df['note_title'].dropna())
words = jieba.cut(all_titles)
word_count = Counter(w for w in words if len(w) > 1)

print("\n标题高频词 Top10:")
for word, count in word_count.most_common(10):
    print(f"  {word}: {count}次")

1️⃣2️⃣ 总结与延伸阅读

我们完成了什么?

通过这个项目:

  • ✅ 搞清楚了 SPA 类网站与静态网站的本质区别,理解了"直接 requests 拿不到数据"的根本原因
  • ✅ 掌握了 Playwright 驱动真实浏览器的核心技巧(反检测、等待策略、滚动翻页)
  • ✅ 实现了一套稳定的笔记元数据采集框架,支持多关键词、增量去重、断点续爬
  • ✅ 了解了网络拦截这个更优雅的进阶方案

这个项目让我意识到的几点

爬虫开发里最耗时的往往不是写代码,而是分析页面结构和调试选择器。建议养成一个习惯:在写任何解析代码之前,先花 10 分钟把页面 HTML 保存下来慢慢看,不要一边运行一边猜。

另外,Playwright 方案虽然稳,但运行成本确实高。如果你的采集需求是一次性的,跑完就好,Playwright 够用了。如果需要每天定时运行、长期维护,最终还是要投入时间研究 API 逆向,因为浏览器自动化方案在服务器上部署成本较高,且对内存和 CPU 的要求也更高。

下一步可以做什么?

技术方向:

  • 研究小红书网页端的 API 签名机制(X-S 参数),写一个纯 requests 版本
  • 把 Playwright 采集改为 CDP(Chrome DevTools Protocol)直接协议,速度更快
  • 接入 Scrapy-Playwright 中间件,利用 Scrapy 的管道和调度器

数据分析方向:

  • 结合 jieba 分词,分析热门笔记的标题模式(数字、疑问句、情绪词)
  • 用封面图 URL 批量下载封面,分析高赞笔记的视觉风格(颜色、构图)
  • 建立关键词-笔记热度时序数据库,观察话题生命周期

推荐参考

工具文档:

扩展阅读:

  • Chrome DevTools Protocol 文档(了解浏览器底层机制)
  • Web Scraping with Playwright(O'Reilly 博客系列)

最后的话

小红书爬虫是我见过的"说起来简单,做起来有门道"的典型案例之一。入门的人以为加个 User-Agent 就能搞定,然后一看 HTML 里什么都没有,一脸茫然。出门的人知道 SPA 渲染、API 签名、反指纹检测是怎么回事,写出来的代码才真的能跑。

这中间的差距,其实就是对"一个网页请求背后到底发生了什么"这件事的理解深度。希望这篇文章能帮你把这个理解再往深推一层。

技术的边界不是你能写什么代码,而是你能看懂什么问题。

🌟 文末

好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

✅ 专栏持续更新中|建议收藏 + 订阅

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
nita张2 小时前
战略定位实战:案例分享与经验总结
大数据·人工智能·python
MadPrinter2 小时前
Python 异步爬虫实战:FindQC 商品数据爬取系统完整教程
爬虫·python·算法·自动化
清水白石0082 小时前
Python 函数式编程实战:从零构建函数组合系统
开发语言·python
喵手3 小时前
Python爬虫实战:数据质量治理实战 - 构建企业级规则引擎与异常检测系统!
爬虫·python·爬虫实战·异常检测·零基础python爬虫教学·数据质量治理·企业级规则引擎
头发够用的程序员3 小时前
Python 魔法方法 vs C++ 运算符重载全方位深度对比
开发语言·c++·python
加成BUFF3 小时前
基于DeepSeek+Python开发软件并打包为exe(VSCode+Anaconda Prompt实操)
vscode·python·prompt·conda·anaconda
52Hz1184 小时前
力扣46.全排列、78.子集、17.电话号码的字母组合
python·leetcode
子午4 小时前
【宠物识别系统】Python+深度学习+人工智能+算法模型+图像识别+TensorFlow+2026计算机毕设项目
人工智能·python·深度学习
好家伙VCC4 小时前
# 发散创新:用Python+Pandas构建高效BI数据清洗流水线在现代数据分析领域,**BI(商业智能)工具的核心竞
java·python·数据分析·pandas