㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📝 摘要(Abstract)](#📝 摘要(Abstract))
- [🎯 背景与需求(Why)](#🎯 背景与需求(Why))
- [⚖️ 合规与注意事项(必读)](#⚖️ 合规与注意事项(必读))
-
- [robots.txt 基本说明](#robots.txt 基本说明)
- 频率控制与反爬对策
- [🛠️ 技术选型与整体流程(What/How)](#🛠️ 技术选型与整体流程(What/How))
-
- [静态 vs 动态 vs API](#静态 vs 动态 vs API)
- 整体流程架构
- [📦 环境准备与依赖安装](#📦 环境准备与依赖安装)
- [🔍 核心实现:请求层(Fetcher)](#🔍 核心实现:请求层(Fetcher))
- [📖 核心实现:解析层(Parser)](#📖 核心实现:解析层(Parser))
- [🧹 核心实现:数据清洗层(Cleaner)](#🧹 核心实现:数据清洗层(Cleaner))
- [🔄 核心实现:去重层(Deduplicator)](#🔄 核心实现:去重层(Deduplicator))
- [✂️ 核心实现:分句层(Segmenter)](#✂️ 核心实现:分句层(Segmenter))
- [💾 数据存储与导出(Storage)](#💾 数据存储与导出(Storage))
- [🚀 运行方式与结果展示](#🚀 运行方式与结果展示)
- [⚠️ 常见问题与排错](#⚠️ 常见问题与排错)
-
- [问题1:403 Forbidden 错误](#问题1:403 Forbidden 错误)
- [问题2:429 Too Many Requests](#问题2:429 Too Many Requests)
- 问题3:HTML抓到空壳(动态渲染)
- [问题4:解析报错 - XPath选择器变化](#问题4:解析报错 - XPath选择器变化)
- 问题5:编码乱码
- [🚀 进阶优化(可选但加分)](#🚀 进阶优化(可选但加分))
-
- [1. 异步并发采集](#1. 异步并发采集)
- [2. 断点续爬](#2. 断点续爬)
- [3. 日志与监控](#3. 日志与监控)
- [4. 定时任务(cron)](#4. 定时任务(cron))
- [📚 总结与延伸阅读](#📚 总结与延伸阅读)
- [🌟 文末](#🌟 文末)
-
- [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
- [✅ 免责声明](#✅ 免责声明)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: 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) │
└─────────────────────────────────────────────────────┘
为什么这样选型?
- requests:成熟稳定,社区支持好,99%的静态页面都能搞定
- lxml:C语言实现,解析速度比BS4快5-10倍
- 异步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自动匹配
- 商品推荐特征工程
- 用户意图识别
技术亮点
- 智能请求策略:重试、退避、UA轮换、Session复用
- 鲁棒的解析:多XPath备选、容错处理、字段映射
- NLP处理Pipeline:分词、去重(SimHash)、分句、质量评分
- 性能优化:lxml解析、异步并发、流式写入
下一步可以做什么?
🔥 短期优化
- 增加数据源:淘宝、拼多多、小红书问答区
- 语义去重:使用sentence-transformers计算语义相似度
- 自动分类:根据问题内容分类(物流、售后、功能...)
🚀 中期进阶
- 分布式爬虫:Scrapy + Redis实现分布式任务队列
- 实时更新:监控新问答,定时增量爬取
- 数据标注:半自动标注问答对的意图、情感
💎 长期演进
- 知识图谱:构建商品-问题-答案知识图谱
- 智能客服:基于语料训练检索式/生成式客服模型
- 数据闭环:线上反馈 → 语料更新 → 模型迭代
推荐学习资源
爬虫进阶:
- Scrapy官方文档
- Playwright自动化测试
- 《Python网络数据采集》(Web Scraping with Python)
NLP处理:
- HanLP中文NLP工具包
- sentence-transformers语义相似度
- 《自然语言处理实战》
工程实践:
- Airflow调度系统
- ELK日志分析栈
- 《数据密集型应用系统设计》
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
