最近总有朋友问我:“零基础学网络爬虫到底怎么入手?网上教程一大堆,不是上来就讲正则,就是直接甩框架,看完更懵。”这个提问我很能理解。爬虫确实是个“看起来门槛低、实际坑不少”的方向,如果只是照着代码抄,遇到超时、乱码、反爬、页面结构变化,立刻被打回原形。我打算用一篇完整的项目笔记,从基础到实战把整个链路串起来,把“零基础学网络爬虫,基础到实战一步到位”这件事讲清楚,让没写过一行爬虫的人也能顺利跑通自己的第一个程序,也让已经跑过示例的人理解每一步背后的原因。
这篇内容适合完全没接触过爬虫的新手,也适合写过几段脚本但总在解析、反爬、数据清洗上卡壳的人。我会用实际操作的视角,把网络请求、HTML解析、动态页面处理、数据存储、异常恢复、合规边界这些环节,拆成一条能让读者跟着动手的路径。全程没有多余的包装,只有我踩过坑之后的经验和可以直接复制的方案。
1. 让概念落地:爬虫到底在解决什么问题
很多人以为爬虫是“用代码偷偷把网站数据搬走”,这个理解对了一半。爬虫本质上是一段自动读取网络资源的程序,它不神秘,也不一定违规,关键看你拿它访问什么数据、用什么频率访问、以及拿数据做什么。学习爬虫的第一步,不是急着写代码,而是先建立正确的需求认知。
1.1 零基础最容易误解的三件事
第一,爬虫不等于“破解”。早期宣传常把爬虫和“突破反爬”“绕过验证”绑在一起,导致新手以为学爬虫必须搞逆向。实际上绝大多数数据采集需求,根本不需要对抗任何防护。公开的开放接口、允许访问的静态页面、定期更新的公开数据集,都足够练手和解决很多实际问题。
第二,爬虫不是“万能下载器”。很多教程喜欢拿短视频、电商、社交平台举例,制造“只要会爬虫,什么都能拿”的错觉。但真实世界里,平台有服务条款、有访问频率限制、有用户隐私保护义务。公开数据和个人信息完全是两码事,把爬虫用在敏感数据的采集上,风险极高,也不该成为学习目标。
第三,爬虫的核心不在“爬”,而在“解析和工程化”。只要目标页面结构不复杂,发请求是很快的事情。真正难的是页面改版后怎么快速调整,数据量大了怎么调度失败重试,拿到一堆乱七糟的HTML后怎么精准提取字段,以及长期运行时怎么避免给目标服务器添麻烦。这更接近数据处理和工程问题,而不是“抓包”问题。
1.2 从基础到实战的学习路径怎么拆
结合我自己带新人的经验,一个稳妥的路径是这样的:先理解HTTP请求与响应,再动手写最简单的抓取脚本,然后集中精力攻克解析,接着做一个带存储和异常处理的完整小项目,最后再去碰动态页面和并发控制。
这个顺序容易被忽略的一点是“先解析后反爬”。很多新手学了requests就去挑战最棘手的目标网站,结果被验证码、登录态、字体反爬按在地上摩擦。正确做法是用公开的、无防护的目标练熟请求和解析,建立信心后,再研究频率控制、代理、浏览器自动化这些进阶主题。基础到实战之间跨越的不是代码量,而是对异常处理和数据质量的敏感度。
还有一点:一定要学会读文档。Requests库的文档、BeautifulSoup的文档、Selenium的文档,都是把原理讲得很清楚的优质材料。遇到问题先查文档、再查错误信息,这个习惯能帮你省下大量时间。
1.3 我建议的环境与工具清单
如果你从零开始,我推荐的组合非常简单:Python 3.9以上版本,一个代码编辑器(我用的是VS Code),以及requests、beautifulsoup4、lxml、pandas这几个库。不需要一开始就上完整的Scrapy分布式体系。
环境安装这里有个容易被Windows用户卡住的细节:直接用pip install beautifulsoup4时,如果后面解析速度慢或者某些依赖于lxml的功能异常,多半是编译器问题。建议用Python官方安装包安装时,勾选“Add Python to PATH”,然后执行:
python -m pip install --upgrade pip python -m pip install requests beautifulsoup4 lxml pandas装完后打开Python交互环境验证一下:
import requests, bs4, lxml, pandas print("环境就绪")如果这里报错,多半是pip指向了另一个Python解释器,或者当前Shell没有重开。判断方法是在命令行执行where python,看看有没有多个Python路径。这种环境问题不算难,但很容易在第一天消磨掉学习热情。
2. 请求与响应:先把网络通信这件事搞懂
爬虫的起点是网络请求。你把一个URL交给程序,程序向服务器要资源,服务器返回HTML、JSON或二进制文件。整个过程看起来简单,但其中有几个关键节点决定了你爬得稳不稳:请求头是否真实、超时是否设置、状态码是否处理、响应内容是否解码正确。
2.1 一个请求的生命周期
以访问一个普通网页为例,浏览器或者程序做的事情包括:DNS解析域名、建立TCP连接、发送HTTP请求、等待服务器响应、读取响应体、按编码解析内容。在Python里,requests库替你封装了大部分底层细节,但你仍需理解什么是对端、什么是响应头、什么是状态码。
服务器返回的响应里,状态码是最直接的信号。常见的状态码含义我整理成了表格,建议新手贴在手边:
| 状态码 | 含义 | 常见原因与处理 |
|---|---|---|
| 200 | 请求成功 | 直接解析响应体即可 |
| 301/302 | 重定向 | 跟随或直接交给requests处理 |
| 403 | 禁止访问 | 大概率是请求头被识别,需要补充UA等 |
| 404 | 页面不存在 | URL错误或资源已被移除 |
| 429 | 请求过多 | 频率太高,需要降速 |
| 500/502/503 | 服务器错误 | 服务端临时异常,稍后重试 |
新手常犯的错误是把200当作“一定抓到了”。实际上,很多网站即使请求失败也会返回200,然后把错误信息放在页面里,比如“系统繁忙”或验证页。所以拿到200后,还要检查响应内容是否符合预期。
2.2 常用的请求方式与关键参数
HTTP请求常见方法包括GET、POST、PUT、DELETE等。爬虫里用得最多的是GET和POST。GET适合请求公开页面和查询接口,参数拼在URL后面;POST适合提交表单或JSON数据,参数放在请求体里。
用requests发送GET请求的方式:
import requests url = "https://example.com/api/list" params = {"page": 1, "size": 20} headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" } resp = requests.get(url, params=params, headers=headers, timeout=10) print(resp.status_code) print(resp.text[:500])这里有一个常见误会:params参数传入后,requests会自动把字典拼接成URL查询字符串,不需要自己手动拼,这样可以避免中文和特殊符号的编码问题。而timeout是必须养成的习惯,不设置超时时间,程序可能因为某个请求卡死在那里,整个采集任务停滞。
2.3 用代码发出第一个请求
我建议第一个练习选一个响应结构简单的公开页面或开放API。这里用一个假设的公开接口做演示,URL字段记得替换成你自己的练习目标:
import requests url = "https://example.com/api/items?page=1" try: resp = requests.get(url, timeout=10) resp.raise_for_status() # 状态码不是2xx时抛出异常 data = resp.json() print(data) except requests.exceptions.Timeout: print("请求超时") except requests.exceptions.HTTPError as e: print(f"HTTP错误: {e}") except Exception as e: print(f"其他错误: {e}")raise_for_status()是新手容易忽略但极其实用的方法。它的作用是当状态码为4xx或5xx时直接抛出异常,让你能在错误发生时进入异常分支,而不是拿一个错误页面继续解析。
2.4 请求头、超时和重试为什么重要
不少网站会检测请求的来源。如果没有设置User-Agent,服务器看到的可能是一串默认伪装信息,很容易被识别为自动化脚本。更合理的做法是把UA设置成常见浏览器版本,同时带上Accept、Accept-Language等字段。requests的sessions对象可以帮你在多次请求间保持统一配置:
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/json;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" })重试逻辑也非常重要。网络环境不可能永远稳定,透明重试虽然简单却极实用。可以结合urllib3的Retry类来实现:
from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter retry_strategy = Retry( total=3, backoff_factor=1, status_forcelist=[500, 502, 503], ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter)这里的backoff_factor=1表示第一次重试前等待1秒,第二次2秒,第三次4秒,指数退避能够避免服务器压力过大。
3. 数据解析:拿到网页之后怎么提取
请求拿到的是HTML或JSON,但我们要的是结构化字段。直接把大字符串保存下来当然可以,可后续检索会非常费劲。解析这一步,就是把网页里的有用信息从一堆标签中剥离出来,整理成列表或表格。
3.1 三种解析方式的选型
我用过的解析方式有四种:正则表达式、BeautifulSoup、lxml的XPath、以及浏览器自动化里的元素定位。它们各有适用场景。
正则适合处理文本模式非常固定的情况,比如提取日期、编号、URL。它的优点是依赖少、速度快,但面对嵌套HTML时写起来容易失控。BeautifulSoup是最适合零基础入门的库,它是Python原生对象模型,你可以用find、find_all、select等方法快速定位。lxml+XPath性能更好,适合需要解析大量页面的场景,但学习曲线相对陡峭。
我的建议是:先用BeautifulSoup把项目跑通,再逐步接触XPath。不要全网复制正则去抠页面数据,一旦HTML结构变化,正则字符串往往头一个崩。
3.2 正则表达式的核心用法
正则不应该成为主战工具,但你仍然需要掌握一些基础语法,尤其是清理文本时很有用。比如从长文本中提取所有链接:
import re html = '<a href="https://example.com/page/1">链接1</a>' links = re.findall(r'href="(https?://[^"]+)"', html) print(links)这段正则的含义是:匹配href="后面的内容,并且只匹配http或https开头的链接,直到碰到引号结束。re.findall返回所有匹配结果。
提取数字也可以用正则:
price_text = "价格:99.50 元" price = re.search(r'(\d+\.\d+)', price_text) if price: print(price.group(1))正则里有几个常见的坑:贪婪匹配默认会匹配尽可能多的内容,要尽量使用非贪婪模式*?或+?;点号不匹配换行符,跨行匹配时要用re.S;转义字符在字符串和正则两层各有含义,调试时建议先用在线工具验证。
3.3 CSS选择器与实际提取
BeautifulSoup的核心API很好记。find返回第一个匹配,find_all返回列表,select支持CSS选择器。举个例子,假设页面结构是:
<div class="item">from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml") items = soup.select("div.item") for item in items: title = item.select_one("h2.title").get_text(strip=True) price = item.select_one("span.price").get_text(strip=True) item_id = item.get("data-id") print(title, price, item_id)这里有几个值得注意的地方:引用lxml作为解析器速度更快;get_text(strip=True)能去掉文本首尾空白;item.get("data-id")是获取标签属性值的方法。BeautifulSoup还有一个强大的功能:可以直接修改节点,不过爬虫阶段用不到。
如果目标节点不存在,select_one会返回None,直接对None调用get_text就会报错。处理办法是先判断:
item_element = item.select_one("h2.title") title = item_element.get_text(strip=True) if item_element else ""3.4 动态页面与浏览器自动化
如果一个页面的数据是通过JavaScript动态加载的,直接用requests拿到的HTML里可能没有数据。这时候有几个处理思路:优先去找页面背后的JSON接口,再考虑使用浏览器自动化。
找接口的方法是打开页面源码或开发者工具,搜索关键字,比如列表页中的标题。如果发现数据来自某个.json或.api路径,直接请求那个接口会更高效。因为JSON结构稳定,不需要解析HTML。
如果实在找不到接口,只能采用浏览器自动化,可以用Selenium或Playwright。Playwright语法更现代,内置自动等待,更适合新手。演示代码如下:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com/list", timeout=30000) page.wait_for_selector("div.item") items = page.query_selector_all("div.item") for item in items: title = item.query_selector("h2.title").inner_text() print(title) browser.close()浏览器自动化是为了模拟用户行为而设计的,但它不是“反反爬”工具。频繁开着浏览器无差别访问页面,依然会给服务器带来压力,而且速度远低于直接请求API。能用接口解决的,不要开浏览器。
4. 实战项目:从零完成一个信息采集任务
前面都是技术点,现在串起来做一个最小可用的采集项目。为了不影响任何真实网站,我用一个模拟的公开目录页来描述。整体流程是一样的,你学会后把URL换成自己有权访问的公开页面即可。
4.1 明确目标与合规边界
采集任务开始前,先回答这几个问题:
- 数据来源是否公开可见?是否需要登录才能访问?
- 目标网站是否有robots.txt文件?里面是否明确禁止采集?
- 你采集的数据是否包含个人身份信息?
- 你计划以怎样的频率访问?
这四个问题决定了整个项目的可行边界。如果你要采集的是某个开放API,通常服务条款里已经写清楚了使用限制。如果数据不是公开的、需要登录才能看到,那就不应该作为爬虫练习目标。这一步没想清楚,后面写多少代码都有风险。
4.2 设计数据模型与存储
假设我们要采集一份公开的书籍目录,每个条目包含书名、作者、价格、详情页链接。存储格式可以先用CSV或JSON。CSV适合后续用Excel打开,JSON适合程序之间传递。我的习惯是先写成JSON,调试稳定后再输出CSV。
数据模型设计代码如下:
book_item = { "title": "", "author": "", "price": 0.0, "link": "" }在提取时保持字段唯一,同一本书不要重复入库。可以用详情页链接作为去重键,也可以用书名和作者联合去重。
4.3 完整流程演示
假设页面结构每个书籍卡片像一个商品卡片,完整采集代码可以这样组织:
import requests from bs4 import BeautifulSoup import pandas as pd session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0" }) all_books = [] for page_num in range(1, 6): url = f"https://example.com/books?page={page_num}" try: resp = session.get(url, timeout=10) resp.raise_for_status() except Exception as e: print(f"第{page_num}页请求失败: {e}") continue soup = BeautifulSoup(resp.text, "lxml") cards = soup.select("div.book-card") if not cards: print(f"第{page_num}页没有提取到数据") break for card in cards: title_tag = card.select_one("h3.book-title") author_tag = card.select_one(".author") price_tag = card.select_one(".price") link_tag = card.select_one("a") if title_tag is None or link_tag is None: continue all_books.append({ "title": title_tag.get_text(strip=True), "author": author_tag.get_text(strip=True) if author_tag else "", "price": price_tag.get_text(strip=True) if price_tag else "", "link": link_tag.get("href") }) time.sleep(1) # 每次请求间隔1秒 df = pd.DataFrame(all_books) df.to_csv("books.csv", index=False, encoding="utf-8-sig") print(f"累计采集 {len(df)} 条记录")这段代码里time.sleep(1)是我特意加的。对于小规模采集来说,每页间隔1秒,不会给对方服务器造成压力,也能大幅降低被限流的概率。encoding="utf-8-sig"可以避免CSV在Excel中打开出现中文乱码。
4.4 增量采集与异常恢复
如果采集任务只跑一次,直接循环请求完全够用。但实际工作中,数据会不断更新,你需要做增量采集。最简单的方式是记录已经处理过的详情页URL,下次只采集没见过的页面。这里用一个set来做去重:
seen = set() with open("seen_urls.txt", "r", encoding="utf-8") as f: for line in f: seen.add(line.strip()) if link not in seen: process(link) seen.add(link) with open("seen_urls.txt", "a", encoding="utf-8") as f: f.write(link + "\n")异常恢复同样重要。任务跑到一半挂了,不能从头再来。把已完成的结果实时写入文件,而不是等全部抓完再写,这个习惯越早养成越好。上面的代码每次请求一页数据处理一次,中间某一页出错不会影响前面已经保存的数据。
5. 反爬与效率:这不是猫鼠游戏,而是工程习惯
提到反爬,很多新手的第一反应是找V盾、找验证码绕过方案,这个方向很危险,我不推荐也不展开。真正专业的爬虫工程师会把精力放在降低访问压力、提升数据质量和增强任务稳定性上。反爬背后是服务端需要保护资源,而你要做的是“证明自己是受欢迎的用户”,不是“证明自己不可阻挡”。
5.1 常见反爬策略的原理与对策
最常见的反爬手段是判断User-Agent、判断请求频率、判断IP访问量。正常浏览器每次访问会携带大量请求头字段,而且两个相邻请求之间会有自然延迟。如果你的程序固定毫秒间隔疯狂请求,服务器很容易识别异常。
应对这些策略,正确的做法是:把请求头补全、限制单机并发、设置随机间隔、尽量使用官方公开API。举个例子:
import random import time delay = random.uniform(1, 3) # 随机1到3秒 time.sleep(delay)这里的随机sleep并不是为了“骗过”服务器,而是为了让访问行为更接近真实用户,降低对服务器的瞬时冲击。如果目标站点明确禁止爬虫,哪怕限速再低也不要去尝试。
对于IP被限制的情况,市面上有很多代理方案,但我不建议零基础阶段碰。先学会用Session、控制频率、设置重试,这些足够解决大多数场景。代理会增加大量配置复杂度,而且很多免费代理本身不稳定,反而浪费排错时间。
5.2 请求频率控制与并发
采集场景里,并发控制是效率和安全的平衡。用ThreadPoolExecutor可以并发请求,但必须配合信号量或队列来控制最大并发数。一个简单的并发模型:
from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_one(url): resp = session.get(url, timeout=10) return url, resp.status_code with ThreadPoolExecutor(max_workers=4) as executor: future_map = {executor.submit(fetch_one, url): url for url in url_list} for future in as_completed(future_map): url = future_map[future] try: result = future.result() print(result) except Exception as e: print(f"处理失败: {url}, {e}")max_workers=4不是随意定的。新手阶段对单机采集而言,4个并发已经能提供不错的吞吐量,且不会让目标服务器太难堪。如果任务数据量很大,优先考虑目标网站是否有官方API,而不是无脑加并发。
5.3 日志、监控与数据校验
爬虫跑起来后,你不能只在屏幕前盯进度。要写日志,记录每个阶段成功和失败的数量。最简单的做法是用logging模块:
import logging logging.basicConfig( filename="spider.log", level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" )日志里记录请求URL、状态码、解析条数、异常堆栈。任务结束后,通过日志判断是网络问题、解析问题还是数据质量问题。
数据校验也很有必要。采集到1000条记录,如果一半价格字段为空,这个结果就不能用。先统计字段完整性:
empty_price = df["price"].isna().sum() print(f"价格缺失: {empty_price}")对于关键字段缺失的记录,可以重新采集或标记异常,而不是默认接受。爬虫的价值在于数据可用,不是“爬到就算成功”。
5.4 务必要遵守的爬虫伦理与边界
我再强调一遍底线:只采集公开数据、控制访问频率、尊重版权和个人隐私、不故意绕过访问控制机制、不以爬取数据用于商业竞争或骚扰他人。robots.txt是网站表达抓取意愿的常见方式,虽然不是所有网站都维护,但只要对方明确禁止抓取,你就应该尊重。
还要注意,带着登录状态去抓取“只对登录用户开放”的数据,本质上绕过访问控制,风险很高。这类行为不在“爬虫技术”讨论范围内,而是访问权限问题。学习爬虫时选好练习目标,既保护自己,也能学到更干净的技术栈。
6. 常见问题与排查实录
代码能跑通只是开始。真正让人成长的是排错。我把自己和周围朋友在实际爬虫项目里频繁踩到的坑整理成速查表,供你按图索骥。
6.1 请求异常与状态码速查
| 症状 | 原因 | 处置 |
|---|---|---|
| requests抛ConnectionError | 网络不通或域名解析失败 | 检查网络,使用curl手动验证 |
| 抛Timeout | 服务器响应过慢 | 增大timeout,并加入重试 |
| 返回403 | 请求头缺失或被识别 | 补充UA、Referer、Cookie等字段 |
| 返回429 | 请求频率过高 | 降低频率,增加sleep时间 |
| 返回503 | 服务端过载或维护 | 休息后重试,检查是否触发风控 |
| 返回200但内容为空 | 可能是动态页面 | 找接口或改用浏览器自动化 |
6.2 解析结果为空
这是最常见的困惑:明明页面能打开,代码却提取不到数据。排查顺序是:
- 打印响应内容前500个字符,确认拿到的是不是完整网页。
- 检查页面是否返回了登录跳转、验证页或空壳框架。
- 检查选择的标签名、class是否有空格或动态变化。
- 尝试用浏览器开发者工具重新定位元素。
- 如果页面结构是异步加载的,考虑请求背后的接口。
案例分析:A同学在爬某个公开列表页时,用select("div.content")一直拿到空列表。后来打印HTML发现,页面源码里根本没有div.content,真正的内容在一个div#app-data里。所以当你拿不到数据时,第一件事永远是回到HTML本身找答案,而不是反复改选择器。
6.3 编码问题
中文乱码是新手的第一道坎。这时要分清楚两种情况:响应头里的charset和页面里的meta charset。requests的text属性会自动猜测编码,但有时猜得不准。稳妥做法是用resp.content拿到原始字节,再手动解码:
resp.encoding = "utf-8" # 或者 text = resp.content.decode("utf-8", errors="ignore")如果内容里混入少量字符乱码,通常不是解码错误,而是源本身就脏。清洗时需要替换或正则去除无效字符。例如连续空白、声明标签、HTML注释等,都可以用正则清理。
6.4 从零开始遇到这些坑怎么办
- 不要只盯着别人的代码跑,遇错先读异常信息。我见过很多人把异常堆栈直接删掉,等于放弃了最快定位问题的路径。
- 用最小化数据集验证逻辑。先抓1页数据跑通存储,再扩大到全量,避免一次循环跑到一半才发现解析逻辑错了。
- 给每个环节增加输出。请求后打印状态码,解析后打印条数,存储后打印文件路径。肉眼可见的提示比任何调试器都直接。
- 遇到网页改版不要慌。对比新旧HTML差异,通常只是class名变化。设计解析代码时,尽量用稳定的结构属性,比如
>