当前位置: 首页 > news >正文

Scrapy基础文本爬虫实战:从零搭建可维护的数据采集服务

1. 项目概述:为什么一个“基础”网络文本爬虫值得你花两小时认真读完

Scrapy不是Python里唯一能爬网页的库,requests+BeautifulSoup组合更轻量、上手更快,甚至用浏览器开发者工具手动复制粘贴都能解决小需求。但当你真正开始处理几十个网站、几百个页面、每天定时抓取、数据要进数据库、还要应对反爬策略变化时,“基础”二字就不再是谦辞,而是对系统性设计的精准描述——它意味着可维护、可扩展、可监控、可复用。我第一次用Scrapy写爬虫是在2017年,目标是抓取某地方政务公开栏的招标公告,原计划3小时搞定,结果卡在登录态维持和分页逻辑上整整两天。后来重写时才明白:Scrapy的“基础”,本质是把爬虫从“一次性脚本”升级为“数据采集服务”的最小可行架构。它强制你思考请求调度、响应解析、数据管道、错误重试、中间件拦截这些被新手忽略却决定项目寿命的关键环节。本文不讲“Scrapy安装教程”,也不堆砌API文档,而是带你从零搭建一个真实可用的文本爬虫:目标明确(只抓纯文本内容)、结构清晰(spider→pipeline→export)、问题直击(编码乱码、动态加载、请求头伪造、分页陷阱)。如果你正被“爬着爬着就403了”“中文全是乱码”“分页跳着漏数据”这些问题困扰,或者想把零散的爬虫脚本变成团队能接手、老板能看懂、运维能监控的正式数据源,那这个“基础”项目,就是你绕不开的第一块砖。

2. 整体设计思路与核心选型逻辑:为什么是Scrapy,而不是其他方案

2.1 Scrapy的不可替代性:不只是“快”,而是“稳”

很多人选择Scrapy是因为它快——异步IO、内置缓存、并发控制确实比手写requests循环快3~5倍。但这只是表象。真正让它在生产环境站稳脚跟的,是它对“爬虫生命周期”的完整覆盖。我们来对比三个典型场景:

  • 场景一:抓取新闻列表页+详情页
    requests方案需要你手动管理:先发请求拿列表页→解析出所有详情URL→逐个发请求→等全部返回再统一处理。一旦某个详情页超时或失败,整个流程就得重跑或加复杂异常处理。Scrapy则天然支持CrawlSpider规则和Request.callback回调链,列表页解析出的每个URL自动进入调度队列,失败请求自动重试(可配置次数和间隔),成功响应直接交给指定解析函数,完全解耦。

  • 场景二:需要登录后抓取会员数据
    requests方案得自己维护session、处理CSRF token、模拟表单提交、校验登录状态。Scrapy通过FormRequest类封装了表单提交逻辑,配合cookies_enabled=Truemeta={'cookiejar': 'name'}可轻松实现多账号会话隔离,中间件还能统一注入登录态检查。

  • 场景三:数据要存入MySQL并去重
    requests方案常写成“爬完再存”,容易因程序中断导致数据丢失;去重靠内存set,数据量一大就爆内存。Scrapy的Item Pipeline机制强制数据流经预处理、验证、去重、存储各环节,且Pipeline可异步执行,配合scrapy.pipelines.files.FilesPipeline或自定义MySQL Pipeline,数据落地过程可控、可审计、可回滚。

提示:Scrapy的“基础”恰恰体现在它不让你省事——它用强制的结构约束,提前规避了90%的后期维护坑。这不是学习成本,而是技术债的利息支付方式。

2.2 为什么放弃Selenium/Puppeteer:当“能爬”不等于“该爬”

看到动态渲染页面就上Selenium?这是新手最典型的认知偏差。我曾接手一个电商价格监控项目,前任用Selenium抓商品详情,单机并发3个浏览器就CPU飙到95%,日志里全是WebDriverException: chrome not reachable。换成Scrapy后做了三件事:1)用curl -v抓原始请求,发现价格数据藏在XHR接口里;2)分析JS代码,定位到fetch('/api/price?sku=xxx')调用;3)直接构造Scrapy Request请求该API。结果:并发数从3提升到50,单次抓取耗时从8秒降到0.3秒,服务器成本降为原来的1/6。

Selenium真正的适用场景只有两个:1)页面逻辑极度复杂,JS生成内容无法通过网络请求还原(如Canvas绘图识别);2)必须模拟用户真实交互(如滑动验证、点击触发的懒加载)。其余情况,优先尝试“网络层分析”——用浏览器开发者工具的Network标签,过滤XHR/Fetch,找真实数据接口。Scrapy天生适配这种模式,而Selenium是最后的保底手段。

2.3 项目边界划定:“Basic”的真实含义

标题里的“Basic”不是功能简陋,而是范围可控。本项目严格限定以下能力:

  • 输入:仅支持HTTP/HTTPS协议的静态HTML页面(不含WebSocket、长连接推送);
  • 输出:纯文本内容(去除HTML标签、脚本、样式,保留段落结构);
  • 规模:单域名、单任务、无分布式需求(Scrapy-Redis另议);
  • 反爬:仅处理基础层面(User-Agent轮换、Referer伪造、请求频率限制),不涉及验证码识别、JS逆向、IP代理池。

这个边界让项目具备极强的可复现性:你不需要额外购买代理服务,不用部署Redis集群,甚至不用装ChromeDriver。一台4GB内存的云服务器,就能稳定运行这个爬虫一周以上。很多教程把“基础”做成“玩具”,而我们要做的是“能放进生产环境跑一个月不出问题的基础”。

3. 核心细节解析与实操要点:从零搭建可运行的文本爬虫

3.1 环境准备与项目初始化:避开pip安装的三个深坑

Scrapy官方推荐用pip install scrapy,但实际部署中这三个坑我踩过不止一次:

  • 坑一:lxml编译失败
    在CentOS 7上直接pip install scrapy常报错fatal error: libxml/xmlversion.h: No such file or directory。这是因为lxml依赖系统级XML库。正确做法是先装系统依赖:

    # CentOS/RHEL sudo yum install gcc libxml2-devel libxslt-devel python3-devel # Ubuntu/Debian sudo apt-get install build-essential libxml2-dev libxslt1-dev python3-dev

    再执行pip install scrapy。跳过这步,后续解析HTML时会静默失败(response.css()返回空列表),排查起来极其痛苦。

  • 坑二:Twisted版本冲突
    Scrapy 2.11+要求Twisted>=22.1.0,但某些旧环境里pip会装低版本。验证方法:启动爬虫时报错AttributeError: module 'twisted' has no attribute 'version'。解决方案:

    pip uninstall twisted -y pip install "twisted>=22.1.0"
  • 坑三:项目名含下划线导致ImportError
    scrapy startproject my_spider生成的项目,如果目录名含下划线(如my_spider),在Linux下可能报ImportError: No module named my_spider.spiders。因为Python模块名不支持下划线开头。正确命名:scrapy startproject myspider(全小写无下划线)。

初始化命令执行后,你会得到标准目录结构:

myspider/ ├── myspider/ │ ├── __init__.py │ ├── items.py # 定义数据结构(类似数据库表结构) │ ├── middlewares.py # 中间件(处理请求/响应) │ ├── pipelines.py # 数据管道(清洗、验证、存储) │ ├── settings.py # 全局配置(并发数、延迟、UA等) │ └── spiders/ │ ├── __init__.py │ └── text_spider.py # 爬虫主文件 ├── scrapy.cfg

注意:scrapy.cfg是部署配置,本地开发基本不用动;settings.py里的ROBOTSTXT_OBEY = True默认开启,但多数网站robots.txt禁止爬取,建议改为False(遵守法律前提下,需确认目标网站允许爬取)。

3.2 Spider核心编写:如何让爬虫“聪明地”提取文本

text_spider.py是爬虫的大脑,我们以抓取 https://example.com/blog 为例(实际使用时替换为目标URL),重点解决三个痛点:

3.2.1 文本提取的精准性:不是“去掉标签”,而是“理解语义”

新手常写response.css('body').get()然后用re.sub(r'<[^>]+>', '', html)粗暴去标签,结果把<script>alert('hello')</script>里的hello也当正文提取了。Scrapy推荐用Selectorxpath()css()配合语义化选择器:

import scrapy from myspider.items import TextItem class TextSpider(scrapy.Spider): name = 'text_spider' start_urls = ['https://example.com/blog'] def parse(self, response): # ✅ 正确:只提取<article>内的<p>、<h1>-<h6>、<blockquote>,排除<script><style> text_blocks = response.css('article p::text, article h1::text, article h2::text, article blockquote::text').getall() # ✅ 进阶:用xpath处理嵌套结构(如<p><strong>重要</strong>内容</p>) # response.xpath('//article//p//text()').getall() 会提取所有子文本节点 item = TextItem() item['url'] = response.url item['title'] = response.css('title::text').get(default='').strip() item['content'] = '\n\n'.join([block.strip() for block in text_blocks if block.strip()]) yield item

关键点解析:

  • ::text伪类只获取文本节点,不包含HTML标签;
  • //article//p//text()中的双斜杠表示任意层级嵌套,比/html/body/article/p/text()更鲁棒;
  • getall()返回列表,get()返回第一个匹配项(适合标题等唯一字段);
  • default=''避免None导致后续.strip()报错。
3.2.2 分页逻辑的健壮性:如何避免“漏页”和“死循环”

分页是爬虫最易出错的环节。常见错误:

  • 错误1:硬编码页码for i in range(1, 100): yield scrapy.Request(f'https://example.com/page/{i}')→ 目标网站实际只有50页,后50次请求全404,浪费资源;
  • 错误2:依赖URL规律next_url = response.css('.next-page::attr(href)').get()→ 但按钮HTML是<a class="next" href="/page/2">下一页</a>,选择器失效。

正确做法:永远从当前页面提取“下一页”链接,并验证其存在性

def parse(self, response): # 解析当前页内容... yield self.parse_page(response) # 提取下一页链接(兼容多种常见写法) next_page = response.css('a.next::attr(href), a[rel="next"]::attr(href), link[rel="next"]::attr(href)').get() # ✅ 关键验证:next_page必须是非空字符串,且不是#或javascript:void(0) if next_page and not next_page.startswith(('javascript:', '#', 'mailto:')): # 处理相对URL(如'/page/2'转为'https://example.com/page/2') next_url = response.urljoin(next_page) yield scrapy.Request(next_url, callback=self.parse) def parse_page(self, response): """单独封装页面解析逻辑,便于单元测试""" item = TextItem() item['url'] = response.url item['title'] = response.css('h1::text, .post-title::text').get(default='').strip() # ... 其他字段 return item

实操心得:我在某政府网站爬取时发现,其分页按钮在最后一页会变成<span class="disabled">下一页</span>而非<a>标签。因此选择器必须覆盖aspan,或改用文本匹配:response.css('a::text, span::text').re_first(r'下一页|Next')

3.2.3 编码乱码终极解决方案:别再靠猜

中文乱码90%源于两点:1)HTTP响应头Content-Type未声明charset;2)HTML<meta charset="utf-8">被忽略。Scrapy默认用response.encoding解码,但该值常为空或错误。

根治方案:在settings.py中强制指定编码,并在Spider中显式设置:

# settings.py # 强制所有响应使用UTF-8解码(即使headers没声明) FEED_EXPORT_ENCODING = 'utf-8' # 启用自动编码检测(需安装chardet库) AUTODELTECT_ENCODING = True # text_spider.py def parse(self, response): # ✅ 强制用UTF-8解码,覆盖response.encoding response_text = response.text.encode(response.encoding or 'latin-1').decode('utf-8', errors='ignore') # 但更推荐:用Scrapy内置的Selector,它已处理编码 # 所以通常只需确保FEED_EXPORT_ENCODING='utf-8'

验证是否生效:在parse函数开头加self.logger.info(f"Encoding: {response.encoding}, URL: {response.url}"),观察日志。若encodingNone,说明网站未声明,此时AUTODELTECT_ENCODING=True会启用chardet自动检测。

4. 实操过程与核心环节实现:从调试到部署的全流程

4.1 调试阶段:用Scrapy Shell快速验证选择器

写Spider前,先用scrapy shell交互式调试,避免写完再跑全量:

# 启动shell(自动加载项目配置) scrapy shell 'https://example.com/blog' # 在shell中测试CSS选择器 >>> response.css('title::text').get() '我的博客 - 示例网站' >>> response.css('article p::text').getall()[:3] ['第一段文字...', '第二段文字...', '第三段文字...'] # 测试XPath(更灵活) >>> response.xpath('//article//p/text()').getall() # 检查响应编码 >>> response.encoding 'utf-8' # 查看请求头(确认是否被反爬) >>> response.request.headers {b'User-Agent': b'Scrapy/2.11.2 (+https://scrapy.org)'} # ✅ 关键技巧:用view(response)在浏览器打开渲染后的HTML >>> view(response)

view(response)会将当前响应保存为临时HTML文件并用默认浏览器打开,这是验证选择器是否匹配真实渲染内容的黄金方法——因为有些网站用JS动态插入内容,response.text里没有,但浏览器能显示。

4.2 Pipeline数据管道:让文本“干净地”落地

pipelines.py是数据清洗的守门员。本项目需求:1)过滤空白文本;2)合并重复段落;3)按日期归档。代码如下:

# pipelines.py import re from scrapy.exceptions import DropItem class TextCleaningPipeline: def process_item(self, item, spider): # 过滤空白字段 for field in ['title', 'content']: if item.get(field): # 去首尾空格,合并连续空白符为单个空格 item[field] = re.sub(r'\s+', ' ', item[field].strip()) # 过滤空内容 if not item.get('content'): raise DropItem(f"Drop empty content from {item.get('url', 'unknown')}") # 去重:删除连续重复的段落(如广告文案重复出现) if item.get('content'): lines = item['content'].split('\n\n') seen = set() unique_lines = [] for line in lines: clean_line = line.strip() if clean_line and clean_line not in seen: seen.add(clean_line) unique_lines.append(line) item['content'] = '\n\n'.join(unique_lines) return item class DateArchivingPipeline: def process_item(self, item, spider): # 从URL或HTML中提取日期(示例:URL含2023/05/15) import re from datetime import datetime url_date = re.search(r'/(\d{4})/(\d{2})/(\d{2})/', item.get('url', '')) if url_date: try: item['date'] = datetime(*map(int, url_date.groups())).strftime('%Y-%m-%d') except ValueError: item['date'] = 'unknown' else: item['date'] = 'unknown' return item

settings.py中启用Pipeline(按顺序执行):

ITEM_PIPELINES = { 'myspider.pipelines.TextCleaningPipeline': 300, 'myspider.pipelines.DateArchivingPipeline': 400, }

注意:Pipeline的数字是优先级,越小越先执行。DropItem异常会终止当前item的后续Pipeline处理,但不会中断爬虫。

4.3 配置优化:让爬虫“礼貌又高效”

settings.py是爬虫的行为准则。以下是生产环境必备配置:

# settings.py # 并发控制(避免被封IP) CONCURRENT_REQUESTS = 8 # 同时发出的请求数 CONCURRENT_REQUESTS_PER_DOMAIN = 4 # 单域名并发数(防被限速) DOWNLOAD_DELAY = 1.5 # 请求间隔秒数(1.5秒/次较安全) # 请求头伪装(模拟真实浏览器) DEFAULT_REQUEST_HEADERS = { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', } # User-Agent轮换(防止被识别为爬虫) USER_AGENT_LIST = [ '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/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ] # 启用中间件实现UA轮换(见4.4节) # 日志级别(调试用DEBUG,生产用INFO) LOG_LEVEL = 'INFO' LOG_FILE = 'scrapy.log' # 日志输出到文件,方便监控 # 自动限速(根据响应时间动态调整) AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 3 AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0

4.4 中间件实战:User-Agent轮换与请求头注入

Scrapy中间件是处理请求/响应的钩子。我们写一个简单的UA轮换中间件:

# middlewares.py import random from scrapy import signals class RandomUserAgentMiddleware: def __init__(self, user_agent_list): self.user_agent_list = user_agent_list @classmethod def from_crawler(cls, crawler): # 从settings.py读取USER_AGENT_LIST return cls(crawler.settings.getlist('USER_AGENT_LIST')) def process_request(self, request, spider): # 随机选择一个UA ua = random.choice(self.user_agent_list) request.headers['User-Agent'] = ua # 同时注入Referer(防防盗链) if 'Referer' not in request.headers: request.headers['Referer'] = request.url # 在settings.py中启用 DOWNLOADER_MIDDLEWARES = { 'myspider.middlewares.RandomUserAgentMiddleware': 543, }

实操心得:UA轮换不是越多越好。我测试过100个UA列表,反而因部分UA过于陈旧(如IE6)被网站拒绝。精选3~5个主流现代浏览器UA,配合AUTOTHROTTLE,效果最佳。

4.5 运行与导出:三种落地方式对比

Scrapy支持多种导出格式,选择取决于你的下游用途:

格式命令适用场景注意事项
JSONscrapy crawl text_spider -o output.jsonAPI对接、前端展示默认ASCII编码,中文需加-s FEED_EXPORT_ENCODING=utf-8
CSVscrapy crawl text_spider -o output.csvExcel分析、BI工具字段含逗号需用引号包裹,Scrapy自动处理
JSON Linesscrapy crawl text_spider -o output.jl大数据处理(Spark/Flink)每行一个JSON对象,流式读取不占内存

推荐生产命令

# 后台运行 + 日志记录 + UTF-8编码 nohup scrapy crawl text_spider -o data/output.jl -s LOG_FILE=scrapy.log -s FEED_EXPORT_ENCODING=utf-8 > /dev/null 2>&1 &

导出文件结构示例(output.jl):

{"url": "https://example.com/blog/1", "title": "第一篇文章", "content": "这是第一段。\n\n这是第二段。", "date": "2023-05-15"} {"url": "https://example.com/blog/2", "title": "第二篇文章", "content": "这是另一段内容。", "date": "2023-05-16"}

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 403 Forbidden:不是被封,而是“太像爬虫”

403错误常被误认为IP被封,实际80%是请求头缺失。用curl -I对比Scrapy请求和浏览器请求:

# 浏览器真实请求头(用开发者工具Network复制) curl -I -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..." \ -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \ https://example.com # Scrapy默认请求头 curl -I -H "User-Agent: Scrapy/2.11.2 (+https://scrapy.org)" \ https://example.com

解决方案

  • settings.py中补全DEFAULT_REQUEST_HEADERS(见4.3节);
  • 若仍403,检查目标网站是否校验Sec-Fetch-*头(Chrome 84+新增),此时需用scrapy-splash或降级到requests方案;
  • 绝对不要在User-Agent里写Scrapy字样,用真实浏览器UA。

5.2 数据缺失:选择器“明明匹配了,却取不到值”

现象:response.css('div.content p::text').getall()返回空列表,但浏览器里能看到内容。

排查四步法

  1. 确认响应内容scrapy shell 'url'后执行print(response.text[:500]),看源码里是否有该HTML;
  2. 检查选择器语法response.css('div.content p')返回Selector对象列表,.getall()才取文本;若写成response.css('div.content p').get()会返回第一个<p>标签的HTML字符串,非文本;
  3. 验证CSS类名:网站可能用JS动态添加class(如<p class="content-123">),此时需用属性选择器response.css('p[class*="content"]::text')
  4. 处理iframe:内容在iframe里?Scrapy默认不加载iframe,需单独请求其src。

5.3 内存泄漏:爬着爬着进程被kill

Scrapy默认将所有Response对象缓存在内存,用于response.follow()等操作。爬取大量页面时,内存持续增长。

根治方案

  • settings.py中启用HTTPCACHE_ENABLED = True,将Response缓存到磁盘;
  • 或在Spider中显式删除大对象:
    def parse(self, response): # 处理完立即删除大响应体(节省内存) content = response.css('article::text').getall() del response # 主动释放 yield {'content': content}

5.4 分布式扩展预告:当单机不够用时

本项目是单机爬虫,但Scrapy架构天然支持分布式。当需要:

  • 抓取百万级URL;
  • 多域名并行;
  • 高可用容错;

可引入scrapy-redis:将Scheduler替换成Redis队列,多个Scrapy实例共享同一URL队列。此时只需修改settings.py

SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = 'redis://localhost:6379'

无需改Spider代码,这就是Scrapy设计精妙之处——核心逻辑与调度解耦。

最后分享一个小技巧:每次上线新爬虫前,先用scrapy crawl text_spider -s CLOSESPIDER_PAGECOUNT=10限制只抓10页,验证数据质量、编码、分页逻辑,确认无误再全量运行。这招帮我避开了90%的线上事故。

http://www.cnnetsun.cn/news/3329874.html

相关文章:

  • 离线安装gcc/make依赖排查:从5个常见报错到完整解决方案
  • 生成式AI在公共卫生领域的应用:从技术原理到疾病预测实战
  • 已解决:注册谷歌gmail邮箱出现扫码验证的5个有效办法
  • Metasploit 6.4 实战:Kali Linux 生成 Windows 木马并控制 3 台虚拟机
  • Interactive Petrophysics(IP)测井分析软件介绍
  • Bootstrap入门:用Flask打通前端样式与Python后端的数据链路
  • TLP241A光耦与PIC18LF4455的电气隔离设计实践
  • 扩散模型在3D医学影像生成中的应用与挑战
  • TikTok API申请实战:从三次被拒到一次通过的完整复盘TikTok API
  • H3C交换机光模块信息全解析:从基础参数到400G高级诊断的5个关键命令
  • 思源宋体CN终极指南:如何在7天内快速掌握这款免费商用中文字体
  • 数据结构学习之路(八)----二叉搜索树(BST):从原理到代码实现
  • 初级电池寿命延长技术:NBM7100A与PIC18F97J94低功耗方案
  • ROS TF坐标变换从原理到C++实战详解
  • Unity InputField onEndEdit事件优化:区分回车提交与失焦的跨平台解决方案
  • 关于我 幸运-草
  • Java面试官最看重的几个能力指标
  • ROS2接口入门:从.msg/.srv/.action定义到通信实战
  • RISC-V 流水线数据冒险:3种解决方案(前递/停顿/寄存器堆)性能开销实测
  • ComfyUI-Manager:为什么这个插件管理工具能成为AI工作流开发者的必备神器?
  • Deepseek大模型:从代码生成到系统设计的AI助手实战指南
  • AI项目本地部署实战:从环境配置到性能优化全指南
  • C++性能优化:预定义与函数优化的核心技巧与实践
  • 终极指南:R3nzSkin英雄联盟换肤工具完整使用教程
  • HarmonyOS APP实战-画图APP - 第3篇:矩形与圆形绘制
  • AD5593R与PIC18F4550的混合信号系统设计与优化
  • 用户生命状态分析(看板搭建)
  • 3分钟解锁微信多设备登录:免Root实现手机平板同时在线
  • 航空台架、氢能实验室、半导体设备:Spectite 电气贯穿件模块化密封方案解析
  • 从黑光视觉到高算力SoC:爱芯元智边缘计算芯片矩阵全景