用Scrapy搭建基础文本爬虫:从零抓取Towards AI技术文章
1. 项目概述为什么一个“基础”网络文本爬虫值得你亲手搭一遍很多人第一次听说“网络爬虫”脑子里浮现的要么是科幻片里飞速滚动代码的黑客屏幕要么是某家公司被曝出偷偷抓取竞品数据的新闻标题。但现实里最常被低估、也最该被认真对待的恰恰是那种看起来最朴素的——只负责把网页上的纯文本内容干净、稳定、可复现地提取出来的小工具。它不涉及登录态模拟不破解反爬逻辑不处理动态渲染甚至不存进数据库。它就干一件事打开一个网页找到你指定的那几段文字原样拎出来存成一个干净的 .txt 或 .md 文件。我做技术分享这十多年见过太多人一上来就想搞“全站自动翻页去重分布式调度实时入库”结果卡在第一个页面连 title 都没抓对。而真正能跑通、能复用、能写进简历里当“实操案例”的往往就是这样一个从零开始、只抓文本的 Scrapy 小项目。它像学骑车时的辅助轮——看似简单但踩稳了才能放开手去跑。关键词里提到的Towards AI其实是个典型场景它是一类以长文深度分析见长的技术媒体文章结构清晰、语义标签规范h1/h2/p/section 分层明确非常适合新手练手。你不需要懂 JavaScript 渲染原理也不需要研究 Cookie 加密规则只要理解 HTML 的树状结构和 CSS 选择器的基本逻辑就能拿到高质量文本。这个项目适合谁第一类是刚学完 Python 基础、想找个“看得见摸得着”成果的新手第二类是内容运营、市场分析或学术研究者需要定期批量下载某类技术文章做语料整理第三类是开发者想快速验证某个网站的 DOM 结构是否稳定、是否适合作为后续自动化流程的数据源。它不承诺“全自动无人值守”但能让你亲手摸清每一个环节从环境初始化、spider 编写、选择器调试到编码处理、文件落地再到如何应对常见的乱码、空值、结构漂移问题。这不是一个“黑盒工具”而是一张你亲手绘制的、通往数据采集世界的入门地图。2. 整体设计思路与方案选型解析为什么是 Scrapy而不是 requests BeautifulSoup很多人会问既然只是抓文本用requests加BeautifulSoup不就完事了三行代码发起请求两行定位元素一行写入文件——看起来更轻量、更直接。这话没错但只说对了一半。Scrapy 的价值从来不在“能不能做”而在于它把“怎么做才稳、才可持续、才易维护”这件事提前十年替你考虑好了。我们来拆解一下这个“基础”项目背后的真实设计逻辑。首先看扩展性瓶颈。用requests写单页没问题但当你需要抓取一个包含 50 篇文章的目录页时就得自己写循环、管理 URL 队列、处理请求间隔、记录失败日志。而 Scrapy 内置的Crawler引擎天然支持并发请求、自动去重、失败重试、请求延迟控制。你只需要在start_urls里丢进去一个列表Scrapy 就会按你设定的DOWNLOAD_DELAY和CONCURRENT_REQUESTS自动调度连线程池都不用你手动建。再看结构化能力。BeautifulSoup是个优秀的解析器但它本身不提供数据管道。你抓到的 title、content、author得自己拼成字典、自己转 JSON、自己处理缺失字段。Scrapy 的Item类和ItemLoader就是为这事生的。它强制你定义数据结构比如ArticleItem(titleField(), contentField(), publish_dateField())再通过CSS或XPath规则把原始 HTML 映射过去。这个过程不是“多此一举”而是把“数据契约”提前固化下来——未来哪怕网站改版你只要调整ItemLoader里的选择器整个数据流依然能跑通不会因为某篇文章少了个span classauthor就让整个程序崩掉。最关键的是工程化底座。Scrapy 项目自带settings.py你可以集中配置USER_AGENT、ROBOTSTXT_OBEY、DEFAULT_REQUEST_HEADERS避免每次发请求都重复写 headers。它还有pipelines.py专门处理清洗逻辑比如把p标签里的\n\n替换成单个换行把连续多个空格压缩成一个把 HTML 实体nbsp;转成普通空格。这些琐碎但高频的操作在pipelines里写一次所有 spider 共享。而用requests方案这些逻辑会散落在各个脚本里改一处漏十处。当然Scrapy 也有代价学习曲线略陡项目结构稍重。但这个“基础文本爬虫”项目恰恰是压低门槛的最佳切口。我们不碰中间件、不写自定义 downloader、不集成 Redis 去重就用最标准的SpiderItemPipeline三层结构。就像学开车先练直行和刹车等你熟悉了油门和离合的配合节奏再上高速也不迟。实测下来一个有 Python 基础的人花半天时间搭好这个骨架比反复调试requests的 session 状态和BeautifulSoup的嵌套.find()更省心。3. 核心细节解析与实操要点从环境初始化到选择器调试的完整链路搭建这个爬虫真正的难点从来不在“写代码”而在于如何让代码在真实网络环境中稳定产出预期结果。很多教程跳过这一步直接贴出最终的parse()方法导致读者照着敲完发现返回空列表然后卡死。下面我把从零开始的每一步关键操作、常见陷阱和绕过技巧掰开揉碎讲清楚。3.1 环境初始化与项目创建别跳过scrapy startproject第一步永远是创建虚拟环境。别图省事用系统 Python也别用 conda除非你团队统一用它。就用最标准的python -m venv scrapy_env然后激活。为什么因为 Scrapy 对依赖版本敏感尤其是Twisted和parsel不同 Python 版本下兼容性差异大。我试过在 Python 3.9 下用 pip install scrapy结果scrapy startproject报ModuleNotFoundError: No module named twisted查了半天才发现是Twisted22.x 不支持 3.9降级到 21.7 才行。而用虚拟环境这个问题一劳永逸。创建项目命令是scrapy startproject towards_ai_scraper。注意命名规范必须是合法的 Python 包名小写字母、下划线、数字不能以数字开头不能带中划线-。生成的目录结构里重点盯住三个文件spiders/目录下的 spider 文件我们叫它towardsai_spider.py、items.py定义数据结构、pipelines.py数据清洗。其他如middlewares.py、extensions.py本次项目完全不用碰。提示scrapy startproject会自动生成scrapy.cfg这是 Scrapy 的部署配置文件。虽然我们本地开发用不到但千万别删。它里面藏着deploy相关的路径配置未来如果要部署到 Scrapyd就靠它识别项目根目录。3.2 Spider 编写start_urls与parse()的黄金组合Spider是整个爬虫的大脑。我们新建towardsai_spider.py继承scrapy.Spider。核心就两个属性name唯一标识运行时用scrapy crawl name调用和start_urls起始 URL 列表。这里有个极易被忽略的细节start_urls必须是列表哪怕只有一个 URL也得写成[https://towardsai.net/p/...]写成字符串会报错。parse()方法是入口函数它接收response对象即服务器返回的 HTML 内容。关键来了不要在parse()里直接写业务逻辑。正确的做法是先用response.css()或response.xpath()定位到目标元素把提取到的原始字符串存进item再yield item。比如 Towards AI 的文章标题通常在h1 classpost-title里那么def parse(self, response): item TowardsAiItem() item[title] response.css(h1.post-title::text).get() item[content] response.css(div.post-content p::text).getall() yield item注意get()和getall()的区别get()返回第一个匹配项字符串或 Nonegetall()返回所有匹配项的列表。对于正文我们用getall()拿到所有p的文本列表后续在 pipeline 里用\n.join()拼接。这样做的好处是如果某篇文章没有pgetall()返回空列表不会报错而如果你用response.css(p).get().strip()遇到空列表就会触发AttributeError。注意::text是 CSS 伪类表示只取元素的文本内容不包括子标签。这是避免抓到a href...链接文字/a里混入 HTML 标签的关键。实测 Towards AI 的正文p里偶尔嵌套em或strong用::text能干净剥离。3.3 Items 定义用Item强制约束数据契约items.py不是可选项是必选项。它定义了你最终想要的数据结构。很多人嫌麻烦直接在parse()里yield {title: ..., content: ...}这在单页测试时没问题但一旦加了分页逻辑、加了多级解析数据结构就会失控。我们定义TowardsAiItemimport scrapy class TowardsAiItem(scrapy.Item): title scrapy.Field() content scrapy.Field() author scrapy.Field() publish_date scrapy.Field() url scrapy.Field()看到没每个字段都是scrapy.Field()不是字符串不是默认值。这是 Scrapy 的约定Field()是一个占位符告诉框架“这里将来会填一个值”它本身不做类型校验但为后续ItemLoader和Pipeline提供了统一的字段名接口。比如你在Pipeline里写if item.get(title):这个title就来自items.py的定义。如果某天网站改版h1变成了h2你只需要改parse()里的选择器item[title]这个键名永远不变下游所有逻辑不受影响。3.4 Pipelines 数据清洗解决乱码、空格、换行三大痛点pipelines.py是数据落地前的最后一道闸门。默认生成的process_item()是空壳我们要在里面塞进真实逻辑。针对文本爬虫三个问题高频出现乱码问题Towards AI 的页面声明是 UTF-8但某些老文章可能混入 Latin-1 字符。response.text默认用response.encoding解码而 Scrapy 有时会误判。解决方案是在settings.py里强制FEED_EXPORT_ENCODING utf-8并在 pipeline 里对item[content]做二次编码检查def process_item(self, item, spider): if isinstance(item[content], list): # 将列表中的每个字符串做清理 cleaned_content [] for para in item[content]: if para: # 去首尾空格合并连续空白符 cleaned_para re.sub(r\s, , para.strip()) cleaned_content.append(cleaned_para) item[content] \n\n.join(cleaned_content) return item空格与换行混乱HTML 里p第一段 \n\n 第二段/p直接::text会得到第一段 \n\n 第二段存成 txt 后阅读体验极差。正则re.sub(r\s, , ...)把所有空白符空格、制表符、换行替换成单个空格再用strip()去首尾最后用\n\n.join()模拟段落分隔。字段缺失兜底item[author]可能为None直接写入文件会报错。在 pipeline 里统一处理item[author] item.get(author, Unknown Author)。这些逻辑如果写在parse()里会污染核心解析逻辑写在 pipeline 里职责单一修改方便且所有 spider 复用。4. 实操过程与核心环节实现从单页调试到批量抓取的全流程记录现在我们把前面所有环节串起来走一遍真实的、可复现的实操流程。我会以 Towards AI 的一篇具体文章为例比如这篇关于 LLM Prompt Engineering 的https://towardsai.net/p/llm-prompt-engineering记录每一步的命令、输出、遇到的问题及解决方法。这不是理想化的“教科书流程”而是带着真实世界毛刺的现场笔记。4.1 单页调试用scrapy shell命令行交互式定位别急着写parse()。先用scrapy shell https://towardsai.net/p/llm-prompt-engineering进入交互式终端。这是 Scrapy 最被低估的神器它能让你像调试前端一样实时查看response对象测试 CSS/XPath 选择器。进入后先看response.status是否为 200。如果不是说明网站有基础反爬比如检测 User-Agent这时立刻去settings.py改USER_AGENT比如设成Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36。接着用response.css(h1::text).get()测试标题。如果返回None用view(response)命令在浏览器里打开原始 HTML手动检查h1的 class 名。你会发现 Towards AI 的标题 class 是post-title所以正确写法是response.css(h1.post-title::text).get()。再试正文response.css(div.post-content p::text).getall()。如果返回空列表别慌用response.css(div.post-content)看这个 div 是否存在。如果存在但p::text拿不到可能是正文用了section或article包裹或者p里嵌套了span。这时换 XPathresponse.xpath(//div[classpost-content]//p//text()).getall()。XPath 的//表示任意层级比 CSS 的更宽容。实测下来Towards AI 的正文结构偶有变化XPath 方案容错率更高。实操心得scrapy shell里所有命令都支持 Tab 补全。输入response.c Tab会提示css,xpath,text,body等方法。多用len(...)查长度...[:3]看前三个结果比盲目print更高效。4.2 编写完整 Spider加入分页与作者信息提取单页验证 OK 后开始写正式towardsai_spider.py。除了标题和正文我们还想抓作者和发布日期。Towards AI 的作者通常在div classpost-meta里的a标签日期在同级time标签里。所以parse()方法扩充为def parse(self, response): item TowardsAiItem() item[url] response.url item[title] response.css(h1.post-title::text).get() item[author] response.css(div.post-meta a::text).get() item[publish_date] response.css(div.post-meta time::attr(datetime)).get() # 正文用 XPath 容错 paragraphs response.xpath(//div[contains(class,post-content)]//p//text()).getall() item[content] [p.strip() for p in paragraphs if p.strip()] yield item注意::attr(datetime)这是获取 HTML 属性值的写法time datetime2023-05-15的datetime值就通过这个拿到。if p.strip()是过滤掉纯空白字符串避免getall()返回一堆\n。4.3 运行与导出从命令行到文件落地的完整命令链写完 spider运行命令是scrapy crawl towardsai -o articles.json。-o参数指定输出文件Scrapy 支持.json,.csv,.jlJSON Lines格式。.json适合小数据量.jl适合大数据流每行一个 JSON可逐行读取不占内存。但实际运行时你会发现articles.json里content字段是一长串列表可读性差。这时用FEED_EXPORT_INDENT 2在settings.py里设置让 JSON 自动缩进。更进一步我们想导出为 Markdown方便阅读。Scrapy 本身不支持.md但可以写一个自定义 exporter。不过对于基础项目更简单的办法是先导出.jl再用 Python 脚本转换。我写了个convert_to_md.pyimport json with open(articles.jl, r, encodingutf-8) as f: for line in f: item json.loads(line) with open(f{item[title][:50].replace(/, _)}.md, w, encodingutf-8) as md_f: md_f.write(f# {item[title]}\n\n) md_f.write(f**Author**: {item.get(author, Unknown)}\n\n) md_f.write(f**Published**: {item.get(publish_date, Unknown)}\n\n) md_f.write(---\n\n) md_f.write(\n\n.join(item.get(content, [])))运行scrapy crawl towardsai -o articles.jl再运行python convert_to_md.py瞬间生成一堆可读性极强的 Markdown 文件。这个“两步走”策略比硬啃 Scrapy 的 exporter 机制更符合新手节奏。4.4 批量抓取从单页到目录页的平滑升级现在我们想抓整个 “Machine Learning” 分类下的所有文章。Towards AI 的分类页 URL 是https://towardsai.net/p/category/machine-learning里面用a href/p/xxx链接到具体文章。这时start_urls就不能写死单个 URL 了得在parse()里解析目录页提取所有文章链接再yield scrapy.Request(url, callbackself.parse_article)。我们新增一个parse_category()方法def start_requests(self): yield scrapy.Request( urlhttps://towardsai.net/p/category/machine-learning, callbackself.parse_category ) def parse_category(self, response): # 提取所有文章链接 article_links response.css(div.post-card a::attr(href)).getall() for link in article_links: # 拼接完整 URL full_url response.urljoin(link) yield scrapy.Request(urlfull_url, callbackself.parse_article) def parse_article(self, response): # 这里放之前写好的 parse 逻辑 item TowardsAiItem() # ... 同上 yield itemresponse.urljoin(link)是关键它把相对路径/p/xxx自动补全成https://towardsai.net/p/xxx避免手拼 URL 出错。scrapy.Request的callback参数指定回调函数这样parse_category()负责找链接parse_article()负责抓内容职责清晰代码易读。5. 常见问题与排查技巧实录那些只有亲手踩过才知道的坑即使是最“基础”的文本爬虫也会在真实运行中冒出各种意料之外的问题。下面是我和团队在过去三年里用 Scrapy 抓取各类技术博客包括 Towards AI、Medium、Dev.to时总结出的高频问题、排查路径和独家解决技巧。这些问题90% 的入门教程都不会提但它们才是决定你项目能否真正落地的关键。5.1 问题速查表症状、原因、解决方案问题现象可能原因解决方案实操备注scrapy crawl报ModuleNotFoundError: No module named twistedPython 版本与 Twisted 不兼容降级 Twistedpip install twisted21.7.0Scrapy 2.6 推荐用 Python 3.8-3.103.11 需要 Twisted 23.8response.css(...).get()总是返回None选择器写错或元素在 JS 渲染后才出现用view(response)在浏览器打开检查真实 HTML 结构换 XPath 试试Towards AI 的部分页面用 React 渲染但文章主体是服务端渲染CSS 选择器足够导出的 JSON 里content字段是空列表[]getall()拿到的是空字符串未过滤在 pipeline 里加if p.strip():过滤不要在parse()里过滤避免None值传入 pipeline生成的 Markdown 文件乱码中文显示为 文件写入时未指定编码open(..., encodingutf-8)必须显式写Windows 系统尤其要注意记事本默认用 GBK 打开 UTF-8 文件会乱码爬取速度极慢或被封 IPDOWNLOAD_DELAY过小或未设置RANDOMIZE_DOWNLOAD_DELAYsettings.py中设DOWNLOAD_DELAY 1.5RANDOMIZE_DOWNLOAD_DELAY True随机延迟 0.5~2.5 秒比固定延迟更像真人浏览5.2 独家避坑技巧来自生产环境的血泪经验技巧一用--nolog和--loglevelINFO精准控制日志输出默认scrapy crawl会打印大量 DEBUG 日志刷屏严重。运行时加--loglevelINFO只显示 INFO 及以上级别如Scraped from 200 ...加--nolog完全关闭日志适合静默批量运行。但调试时--loglevelDEBUG能看到每个请求的 headers 和 cookies是排查 403 错误的利器。技巧二FEED_EXPORT_FIELDS控制导出字段顺序与范围默认导出所有Item字段但你可能只想导出title和content。在settings.py里加FEED_EXPORT_FIELDS [title, content, url]这样生成的 CSV 或 JSON字段顺序固定且不包含author等可能为空的字段避免下游解析报错。技巧三CLOSESPIDER_ITEMCOUNT防止无限爬取写分页逻辑时万一next_page选择器失效可能导致无限请求同一页面。在settings.py加CLOSESPIDER_ITEMCOUNT 50爬到 50 条数据就自动停止避免资源耗尽。这是上线前必加的安全阀。技巧四scrapy check命令验证 spider 合法性在项目根目录运行scrapy check towardsaiScrapy 会静态分析你的 spider 代码检查parse()方法是否存在、yield是否正确、Item字段是否定义等。它不运行代码但能提前发现语法错误比等到scrapy crawl报错再改快得多。技巧五用scrapy list和scrapy view辅助开发scrapy list列出所有可用 spider 名称确认name拼写无误scrapy view https://...直接用默认浏览器打开该 URL 的响应 HTML比view(response)更直观尤其适合检查 CSS 选择器是否匹配。最后再分享一个小技巧这个爬虫项目我建议你永远保留一个test_single.py脚本里面只写三行from scrapy import cmdline cmdline.execute(scrapy crawl towardsai -a urlhttps://towardsai.net/p/xxx.split())用-a参数传入单个 URL快速验证新写的parse()逻辑不用每次都改start_urls。这种“小步快跑、即时反馈”的节奏才是高效开发的核心。