资讯动态

Scrapy新闻抓取实战:从项目搭建到数据存储的完整流程

发布时间:2026/10/16 8:52:56 来源:尧图企业网站定制
1. 为什么我至今还在用Scrapy做新闻类抓取新闻站点的结构有个很讨厌的特点列表页翻页规则五花八门详情页正文的DOM结构又经常改版而且很多站点对请求频率极其敏感。我试过用纯requests加BeautifulSoup硬写一个站点写一套逻辑三个站点之后代码就变成了一团意大利面——重试逻辑、去重逻辑、限速逻辑全糊在一起。后来换成Scrapy最大的感受不是它能爬而是它把调度、去重、限速、重试、管道这些脏活全部标准化了你只需要专注写从哪个页面抽哪些字段。这篇内容面向的是刚接触Scrapy、想跑通一个完整新闻抓取流程的人也适合那些用过Scrapy但一直停留在scrapy shell阶段、没真正把数据落过盘的朋友。我会用一个模拟的新闻站点作为目标从项目创建一路讲到数据入库把中间那些文档里一笔带过、但实际会卡住你的细节全部摊开。核心关键词就三个Scrapy爬虫、新闻抓取、数据存储。读完你应该能独立写出一个能跑、能存、能复用的新闻采集项目而不是一个跑一次就报废的脚本。先说清楚一件事下面所有站点地址、字段名、类名都是虚构的示例你替换成自己的目标即可。抓取任何站点前请先看它的robots协议和服务条款控制频率这是底线不是建议。2. 动手之前把Scrapy的骨架先想明白2.1 一次请求在Scrapy里到底走了哪条路很多人上来就写parse方法结果数据存不对、重复抓、被限速根本原因是没搞清楚Scrapy内部的流转顺序。我用一句话概括引擎从调度器拿一个请求交给下载器下载器返回响应引擎再把响应交给爬虫的解析函数解析函数产出item或新的requestitem进管道request回调度器。这个链路里有两个地方是新手最容易忽略的。第一是调度器的去重Scrapy默认用请求的URL指纹做去重也就是说同一个URL不会被请求两次但如果你在URL后面拼了随机的查询参数去重就失效了会重复抓。第二是管道的顺序多个Pipeline是按数字从小到大依次执行的清洗、去重、入库的顺序如果排错会出现脏数据先入库再清洗的尴尬。理解这条链路之后你写代码时脑子里应该有一张图列表页的parse负责产出详情页的Request详情页的parse_detail负责产出Item。分工清晰后面加字段、改规则都不会乱。2.2 项目目录里每个文件该放什么scrapy startproject news_crawler之后会生成一堆文件新手经常把所有逻辑都塞进spiders目录下的那个爬虫文件里。我的习惯是这样分工的items.py只放字段定义一个字段一行别在这里写逻辑。spiders/每个站点一个爬虫文件只负责怎么抽数据。pipelines.py负责清洗、去重、写库所有和存储相关的脏活都在这。settings.py限速、并发、管道启用、UA这些全局配置。middlewares.py只有需要处理动态UA、代理轮换、Cookie时才动它。这个分工的好处是当目标站点改版时你只需要改spiders里对应的那个文件存储逻辑完全不受影响。我见过太多人把数据库连接写在爬虫类里结果换一个站点就要复制一遍连接代码维护成本极高。2.3 创建项目与第一个爬虫的完整命令# 创建项目 scrapy startproject news_crawler cd news_crawler # 生成一个爬虫骨架domain限定抓取范围 scrapy genspider news_spider example-news.com生成之后先别急着写解析用scrapy shell验证你的选择器能不能命中这一步能省掉后面大量的调试时间scrapy shell https://example-news.com/list?page1进入交互环境后用response.css(div.news-item)看能不能拿到列表节点。拿不到就说明选择器写错了或者页面是JS渲染的。这一步是分水岭如果shell里拿不到数据你在爬虫里写再多代码也是白搭。3. 列表页与详情页的字段抽取实战3.1 列表页只做一件事拿到详情页的链接列表页的解析函数我建议写得极简只负责抽取详情页URL然后yield Request。不要在列表页里顺手把标题、时间也抽了因为列表页的字段往往不全而且详情页里一定有更完整的版本重复抽取只会让数据来源混乱。import scrapy class NewsSpiderSpider(scrapy.Spider): name news_spider allowed_domains [example-news.com] start_urls [https://example-news.com/list?page1] def parse(self, response): # 抽取列表页里每条新闻的详情链接 links response.css(div.news-item a.title::attr(href)).getall() for link in links: yield response.follow(link, callbackself.parse_detail) # 翻页找到下一页按钮 next_page response.css(a.next-page::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse)这里用response.follow而不是手动拼scrapy.Request(url)好处是它会自动处理相对路径。新闻站点的链接经常是/article/12345这种相对路径手动拼很容易漏掉域名前缀导致请求失败。response.follow帮你把这件事做了省心。翻页这里有个坑很多站点的下一页按钮在最后一页仍然存在只是链接指向自己或者javascript:;。如果不做判断会陷入死循环。我的做法是加一个页码上限或者在parse里判断当前页码超过阈值就停止。更稳妥的方式是判断next_page是否和当前URL相同相同就不再跟进。3.2 详情页抽取正文是最难搞的部分详情页里标题、作者、发布时间通常都有明确的标签用CSS选择器直接拿就行。真正麻烦的是正文。新闻正文的HTML结构千奇百怪有的是div classcontent里一堆p有的是article标签还有的把正文和广告混在同一个容器里。我的通用策略是先定位到正文容器再抽取容器内所有p标签的文本最后用换行符拼接。这样能过滤掉大部分嵌套的无关标签。def parse_detail(self, response): item NewsItem() item[title] response.css(h1.article-title::text).get(default).strip() item[author] response.css(span.author::text).get(default).strip() item[publish_time] response.css(span.time::text).get(default).strip() item[url] response.url # 正文定位容器后抽取所有段落 paragraphs response.css(div.article-content p::text).getall() item[content] \n.join(p.strip() for p in paragraphs if p.strip()) yield itemget(default)这个写法很重要。如果某个字段在页面上不存在get()会返回None后面做字符串处理时就会报AttributeError。给一个空字符串默认值能让你的爬虫在遇到残缺页面时继续跑下去而不是直接崩掉。这是我在抓了几万条数据之后养成的习惯——永远假设页面是不完整的。3.3 用Item定义字段别用字典裸奔新手喜欢直接yield {title: ..., content: ...}能跑但字段名写错了不会有任何提示而且没法用Item Pipeline做字段级校验。用Item定义字段后拼写错误会直接报错还能配合ItemLoader做统一的清洗。import scrapy class NewsItem(scrapy.Item): title scrapy.Field() author scrapy.Field() publish_time scrapy.Field() content scrapy.Field() url scrapy.Field()字段定义好之后所有爬虫产出的数据格式就统一了后面写存储管道时不用再考虑这个爬虫的字段叫title那个叫headline这种破事。4. 数据清洗与落库Pipeline才是重头戏4.1 为什么清洗必须放在Pipeline而不是爬虫里把清洗逻辑写在爬虫的parse_detail里短期看没问题但一旦你有多个爬虫每个都要复制一遍清洗代码。更糟的是当你想调整清洗规则比如时间格式统一要改好几个文件。Pipeline的价值就在于它是所有爬虫的公共出口数据从这里流过时统一处理。我一般会写两个Pipeline一个负责清洗去空白、统一时间格式、截断超长字段一个负责存储写数据库。清洗Pipeline的process_item返回item存储Pipeline接收清洗后的item。4.2 清洗Pipeline处理那些看起来没问题的脏数据新闻数据里最常见的脏数据有三类空白字符全角空格、换行、制表符混在一起、时间格式不统一有的是2024-01-01有的是2024年1月1日、正文里混入的无关文本比如责任编辑XXX。import re class CleanPipeline: def process_item(self, item, spider): # 统一去除首尾空白和全角空格 for field in [title, author, content]: if item.get(field): item[field] re.sub(r[\s\u3000], , item[field]).strip() # 时间格式归一化 if item.get(publish_time): item[publish_time] self.normalize_time(item[publish_time]) # 正文截断防止超长文本撑爆数据库字段 if item.get(content) and len(item[content]) 20000: item[content] item[content][:20000] return item def normalize_time(self, raw): # 把2024年1月1日转成2024-01-01 raw raw.replace(年, -).replace(月, -).replace(日, ) return raw.strip()这里有个细节值得说re.sub(r[\s\u3000], , ...)里的\u3000是全角空格。中文新闻站点里全角空格极其常见用普通的strip()根本去不掉必须显式处理。这个坑我踩过当时数据入库后发现标题前面总有个看不见的字符排查了半天才发现是全角空格。4.3 存储PipelineSQLite起步MySQL进阶存储选型上我的建议是本地测试用SQLite正式跑用MySQL或PostgreSQL。SQLite零配置一个文件就是数据库适合验证流程但它的并发写入能力弱多个爬虫同时写会锁表。import sqlite3 class SQLitePipeline: def open_spider(self, spider): self.conn sqlite3.connect(news.db) self.conn.execute( CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, author TEXT, publish_time TEXT, content TEXT, url TEXT UNIQUE ) ) self.conn.commit() def process_item(self, item, spider): try: self.conn.execute( INSERT OR IGNORE INTO news (title, author, publish_time, content, url) VALUES (?, ?, ?, ?, ?), (item[title], item[author], item[publish_time], item[content], item[url]) ) self.conn.commit() except Exception as e: spider.logger.error(f入库失败: {e}) return item def close_spider(self, spider): self.conn.close()注意url字段加了UNIQUE约束配合INSERT OR IGNORE即使调度器去重失效导致重复请求数据库层面也能兜底。这是双重去重的思路Scrapy调度器去重防重复请求数据库唯一约束防重复入库。两层保险数据干净得多。如果换MySQL把连接换成pymysql或mysqlclientSQL语句基本不用改只是INSERT OR IGNORE要换成INSERT IGNORE。字段类型上content建议用TEXT或LONGTEXT别用VARCHAR新闻正文动辄几千字VARCHAR(255)直接截断。4.4 在settings里启用Pipeline和限速写完Pipeline别忘了在settings.py里启用顺序按数字从小到大ITEM_PIPELINES { news_crawler.pipelines.CleanPipeline: 300, news_crawler.pipelines.SQLitePipeline: 400, } # 限速每个请求间隔1秒并发数控制在4以内 DOWNLOAD_DELAY 1 CONCURRENT_REQUESTS 4 CONCURRENT_REQUESTS_PER_DOMAIN 2 # 自动限速根据服务器响应动态调整 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1 AUTOTHROTTLE_MAX_DELAY 10 # 遵守robots协议 ROBOTSTXT_OBEY True # 默认UA改成常见的浏览器UA USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36DOWNLOAD_DELAY和CONCURRENT_REQUESTS_PER_DOMAIN这两个参数是保命用的。新闻站点对高频请求非常敏感我见过有人把并发开到32结果五分钟就被封IP。宁可慢不要断这是抓取新闻类站点的基本素养。AUTOTHROTTLE开启后Scrapy会根据响应延迟自动调整请求间隔比手动设固定值更聪明。5. 跑起来之后才会遇到的真实问题5.1 数据抓到了但全是空的选择器失效排查最常见的情况是爬虫跑完了日志显示抓了200条但打开数据库一看标题全是空字符串。这几乎可以肯定是选择器失效了。排查顺序是这样的先用scrapy shell打开一个详情页手动执行你的选择器看返回什么。如果返回空列表用浏览器的开发者工具重新确认DOM结构注意class名是否变了。如果shell里能拿到但爬虫里拿不到检查是不是页面做了UA判断——有些站点对爬虫UA返回简化版页面。我遇到过一次特别隐蔽的列表页的class名是news-item但详情页的标题class是article-title我在列表页的解析里误用了详情页的选择器结果当然拿不到。列表页和详情页的选择器一定要分开验证别想当然。5.2 翻页翻到一半停了分页逻辑的边界翻页停止通常有三个原因下一页链接是JS生成的、下一页链接在最后一页指向自己、或者被限速后请求失败但没重试。对于JS生成的下一页Scrapy默认拿不到需要分析XHR请求找到真正的分页接口。很多新闻站点的分页其实是/list?page2这种规律URL你完全可以在代码里用循环生成不依赖页面上的按钮def start_requests(self): for page in range(1, 51): # 抓前50页 yield scrapy.Request(fhttps://example-news.com/list?page{page})这种方式最稳不受页面按钮影响。缺点是页数要手动设上限但对于新闻抓取来说通常你只需要最近的内容设个上限反而合理。5.3 被限速或封禁的信号与应对被限速的信号很明显响应时间突然变长、返回403或429状态码、返回一个验证页面而不是正常内容。一旦出现这些信号立刻做三件事把DOWNLOAD_DELAY调大到3-5秒。把CONCURRENT_REQUESTS_PER_DOMAIN降到1。检查ROBOTSTXT_OBEY是否开启。如果已经被封换IP是下策更稳妥的是降低频率、加长间隔、错峰抓取。我个人的经验是新闻站点在凌晨的响应最宽松如果你不急着要实时数据把抓取任务安排在低峰时段成功率会高很多。5.4 正文里混入广告和推荐阅读怎么过滤新闻详情页的正文容器里经常混着相关阅读广告编辑推荐这些块。通用的过滤思路是先看这些块有没有独立的class如果有在选择器里用:not()排除或者先抽取再按关键词过滤。# 排除带有 ad 或 related class 的段落 paragraphs response.css(div.article-content p:not(.ad):not(.related)::text).getall()如果这些块没有独立class只能靠文本特征过滤比如过滤掉包含责任编辑点击查看更多的段落。这种过滤不完美但能去掉大部分噪音。不要追求100%干净新闻抓取的目标是拿到主体内容残留一点噪音是可以接受的。6. 让这个爬虫能长期跑下去的几个改造6.1 把字段抽取规则外置成配置当你要抓的站点从一个变成五个时把选择器硬编码在爬虫里就是灾难。我的做法是把每个站点的选择器写成一个字典爬虫读取配置来抽取SITE_CONFIG { example-news.com: { list_item: div.news-item a.title::attr(href), next_page: a.next-page::attr(href), title: h1.article-title::text, content: div.article-content p::text, } }这样新增一个站点只需要加一段配置不用改爬虫逻辑。这个改造在站点数量超过三个之后收益非常明显。6.2 增量抓取只抓新的不重复抓旧的新闻是持续更新的每次都全量抓既浪费资源又容易被限速。增量抓取的核心是记录上次抓取的位置。最简单的做法是记录上次抓到的最大发布时间下次只抓比这个时间新的内容。如果站点支持按时间筛选直接在URL里带上时间参数如果不支持就在解析时判断发布时间遇到旧内容就停止翻页。def parse_detail(self, response): item NewsItem() # ... 抽取字段 ... if item[publish_time] and item[publish_time] self.last_crawl_time: return # 跳过旧内容 yield item配合数据库的url唯一约束即使偶尔重复抓了也不会产生重复数据。增量抓取是让爬虫从玩具变成工具的关键一步。6.3 日志与监控出问题能第一时间发现Scrapy默认的日志级别是DEBUG信息量太大。正式跑的时候把日志级别调到INFO只记录关键事件LOG_LEVEL INFO LOG_FILE crawler.log然后在Pipeline里加一个统计记录成功入库多少条、失败多少条。跑完之后看一眼日志如果失败率超过10%说明有问题需要排查。我习惯在close_spider里打印一个汇总这样每次跑完心里有数。6.4 关于合规与频率的最后提醒抓取新闻内容用于个人学习和技术研究是一回事大规模抓取并传播是另一回事。控制频率、遵守robots、不抓取需要登录的内容、不绕过任何访问限制这几条是底线。我见过太多人因为抓得太猛被封IP然后跑来问怎么办其实答案很简单慢一点再慢一点。技术上的优化永远建立在合规的前提下这个顺序不能颠倒。我在实际项目里最深的体会是Scrapy的难点从来不在框架本身而在于对目标页面结构的准确理解和对异常情况的预判。框架帮你处理了调度和并发但选择器写错、字段漏抽、翻页逻辑有漏洞这些只能靠你在scrapy shell里一条条验证。把验证这一步做扎实后面的事情就顺了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑