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爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

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

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


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

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

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


✅ 免责声明

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

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

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
青 春 记 忆4 小时前
Dify Docker Compose 通用无损升级指南:从备份、双版本预演到切换与回滚
运维·人工智能·python·docker·容器
GlueNa2SiO34 小时前
03-Flask模板引擎Jinja2详解
笔记·python·flask
临沂GEO6 小时前
GEO搜索优化科普|正规地理位置流量运营入门指南
大数据·人工智能·python·流量运营
大模型码小白6 小时前
Spring AI Tool 实现自然语言操作 MySQL 数据库详解
服务器·开发语言·数据库·人工智能·python·mysql·spring
深蓝电商API7 小时前
常见反爬策略全解析
爬虫·反爬策略
荷蒲8 小时前
【小白量化Qbuddy】利用AI学习中文Python
人工智能·python·学习
数据狐(Datafox)9 小时前
京东商品列表API技术解析与落地应用(含标准 JSON 示例)
java·大数据·前端·人工智能·python·数据分析·json
量化吞吐机9 小时前
2026年下半年手工交易规则走向量化表达,产品该先接住哪一步
人工智能·python
Metaphor69210 小时前
使用 Python 读取和删除 Excel 文件属性
python·excel
迷迭香yy11 小时前
Python构建转融券多空识别系统从接口到因子的全流程 IG50免费开源股票数据API接口
开发语言·python·开源