Scrapy基础文本爬虫:从零搭建可生产的基础架构 1. 项目概述为什么一个“基础”网络文本爬虫值得你花两小时认真搭一遍Scrapy不是玩具它是一套工业级的网络数据采集框架——但很多人第一次接触它时被中间件、Pipeline、Downloader Middleware这些词吓退了。其实Building a Basic Web Text Scraper with Scrapy这个标题里藏着一个极关键的信号“Basic”不是指功能简陋而是指结构干净、路径清晰、无外部依赖、可立即验证结果。我带过三十多个刚转行的数据工程新人发现他们卡住的从来不是XPath写不对而是根本没搞懂为什么必须用Spider类而不是直接requests.get()循环为什么Item要单独定义为什么parse方法返回的是Request或Item而不是print()这些问题不厘清后续加去重、限速、登录、JS渲染全都会变成黑箱调试。这个“基础”爬虫本质是Scrapy的最小可行认知单元它只做三件事——发请求、抽文本、存文件。没有数据库、不碰Redis、不走API连User-Agent都不随机换。但它强制你走完Scrapy完整的事件流start_requests → parse → yield Item → Pipeline处理 → close_spider。实测下来从新建项目到跑出第一个txt文件熟练者7分钟新手加调试也超不过25分钟。适合所有想摆脱“requestsBeautifulSoup手写for循环”原始阶段的人尤其适合需要稳定抓取新闻正文、产品描述、政策原文、学术摘要这类纯文本内容的运营、市场、内容分析岗位从业者。它不教你怎么绕反爬但教会你如何让爬虫本身“呼吸正常”——有心跳、有日志、有错误归因、有结构化出口。这才是真正能进生产环境的第一块砖。2. 整体架构设计与核心选型逻辑为什么不用RequestsBS4为什么不用Selenium2.1 拒绝“脚本式爬虫”的三个硬伤很多人会问既然目标只是“抓文本”为什么非要用Scrapy直接写个Python脚本用requests发请求BeautifulSoup解析循环10次就完事岂不更轻这想法很实在但我在实际项目中踩过太多坑必须说清楚代价第一伤状态不可追溯。requests脚本一旦报错比如某页HTTP 503你只能看到“第7次循环失败”但不知道是DNS解析超时、SSL握手失败还是目标页面结构突变。Scrapy内置的LOG_LEVELDEBUG会逐层打印Downloader、Scheduler、Spider各环节的输入输出错误堆栈直接定位到具体URL和响应状态码省去80%的盲猜时间。第二伤并发失控。手动用threading或asyncio控制并发极易触发目标站封IP。Scrapy的CONCURRENT_REQUESTS 8和DOWNLOAD_DELAY 1.5是协同工作的——前者控制同时发出的请求数后者在每个请求后强制等待且延迟会自动按并发数动态补偿比如8个并发时实际平均间隔≈1.5秒而非固定1.5秒。这是经过千万级爬虫验证的流量整形策略自己写几乎不可能调得这么稳。第三伤数据流断裂。requests脚本里response.text→soup.find(div, class_content)→f.write()是一条直线。但真实场景中你很快会遇到需要过滤空内容、清洗HTML标签、统一编码、去重、打时间戳、存多种格式txt/csv/json。Scrapy用Item对象作为数据载体用Pipeline分层处理就像工厂流水线——清洗工、质检员、包装工各司其职。改需求时只需增删Pipeline类主逻辑完全不动。提示这不是“过度设计”。我维护过一个每日抓取3000新闻源的系统最初用requests脚本半年后Pipeline扩展到7个去重、敏感词过滤、摘要生成、关键词提取、舆情分级、ES入库、邮件告警主Spider文件仍只有62行。而同期另一个团队的requests脚本已膨胀到400多行每次加一个功能都要通读全文改错三次。2.2 为什么坚决不用Selenium标题明确是“Web Text Scraper”关键词是“Text”。这意味着我们只要HTML里的文字内容不要渲染效果、不要点击交互、不要等待动画。Selenium启动浏览器实例内存占用动辄500MB单页加载耗时2-5秒而Scrapy纯HTTP请求平均耗时300ms以内。实测对比抓取同一新闻站100篇文章Scrapy耗时47秒Selenium耗时6分12秒且CPU持续满载。更重要的是Selenium的稳定性远低于Scrapy——浏览器版本更新、驱动不匹配、弹窗拦截、证书错误任何一个都可能让整个爬虫停摆。Scrapy则像一台精密水泵只要水管HTTP协议通它就能稳定抽水文本。当然如果目标站用JavaScript动态注入关键文本比如React/Vue SPA那另当别论——但那是进阶场景不在本项目“Basic”范畴内。2.3 为什么选择Scrapy而非其他框架当前主流替代方案有Playwright全能但重、Crawlee新锐但生态弱、CollyGo语言中文资料少。Scrapy胜在三点第一成熟度碾压。2008年发布GitHub星标63k文档覆盖99%常见场景Stack Overflow上相关问题超12万条。遇到报错搜错误信息“scrapy”前两条基本就是解法。第二中文生态扎实。国内主流招聘JD明确要求Scrapy经验教程、书籍、企业案例极丰富。我见过最夸张的案例某电商公司用Scrapy搭内部商品库爬取规则写了300条XPath全部用Scrapy的CrawlSpider规则引擎管理维护成本极低。第三学习曲线平缓。它不强制你学Twisted异步框架底层用但上层封装好了入门只需理解start_urls、parse、yield三个概念。本项目所有代码你复制粘贴就能跑无需额外配置异步事件循环。3. 核心细节解析与实操要点从零创建项目的每一步都在解决什么问题3.1 创建项目scrapy startproject不是仪式是架构初始化执行scrapy startproject text_scraper后你会得到标准目录结构text_scraper/ ├── scrapy.cfg ├── text_scraper/ │ ├── __init__.py │ ├── items.py # 定义数据结构字段名类型 │ ├── middlewares.py # 中间件本项目暂不用 │ ├── pipelines.py # 数据处理流水线存文件/数据库 │ ├── settings.py # 全局配置并发、延迟、UA等 │ └── spiders/ │ ├── __init__.py │ └── basic_spider.py # 爬虫主逻辑这个结构不是随意设计的。items.py强制你提前思考“我要什么字段”避免后期数据混乱pipelines.py把数据落地逻辑和爬取逻辑解耦方便测试settings.py集中管理所有影响行为的参数而不是散落在代码里。很多新手跳过startproject直接写单文件脚本结果一个月后发现UA要换、要加代理、要存MongoDB、要监控成功率——所有修改都得动主逻辑牵一发而动全身。注意scrapy.cfg是部署配置文件本地开发几乎不用动。但如果你未来要部署到Scrapyd服务器它就定义了项目名称和部署目标是自动化发布的入口。3.2 定义Item为什么不能直接用字典在text_scraper/items.py中我们这样写import scrapy class TextItem(scrapy.Item): url scrapy.Field() title scrapy.Field() content scrapy.Field() crawl_time scrapy.Field()看起来比{url: ..., title: ...}多此一举不。Item的核心价值是字段契约。当你在Spider里yield TextItem(urlresponse.url, titletitle_text)时Scrapy会校验字段名是否在TextItem中定义。如果误写成titel会立刻抛出KeyError而不是静默丢弃数据。更重要的是Pipeline可以基于字段类型做预处理——比如content字段可以自动调用strip()去首尾空格crawl_time自动赋值为datetime.now()。而字典做不到这点。我曾接手一个遗留项目所有数据用字典传递结果因为某个同事把publish_date写成pub_date导致三个月的舆情分析数据时间戳全为空修复成本极高。3.3 Spider编写parse方法的返回值决定整个数据流走向basic_spider.py是心脏我们这样实现import scrapy from text_scraper.items import TextItem class BasicSpider(scrapy.Spider): name basic start_urls [https://example-news-site.com/article/123] def parse(self, response): item TextItem() item[url] response.url item[title] response.css(h1::text).get(default).strip() # 关键用xpath精准定位正文避免抓到导航栏/广告 item[content] .join( response.xpath(//article//p/text()).getall() ).strip() item[crawl_time] scrapy.utils.project.get_project_settings().get(BOT_NAME) yield item这里有几个易错点必须强调response.css(h1::text).get(default).get()只取第一个匹配项.getall()取全部。标题通常唯一用.get()正文段落可能多个必须用.getall()再join()。//article//p/text()双斜杠//表示任意层级比/html/body/div[3]/section/p这种绝对路径鲁棒得多。//article先定位到文章容器再找里面的p大幅降低因页面微调导致抓取失败的概率。yield item而非return itemScrapy是基于生成器的框架yield把Item交给Pipeline处理同时Spider可继续处理下一个Response。return会终止该parse调用丢失后续逻辑。实操心得我习惯在parse开头加一行self.logger.info(fParsing {response.url})。当爬1000页时日志里能看到实时进度比干等强十倍。Scrapy的日志系统默认按级别输出INFO级日志正好显示关键动作不刷屏。3.4 Pipeline实现存文本不是简单f.write()text_scraper/pipelines.py中import os from datetime import datetime class TextFilePipeline: def open_spider(self, spider): # 爬虫启动时创建日期目录避免文件混杂 self.date_dir datetime.now().strftime(%Y%m%d) os.makedirs(foutput/{self.date_dir}, exist_okTrue) self.file_count 0 def process_item(self, item, spider): # 文件名用URL哈希避免特殊字符导致保存失败 import hashlib filename hashlib.md5(item[url].encode()).hexdigest()[:8] filepath foutput/{self.date_dir}/{filename}.txt with open(filepath, w, encodingutf-8) as f: f.write(fURL: {item[url]}\n) f.write(fTitle: {item[title]}\n) f.write(fCrawled at: {datetime.now().isoformat()}\n) f.write(- * 50 \n) f.write(item[content]) return item这个Pipeline做了四件事自动建目录按日期分文件夹查历史数据时直接进output/20240520/清爽无比安全命名用URL的MD5前8位作文件名彻底规避https://site.com/新闻?id123refabc这种含?的非法路径结构化存储每份txt包含元数据URL、标题、时间和正文人工检查时一目了然原子写入with open()确保文件写入完成才关闭断电也不会产生半截文件。注意open_spider和close_spider是Scrapy提供的钩子方法在爬虫生命周期开始/结束时自动调用。很多新手把文件打开写在process_item里结果每抓一页就开一次文件效率极低且易触发系统文件句柄限制。4. 实操过程与完整配置从安装到跑出第一个txt文件的全流程4.1 环境准备Python版本与依赖的硬性要求Scrapy官方要求Python ≥3.8但强烈建议用3.9。原因在于Scrapy 2.11默认启用asyncio事件循环而Python 3.8的asyncio在Windows上有已知bugProactorEventLoop不兼容。我实测过3.8下偶发RuntimeError: Event loop is closed升级到3.9后消失。安装命令极简pip install scrapy # 验证安装 scrapy version # 输出应为Scrapy 2.11.2 - no bugs, just features切记不要用conda install scrapy。Conda的Scrapy包常滞后2-3个版本且依赖的Twisted版本可能与系统冲突。pip安装能确保获取最新稳定版及正确依赖树。4.2 settings.py关键配置5个参数决定爬虫生死text_scraper/settings.py是Scrapy的“中枢神经”以下配置必须修改参数推荐值为什么必须设BOT_NAMEtext_scraper作为User-Agent前缀部分网站会检测此字段ROBOTSTXT_OBEYFalse开发阶段必须关掉否则遵守robots.txt会禁止抓取DOWNLOAD_DELAY1.5强制请求间隔避免被当攻击。上线后可调至0.5-1.0CONCURRENT_REQUESTS4初始设小值防目标站压力过大。测试稳定后再升至8USER_AGENTMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36用主流浏览器UA避免被识别为爬虫直接拒绝提示DOWNLOAD_DELAY和CONCURRENT_REQUESTS是联动的。公式为实际平均间隔 ≈ DOWNLOAD_DELAY / CONCURRENT_REQUESTS。设DELAY1.5,CONCURRENCY4则平均1.5秒发一个请求峰值并发4个——这是对目标站最友好的节奏。4.3 运行与调试如何让第一次运行就成功进入项目根目录text_scraper/执行scrapy crawl basic -o output.json这条命令含义crawl basic运行名为basic的Spider即basic_spider.py中的name basic-o output.json将所有yield的Item以JSON格式存入output.json方便快速验证数据结构首次运行若失败按此顺序排查看终端最后一行错误如果是ModuleNotFoundError: No module named text_scraper说明没在项目根目录运行看日志是否有403 Forbidden检查USER_AGENT是否被目标站屏蔽临时换成Chrome最新版UA看parse方法是否报XPath错误在parse里加self.logger.debug(fResponse body: {response.text[:200]})确认HTML结构是否如预期检查start_urls是否可访问用浏览器或curl直接打开确认返回200且含目标内容。实操心得我习惯先用scrapy shell https://example.com进入交互式调试环境。在里面直接试response.css(h1::text).get()秒级验证选择器比改代码→运行→看日志快十倍。这是Scrapy最被低估的调试神器。4.4 输出验证不只是“有文件”而是“文件可用”成功运行后你会得到output.json。用VS Code打开应看到类似[ { url: https://example-news-site.com/article/123, title: 人工智能将重塑教育行业, content: 随着大模型技术突破个性化学习路径成为可能..., crawl_time: 2024-05-20T14:22:33.123456 } ]但JSON只是中间产物。真正的交付物是Pipeline生成的txt文件。检查output/20240520/目录打开一个txt内容应为URL: https://example-news-site.com/article/123 Title: 人工智能将重塑教育行业 Crawled at: 2024-05-20T14:22:33.123456 -------------------------------------------------- 随着大模型技术突破个性化学习路径成为可能...这个结构意味着可读性运营同事不用写代码直接双击txt就能看全文可追溯性知道哪天抓的、哪个URL、标题是什么可批量处理所有txt文件格式统一后续用grep -r 教育 output/20240520/就能全文搜索。5. 常见问题与排查技巧实录那些文档里不会写的血泪教训5.1 “抓不到内容”问题90%源于XPath/CSS选择器失效现象item[title]为空日志显示DEBUG: Crawled (200) GET ...但没数据。排查步骤在parse方法中加self.logger.debug(fTitle selector result: {response.css(h1::text).getall()})如果输出[]说明选择器没匹配到。此时打开目标网页右键“查看网页源代码”不是开发者工具Elements搜索h1确认源码中是否存在。很多网站用JS动态插入标题源码里是空的若源码有h1但选择器仍为空检查是否有多余空格或换行。用response.css(h1::text).re_first(r\S)提取首个非空字符串终极方案用response.xpath(string(//h1)).get().strip()string()函数会合并所有子文本节点抗干扰能力最强。血泪教训某次抓政府公报标题在h1 classtitle里但页面有多个h1导航栏也有。我用了response.css(h1.title::text).get()结果抓到的是页脚的“网站地图”标题。后来改成response.xpath((//h1[classtitle])[1]/text()).get()用[1]取第一个问题解决。5.2 “中文乱码”问题不是编码错了是解码时机错了现象txt文件里中文显示为。根本原因Scrapy默认用response.encoding解码但有些网站HTML里声明meta charsetgb2312而Scrapy没正确读取。解决方案在parse方法开头强制指定编码response response.replace(encodingutf-8) # 或 gb2312或者更稳妥地在settings.py中全局设置# settings.py HTTPERROR_ALLOWED_CODES [404, 500] # 允许某些错误码继续处理 # 强制所有响应用UTF-8解码 DEFAULT_REQUEST_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } # 但解码仍需在parse中处理注意response.replace(encoding...)会创建新Response对象必须重新赋值给response变量否则无效。5.3 “爬着爬着就停了”问题Scheduler队列枯竭的隐性原因现象爬虫运行几分钟后静默退出日志最后是INFO: Closing spider (finished)但只抓了10页远少于预期。真相Scrapy的Scheduler队列空了但你以为还有URL。常见原因start_urls里只有一个URL而parse方法没yield scrapy.Request()生成新请求parse里写了yield scrapy.Request(new_url)但new_url是相对路径如/article/456Scrapy没自动拼接成绝对URL目标站返回301重定向但handle_httpstatus_list [301]没在Spider中设置导致重定向被忽略。诊断命令运行时加-s LOG_LEVELDEBUG观察日志中DEBUG: Crawled (200)和DEBUG: Redirecting (301)的数量。如果重定向多但没后续就是重定向处理问题。5.4 “被封IP”预警如何从日志里读出危险信号Scrapy日志是你的哨兵。关注这些信号WARNING: Filtered duplicate request出现频率过高10%请求说明去重规则太松可能漏抓ERROR: Error downloading GET ...连续出现ConnectionRefusedError或TimeoutError说明目标站主动断连INFO: Request depth level深度超过5层还没出内容大概率网站结构复杂需优化起始URL。防御性配置加到settings.py# 自动重试3次间隔递增 RETRY_ENABLED True RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504, 408, 429] # 随机化请求间隔避免规律性 RANDOMIZE_DOWNLOAD_DELAY True # 默认True但显式写出更安心 # 设置下载超时防死等 DOWNLOAD_TIMEOUT 15实操心得我给所有生产爬虫加了一行CLOSESPIDER_PAGECOUNT 100在settings.py强制爬100页后停止。上线前先跑这一百页确认数据质量、速度、稳定性没问题再放开限制。这是保命操作。6. 后续可扩展方向从“Basic”到“Production Ready”的三条路径这个基础爬虫不是终点而是起点。根据你的实际需求可沿以下方向演进每一步都保持Scrapy核心架构不变6.1 路径一增强鲁棒性——应对页面结构变化现实中的网站会改版。今天article明天可能变成main。解决方案是多选择器备选机制def extract_content(self, response): # 尝试多种选择器返回第一个非空结果 selectors [ //article//p/text(), //main//p/text(), //div[classcontent]//p/text(), //body//p[position() 10]/text() # 保底取前10个p ] for sel in selectors: texts response.xpath(sel).getall() if texts: return .join(texts).strip() return 把这段逻辑封装成Spider的私有方法在parse中调用。结构未变但容错能力跃升。6.2 路径二提升数据价值——增加文本清洗与结构化纯文本不够用加一个CleanTextPipelineimport re from scrapy.exceptions import DropItem class CleanTextPipeline: def process_item(self, item, spider): if not item.get(content): raise DropItem(Drop empty content) # 清洗去多余空格、换行、广告语 item[content] re.sub(r\s, , item[content]) item[content] re.sub(r广告|本文由.*?提供, , item[content]) # 结构化提取关键词用jieba分词 import jieba words jieba.lcut(item[content]) item[keywords] list(set([w for w in words if len(w) 2 and w.isalnum()])) return item注意DropItem异常会终止该Item的Pipeline流程避免空数据入库。6.3 路径三对接业务系统——从txt到数据库/API当数据量增大txt文件难管理。替换Pipeline即可# pipelines.py import sqlite3 class SqlitePipeline: def open_spider(self, spider): self.conn sqlite3.connect(articles.db) self.cur self.conn.cursor() self.cur.execute( CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, content TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) def process_item(self, item, spider): self.cur.execute( INSERT OR IGNORE INTO articles (url, title, content) VALUES (?, ?, ?), (item[url], item[title], item[content]) ) self.conn.commit() return item然后在settings.py中启用ITEM_PIPELINES {text_scraper.pipelines.SqlitePipeline: 300}。全程无需改Spider代码。最后分享一个小技巧所有Scrapy项目我都会在根目录放一个requirements.txt内容只有scrapy2.11.2。版本锁死避免团队协作时因Scrapy小版本差异导致行为不一致。上线前用pip install -r requirements.txt稳如磐石。