简介:面向希望快速上手 Scrapy 框架的 Python 爬虫学习者,这份资源以 BBS 论坛作为实战目标,演示了从请求发起、页面解析到结构化数据提取与存储的完整流程,并将爬虫逻辑、数据字段定义、处理管道与项目配置分层组织在一个小型工程中,便于初学者理解框架各组件之间的协作关系。压缩包共 14 个文件,其中 6 个 py 源码文件是核心,涵盖蜘蛛程序、数据模型、管道处理与设置模块;6 个 pyc 为对应编译产物,可用于运行表现对照;另有 1 份 docx 说明文档和 1 个 cfg 配置文件,帮助解决环境配置与启动问题。资源包整体仅 18KB,轻量易读,解压后即可查看代码结构,学习负担小。目前已有 5615 人学习下载,适合爬虫入门者边读源码边动手实践,也适合作为课程设计、毕业设计或小型论坛数据采集项目的参考模板。 大概每个和数据打交道的人都经历过这种时刻:需要在几十上百个网页里找同一类信息,手动打开、复制、粘贴,重复操作一下午,到头来还容易抄错行。我第一次认真决定写爬虫,就是因为要整理一批公开的新闻列表和对应的正文链接,页面数量不算多,但手工整理实在太折磨人。后来花了小半个晚上把流程做成了脚本,十分钟跑完所有页面,那种成就感是挺直接的。
爬虫抓取网页数据,本质上就是用程序模拟浏览器去访问网页、拿到HTML或接口返回的数据,再按需求抽取成结构化信息。它不神秘,也不该被神化。它解决的是批量的、重复的、规则明确的网页数据采集问题。这篇文章适合想把手动复制粘贴时间省下来的运营、数据分析、后端开发,以及所有想系统入门爬虫的人。我会从边界意识、环境选型、完整示例、反爬认知到工程化稳定运行,把一条能直接落地的路径讲透。
1. 动手之前,先确定“能爬”和“该爬”的边界
1.1 爬虫只是自动化的浏览器,不是绕过规则的黑魔法
很多人一听“爬虫”,下意识觉得它是某种灰色工具,实际上它的技术内核非常朴素:客户端发一个HTTP请求,服务器返回响应内容,程序再对内容做解析。这个过程和你在浏览器里打开一个网页没有本质区别,区别仅在于浏览器还会渲染CSS、执行JS,而爬虫脚本通常只关心最终的HTML或JSON数据。
既然本质是“自动化的浏览器”,那么评价一次爬取行为是否合适,也完全可以套用现实生活的常识。你进一家实体店,随手翻阅货架上的公开宣传册,这很正常;但如果你蹲在门口把进出的人挨个登记,或者趁店员不注意翻进仓库拍照,那就是另一回事了。爬虫的边界类似,动手之前先问自己三个问题:数据是不是公开可见的?请求频率会不会给服务器造成压力?采集后的用途是否正当?三条都过了,再写代码也完全不迟。
我个人建议:入门阶段只抓“完全公开、无需登录、服务端不排斥程序访问”的页面。这类页面足够你练熟整套流程,也不会给自己埋雷。等能力上来之后,如果需要采集登录后才能看到的数据,优先确认是否有官方API或授权渠道,而不是一上来就想怎么绕过限制。
1.2 Robots协议与访问节奏:先看规则再动手
判定一个站点是否欢迎自动访问,最直接的依据是/robots.txt。在站点域名后拼上这个路径,比如https://example.com/robots.txt,就能看到类似User-agent: *、Disallow: /admin/这样的说明。它在一定程度上代表了站点对自动访问的态度。遵守它不是为了应付谁,而是为后续稳定抓取创造条件——一个明确声明禁止抓取的路径,服务器大概率也有对应的访问控制。
另一个新手最容易忽略的点是访问节奏。服务器最怕的往往不是某个路径被爬了,而是一瞬间涌来大量高频请求,把带宽和数据库拖垮。我自己踩过一次坑:刚开始学爬虫时,写了个循环去抓某个小型公开站点的列表页,忘记加任何间隔,结果跑到第100多个请求时,页面开始返回503。后来我把请求间隔拉长到1秒左右,任务就再没出过问题。
一个可靠的参考值:动态列表页的请求间隔设在0.5到1秒,批量任务放在2到3秒;如果目标站点体量小、响应慢,间隔还要更保守。学会在代码里用time.sleep()控制节奏,这比任何UA伪装都管用。
2. 轻量环境与工具链:Requests加两个解析库就够了
2.1 为什么入门选Requests而不是Scrapy
爬虫的生态其实很丰富,有Scrapy这种重型框架,也有Playwright这类浏览器自动化工具。但对新手来说,我最推荐从requests+lxml起步。原因很简单:Scrapy虽然功能强大,但它的调度器、中间件、管道、Item等概念对初学者是一大堆黑箱,调试链路长,出了问题很难定位到底卡在哪一层。而Requests只有几十行代码就能跑通一次完整的请求,所见即所得,报错也直观,适合建立正确的心智模型。
当你用轻量方案跑通几个项目,再回头看Scrapy,才能理解它到底解决了哪些重复劳动。到那时你会发现,分布式调度、自动重试、限速器其实都是你手写过的逻辑,只是框架帮你标准化了。所以入门阶段的重点不是“用最强的工具”,而是“用最简单的工具把原理吃透”。
执行环境上,一个纯净的Python 3.9以上版本即可,依赖安装就两条命令:
pip install requests lxml pandaspandas不是必装项,但后面做数据清洗会用到,建议一次装好。解析库我通常只用lxml,XPath和CSS选择器都能支持,速度也足够快。
2.2 用浏览器开发者工具找到真正返回数据的请求
写爬虫最耗时的一步往往不是写代码,而是定位“数据到底在哪个请求里”。很多人会复制浏览器地址栏的URL,但实际请求发出后,页面上的内容可能是由好几个接口拼接出来的。正确的做法是打开开发者工具(F12),切到Network标签,刷新页面,然后逐个看请求的Response内容。
具体来看,列出的请求类型里要重点找两种:一种是文档类型的请求,也就是最原始的HTML页面;另一种是Fetch/XHR类型的接口,通常返回JSON或局部HTML。在浏览器里按Ctrl+F搜索你要抓取的关键字,比如新闻标题、商品价格,看哪个请求的响应里包含这个关键字,那个就是目标。
这时候右键复制它的Request URL和需要的请求头,存下来备用。尤其要注意请求头里的User-Agent和Referer,后面模拟请求时大概率要用到。很多新手在这一步就迷路了:看到Network列表里几十个请求,不知道选哪个。记住一个原则——优先找返回内容和你肉眼看到的页面数据一致的那个请求,别管名字叫什么。
3. 跑通第一个完整任务:请求、解析、落盘的全链路
3.1 请求阶段:带超时、带状态检查的GET
假设目标是一个公开的资讯列表页,页面上有新闻标题、链接和发布时间。整个爬虫可以拆成三段:先请求拿HTML,再解析抽字段,最后保存结果。
请求阶段是最容易翻车的环节。网上很多老教程只写一行requests.get(url),然后直接resp.text,这在网络不稳定的环境下会频繁报错。一个更健壮的写法是这样:
import requests url = "https://example.com/news/list" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" } resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() # 状态码非200时主动抛异常 print(resp.text[:500])这里的timeout=10必须加,否则遇到无响应的连接,脚本可能一直挂在那里。raise_for_status()会在状态码为4xx或5xx时直接抛异常,方便你尽早发现异常页面,而不是把一个错误页面当成正常内容去解析。
3.2 解析阶段:XPath比正则更适合处理HTML
拿到HTML之后,很多新手第一反应是用正则去匹配内容。说实话,正规的HTML结构解析尽量不要用正则,因为标签嵌套、属性顺序变化很容易让正则在不知不觉中写出一堆脆弱的匹配规则。我更推荐用XPath,它像一门“专门查HTML的语言”,在lxml里可以快速、稳定地定位节点。
from lxml import etree tree = etree.HTML(resp.text) items = tree.xpath('//div[contains(@class, "news-item")]') for item in items: title_node = item.xpath('.//h2/text()') link_node = item.xpath('.//a/@href') date_node = item.xpath('.//span[@class="date"]/text()') title = title_node[0].strip() if title_node else "" link = link_node[0] if link_node else "" date = date_node[0].strip() if date_node else "" print(title, link, date)这里有几个细节要留意。XPath的text()返回的是一个列表,哪怕只有一个匹配也要用下标取;.//开头的写法表示从当前节点往下查找,不要漏掉点号。调试XPath最方便的方式是直接在浏览器Console里用$x('//div[contains(@class, "news-item")]'),确认能选中节点后再写进脚本,能省下不少来回试错的时间。
3.3 落盘阶段:CSV、SQLite还是JSON
解析得到的数据一定要落盘才算真正“抓取成功”。三种常见存储方式各有适用场景,我按需求判据给出一张对照表:
| 存储方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CSV | 数据量小、后续用Excel或pandas分析 | 直观、通用 | 字段嵌套复杂时不方便 |
| JSON | 数据结构化程度高、需要对接程序 | 层级清晰、可嵌套 | 人眼查看不够友好 |
| SQLite | 数据量大、需要频繁查询和去重 | 支持SQL、单文件易管理 | 需要多学一点SQL语法 |
入门阶段我建议优先用CSV加pandas导出,尤其注意编码参数:
import pandas as pd records = [] # 假设已在循环中取出多个 title, link, date records.append({"title": title, "link": link, "date": date}) df = pd.DataFrame(records) df.to_csv("output.csv", index=False, encoding="utf-8-sig")有人会问为什么用utf-8-sig。直接写UTF-8会在Excel里打开时出现中文乱码,utf-8-sig会在文件头写入BOM标记,Excel和其他常用表格软件都能正确识别。这个细节是我第一次给同事交数据时踩坑踩出来的,当时他打开CSV满屏乱码,我十分尴尬。
4. 请求头、反爬与动态页面:为什么脚本拿不到肉眼可见的数据
4.1 三个最常见的异常响应:403、418与重定向
同样一个页面,浏览器能打开,脚本却频繁遇到403、418,这是新人必问的问题。403表示服务器拒绝请求,通常意味着服务端识别出你不是一个正常浏览器;418则更直白,它是I'm a teapot,很多站点拿它作为反爬拦截的占位状态码,看到它基本可以确认你的请求特征已经被风控盯上了。还有一个容易遇到的是重定向循环,脚本在几个URL之间跳来跳去,最终返回一个200但内容和你想要的完全无关。
我整理了一份解决对应关系的表格:
| 状态码 | 常见原因 | 首选处理方式 |
|---|---|---|
| 403 | User-Agent缺失或请求特征明显 | 补充常用浏览器请求头 |
| 418 | 请求频率过高被风控拦截 | 立刻停止任务,拉长请求间隔 |
| 301/302循环 | 登录态缺失或协议跳转异常 | 检查是否启用了不合适的重定向 |
需要提醒的是,看到418不要去硬刚。很多新手的第一反应是“伪装得更像一点”,但服务端做风控更多看的是行为特征和统计规律。最有效的做法是停一会儿、降低频率、把代码从“暴力抓取”改成“礼貌访问”,基本都能恢复正常。
4.2 把请求头写得像真实用户,但别陷入“完美伪装”误区
模拟真实用户的请求头,通常需要关注几个字段:User-Agent、Accept、Accept-Language、Referer。其中User-Agent最重要,它直接告诉服务器“我是哪种浏览器”,很多站点只做这一项检查。其余字段可以按浏览器的实际请求头原样复制。
但有几句话我必须说:不要追求“完全还原浏览器的所有header指纹”。服务端通常看的是分布规律,而不是某个header字段是否带全。比如你用一个静态UA配合固定间隔做低频抓取,可能比每次随机换UA却以每秒10个请求高频访问更安全。把心力和时间花在控制访问频率、遵守robots协议、处理好数据质量上,性价比要高得多。
另外强调一点:不要伪造过度的浏览器环境。有的教程让你连Sec-Ch-UA、Accept-Encoding也一并带上,如果脚本自身压缩解码没做对,反而会引入更多问题。用最朴素的方式正确表达“我是一个真实用户”就够了,而不是扮演一个“各方面完美但我自己都控制不住的浏览器”。
4.3 动态渲染页面:数据根本不在HTML里
早期网页的数据基本都在HTML源码里,爬虫抓下来直接用XPath解析就行。但现在的页面越来越多地采用前后端分离架构,HTML骨架里几乎没有真实数据,内容是在浏览器里执行JavaScript后异步渲染出来的。这种页面有一个很明显的判断方法:在浏览器里右键查看网页源代码,搜一下你肉眼看到的某个关键字,如果源代码里搜不到,基本可以断定数据是异步加载的。
遇到这种情况,入门阶段最推荐的方式不是立刻上浏览器自动化,而是切到开发者工具的Network-Fetch/XHR面板,找到真正返回数据的那个JSON接口,直接请求这个接口。接口通常返回结构清晰的JSON,解析甚至比HTML还要简单。浏览器自动化工具如Playwright是后续可以学的方向,但引入它意味着更大的开销和更复杂的等待策略,对新手并不友好。
判断接口时也有一个技巧:把Network面板的请求按返回体大小排序,往往那个返回体特别大的,就是承载核心数据的接口。
5. 从能跑到稳定跑:异常重试、限速去重与踩坑修复
5.1 异常捕获与退避重试:别做永动机
写爬虫的初期,脚本跑一半挂掉是家常便饭。要么网络超时,要么服务器返回5xx,要么页面结构变化导致解析空指针。不做重试,数据就会漏;做重试太频繁,又会加重服务端负担。一个比较稳妥的模式是“有限次数退避重试”:每次失败后等待时间递增,超过最大次数才放弃。
import time import requests def fetch_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() return resp except (requests.RequestException, ValueError) as exc: print(f"第 {attempt + 1} 次请求失败: {exc}") if attempt < max_retries - 1: time.sleep(2 * (attempt + 1)) # 2秒、4秒、6秒递增 return None注意这里没有写成无限重试。无限重试最可怕的后果是:对端服务已经恢复不过来了,你的脚本还在每隔1秒打一次,最终拖出更大规模的封禁。实际项目中,重试两次失败就应进入告警流程,让人来处理而不是让机器硬顶。
5.2 限速、URL去重与日志:稳定运行的三个底座
要让爬虫能够无人值守地跑上一整晚,有三件事必须在动手前规划好。
限速是最基本的,也是很多人忽略的。不管你的目标任务量有多大,都不应该一次性把所有请求打出去。用time.sleep(random.uniform(0.5, 1.5))这样的小随机间隔,比固定间隔更贴近真实用户的行为模式,对服务端也更友好。
URL去重解决的是“重复抓取”的问题。实际上,分页列表经常会出现同一个链接出现在多个页面,或者下一页的入口跳回上一页的情况。一个简单的办法是维护一个已抓取URL的集合,请求前先做判断:
already_seen = set() for url in urls: if url in already_seen: continue resp = fetch_with_retry(url, headers) if resp: already_seen.add(url) # 做解析和保存 time.sleep(random.uniform(0.5, 1.5))日志则是排查问题的第一手资料。用Python内置的logging模块,把每次请求的URL、状态码、耗时和异常记录到文件里,一旦任务中断,你能立刻知道它停在哪一步、遇到了什么错误。不要只靠print(),print在终端上一刷屏,历史信息就找不回来了。
5.3 三个反复踩的坑:编码乱码、相对路径与死循环
第一个坑是编码问题。有的网站响应里没有明确charset,requests默认猜测可能出错,导致抓下来的文本乱码。常规解法是先看响应头里的字符集,拿不准时用resp.apparent_encoding让程序自动推断,然后显式设置:
resp.encoding = resp.apparent_encoding第二个坑是翻页时的相对路径。列表页里的下一页链接经常是./news?page=2这类相对地址,如果你只拿到一个相对路径,直接请求就会404。正确的做法是用urllib.parse.urljoin把相对路径和当前页面地址拼接成完整URL,这一步极其容易漏,漏掉的后果就是爬完第一页之后全部分页失效。
第三个坑是翻页终止条件。有些站点的“下一页”按钮在最后一页依然存在,只是指向当前页或空页面,如果不做判断,脚本会在同一页上反复抓取,形成一个死循环。稳妥的做法是在循环体里限制最大翻页数,同时检查新页面提取到的列表项数量:如果下一页的内容和上一页完全一致,就主动break。
在我实际跑过的任务里,这三个坑出现频率极高,解决难度都不大,但每个都足以让一个新手折腾半小时以上。重点是遇到问题先想“是不是结构变了”“是不是缺了拼接”,而不是急着改解析规则。
最后分享一个小习惯:每次写完爬虫,先不要让它跑全量,抽一个最小范围的目标跑一轮,确认字段完整、编码正常、耗时合理,再放开批量运行。这轮验证能帮你拦截掉绝大多数半夜告警。爬虫真正的难点从来不在于发请求,而在于把边界、频率、数据质量三个基础项做扎实。
本文还有配套的精品资源,点击获取