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

Scrapy基础文本爬虫实战:从零搭建可维护的数据采集服务 1. 项目概述为什么一个“基础”网络文本爬虫值得你花两小时认真读完Scrapy不是Python里唯一能爬网页的库requestsBeautifulSoup组合更轻量、上手更快甚至用浏览器开发者工具手动复制粘贴都能解决小需求。但当你真正开始处理几十个网站、几百个页面、每天定时抓取、数据要进数据库、还要应对反爬策略变化时“基础”二字就不再是谦辞而是对系统性设计的精准描述——它意味着可维护、可扩展、可监控、可复用。我第一次用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_enabledTrue和meta{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?skuxxx)调用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要求Twisted22.1.0但某些旧环境里pip会装低版本。验证方法启动爬虫时报错AttributeError: module twisted has no attribute version。解决方案pip uninstall twisted -y pip install twisted22.1.0坑三项目名含下划线导致ImportErrorscrapy 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)粗暴去标签结果把scriptalert(hello)/script里的hello也当正文提取了。Scrapy推荐用Selector的xpath()或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排除scriptstyle text_blocks response.css(article p::text, article h1::text, article h2::text, article blockquote::text).getall() # ✅ 进阶用xpath处理嵌套结构如pstrong重要/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(fhttps://example.com/page/{i})→ 目标网站实际只有50页后50次请求全404浪费资源错误2依赖URL规律next_url response.css(.next-page::attr(href)).get()→ 但按钮HTML是a classnext href/page/2下一页/a选择器失效。正确做法永远从当前页面提取“下一页”链接并验证其存在性def parse(self, response): # 解析当前页内容... yield self.parse_page(response) # 提取下一页链接兼容多种常见写法 next_page response.css(a.next::attr(href), a[relnext]::attr(href), link[relnext]::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, callbackself.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 classdisabled下一页/span而非a标签。因此选择器必须覆盖a和span或改用文本匹配response.css(a::text, span::text).re_first(r下一页|Next)。3.2.3 编码乱码终极解决方案别再靠猜中文乱码90%源于两点1HTTP响应头Content-Type未声明charset2HTMLmeta charsetutf-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, errorsignore) # 但更推荐用Scrapy内置的Selector它已处理编码 # 所以通常只需确保FEED_EXPORT_ENCODINGutf-8验证是否生效在parse函数开头加self.logger.info(fEncoding: {response.encoding}, URL: {response.url})观察日志。若encoding为None说明网站未声明此时AUTODELTECT_ENCODINGTrue会启用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 {bUser-Agent: bScrapy/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(fDrop 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en-US;q0.8,en;q0.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.04.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_ENCODINGutf-8CSVscrapy 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_FILEscrapy.log -s FEED_EXPORT_ENCODINGutf-8 /dev/null 21 导出文件结构示例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/xhtmlxml,application/xml;q0.9,*/*;q0.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()返回空列表但浏览器里能看到内容。排查四步法确认响应内容scrapy shell url后执行print(response.text[:500])看源码里是否有该HTML检查选择器语法response.css(div.content p)返回Selector对象列表.getall()才取文本若写成response.css(div.content p).get()会返回第一个p标签的HTML字符串非文本验证CSS类名网站可能用JS动态添加class如p classcontent-123此时需用属性选择器response.css(p[class*content]::text)处理iframe内容在iframe里Scrapy默认不加载iframe需单独请求其src。5.3 内存泄漏爬着爬着进程被killScrapy默认将所有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.pySCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter REDIS_URL redis://localhost:6379无需改Spider代码这就是Scrapy设计精妙之处——核心逻辑与调度解耦。最后分享一个小技巧每次上线新爬虫前先用scrapy crawl text_spider -s CLOSESPIDER_PAGECOUNT10限制只抓10页验证数据质量、编码、分页逻辑确认无误再全量运行。这招帮我避开了90%的线上事故。