简介:本资源是一份面向Python初学者的网络爬虫入门实践指南,聚焦requests与BeautifulSoup两大核心库的协同使用,解决网页标题(
标签)自动化提取这一典型Web Scraping任务。内容覆盖HTTP请求发送、HTML响应解析、CSS选择器基础应用及异常处理要点,兼顾技术规范性与学习友好性,适合零基础或具备Python语法基础的学习者快速上手爬虫开发。资源为单文件docx文档,共1个文件,大小仅14KB,结构清晰,含完整可运行代码、分步注释、环境安装指令及合规爬取提醒,便于边学边练。目前已有133人学习下载,读者可直接复用示例代码适配目标网站,掌握从请求发起、内容解析到结果提取的全流程闭环,并建立对robots.txt、反爬策略与道德规范的初步认知。
1. 这不是“写个爬虫就完事”的玩具代码:它是一把能切开网页结构的解剖刀,专治新手卡在“请求发出去了但啥也没拿到”的玄学现场
你试过requests.get(url)返回 200 却soup.find_all('h1')为空吗?你改过headers却发现连response.text都像乱码一样飘着<script>标签吗?这不是你代码写错了——是网页早就不按教科书出牌了。这个实例表面只抓<h1>,实则覆盖了真实爬虫落地最关键的三道坎:请求层能否被服务器认作“人”、解析层能否绕过 HTML 的噪声干扰、结构层能否从嵌套混乱中稳准提取目标节点。它不教你 Selenium 或 Scrapy,因为那会掩盖最底层的 HTTP 语义和 DOM 解析逻辑;它用最简组合(requests + BeautifulSoup),逼你直面status_code、encoding、parser选择、select()与find_all()的行为差异这些血泪经验。适合刚写完print("Hello World")、正准备啃第一块网页数据硬骨头的 Python 新手,也适合想带学生做 45 分钟课堂 Demo 的某导师——代码可直接粘贴运行,但每行背后都埋着一个你迟早要踩的坑。
2. 请求不是“发个 URL 就完事”:HTTP 状态、编码、User-Agent 三者缺一不可的握手协议
2.1 为什么 status_code == 200 只是入场券,不是通关文牒?
requests.get(url)返回response.status_code == 200,只说明服务器收到了请求并返回了响应体,不代表返回的是你想要的 HTML 文本。常见陷阱包括:
- 重定向未处理:
example.com自动跳转到www.example.com,但requests默认不跟随重定向(allow_redirects=False),此时status_code可能是301或302,而你却在检查200; - 服务器反爬拦截:部分站点对无
User-Agent的请求直接返回403或空内容,但某些 CDN 会伪装成200并塞入一段 JS 跳转代码,导致response.text看似正常实则无效; - 内容类型错配:
Content-Type: application/json却被当 HTML 解析,BeautifulSoup会静默失败。
提示:永远在解析前加两行防御性检查:
print(f"Status: {response.status_code}") print(f"Content-Type: {response.headers.get('content-type', 'unknown')}")
2.2 编码乱码?别急着.encode().decode(),先看response.apparent_encoding和response.encoding
response.text出现中文乱码(如æ£å¼ç½ç«),90% 源于requests自动推断编码失败。response.encoding是requests根据 HTTP 头或 HTML meta 标签猜测的编码,常不准;response.apparent_encoding则调用chardet库做字节分析,更可靠但需手动赋值。
import requests from bs4 import BeautifulSoup url = 'https://httpbin.org/html' # 测试用稳定 URL response = requests.get(url) # 关键:强制用 apparent_encoding 修正 response.encoding = response.apparent_encoding print("Detected encoding:", response.apparent_encoding) print("First 100 chars:", response.text[:100]) soup = BeautifulSoup(response.text, 'html.parser')参数说明:
response.apparent_encoding返回字符串(如'utf-8'或'gbk'),直接赋给response.encoding即可;- 若仍乱码,可尝试
response.content.decode('utf-8', errors='ignore'),errors='ignore'会跳过非法字节(慎用于生产); - 绝对避免
response.text.encode('latin-1').decode('utf-8')这类“玄学转码”,它掩盖问题而非解决问题。
2.3 User-Agent 不是可选配置,是请求合法性的第一道签名
没有User-Agent的请求,在多数现代网站眼里等同于爬虫机器人。requests默认 UA 是python-requests/2.x.x,极易被拦截。必须显式设置为常见浏览器 UA:
headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } response = requests.get(url, headers=headers, timeout=10)参数说明:
timeout=10强制设超时,避免请求挂起阻塞整个脚本;- UA 字符串可从浏览器开发者工具 Network 标签页复制,不要用网上搜来的过期 UA(如含
Chrome/80),服务器可能已将其列入黑名单; - 若需轮换 UA(防封),可用
fake-useragent库,但入门阶段先用固定 UA 把流程跑通。
3. 解析不是“soup.find_all 就完事”:HTML 结构噪声、嵌套深度、CSS 选择器精度的三重过滤
3.1find_all('h1')为什么经常返回空?HTML 中的“幽灵 h1”陷阱
你以为网页源码里有<h1>首页</h1>,find_all('h1')就该返回结果?错。真实网页中<h1>可能藏在这些地方:
- JS 动态注入:
<h1>标签由 JavaScript 在页面加载后插入,requests拿到的是初始 HTML,不含 JS 渲染结果; - 注释包裹:
<!-- <h1>测试标题</h1> -->,BeautifulSoup默认解析注释为Comment对象,find_all不会匹配; - 命名空间污染:
<h1 xmlns="http://www.w3.org/1999/xhtml">,带命名空间的标签需用soup.find_all(['h1'], namespace={'': 'http://www.w3.org/1999/xhtml'}); - 大小写混用:某些老旧站点用
<H1>,find_all('h1')区分大小写,需加recursive=True并用正则soup.find_all(re.compile(r'^h[1-6]$', re.I))。
验证方法:打印response.text前 500 字符,肉眼搜索<h1,确认标签真实存在且格式规范。
3.2html.parservslxmlvshtml5lib:解析器选型决定你能抓到多少“残缺 HTML”
BeautifulSoup(response.text, 'html.parser')中的 parser 参数不是摆设。不同解析器对破损 HTML 的容忍度天差地别:
| 解析器 | 速度 | 容错性 | 适用场景 | 安装依赖 |
|---|---|---|---|---|
html.parser | 中 | 低 | 标准 HTML,学习用 | Python 内置 |
lxml | 快 | 高 | 生产环境,需pip install lxml | 需系统级依赖(libxml2) |
html5lib | 慢 | 极高 | 严重破损 HTML(如邮件模板) | pip install html5lib |
# 推荐入门用 lxml(容错强+速度快) try: soup = BeautifulSoup(response.text, 'lxml') except ImportError: # 降级到 html.parser soup = BeautifulSoup(response.text, 'html.parser')关键区别:lxml会自动修复<div><h1>标题</h1></div>中缺失的闭合标签,而html.parser可能将<h1>解析为孤立节点导致find_all失效。
3.3 CSS Selectors 比find_all更精准:用soup.select('h1')替代soup.find_all('h1')
find_all('h1')是基础,但soup.select()支持完整 CSS 选择器语法,能解决find_all无法处理的复杂定位:
# 查找 class="title" 的 h1 soup.select('h1.title') # 查找 id="main-title" 的任意标签 soup.select('#main-title') # 查找 article 下第一个 h1 soup.select('article h1:first-of-type') # 查找>import requests from bs4 import BeautifulSoup import logging def fetch_title(url: str, timeout: int = 10) -> list: """ 从指定 URL 提取所有 <h1> 标签文本 Args: url: 目标网页 URL timeout: 请求超时秒数 Returns: list[str]: 所有 h1 文本列表,失败时返回空列表 Raises: requests.RequestException: 网络请求异常 """ headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } try: response = requests.get(url, headers=headers, timeout=timeout) response.raise_for_status() # 抛出 4xx/5xx 异常 # 修复编码 response.encoding = response.apparent_encoding or 'utf-8' # 使用 lxml 解析(若可用) try: soup = BeautifulSoup(response.text, 'lxml') except ImportError: soup = BeautifulSoup(response.text, 'html.parser') # 用 CSS 选择器提取 h1 h1_list = [] for h1 in soup.select('h1'): text = h1.get_text(strip=True) if text: # 过滤空文本 h1_list.append(text) logging.info(f"成功提取 {len(h1_list)} 个 h1 标签") return h1_list except requests.exceptions.Timeout: logging.error(f"请求超时: {url}") return [] except requests.exceptions.ConnectionError: logging.error(f"连接失败: {url}") return [] except requests.exceptions.HTTPError as e: logging.error(f"HTTP 错误 {e.response.status_code}: {url}") return [] except Exception as e: logging.error(f"解析异常 {type(e).__name__}: {url} - {e}") return [] # 使用示例 if __name__ == "__main__": titles = fetch_title('https://httpbin.org/html') print("提取到的标题:", titles)函数设计逻辑:
- 输入校验:
url类型提示 +timeout显式参数; - 异常分类捕获:网络层(
Timeout,ConnectionError)、HTTP 层(HTTPError)、解析层(Exception); - 日志分级:
logging.info记录成功,logging.error记录失败,便于后续排查; - 返回确定性:无论成功失败,均返回
list,调用方无需try/except。
5.2 用logging替代print:让爬虫行为可追溯、可审计
print在脚本中调试尚可,但一旦需要批量抓取多个 URL 或长期运行,就必须用logging。以下配置让日志同时输出到控制台和文件:
import logging # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(), # 输出到控制台 logging.FileHandler('crawler.log', encoding='utf-8') # 输出到文件 ] ) # 在 fetch_title 中已使用 logging.info/error好处:
- 时间戳精确到毫秒,可定位某次失败发生时刻;
crawler.log文件可被grep搜索,如grep "HTTP 错误 403" crawler.log快速统计封禁次数;- 生产环境可对接 ELK,实现日志集中分析。
5.3 批量抓取:用concurrent.futures实现安全并发,避免被封 IP
单线程抓 100 个 URL 要 100 秒?用线程池提速,但必须加限流和随机延迟:
from concurrent.futures import ThreadPoolExecutor, as_completed import time import random def batch_fetch(urls: list, max_workers: int = 3) -> dict: """ 批量抓取 URL 的 h1 标题 Args: urls: URL 列表 max_workers: 最大并发线程数(建议 1-5,避免触发反爬) Returns: dict: {url: [title1, title2, ...]} """ results = {} with ThreadPoolExecutor(max_workers=max_workers) as executor: # 提交所有任务 future_to_url = { executor.submit(fetch_title, url): url for url in urls } # 获取结果 for future in as_completed(future_to_url): url = future_to_url[future] try: titles = future.result() results[url] = titles # 随机延迟 1-3 秒,模拟人工操作 time.sleep(random.uniform(1, 3)) except Exception as e: logging.error(f"批量抓取异常 {url}: {e}") results[url] = [] return results # 使用示例 urls = ['https://httpbin.org/html', 'https://httpbin.org/robots.txt'] results = batch_fetch(urls) for url, titles in results.items(): print(f"{url} -> {titles}")关键参数:
max_workers=3:保守值,多数小站可承受;若目标站明确允许爬虫,可增至5;time.sleep(random.uniform(1, 3)):每次请求后随机等待,打破规律性;as_completed:按完成顺序返回结果,非提交顺序,提升响应感。
6. 验证爬虫是否“真能用”:三步自检清单与一个我坚持十年的硬核习惯
6.1 三步自检清单:不跑通这三项,别急着说“我的爬虫写好了”
很多新手写完代码,看到终端输出几个标题就以为大功告成。但真实场景中,能跑通 ≠ 能用。我给自己定下铁律:任何爬虫上线前,必须通过以下三步验证:
| 步骤 | 操作 | 通过标准 | 不通过意味着 |
|---|---|---|---|
| ① 单 URL 压力测试 | 对同一 URL 连续请求 10 次,间隔 1 秒 | 10 次全部返回相同数量的<h1>,且内容一致 | 服务器做了频率限制或动态渲染,需加time.sleep或更换 UA |
| ② 多 URL 兼容性测试 | 选 5 个不同建站系统(WordPress、Vue SSR、静态 HTML、Wix、Shopify)的页面 | 至少 4 个能正确提取<h1>,失败的需单独分析原因 | 解析器容错不足(应切lxml),或未处理 JS 渲染(需上 Selenium) |
| ③ 网络异常模拟 | 手动断网、或用iptables拦截80/443端口 | 程序不崩溃,logging记录ConnectionError,返回空列表 | 异常未捕获,生产环境会静默失败 |
提示:用
http://httpbin.org/系列测试端点(如/delay/2模拟慢网,/status/404模拟错误)比用真实网站更可控。
6.2 表格:requests + BeautifulSoup爬虫能力边界速查(什么能做,什么不能做)
| 场景 | 能否胜任 | 替代方案 | 我的经验备注 |
|---|---|---|---|
| 静态 HTML 页面(如政府公开目录) | ✅ 完全胜任 | — | 这是它的主战场,lxml解析快且稳 |
| 含 AJAX 加载内容的页面(如微博时间线) | ❌ 无法执行 JS | Selenium / Playwright | requests只拿初始 HTML,AJAX 数据在XHR请求里,需抓包分析接口 |
| 需登录后访问的页面(如个人后台) | ⚠️ 可行但复杂 | requests.Session()+ 手动管理 Cookie | 需分析登录接口、CSRF Token,易失效,不如 Selenium 录制稳定 |
| 大流量采集(日均百万页) | ❌ 效率瓶颈 | Scrapy + 分布式(Redis) | requests是同步阻塞,Scrapy异步非阻塞,吞吐量高 10 倍以上 |
| 需绕过 Cloudflare 等 WAF | ❌ 几乎不可能 | Puppeteer + 真实浏览器指纹 | requests无 JS 执行能力,WAF 会直接拦截无头请求 |
6.3 我坚持十年的硬核习惯:每次写完爬虫,强制用curl -v对比请求头
这是让我躲过 90% “请求发出去但没响应” 类问题的后悔药。requests封装太厚,你永远不知道它暗地里加了哪些头、怎么处理重定向。而curl -v是裸眼可见的真相:
# 用 curl 模拟 requests 的请求 curl -v \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \ -H "Accept: text/html,application/xhtml+xml" \ https://httpbin.org/html对比要点:
curl输出中的> GET /html HTTP/2和< HTTP/2 200确认协议和状态;curl的* Connected to httpbin.org行确认 DNS 和 TCP 连接成功;curl的< content-type: text/html; charset=utf-8与response.headers['content-type']是否一致;- 若
curl能拿到 HTML 而requests拿不到,一定是requests的headers或timeout设置有问题。
从那以后我每次写完requests.get(),都习惯性敲一遍curl -v,哪怕只是 10 秒的事。它不保证 100% 解决问题,但能瞬间排除 70% 的网络层幻觉。希望帮到你。
本文还有配套的精品资源,点击获取