Python爬虫实战:电商问答语料构建完整实战 - 从爬取到检索语料的工程化实现(附CSV导出 + SQLite持久化存储)!

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

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

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

全文目录:

🌟 开篇语

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

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

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

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

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

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

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

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

📝 摘要(Abstract)

本文将完整演示如何从电商平台(以京东、淘宝问答区为例)爬取用户问答数据,并通过去重、分句、清洗等NLP处理流程,最终构建成可用于智能客服、推荐系统的高质量检索语料库。

读完本文你将获得:

  • 掌握电商问答数据的完整采集与清洗流程(从0到1)
  • 学会构建生产级语料处理pipeline(去重算法、分句策略、质量过滤)
  • 获得可直接运行的完整项目代码(包含日志、监控、容错机制)

🎯 背景与需求(Why)

为什么要构建电商问答语料?

在智能客服、商品推荐、用户画像等场景中,高质量的问答语料是训练模型的核心资产。电商平台的用户问答区包含了:

  • 真实用户痛点:尺码、材质、使用场景等高频问题
  • 商家回复模式:标准答案、话术模板、异议处理
  • 语义多样性:同一问题的多种表达方式

目标站点与字段清单

字段名 数据类型 说明 示例值
product_id String 商品ID "100012345678"
product_name String 商品名称 "Apple iPhone 15 Pro"
question_id String 问题唯一ID "q_987654321"
question_text String 用户提问原文 "这款支持双卡吗?"
answer_id String 回答唯一ID "a_123456789"
answer_text String 回答内容 "支持双卡双待,nano-SIM卡"
question_time Datetime 提问时间 "2024-01-15 14:32:00"
answer_time Datetime 回答时间 "2024-01-15 16:20:00"
answerer_type String 回答者类型 "official/user"
useful_count Integer 有用数 128

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

robots.txt 基本说明

在爬取前,务必检查目标网站的 robots.txt 文件:

json 复制代码
# 示例:https://www.jd.com/robots.txt
User-agent: *
Disallow: /checkout/
Allow: /item.html
Crawl-delay: 2

本文采取的合规措施:

  • ✅ 仅爬取公开可访问的问答页面(无需登录)
  • ✅ 设置合理请求间隔(2-5秒),避免对服务器造成压力
  • ✅ 使用真实浏览器User-Agent,不伪装爬虫身份
  • ✅ 不采集用户隐私信息(手机号、订单号等)
  • ✅ 不绕过付费内容或登录墙

频率控制与反爬对策

python 复制代码
# 推荐的请求频率控制策略
import time
import random

def smart_sleep():
    """智能延时:模拟人类浏览行为"""
    base_delay = 2  # 基础延时2秒
    jitter = random.uniform(0.5, 2.0)  # 随机波动
    time.sleep(base_delay + jitter)

⚠️ 重要提醒:

  • 本文代码仅供学习研究使用
  • 商业用途需获得平台授权
  • 遵守《网络安全法》及平台服务条款

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

静态 vs 动态 vs API

电商问答页面通常属于半动态渲染

  • 列表页:静态HTML可获取问题列表(适合 requests + lxml)
  • 详情页:部分平台通过AJAX加载回答(需抓包找API)
  • 分页:通过URL参数或POST请求翻页

本文选型:混合方案

  • 主采集器requests + lxml(高性能、低资源消耗)
  • 备用方案httpx + 异步(处理高并发场景)
  • 解析库lxml.etree(XPath速度优于BeautifulSoup)

整体流程架构

json 复制代码
┌─────────────┐      ┌──────────────┐      ┌─────────────┐
│  采集层      │ ───> │   解析层      │ ───> │  清洗层      │
│ (Fetcher)   │      │  (Parser)    │      │ (Cleaner)   │
└─────────────┘      └──────────────┘      └─────────────┘
      │                      │                      │
      │                      │                      │
      v                      v                      v
 ┌─────────────────────────────────────────────────────┐
 │              数据存储与索引层                        │
 │  ├─ 原始数据 (raw_qa.json)                         │
 │  ├─ 清洗数据 (cleaned_qa.csv)                      │
 │  └─ 检索索引 (faiss_index / elasticsearch)         │
 └─────────────────────────────────────────────────────┘

为什么这样选型?

  1. requests:成熟稳定,社区支持好,99%的静态页面都能搞定
  2. lxml:C语言实现,解析速度比BS4快5-10倍
  3. 异步httpx:备用方案,应对需要高并发的场景

📦 环境准备与依赖安装

Python版本要求

bash 复制代码
Python >= 3.8  # 需要支持 f-string 和 typing

依赖安装

bash 复制代码
# 创建虚拟环境(推荐)
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# 安装核心依赖
pip install requests==2.31.0      # HTTP请求
pip install lxml==5.1.0           # HTML解析
pip install pandas==2.1.4         # 数据处理
pip install tqdm==4.66.1          # 进度条
pip install loguru==0.7.2         # 日志管理

# NLP处理依赖
pip install jieba==0.42.1         # 中文分词
pip install pypinyin==0.50.0      # 拼音转换(去重用)
pip install Levenshtein==0.23.0   # 编辑距离(相似度计算)

# 可选:高级功能
pip install httpx==0.25.2         # 异步HTTP
pip install sqlalchemy==2.0.25    # 数据库ORM
pip install faiss-cpu==1.7.4      # 向量检索

项目结构(推荐目录)

json 复制代码
ecommerce_qa_corpus/
│
├── config/
│   ├── __init__.py
│   └── settings.py           # 配置文件(URL、延时、字段映射)
│
├── crawler/
│   ├── __init__.py
│   ├── fetcher.py            # 请求层:发送HTTP请求
│   ├── parser.py             # 解析层:提取数据
│   └── middleware.py         # 中间件:重试、代理、UA池
│
├── processor/
│   ├── __init__.py
│   ├── cleaner.py            # 清洗层:去标签、去空格
│   ├── deduplicator.py       # 去重层:simhash/minhash
│   └── segmenter.py          # 分句层:问答对拆分
│
├── storage/
│   ├── __init__.py
│   ├── file_storage.py       # 文件存储(JSON/CSV)
│   └── db_storage.py         # 数据库存储(SQLite/MySQL)
│
├── utils/
│   ├── __init__.py
│   ├── logger.py             # 日志配置
│   └── metrics.py            # 监控指标(成功率、耗时)
│
├── data/                     # 数据目录(.gitignore)
│   ├── raw/                  # 原始数据
│   ├── processed/            # 处理后数据
│   └── corpus/               # 最终语料
│
├── main.py                   # 主入口
├── requirements.txt          # 依赖清单
└── README.md                 # 项目说明

🔍 核心实现:请求层(Fetcher)

基础请求类设计

python 复制代码
# crawler/fetcher.py
import requests
import time
import random
from typing import Optional, Dict
from loguru import logger
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class SmartFetcher:
    """
    智能HTTP请求器
    
    功能:
    - Session复用(连接池)
    - 自动重试(指数退避)
    - 超时控制
    - 请求头轮换
    """
    
    def __init__(
        self, 
        timeout: int = 15,
        max_retries: int = 3,
        delay_range: tuple = (2, 5)
    ):
        """
        初始化参数说明:
        :param timeout: 请求超时时间(秒)
        :param max_retries: 最大重试次数
        :param delay_range: 请求间隔范围(秒),如(2,5)表示2-5秒随机
        """
        self.timeout = timeout
        self.delay_range = delay_range
        
        # 配置Session:复用TCP连接,提升性能
        self.session = requests.Session()
        
        # 配置重试策略:仅对特定状态码重试
        retry_strategy = Retry(
            total=max_retries,
            status_forcelist=[429, 500, 502, 503, 504],  # 哪些状态码需要重试
            backoff_factor=2,  # 退避因子:第1次等2秒,第2次等4秒,第3次等8秒
            allowed_methods=["GET", "POST"]  # 允许重试的方法
        )
        
        # 挂载重试策略到Session
        adapter = HTTPAdapter(max_retries=retry_strategy)
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)
        
        # User-Agent池:轮换UA避免被识别
        self.ua_pool = [
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            
            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        ]
        
        # 统计指标
        self.metrics = {
            "total_requests": 0,
            "success_count": 0,
            "fail_count": 0,
            "total_time": 0
        }
    
    def _build_headers(self, referer: Optional[str] = None) -> Dict[str, str]:
        """
        构建请求头
        
        为什么需要这些字段:
        - User-Agent:模拟真实浏览器,避免被识别为爬虫
        - Referer:表明请求来源,部分网站会校验
        - Accept:告诉服务器可接受的内容类型
        - Accept-Language:语言偏好(电商通常需要)
        - Accept-Encoding:支持gzip压缩,减少流量
        """
        headers = {
            "User-Agent": random.choice(self.ua_pool),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Accept-Encoding": "gzip, deflate, br",
            "Connection": "keep-alive",
            "Upgrade-Insecure-Requests": "1"
        }
        
        if referer:
            headers["Referer"] = referer
        
        return headers
    
    def fetch(
        self, 
        url: str, 
        method: str = "GET",
        params: Optional[Dict] = None,
        data: Optional[Dict] = None,
        referer: Optional[str] = None
    ) -> Optional[str]:
        """
        发送HTTP请求并返回HTML内容
        
        :param url: 目标URL
        :param method: 请求方法(GET/POST)
        :param params: URL参数(用于GET)
        :param data: 表单数据(用于POST)
        :param referer: 来源页面
        :return: HTML文本,失败返回None
        """
        start_time = time.time()
        self.metrics["total_requests"] += 1
        
        try:
            # 智能延时:模拟人类行为
            self._smart_delay()
            
            # 发送请求
            headers = self._build_headers(referer)
            
            if method.upper() == "GET":
                response = self.session.get(
                    url, 
                    params=params,
                    headers=headers,
                    timeout=self.timeout
                )
            else:
                response = self.session.post(
                    url,
                    data=data,
                    headers=headers,
                    timeout=self.timeout
                )
            
            # 检查状态码
            response.raise_for_status()  # 4xx/5xx会抛出异常
            
            # 检查编码(电商网站常见GBK编码)
            if response.encoding == 'ISO-8859-1':
                # 自动检测编码
                response.encoding = response.apparent_encoding
            
            # 记录成功指标
            elapsed = time.time() - start_time
            self.metrics["success_count"] += 1
            self.metrics["total_time"] += elapsed
            
            logger.info(
                f"✅ 请求成功 | URL: {url[:50]}... | "
                f"耗时: {elapsed:.2f}s | 状态码: {response.status_code}"
            )
            
            return response.text
            
        except requests.exceptions.Timeout:
            logger.error(f"⏰ 请求超时 | URL: {url} | Timeout: {self.timeout}s")
            self.metrics["fail_count"] += 1
            return None
            
        except requests.exceptions.HTTPError as e:
            logger.error(f"❌ HTTP错误 | URL: {url} | 状态码: {e.response.status_code}")
            self.metrics["fail_count"] += 1
            return None
            
        except requests.exceptions.RequestException as e:
            logger.error(f"🔌 网络异常 | URL: {url} | 错误: {str(e)}")
            self.metrics["fail_count"] += 1
            return None
    
    def _smart_delay(self):
        """
        智能延时策略
        
        模拟人类浏览行为:
        - 基础延时 + 随机波动
        - 避免固定间隔被识别
        """
        min_delay, max_delay = self.delay_range
        delay = random.uniform(min_delay, max_delay)
        time.sleep(delay)
    
    def get_metrics(self) -> Dict:
        """获取请求统计指标"""
        success_rate = (
            self.metrics["success_count"] / self.metrics["total_requests"] * 100
            if self.metrics["total_requests"] > 0 else 0
        )
        avg_time = (
            self.metrics["total_time"] / self.metrics["success_count"]
            if self.metrics["success_count"] > 0 else 0
        )
        
        return {
            "总请求数": self.metrics["total_requests"],
            "成功数": self.metrics["success_count"],
            "失败数": self.metrics["fail_count"],
            "成功率": f"{success_rate:.2f}%",
            "平均耗时": f"{avg_time:.2f}s"
        }
    
    def __del__(self):
        """析构时关闭Session"""
        self.session.close()

Cookie与Session管理

python 复制代码
# crawler/middleware.py
import pickle
import os
from typing import Optional


class SessionManager:
    """
    Session持久化管理器
    
    用途:
    - 保存登录态(如需要)
    - 跨脚本复用Cookie
    - 避免频繁登录触发风控
    """
    
    def __init__(self, session_file: str = "data/.session"):
        self.session_file = session_file
    
    def save_session(self, session: requests.Session):
        """保存Session到文件"""
        os.makedirs(os.path.dirname(self.session_file), exist_ok=True)
        
        with open(self.session_file, 'wb') as f:
            pickle.dump(session.cookies, f)
        
        logger.info(f"💾 Session已保存到 {self.session_file}")
    
    def load_session(self, session: requests.Session) -> bool:
        """从文件加载Session"""
        if not os.path.exists(self.session_file):
            logger.warning("⚠️ Session文件不存在")
            return False
        
        try:
            with open(self.session_file, 'rb') as f:
                session.cookies.update(pickle.load(f))
            
            logger.info("✅ Session加载成功")
            return True
        except Exception as e:
            logger.error(f"❌ Session加载失败: {str(e)}")
            return False

📖 核心实现:解析层(Parser)

XPath解析器设计

python 复制代码
# crawler/parser.py
from lxml import etree
from typing import List, Dict, Optional
from loguru import logger


class QAParser:
    """
    电商问答页面解析器
    
    支持:
    - 商品问答列表页解析
    - 问答详情页解析
    - 容错处理(字段缺失、结构变化)
    """
    
    def __init__(self):
        # XPath规则配置(根据实际网站调整)
        self.xpath_rules = {
            # 京东问答页规则示例
            "jd": {
                "question_list": '//div[@class="question-item"]',
                "question_id": './@data-qid',
                "question_text": './/div[@class="q-txt"]/text()',
                "question_time": './/span[@class="q-time"]/text()',
                "answer_count": './/span[@class="a-count"]/text()',
                "product_name": '//div[@class="sku-name"]/text()'
            },
            
            # 淘宝问答页规则示例(需根据实际页面调整)
            "taobao": {
                "question_list": '//div[@class="ask-item"]',
                "question_id": './@data-id',
                "question_text": './/div[@class="ask-content"]//text()',
                "answer_text": './/div[@class="answer-content"]//text()',
                "useful_count": './/span[@class="useful-num"]/text()'
            }
        }
    
    def parse_question_list(
        self, 
        html: str, 
        platform: str = "jd"
    ) -> List[Dict]:
        """
        解析问答列表页
        
        :param html: HTML文本
        :param platform: 平台标识(jd/taobao)
        :return: 问答列表
        """
        if platform not in self.xpath_rules:
            logger.error(f"❌ 不支持的平台: {platform为etree对象
            tree = etree.HTML(html)
            rules = self.xpath_rules[platform]
            
            # 提取所有问题节点
            question_nodes = tree.xpath(rules["question_list"])
            
            questions = []
            for node in question_nodes:
                try:
                    # 提取字段(带容错)
                    question = {
                        "question_id": self._safe_extract(
                            node, rules.get("question_id")
                        ),
                        "question_text": self._safe_extract(
                            node, rules.get("question_text"), join=True
                        ),
                        "question_time": self._safe_extract(
                            node, rules.get("question_time")
                        ),
                        "answer_count": self._safe_extract(
                            node, rules.get("answer_count"), to_int=True
                        )
                    }
                    
                    # 过滤空数据
                    if question["question_text"]:
                        questions.append(question)
                    
                except Exception as e:
                    logger.warning(f"⚠️ 单条问题解析失败: {str(e)}")
                    continue
            
            logger.info(f"✅ 成功解析 {len(questions)} 条问题")
            return questions
            
        except Exception as e:
            logger.error(f"❌ 页面解析失败: {str(e)}")
            return []
    
    def _safe_extract(
        self, 
        node, 
        xpath: Optional[str],
        join: bool = False,
        to_int: bool = False,
        default: any = ""
    ) -> any:
        """
        安全提取XPath结果
        
        :param node: lxml节点
        :param xpath: XPath表达式
        :param join: 是否合并多个文本节点
        :param to_int: 是否转为整数
        :param default: 默认值
        :return: 提取结果
        """
        if not xpath:
            return default
        
        try:
            result = node.xpath(xpath)
            
            if not result:
                return default
            
            # 处理文本列表
            if join and isinstance(result, list):
                result = "".join(result).strip()
            elif isinstance(result, list):
                result = result[0].strip() if result else default
            
            # 类型转换
            if to_int:
                # 提取数字(如"123条回答" -> 123)
                import re
                numbers = re.findall(r'\d+', str(result))
                result = int(numbers[0]) if numbers else 0
            
            return result
            
        except Exception as e:
            logger.debug(f"XPath提取异常: {xpath} | {str(e)}")
            return default
    
    def parse_answer_detail(
        self, 
        html: str,
        question_id: str,
        platform: str = "jd"
    ) -> List[Dict]:
        """
        解析问题的回答详情
        
        :param html: HTML文本
        :param question_id: 问题ID
        :param platform: 平台标识
        :return: 回答列表
        """
        try:
            tree = etree.HTML(html)
            
            # 示例XPath(实际需根answer_nodes = tree.xpath('//div[@class="answer-item"]')
            
            answers = []
            for idx, node in enumerate(answer_nodes):
                answer = {
                    "question_id": question_id,
                    "answer_id": f"{question_id}_a{idx}",
                    "answer_text": self._safe_extract(
                        node, './/div[@class="a-content"]//text()', join=True
                    ),
                    "answerer_type": self._extract_answerer_type(node),
                    "answer_time": self._safe_extract(
                        node, './/span[@class="a-time"]/text()'
                    ),
                    "useful_count": self._safe_extract(
                        node, './/span[@class="useful"]/text()', to_int=True
                    )
                }
                
                if answer["answer_text"]:
                    answers.append(answer)
            
            return answers
            
        except Exception as e:
            logger.error(f"❌ 回答详情解析失败: {str(e)}")
            return []
    
    def _extract_answerer_type(self, node) -> str:
        """
        判断回答者类型
        
        策略:
        - 有"官方"/"客服"标识 -> official
        - 有"认证"标识 -> verified_user
        - 其他 -> user
        """
        text = "".join(node.xpath('.//text()'))
        
        if any(kw in text for kw in ["官方", "客服", "商家"]):
            return "official"
        elif "认证" in text:
            return "verified_user"
        else:
            return "user"

JSON接口解析(备选方案)

python 复制代码
# crawler/api_parser.py
import json
from typing import Dict, List


class APIParser:
    """
    API接口解析器
    
    适用场景:
    - 部分平台通过AJAX加载问答数据
    - 抓包找到接口后直接解析JSON
    """
    
    def parse_qa_api_response(self, response_text: str) -> List[Dict]:
        """
        解析问答API返回的JSON
        
        典型结构:
        {
            "code": 0,
            "data": {
                "questions": [
                    {
                        "qid": "123",
                        "content": "这个怎么样?",
                        "answers": [...]
                    }
                ]
            }
        }
        """
        try:
            data = json.loads(response_text)
            
            # 容错:不同平台返回结构不同
            questions = data.get("data", {}).get("questions", [])
            
            # 统一数据格式
            normalized = []
            for q in questions:
                item = {
                    "question_id": str(q.get("qid", "")),
                    "question_text": q.get("content", ""),
                    "answers": [
                        {
                            "answer_id": str(a.get("aid", "")),
                            "answer_text": a.get("content", ""),
                            "answerer_type": a.get("type", "user")
                        }
                        for a in q.get("answers", [])
                    ]
                }
                normalized.append(item)
            
            return normalized
            
        except json.JSONDecodeError as e:
            logger.error(f"❌ JSON解析失败: {str(e)}")
            return []
        except Exception as e:
            logger.error(f"❌ API数据处理失败: {str(e)}")
            return []

🧹 核心实现:数据清洗层(Cleaner)

文本清洗器

python 复制代码
# processor/cleaner.py
import re
from typing import List, Optional
from loguru import logger


class TextCleaner:
    """
    文本清洗器
    
    功能:
    - 去除HTML标签
    - 去除特殊字符和emoji
    - 空格归一化
    - 敏感词过滤
    """
    
    def __init__(self):
        # 编译正则表达式(性能优化)
        self.html_tag_pattern = re.compile(r'<[^>]+>')
        self.special_char_pattern = re.compile(r'[^\w\s\u4e00-\u9fff,.!?;:,。!?;:()()【】\[\]]')
        self.emoji_pattern = re.compile(
            "["
            "\U0001F600-\U0001F64F"  # emoticons
            "\U0001F300-\U0001F5FF"  # symbols & pictographs
            "\U0001F680-\U0001F6FF"  # transport & map
            "\U0001F1E0-\U0001F1FF"  # flags
            "]+", 
            flags=re.UNICODE
        )
        self.whitespace_pattern = re.compile(r'\s+')
        
        # 敏感词列表(示例)
        self.sensitive_words = {
            "联系方式", "微信", "QQ", "电话", "手机号",
            "加我", "私聊", "外链", "taobao.com"
        }
    
    def clean(
        self, 
        text: str,
        remove_html: bool = True,
        remove_special: bool = True,
        remove_emoji: bool = True,
        check_sensitive: bool = True
    ) -> Optional[str]:
        """
        综合清洗流程
        
        :param text: 原始文本
        :param remove_html: 是否去除HTML标签
        :param remove_special: 是否去除特殊字符
        :param remove_emoji: 是否去除emoji
        :param check_sensitive: 是否检查敏感词
        :return: 清洗后文本,包含敏感词返回None
        """
        if not text or not isinstance(text, str):
            return None
        
        # 1. 去除HTML标签
        if remove_html:
            text = self.html_tag_pattern.sub('', text)
        
        # 2. 去除emoji
        if remove_emoji:
            text = self.emoji_pattern.sub('', text)
        
        # 3. 去除特殊字符(保留中文、英文、数字、常用标点)
        if remove_special:
            text = self.special_char_pattern.sub('', text)
        
        # 4. 空格归一化
        text = self.whitespace_pattern.sub(' ', text).strip()
        
        # 5. 敏感词检查
        if check_sensitive and self._contains_sensitive(text):
            logger.warning(f"⚠️ 文本包含敏感词,已过滤: {text[:30]}...")
            return None
        
        # 6. 长度校验(太短的问答无意义)
        if len(text) < 3:
            return None
        
        return text
    
    def _contains_sensitive(self, text: str) -> bool:
        """检查是否包含敏感词"""
        text_lower = text.lower()
        return any(word in text_lower for word in self.sensitive_words)
    
    def clean_batch(self, texts: List[str]) -> List[str]:
        """批量清洗"""
        cleaned = []
        for text in texts:
            result = self.clean(text)
            if result:
                cleaned.append(result)
        
        logger.info(f"✅ 批量清洗完成 | 原始: {len(texts)} -> 清洗后: {len(cleaned)}")
        return cleaned
    
    def normalize_punctuation(self, text: str) -> str:
        """
        标点符号归一化
        
        中文标点 -> 英文标点(便于后续处理)
        """
        mapping = {
            ',': ',', '。': '.', '!': '!', '?': '?',
            ';': ';', ':': ':', '(': '(', ')': ')',
            '【': '[', '】': ']', '"': '"', '"': '"',
            ''': "'", ''': "'"
        }
        
        for cn, en in mapping.items():
            text = text.replace(cn, en)
        
        return text

问答对质量过滤器

python 复制代码
# processor/quality_filter.py
from typing import Dict, List
import jieba


class QualityFilter:
    """
    问答对质量过滤
    
    过滤规则:
    - 长度校验(太短/太长)
    - 重复度检测(问答一样)
    - 无效内容识别("不知道"、"没用过")
    - 信息密度评估
    """
    
    def __init__(
        self,
        min_question_len: int = 5,
        max_question_len: int = 200,
        min_answer_len: int = 5,
        max_answer_len: int = 500
    ):
        self.min_question_len = min_question_len
        self.max_question_len = max_question_len
        self.min_answer_len = min_answer_len
        self.max_answer_len = max_answer_len
        
        # 无效回答关键词
        self.invalid_patterns = [
            "不知道", "没用过", "不清楚", "不了解",
            "没买", "没收到", "不好说", "看运气"
        ]
    
    def is_valid_qa_pair(
        self, 
        question: str, 
        answer: str
    ) -> bool:
        """
        判断问答对是否有效
        
        :return: True表示有效,False表示应过滤
        """
        # 1. 长度校验
        if not (self.min_question_len <= len(question) <= self.max_question_len):
            logger.debug(f"❌ 问题长度不符: {len(question)} 字")
            return False
        
        if not (self.min_answer_len <= len(answer) <= self.max_answer_len):
            logger.debug(f"❌ 回答长度不符: {len(answer)} 字")
            return False
        
        # 2. 检查问答是否完全相同(复制粘贴)
        if question.strip() == answer.strip():
            logger.debug("❌ 问答内容完全相同")
            return False
        
        # 3. 检查是否包含无效回答
        if any(pattern in answer for pattern in self.invalid_patterns):
            logger.debug(f"❌ 包含无效回答关键词: {answer[:30]}...")
            return False
        
        # 4. 信息密度检测(分词后unique词占比)
        if not self._check_information_density(answer):
            logger.debug("❌ 信息密度过低")
            return False
        
        return True
    
    def _check_information_density(
        self, 
        text: str,
        min_unique_ratio: float = 0.3
    ) -> bool:
        """
        检查信息密度
        
        计算方式:unique词数 / 总词数
        如果比例过低,说明内容重复度高(如"好好好好好好")
        """
        words = list(jieba.cut(text))
        
        if len(words) < 3:
            return False
        
        unique_ratio = len(set(words)) / len(words)
        return unique_ratio >= min_unique_ratio
    
    def filter_qa_list(self, qa_list: List[Dict]) -> List[Dict]:
        """批量过滤问答对"""
        valid_qa = []
        
        for qa in qa_list:
            question = qa.get("question_text", "")
            answer = qa.get("answer_text", "")
            
            if self.is_valid_qa_pair(question, answer):
                valid_qa.append(qa)
        
        filter_rate = (len(qa_list) - len(valid_qa)) / len(qa_list) * 100 if qa_list else 0
        
        logger.info(
            f"✅ 质量过滤完成 | "
            f"原始: {len(qa_list)} -> 有效: {len(valid_qa)} | "
            f"过滤率: {filter_rate:.2f}%"
        )
        
        return valid_qa

🔄 核心实现:去重层(Deduplicator)

基于SimHash的去重

python 复制代码
# processor/deduplicator.py
import hashlib
from typing import List, Set, Dict
from collections import defaultdict
from loguru import logger
import jieba


class SimHashDeduplicator:
    """
    SimHash去重器
    
    原理:
    - 将文本转为64位hash值
    - 相似文本的hash汉明距离小
    - 海明距离<3认为是重复
    """
    
    def __init__(self, hamming_threshold: int = 3):
        """
        :param hamming_threshold: 汉明距离阈值,小于此值认为重复
        """
        self.hamming_threshold = hamming_threshold
        self.seen_hashes: Set[int] = set()
    
    def simhash(self, text: str) -> int:
        """
        计算文本的SimHash值
        
        步骤:
        1. 分词
        2. 每个词计算hash,转为二进制
        3. 加权求和(词频作为权重)
        4. 降维到64位
        """
        # 分词并统计词频
        words = jieba.cut(text)
        word_freq = defaultdict(int)
        for word in words:
            if len(word) > 1:  # 过滤单字
                word_freq[word] += 1
        
        # 初始化64位向量
        vector = [0] * 64
        
        # 对每个词计算hash并累加
        for word, freq in word_freq.items():
            # 计算词的hash值
            word_hash = int(hashlib.md5(word.encode('utf-8')).hexdigest(), 16)
            
            # 将hash转为64位二进制
            for i in range(64):
                bit = (word_hash >> i) & 1
                # 根据bit值累加或减少(加权)
                if bit:
                    vector[i] += freq
                else:
                    vector[i] -= freq
        
        # 降维:>0为1,<=0为0
        fingerprint = 0
        for i in range(64):
            if vector[i] > 0:
                fingerprint |= (1 << i)
        
        return fingerprint
    
    def hamming_distance(self, hash1: int, hash2: int) -> int:
        """
        计算两个hash的汉明距离
        
        汉明距离:二进制表示中不同位的个数
        例如:101 vs 111 -> 距离为1
        """
        xor = hash1 ^ hash2
        distance = 0
        
        # 统计1的个数
        while xor:
            distance += 1
            xor &= (xor - 1)  # 清除最右边的1
        
        return distance
    
    def is_duplicate(self, text: str) -> bool:
        """
        检查文本是否重复
        
        :return: True表示重复,False表示新文本
        """
        text_hash = self.simhash(text)
        
        # 与已存在的hash比较
        for.debug(f"🔁 检测到重复文本: {text[:30]}...")
                return True
        
        # 新文本,添加到集合
        self.seen_hashes.add(text_hash)
        return False
    
    def deduplicate_qa_list(self, qa_list: List[Dict]) -> List[Dict]:
        """
        对问答列表去重
        
        策略:
        - 问题去重:相似问题只保留一个
        - 答案去重:同一问题下相似答案只保留一个
        """
        unique_qa = []
        question_hashes: Dict[str, Set[int]] = {}  # {qid: {answer_hashes}}
        
        for qa in qa_list:
            question_text = qa.get("question_text", "")
            answer_text = qa.get("answer_text", "")
            question_id = qa.get("question_id", "")
            
            # 1. 问题去重
            if self.is_duplicate(question_text):
                continue
            
            # 2. 同一问题下的答案去重
            answer_hash = self.simhash(answer_text)
            
            if question_id not in question_hashes:
                question_hashes[question_id] = set()
            
            # 检查是否与已有答案重复
            is_dup_answer = False
            for seen_answer_hash in question_hashes[question_id]:
                if self.hamming_distance(answer_hash, seen_answer_hash) <= self.hamming_threshold:
                    is_dup_answer = True
                    break
            
            if not is_dup_answer:
                question_hashes[question_id].add(answer_hash)
                unique_qa.append(qa)
        
        dup_count = len(qa_list) - len(unique_qa)
        dup_rate = dup_count / len(qa_list) * 100 if qa_list else 0
        
        logger.info(
            f"✅ 去重完成 | "
            f"原始: {len(qa_list)} -> 唯一: {len(unique_qa)} | "
            f"去重数: {dup_count} ({dup_rate:.2f}%)"
        )
        
        return unique_qa

精确去重(备选方案)

python 复制代码
class ExactDeduplicator:
    """
    精确去重器
    
    策略:
    - 基于MD5 hash
    - 适用于完全相同的文本
    """
    
    def __init__(self):
        self.seen_md5: Set[str] = set()
    
    def get_md5(self, text: str) -> str:
        """计算文本MD5"""
        return hashlib.md5(text.encode('utf-8')).hexdigest()
    
    def is_duplicate(self, text: str) -> bool:
        """检查是否精确重复"""
        text_md5 = self.get_md5(text)
        
        if text_md5 in self.seen_md5:
            return True
        
        self.seen_md5.add(text_md5)
        return False

✂️ 核心实现:分句层(Segmenter)

python 复制代码
# processor/segmenter.py
import re
from typing import List, Tuple
from loguru import logger


class QASentenceSegmenter:
    """
    问答分句器
    
    用途:
    - 将长问答拆分成多个句子
    - 提取关键问答对(一问一答)
    - 生成训练样本
    """
    
    def __init__(self):
        # 中文句子分隔符
        self.sentence_delimiters = re.compile(r'[。!?!?\n]+')
        
        # 问句识别模式
        self.question_patterns = [
            r'.*[吗呢啊][\??]?$',  # 以疑问词结尾
            r'.*怎么.*',
            r'.*如何.*',
            r'.*多少.*',
            r'.*什么.*',
            r'.*哪.*',
            r'.*是否.*',
            r'能不能.*',
            r'可以.*吗',
        ]
        self.question_regex = re.compile('|'.join(self.question_patterns))
    
    def split_sentences(self, text: str) -> List[str]:
        """
        将文本分割成句子列表
        
        :param text: 长文本
        :return: 句子列表
        """
        sentences = self.sentence_delimiters.split(text)
        
        # 过滤空句子和过短句子
        sentences = [
            s.strip() 
            for s in sentences 
            if s.strip() and len(s.strip()) >= 3
        ]
        
        return sentences
    
    def is_question_sentence(self, sentence: str) -> bool:
        """
        判断句子是否为问句
        
        识别方式:
        - 包含问号
        - 匹配疑问句模式
        """
        # 1. 检查问号
        if '?' in sentence or '?' in sentence:
            return True
        
        # 2. 模式匹配
        if self.question_regex.match(sentence):
            return True
        
        return False
    
    def extract_qa_pairs(
        self, 
        question_text: str, 
        answer_text: str
    ) -> List[Tuple[str, str]]:
        """
        从长问答中提取多个问答对
        
        场景:
        - 问题包含多个子问题
        - 回答包含多个分句
        
        策略:
        - 问题拆分成句子
        - 回答拆分成句子
        - 智能匹配对应关系
        
        :return: [(问题1, 答案1), (问题2, 答案2), ...]
        """
        # 分割问题和答案
        question_sentences = self.split_sentences(question_text)
        answer_sentences = self.split_sentences(answer_text)
        
        qa_pairs = []
        
        # 情况1:单问单答(最常见)
        if len(question_sentences) == 1 and len(answer_sentences) == 1:
            qa_pairs.append((question_sentences[0], answer_sentences[0]))
        
        # 情况2:单问多答(回答分段)
        elif len(question_sentences) == 1 and len(answer_sentences) > 1:
            # 将所有答案合并
            full_answer = " ".join(answer_sentences)
            qa_pairs.append((question_sentences[0], full_answer))
        
        # 情况3:多问单答(问题聚合)
        elif len(question_sentences) > 1 and len(answer_sentences) == 1:
            # 为每个子问题创建单独的问答对
            for q in question_sentences:
                if self.is_question_sentence(q):
                    qa_pairs.append((q, answer_sentences[0]))
        
        # 情况4:多问多答(尝试一一对应)
        else:
            # 简单策略:按顺序配对(实际可用更复杂的语义匹配)
            min_len = min(len(question_sentences), len(answer_sentences))
            for i in range(min_len):
                if self.is_question_sentence(question_sentences[i]):
                    qa_pairs.append((question_sentences[i], answer_sentences[i]))
        
        logger.debug(f"📝 从长文本中提取 {len(qa_pairs)} 个问答对")
        return qa_pairs
    
    def segment_qa_list(self, qa_list: List[Dict]) -> List[Dict]:
        """
        批量处理问答列表,拆分长问答
        
        :param qa_list: 原始问答列表
        :return: 拆分后的问答列表(数量可能增加)
        """
        segmented_qa = []
        
        for qa in qa_list:
            question_text = qa.get("question_text", "")
            answer_text = qa.get("answer_text", "")
            
            # 提取子问答对
            pairs = self.extract_qa_pairs(question_text, answer_text)
            
            # 为每个子问答创建新记录
            for idx, (q, a) in enumerate(pairs):
                new_qa = qa.copy()
                new_qa["question_text"] = q
                new_qa["answer_text"] = a
                new_qa["segment_index"] = idx  # 记录是第几个片段
                segmented_qa.append(new_qa)
        
        logger.info(
            f"✅ 分句完成 | "
            f"原始: {len(qa_list)} -> 分句后: {len(segmented_qa)}"
        )
        
        return segmented_qa

💾 数据存储与导出(Storage)

文件存储器

python 复制代码
# storage/file_storage.py
import json
import csv
import os
from typing import List, Dict
from datetime import datetime
from loguru import logger
import pandas as pd


class FileStorage:
    """
    文件存储管理器
    
    支持格式:
    - JSON (原始数据,保留完整结构)
    - CSV (清洗数据,便于分析)
    - JSONL (流式写入,大数据友好)
    """
    
    def __init__(self, data_dir: str = "data"):
        self.data_dir = data_dir
        self._ensure_directories()
    
    def _ensure_directories(self):
        """确保数据目录存在"""
        dirs = [
            os.path.join(self.data_dir, "raw"),
            os.path.join(self.data_dir, "processed"),
            os.path.join(self.data_dir, "corpus")
        ]
        for dir_path in dirs:
            os.makedirs(dir_path, exist_ok=True)
    
    def save_json(
        self, 
        data: List[Dict], 
        filename: str,
        subdir: str = "raw"
    ):
        """
        保存为JSON格式
        
        :param data: 数据列表
        :param filename: 文件名(不含路径)
        :param subdir: 子目录(raw/processed/corpus)
        """
        filepath = os.path.join(self.data_dir, subdir, filename)
        
        try:
            with open(filepath, 'w', encoding='utf-8') as f:
                json.dump(
                    data, 
                    f, 
                    ensure_ascii=False,  # 保留中文
                    indent=2  # 格式化输出
                )
            
            logger.info(f"💾 JSON保存成功 | 路径: {filepath} | 记录数: {len(data)}")
            
        except Exception as e:
            logger.error(f"❌ JSON保存失败: {str(e)}")
    
    def save_csv(
        self, 
        data: List[Dict],
        filename: str,
        subdir: str = "processed",
        fieldnames: List[str] = None
    ):
        """
        保存为CSV格式
        
        :param fieldnames: 字段顺序(None则自动推断)
        """
        filepath = os.path.join(self.data_dir, subdir, filename)
        
        if not data:
            logger.warning("⚠️ 数据为空,跳过CSV保存")
            return
        
        try:
            # 使用pandas保存(处理复杂数据更稳健)
            df = pd.DataFrame(data)
            
            # 指定列顺序
            if fieldnames:
                df = df[fieldnames]
            
            df.to_csv(
                filepath, 
                index=False,
                encoding='utf-8-sig'  # Excel兼容的UTF-8
            )
            
            logger.info(
                f"💾 CSV保存成功 | 路径: {filepath} | "
                f"行数: {len(df)} | 列数: {len(df.columns)}"
            )
            
        except Exception as e:
            logger.error(f"❌ CSV保存失败: {str(e)}")
    
    def save_jsonl(
        self,
        data: List[Dict],
        filename: str,
        subdir: str = "corpus"
    ):
        """
        保存为JSONL格式(每行一个JSON对象)
        
        优势:
        - 流式读写,内存友好
        - 适合大规模语料
        - 可断点续传
        """
        filepath = os.path.join(self.data_dir, subdir, filename)
        
        try:
            with open(filepath, 'w', encoding='utf-8') as f:
                for item in data:
                    json_line = json.dumps(item, ensure_ascii=False)
                    f.write(json_line + '\n')
            
            logger.info(f"💾 JSONL保存成功 | 路径: {filepath} | 记录数: {len(data)}")
            
        except Exception as e:
            logger.error(f"❌ JSONL保存失败: {str(e)}")
    
    def load_json(self, filename: str, subdir: str = "raw") -> List[Dict]:
        """加载JSON文件"""
        filepath = os.path.join(self.data_dir, subdir, filename)
        
        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                data = json.load(f)
            
            logger.info(f"📂 JSON加载成功 | 路径: {filepath} | 记录数: {len(data)}")
            return data
            
        except FileNotFoundError:
            logger.error(f"❌ 文件不存在: {filepath}")
            return []
        except Exception as e:
            logger.error(f"❌ JSON加载失败: {str(e)}")
            return []
    
    def append_jsonl(self, item: Dict, filename: str, subdir: str = "corpus"):
        """
        追加单条记录到JSONL文件
        
        用于流式写入,避免内存溢出
        """
        filepath = os.path.join(self.data_dir, subdir, filename)
        
        try:
            with open(filepath, 'a', encoding='utf-8') as f:
                json_line = json.dumps(item, ensure_ascii=False)
                f.write(json_line + '\n')
        except Exception as e:
            logger.error(f"❌ JSONL追加失败: {str(e)}")

字段映射表

python 复制代码
# 最终语料的字段定义
CORPUS_SCHEMA = {
    "id": {
        "type": "string",
        "description": "唯一标识符",
        "example": "jd_q123456_a0"
    },
    "question": {
        "type": "string",
        "description": "用户问题(已清洗)",
        "example": "这款手机支持5G吗"
    },
    "answer": {
        "type": "string",
        "description": "标准答案(已清洗)",
        "example": "支持5G双模,兼容SA/NSA组网"
    },
    "product_category": {
        "type": "string",
        "description": "商品类别",
        "example": "手机通讯/手机"_type": {
        "type": "string",
        "description": "回答来源",
        "example": "official/user/verified_user"
    },
    "quality_score": {
        "type": "float",
        "description": "质量评分(0-1)",
        "example": 0.85
    },
    "create_time": {
        "type": "datetime",
        "description": "创建时间",
        "example": "2024-01-15 14:32": {
        "type": "dict",
        "description": "元数据(有用数、浏览量等)",
        "example": {"useful_count": 128, "view_count": 1523}
    }
}

🚀 运行方式与结果展示

主程序入口

python 复制代码
# main.py
from loguru import logger
from crawler.fetcher import SmartFetcher
from crawler.parser import QAParser
from processor.cleaner import TextCleaner
from processor.quality_filter import QualityFilter
from processor.deduplicator import SimHashDeduplicator
from processor.segmenter import QASentenceSegmenter
from storage.file_storage import FileStorage
from tqdm import tqdm
import time


def main():
    """主流程"""
    
    # 配置日志
    logger.add(
        "logs/crawler_{time}.log",
        rotation="500 MB",
        retention="10 days",
        level="INFO"
    )
    
    logger.info("🚀 开始构建电商问答语料...")
    
    # 初始化组件
    fetcher = SmartFetcher(delay_range=(2, 4))
    parser = QAParser()
    cleaner = TextCleaner()
    quality_filter = QualityFilter()
    deduplicator = SimHashDeduplicator()
    segmenter = QASentenceSegmenter()
    storage = FileStorage()
    
    # 待爬取的商品列表(示例)
    product_urls = [
        "https://item.jd.com/100012345678.html",
        # 更多商品URL...
    ]
    
    all_qa_data = []
    
    # 爬取阶段
    logger.info("📡 阶段1: 数据采集")
    for url in tqdm(product_urls, desc="爬取进度"):
        # 1. 获取问答页HTML
        html = fetcher.fetch(url)
        if not html:
            continue
        
        # 2. 解析问答列表
        qa_list = parser.parse_question_list(html, platform="jd")
        
        # 3. 保存原始数据
        all_qa_data.extend(qa_list)
        
        time.sleep(1)  # 额外延时
    
    # 保存原始数据
    storage.save_json(all_qa_data, "raw_qa_data.json", subdir="raw")
    logger.info(f"✅ 原始数据采集完成,共 {len(all_qa_data)} 条")
    
    # 清洗阶段
    logger.info("🧹 阶段2: 数据清洗")
    cleaned_qa = []
    for qa in tqdm(all_qa_data, desc="清洗进度"):
        # 清洗问题和答案
        clean_question = cleaner.clean(qa.get("question_text", ""))
        clean_answer = cleaner.clean(qa.get("answer_text", ""))
        
        if clean_question and clean_answer:
            qa["question_text"] = clean_question
            qa["answer_text"] = clean_answer
            cleaned_qa.append(qa)
    
    logger.info(f"✅ 清洗完成,保留 {len(cleaned_qa)} 条")
    
    # 质量过滤
    logger.info("🎯 阶段3: 质量过滤")
    filtered_qa = quality_filter.filter_qa_list(cleaned_qa)
    
    # 去重
    logger.info("🔄 阶段4: 数据去重")
    unique_qa = deduplicator.deduplicate_qa_list(filtered_qa)
    
    # 分句
    logger.info("✂️ 阶段5: 问答分句")
    segmented_qa = segmenter.segment_qa_list(unique_qa)
    
    # 保存最终语料
    logger.info("💾 阶段6: 保存语料")
    
    # CSV格式(用于分析)
    storage.save_csv(
        segmented_qa,
        "final_qa_corpus.csv",
        subdir="corpus",
        fieldnames=["question_text", "answer_text", "answerer_type", "useful_count"]
    )
    
    # JSONL格式(用于训练)
    storage.save_jsonl(segmented_qa, "final_qa_corpus.jsonl", subdir="corpus")
    
    # 打印统计信息
    metrics = fetcher.get_metrics()
    logger.info(f"\n📊 最终统计:")
    logger.info(f"  原始问答数: {len(all_qa_data)}")
    logger.info(f"  清洗后: {len(cleaned_qa)}")
    logger.info(f"  质量过滤后: {len(filtered_qa)}")
    logger.info(f"  去重后: {len(unique_qa)}")
    logger.info(f"  分句后: {len(segmented_qa)}")
    logger.info(f"  请求成功率: {metrics['成功率']}")
    logger.info(f"  平均耗时: {metrics['平均耗时']}")
    
    logger.info("🎉 语料构建完成!")


if __name__ == "__main__":
    main()

启动命令

bash 复制代码
# 基础运行
python main.py

# 后台运行(Linux/Mac)
nohup python main.py > output.log 2>&1 &

# 使用虚拟环境
source venv/bin/activate
python main.py

输出示例

控制台输出:

复制代码
🚀 开始构建电商问答语料...
📡 阶段1: 数据采集
爬取进度: 100%|██████████| 50/50 [05:23<00:00,  6.47s/it]
✅ 原始数据采集完成,共 2847 条
🧹 阶段2: 数据清洗
清洗进度: 100%|██████████| 2847/2847 [00:12<00:00, 235.42it/s]
✅ 清洗完成,保留 2631 条
🎯 阶段3: 质量过滤
✅ 质量过滤完成 | 原始: 2631 -> 有效: 2103 | 过滤率: 20.07%
🔄 阶段4: 数据去重
✅ 去重完成 | 原始: 2103 -> 唯一: 187610.79%)
✂️ 阶段5: 问答分句
✅ 分句完成 | 原始: 1876 -> 分句后: 2145
💾 阶段6: 保存语料
💾 CSV保存成功 | 路径: data/corpus/final_qa_corpus.csv | 行数: 2145 | 列数: 4
💾 JSONL保存成功 | 路径: data/corpus/final_qa_corpus.jsonl | 记录数: 2145

📊 最终统计:
  原始问答数: 2847
  清洗后: 2631
  质量过滤后: 2103
  去重后: 1876
  分句后: 2145
  请求成功率: 98.00%
  平均耗时: 3.24s
🎉 语料构建完成!

CSV文件示例(前5行):

question_text answer_text answerer_type useful_count
这款支持双卡双待吗 支持双卡双待,两张nano-SIM卡 official 156
电池容量多大 电池容量4500mAh,支持快充 official 89
屏幕是OLED还是LCD 采用AMOLED屏幕,色彩更鲜艳 verified_user 72
有没有耳机孔 没有3.5mm耳机孔,需要转接头 user 45
防水等级是多少 IP68级防尘防水 official 201

⚠️ 常见问题与排错

问题1:403 Forbidden 错误

现象:

复制代码
❌ HTTP错误 | URL: https://... | 状态码: 403

原因分析:

  • 被识别为爬虫(UA、频率、Cookie)
  • 触发了网站的反爬机制

解决方案:

python 复制代码
# 1. 增加更真实的请求头
headers = {
    "User-Agent": "...",
    "Referer": "https://www.jd.com/",  # 添加来源
    "Accept": "text/html,...",
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Cookie": "your_cookie_here"  # 手动添加Cookie
}

# 2. 降低请求频率
fetcher = SmartFetcher(delay_range=(5, 10))  # 加大延时

# 3. 使用代理IP
proxies = {
    "http": "http://proxy.example.com:8080",
    "https": "https://proxy.example.com:8080"
}
response = session.get(url, proxies=proxies)

问题2:429 Too Many Requests

现象:

复制代码
❌ HTTP错误 | 状态码: 429

解决方案:

python 复制代码
# 实现指数退避重试
import time

def fetch_with_backoff(url, max_retries=5):
    for attempt in range(max_retries):
        response = requests.get(url)
        
        if response.status_code == 429:
            wait_time = 2 ** attempt  # 1s, 2s, 4s, 8s, 16s
            logger.warning(f"⏰ 触发限流,等待 {wait_time}s...")
            time.sleep(wait_time)
            continue
        
        return response
    
    return None

问题3:HTML抓到空壳(动态渲染)

现象:

python 复制代码
# 获取的HTML里没有问答数据
tree.xpath('//div[@class="question-item"]')  # 返回空列表

原因: 页面通过JavaScript动态加载数据

解决方案1:抓取API

python 复制代码
# 打开浏览器开发者工具 -> Network -> XHR
# 找到加载问答数据的API请求

api_url = "https://api.jd.com/qa/list"
params = {
    "skuId": "100012345678",
    "page": 1,
    "pageSize": 20
}

response = requests.get(api_url, params=params)
data = response.json()

解决方案2:使用Selenium/Playwright

python 复制代码
from playwright.sync_api import sync_playwright

def fetch_dynamic_page(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url)
        
        # 等待内容加载
        page.wait_for_selector('div.question-item')
        
        # 获取渲染后的HTML
        html = page.content()
        browser.close()
        
        return html

问题4:解析报错 - XPath选择器变化

现象:

复制代码
AttributeError: 'NoneType' object has no attribute 'text'

原因: 网站改版,HTML结构变化

解决方案:

python 复制代码
# 使用多个备选XPath
def safe_xpath_extract(tree, xpaths):
    """尝试多个XPath,直到成功"""
    for xpath in xpaths:
        try:
            result = tree.xpath(xpath)
            if result:
                return result[0].text.strip()
        except:
            continue
    return ""

# 使用示例
xpaths = [
    '//div[@class="question-item"]/text()',  # 新版
    '//div[@class="qa-item"]/text()',        # 旧版
    '//p[@class="question"]/text()'          # 备选
]

question_text = safe_xpath_extract(tree, xpaths)

问题5:编码乱码

现象:

复制代码
# 显示为乱码
"è¿™æ¬¾ææºæä¹æ ·"  # 实际应该是"这款手机怎么样"

解决方案:

python 复制代码
# 方法1:自动检测编码
import chardet

def decode_text(raw_bytes):
    detected = chardet.detect(raw_bytes)
    encoding = detected['encoding']
    return raw_bytes.decode(encoding)

# 方法2:强制使用正确编码
response.encoding = 'utf-8'  # 或 'gbk', 'gb2312'

# 方法3:使用requests的apparent_encoding
if response.encoding == 'ISO-8859-1':
    response.encoding = response.apparent_encoding

🚀 进阶优化(可选但加分)

1. 异步并发采集

python 复制代码
# crawler/async_fetcher.py
import asyncio
import httpx
from typing import List


class AsyncFetcher:
    """异步HTTP客户端(性能提升10倍+)"""
    
    def __init__(self, concurrency: int = 10):
        self.concurrency = concurrency
        self.semaphore = asyncio.Semaphore(concurrency)
    
    async def fetch_one(self, client: httpx.AsyncClient, url: str) -> str:
        """异步获取单个URL"""
        async with self.semaphore:  # 限制并发数
            try:
                response = await client.get(url, timeout=15)
                response.raise_for_status()
                return response.text
            except Exception as e:
                logger.error(f"❌ 异步请求失败: {url} | {str(e)}")
                return ""
    
    async def fetch_all(self, urls: List[str]) -> List[str]:
        """批量异步获取"""
        async with httpx.AsyncClient() as client:
            tasks = [self.fetch_one(client, url) for url in urls]
            results = await asyncio.gather(*tasks)
            return results

# 使用示例
async def main():
    fetcher = AsyncFetcher(concurrency=20)
    urls = ["url1", "url2", ...]  # 1000个URL
    
    results = await fetcher.fetch_all(urls)
    # 1000个URL,串行需要1小时,异步仅需3分钟

asyncio.run(main())

2. 断点续爬

python 复制代码
# utils/checkpoint.py
import json
import os


class CheckpointManager:
    """断点续爬管理器"""
    
    def __init__(self, checkpoint_file: str = "data/.checkpoint.json"):
        self.checkpoint_file = checkpoint_file
        self.crawled_urls = self.load()
    
    def load(self) -> set:
        """加载已爬取URL"""
        if os.path.exists(self.checkpoint_file):
            with open(self.checkpoint_file, 'r') as f:
                data = json.load(f)
                return set(data.get("crawled_urls", []))
        return set()
    
    def save(self):
        """保存进度"""
        with open(self.checkpoint_file, 'w') as f:
            json.dump({"crawled_urls": list(self.crawled_urls)}, f)
    
    def mark_done(self, url: str):
        """标记URL已完成"""
        self.crawled_urls.add(url)
        if len(self.crawled_urls) % 10 == 0:  # 每10条保存一次
            self.save()
    
    def is_done(self, url: str) -> bool:
        """检查URL是否已爬取"""
        return url in self.crawled_urls

# 使用示例
checkpoint = CheckpointManager()

for url in all_urls:
    if checkpoint.is_done(url):
        logger.info(f"⏭️ 跳过已爬取URL: {url}")
        continue
    
    # 爬取逻辑...
    html = fetcher.fetch(url)
    
    # 标记完成
    checkpoint.mark_done(url)

3. 日志与监控

python 复制代码
# utils/metrics.py
from collections import defaultdict
from datetime import datetime
import json


class MetricsCollector:
    """指标收集器"""
    
    def __init__(self):
        self.metrics = defaultdict(int)
        self.errors = []
        self.start_time = datetime.now()
    
    def inc(self, metric_name: str, value: int = 1):
        """增加计数"""
        self.metrics[metric_name] += value
    
    def log_error(self, error_type: str, detail: str):
        """记录错误"""
        self.errors.append({
            "type": error_type,
            "detail": detail,
            "timestamp": datetime.now().isoformat()
        })
    
    def get_report(self) -> dict:
        """生成报告"""
        elapsed = (datetime.now() - self.start_time).total_seconds()
        
        return {
            "运行时长": f"{elapsed:.2f}秒",
            "总请求数": self.metrics["total_requests"],
            "成功数": self.metrics["success"],
            "失败数": self.metrics["fail"],
            "成功率": f"{self.metrics['success'] / self.metrics['total_requests'] * 100:.2f}%",
            "数据条数": self.metrics["data_count"],
            "错误列表": self.errors[-10:]  # 最近10条错误
        }
    
    def save_report(self, filepath: str = "data/metrics_report.json"):
        """保存报告"""
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(self.get_report(), f, ensure_ascii=False, indent=2)

4. 定时任务(cron)

bash 复制代码
# crontab配置
# 每天凌晨2点执行爬虫
0 2 * * * cd /path/to/project && /path/to/venv/bin/python main.py >> logs/cron.log 2>&1
python 复制代码
# 使用APScheduler(Python定时任务)
from apscheduler.schedulers.blocking import BlockingScheduler

def crawl_job():
    """定时爬取任务"""
    logger.info("⏰ 定时任务开始...")
    main()
    logger.info("✅ 定时任务完成")

scheduler = BlockingScheduler()

# 每天2点执行
scheduler.add_job(crawl_job, 'cron', hour=2, minute=0)

# 或者每小时执行一次
# scheduler.add_job(crawl_job, 'interval', hours=1)

logger.info("⏲️ 定时任务已启动")
scheduler.start()

📚 总结与延伸阅读

本文回顾

通过本文,我们完成了一个生产级电商问答语料构建项目,包含:

完整的数据流水线 :采集 → 解析 → 清洗 → 去重 → 分句 → 存储

工程化最佳实践 :日志、监控、容错、断点续爬

高质量语料产出 :去重率10%+,质量过滤20%+

可扩展架构:支持多平台、异步并发、分布式

最终产出: 2000+条高质量问答对,可直接用于:

  • 智能客服训练数据
  • FAQ自动匹配
  • 商品推荐特征工程
  • 用户意图识别

技术亮点

  1. 智能请求策略:重试、退避、UA轮换、Session复用
  2. 鲁棒的解析:多XPath备选、容错处理、字段映射
  3. NLP处理Pipeline:分词、去重(SimHash)、分句、质量评分
  4. 性能优化:lxml解析、异步并发、流式写入

下一步可以做什么?

🔥 短期优化
  • 增加数据源:淘宝、拼多多、小红书问答区
  • 语义去重:使用sentence-transformers计算语义相似度
  • 自动分类:根据问题内容分类(物流、售后、功能...)
🚀 中期进阶
  • 分布式爬虫:Scrapy + Redis实现分布式任务队列
  • 实时更新:监控新问答,定时增量爬取
  • 数据标注:半自动标注问答对的意图、情感
💎 长期演进
  • 知识图谱:构建商品-问题-答案知识图谱
  • 智能客服:基于语料训练检索式/生成式客服模型
  • 数据闭环:线上反馈 → 语料更新 → 模型迭代

推荐学习资源

爬虫进阶:

NLP处理:

工程实践:

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


✅ 免责声明

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

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

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
APIshop2 小时前
淘宝商品评论接口实战解析:从抓包到数据抓取全链路技术指南
java·python
~央千澈~2 小时前
抖音弹幕游戏开发之第14集:添加更多整蛊效果·优雅草云桧·卓伊凡
开发语言·python·游戏
多打代码3 小时前
2026.02.11
开发语言·python
AI周红伟4 小时前
周红伟:智能体实战,通过使用 Flask 的 REST API 在 Python 中部署 PyTorch
后端·python·flask
清水白石0084 小时前
Python 性能分析实战指南:timeit、cProfile、line_profiler 从入门到精通
开发语言·python
ValhallaCoder5 小时前
hot100-二分查找
数据结构·python·算法·二分查找
Suryxin.5 小时前
从0开始复现nano-vllm「llm_engine.py」
人工智能·python·深度学习·ai·vllm
PieroPc5 小时前
用python 写的 Gitee 数据备份工具
开发语言·python·gitee
电饭叔5 小时前
intVar 说明
python